From 06f3d5e79d0c6ba5a863232e12e75a2bc2c188cf Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 3 Aug 2026 20:32:47 -0700 Subject: [PATCH 01/10] feat(meteora): add DAMM v2 (cp-amm) AMM connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Meteora DAMM v2 integration under /connectors/meteora/amm/*, mirroring the Raydium AMM connector and backed by @meteora-ag/cp-amm-sdk. Endpoints: pool-info, position-info, quote-swap, execute-swap, quote-liquidity, add-liquidity, remove-liquidity, and create-pool (with a generic CreatePool schema added to amm-schema.ts). DAMM v2's custom features are mapped onto the AMM interface (NFT positions, sqrt-price accounting, config-driven pool creation, base/dynamic fees, Token-2022, locks/vesting) and documented — with proposed enhancements — in docs/connectors/meteora-damm-v2.md. create-pool requires an explicit configAddress because many permissionless configs are launch configs with ~99% starting fees. Unit tests mirror the Raydium AMM suite (pool-info, quote-swap BUY/SELL, quote-liquidity, create-pool guard). typecheck + lint clean; full Meteora suite green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY --- docs/connectors/meteora-damm-v2.md | 115 ++++++++ package.json | 1 + pnpm-lock.yaml | 36 +++ src/app.ts | 1 + .../meteora/amm-routes/addLiquidity.ts | 140 ++++++++++ .../meteora/amm-routes/createPool.ts | 190 +++++++++++++ .../meteora/amm-routes/executeSwap.ts | 121 ++++++++ src/connectors/meteora/amm-routes/index.ts | 23 ++ src/connectors/meteora/amm-routes/poolInfo.ts | 38 +++ .../meteora/amm-routes/positionInfo.ts | 91 ++++++ .../meteora/amm-routes/quoteLiquidity.ts | 155 +++++++++++ .../meteora/amm-routes/quoteSwap.ts | 209 ++++++++++++++ .../meteora/amm-routes/removeLiquidity.ts | 151 ++++++++++ src/connectors/meteora/meteora-damm.ts | 159 +++++++++++ src/connectors/meteora/meteora.config.ts | 4 +- src/connectors/meteora/meteora.routes.ts | 19 +- src/connectors/meteora/schemas.ts | 263 ++++++++++++++++++ src/schemas/amm-schema.ts | 38 +++ .../meteora/amm-routes/create-pool.test.ts | 48 ++++ .../meteora/amm-routes/poolInfo.test.ts | 78 ++++++ .../amm-routes/quote-liquidity.test.ts | 89 ++++++ .../meteora/amm-routes/quote-swap.test.ts | 156 +++++++++++ .../connectors/meteora/meteora.routes.test.ts | 15 +- 23 files changed, 2131 insertions(+), 9 deletions(-) create mode 100644 docs/connectors/meteora-damm-v2.md create mode 100644 src/connectors/meteora/amm-routes/addLiquidity.ts create mode 100644 src/connectors/meteora/amm-routes/createPool.ts create mode 100644 src/connectors/meteora/amm-routes/executeSwap.ts create mode 100644 src/connectors/meteora/amm-routes/index.ts create mode 100644 src/connectors/meteora/amm-routes/poolInfo.ts create mode 100644 src/connectors/meteora/amm-routes/positionInfo.ts create mode 100644 src/connectors/meteora/amm-routes/quoteLiquidity.ts create mode 100644 src/connectors/meteora/amm-routes/quoteSwap.ts create mode 100644 src/connectors/meteora/amm-routes/removeLiquidity.ts create mode 100644 src/connectors/meteora/meteora-damm.ts create mode 100644 test/connectors/meteora/amm-routes/create-pool.test.ts create mode 100644 test/connectors/meteora/amm-routes/poolInfo.test.ts create mode 100644 test/connectors/meteora/amm-routes/quote-liquidity.test.ts create mode 100644 test/connectors/meteora/amm-routes/quote-swap.test.ts diff --git a/docs/connectors/meteora-damm-v2.md b/docs/connectors/meteora-damm-v2.md new file mode 100644 index 0000000000..1e151d9dcf --- /dev/null +++ b/docs/connectors/meteora-damm-v2.md @@ -0,0 +1,115 @@ +# Meteora DAMM v2 (AMM) Connector + +DAMM v2 is Meteora's constant-product AMM, implemented by the on-chain **cp-amm** program +(`cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG`) and driven by the +[`@meteora-ag/cp-amm-sdk`](https://docs.meteora.ag/developer-guides/damm-v2/typescript-sdk/getting-started). + +Gateway exposes it under the standard AMM interface at `/connectors/meteora/amm/*`, mirroring +the Raydium AMM connector: + +| Endpoint | Method | Notes | +|---|---|---| +| `/connectors/meteora/amm/pool-info` | GET | Pool reserves, price, base (cliff) fee % | +| `/connectors/meteora/amm/position-info` | GET | Wallet's aggregated liquidity in a pool | +| `/connectors/meteora/amm/quote-swap` | GET | Exact-in (SELL) / exact-out (BUY) quote | +| `/connectors/meteora/amm/execute-swap` | POST | Swap | +| `/connectors/meteora/amm/quote-liquidity` | GET | Two-sided deposit quote | +| `/connectors/meteora/amm/add-liquidity` | POST | Add to (or open) a position | +| `/connectors/meteora/amm/remove-liquidity` | POST | Remove a % of position liquidity | +| `/connectors/meteora/amm/create-pool` | POST | Create + seed a new pool | + +The implementation deliberately keeps to "the basics" so it fits the shared AMM schema. This +document records where DAMM v2 differs from a classic fungible-LP AMM (e.g. Raydium AMM/CPMM), +how each difference is currently mapped onto the AMM interface, and what a fuller treatment +would look like. + +--- + +## Custom features of DAMM v2 and how they are handled + +### 1. Positions are NFTs, not fungible LP tokens +A classic AMM mints a fungible LP token; your position is just your LP balance, and there is +exactly one position per (wallet, pool). DAMM v2 instead mints a **position NFT** per position, +and a wallet can hold **several positions in the same pool**. Liquidity, fees, and vesting all +live on the position account (`PositionState`), keyed by the NFT. + +- **`add-liquidity`** — if the wallet already holds a position in the pool, liquidity is added to + its **largest** position; otherwise a new position NFT is minted + (`createPositionAndAddLiquidity`). Minting requires the NFT mint keypair to co-sign; Gateway's + Solana send path already supports ephemeral extra signers, so the keypair is generated in the + route and passed through. +- **`remove-liquidity`** — operates on the wallet's **largest** position and removes the requested + percentage of its *unlocked* liquidity (100% removes the exact unlocked amount). +- **`position-info`** — there is no LP token balance to report, so amounts are **summed across all + of the wallet's positions** in the pool (via `getWithdrawQuote` on each). `lpTokenAmount` is + reported as the aggregate position liquidity (the Q64 liquidity value converted to a decimal), + clearly *not* an SPL token balance. + + **Proposed enhancement:** add position-addressed routes (`positions-owned`, and + `position`/`add`/`remove` that take a `positionAddress`), mirroring the CLMM interface, so callers + can manage multiple positions per pool precisely instead of always defaulting to the largest. + +### 2. sqrt-price / concentrated-liquidity accounting +DAMM v2 uses Uniswap-v3-style `sqrtPrice` (Q64) math with `sqrtMinPrice`/`sqrtMaxPrice` bounds, +even though the permissionless pools Gateway creates are **full-range** (min = `MIN_SQRT_PRICE`, +max = `MAX_SQRT_PRICE`), which reproduces constant-product behaviour. All conversions go through the +SDK helpers (`getPriceFromSqrtPrice`, `getDepositQuote`, `getWithdrawQuote`, `getQuote2`, +`getLiquidityDelta`) rather than reimplementing the math. `price` in `pool-info` is quote-per-base +(token B per token A). + +### 3. Pool creation is config-driven, and configs can carry very high launch fees +A pool is created against a **config account** that fixes the fee schedule, `collectFeeMode`, and +price bounds. There are hundreds of permissionless static configs, and **many are token-launch +configs whose base fee starts at ~99% and decays** (fee schedulers/rate limiters). Auto-selecting +one blindly could create a pool with a punitive fee. + +- **`create-pool`** therefore **requires an explicit `configAddress`**. The deposit ratio + (`baseTokenAmount : quoteTokenAmount`) sets the initial price via + `preparePoolCreationParams`; liquidity spans the full range. Token order is base → token A, + quote → token B. The new pool address is derived deterministically + (`derivePoolAddress(config, tokenAMint, tokenBMint)`) and returned. +- Discover configs with the SDK (`cpAmm.getAllConfigs()` / `getStaticConfigs()`) or the Meteora + app, and pass one whose fee/`collectFeeMode` you want. + + **Proposed enhancement:** a safe auto-select that decodes each static config's base fee, keeps + only **static-fee** configs (fee scheduler `numberOfPeriod === 0`) with `collectFeeMode = BothToken`, + and picks the one matching a requested `feePct` (or the lowest). This needs per-config fee decoding + (`fetchPoolFees` / the pod-aligned fee decoders) and is intentionally left out of the basics. + +### 4. Fee model: base (cliff) fee + optional dynamic fee +The fee reported by `pool-info` is the pool's **base (cliff) fee** (`fetchPoolFees(...).cliffFeeNumerator` +→ bps → %). Pools may additionally run a **dynamic fee** and/or a **fee scheduler** that make the +effective fee vary with volatility or time, so the reported number is the representative base fee, +not necessarily the fee charged on a given swap. Swap quotes/executions always use the SDK's +`getQuote2`, which accounts for the live fee at the current slot/timestamp. + +### 5. Activation clock (slot vs timestamp) +Each pool activates on either a **slot** or a **unix timestamp** (`activationType`). Remove-liquidity +and vesting math need the matching "current point", so the connector reads the current slot and +block time and picks the right one per pool. + +### 6. Token-2022 support +Both sides of a pool may be Token or Token-2022 mints. The connector resolves each side's token +program from the pool's `tokenAFlag`/`tokenBFlag` (existing pools) or from the mint account owner +(pool creation), and passes it to every instruction. Transfer-fee extensions are handled by the +SDK's quote helpers. + +### 7. Locks, vesting, permanent locks, rewards, split/merge +DAMM v2 positions can be time-locked, permanently locked, vested, and can accrue farming rewards; +positions can also be split or merged. **None of these are exposed** in this basic connector. +`remove-liquidity` only ever touches **unlocked** liquidity, and `remove-liquidity`/`position-info` +pass the position's vesting accounts to the SDK so locked liquidity is respected. + + **Proposed enhancement:** dedicated routes for `lock-position`, `claim-fees`, `claim-reward`, and + `collect-fees`, plus surfacing locked/vested amounts in `position-info`. + +--- + +## Not covered (basics scope) + +- Multiple-position management per pool (add/remove always target the largest position). +- `createCustomPool` (arbitrary fee params without a preexisting config). +- Fee claiming, rewards, locking/vesting, and split/merge operations. +- Automatic config selection for `create-pool`. + +These are the natural follow-ups; each is called out above next to the feature it belongs to. diff --git a/package.json b/package.json index 53e0e5ad25..79d02107de 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@ledgerhq/hw-app-solana": "^7.5.0", "@ledgerhq/hw-transport-node-hid": "^6.29.8", "@ledgerhq/hw-transport-node-hid-singleton": "^6.31.8", + "@meteora-ag/cp-amm-sdk": "1.4.5", "@meteora-ag/dlmm": "1.7.5", "@orca-so/common-sdk": "^0.6.11", "@orca-so/whirlpools": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 830e1a778a..98b40a0a50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: '@ledgerhq/hw-transport-node-hid-singleton': specifier: ^6.31.8 version: 6.31.8 + '@meteora-ag/cp-amm-sdk': + specifier: 1.4.5 + version: 1.4.5(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) '@meteora-ag/dlmm': specifier: 1.7.5 version: 1.7.5(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -1341,6 +1344,9 @@ packages: '@metaplex-foundation/mpl-token-metadata@2.13.0': resolution: {integrity: sha512-Fl/8I0L9rv4bKTV/RAl5YIbJe9SnQPInKvLz+xR1fEc4/VQkuCn3RPgypfUMEKWmCznzaw4sApDxy6CFS4qmJw==} + '@meteora-ag/cp-amm-sdk@1.4.5': + resolution: {integrity: sha512-Uw1tj0llVf61FUkkx3aRcT868d/2fiBK+7GpP/rH3yZaDC15JBmjtCpx6ghS8jA2ZM4qvMb1cnmF3hLH9C3wAA==} + '@meteora-ag/dlmm@1.7.5': resolution: {integrity: sha512-cXfEvAaInhuBwz4pKcMaJHMWbysEEApMTeYnL4kMD38e6YyOht6hfJme2cjaWXct5jAbVvA0AEoFZdjcC/nQhA==} @@ -3438,6 +3444,10 @@ packages: resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} engines: {node: '>=4'} + chain@0.4.2: + resolution: {integrity: sha512-GtM+TlN398yBhtSp1D2dBLQomKM3Umbji3h2/NdCqAWSMKhWbjlz33j0e55rStsEZD+8OLRHuz7kWd0U3xKMDg==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -4805,6 +4815,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + io-ts@1.10.4: resolution: {integrity: sha512-b23PteSnYXSONJ6JQXRAlvJhuw8KOtkqa87W4wDtvMrud/DTJd5X+NpOOI+O/zZwVq6v0VLAaJ+1EDViKEuN9g==} @@ -9104,6 +9117,23 @@ snapshots: - typescript - utf-8-validate + '@meteora-ag/cp-amm-sdk@1.4.5(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)': + dependencies: + '@coral-xyz/anchor': 0.31.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@solana/spl-token': 0.4.8(@solana/web3.js@1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@solana/web3.js': 1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@types/bn.js': 5.1.6 + bn.js: 5.2.1 + chain: 0.4.2 + decimal.js: 10.5.0 + invariant: 2.2.4 + transitivePeerDependencies: + - bufferutil + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + '@meteora-ag/dlmm@1.7.5(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)': dependencies: '@coral-xyz/anchor': 0.31.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -12292,6 +12322,8 @@ snapshots: pathval: 1.1.1 type-detect: 4.1.0 + chain@0.4.2: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -13953,6 +13985,10 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + io-ts@1.10.4: dependencies: fp-ts: 1.19.3 diff --git a/src/app.ts b/src/app.ts index 0d1591e162..9518355c2b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -324,6 +324,7 @@ const configureGatewayServer = () => { // Meteora routes app.register(meteoraRoutes.clmm, { prefix: '/connectors/meteora/clmm' }); + app.register(meteoraRoutes.amm, { prefix: '/connectors/meteora/amm' }); // // Orca routes app.register(orcaRoutes.clmm, { prefix: '/connectors/orca/clmm' }); diff --git a/src/connectors/meteora/amm-routes/addLiquidity.ts b/src/connectors/meteora/amm-routes/addLiquidity.ts new file mode 100644 index 0000000000..b2a5f94baf --- /dev/null +++ b/src/connectors/meteora/amm-routes/addLiquidity.ts @@ -0,0 +1,140 @@ +import { Keypair, PublicKey, Transaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { Solana } from '../../../chains/solana/solana'; +import { AddLiquidityResponse, AddLiquidityResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraConfig } from '../meteora.config'; +import { MeteoraAmmAddLiquidityRequest } from '../schemas'; + +import { getLiquidityQuote } from './quoteLiquidity'; + +export async function addLiquidity( + network: string, + walletAddress: string, + poolAddress: string, + baseTokenAmount: number, + quoteTokenAmount: number, + slippagePct: number, +): Promise { + const solana = await Solana.getInstance(network); + const meteoraDamm = await MeteoraDamm.getInstance(network); + + const poolState = await meteoraDamm.getPoolState(poolAddress); + const { tokenAProgram, tokenBProgram } = meteoraDamm.getTokenPrograms(poolState); + + const quote = await getLiquidityQuote(meteoraDamm, poolState, baseTokenAmount, quoteTokenAmount, slippagePct); + if (quote.liquidityDelta.isZero()) { + throw httpErrors.badRequest('Computed liquidity is zero — increase the token amounts'); + } + + const owner = new PublicKey(walletAddress); + const pool = new PublicKey(poolAddress); + + // DAMM v2 positions are NFTs. Add to the wallet's largest existing position in this pool, or + // create a new position NFT if the wallet has none. + const existing = await meteoraDamm.getUserPositions(poolAddress, walletAddress); + + let transaction: Transaction; + const extraSigners: Keypair[] = []; + + const shared = { + liquidityDelta: quote.liquidityDelta, + maxAmountTokenA: quote.maxAmountTokenA, + maxAmountTokenB: quote.maxAmountTokenB, + tokenAAmountThreshold: quote.maxAmountTokenA, + tokenBAmountThreshold: quote.maxAmountTokenB, + tokenAMint: poolState.tokenAMint, + tokenBMint: poolState.tokenBMint, + tokenAProgram, + tokenBProgram, + }; + + if (existing.length > 0) { + const target = existing[0]; + logger.info(`Adding liquidity to existing DAMM v2 position ${target.position.toBase58()} in pool ${poolAddress}`); + transaction = await meteoraDamm.cpAmm.addLiquidity({ + owner, + pool, + position: target.position, + positionNftAccount: target.positionNftAccount, + tokenAVault: poolState.tokenAVault, + tokenBVault: poolState.tokenBVault, + ...shared, + }); + } else { + const positionNft = Keypair.generate(); + extraSigners.push(positionNft); + logger.info(`Opening new DAMM v2 position (NFT ${positionNft.publicKey.toBase58()}) in pool ${poolAddress}`); + transaction = await meteoraDamm.cpAmm.createPositionAndAddLiquidity({ + owner, + pool, + positionNft: positionNft.publicKey, + ...shared, + }); + } + + const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, extraSigners); + const txData = await solana.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + + if (txData) { + const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ + poolState.tokenAMint.toBase58(), + poolState.tokenBMint.toBase58(), + ]); + return { + signature, + status: 1, // CONFIRMED + data: { + fee: txData.meta.fee / 1e9, + baseTokenAmountAdded: Math.abs(balanceChanges[0]), + quoteTokenAmountAdded: Math.abs(balanceChanges[1]), + }, + }; + } + return { signature, status: 0 }; // PENDING +} + +export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: typeof MeteoraAmmAddLiquidityRequest.static; + Reply: AddLiquidityResponseType; + }>( + '/add-liquidity', + { + schema: { + description: 'Add liquidity to a Meteora DAMM v2 pool (opens a position NFT if the wallet has none)', + tags: ['/connector/meteora'], + body: MeteoraAmmAddLiquidityRequest, + response: { + 200: AddLiquidityResponse, + }, + }, + }, + async (request) => { + try { + const { network, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.body; + const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; + return await addLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + effectiveSlippage, + ); + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to add liquidity'); + } + }, + ); +}; + +export default addLiquidityRoute; diff --git a/src/connectors/meteora/amm-routes/createPool.ts b/src/connectors/meteora/amm-routes/createPool.ts new file mode 100644 index 0000000000..2c96b61360 --- /dev/null +++ b/src/connectors/meteora/amm-routes/createPool.ts @@ -0,0 +1,190 @@ +import { MIN_SQRT_PRICE, MAX_SQRT_PRICE, derivePoolAddress, getTokenDecimals } from '@meteora-ag/cp-amm-sdk'; +import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; +import { Keypair, PublicKey, Transaction } from '@solana/web3.js'; +import BN from 'bn.js'; +import { Decimal } from 'decimal.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { Solana } from '../../../chains/solana/solana'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraAmmCreatePoolRequest } from '../schemas'; + +/** Resolves a token symbol or mint address to a PublicKey. */ +async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { + const tokenInfo = await solana.getToken(tokenOrAddress); + if (tokenInfo) return new PublicKey(tokenInfo.address); + try { + return new PublicKey(tokenOrAddress); + } catch { + throw httpErrors.badRequest(sanitizeErrorMessage('Token not found: {}', tokenOrAddress)); + } +} + +/** Detects whether a mint is owned by the Token or Token-2022 program. */ +async function getMintProgram(solana: Solana, mint: PublicKey): Promise { + const info = await solana.connection.getAccountInfo(mint); + if (!info) throw httpErrors.badRequest(`Mint account not found: ${mint.toBase58()}`); + if (info.owner.equals(TOKEN_2022_PROGRAM_ID)) return TOKEN_2022_PROGRAM_ID; + if (info.owner.equals(TOKEN_PROGRAM_ID)) return TOKEN_PROGRAM_ID; + throw httpErrors.badRequest(`Mint ${mint.toBase58()} is not an SPL token mint`); +} + +export async function createPool( + network: string, + walletAddress: string, + baseToken: string, + quoteToken: string, + baseTokenAmount: number, + quoteTokenAmount: number, + configAddress?: string, +): Promise { + if (!configAddress) { + throw httpErrors.badRequest( + 'configAddress is required. DAMM v2 pools are created against a config account that defines the ' + + 'fee tier and parameters; many configs are launch configs with very high starting fees, so Gateway ' + + 'does not auto-select one. Choose a config from the Meteora config list and pass its address. ' + + 'See docs/connectors/meteora-damm-v2.md.', + ); + } + + const solana = await Solana.getInstance(network); + const meteoraDamm = await MeteoraDamm.getInstance(network); + + const tokenAMint = await resolveMint(solana, baseToken); + const tokenBMint = await resolveMint(solana, quoteToken); + if (tokenAMint.equals(tokenBMint)) { + throw httpErrors.badRequest('baseToken and quoteToken must be different'); + } + + let config: PublicKey; + try { + config = new PublicKey(configAddress); + } catch { + throw httpErrors.badRequest(`Invalid config address: ${configAddress}`); + } + + let configState; + try { + configState = await meteoraDamm.cpAmm.fetchConfigState(config); + } catch { + throw httpErrors.badRequest(`Config not found: ${configAddress}`); + } + + const pool = derivePoolAddress(config, tokenAMint, tokenBMint); + if (await meteoraDamm.cpAmm.isPoolExist(pool)) { + throw httpErrors.badRequest(`Pool already exists for this token pair and config: ${pool.toBase58()}`); + } + + const [tokenAProgram, tokenBProgram] = await Promise.all([ + getMintProgram(solana, tokenAMint), + getMintProgram(solana, tokenBMint), + ]); + const [tokenADecimal, tokenBDecimal] = await Promise.all([ + getTokenDecimals(solana.connection, tokenAMint, tokenAProgram), + getTokenDecimals(solana.connection, tokenBMint, tokenBProgram), + ]); + + const tokenAAmount = new BN(new Decimal(baseTokenAmount).mul(new Decimal(10).pow(tokenADecimal)).toFixed(0)); + const tokenBAmount = new BN(new Decimal(quoteTokenAmount).mul(new Decimal(10).pow(tokenBDecimal)).toFixed(0)); + if (tokenAAmount.isZero() || tokenBAmount.isZero()) { + throw httpErrors.badRequest('Both baseTokenAmount and quoteTokenAmount must be greater than zero'); + } + + // The deposit ratio sets the initial price; liquidity spans the full price range. + const { initSqrtPrice, liquidityDelta } = meteoraDamm.cpAmm.preparePoolCreationParams({ + tokenAAmount, + tokenBAmount, + minSqrtPrice: MIN_SQRT_PRICE, + maxSqrtPrice: MAX_SQRT_PRICE, + collectFeeMode: configState.collectFeeMode, + }); + + const positionNft = Keypair.generate(); + logger.info( + `Creating Meteora DAMM v2 pool ${pool.toBase58()} (${baseToken}/${quoteToken}) with position NFT ${positionNft.publicKey.toBase58()}`, + ); + + const transaction: Transaction = await meteoraDamm.cpAmm.createPool({ + creator: new PublicKey(walletAddress), + payer: new PublicKey(walletAddress), + config, + positionNft: positionNft.publicKey, + tokenAMint, + tokenBMint, + initSqrtPrice, + liquidityDelta, + tokenAAmount, + tokenBAmount, + activationPoint: null, + tokenAProgram, + tokenBProgram, + }); + + const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, [positionNft]); + const txData = await solana.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + + if (txData) { + const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ + tokenAMint.toBase58(), + tokenBMint.toBase58(), + ]); + return { + signature, + status: 1, // CONFIRMED + poolAddress: pool.toBase58(), + data: { + fee: txData.meta.fee / 1e9, + baseTokenAmountAdded: Math.abs(balanceChanges[0]), + quoteTokenAmountAdded: Math.abs(balanceChanges[1]), + }, + }; + } + return { signature, status: 0, poolAddress: pool.toBase58() }; // PENDING +} + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: typeof MeteoraAmmCreatePoolRequest.static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: 'Create a new Meteora DAMM v2 pool and seed it with initial liquidity', + tags: ['/connector/meteora'], + body: MeteoraAmmCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { network, walletAddress, baseToken, quoteToken, baseTokenAmount, quoteTokenAmount, configAddress } = + request.body; + return await createPool( + network, + walletAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + configAddress, + ); + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to create pool'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/connectors/meteora/amm-routes/executeSwap.ts b/src/connectors/meteora/amm-routes/executeSwap.ts new file mode 100644 index 0000000000..07a199b0c8 --- /dev/null +++ b/src/connectors/meteora/amm-routes/executeSwap.ts @@ -0,0 +1,121 @@ +import { SwapMode } from '@meteora-ag/cp-amm-sdk'; +import { PublicKey, Transaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { Solana } from '../../../chains/solana/solana'; +import { ExecuteSwapResponse, ExecuteSwapResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraConfig } from '../meteora.config'; +import { MeteoraAmmExecuteSwapRequest } from '../schemas'; + +import { getRawSwapQuote } from './quoteSwap'; + +export async function executeSwap( + network: string, + walletAddress: string, + poolAddress: string, + baseToken: string, + side: 'BUY' | 'SELL', + amount: number, + slippagePct: number, +): Promise { + const solana = await Solana.getInstance(network); + const meteoraDamm = await MeteoraDamm.getInstance(network); + + const quote = await getRawSwapQuote(meteoraDamm, poolAddress, baseToken, side, amount, slippagePct); + const { poolState } = quote; + const { tokenAProgram, tokenBProgram } = meteoraDamm.getTokenPrograms(poolState); + + logger.info(`Executing ${amount} ${side} swap in Meteora DAMM v2 pool ${poolAddress}`); + + const swapParams = { + payer: new PublicKey(walletAddress), + pool: new PublicKey(poolAddress), + inputTokenMint: quote.inputMint, + outputTokenMint: quote.outputMint, + tokenAMint: poolState.tokenAMint, + tokenBMint: poolState.tokenBMint, + tokenAVault: poolState.tokenAVault, + tokenBVault: poolState.tokenBVault, + tokenAProgram, + tokenBProgram, + referralTokenAccount: null, + poolState, + }; + + const transaction: Transaction = + quote.swapMode === SwapMode.ExactIn + ? await meteoraDamm.cpAmm.swap2({ + ...swapParams, + swapMode: SwapMode.ExactIn, + amountIn: quote.amountInBN, + minimumAmountOut: quote.minimumAmountOutBN, + }) + : await meteoraDamm.cpAmm.swap2({ + ...swapParams, + swapMode: SwapMode.ExactOut, + amountOut: quote.amountOutBN, + maximumAmountIn: quote.maximumAmountInBN, + }); + + const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); + const txData = await solana.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + + const result = await solana.handleConfirmation( + signature, + txData !== null, + txData, + quote.inputMint.toBase58(), + quote.outputMint.toBase58(), + walletAddress, + side, + ); + + return result as ExecuteSwapResponseType; +} + +export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: typeof MeteoraAmmExecuteSwapRequest.static; + Reply: ExecuteSwapResponseType; + }>( + '/execute-swap', + { + schema: { + description: 'Execute a swap on a Meteora DAMM v2 pool', + tags: ['/connector/meteora'], + body: MeteoraAmmExecuteSwapRequest, + response: { + 200: ExecuteSwapResponse, + }, + }, + }, + async (request) => { + try { + const { network, walletAddress, poolAddress, baseToken, amount, side, slippagePct } = request.body; + const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; + + return await executeSwap( + network, + walletAddress, + poolAddress, + baseToken, + side as 'BUY' | 'SELL', + amount, + effectiveSlippage, + ); + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw httpErrors.internalServerError('Swap execution failed'); + } + }, + ); +}; + +export default executeSwapRoute; diff --git a/src/connectors/meteora/amm-routes/index.ts b/src/connectors/meteora/amm-routes/index.ts new file mode 100644 index 0000000000..3142acb50a --- /dev/null +++ b/src/connectors/meteora/amm-routes/index.ts @@ -0,0 +1,23 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { addLiquidityRoute } from './addLiquidity'; +import { createPoolRoute } from './createPool'; +import { executeSwapRoute } from './executeSwap'; +import { poolInfoRoute } from './poolInfo'; +import { positionInfoRoute } from './positionInfo'; +import { quoteLiquidityRoute } from './quoteLiquidity'; +import { quoteSwapRoute } from './quoteSwap'; +import { removeLiquidityRoute } from './removeLiquidity'; + +export const meteoraAmmRoutes: FastifyPluginAsync = async (fastify) => { + await fastify.register(poolInfoRoute); + await fastify.register(positionInfoRoute); + await fastify.register(quoteSwapRoute); + await fastify.register(quoteLiquidityRoute); + await fastify.register(executeSwapRoute); + await fastify.register(addLiquidityRoute); + await fastify.register(removeLiquidityRoute); + await fastify.register(createPoolRoute); +}; + +export default meteoraAmmRoutes; diff --git a/src/connectors/meteora/amm-routes/poolInfo.ts b/src/connectors/meteora/amm-routes/poolInfo.ts new file mode 100644 index 0000000000..3bcb8dfe7b --- /dev/null +++ b/src/connectors/meteora/amm-routes/poolInfo.ts @@ -0,0 +1,38 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/amm-schema'; +import { logger } from '../../../services/logger'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraAmmGetPoolInfoRequest } from '../schemas'; + +export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: GetPoolInfoRequestType; + Reply: PoolInfo; + }>( + '/pool-info', + { + schema: { + description: 'Get AMM pool information from Meteora DAMM v2', + tags: ['/connector/meteora'], + querystring: MeteoraAmmGetPoolInfoRequest, + response: { + 200: PoolInfoSchema, + }, + }, + }, + async (request): Promise => { + try { + const { poolAddress, network } = request.query; + const meteoraDamm = await MeteoraDamm.getInstance(network); + return await meteoraDamm.getPoolInfo(poolAddress); + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to fetch pool info'); + } + }, + ); +}; + +export default poolInfoRoute; diff --git a/src/connectors/meteora/amm-routes/positionInfo.ts b/src/connectors/meteora/amm-routes/positionInfo.ts new file mode 100644 index 0000000000..9889838ad7 --- /dev/null +++ b/src/connectors/meteora/amm-routes/positionInfo.ts @@ -0,0 +1,91 @@ +import { q64ToDecimal } from '@meteora-ag/cp-amm-sdk'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { Decimal } from 'decimal.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { GetPositionInfoRequestType, PositionInfo, PositionInfoSchema } from '../../../schemas/amm-schema'; +import { logger } from '../../../services/logger'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraAmmGetPositionInfoRequest } from '../schemas'; + +export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: GetPositionInfoRequestType; + Reply: PositionInfo; + }>( + '/position-info', + { + schema: { + 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.', + tags: ['/connector/meteora'], + querystring: MeteoraAmmGetPositionInfoRequest, + response: { + 200: PositionInfoSchema, + }, + }, + }, + async (request): Promise => { + try { + const { poolAddress, walletAddress, network } = request.query; + + try { + new PublicKey(walletAddress); + } catch { + throw fastify.httpErrors.badRequest('Invalid wallet address'); + } + + const meteoraDamm = await MeteoraDamm.getInstance(network); + const poolState = await meteoraDamm.getPoolState(poolAddress); + const { tokenADecimal, tokenBDecimal } = await meteoraDamm.getTokenDecimals(poolState); + const price = meteoraDamm.getPrice(poolState, tokenADecimal, tokenBDecimal); + + const positions = await meteoraDamm.getUserPositions(poolAddress, walletAddress); + + let totalLiquidity = new BN(0); + let baseRaw = new BN(0); + let quoteRaw = new BN(0); + for (const { positionState } of positions) { + const liquidity = positionState.unlockedLiquidity; + if (liquidity.isZero()) continue; + totalLiquidity = totalLiquidity.add(liquidity); + const wq = meteoraDamm.cpAmm.getWithdrawQuote({ + liquidityDelta: liquidity, + minSqrtPrice: poolState.sqrtMinPrice, + maxSqrtPrice: poolState.sqrtMaxPrice, + sqrtPrice: poolState.sqrtPrice, + collectFeeMode: poolState.collectFeeMode, + tokenAAmount: poolState.tokenAAmount, + tokenBAmount: poolState.tokenBAmount, + liquidity: poolState.liquidity, + }); + baseRaw = baseRaw.add(wq.outAmountA); + quoteRaw = quoteRaw.add(wq.outAmountB); + } + + const toUi = (raw: BN, decimals: number) => + new Decimal(raw.toString()).div(new Decimal(10).pow(decimals)).toNumber(); + + return { + poolAddress, + walletAddress, + baseTokenAddress: poolState.tokenAMint.toBase58(), + quoteTokenAddress: poolState.tokenBMint.toBase58(), + // DAMM v2 has no fungible LP token; report the aggregate position liquidity (Q64 → decimal). + lpTokenAmount: Number(q64ToDecimal(totalLiquidity).toString()), + baseTokenAmount: toUi(baseRaw, tokenADecimal), + quoteTokenAmount: toUi(quoteRaw, tokenBDecimal), + price, + }; + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to fetch position info'); + } + }, + ); +}; + +export default positionInfoRoute; diff --git a/src/connectors/meteora/amm-routes/quoteLiquidity.ts b/src/connectors/meteora/amm-routes/quoteLiquidity.ts new file mode 100644 index 0000000000..e2aa263f80 --- /dev/null +++ b/src/connectors/meteora/amm-routes/quoteLiquidity.ts @@ -0,0 +1,155 @@ +import { PoolState } from '@meteora-ag/cp-amm-sdk'; +import BN from 'bn.js'; +import { Decimal } from 'decimal.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { + QuoteLiquidityRequestType, + QuoteLiquidityResponse, + QuoteLiquidityResponseType, +} from '../../../schemas/amm-schema'; +import { logger } from '../../../services/logger'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraConfig } from '../meteora.config'; +import { MeteoraAmmQuoteLiquidityRequest } from '../schemas'; + +/** A resolved deposit quote: which side limits the deposit, the amounts, and the liquidity delta. */ +export interface LiquidityQuote { + baseLimited: boolean; + baseTokenAmount: number; + quoteTokenAmount: number; + baseTokenAmountMax: number; + quoteTokenAmountMax: number; + liquidityDelta: BN; + maxAmountTokenA: BN; + maxAmountTokenB: BN; + tokenADecimal: number; + tokenBDecimal: number; +} + +function toRaw(amount: number, decimals: number): BN { + return new BN(new Decimal(amount).mul(new Decimal(10).pow(decimals)).toFixed(0)); +} + +function toUi(raw: BN, decimals: number): number { + return new Decimal(raw.toString()).div(new Decimal(10).pow(decimals)).toNumber(); +} + +function withSlippageUp(raw: BN, slippagePct: number): BN { + return new BN(new Decimal(raw.toString()).mul(1 + slippagePct / 100).toFixed(0)); +} + +/** + * Computes the deposit quote for a DAMM v2 pool. Deposits are two-sided at the current price; + * one token limits the deposit and the other is derived. `base` = token A, `quote` = token B. + */ +export async function getLiquidityQuote( + meteoraDamm: MeteoraDamm, + poolState: PoolState, + baseTokenAmount: number, + quoteTokenAmount: number, + slippagePct: number, +): Promise { + const { tokenADecimal, tokenBDecimal } = await meteoraDamm.getTokenDecimals(poolState); + const baseRaw = toRaw(baseTokenAmount, tokenADecimal); + const quoteRaw = toRaw(quoteTokenAmount, tokenBDecimal); + + const common = { + minSqrtPrice: poolState.sqrtMinPrice, + maxSqrtPrice: poolState.sqrtMaxPrice, + sqrtPrice: poolState.sqrtPrice, + collectFeeMode: poolState.collectFeeMode, + tokenAAmount: poolState.tokenAAmount, + tokenBAmount: poolState.tokenBAmount, + liquidity: poolState.liquidity, + }; + + // Quote from each side; the side yielding the smaller liquidity delta is the limiting one. + const fromBase = meteoraDamm.cpAmm.getDepositQuote({ ...common, inAmount: baseRaw, isTokenA: true }); + const fromQuote = meteoraDamm.cpAmm.getDepositQuote({ ...common, inAmount: quoteRaw, isTokenA: false }); + + const baseLimited = fromBase.liquidityDelta.lte(fromQuote.liquidityDelta); + + if (baseLimited) { + // Consume all base; the required quote is fromBase.outputAmount. + const requiredQuoteRaw = fromBase.outputAmount; + const maxAmountTokenB = withSlippageUp(requiredQuoteRaw, slippagePct); + return { + baseLimited: true, + baseTokenAmount, + quoteTokenAmount: toUi(requiredQuoteRaw, tokenBDecimal), + baseTokenAmountMax: baseTokenAmount, + quoteTokenAmountMax: toUi(maxAmountTokenB, tokenBDecimal), + liquidityDelta: fromBase.liquidityDelta, + maxAmountTokenA: baseRaw, + maxAmountTokenB, + tokenADecimal, + tokenBDecimal, + }; + } + + // Consume all quote; the required base is fromQuote.outputAmount. + const requiredBaseRaw = fromQuote.outputAmount; + const maxAmountTokenA = withSlippageUp(requiredBaseRaw, slippagePct); + return { + baseLimited: false, + baseTokenAmount: toUi(requiredBaseRaw, tokenADecimal), + quoteTokenAmount, + baseTokenAmountMax: toUi(maxAmountTokenA, tokenADecimal), + quoteTokenAmountMax: quoteTokenAmount, + liquidityDelta: fromQuote.liquidityDelta, + maxAmountTokenA, + maxAmountTokenB: quoteRaw, + tokenADecimal, + tokenBDecimal, + }; +} + +export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: QuoteLiquidityRequestType; + Reply: QuoteLiquidityResponseType; + }>( + '/quote-liquidity', + { + schema: { + description: 'Quote amounts for adding liquidity to a Meteora DAMM v2 pool', + tags: ['/connector/meteora'], + querystring: MeteoraAmmQuoteLiquidityRequest, + response: { + 200: QuoteLiquidityResponse, + }, + }, + }, + async (request): Promise => { + try { + const { network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.query; + const meteoraDamm = await MeteoraDamm.getInstance(network); + const poolState = await meteoraDamm.getPoolState(poolAddress); + const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; + + const quote = await getLiquidityQuote( + meteoraDamm, + poolState, + baseTokenAmount, + quoteTokenAmount, + effectiveSlippage, + ); + + return { + baseLimited: quote.baseLimited, + baseTokenAmount: quote.baseTokenAmount, + quoteTokenAmount: quote.quoteTokenAmount, + baseTokenAmountMax: quote.baseTokenAmountMax, + quoteTokenAmountMax: quote.quoteTokenAmountMax, + }; + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to quote liquidity'); + } + }, + ); +}; + +export default quoteLiquidityRoute; diff --git a/src/connectors/meteora/amm-routes/quoteSwap.ts b/src/connectors/meteora/amm-routes/quoteSwap.ts new file mode 100644 index 0000000000..58aead9a0b --- /dev/null +++ b/src/connectors/meteora/amm-routes/quoteSwap.ts @@ -0,0 +1,209 @@ +import { PoolState, SwapMode } from '@meteora-ag/cp-amm-sdk'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { Decimal } from 'decimal.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { Solana } from '../../../chains/solana/solana'; +import { QuoteSwapResponse, QuoteSwapResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraConfig } from '../meteora.config'; +import { MeteoraAmmQuoteSwapRequest } from '../schemas'; + +/** + * A fully-resolved DAMM v2 swap quote. The BN fields are what the swap instruction consumes; + * the number fields are the human-readable amounts for the API response. + */ +export interface RawSwapQuote { + poolState: PoolState; + side: 'BUY' | 'SELL'; + swapMode: SwapMode; + inputMint: PublicKey; + outputMint: PublicKey; + // Instruction inputs + amountInBN: BN; // exact-in amount (SELL) + amountOutBN: BN; // exact-out amount (BUY) + minimumAmountOutBN: BN; // SELL slippage guard + maximumAmountInBN: BN; // BUY slippage guard + // Human-readable + amountIn: number; + amountOut: number; + minAmountOut: number; + maxAmountIn: number; + price: number; // quote token per base token, in the request's base/quote terms + priceImpactPct: number; +} + +function toUi(raw: BN, decimals: number): number { + return new Decimal(raw.toString()).div(new Decimal(10).pow(decimals)).toNumber(); +} + +function toRaw(amount: number, decimals: number): BN { + return new BN(new Decimal(amount).mul(new Decimal(10).pow(decimals)).toFixed(0)); +} + +/** Resolves a token symbol or mint address to a PublicKey using the token list, then raw address. */ +async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { + const tokenInfo = await solana.getToken(tokenOrAddress); + if (tokenInfo) return new PublicKey(tokenInfo.address); + try { + return new PublicKey(tokenOrAddress); + } catch { + throw httpErrors.badRequest(sanitizeErrorMessage('Token not found: {}', tokenOrAddress)); + } +} + +/** + * Builds a swap quote for a DAMM v2 pool. `amount` is always denominated in the base token. + * SELL sells the base token (exact-in); BUY buys the base token (exact-out). + */ +export async function getRawSwapQuote( + meteoraDamm: MeteoraDamm, + poolAddress: string, + baseToken: string, + side: 'BUY' | 'SELL', + amount: number, + slippagePct: number, +): Promise { + const solana = meteoraDamm.solana; + const poolState = await meteoraDamm.getPoolState(poolAddress); + const { tokenADecimal, tokenBDecimal } = await meteoraDamm.getTokenDecimals(poolState); + + const baseMint = await resolveMint(solana, baseToken); + const baseIsTokenA = baseMint.equals(poolState.tokenAMint); + if (!baseIsTokenA && !baseMint.equals(poolState.tokenBMint)) { + throw httpErrors.badRequest(`Token ${baseMint.toBase58()} is not part of pool ${poolAddress}`); + } + const baseDecimal = baseIsTokenA ? tokenADecimal : tokenBDecimal; + const otherDecimal = baseIsTokenA ? tokenBDecimal : tokenADecimal; + const otherMint = baseIsTokenA ? poolState.tokenBMint : poolState.tokenAMint; + + const slot = await solana.connection.getSlot(); + const time = await solana.connection.getBlockTime(slot); + const currentPoint = meteoraDamm.getCurrentPoint(poolState, slot, time ?? Math.floor(Date.now() / 1000)); + + let result: RawSwapQuote; + if (side === 'SELL') { + const amountInBN = toRaw(amount, baseDecimal); + const quote = meteoraDamm.cpAmm.getQuote2({ + inputTokenMint: baseMint, + poolState, + currentPoint, + amountIn: amountInBN, + slippage: slippagePct, + swapMode: SwapMode.ExactIn, + tokenADecimal, + tokenBDecimal, + hasReferral: false, + }); + const amountOut = toUi(quote.outputAmount, otherDecimal); + const minAmountOut = toUi(quote.minimumAmountOut, otherDecimal); + result = { + poolState, + side, + swapMode: SwapMode.ExactIn, + inputMint: baseMint, + outputMint: otherMint, + amountInBN, + amountOutBN: new BN(0), + minimumAmountOutBN: quote.minimumAmountOut, + maximumAmountInBN: amountInBN, + amountIn: amount, + amountOut, + minAmountOut, + maxAmountIn: amount, + price: amount > 0 ? amountOut / amount : 0, + priceImpactPct: Number(quote.priceImpact.toString()), + }; + } else { + const amountOutBN = toRaw(amount, baseDecimal); + const quote = meteoraDamm.cpAmm.getQuote2({ + inputTokenMint: otherMint, + poolState, + currentPoint, + amountOut: amountOutBN, + slippage: slippagePct, + swapMode: SwapMode.ExactOut, + tokenADecimal, + tokenBDecimal, + hasReferral: false, + }); + const amountIn = toUi(quote.includedFeeInputAmount, otherDecimal); + const maxAmountIn = toUi(quote.maximumAmountIn, otherDecimal); + result = { + poolState, + side, + swapMode: SwapMode.ExactOut, + inputMint: otherMint, + outputMint: baseMint, + amountInBN: new BN(0), + amountOutBN, + minimumAmountOutBN: amountOutBN, + maximumAmountInBN: quote.maximumAmountIn, + amountIn, + amountOut: amount, + minAmountOut: amount, + maxAmountIn, + price: amount > 0 ? amountIn / amount : 0, + priceImpactPct: Number(quote.priceImpact.toString()), + }; + } + return result; +} + +export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: typeof MeteoraAmmQuoteSwapRequest.static; + Reply: QuoteSwapResponseType; + }>( + '/quote-swap', + { + schema: { + description: 'Get a swap quote for a Meteora DAMM v2 pool', + tags: ['/connector/meteora'], + querystring: MeteoraAmmQuoteSwapRequest, + response: { + 200: QuoteSwapResponse, + }, + }, + }, + async (request): Promise => { + try { + const { network, poolAddress, baseToken, amount, side, slippagePct } = request.query; + const meteoraDamm = await MeteoraDamm.getInstance(network); + const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; + + const quote = await getRawSwapQuote( + meteoraDamm, + poolAddress, + baseToken, + side as 'BUY' | 'SELL', + amount, + effectiveSlippage, + ); + + return { + poolAddress, + tokenIn: quote.inputMint.toBase58(), + tokenOut: quote.outputMint.toBase58(), + amountIn: quote.amountIn, + amountOut: quote.amountOut, + price: quote.price, + slippagePct: effectiveSlippage, + minAmountOut: quote.minAmountOut, + maxAmountIn: quote.maxAmountIn, + priceImpactPct: quote.priceImpactPct, + }; + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to get swap quote'); + } + }, + ); +}; + +export default quoteSwapRoute; diff --git a/src/connectors/meteora/amm-routes/removeLiquidity.ts b/src/connectors/meteora/amm-routes/removeLiquidity.ts new file mode 100644 index 0000000000..51238a3d6a --- /dev/null +++ b/src/connectors/meteora/amm-routes/removeLiquidity.ts @@ -0,0 +1,151 @@ +import { PublicKey, Transaction } from '@solana/web3.js'; +import BN from 'bn.js'; +import { Decimal } from 'decimal.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { Solana } from '../../../chains/solana/solana'; +import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraConfig } from '../meteora.config'; +import { MeteoraAmmRemoveLiquidityRequest } from '../schemas'; + +function withSlippageDown(raw: BN, slippagePct: number): BN { + return new BN(new Decimal(raw.toString()).mul(1 - slippagePct / 100).toFixed(0)); +} + +export async function removeLiquidity( + network: string, + walletAddress: string, + poolAddress: string, + percentageToRemove: number, + slippagePct: number, +): Promise { + if (percentageToRemove <= 0 || percentageToRemove > 100) { + throw httpErrors.badRequest('percentageToRemove must be between 0 and 100'); + } + + const solana = await Solana.getInstance(network); + const meteoraDamm = await MeteoraDamm.getInstance(network); + + const poolState = await meteoraDamm.getPoolState(poolAddress); + const { tokenAProgram, tokenBProgram } = meteoraDamm.getTokenPrograms(poolState); + + // DAMM v2 positions are NFTs; operate on the wallet's largest position in this pool. + const positions = await meteoraDamm.getUserPositions(poolAddress, walletAddress); + if (positions.length === 0) { + throw httpErrors.notFound(`No DAMM v2 position found for wallet in pool ${poolAddress}`); + } + const target = positions[0]; + const unlocked = target.positionState.unlockedLiquidity; + if (unlocked.isZero()) { + throw httpErrors.badRequest('Position has no unlocked liquidity to remove'); + } + + // Remove the requested fraction of unlocked liquidity (100% takes the exact unlocked amount). + const liquidityDelta = + percentageToRemove === 100 + ? unlocked + : new BN(new Decimal(unlocked.toString()).mul(percentageToRemove / 100).toFixed(0)); + + const withdrawQuote = meteoraDamm.cpAmm.getWithdrawQuote({ + liquidityDelta, + minSqrtPrice: poolState.sqrtMinPrice, + maxSqrtPrice: poolState.sqrtMaxPrice, + sqrtPrice: poolState.sqrtPrice, + collectFeeMode: poolState.collectFeeMode, + tokenAAmount: poolState.tokenAAmount, + tokenBAmount: poolState.tokenBAmount, + liquidity: poolState.liquidity, + }); + + const vestings = (await meteoraDamm.cpAmm.getAllVestingsByPosition(target.position)).map((v) => ({ + account: v.publicKey, + vestingState: v.account, + })); + + const slot = await solana.connection.getSlot(); + const time = await solana.connection.getBlockTime(slot); + const currentPoint = meteoraDamm.getCurrentPoint(poolState, slot, time ?? Math.floor(Date.now() / 1000)); + + logger.info(`Removing ${percentageToRemove}% liquidity from DAMM v2 position ${target.position.toBase58()}`); + + const transaction: Transaction = await meteoraDamm.cpAmm.removeLiquidity({ + owner: new PublicKey(walletAddress), + pool: new PublicKey(poolAddress), + position: target.position, + positionNftAccount: target.positionNftAccount, + liquidityDelta, + tokenAAmountThreshold: withSlippageDown(withdrawQuote.outAmountA, slippagePct), + tokenBAmountThreshold: withSlippageDown(withdrawQuote.outAmountB, slippagePct), + tokenAMint: poolState.tokenAMint, + tokenBMint: poolState.tokenBMint, + tokenAVault: poolState.tokenAVault, + tokenBVault: poolState.tokenBVault, + tokenAProgram, + tokenBProgram, + vestings, + currentPoint, + }); + + const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); + const txData = await solana.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + + if (txData) { + const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ + poolState.tokenAMint.toBase58(), + poolState.tokenBMint.toBase58(), + ]); + return { + signature, + status: 1, // CONFIRMED + data: { + fee: txData.meta.fee / 1e9, + baseTokenAmountRemoved: Math.abs(balanceChanges[0]), + quoteTokenAmountRemoved: Math.abs(balanceChanges[1]), + }, + }; + } + return { signature, status: 0 }; // PENDING +} + +export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: typeof MeteoraAmmRemoveLiquidityRequest.static; + Reply: RemoveLiquidityResponseType; + }>( + '/remove-liquidity', + { + schema: { + description: 'Remove liquidity from the wallet position in a Meteora DAMM v2 pool', + tags: ['/connector/meteora'], + body: MeteoraAmmRemoveLiquidityRequest, + response: { + 200: RemoveLiquidityResponse, + }, + }, + }, + async (request) => { + try { + const { network, walletAddress, poolAddress, percentageToRemove } = request.body; + return await removeLiquidity( + network, + walletAddress, + poolAddress, + percentageToRemove, + MeteoraConfig.config.slippagePct, + ); + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to remove liquidity'); + } + }, + ); +}; + +export default removeLiquidityRoute; diff --git a/src/connectors/meteora/meteora-damm.ts b/src/connectors/meteora/meteora-damm.ts new file mode 100644 index 0000000000..06df2764ec --- /dev/null +++ b/src/connectors/meteora/meteora-damm.ts @@ -0,0 +1,159 @@ +import { + CpAmm, + PoolState, + PositionState, + getPriceFromSqrtPrice, + getTokenProgram, + getTokenDecimals, + feeNumeratorToBps, + ActivationType, +} from '@meteora-ag/cp-amm-sdk'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; + +import { Solana } from '../../chains/solana/solana'; +import { PoolInfo as AmmPoolInfo } from '../../schemas/amm-schema'; +import { httpErrors } from '../../services/error-handler'; +import { logger } from '../../services/logger'; + +import { MeteoraConfig } from './meteora.config'; + +/** A resolved user position in a DAMM v2 pool (positions are NFTs, not fungible LP tokens). */ +export interface DammUserPosition { + positionNftAccount: PublicKey; + position: PublicKey; + positionState: PositionState; +} + +/** + * Meteora DAMM v2 (cp-amm) connector. + * + * DAMM v2 is a constant-product AMM whose liquidity is held in NFT positions with a + * sqrt-price accounting model (see docs/connectors/meteora-damm-v2.md for how its custom + * features map onto Gateway's AMM interface). This class is intentionally separate from the + * DLMM `Meteora` class because the two use different SDKs and account models. + */ +export class MeteoraDamm { + private static _instances: { [name: string]: MeteoraDamm }; + public solana: Solana; + public cpAmm: CpAmm; + public config: MeteoraConfig.RootConfig; + + private constructor() { + this.config = MeteoraConfig.config; + this.solana = null; + } + + /** Gets singleton instance of MeteoraDamm for a network */ + public static async getInstance(network: string): Promise { + if (!MeteoraDamm._instances) { + MeteoraDamm._instances = {}; + } + if (!MeteoraDamm._instances[network]) { + const instance = new MeteoraDamm(); + await instance.init(network); + MeteoraDamm._instances[network] = instance; + } + return MeteoraDamm._instances[network]; + } + + private async init(network: string) { + this.solana = await Solana.getInstance(network); + this.cpAmm = new CpAmm(this.solana.connection); + logger.info('Initializing Meteora DAMM v2 (cp-amm)'); + } + + /** Fetches on-chain pool state, throwing a clean 404 if the address is not a DAMM v2 pool */ + async getPoolState(poolAddress: string): Promise { + let poolPubkey: PublicKey; + try { + poolPubkey = new PublicKey(poolAddress); + } catch { + throw httpErrors.badRequest(`Invalid pool address: ${poolAddress}`); + } + try { + return await this.cpAmm.fetchPoolState(poolPubkey); + } catch (error) { + logger.debug(`Could not decode ${poolAddress} as Meteora DAMM v2 pool: ${error.message}`); + throw httpErrors.notFound(`Pool not found: ${poolAddress}`); + } + } + + /** Returns the SPL token programs (Token / Token-2022) for each side of the pool */ + getTokenPrograms(poolState: PoolState): { tokenAProgram: PublicKey; tokenBProgram: PublicKey } { + return { + tokenAProgram: getTokenProgram(poolState.tokenAFlag), + tokenBProgram: getTokenProgram(poolState.tokenBFlag), + }; + } + + /** Resolves the token decimals for both sides of the pool from on-chain mints */ + async getTokenDecimals(poolState: PoolState): Promise<{ tokenADecimal: number; tokenBDecimal: number }> { + const { tokenAProgram, tokenBProgram } = this.getTokenPrograms(poolState); + const [tokenADecimal, tokenBDecimal] = await Promise.all([ + getTokenDecimals(this.solana.connection, poolState.tokenAMint, tokenAProgram), + getTokenDecimals(this.solana.connection, poolState.tokenBMint, tokenBProgram), + ]); + return { tokenADecimal, tokenBDecimal }; + } + + /** Current base (cliff) fee of the pool as a percentage (e.g. 0.25 for 0.25%) */ + async getFeePct(poolAddress: string): Promise { + try { + const decoded = await this.cpAmm.fetchPoolFees(new PublicKey(poolAddress)); + if (decoded && (decoded as any).cliffFeeNumerator) { + return feeNumeratorToBps((decoded as any).cliffFeeNumerator) / 100; + } + } catch (error) { + logger.warn(`Could not decode fees for pool ${poolAddress}: ${error.message}`); + } + return 0; + } + + /** + * The pool's activation-clock reading used by remove-liquidity/vesting math: a slot number + * for slot-activated pools, a unix timestamp for timestamp-activated pools. + */ + getCurrentPoint(poolState: PoolState, currentSlot: number, currentTime: number): BN { + return new BN(poolState.activationType === ActivationType.Timestamp ? currentTime : currentSlot); + } + + /** Price of the pool as quote (token B) per base (token A) */ + getPrice(poolState: PoolState, tokenADecimal: number, tokenBDecimal: number): number { + return Number(getPriceFromSqrtPrice(poolState.sqrtPrice, tokenADecimal, tokenBDecimal).toString()); + } + + /** Gets AMM pool information in Gateway's standard shape (base = token A, quote = token B) */ + async getPoolInfo(poolAddress: string): Promise { + const poolState = await this.getPoolState(poolAddress); + const { tokenADecimal, tokenBDecimal } = await this.getTokenDecimals(poolState); + + const [reserveA, reserveB, feePct] = await Promise.all([ + this.solana.connection.getTokenAccountBalance(poolState.tokenAVault), + this.solana.connection.getTokenAccountBalance(poolState.tokenBVault), + this.getFeePct(poolAddress), + ]); + + return { + address: poolAddress, + baseTokenAddress: poolState.tokenAMint.toBase58(), + quoteTokenAddress: poolState.tokenBMint.toBase58(), + feePct, + price: this.getPrice(poolState, tokenADecimal, tokenBDecimal), + baseTokenAmount: reserveA.value.uiAmount ?? 0, + quoteTokenAmount: reserveB.value.uiAmount ?? 0, + }; + } + + /** + * Gets the wallet's positions in a pool, sorted by unlocked liquidity (largest first). + * A wallet can hold multiple NFT positions in the same pool; AMM-interface routes operate + * on the largest one (see the connector doc). + */ + async getUserPositions(poolAddress: string, walletAddress: string): Promise { + const pool = new PublicKey(poolAddress); + const owner = new PublicKey(walletAddress); + const positions = await this.cpAmm.getUserPositionByPool(pool, owner); + return positions.sort((a, b) => (b.positionState.unlockedLiquidity.gt(a.positionState.unlockedLiquidity) ? 1 : -1)); + } +} diff --git a/src/connectors/meteora/meteora.config.ts b/src/connectors/meteora/meteora.config.ts index 807c062ae7..9b1be719af 100644 --- a/src/connectors/meteora/meteora.config.ts +++ b/src/connectors/meteora/meteora.config.ts @@ -9,7 +9,9 @@ export namespace MeteoraConfig { export type Network = string; // Supported trading types - export const tradingTypes = ['clmm'] as const; + // - clmm: DLMM (dynamic liquidity market maker) + // - amm: DAMM v2 (constant-product cp-amm) + export const tradingTypes = ['clmm', 'amm'] as const; export interface RootConfig { // Global configuration diff --git a/src/connectors/meteora/meteora.routes.ts b/src/connectors/meteora/meteora.routes.ts index b6ee081733..5c7ca802e4 100644 --- a/src/connectors/meteora/meteora.routes.ts +++ b/src/connectors/meteora/meteora.routes.ts @@ -2,6 +2,7 @@ import sensible from '@fastify/sensible'; import type { FastifyPluginAsync } from 'fastify'; // Import routes +import { meteoraAmmRoutes } from './amm-routes'; import { meteoraClmmRoutes } from './clmm-routes'; // CLMM routes including swap endpoints @@ -19,9 +20,25 @@ const meteoraClmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { }); }; -// Export the CLMM routes +// AMM routes (DAMM v2 / cp-amm), including swap endpoints +const meteoraAmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { + await fastify.register(sensible); + + await fastify.register(async (instance) => { + instance.addHook('onRoute', (routeOptions) => { + if (routeOptions.schema && routeOptions.schema.tags) { + routeOptions.schema.tags = ['/connector/meteora']; + } + }); + + await instance.register(meteoraAmmRoutes); + }); +}; + +// Export the CLMM and AMM routes export const meteoraRoutes = { clmm: meteoraClmmRoutesWrapper, + amm: meteoraAmmRoutesWrapper, }; export default meteoraRoutes; diff --git a/src/connectors/meteora/schemas.ts b/src/connectors/meteora/schemas.ts index 26ca651eae..8326d0f646 100644 --- a/src/connectors/meteora/schemas.ts +++ b/src/connectors/meteora/schemas.ts @@ -500,3 +500,266 @@ export const MeteoraClmmQuotePositionRequest = Type.Object({ }), ), }); + +// ======================================== +// DAMM v2 (AMM) Request Schemas +// ======================================== + +const DAMM_V2_POOL_ADDRESS_EXAMPLE = 'FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv'; + +export const MeteoraAmmGetPoolInfoRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...MeteoraConfig.networks], + }), + ), + poolAddress: Type.String({ + description: 'Meteora DAMM v2 pool address', + examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], + }), +}); + +export const MeteoraAmmGetPositionInfoRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...MeteoraConfig.networks], + }), + ), + poolAddress: Type.String({ + description: 'Meteora DAMM v2 pool address', + examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], + }), + walletAddress: Type.Optional( + Type.String({ + description: 'Solana wallet address', + default: solanaChainConfig.defaultWallet, + }), + ), +}); + +export const MeteoraAmmQuoteSwapRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...MeteoraConfig.networks], + }), + ), + poolAddress: Type.String({ + description: 'Meteora DAMM v2 pool address', + examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], + }), + baseToken: Type.String({ + description: 'Token to determine swap direction', + examples: [BASE_TOKEN], + }), + quoteToken: Type.Optional( + Type.String({ + description: 'The other token in the pair (optional - resolved from the pool if omitted)', + examples: [QUOTE_TOKEN], + }), + ), + amount: Type.Number({ + description: 'Amount to swap (denominated in the base token)', + examples: [SWAP_AMOUNT], + }), + side: Type.String({ + description: 'Trade direction', + enum: ['BUY', 'SELL'], + default: 'SELL', + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: MeteoraConfig.config.slippagePct, + examples: [MeteoraConfig.config.slippagePct], + }), + ), +}); + +export const MeteoraAmmExecuteSwapRequest = Type.Object({ + walletAddress: Type.Optional( + Type.String({ + description: 'Solana wallet address that will execute the swap', + default: solanaChainConfig.defaultWallet, + }), + ), + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...MeteoraConfig.networks], + }), + ), + poolAddress: Type.String({ + description: 'Meteora DAMM v2 pool address', + examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], + }), + baseToken: Type.String({ + description: 'Base token symbol or address', + examples: [BASE_TOKEN], + }), + quoteToken: Type.Optional( + Type.String({ + description: 'The other token in the pair (optional - resolved from the pool if omitted)', + examples: [QUOTE_TOKEN], + }), + ), + amount: Type.Number({ + description: 'Amount to swap (denominated in the base token)', + examples: [SWAP_AMOUNT], + }), + side: Type.String({ + description: 'Trade direction', + enum: ['BUY', 'SELL'], + default: 'SELL', + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: MeteoraConfig.config.slippagePct, + examples: [MeteoraConfig.config.slippagePct], + }), + ), +}); + +export const MeteoraAmmQuoteLiquidityRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...MeteoraConfig.networks], + }), + ), + poolAddress: Type.String({ + description: 'Meteora DAMM v2 pool address', + examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], + }), + baseTokenAmount: Type.Number({ + description: 'Amount of base token to add', + examples: [BASE_TOKEN_AMOUNT], + }), + quoteTokenAmount: Type.Number({ + description: 'Amount of quote token to add', + examples: [QUOTE_TOKEN_AMOUNT], + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: MeteoraConfig.config.slippagePct, + examples: [MeteoraConfig.config.slippagePct], + }), + ), +}); + +export const MeteoraAmmAddLiquidityRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...MeteoraConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Solana wallet address', + default: solanaChainConfig.defaultWallet, + }), + ), + poolAddress: Type.String({ + description: 'Meteora DAMM v2 pool address', + examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], + }), + baseTokenAmount: Type.Number({ + description: 'Amount of base token to add', + examples: [BASE_TOKEN_AMOUNT], + }), + quoteTokenAmount: Type.Number({ + description: 'Amount of quote token to add', + examples: [QUOTE_TOKEN_AMOUNT], + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: MeteoraConfig.config.slippagePct, + examples: [MeteoraConfig.config.slippagePct], + }), + ), +}); + +export const MeteoraAmmRemoveLiquidityRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...MeteoraConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Solana wallet address', + default: solanaChainConfig.defaultWallet, + }), + ), + poolAddress: Type.String({ + description: 'Meteora DAMM v2 pool address', + examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], + }), + percentageToRemove: Type.Number({ + minimum: 0, + maximum: 100, + description: 'Percentage of the wallet position liquidity to remove', + examples: [100], + }), +}); + +export const MeteoraAmmCreatePoolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...MeteoraConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Solana wallet address that will create and seed the pool', + default: solanaChainConfig.defaultWallet, + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address (becomes pool token A)', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Quote token symbol or address (becomes pool token B)', + examples: [QUOTE_TOKEN], + }), + baseTokenAmount: Type.Number({ + description: 'Amount of base token to seed the pool with', + examples: [BASE_TOKEN_AMOUNT], + }), + quoteTokenAmount: Type.Number({ + description: 'Amount of quote token to seed the pool with. The base:quote ratio sets the initial price.', + examples: [QUOTE_TOKEN_AMOUNT], + }), + configAddress: Type.Optional( + Type.String({ + description: + 'DAMM v2 config account that defines the fee tier and pool parameters. ' + + 'If omitted, the lowest-fee static config is used (see the connector doc).', + }), + ), +}); diff --git a/src/schemas/amm-schema.ts b/src/schemas/amm-schema.ts index a27cfca4e3..3b56e99243 100644 --- a/src/schemas/amm-schema.ts +++ b/src/schemas/amm-schema.ts @@ -102,6 +102,44 @@ export const RemoveLiquidityResponse = Type.Object( ); export type RemoveLiquidityResponseType = Static; +// ======================================== +// Pool Creation Types +// ======================================== + +export const CreatePoolRequest = Type.Object( + { + network: Type.Optional(Type.String()), + walletAddress: Type.Optional(Type.String()), + 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.Number({ + description: 'Amount of quote token to seed the pool with. The base:quote ratio sets the initial price.', + }), + }, + { $id: 'CreatePoolRequest' }, +); +export type CreatePoolRequestType = Static; + +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' }), + + // Only included when status = CONFIRMED + data: Type.Optional( + Type.Object({ + fee: Type.Number(), + baseTokenAmountAdded: Type.Number(), + quoteTokenAmountAdded: Type.Number(), + }), + ), + }, + { $id: 'CreatePoolResponse' }, +); +export type CreatePoolResponseType = Static; + export const PositionInfoSchema = Type.Object( { poolAddress: Type.String(), diff --git a/test/connectors/meteora/amm-routes/create-pool.test.ts b/test/connectors/meteora/amm-routes/create-pool.test.ts new file mode 100644 index 0000000000..39c4fff3c0 --- /dev/null +++ b/test/connectors/meteora/amm-routes/create-pool.test.ts @@ -0,0 +1,48 @@ +import { MeteoraDamm } from '../../../../src/connectors/meteora/meteora-damm'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/connectors/meteora/meteora-damm'); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../../src/connectors/meteora/amm-routes/createPool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /create-pool (Meteora DAMM v2)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('requires an explicit configAddress (no unsafe auto-selection)', async () => { + (MeteoraDamm.getInstance as jest.Mock).mockResolvedValue({}); + + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'mainnet-beta', + walletAddress: '82Sg8kkChhY7Qb2ptR4uLGqLg7Zm3z9v9tQ6Zb6Jk4iZ', + baseToken: 'SOL', + quoteToken: 'USDC', + baseTokenAmount: 0.1, + quoteTokenAmount: 15, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/configAddress is required/); + }); +}); diff --git a/test/connectors/meteora/amm-routes/poolInfo.test.ts b/test/connectors/meteora/amm-routes/poolInfo.test.ts new file mode 100644 index 0000000000..0d8be5e891 --- /dev/null +++ b/test/connectors/meteora/amm-routes/poolInfo.test.ts @@ -0,0 +1,78 @@ +import { MeteoraDamm } from '../../../../src/connectors/meteora/meteora-damm'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/connectors/meteora/meteora-damm'); + +const mockPoolAddress = 'FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv'; +const mockSOL = 'So11111111111111111111111111111111111111112'; +const mockUSDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { poolInfoRoute } = await import('../../../../src/connectors/meteora/amm-routes/poolInfo'); + await server.register(poolInfoRoute); + return server; +}; + +describe('GET /pool-info (Meteora DAMM v2)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns AMM pool information', async () => { + (MeteoraDamm.getInstance as jest.Mock).mockResolvedValue({ + getPoolInfo: jest.fn().mockResolvedValue({ + address: mockPoolAddress, + baseTokenAddress: mockSOL, + quoteTokenAddress: mockUSDC, + feePct: 0.25, + price: 150, + baseTokenAmount: 1000, + quoteTokenAmount: 150000, + }), + }); + + const response = await server.inject({ + method: 'GET', + url: '/pool-info', + query: { network: 'mainnet-beta', poolAddress: mockPoolAddress }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toEqual({ + address: mockPoolAddress, + baseTokenAddress: mockSOL, + quoteTokenAddress: mockUSDC, + feePct: 0.25, + price: 150, + baseTokenAmount: 1000, + quoteTokenAmount: 150000, + }); + }); + + it('propagates a 404 when the pool is not a DAMM v2 pool', async () => { + (MeteoraDamm.getInstance as jest.Mock).mockResolvedValue({ + getPoolInfo: jest.fn().mockRejectedValue({ statusCode: 404, message: 'Pool not found' }), + }); + + const response = await server.inject({ + method: 'GET', + url: '/pool-info', + query: { network: 'mainnet-beta', poolAddress: mockPoolAddress }, + }); + + expect(response.statusCode).toBe(404); + }); +}); diff --git a/test/connectors/meteora/amm-routes/quote-liquidity.test.ts b/test/connectors/meteora/amm-routes/quote-liquidity.test.ts new file mode 100644 index 0000000000..640a56b0b2 --- /dev/null +++ b/test/connectors/meteora/amm-routes/quote-liquidity.test.ts @@ -0,0 +1,89 @@ +import BN from 'bn.js'; + +import { MeteoraDamm } from '../../../../src/connectors/meteora/meteora-damm'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/connectors/meteora/meteora-damm'); + +const mockPoolAddress = 'FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { quoteLiquidityRoute } = await import('../../../../src/connectors/meteora/amm-routes/quoteLiquidity'); + await server.register(quoteLiquidityRoute); + return server; +}; + +const poolState = { + sqrtMinPrice: new BN(1), + sqrtMaxPrice: new BN(2), + sqrtPrice: new BN(1), + collectFeeMode: 0, + tokenAAmount: new BN(0), + tokenBAmount: new BN(0), + liquidity: new BN(0), +}; + +describe('GET /quote-liquidity (Meteora DAMM v2)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('quotes a base-limited deposit', async () => { + // Depositing the base side yields the smaller liquidity delta, so base limits the deposit + // and the required quote (1.5 USDC) is derived from it. + const getDepositQuote = jest.fn(({ isTokenA }: { isTokenA: boolean }) => + isTokenA + ? { + liquidityDelta: new BN(100), + outputAmount: new BN(1_500_000), + actualInputAmount: new BN(0), + consumedInputAmount: new BN(0), + } + : { + liquidityDelta: new BN(200), + outputAmount: new BN(20_000_000), + actualInputAmount: new BN(0), + consumedInputAmount: new BN(0), + }, + ); + (MeteoraDamm.getInstance as jest.Mock).mockResolvedValue({ + getPoolState: jest.fn().mockResolvedValue(poolState), + getTokenDecimals: jest.fn().mockResolvedValue({ tokenADecimal: 9, tokenBDecimal: 6 }), + cpAmm: { getDepositQuote }, + }); + + const response = await server.inject({ + method: 'GET', + url: '/quote-liquidity', + query: { + network: 'mainnet-beta', + poolAddress: mockPoolAddress, + baseTokenAmount: '0.01', + quoteTokenAmount: '2', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toMatchObject({ + baseLimited: true, + baseTokenAmount: 0.01, + quoteTokenAmount: 1.5, + baseTokenAmountMax: 0.01, + quoteTokenAmountMax: 1.515, // 1.5 * (1 + 1%) + }); + }); +}); diff --git a/test/connectors/meteora/amm-routes/quote-swap.test.ts b/test/connectors/meteora/amm-routes/quote-swap.test.ts new file mode 100644 index 0000000000..8fa4385496 --- /dev/null +++ b/test/connectors/meteora/amm-routes/quote-swap.test.ts @@ -0,0 +1,156 @@ +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; + +import { MeteoraDamm } from '../../../../src/connectors/meteora/meteora-damm'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/connectors/meteora/meteora-damm'); + +const mockPoolAddress = 'FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv'; +const mockSOL = 'So11111111111111111111111111111111111111112'; +const mockUSDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { quoteSwapRoute } = await import('../../../../src/connectors/meteora/amm-routes/quoteSwap'); + await server.register(quoteSwapRoute); + return server; +}; + +// A DAMM v2 pool where token A = SOL (9 decimals), token B = USDC (6 decimals). +const buildMockInstance = (getQuote2: jest.Mock) => ({ + solana: { + getToken: jest.fn((t: string) => { + if (t === 'SOL' || t === mockSOL) return Promise.resolve({ address: mockSOL, decimals: 9, symbol: 'SOL' }); + if (t === 'USDC' || t === mockUSDC) return Promise.resolve({ address: mockUSDC, decimals: 6, symbol: 'USDC' }); + return Promise.resolve(null); + }), + connection: { + getSlot: jest.fn().mockResolvedValue(100), + getBlockTime: jest.fn().mockResolvedValue(1700000000), + }, + }, + getPoolState: jest.fn().mockResolvedValue({ + tokenAMint: new PublicKey(mockSOL), + tokenBMint: new PublicKey(mockUSDC), + }), + getTokenDecimals: jest.fn().mockResolvedValue({ tokenADecimal: 9, tokenBDecimal: 6 }), + getCurrentPoint: jest.fn().mockReturnValue(new BN(1700000000)), + cpAmm: { getQuote2 }, +}); + +describe('GET /quote-swap (Meteora DAMM v2)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('quotes a SELL (exact-in) of the base token', async () => { + // Sell 0.1 SOL -> 14.85 USDC out (min 14.7 with slippage). + const getQuote2 = jest.fn().mockReturnValue({ + outputAmount: new BN(14_850_000), + minimumAmountOut: new BN(14_700_000), + priceImpact: { toString: () => '1' }, + }); + (MeteoraDamm.getInstance as jest.Mock).mockResolvedValue(buildMockInstance(getQuote2)); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet-beta', + poolAddress: mockPoolAddress, + baseToken: 'SOL', + amount: '0.1', + side: 'SELL', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(getQuote2).toHaveBeenCalledWith(expect.objectContaining({ swapMode: 0 })); // ExactIn + expect(body).toMatchObject({ + poolAddress: mockPoolAddress, + tokenIn: mockSOL, + tokenOut: mockUSDC, + amountIn: 0.1, + amountOut: 14.85, + minAmountOut: 14.7, + maxAmountIn: 0.1, + price: 148.5, + priceImpactPct: 1, + slippagePct: 1, + }); + }); + + it('quotes a BUY (exact-out) of the base token', async () => { + // Buy 0.1 SOL for ~15 USDC in (max 15.15 with slippage). + const getQuote2 = jest.fn().mockReturnValue({ + includedFeeInputAmount: new BN(15_000_000), + maximumAmountIn: new BN(15_150_000), + priceImpact: { toString: () => '1' }, + }); + (MeteoraDamm.getInstance as jest.Mock).mockResolvedValue(buildMockInstance(getQuote2)); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet-beta', + poolAddress: mockPoolAddress, + baseToken: 'SOL', + amount: '0.1', + side: 'BUY', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(getQuote2).toHaveBeenCalledWith(expect.objectContaining({ swapMode: 2 })); // ExactOut + expect(body).toMatchObject({ + poolAddress: mockPoolAddress, + tokenIn: mockUSDC, + tokenOut: mockSOL, + amountIn: 15, + amountOut: 0.1, + maxAmountIn: 15.15, + minAmountOut: 0.1, + price: 150, + priceImpactPct: 1, + slippagePct: 1, + }); + }); + + it('rejects a base token that is not in the pool', async () => { + const getQuote2 = jest.fn(); + (MeteoraDamm.getInstance as jest.Mock).mockResolvedValue(buildMockInstance(getQuote2)); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet-beta', + poolAddress: mockPoolAddress, + baseToken: 'Es9vMFrzaCERmJfrF4H2FYD4KCon15JpFuLYc7uGZa9K', // USDT, not in pool + amount: '0.1', + side: 'SELL', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(400); + expect(getQuote2).not.toHaveBeenCalled(); + }); +}); diff --git a/test/connectors/meteora/meteora.routes.test.ts b/test/connectors/meteora/meteora.routes.test.ts index 0bfa50b9e7..0ebaf342b4 100644 --- a/test/connectors/meteora/meteora.routes.test.ts +++ b/test/connectors/meteora/meteora.routes.test.ts @@ -20,7 +20,7 @@ describe('Meteora Routes Structure', () => { }); describe('Folder Structure', () => { - it('should only have clmm-routes folder', () => { + it('should have clmm-routes (DLMM) and amm-routes (DAMM v2) folders', () => { const meteoraPath = path.join(__dirname, '../../../src/connectors/meteora'); const clmmRoutesPath = path.join(meteoraPath, 'clmm-routes'); const ammRoutesPath = path.join(meteoraPath, 'amm-routes'); @@ -28,7 +28,7 @@ describe('Meteora Routes Structure', () => { const routesPath = path.join(meteoraPath, 'routes'); expect(fs.existsSync(clmmRoutesPath)).toBe(true); - expect(fs.existsSync(ammRoutesPath)).toBe(false); + expect(fs.existsSync(ammRoutesPath)).toBe(true); expect(fs.existsSync(swapRoutesPath)).toBe(false); expect(fs.existsSync(routesPath)).toBe(false); }); @@ -43,17 +43,18 @@ describe('Meteora Routes Structure', () => { }); describe('Route Registration', () => { - it('should register Meteora CLMM routes at /connectors/meteora/clmm', async () => { - const routes = fastify.printRoutes(); + it('should register Meteora CLMM (DLMM) and AMM (DAMM v2) routes', async () => { + // commonPrefix:false prints full paths (no radix-tree prefix collapsing). + const routes = fastify.printRoutes({ commonPrefix: false }); // Check that Meteora CLMM routes are registered expect(routes).toContain('meteora/clmm/'); + // Check that Meteora AMM (DAMM v2) routes are registered + expect(routes).toContain('meteora/amm/'); + // Check that swap routes are NOT directly under /swap expect(routes).not.toContain('meteora/swap/'); - - // Check that AMM routes are NOT registered - expect(routes).not.toContain('meteora/amm/'); }); }); }); From 222ba26b0840f7ac99da0d7e04bddaa51caae4f1 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 4 Aug 2026 02:26:55 -0700 Subject: [PATCH 02/10] feat(meteora): seed DAMM v2 pools at market price by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create-pool now resolves the initial price in priority order: explicit initialPrice, then explicit quoteTokenAmount ratio, else the live market price fetched from the unified swap router (/trading/swap/quote → Jupiter on Solana). Only baseTokenAmount is required; the quote side is derived. If the base token has no market to price against, it throws a clear error asking for initialPrice/quoteTokenAmount rather than guessing. This prevents opening a pool off-market and being immediately arbitraged/sniped (as observed in live testing: a pool seeded at 0.001 was repriced to ~0.0039 by MEV bots within one slot; the router quote returns 0.00385, i.e. the correct on-market seed price). The seed price is returned as `price` in CreatePoolResponse. quoteTokenAmount is now optional; initialPrice added to the request schema. Doc updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY --- docs/connectors/meteora-damm-v2.md | 33 ++++++-- .../meteora/amm-routes/createPool.ts | 84 +++++++++++++++++-- src/connectors/meteora/schemas.ts | 23 +++-- src/schemas/amm-schema.ts | 18 +++- 4 files changed, 134 insertions(+), 24 deletions(-) diff --git a/docs/connectors/meteora-damm-v2.md b/docs/connectors/meteora-damm-v2.md index 1e151d9dcf..43ae1b3ea4 100644 --- a/docs/connectors/meteora-damm-v2.md +++ b/docs/connectors/meteora-damm-v2.md @@ -63,18 +63,33 @@ price bounds. There are hundreds of permissionless static configs, and **many ar configs whose base fee starts at ~99% and decays** (fee schedulers/rate limiters). Auto-selecting one blindly could create a pool with a punitive fee. -- **`create-pool`** therefore **requires an explicit `configAddress`**. The deposit ratio - (`baseTokenAmount : quoteTokenAmount`) sets the initial price via - `preparePoolCreationParams`; liquidity spans the full range. Token order is base → token A, - quote → token B. The new pool address is derived deterministically - (`derivePoolAddress(config, tokenAMint, tokenBMint)`) and returned. +- **`create-pool`** therefore **requires an explicit `configAddress`**. Token order is base → + token A, quote → token B. The new pool address is derived deterministically + (`derivePoolAddress(config, tokenAMint, tokenBMint)`) and returned along with the seed `price`. - Discover configs with the SDK (`cpAmm.getAllConfigs()` / `getStaticConfigs()`) or the Meteora app, and pass one whose fee/`collectFeeMode` you want. - **Proposed enhancement:** a safe auto-select that decodes each static config's base fee, keeps - only **static-fee** configs (fee scheduler `numberOfPeriod === 0`) with `collectFeeMode = BothToken`, - and picks the one matching a requested `feePct` (or the lowest). This needs per-config fee decoding - (`fetchPoolFees` / the pod-aligned fee decoders) and is intentionally left out of the basics. +#### Initial price: seed on-market to avoid getting sniped +A new pool's price is set by its seed ratio. If you open it **off-market**, arbitrage/MEV bots +rebalance it to the true price within the same slot — you effectively subsidise them. `create-pool` +resolves the seed price in this priority order: + +1. **`initialPrice`** (quote per base) if provided — `quoteTokenAmount = baseTokenAmount × initialPrice`. +2. **`quoteTokenAmount`** if provided — the `baseTokenAmount : quoteTokenAmount` ratio sets the price. +3. **Otherwise, the current market price is fetched** from the unified swap router + (`/trading/swap/quote`, i.e. the network's configured `swapProvider` — Jupiter on Solana, which + aggregates existing venues) via a SELL quote of the base token, and the pool is seeded there. + +Only `baseTokenAmount` is required; the quote side is derived. If the base token has **no existing +market** (nothing for the router to price against), the fetch fails with a clear error asking you to +pass `initialPrice` or `quoteTokenAmount` — Gateway never guesses a price. The seed price used is +returned as `price` in the response. + + **Proposed enhancement:** a safe config auto-select that decodes each static config's base fee, + keeps only **static-fee** configs (fee scheduler `numberOfPeriod === 0`) with + `collectFeeMode = BothToken`, and picks the one matching a requested `feePct` (or the lowest). This + needs per-config fee decoding (`fetchPoolFees` / the pod-aligned fee decoders) and is left out of + the basics. ### 4. Fee model: base (cliff) fee + optional dynamic fee The fee reported by `pool-info` is the pool's **base (cliff) fee** (`fetchPoolFees(...).cliffFeeNumerator` diff --git a/src/connectors/meteora/amm-routes/createPool.ts b/src/connectors/meteora/amm-routes/createPool.ts index 2c96b61360..24f6d03240 100644 --- a/src/connectors/meteora/amm-routes/createPool.ts +++ b/src/connectors/meteora/amm-routes/createPool.ts @@ -33,14 +33,46 @@ async function getMintProgram(solana: Solana, mint: PublicKey): Promise { + const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + let quote: any; + try { + quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, amount, 'SELL'); + } catch (e: any) { + throw httpErrors.badRequest( + `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + + 'Pass initialPrice or quoteTokenAmount explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw httpErrors.badRequest( + `No market route found for ${baseToken}/${quoteToken}. Pass initialPrice or quoteTokenAmount explicitly.`, + ); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} + export async function createPool( network: string, walletAddress: string, baseToken: string, quoteToken: string, baseTokenAmount: number, - quoteTokenAmount: number, + quoteTokenAmount?: number, configAddress?: string, + initialPrice?: number, ): Promise { if (!configAddress) { throw httpErrors.badRequest( @@ -88,10 +120,40 @@ export async function createPool( getTokenDecimals(solana.connection, tokenBMint, tokenBProgram), ]); + if (baseTokenAmount <= 0) { + throw httpErrors.badRequest('baseTokenAmount must be greater than zero'); + } + + // Resolve the seed price (quote per base). Priority: + // 1) explicit initialPrice + // 2) explicit quoteTokenAmount (the base:quote ratio sets the price) + // 3) live market price from the unified swap router — so the pool opens on-market and is not + // immediately arbitraged/sniped (see docs/connectors/meteora-damm-v2.md). + let seedPrice: number; + let seedSource: string; + if (initialPrice !== undefined) { + if (initialPrice <= 0) throw httpErrors.badRequest('initialPrice must be greater than zero'); + seedPrice = initialPrice; + seedSource = 'initialPrice'; + } else if (quoteTokenAmount !== undefined) { + if (quoteTokenAmount <= 0) throw httpErrors.badRequest('quoteTokenAmount must be greater than zero'); + seedPrice = quoteTokenAmount / baseTokenAmount; + seedSource = 'quoteTokenAmount ratio'; + } else { + seedPrice = await fetchMarketPrice(network, baseToken, quoteToken, baseTokenAmount); + seedSource = 'market (unified swap router)'; + } + + const effectiveQuoteAmount = baseTokenAmount * seedPrice; + logger.info( + `Seeding pool at ${seedPrice} ${quoteToken}/${baseToken} [${seedSource}]: ` + + `${baseTokenAmount} base + ${effectiveQuoteAmount} quote`, + ); + const tokenAAmount = new BN(new Decimal(baseTokenAmount).mul(new Decimal(10).pow(tokenADecimal)).toFixed(0)); - const tokenBAmount = new BN(new Decimal(quoteTokenAmount).mul(new Decimal(10).pow(tokenBDecimal)).toFixed(0)); + const tokenBAmount = new BN(new Decimal(effectiveQuoteAmount).mul(new Decimal(10).pow(tokenBDecimal)).toFixed(0)); if (tokenAAmount.isZero() || tokenBAmount.isZero()) { - throw httpErrors.badRequest('Both baseTokenAmount and quoteTokenAmount must be greater than zero'); + throw httpErrors.badRequest('Computed token amounts are zero — increase baseTokenAmount'); } // The deposit ratio sets the initial price; liquidity spans the full price range. @@ -139,6 +201,7 @@ export async function createPool( signature, status: 1, // CONFIRMED poolAddress: pool.toBase58(), + price: seedPrice, data: { fee: txData.meta.fee / 1e9, baseTokenAmountAdded: Math.abs(balanceChanges[0]), @@ -146,7 +209,7 @@ export async function createPool( }, }; } - return { signature, status: 0, poolAddress: pool.toBase58() }; // PENDING + return { signature, status: 0, poolAddress: pool.toBase58(), price: seedPrice }; // PENDING } export const createPoolRoute: FastifyPluginAsync = async (fastify) => { @@ -167,8 +230,16 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { network, walletAddress, baseToken, quoteToken, baseTokenAmount, quoteTokenAmount, configAddress } = - request.body; + const { + network, + walletAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + configAddress, + initialPrice, + } = request.body; return await createPool( network, walletAddress, @@ -177,6 +248,7 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, configAddress, + initialPrice, ); } catch (e) { logger.error(e); diff --git a/src/connectors/meteora/schemas.ts b/src/connectors/meteora/schemas.ts index 8326d0f646..061c09bd91 100644 --- a/src/connectors/meteora/schemas.ts +++ b/src/connectors/meteora/schemas.ts @@ -751,15 +751,26 @@ export const MeteoraAmmCreatePoolRequest = Type.Object({ description: 'Amount of base token to seed the pool with', examples: [BASE_TOKEN_AMOUNT], }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to seed the pool with. The base:quote ratio sets the initial price.', - examples: [QUOTE_TOKEN_AMOUNT], - }), + 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 current market price is fetched from the swap router.', + examples: [QUOTE_TOKEN_AMOUNT], + }), + ), + initialPrice: Type.Optional( + Type.Number({ + 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.', + }), + ), configAddress: Type.Optional( Type.String({ description: - 'DAMM v2 config account that defines the fee tier and pool parameters. ' + - 'If omitted, the lowest-fee static config is used (see the connector doc).', + '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.', }), ), }); diff --git a/src/schemas/amm-schema.ts b/src/schemas/amm-schema.ts index 3b56e99243..53107684f7 100644 --- a/src/schemas/amm-schema.ts +++ b/src/schemas/amm-schema.ts @@ -113,9 +113,20 @@ export const CreatePoolRequest = Type.Object( 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.Number({ - description: 'Amount of quote token to seed the pool with. The base:quote ratio sets the initial price.', - }), + 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.', + }), + ), }, { $id: 'CreatePoolRequest' }, ); @@ -126,6 +137,7 @@ 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 seeded at (quote per base)' })), // Only included when status = CONFIRMED data: Type.Optional( From 31c6849da549a3e09d2599a642ec4729d500d374 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 4 Aug 2026 20:17:54 -0700 Subject: [PATCH 03/10] feat(amm): add create-pool to Raydium (CPMM) and Uniswap (V2) + unified /trading/amm/create-pool Adds pool creation across AMM connectors, matching the Meteora DAMM v2 create-pool: - Raydium: cpmm.createPool (CP-Swap), single-tx, dynamic fee-config from the Raydium API, mainnet/devnet program selection, market-price seeding (anti-snipe). - Uniswap: V2 router.addLiquidity auto-creates the pair (fixed 0.30% fee), pair address read from the factory; ETH/WETH via addLiquidityETH; allowance checks; market-price seeding. - Unified POST /trading/amm/create-pool dispatches on connector (meteora|raydium|uniswap), carrying per-connector optionals (configAddress / feeConfigIndex+openTime / gasPrice+maxGas). All three share the generic CreatePoolResponse. Seed price priority everywhere: initialPrice -> quoteTokenAmount ratio -> live market price from the unified swap router. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY --- src/app.ts | 6 +- .../raydium/amm-routes/createPool.ts | 263 ++++++++++++ src/connectors/raydium/amm-routes/index.ts | 2 + src/connectors/raydium/schemas.ts | 59 +++ .../uniswap/amm-routes/createPool.ts | 381 ++++++++++++++++++ src/connectors/uniswap/amm-routes/index.ts | 2 + src/connectors/uniswap/schemas.ts | 61 +++ src/trading/trading-amm-routes/create-pool.ts | 176 ++++++++ src/trading/trading-amm-routes/index.ts | 1 + src/trading/trading.routes.ts | 8 + 10 files changed, 958 insertions(+), 1 deletion(-) create mode 100644 src/connectors/raydium/amm-routes/createPool.ts create mode 100644 src/connectors/uniswap/amm-routes/createPool.ts create mode 100644 src/trading/trading-amm-routes/create-pool.ts create mode 100644 src/trading/trading-amm-routes/index.ts diff --git a/src/app.ts b/src/app.ts index 9518355c2b..5ef6fe21dd 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 } from './trading/trading.routes'; +import { tradingRoutes, tradingClmmRoutes, tradingAmmRoutes } from './trading/trading.routes'; import { GATEWAY_VERSION } from './version'; import { walletRoutes } from './wallet/wallet.routes'; @@ -81,6 +81,7 @@ const swaggerOptions = { { name: '/pools', description: 'Pool management endpoints' }, { name: '/trading/swap', description: 'Unified cross-chain swap endpoints' }, { name: '/trading/clmm', description: 'Unified cross-chain CLMM (Concentrated Liquidity) endpoints' }, + { name: '/trading/amm', description: 'Unified cross-connector AMM endpoints (pool creation)' }, // Chains { @@ -296,6 +297,9 @@ const configureGatewayServer = () => { // Register trading CLMM routes (unified cross-chain concentrated liquidity) app.register(tradingClmmRoutes, { prefix: '/trading/clmm' }); + // Register trading AMM routes (unified cross-connector AMM: pool creation) + app.register(tradingAmmRoutes, { prefix: '/trading/amm' }); + // Register chain routes app.register(solanaRoutes, { prefix: '/chains/solana' }); app.register(ethereumRoutes, { prefix: '/chains/ethereum' }); diff --git a/src/connectors/raydium/amm-routes/createPool.ts b/src/connectors/raydium/amm-routes/createPool.ts new file mode 100644 index 0000000000..39a9d54a4c --- /dev/null +++ b/src/connectors/raydium/amm-routes/createPool.ts @@ -0,0 +1,263 @@ +import { + ApiCpmmConfigInfo, + CREATE_CPMM_POOL_PROGRAM, + CREATE_CPMM_POOL_FEE_ACC, + DEV_CREATE_CPMM_POOL_PROGRAM, + DEV_CREATE_CPMM_POOL_FEE_ACC, +} from '@raydium-io/raydium-sdk-v2'; +import { Static } from '@sinclair/typebox'; +import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, getMint } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { Decimal } from 'decimal.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { Solana } from '../../../chains/solana/solana'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { Raydium } from '../raydium'; +import { RaydiumAmmCreatePoolRequest } from '../schemas'; + +/** Resolves a token symbol or mint address to a PublicKey. */ +async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { + const tokenInfo = await solana.getToken(tokenOrAddress); + if (tokenInfo) return new PublicKey(tokenInfo.address); + try { + return new PublicKey(tokenOrAddress); + } catch { + throw httpErrors.badRequest(sanitizeErrorMessage('Token not found: {}', tokenOrAddress)); + } +} + +/** Detects whether a mint is owned by the Token or Token-2022 program. */ +async function getMintProgram(solana: Solana, mint: PublicKey): Promise { + const info = await solana.connection.getAccountInfo(mint); + if (!info) throw httpErrors.badRequest(`Mint account not found: ${mint.toBase58()}`); + if (info.owner.equals(TOKEN_2022_PROGRAM_ID)) return TOKEN_2022_PROGRAM_ID; + if (info.owner.equals(TOKEN_PROGRAM_ID)) return TOKEN_PROGRAM_ID; + throw httpErrors.badRequest(`Mint ${mint.toBase58()} is not an SPL token mint`); +} + +/** + * Fetches the current market price (quote per base) from the unified swap router so a new pool + * can be seeded on-market instead of at an arbitrary ratio. Seeding off-market invites arbitrage + * bots to instantly rebalance the pool. Uses a SELL quote of the base token via the network's + * configured swap provider (Jupiter aggregates existing venues); throws a clear error if no + * market route exists. + */ +async function fetchMarketPrice( + network: string, + baseToken: string, + quoteToken: string, + amount: number, +): Promise { + const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + let quote: any; + try { + quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, amount, 'SELL'); + } catch (e: any) { + throw httpErrors.badRequest( + `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + + 'Pass initialPrice or quoteTokenAmount explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw httpErrors.badRequest( + `No market route found for ${baseToken}/${quoteToken}. Pass initialPrice or quoteTokenAmount explicitly.`, + ); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} + +export async function createPool( + network: string, + walletAddress: string, + baseToken: string, + quoteToken: string, + baseTokenAmount: number, + quoteTokenAmount?: number, + initialPrice?: number, + feeConfigIndex: number = 0, + openTime: number = 0, +): Promise { + const solana = await Solana.getInstance(network); + const raydium = await Raydium.getInstance(network); + + // Set the SDK owner to the wallet's public key so getOrCreateTokenAccount targets the right + // owner. The tx is built unsigned; signing/sending is delegated to sendAndConfirmTransactionForWallet. + await raydium.setOwner(new PublicKey(walletAddress)); + + const baseMint = await resolveMint(solana, baseToken); + const quoteMint = await resolveMint(solana, quoteToken); + if (baseMint.equals(quoteMint)) { + throw httpErrors.badRequest('baseToken and quoteToken must be different'); + } + + if (baseTokenAmount <= 0) { + throw httpErrors.badRequest('baseTokenAmount must be greater than zero'); + } + + const [baseProgram, quoteProgram] = await Promise.all([ + getMintProgram(solana, baseMint), + getMintProgram(solana, quoteMint), + ]); + const [baseMintInfo, quoteMintInfo] = await Promise.all([ + getMint(solana.connection, baseMint, undefined, baseProgram), + getMint(solana.connection, quoteMint, undefined, quoteProgram), + ]); + const baseDecimals = baseMintInfo.decimals; + const quoteDecimals = quoteMintInfo.decimals; + + // Resolve the seed price (quote per base). Priority: + // 1) explicit initialPrice + // 2) explicit quoteTokenAmount (the base:quote ratio sets the price) + // 3) live market price from the unified swap router — so the pool opens on-market. + let seedPrice: number; + let seedSource: string; + if (initialPrice !== undefined) { + if (initialPrice <= 0) throw httpErrors.badRequest('initialPrice must be greater than zero'); + seedPrice = initialPrice; + seedSource = 'initialPrice'; + } else if (quoteTokenAmount !== undefined) { + if (quoteTokenAmount <= 0) throw httpErrors.badRequest('quoteTokenAmount must be greater than zero'); + seedPrice = quoteTokenAmount / baseTokenAmount; + seedSource = 'quoteTokenAmount ratio'; + } else { + seedPrice = await fetchMarketPrice(network, baseToken, quoteToken, baseTokenAmount); + seedSource = 'market (unified swap router)'; + } + + const effectiveQuoteAmount = baseTokenAmount * seedPrice; + logger.info( + `Seeding Raydium CPMM pool at ${seedPrice} ${quoteToken}/${baseToken} [${seedSource}]: ` + + `${baseTokenAmount} base + ${effectiveQuoteAmount} quote`, + ); + + const baseAmount = new BN(new Decimal(baseTokenAmount).mul(new Decimal(10).pow(baseDecimals)).toFixed(0)); + const quoteAmount = new BN(new Decimal(effectiveQuoteAmount).mul(new Decimal(10).pow(quoteDecimals)).toFixed(0)); + if (baseAmount.isZero() || quoteAmount.isZero()) { + throw httpErrors.badRequest('Computed token amounts are zero — increase baseTokenAmount'); + } + + // Fetch the CPMM fee-config list dynamically from the Raydium API — no hardcoded fallback. + let feeConfigs: ApiCpmmConfigInfo[]; + try { + feeConfigs = await raydium.raydiumSDK.api.getCpmmConfigs(); + } catch (e: any) { + throw httpErrors.internalServerError(`Could not fetch Raydium CPMM fee configs: ${e.message}`); + } + if (!feeConfigs || feeConfigs.length === 0) { + throw httpErrors.internalServerError('Raydium API returned no CPMM fee configs'); + } + if (feeConfigIndex < 0 || feeConfigIndex >= feeConfigs.length) { + throw httpErrors.badRequest(`feeConfigIndex ${feeConfigIndex} out of range (0-${feeConfigs.length - 1})`); + } + const feeConfig = feeConfigs[feeConfigIndex]; + + // Select the CPMM program + create-pool fee account for the connector's cluster, mirroring how + // raydium.ts derives mainnet vs devnet from solana.network. + const isMainnet = solana.network === 'mainnet-beta'; + const programId = isMainnet ? CREATE_CPMM_POOL_PROGRAM : DEV_CREATE_CPMM_POOL_PROGRAM; + const poolFeeAccount = isMainnet ? CREATE_CPMM_POOL_FEE_ACC : DEV_CREATE_CPMM_POOL_FEE_ACC; + + // NOTE on mint ordering: the CPMM program requires the pool's token0 < token1 (byte-compared + // mint pubkeys). raydium.cpmm.createPool sorts (mintA, mintB) internally and swaps + // (mintAAmount, mintBAmount) in lockstep, so passing base as mintA / quote as mintB — with their + // respective amounts — deposits the correct ratio regardless of on-chain ordering. The reported + // `price` (seedPrice) stays quote-per-base and balance changes are read per-mint, so both are + // independent of the SDK's internal sort. + const { transaction, extInfo } = await raydium.raydiumSDK.cpmm.createPool({ + programId, + poolFeeAccount, + mintA: { address: baseMint.toBase58(), decimals: baseDecimals, programId: baseProgram.toBase58() }, + mintB: { address: quoteMint.toBase58(), decimals: quoteDecimals, programId: quoteProgram.toBase58() }, + mintAAmount: baseAmount, + mintBAmount: quoteAmount, + startTime: new BN(openTime), + feeConfig, + associatedOnly: false, + ownerInfo: { feePayer: new PublicKey(walletAddress), useSOLBalance: true }, + txVersion: raydium.txVersion, + }); + + const poolAddress = extInfo.address.poolId.toBase58(); + 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, + }); + + if (txData) { + const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ + baseMint.toBase58(), + quoteMint.toBase58(), + ]); + return { + signature, + status: 1, // CONFIRMED + poolAddress, + price: seedPrice, + data: { + fee: txData.meta.fee / 1e9, + baseTokenAmountAdded: Math.abs(balanceChanges[0]), + quoteTokenAmountAdded: Math.abs(balanceChanges[1]), + }, + }; + } + return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING +} + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: Static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: 'Create a new Raydium CPMM (CP-Swap) pool and seed it with initial liquidity', + tags: ['/connector/raydium'], + body: RaydiumAmmCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { + network, + walletAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + initialPrice, + feeConfigIndex, + openTime, + } = request.body; + return await createPool( + network, + walletAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + initialPrice, + feeConfigIndex, + openTime, + ); + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to create pool'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/connectors/raydium/amm-routes/index.ts b/src/connectors/raydium/amm-routes/index.ts index cb9c240e9e..a9071694b3 100644 --- a/src/connectors/raydium/amm-routes/index.ts +++ b/src/connectors/raydium/amm-routes/index.ts @@ -1,6 +1,7 @@ import { FastifyPluginAsync } from 'fastify'; import { addLiquidityRoute } from './addLiquidity'; +import { createPoolRoute } from './createPool'; import { executeSwapRoute } from './executeSwap'; import { poolInfoRoute } from './poolInfo'; import { positionInfoRoute } from './positionInfo'; @@ -16,6 +17,7 @@ export const raydiumAmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(executeSwapRoute); await fastify.register(addLiquidityRoute); await fastify.register(removeLiquidityRoute); + await fastify.register(createPoolRoute); }; export default raydiumAmmRoutes; diff --git a/src/connectors/raydium/schemas.ts b/src/connectors/raydium/schemas.ts index 3466d2432a..6b966056be 100644 --- a/src/connectors/raydium/schemas.ts +++ b/src/connectors/raydium/schemas.ts @@ -248,6 +248,65 @@ export const RaydiumAmmRemoveLiquidityRequest = Type.Object({ }), }); +export const RaydiumAmmCreatePoolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...RaydiumConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Solana wallet address that will create and seed the pool', + default: solanaChainConfig.defaultWallet, + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address (becomes the pool base)', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Quote token symbol or address (becomes the pool quote)', + examples: [QUOTE_TOKEN], + }), + baseTokenAmount: Type.Number({ + description: 'Amount of base token to seed the pool with', + examples: [BASE_TOKEN_AMOUNT], + }), + 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 current market price is fetched from the swap router.', + examples: [QUOTE_TOKEN_AMOUNT], + }), + ), + initialPrice: Type.Optional( + Type.Number({ + 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.', + }), + ), + feeConfigIndex: Type.Optional( + Type.Integer({ + 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, + }), + ), + openTime: Type.Optional( + Type.Integer({ + description: 'Unix timestamp (seconds) when trading opens. Default 0 opens the pool immediately on confirmation.', + default: 0, + minimum: 0, + }), + ), +}); + // ======================================== // CLMM Request Schemas // ======================================== diff --git a/src/connectors/uniswap/amm-routes/createPool.ts b/src/connectors/uniswap/amm-routes/createPool.ts new file mode 100644 index 0000000000..3bd97db5f1 --- /dev/null +++ b/src/connectors/uniswap/amm-routes/createPool.ts @@ -0,0 +1,381 @@ +import { Contract } from '@ethersproject/contracts'; +import { Static } from '@sinclair/typebox'; +import { Percent } from '@uniswap/sdk-core'; +import { Decimal } from 'decimal.js'; +import { BigNumber, constants, utils } from 'ethers'; +import { FastifyPluginAsync } from 'fastify'; + +import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { logger } from '../../../services/logger'; +import { UniswapAmmCreatePoolRequest } from '../schemas'; +import { UniswapConfig } from '../uniswap.config'; +import { + IUniswapV2FactoryABI, + IUniswapV2PairABI, + IUniswapV2Router02ABI, + getUniswapV2FactoryAddress, + getUniswapV2RouterAddress, +} from '../uniswap.contracts'; +import { formatTokenAmount } from '../uniswap.utils'; + +// Default gas limit for AMM create-pool operations (pair creation + initial mint costs more than a plain add). +// Uniswap V2 pools all share a fixed 0.30% swap fee — there is no fee parameter to set. +const AMM_CREATE_POOL_GAS_LIMIT = 600000; + +/** + * Resolves a token symbol or address to its on-chain TokenInfo and flags whether it is the native + * ETH / WETH side. Native ETH is not an ERC20, so a V2 pair is always WETH-based; when the caller + * passes 'ETH' we resolve WETH for the pair address and decimals, and the ETH amount is supplied as + * native value via addLiquidityETH (the router wraps it) — mirroring addLiquidity.ts. + */ +async function resolveToken(ethereum: Ethereum, tokenOrAddress: string): Promise<{ token: TokenInfo; isEth: boolean }> { + const isEthInput = tokenOrAddress.toUpperCase() === 'ETH'; + const lookup = isEthInput ? 'WETH' : tokenOrAddress; + const token = await ethereum.getToken(lookup); + if (!token) { + throw new Error(`Token not found: ${tokenOrAddress}`); + } + const isEth = isEthInput || token.symbol.toUpperCase() === 'WETH'; + return { token, isEth }; +} + +/** + * Fetches the current market price (quote per base) from the unified swap router so a new pool can be + * seeded on-market instead of at an arbitrary ratio. Seeding off-market invites arbitrage bots to + * instantly rebalance the pool. Uses a SELL quote of the base token via the network's configured swap + * provider (an aggregator that does not require this not-yet-created pool); throws a clear error if no + * market route exists. + */ +async function fetchMarketPrice( + network: string, + baseToken: string, + quoteToken: string, + amount: number, +): Promise { + const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + let quote: any; + try { + quote = await getUnifiedQuoteSwap(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); + } catch (e: any) { + throw new Error( + `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + + 'Pass initialPrice or quoteTokenAmount explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw new Error( + `No market route found for ${baseToken}/${quoteToken}. Pass initialPrice or quoteTokenAmount explicitly.`, + ); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} + +export async function createPool( + network: string, + walletAddress: string, + baseToken: string, + quoteToken: string, + baseTokenAmount: number, + quoteTokenAmount?: number, + initialPrice?: number, + gasPrice?: number, + maxGas?: number, + slippagePct: number = UniswapConfig.config.slippagePct, +): Promise { + if (baseTokenAmount <= 0) { + throw new Error('baseTokenAmount must be greater than zero'); + } + + const ethereum = await Ethereum.getInstance(network); + + const wallet = await ethereum.getWallet(walletAddress); + if (!wallet) { + throw new Error('Wallet not found'); + } + + const { token: baseTokenInfo, isEth: baseIsEth } = await resolveToken(ethereum, baseToken); + const { token: quoteTokenInfo, isEth: quoteIsEth } = await resolveToken(ethereum, quoteToken); + + if (baseTokenInfo.address.toLowerCase() === quoteTokenInfo.address.toLowerCase()) { + throw new Error('baseToken and quoteToken must be different'); + } + if (baseIsEth && quoteIsEth) { + throw new Error('Only one side of the pair can be ETH/WETH'); + } + + // Resolve the seed price (quote per base). Priority: + // 1) explicit initialPrice + // 2) explicit quoteTokenAmount (the base:quote ratio sets the price) + // 3) live market price from the unified swap router — so the pool opens on-market and is not + // immediately arbitraged/sniped. + let seedPrice: number; + let seedSource: string; + if (initialPrice !== undefined) { + if (initialPrice <= 0) throw new Error('initialPrice must be greater than zero'); + seedPrice = initialPrice; + seedSource = 'initialPrice'; + } else if (quoteTokenAmount !== undefined) { + if (quoteTokenAmount <= 0) throw new Error('quoteTokenAmount must be greater than zero'); + seedPrice = quoteTokenAmount / baseTokenAmount; + seedSource = 'quoteTokenAmount ratio'; + } else { + seedPrice = await fetchMarketPrice(network, baseToken, quoteToken, baseTokenAmount); + seedSource = 'market (unified swap router)'; + } + + const effectiveQuoteAmount = baseTokenAmount * seedPrice; + logger.info( + `Seeding Uniswap V2 pool at ${seedPrice} ${quoteTokenInfo.symbol}/${baseTokenInfo.symbol} [${seedSource}]: ` + + `${baseTokenAmount} ${baseTokenInfo.symbol} + ${effectiveQuoteAmount} ${quoteTokenInfo.symbol}`, + ); + + // Convert desired amounts to raw units. Decimal keeps the quote side within its token decimals. + const rawBaseAmount = utils.parseUnits( + new Decimal(baseTokenAmount).toFixed(baseTokenInfo.decimals), + baseTokenInfo.decimals, + ); + const rawQuoteAmount = utils.parseUnits( + new Decimal(effectiveQuoteAmount).toFixed(quoteTokenInfo.decimals), + quoteTokenInfo.decimals, + ); + if (rawBaseAmount.isZero() || rawQuoteAmount.isZero()) { + throw new Error('Computed token amounts are zero — increase baseTokenAmount'); + } + + // Slippage-adjusted minimums (min amounts accepted into the pair). A brand-new pair has no reserves, + // so the router mints against exactly the desired amounts, but we still pass minimums to match the + // add-liquidity semantics and guard against a same-block seed by someone else. + const slippageTolerance = new Percent(Math.floor(slippagePct * 100), 10000); + const slippageMultiplier = new Percent(1).subtract(slippageTolerance); + const rawBaseMinAmount = rawBaseAmount + .mul(slippageMultiplier.numerator.toString()) + .div(slippageMultiplier.denominator.toString()); + const rawQuoteMinAmount = rawQuoteAmount + .mul(slippageMultiplier.numerator.toString()) + .div(slippageMultiplier.denominator.toString()); + + const factoryAddress = getUniswapV2FactoryAddress(network); + const routerAddress = getUniswapV2RouterAddress(network); + const factory = new Contract(factoryAddress, IUniswapV2FactoryABI.abi, ethereum.provider); + + // Create semantics: a V2 pair is a singleton per token pair. The factory may already have deployed + // the pair contract with ZERO reserves (an empty pair is legal and still needs seeding), so we only + // reject when the pair already holds reserves — i.e. it is a live pool, not a fresh/empty one. + const existingPair: string = await factory.getPair(baseTokenInfo.address, quoteTokenInfo.address); + if (existingPair && existingPair !== constants.AddressZero) { + const pairContract = new Contract(existingPair, IUniswapV2PairABI.abi, ethereum.provider); + const reserves = await pairContract.getReserves(); + if (!BigNumber.from(reserves[0]).isZero() || !BigNumber.from(reserves[1]).isZero()) { + throw new Error(`Pool already exists for this token pair with liquidity: ${existingPair}`); + } + logger.info(`Empty V2 pair ${existingPair} already deployed — seeding it with initial liquidity`); + } + + // Router with signer. addLiquidity auto-creates the pair via the factory if it does not yet exist. + const router = new Contract(routerAddress, IUniswapV2Router02ABI.abi, wallet); + + 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 + // ETH side is supplied as native value (the router wraps it to WETH), matching addLiquidity.ts. + const ethRawAmount = baseIsEth ? rawBaseAmount : rawQuoteAmount; + const ethRawMinAmount = baseIsEth ? rawBaseMinAmount : rawQuoteMinAmount; + + const erc20TokenInfo = baseIsEth ? quoteTokenInfo : baseTokenInfo; + const erc20RawAmount = baseIsEth ? rawQuoteAmount : rawBaseAmount; + const erc20RawMinAmount = baseIsEth ? rawQuoteMinAmount : rawBaseMinAmount; + + const tokenContract = ethereum.getContract(erc20TokenInfo.address, wallet); + const allowance = await ethereum.getERC20Allowance(tokenContract, wallet, routerAddress, erc20TokenInfo.decimals); + const currentAllowance = BigNumber.from(allowance.value); + if (currentAllowance.lt(erc20RawAmount)) { + throw new Error( + `Insufficient allowance for ${erc20TokenInfo.symbol}. Please approve at least ` + + `${formatTokenAmount(erc20RawAmount.toString(), erc20TokenInfo.decimals)} ${erc20TokenInfo.symbol} ` + + `for the Uniswap router (${routerAddress})`, + ); + } + + const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + gasOptions.value = ethRawAmount; + + tx = await router.addLiquidityETH( + erc20TokenInfo.address, + erc20RawAmount, + erc20RawMinAmount, + ethRawMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } else { + // Both sides are ERC20 → addLiquidity. Both need an allowance to the router. + const baseTokenContract = ethereum.getContract(baseTokenInfo.address, wallet); + const baseAllowance = await ethereum.getERC20Allowance( + baseTokenContract, + wallet, + routerAddress, + baseTokenInfo.decimals, + ); + const quoteTokenContract = ethereum.getContract(quoteTokenInfo.address, wallet); + const quoteAllowance = await ethereum.getERC20Allowance( + quoteTokenContract, + wallet, + routerAddress, + quoteTokenInfo.decimals, + ); + + if (BigNumber.from(baseAllowance.value).lt(rawBaseAmount)) { + throw new Error( + `Insufficient allowance for ${baseTokenInfo.symbol}. Please approve at least ` + + `${formatTokenAmount(rawBaseAmount.toString(), baseTokenInfo.decimals)} ${baseTokenInfo.symbol} ` + + `for the Uniswap router (${routerAddress})`, + ); + } + if (BigNumber.from(quoteAllowance.value).lt(rawQuoteAmount)) { + throw new Error( + `Insufficient allowance for ${quoteTokenInfo.symbol}. Please approve at least ` + + `${formatTokenAmount(rawQuoteAmount.toString(), quoteTokenInfo.decimals)} ${quoteTokenInfo.symbol} ` + + `for the Uniswap router (${routerAddress})`, + ); + } + + const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + + tx = await router.addLiquidity( + baseTokenInfo.address, + quoteTokenInfo.address, + rawBaseAmount, + rawQuoteAmount, + rawBaseMinAmount, + rawQuoteMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } + + logger.info(`Creating Uniswap V2 pool ${baseTokenInfo.symbol}/${quoteTokenInfo.symbol} via tx ${tx.hash}`); + + const receipt = await ethereum.handleTransactionExecution(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 + return { + signature: receipt.transactionHash, + status: 1, // CONFIRMED + 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 + poolAddress: pairAddress, + price: seedPrice, + }; +} + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + await fastify.register(require('@fastify/sensible')); + + fastify.post<{ + Body: Static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: 'Create a new Uniswap V2 (AMM) pool and seed it with initial liquidity (fixed 0.30% fee)', + tags: ['/connector/uniswap'], + body: UniswapAmmCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { + network, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + initialPrice, + slippagePct, + gasPrice, + maxGas, + walletAddress: requestedWalletAddress, + } = request.body; + + if (!baseToken || !quoteToken || !baseTokenAmount) { + throw fastify.httpErrors.badRequest('Missing required parameters'); + } + + let walletAddress = requestedWalletAddress; + if (!walletAddress) { + walletAddress = await Ethereum.getFirstWalletAddress(); + if (!walletAddress) { + throw fastify.httpErrors.badRequest('No wallet address provided and no wallets found.'); + } + 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, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + initialPrice, + gasPriceGwei, + maxGas, + slippagePct, + ); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + + if (e.message && e.message.includes('Insufficient allowance')) { + throw fastify.httpErrors.badRequest(e.message); + } + if (e.message && e.message.includes('already exists')) { + throw fastify.httpErrors.badRequest(e.message); + } + if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { + throw fastify.httpErrors.badRequest( + 'Insufficient ETH balance to pay for gas fees. Please add more ETH to your wallet.', + ); + } + + throw fastify.httpErrors.internalServerError('Failed to create pool'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/connectors/uniswap/amm-routes/index.ts b/src/connectors/uniswap/amm-routes/index.ts index de4869ebd9..651da9f512 100644 --- a/src/connectors/uniswap/amm-routes/index.ts +++ b/src/connectors/uniswap/amm-routes/index.ts @@ -1,6 +1,7 @@ import { FastifyPluginAsync } from 'fastify'; import addLiquidityRoute from './addLiquidity'; +import createPoolRoute from './createPool'; import executeSwapRoute from './executeSwap'; import poolInfoRoute from './poolInfo'; import positionInfoRoute from './positionInfo'; @@ -15,6 +16,7 @@ export const uniswapAmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(quoteLiquidityRoute); await fastify.register(executeSwapRoute); await fastify.register(addLiquidityRoute); + await fastify.register(createPoolRoute); await fastify.register(removeLiquidityRoute); }; diff --git a/src/connectors/uniswap/schemas.ts b/src/connectors/uniswap/schemas.ts index 3b8287db06..cf2d9f0171 100644 --- a/src/connectors/uniswap/schemas.ts +++ b/src/connectors/uniswap/schemas.ts @@ -210,6 +210,67 @@ export const UniswapAmmAddLiquidityRequest = Type.Object({ ), }); +// Uniswap AMM Create Pool Request +export const UniswapAmmCreatePoolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...UniswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will create and seed the pool', + default: ethereumChainConfig.defaultWallet, + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address (becomes the pool base)', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Quote token symbol or address (becomes the pool quote)', + examples: [QUOTE_TOKEN], + }), + 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 current market price is fetched from the unified swap router.', + }), + ), + initialPrice: Type.Optional( + Type.Number({ + 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.', + }), + ), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + 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 Remove Liquidity Request export const UniswapAmmRemoveLiquidityRequest = Type.Object({ network: Type.Optional( diff --git a/src/trading/trading-amm-routes/create-pool.ts b/src/trading/trading-amm-routes/create-pool.ts new file mode 100644 index 0000000000..aa9688e6e4 --- /dev/null +++ b/src/trading/trading-amm-routes/create-pool.ts @@ -0,0 +1,176 @@ +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 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 { 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)`, + ); + } + 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'], + }), + walletAddress: Type.String({ + description: 'Wallet address (pool creator + payer)', + default: defaultWallet, + }), + 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<{ + Body: Static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: + 'Create and seed a new AMM pool across supported connectors (Meteora DAMM v2, Raydium CPMM, Uniswap V2)', + tags: ['/trading/amm'], + body: UnifiedCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { + connector, + chainNetwork, + walletAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + initialPrice, + configAddress, + feeConfigIndex, + openTime, + gasPrice, + maxGas, + slippagePct, + } = request.body; + + const { network } = parseChainNetwork(chainNetwork); + + switch (connector) { + case 'meteora': + return await meteoraCreatePool( + network, + walletAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + configAddress, + initialPrice, + ); + + case 'raydium': + return await raydiumCreatePool( + network, + walletAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + initialPrice, + feeConfigIndex, + openTime, + ); + + case 'uniswap': + return await uniswapCreatePool( + network, + walletAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + initialPrice, + gasPrice, + maxGas, + slippagePct, + ); + + default: + 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'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/trading/trading-amm-routes/index.ts b/src/trading/trading-amm-routes/index.ts new file mode 100644 index 0000000000..59af2003a0 --- /dev/null +++ b/src/trading/trading-amm-routes/index.ts @@ -0,0 +1 @@ +export { createPoolRoute } from './create-pool'; diff --git a/src/trading/trading.routes.ts b/src/trading/trading.routes.ts index 726401fb80..5218c6d8e7 100644 --- a/src/trading/trading.routes.ts +++ b/src/trading/trading.routes.ts @@ -7,6 +7,7 @@ import { positionsOwnedRoute } from './clmm/positions-owned'; import { quotePositionRoute } from './clmm/quote-position'; import { executeSwapRoute } from './swap/execute'; import { quoteSwapRoute } from './swap/quote'; +import { createPoolRoute } from './trading-amm-routes'; import { openPositionRoute, addLiquidityRoute, @@ -40,6 +41,13 @@ export const tradingClmmRoutes: FastifyPluginAsync = async (fastify) => { fastify.register(closePositionRoute); }; +export const tradingAmmRoutes: FastifyPluginAsync = async (fastify) => { + await fastify.register(sensible); + + // Register AMM transaction routes (unified cross-connector) + fastify.register(createPoolRoute); +}; + // Legacy export for backward compatibility export const tradingRoutes = tradingSwapRoutes; From a47cb3408f4729b3c46c09ecab3569bbf6eada86 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 5 Aug 2026 05:59:58 -0700 Subject: [PATCH 04/10] feat(amm): standardize connector AMM ops and add unified /trading/amm/* for all 7 operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardizes Meteora/Raydium/Uniswap AMM route files to export a uniform network-based function per operation (getPoolInfo, getPositionInfo, quoteSwap, executeSwap, quoteLiquidity, addLiquidity, removeLiquidity), each returning the shared amm-schema type — mirroring how the unified CLMM routes consume connectors. Adds unified cross-connector routes under /trading/amm (pool-info, position-info, quote-swap, execute-swap, quote-liquidity, add-liquidity, remove-liquidity) that dispatch on `connector`, alongside the existing create-pool. Uniswap V2 wrappers derive the quote token from the pair (token0/token1); Uniswap create-pool validation errors now return 400 (not 500). Updates the unified swap router (trading/swap/{quote,execute}) to the new connector signatures. Tests: adds create-pool unit tests for Raydium (CPMM) and Uniswap (V2) and a unified-dispatch test; existing AMM + swap tests pass (54 total). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY --- .../meteora/amm-routes/addLiquidity.ts | 2 +- .../meteora/amm-routes/executeSwap.ts | 2 +- src/connectors/meteora/amm-routes/poolInfo.ts | 9 +- .../meteora/amm-routes/positionInfo.ts | 109 ++++--- .../meteora/amm-routes/quoteLiquidity.ts | 44 +-- .../meteora/amm-routes/quoteSwap.ts | 54 ++-- .../meteora/amm-routes/removeLiquidity.ts | 2 +- .../raydium/amm-routes/addLiquidity.ts | 21 +- .../raydium/amm-routes/executeSwap.ts | 22 +- src/connectors/raydium/amm-routes/poolInfo.ts | 34 +- .../raydium/amm-routes/positionInfo.ts | 113 ++++--- .../raydium/amm-routes/quoteLiquidity.ts | 9 +- .../raydium/amm-routes/quoteSwap.ts | 34 +- .../raydium/amm-routes/removeLiquidity.ts | 16 +- .../uniswap/amm-routes/addLiquidity.ts | 47 ++- .../uniswap/amm-routes/createPool.ts | 21 +- .../uniswap/amm-routes/executeSwap.ts | 19 +- src/connectors/uniswap/amm-routes/poolInfo.ts | 101 +++--- .../uniswap/amm-routes/poolTokens.ts | 49 +++ .../uniswap/amm-routes/positionInfo.ts | 62 ++++ .../uniswap/amm-routes/quoteLiquidity.ts | 32 ++ .../uniswap/amm-routes/quoteSwap.ts | 13 +- .../uniswap/amm-routes/removeLiquidity.ts | 294 ++++++++---------- src/trading/swap/execute.ts | 13 +- src/trading/swap/quote.ts | 4 +- .../trading-amm-routes/add-liquidity.ts | 87 ++++++ src/trading/trading-amm-routes/common.ts | 25 ++ .../trading-amm-routes/execute-swap.ts | 68 ++++ src/trading/trading-amm-routes/index.ts | 7 + src/trading/trading-amm-routes/pool-info.ts | 61 ++++ .../trading-amm-routes/position-info.ts | 62 ++++ .../trading-amm-routes/quote-liquidity.ts | 64 ++++ src/trading/trading-amm-routes/quote-swap.ts | 66 ++++ .../trading-amm-routes/remove-liquidity.ts | 64 ++++ src/trading/trading.routes.ts | 20 +- .../raydium/amm-routes/create-pool.test.ts | 58 ++++ .../uniswap/amm-routes/create-pool.test.ts | 60 ++++ .../trading-amm-routes/create-pool.test.ts | 39 +++ 38 files changed, 1353 insertions(+), 454 deletions(-) create mode 100644 src/connectors/uniswap/amm-routes/poolTokens.ts create mode 100644 src/trading/trading-amm-routes/add-liquidity.ts create mode 100644 src/trading/trading-amm-routes/common.ts create mode 100644 src/trading/trading-amm-routes/execute-swap.ts create mode 100644 src/trading/trading-amm-routes/pool-info.ts create mode 100644 src/trading/trading-amm-routes/position-info.ts create mode 100644 src/trading/trading-amm-routes/quote-liquidity.ts create mode 100644 src/trading/trading-amm-routes/quote-swap.ts create mode 100644 src/trading/trading-amm-routes/remove-liquidity.ts create mode 100644 test/connectors/raydium/amm-routes/create-pool.test.ts create mode 100644 test/connectors/uniswap/amm-routes/create-pool.test.ts create mode 100644 test/trading/trading-amm-routes/create-pool.test.ts diff --git a/src/connectors/meteora/amm-routes/addLiquidity.ts b/src/connectors/meteora/amm-routes/addLiquidity.ts index b2a5f94baf..2c379f2c68 100644 --- a/src/connectors/meteora/amm-routes/addLiquidity.ts +++ b/src/connectors/meteora/amm-routes/addLiquidity.ts @@ -17,7 +17,7 @@ export async function addLiquidity( poolAddress: string, baseTokenAmount: number, quoteTokenAmount: number, - slippagePct: number, + slippagePct: number = MeteoraConfig.config.slippagePct, ): Promise { const solana = await Solana.getInstance(network); const meteoraDamm = await MeteoraDamm.getInstance(network); diff --git a/src/connectors/meteora/amm-routes/executeSwap.ts b/src/connectors/meteora/amm-routes/executeSwap.ts index 07a199b0c8..8e12fe4031 100644 --- a/src/connectors/meteora/amm-routes/executeSwap.ts +++ b/src/connectors/meteora/amm-routes/executeSwap.ts @@ -19,7 +19,7 @@ export async function executeSwap( baseToken: string, side: 'BUY' | 'SELL', amount: number, - slippagePct: number, + slippagePct: number = MeteoraConfig.config.slippagePct, ): Promise { const solana = await Solana.getInstance(network); const meteoraDamm = await MeteoraDamm.getInstance(network); diff --git a/src/connectors/meteora/amm-routes/poolInfo.ts b/src/connectors/meteora/amm-routes/poolInfo.ts index 3bcb8dfe7b..b03e3b2885 100644 --- a/src/connectors/meteora/amm-routes/poolInfo.ts +++ b/src/connectors/meteora/amm-routes/poolInfo.ts @@ -5,6 +5,12 @@ import { logger } from '../../../services/logger'; import { MeteoraDamm } from '../meteora-damm'; import { MeteoraAmmGetPoolInfoRequest } from '../schemas'; +/** Standard AMM pool-info entry point (network-based) — consumed by the unified /trading/amm dispatcher. */ +export async function getPoolInfo(network: string, poolAddress: string): Promise { + const meteoraDamm = await MeteoraDamm.getInstance(network); + return await meteoraDamm.getPoolInfo(poolAddress); +} + export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: GetPoolInfoRequestType; @@ -24,8 +30,7 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { async (request): Promise => { try { const { poolAddress, network } = request.query; - const meteoraDamm = await MeteoraDamm.getInstance(network); - return await meteoraDamm.getPoolInfo(poolAddress); + return await getPoolInfo(network, poolAddress); } catch (e) { logger.error(e); if (e.statusCode) throw e; diff --git a/src/connectors/meteora/amm-routes/positionInfo.ts b/src/connectors/meteora/amm-routes/positionInfo.ts index 9889838ad7..97949371bf 100644 --- a/src/connectors/meteora/amm-routes/positionInfo.ts +++ b/src/connectors/meteora/amm-routes/positionInfo.ts @@ -5,10 +5,69 @@ import { Decimal } from 'decimal.js'; import { FastifyPluginAsync } from 'fastify'; import { GetPositionInfoRequestType, PositionInfo, PositionInfoSchema } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { MeteoraDamm } from '../meteora-damm'; import { MeteoraAmmGetPositionInfoRequest } from '../schemas'; +/** + * Standard AMM position-info entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. DAMM v2 positions are NFTs; amounts are summed across the wallet's positions in the pool. + */ +export async function getPositionInfo( + network: string, + poolAddress: string, + walletAddress: string, +): Promise { + try { + new PublicKey(walletAddress); + } catch { + throw httpErrors.badRequest('Invalid wallet address'); + } + + const meteoraDamm = await MeteoraDamm.getInstance(network); + const poolState = await meteoraDamm.getPoolState(poolAddress); + const { tokenADecimal, tokenBDecimal } = await meteoraDamm.getTokenDecimals(poolState); + const price = meteoraDamm.getPrice(poolState, tokenADecimal, tokenBDecimal); + + const positions = await meteoraDamm.getUserPositions(poolAddress, walletAddress); + + let totalLiquidity = new BN(0); + let baseRaw = new BN(0); + let quoteRaw = new BN(0); + for (const { positionState } of positions) { + const liquidity = positionState.unlockedLiquidity; + if (liquidity.isZero()) continue; + totalLiquidity = totalLiquidity.add(liquidity); + const wq = meteoraDamm.cpAmm.getWithdrawQuote({ + liquidityDelta: liquidity, + minSqrtPrice: poolState.sqrtMinPrice, + maxSqrtPrice: poolState.sqrtMaxPrice, + sqrtPrice: poolState.sqrtPrice, + collectFeeMode: poolState.collectFeeMode, + tokenAAmount: poolState.tokenAAmount, + tokenBAmount: poolState.tokenBAmount, + liquidity: poolState.liquidity, + }); + baseRaw = baseRaw.add(wq.outAmountA); + quoteRaw = quoteRaw.add(wq.outAmountB); + } + + const toUi = (raw: BN, decimals: number) => new Decimal(raw.toString()).div(new Decimal(10).pow(decimals)).toNumber(); + + return { + poolAddress, + walletAddress, + baseTokenAddress: poolState.tokenAMint.toBase58(), + quoteTokenAddress: poolState.tokenBMint.toBase58(), + // DAMM v2 has no fungible LP token; report the aggregate position liquidity (Q64 → decimal). + lpTokenAmount: Number(q64ToDecimal(totalLiquidity).toString()), + baseTokenAmount: toUi(baseRaw, tokenADecimal), + quoteTokenAmount: toUi(quoteRaw, tokenBDecimal), + price, + }; +} + export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: GetPositionInfoRequestType; @@ -30,55 +89,7 @@ export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { async (request): Promise => { try { const { poolAddress, walletAddress, network } = request.query; - - try { - new PublicKey(walletAddress); - } catch { - throw fastify.httpErrors.badRequest('Invalid wallet address'); - } - - const meteoraDamm = await MeteoraDamm.getInstance(network); - const poolState = await meteoraDamm.getPoolState(poolAddress); - const { tokenADecimal, tokenBDecimal } = await meteoraDamm.getTokenDecimals(poolState); - const price = meteoraDamm.getPrice(poolState, tokenADecimal, tokenBDecimal); - - const positions = await meteoraDamm.getUserPositions(poolAddress, walletAddress); - - let totalLiquidity = new BN(0); - let baseRaw = new BN(0); - let quoteRaw = new BN(0); - for (const { positionState } of positions) { - const liquidity = positionState.unlockedLiquidity; - if (liquidity.isZero()) continue; - totalLiquidity = totalLiquidity.add(liquidity); - const wq = meteoraDamm.cpAmm.getWithdrawQuote({ - liquidityDelta: liquidity, - minSqrtPrice: poolState.sqrtMinPrice, - maxSqrtPrice: poolState.sqrtMaxPrice, - sqrtPrice: poolState.sqrtPrice, - collectFeeMode: poolState.collectFeeMode, - tokenAAmount: poolState.tokenAAmount, - tokenBAmount: poolState.tokenBAmount, - liquidity: poolState.liquidity, - }); - baseRaw = baseRaw.add(wq.outAmountA); - quoteRaw = quoteRaw.add(wq.outAmountB); - } - - const toUi = (raw: BN, decimals: number) => - new Decimal(raw.toString()).div(new Decimal(10).pow(decimals)).toNumber(); - - return { - poolAddress, - walletAddress, - baseTokenAddress: poolState.tokenAMint.toBase58(), - quoteTokenAddress: poolState.tokenBMint.toBase58(), - // DAMM v2 has no fungible LP token; report the aggregate position liquidity (Q64 → decimal). - lpTokenAmount: Number(q64ToDecimal(totalLiquidity).toString()), - baseTokenAmount: toUi(baseRaw, tokenADecimal), - quoteTokenAmount: toUi(quoteRaw, tokenBDecimal), - price, - }; + return await getPositionInfo(network, poolAddress, walletAddress); } catch (e) { logger.error(e); if (e.statusCode) throw e; diff --git a/src/connectors/meteora/amm-routes/quoteLiquidity.ts b/src/connectors/meteora/amm-routes/quoteLiquidity.ts index e2aa263f80..78544d13e0 100644 --- a/src/connectors/meteora/amm-routes/quoteLiquidity.ts +++ b/src/connectors/meteora/amm-routes/quoteLiquidity.ts @@ -105,6 +105,30 @@ export async function getLiquidityQuote( }; } +/** + * Standard AMM quote-liquidity entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. Wraps getLiquidityQuote and shapes it into the shared QuoteLiquidityResponse. + */ +export async function quoteLiquidity( + network: string, + poolAddress: string, + baseTokenAmount: number, + quoteTokenAmount: number, + slippagePct?: number, +): Promise { + const meteoraDamm = await MeteoraDamm.getInstance(network); + const poolState = await meteoraDamm.getPoolState(poolAddress); + const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; + const quote = await getLiquidityQuote(meteoraDamm, poolState, baseTokenAmount, quoteTokenAmount, effectiveSlippage); + return { + baseLimited: quote.baseLimited, + baseTokenAmount: quote.baseTokenAmount, + quoteTokenAmount: quote.quoteTokenAmount, + baseTokenAmountMax: quote.baseTokenAmountMax, + quoteTokenAmountMax: quote.quoteTokenAmountMax, + }; +} + export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: QuoteLiquidityRequestType; @@ -124,25 +148,7 @@ export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { async (request): Promise => { try { const { network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.query; - const meteoraDamm = await MeteoraDamm.getInstance(network); - const poolState = await meteoraDamm.getPoolState(poolAddress); - const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; - - const quote = await getLiquidityQuote( - meteoraDamm, - poolState, - baseTokenAmount, - quoteTokenAmount, - effectiveSlippage, - ); - - return { - baseLimited: quote.baseLimited, - baseTokenAmount: quote.baseTokenAmount, - quoteTokenAmount: quote.quoteTokenAmount, - baseTokenAmountMax: quote.baseTokenAmountMax, - quoteTokenAmountMax: quote.quoteTokenAmountMax, - }; + return await quoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); } catch (e) { logger.error(e); if (e.statusCode) throw e; diff --git a/src/connectors/meteora/amm-routes/quoteSwap.ts b/src/connectors/meteora/amm-routes/quoteSwap.ts index 58aead9a0b..02def03bb2 100644 --- a/src/connectors/meteora/amm-routes/quoteSwap.ts +++ b/src/connectors/meteora/amm-routes/quoteSwap.ts @@ -154,6 +154,35 @@ export async function getRawSwapQuote( return result; } +/** + * Standard AMM quote-swap entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. Wraps getRawSwapQuote and shapes it into the shared QuoteSwapResponse. + */ +export async function quoteSwap( + network: string, + poolAddress: string, + baseToken: string, + side: 'BUY' | 'SELL', + amount: number, + slippagePct?: number, +): Promise { + const meteoraDamm = await MeteoraDamm.getInstance(network); + const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; + const quote = await getRawSwapQuote(meteoraDamm, poolAddress, baseToken, side, amount, effectiveSlippage); + return { + poolAddress, + tokenIn: quote.inputMint.toBase58(), + tokenOut: quote.outputMint.toBase58(), + amountIn: quote.amountIn, + amountOut: quote.amountOut, + price: quote.price, + slippagePct: effectiveSlippage, + minAmountOut: quote.minAmountOut, + maxAmountIn: quote.maxAmountIn, + priceImpactPct: quote.priceImpactPct, + }; +} + export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: typeof MeteoraAmmQuoteSwapRequest.static; @@ -173,30 +202,7 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { async (request): Promise => { try { const { network, poolAddress, baseToken, amount, side, slippagePct } = request.query; - const meteoraDamm = await MeteoraDamm.getInstance(network); - const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; - - const quote = await getRawSwapQuote( - meteoraDamm, - poolAddress, - baseToken, - side as 'BUY' | 'SELL', - amount, - effectiveSlippage, - ); - - return { - poolAddress, - tokenIn: quote.inputMint.toBase58(), - tokenOut: quote.outputMint.toBase58(), - amountIn: quote.amountIn, - amountOut: quote.amountOut, - price: quote.price, - slippagePct: effectiveSlippage, - minAmountOut: quote.minAmountOut, - maxAmountIn: quote.maxAmountIn, - priceImpactPct: quote.priceImpactPct, - }; + return await quoteSwap(network, poolAddress, baseToken, side as 'BUY' | 'SELL', amount, slippagePct); } catch (e) { logger.error(e); if (e.statusCode) throw e; diff --git a/src/connectors/meteora/amm-routes/removeLiquidity.ts b/src/connectors/meteora/amm-routes/removeLiquidity.ts index 51238a3d6a..00dfa069a2 100644 --- a/src/connectors/meteora/amm-routes/removeLiquidity.ts +++ b/src/connectors/meteora/amm-routes/removeLiquidity.ts @@ -20,7 +20,7 @@ export async function removeLiquidity( walletAddress: string, poolAddress: string, percentageToRemove: number, - slippagePct: number, + slippagePct: number = MeteoraConfig.config.slippagePct, ): Promise { if (percentageToRemove <= 0 || percentageToRemove > 100) { throw httpErrors.badRequest('percentageToRemove must be between 0 and 100'); diff --git a/src/connectors/raydium/amm-routes/addLiquidity.ts b/src/connectors/raydium/amm-routes/addLiquidity.ts index d7077999ff..33ed4a8213 100644 --- a/src/connectors/raydium/amm-routes/addLiquidity.ts +++ b/src/connectors/raydium/amm-routes/addLiquidity.ts @@ -11,7 +11,7 @@ import { Static } from '@sinclair/typebox'; import { VersionedTransaction, Transaction, PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; import { @@ -19,6 +19,7 @@ import { AddLiquidityResponseType, QuoteLiquidityResponseType, } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; @@ -104,8 +105,7 @@ async function createAddLiquidityTransaction( throw new Error(`Unsupported pool type: ${ammPoolInfo.poolType}`); } -async function addLiquidity( - _fastify: FastifyInstance, +export async function addLiquidity( network: string, walletAddress: string, poolAddress: string, @@ -123,18 +123,17 @@ async function addLiquidity( const ammPoolInfo = await raydium.getAmmPoolInfo(poolAddress); if (!ammPoolInfo) { - throw _fastify.httpErrors.notFound(`Pool not found for address: ${poolAddress}`); + throw httpErrors.notFound(`Pool not found for address: ${poolAddress}`); } // Get pool info and keys since they're no longer in quoteLiquidity response const poolResponse = await raydium.getPoolfromAPI(poolAddress); if (!poolResponse) { - throw _fastify.httpErrors.notFound(`Pool not found for address: ${poolAddress}`); + throw httpErrors.notFound(`Pool not found for address: ${poolAddress}`); } const [poolInfo, poolKeys] = poolResponse; const quoteResponse = (await quoteLiquidity( - _fastify, network, poolAddress, baseTokenAmount, @@ -245,15 +244,7 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { try { const { network, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.body; - return await addLiquidity( - fastify, - network, - walletAddress, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); + return await addLiquidity(network, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); } catch (e) { logger.error(e); if (e.statusCode) throw e; diff --git a/src/connectors/raydium/amm-routes/executeSwap.ts b/src/connectors/raydium/amm-routes/executeSwap.ts index 90fdd36af5..273e56f1c4 100644 --- a/src/connectors/raydium/amm-routes/executeSwap.ts +++ b/src/connectors/raydium/amm-routes/executeSwap.ts @@ -16,11 +16,10 @@ import { getRawSwapQuote } from './quoteSwap'; export async function executeSwap( network: string, walletAddress: string, + poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', - poolAddress: string, + amount: number, slippagePct: number = RaydiumConfig.config.slippagePct, ): Promise { const solana = await Solana.getInstance(network); @@ -37,6 +36,18 @@ export async function executeSwap( throw httpErrors.notFound(sanitizeErrorMessage('Pool not found: {}', poolAddress)); } + // Derive the counter ("quote") token from the pool given the requested base token. + const baseTokenInfo = await solana.getToken(baseToken); + const resolvedBaseAddress = baseTokenInfo ? baseTokenInfo.address : baseToken; + let quoteToken: string; + if (resolvedBaseAddress === poolInfo.baseTokenAddress) { + quoteToken = poolInfo.quoteTokenAddress; + } else if (resolvedBaseAddress === poolInfo.quoteTokenAddress) { + quoteToken = poolInfo.baseTokenAddress; + } else { + throw httpErrors.badRequest(`Base token ${baseToken} is not in pool ${poolAddress}`); + } + // Use configured slippage if not provided const effectiveSlippage = slippagePct; @@ -244,11 +255,10 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { return await executeSwap( networkToUse, walletAddress, + poolAddressToUse, baseToken, - quoteToken, - amount, side as 'BUY' | 'SELL', - poolAddressToUse, + amount, slippagePct, ); } catch (e) { diff --git a/src/connectors/raydium/amm-routes/poolInfo.ts b/src/connectors/raydium/amm-routes/poolInfo.ts index 08b42c82de..23669b496c 100644 --- a/src/connectors/raydium/amm-routes/poolInfo.ts +++ b/src/connectors/raydium/amm-routes/poolInfo.ts @@ -1,10 +1,26 @@ import { FastifyPluginAsync } from 'fastify'; import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { RaydiumAmmGetPoolInfoRequest } from '../schemas'; +/** + * Standardized network-first pool-info fetcher for the Raydium AMM/CPMM connector. + * Imported by the unified /trading/amm dispatcher and by the Fastify route below. + */ +export async function getPoolInfo(network: string, poolAddress: string): Promise { + const raydium = await Raydium.getInstance(network); + + const poolInfo = await raydium.getAmmPoolInfo(poolAddress); + if (!poolInfo) throw httpErrors.notFound('Pool not found'); + + // Return only the fields defined in the schema + const { poolType, ...basePoolInfo } = poolInfo; + return basePoolInfo; +} + export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: GetPoolInfoRequestType; @@ -23,21 +39,15 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { }, async (request): Promise => { try { - const { poolAddress } = request.query; - const network = request.query.network; - - const raydium = await Raydium.getInstance(network); - - const poolInfo = await raydium.getAmmPoolInfo(poolAddress); - if (!poolInfo) throw fastify.httpErrors.notFound('Pool not found'); - - // Return only the fields defined in the schema - const { poolType, ...basePoolInfo } = poolInfo; - return basePoolInfo; + const { poolAddress, network } = request.query; + return await getPoolInfo(network, poolAddress); } catch (e) { logger.error(e); - throw fastify.httpErrors.internalServerError('Failed to fetch pool info'); + if (e.statusCode) throw e; + throw httpErrors.internalServerError('Failed to fetch pool info'); } }, ); }; + +export default poolInfoRoute; diff --git a/src/connectors/raydium/amm-routes/positionInfo.ts b/src/connectors/raydium/amm-routes/positionInfo.ts index 1755148037..1b7de916fd 100644 --- a/src/connectors/raydium/amm-routes/positionInfo.ts +++ b/src/connectors/raydium/amm-routes/positionInfo.ts @@ -5,6 +5,7 @@ import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; import { PositionInfo, PositionInfoSchema, GetPositionInfoRequestType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { RaydiumAmmGetPositionInfoRequest } from '../schemas'; @@ -70,6 +71,66 @@ async function calculateLpAmount( }; } +/** + * Standardized network-first position-info fetcher for the Raydium AMM/CPMM connector. + * Imported by the unified /trading/amm dispatcher and by the Fastify route below. + */ +export async function getPositionInfo( + network: string, + poolAddress: string, + walletAddress: string, +): Promise { + // Validate wallet address + try { + new PublicKey(walletAddress); + } catch (error) { + throw httpErrors.badRequest('Invalid wallet address'); + } + + const raydium = await Raydium.getInstance(network); + const solana = await Solana.getInstance(network); + + // Prepare wallet and check if it's hardware + const { wallet, isHardwareWallet } = await raydium.prepareWallet(walletAddress); + + // Get wallet public key + const walletPublicKey = isHardwareWallet ? (wallet as PublicKey) : (wallet as any).publicKey; + + // Validate pool address + try { + new PublicKey(poolAddress); + } catch (error) { + throw httpErrors.badRequest('Invalid pool address'); + } + + // Get pool info + const ammPoolInfo = await raydium.getAmmPoolInfo(poolAddress); + const [poolInfo, poolKeys] = await raydium.getPoolfromAPI(poolAddress); + if (!poolInfo) { + throw httpErrors.notFound('Pool not found'); + } + + // Calculate LP token amount and token amounts + const { lpTokenAmount, baseTokenAmount, quoteTokenAmount } = await calculateLpAmount( + solana, + walletPublicKey, + ammPoolInfo, + poolInfo, + poolAddress, + ); + + return { + poolAddress, + walletAddress, + baseTokenAddress: ammPoolInfo.baseTokenAddress, + quoteTokenAddress: ammPoolInfo.quoteTokenAddress, + lpTokenAmount: lpTokenAmount, + baseTokenAmount, + quoteTokenAmount, + price: poolInfo.price, + }; +} + export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: GetPositionInfoRequestType; @@ -91,59 +152,11 @@ export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { const { poolAddress, walletAddress } = request.query; const network = request.query.network; - // Validate wallet address - try { - new PublicKey(walletAddress); - } catch (error) { - throw fastify.httpErrors.badRequest('Invalid wallet address'); - } - - const raydium = await Raydium.getInstance(network); - const solana = await Solana.getInstance(network); - - // Prepare wallet and check if it's hardware - const { wallet, isHardwareWallet } = await raydium.prepareWallet(walletAddress); - - // Get wallet public key - const walletPublicKey = isHardwareWallet ? (wallet as PublicKey) : (wallet as any).publicKey; - - // Validate pool address - try { - new PublicKey(poolAddress); - } catch (error) { - throw fastify.httpErrors.badRequest('Invalid pool address'); - } - - // Get pool info - const ammPoolInfo = await raydium.getAmmPoolInfo(poolAddress); - const [poolInfo, poolKeys] = await raydium.getPoolfromAPI(poolAddress); - if (!poolInfo) { - throw fastify.httpErrors.notFound('Pool not found'); - } - - // Calculate LP token amount and token amounts - const { lpTokenAmount, baseTokenAmount, quoteTokenAmount } = await calculateLpAmount( - solana, - walletPublicKey, - ammPoolInfo, - poolInfo, - poolAddress, - ); - - return { - poolAddress, - walletAddress, - baseTokenAddress: ammPoolInfo.baseTokenAddress, - quoteTokenAddress: ammPoolInfo.quoteTokenAddress, - lpTokenAmount: lpTokenAmount, - baseTokenAmount, - quoteTokenAmount, - price: poolInfo.price, - }; + return await getPositionInfo(network, poolAddress, walletAddress); } catch (e) { logger.error(e); if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to fetch position info'); + throw httpErrors.internalServerError('Failed to fetch position info'); } }, ); diff --git a/src/connectors/raydium/amm-routes/quoteLiquidity.ts b/src/connectors/raydium/amm-routes/quoteLiquidity.ts index 8dd5848b68..50f886ee07 100644 --- a/src/connectors/raydium/amm-routes/quoteLiquidity.ts +++ b/src/connectors/raydium/amm-routes/quoteLiquidity.ts @@ -5,7 +5,7 @@ import { TokenAmount, } from '@raydium-io/raydium-sdk-v2'; import BN from 'bn.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; import { @@ -54,11 +54,10 @@ function parseCpmmResult(result: CpmmComputePairResult, tokenDecimals: number) { } export async function quoteLiquidity( - _fastify: FastifyInstance, network: string, poolAddress: string, - baseTokenAmount?: number, - quoteTokenAmount?: number, + baseTokenAmount: number, + quoteTokenAmount: number, slippagePct: number = RaydiumConfig.config.slippagePct, ): Promise { try { @@ -235,7 +234,7 @@ export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { try { const { network = 'mainnet-beta', poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.query; - return await quoteLiquidity(fastify, network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); + return await quoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); } catch (e) { logger.error(e); if (e.statusCode) throw e; diff --git a/src/connectors/raydium/amm-routes/quoteSwap.ts b/src/connectors/raydium/amm-routes/quoteSwap.ts index d45b471ad9..bd56d36f6a 100644 --- a/src/connectors/raydium/amm-routes/quoteSwap.ts +++ b/src/connectors/raydium/amm-routes/quoteSwap.ts @@ -544,13 +544,12 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { poolAddressToUse = pool.address; } - const result = await formatSwapQuote( + const result = await quoteSwap( networkToUse, poolAddressToUse, baseToken, - quoteToken, - amount, side as 'BUY' | 'SELL', + amount, slippagePct, ); @@ -581,15 +580,38 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { export default quoteSwapRoute; -// Export quoteSwap wrapper for chain-level routes +/** + * Standardized network-first swap quote for the Raydium AMM/CPMM connector. + * `amount` is denominated in the base token; the counter ("quote") token is derived from the pool. + * Imported by the unified /trading/amm dispatcher and by the Fastify route above. + */ export async function quoteSwap( network: string, poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', + amount: number, slippagePct: number = RaydiumConfig.config.slippagePct, ): Promise { + const raydium = await Raydium.getInstance(network); + const solana = await Solana.getInstance(network); + + const ammPoolInfo = await raydium.getAmmPoolInfo(poolAddress); + if (!ammPoolInfo) { + throw httpErrors.notFound(sanitizeErrorMessage('Pool not found: {}', poolAddress)); + } + + // Derive the counter ("quote") token from the pool given the requested base token. + const baseTokenInfo = await solana.getToken(baseToken); + const resolvedBaseAddress = baseTokenInfo ? baseTokenInfo.address : baseToken; + let quoteToken: string; + if (resolvedBaseAddress === ammPoolInfo.baseTokenAddress) { + quoteToken = ammPoolInfo.quoteTokenAddress; + } else if (resolvedBaseAddress === ammPoolInfo.quoteTokenAddress) { + quoteToken = ammPoolInfo.baseTokenAddress; + } else { + throw httpErrors.badRequest(`Base token ${baseToken} is not in pool ${poolAddress}`); + } + return await formatSwapQuote(network, poolAddress, baseToken, quoteToken, amount, side, slippagePct); } diff --git a/src/connectors/raydium/amm-routes/removeLiquidity.ts b/src/connectors/raydium/amm-routes/removeLiquidity.ts index aa1e97cd06..806801bcc3 100644 --- a/src/connectors/raydium/amm-routes/removeLiquidity.ts +++ b/src/connectors/raydium/amm-routes/removeLiquidity.ts @@ -9,10 +9,11 @@ import { Static } from '@sinclair/typebox'; import { VersionedTransaction, Transaction, PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; @@ -54,6 +55,7 @@ async function createRemoveLiquidityTransaction( poolKeys: any, lpAmount: BN, computeBudgetConfig: { units: number; microLamports: number }, + slippagePct: number = RaydiumConfig.config.slippagePct, ): Promise { if (ammPoolInfo.poolType === 'amm') { // Use a small slippage for minimum amounts (1%) @@ -72,8 +74,7 @@ async function createRemoveLiquidityTransaction( }); return response.transaction; } else if (ammPoolInfo.poolType === 'cpmm') { - // Use default slippage from config - const slippage = new Percent(Math.floor(RaydiumConfig.config.slippagePct * 100), 10000); + const slippage = new Percent(Math.floor(slippagePct * 100), 10000); const response: CPMMWithdrawLiquiditySDKResponse = await raydium.raydiumSDK.cpmm.withdrawLiquidity({ poolInfo: poolInfo as ApiV3PoolInfoStandardItemCpmm, @@ -131,12 +132,12 @@ async function calculateLpAmountToRemove( return new BN(new Decimal(lpBalance.toString()).mul(percentageToRemove / 100).toFixed(0)); } -async function removeLiquidity( - _fastify: FastifyInstance, +export async function removeLiquidity( network: string, walletAddress: string, poolAddress: string, percentageToRemove: number, + slippagePct: number = RaydiumConfig.config.slippagePct, ): Promise { const solana = await Solana.getInstance(network); const raydium = await Raydium.getInstance(network); @@ -150,7 +151,7 @@ async function removeLiquidity( const [poolInfo, poolKeys] = await raydium.getPoolfromAPI(poolAddress); if (percentageToRemove <= 0 || percentageToRemove > 100) { - throw new Error('Invalid percentageToRemove - must be between 0 and 100'); + throw httpErrors.badRequest('Invalid percentageToRemove - must be between 0 and 100'); } // Calculate LP amount to remove @@ -182,6 +183,7 @@ async function removeLiquidity( units: COMPUTE_UNITS, microLamports: priorityFeePerCU, }, + slippagePct, ); // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and @@ -248,7 +250,7 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { try { const { network, walletAddress, poolAddress, percentageToRemove } = request.body; - return await removeLiquidity(fastify, network, walletAddress, poolAddress, percentageToRemove); + return await removeLiquidity(network, walletAddress, poolAddress, percentageToRemove); } catch (e) { logger.error(e); if (e.statusCode) throw e; diff --git a/src/connectors/uniswap/amm-routes/addLiquidity.ts b/src/connectors/uniswap/amm-routes/addLiquidity.ts index b43e8e2834..62b46cdfa8 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 { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { UniswapAmmAddLiquidityRequest } from '../schemas'; import { Uniswap } from '../uniswap'; @@ -20,7 +21,7 @@ import { getUniswapAmmLiquidityQuote } from './quoteLiquidity'; // Default gas limit for AMM add liquidity operations const AMM_ADD_LIQUIDITY_GAS_LIMIT = 500000; -async function addLiquidity( +async function addLiquidityInternal( fastify: any, network: string, walletAddress: string, @@ -281,6 +282,37 @@ async function addLiquidity( }; } +/** + * Standard AMM add-liquidity entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. Base/quote tokens are derived from the pool; gasPrice/maxGas are optional EVM extras. + */ +export async function addLiquidity( + network: string, + walletAddress: string, + poolAddress: string, + 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}`); + return await addLiquidityInternal( + { httpErrors }, + network, + walletAddress, + poolAddress, + poolInfo.baseTokenAddress, + poolInfo.quoteTokenAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + gasPrice, + maxGas, + ); +} + export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { await fastify.register(require('@fastify/sensible')); const walletAddressExample = await Ethereum.getWalletAddressExample(); @@ -330,23 +362,10 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { logger.info(`Using first available wallet address: ${walletAddress}`); } - // Get pool information to determine tokens - const uniswap = await Uniswap.getInstance(networkToUse); - const poolInfo = await getUniswapPoolInfo(poolAddress, networkToUse, 'amm'); - if (!poolInfo) { - throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); - } - - const baseToken = poolInfo.baseTokenAddress; - const quoteToken = poolInfo.quoteTokenAddress; - return await addLiquidity( - fastify, networkToUse, walletAddress, poolAddress, - baseToken, - quoteToken, baseTokenAmount, quoteTokenAmount, slippagePct, diff --git a/src/connectors/uniswap/amm-routes/createPool.ts b/src/connectors/uniswap/amm-routes/createPool.ts index 3bd97db5f1..945078d9b6 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 { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { UniswapAmmCreatePoolRequest } from '../schemas'; import { UniswapConfig } from '../uniswap.config'; @@ -34,7 +35,7 @@ async function resolveToken(ethereum: Ethereum, tokenOrAddress: string): Promise const lookup = isEthInput ? 'WETH' : tokenOrAddress; const token = await ethereum.getToken(lookup); if (!token) { - throw new Error(`Token not found: ${tokenOrAddress}`); + throw httpErrors.badRequest(`Token not found: ${tokenOrAddress}`); } const isEth = isEthInput || token.symbol.toUpperCase() === 'WETH'; return { token, isEth }; @@ -58,13 +59,13 @@ async function fetchMarketPrice( try { quote = await getUnifiedQuoteSwap(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); } catch (e: any) { - throw new Error( + throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + 'Pass initialPrice or quoteTokenAmount explicitly.', ); } if (!quote || !quote.amountIn || !quote.amountOut) { - throw new Error( + throw httpErrors.badRequest( `No market route found for ${baseToken}/${quoteToken}. Pass initialPrice or quoteTokenAmount explicitly.`, ); } @@ -84,24 +85,24 @@ export async function createPool( slippagePct: number = UniswapConfig.config.slippagePct, ): Promise { if (baseTokenAmount <= 0) { - throw new Error('baseTokenAmount must be greater than zero'); + throw httpErrors.badRequest('baseTokenAmount must be greater than zero'); } const ethereum = await Ethereum.getInstance(network); const wallet = await ethereum.getWallet(walletAddress); if (!wallet) { - throw new Error('Wallet not found'); + throw httpErrors.badRequest('Wallet not found'); } const { token: baseTokenInfo, isEth: baseIsEth } = await resolveToken(ethereum, baseToken); const { token: quoteTokenInfo, isEth: quoteIsEth } = await resolveToken(ethereum, quoteToken); if (baseTokenInfo.address.toLowerCase() === quoteTokenInfo.address.toLowerCase()) { - throw new Error('baseToken and quoteToken must be different'); + throw httpErrors.badRequest('baseToken and quoteToken must be different'); } if (baseIsEth && quoteIsEth) { - throw new Error('Only one side of the pair can be ETH/WETH'); + throw httpErrors.badRequest('Only one side of the pair can be ETH/WETH'); } // Resolve the seed price (quote per base). Priority: @@ -112,11 +113,11 @@ export async function createPool( let seedPrice: number; let seedSource: string; if (initialPrice !== undefined) { - if (initialPrice <= 0) throw new Error('initialPrice must be greater than zero'); + if (initialPrice <= 0) throw httpErrors.badRequest('initialPrice must be greater than zero'); seedPrice = initialPrice; seedSource = 'initialPrice'; } else if (quoteTokenAmount !== undefined) { - if (quoteTokenAmount <= 0) throw new Error('quoteTokenAmount must be greater than zero'); + if (quoteTokenAmount <= 0) throw httpErrors.badRequest('quoteTokenAmount must be greater than zero'); seedPrice = quoteTokenAmount / baseTokenAmount; seedSource = 'quoteTokenAmount ratio'; } else { @@ -140,7 +141,7 @@ export async function createPool( quoteTokenInfo.decimals, ); if (rawBaseAmount.isZero() || rawQuoteAmount.isZero()) { - throw new Error('Computed token amounts are zero — increase baseTokenAmount'); + throw httpErrors.badRequest('Computed token amounts are zero — increase baseTokenAmount'); } // Slippage-adjusted minimums (min amounts accepted into the pair). A brand-new pair has no reserves, diff --git a/src/connectors/uniswap/amm-routes/executeSwap.ts b/src/connectors/uniswap/amm-routes/executeSwap.ts index 01fb0dfad3..d9830dd32b 100644 --- a/src/connectors/uniswap/amm-routes/executeSwap.ts +++ b/src/connectors/uniswap/amm-routes/executeSwap.ts @@ -13,6 +13,7 @@ import { UniswapConfig } from '../uniswap.config'; import { getUniswapV2RouterAddress, IUniswapV2Router02ABI } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; +import { resolveSwapPair } from './poolTokens'; import { getUniswapAmmQuote } from './quoteSwap'; // Default gas limit for AMM swap operations @@ -322,7 +323,21 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { ); }; -// Export executeSwap alias for uniform chain route imports -export { executeAmmSwap as executeSwap }; +/** + * Standard AMM execute-swap entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. The quote token is derived from the pool; `amount` is denominated in the base token. + */ +export async function executeSwap( + network: string, + walletAddress: string, + poolAddress: string, + baseToken: string, + side: 'BUY' | 'SELL', + amount: number, + slippagePct: number = UniswapConfig.config.slippagePct, +): Promise { + const { baseAddress, quoteAddress } = await resolveSwapPair(network, poolAddress, baseToken); + return await executeAmmSwap(walletAddress, network, baseAddress, quoteAddress, amount, side, slippagePct); +} export default executeSwapRoute; diff --git a/src/connectors/uniswap/amm-routes/poolInfo.ts b/src/connectors/uniswap/amm-routes/poolInfo.ts index 02270c07b1..3e6d30dec2 100644 --- a/src/connectors/uniswap/amm-routes/poolInfo.ts +++ b/src/connectors/uniswap/amm-routes/poolInfo.ts @@ -3,12 +3,61 @@ import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { UniswapAmmGetPoolInfoRequest } from '../schemas'; import { Uniswap } from '../uniswap'; import { IUniswapV2PairABI } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; +/** + * Standard AMM pool-info accessor: given a network and a Uniswap V2 pool (pair) address, returns the + * shared PoolInfo shape. V2 pairs are pool-addressed and carry a fixed 0.30% fee; token0 is treated + * as base and token1 as quote (the pair contract is the authoritative source of the token ordering). + */ +export async function getPoolInfo(network: string, poolAddress: string): Promise { + const ethereum = await Ethereum.getInstance(network); + const uniswap = await Uniswap.getInstance(network); + + // For Uniswap, read the pair contract to extract the two token addresses. + const pairContract = new Contract(poolAddress, IUniswapV2PairABI.abi, ethereum.provider); + + const token0Address = await pairContract.token0(); + const token1Address = await pairContract.token1(); + + const token0 = await uniswap.getToken(token0Address); + const token1 = await uniswap.getToken(token1Address); + + if (!token0 || !token1) { + throw httpErrors.notFound('Could not find tokens for pool'); + } + + const v2Pair = await uniswap.getV2Pool(token0, token1, poolAddress); + if (!v2Pair) { + throw httpErrors.notFound('Pool not found'); + } + + const pairToken0 = v2Pair.token0; + const pairToken1 = v2Pair.token1; + + // Since we only have poolAddress, use token0 as base and token1 as quote. + const baseTokenAmount = formatTokenAmount(v2Pair.reserve0.quotient.toString(), pairToken0.decimals); + const quoteTokenAmount = formatTokenAmount(v2Pair.reserve1.quotient.toString(), pairToken1.decimals); + + // Price is quoteToken per baseToken. + const price = quoteTokenAmount / baseTokenAmount; + + return { + address: poolAddress, + baseTokenAddress: pairToken0.address, + quoteTokenAddress: pairToken1.address, + feePct: 0.3, // Uniswap V2 fee is fixed at 0.3% + price, + baseTokenAmount, + quoteTokenAmount, + }; +} + export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: GetPoolInfoRequestType; @@ -28,55 +77,7 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { async (request): Promise => { try { const { poolAddress, network } = request.query; - - const ethereum = await Ethereum.getInstance(network); - const uniswap = await Uniswap.getInstance(network); - - // For Uniswap, we need to get the pair contract to extract token addresses - // Create a pair contract instance to read token addresses - const pairContract = new Contract(poolAddress, IUniswapV2PairABI.abi, ethereum.provider); - - // Get token addresses from the pair - const token0Address = await pairContract.token0(); - const token1Address = await pairContract.token1(); - - // Get token objects by address - const token0 = await uniswap.getToken(token0Address); - const token1 = await uniswap.getToken(token1Address); - - if (!token0 || !token1) { - throw new Error('Could not find tokens for pool'); - } - - // Get V2 pair data - const v2Pair = await uniswap.getV2Pool(token0, token1, poolAddress); - - if (!v2Pair) { - throw fastify.httpErrors.notFound('Pool not found'); - } - - // Get the tokens from the pair - const pairToken0 = v2Pair.token0; - const pairToken1 = v2Pair.token1; - - // Since we only have poolAddress, use token0 as base and token1 as quote - const actualBaseToken = pairToken0; - const actualQuoteToken = pairToken1; - const baseTokenAmount = formatTokenAmount(v2Pair.reserve0.quotient.toString(), pairToken0.decimals); - const quoteTokenAmount = formatTokenAmount(v2Pair.reserve1.quotient.toString(), pairToken1.decimals); - - // Calculate price (quoteToken per baseToken) - const price = quoteTokenAmount / baseTokenAmount; - - return { - address: poolAddress, - baseTokenAddress: actualBaseToken.address, - quoteTokenAddress: actualQuoteToken.address, - feePct: 0.3, // Uniswap V2 fee is fixed at 0.3% - price: price, - baseTokenAmount: baseTokenAmount, - quoteTokenAmount: quoteTokenAmount, - }; + return await getPoolInfo(network, poolAddress); } catch (e) { logger.error(`Error in pool-info route: ${e.message}`); if (e.stack) { @@ -85,7 +86,7 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { // Return appropriate error based on the error message if (e.statusCode) { - throw e; // Already a formatted Fastify error + throw e; // Already a formatted error carrying an HTTP status } else if (e.message && e.message.includes('invalid address')) { throw fastify.httpErrors.badRequest(`Invalid pool address`); } else if (e.message && e.message.includes('not found')) { diff --git a/src/connectors/uniswap/amm-routes/poolTokens.ts b/src/connectors/uniswap/amm-routes/poolTokens.ts new file mode 100644 index 0000000000..7bad2395fc --- /dev/null +++ b/src/connectors/uniswap/amm-routes/poolTokens.ts @@ -0,0 +1,49 @@ +import { Contract } from '@ethersproject/contracts'; + +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { httpErrors } from '../../../services/error-handler'; +import { Uniswap } from '../uniswap'; +import { IUniswapV2PairABI } from '../uniswap.contracts'; + +/** The token shape returned by Uniswap.getToken (address/decimals/symbol). */ +type UniToken = NonNullable>>; + +/** + * Reads a Uniswap V2 pair's token0/token1 and resolves them. `base` follows the pair's token0 + * orientation and `quote` its token1 — matching how pool-info / position-info report. + */ +export async function getAmmPoolTokens( + network: string, + poolAddress: string, +): Promise<{ base: UniToken; quote: UniToken }> { + const uniswap = await Uniswap.getInstance(network); + const ethereum = await Ethereum.getInstance(network); + const pair = new Contract(poolAddress, IUniswapV2PairABI.abi, ethereum.provider); + const [t0, t1] = await Promise.all([pair.token0(), pair.token1()]); + const base = await uniswap.getToken(t0); + const quote = await uniswap.getToken(t1); + if (!base || !quote) { + throw httpErrors.badRequest(`Could not resolve token information for pool ${poolAddress}`); + } + return { base, quote }; +} + +/** + * Given a caller-specified base token (symbol or address) and a pool, returns the base and the + * counter ("quote") token addresses — the quote token is whichever pool token is not the base. + * Used by swap ops where `baseToken` selects the direction. + */ +export async function resolveSwapPair( + network: string, + poolAddress: string, + baseToken: string, +): Promise<{ baseAddress: string; quoteAddress: string }> { + const { base, quote } = await getAmmPoolTokens(network, poolAddress); + const uniswap = await Uniswap.getInstance(network); + const baseObj = await uniswap.getToken(baseToken); + if (!baseObj) throw httpErrors.badRequest(`Token not found: ${baseToken}`); + const addr = baseObj.address.toLowerCase(); + if (addr === base.address.toLowerCase()) return { baseAddress: base.address, quoteAddress: quote.address }; + if (addr === quote.address.toLowerCase()) return { baseAddress: quote.address, quoteAddress: base.address }; + throw httpErrors.badRequest(`Token ${baseToken} is not part of pool ${poolAddress}`); +} diff --git a/src/connectors/uniswap/amm-routes/positionInfo.ts b/src/connectors/uniswap/amm-routes/positionInfo.ts index a2df9c5798..bebeca2b9a 100644 --- a/src/connectors/uniswap/amm-routes/positionInfo.ts +++ b/src/connectors/uniswap/amm-routes/positionInfo.ts @@ -9,11 +9,73 @@ import { PositionInfo, PositionInfoSchema, } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Uniswap } from '../uniswap'; import { IUniswapV2PairABI } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; +/** + * Standard AMM position-info entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. V2 positions are fungible LP tokens; base/quote follow the pair's token0/token1. + */ +export async function getPositionInfo( + network: string, + poolAddress: string, + walletAddress: string, +): Promise { + if (!poolAddress) throw httpErrors.badRequest('Pool address is required'); + + const uniswap = await Uniswap.getInstance(network); + const ethereum = await Ethereum.getInstance(network); + + const pairContract = new Contract(poolAddress, IUniswapV2PairABI.abi, ethereum.provider); + const lpBalance = await pairContract.balanceOf(walletAddress); + const [token0, token1] = await Promise.all([pairContract.token0(), pairContract.token1()]); + + const baseTokenObj = await uniswap.getToken(token0); + const quoteTokenObj = await uniswap.getToken(token1); + if (!baseTokenObj || !quoteTokenObj) { + throw httpErrors.badRequest('Token information not found for pool'); + } + + if (lpBalance.isZero()) { + return { + poolAddress, + walletAddress, + baseTokenAddress: baseTokenObj.address, + quoteTokenAddress: quoteTokenObj.address, + lpTokenAmount: 0, + baseTokenAmount: 0, + quoteTokenAmount: 0, + price: 0, + }; + } + + const [totalSupply, reserves] = await Promise.all([pairContract.totalSupply(), pairContract.getReserves()]); + const token0IsBase = token0.toLowerCase() === baseTokenObj.address.toLowerCase(); + const baseTokenReserve = token0IsBase ? reserves[0] : reserves[1]; + const quoteTokenReserve = token0IsBase ? reserves[1] : reserves[0]; + + const userBaseTokenAmount = baseTokenReserve.mul(lpBalance).div(totalSupply); + const userQuoteTokenAmount = quoteTokenReserve.mul(lpBalance).div(totalSupply); + + const baseTokenAmountFloat = formatTokenAmount(baseTokenReserve.toString(), baseTokenObj.decimals); + const quoteTokenAmountFloat = formatTokenAmount(quoteTokenReserve.toString(), quoteTokenObj.decimals); + const price = baseTokenAmountFloat > 0 ? quoteTokenAmountFloat / baseTokenAmountFloat : 0; + + return { + poolAddress, + walletAddress, + baseTokenAddress: baseTokenObj.address, + quoteTokenAddress: quoteTokenObj.address, + lpTokenAmount: formatTokenAmount(lpBalance.toString(), 18), + baseTokenAmount: formatTokenAmount(userBaseTokenAmount.toString(), baseTokenObj.decimals), + quoteTokenAmount: formatTokenAmount(userQuoteTokenAmount.toString(), quoteTokenObj.decimals), + price, + }; +} + export async function checkLPAllowance( ethereum: any, wallet: any, diff --git a/src/connectors/uniswap/amm-routes/quoteLiquidity.ts b/src/connectors/uniswap/amm-routes/quoteLiquidity.ts index 2e46203a04..4fe7454503 100644 --- a/src/connectors/uniswap/amm-routes/quoteLiquidity.ts +++ b/src/connectors/uniswap/amm-routes/quoteLiquidity.ts @@ -14,6 +14,8 @@ import { Uniswap } from '../uniswap'; import { IUniswapV2PairABI, getUniswapV2RouterAddress } from '../uniswap.contracts'; import { formatTokenAmount, getUniswapPoolInfo } from '../uniswap.utils'; +import { getAmmPoolTokens } from './poolTokens'; + export async function getUniswapAmmLiquidityQuote( network: string, poolAddress?: string, @@ -254,4 +256,34 @@ export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { ); }; +/** + * Standard AMM quote-liquidity entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. Base/quote follow the pair's token0/token1 orientation. + */ +export async function quoteLiquidity( + network: string, + poolAddress: string, + baseTokenAmount: number, + quoteTokenAmount: number, + slippagePct?: number, +): Promise { + const { base, quote } = await getAmmPoolTokens(network, poolAddress); + const q = await getUniswapAmmLiquidityQuote( + network, + poolAddress, + base.address, + quote.address, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + return { + baseLimited: q.baseLimited, + baseTokenAmount: q.baseTokenAmount, + quoteTokenAmount: q.quoteTokenAmount, + baseTokenAmountMax: q.baseTokenAmountMax, + quoteTokenAmountMax: q.quoteTokenAmountMax, + }; +} + export default quoteLiquidityRoute; diff --git a/src/connectors/uniswap/amm-routes/quoteSwap.ts b/src/connectors/uniswap/amm-routes/quoteSwap.ts index 8c9d637d35..7241f4f18b 100644 --- a/src/connectors/uniswap/amm-routes/quoteSwap.ts +++ b/src/connectors/uniswap/amm-routes/quoteSwap.ts @@ -16,6 +16,8 @@ import { Uniswap } from '../uniswap'; import { UniswapConfig } from '../uniswap.config'; import { formatTokenAmount, getUniswapPoolInfo } from '../uniswap.utils'; +import { resolveSwapPair } from './poolTokens'; + async function quoteAmmSwap( uniswap: Uniswap, poolAddress: string, @@ -405,15 +407,18 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { export default quoteSwapRoute; -// Export quoteSwap wrapper for chain-level routes +/** + * Standard AMM quote-swap entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. `amount` is denominated in the base token; the quote token is derived from the pool. + */ export async function quoteSwap( network: string, poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', + amount: number, slippagePct: number = UniswapConfig.config.slippagePct, ): Promise { - return await formatSwapQuote(network, poolAddress, baseToken, quoteToken, amount, side, slippagePct); + const { baseAddress, quoteAddress } = await resolveSwapPair(network, poolAddress, baseToken); + return await formatSwapQuote(network, poolAddress, baseAddress, quoteAddress, amount, side, slippagePct); } diff --git a/src/connectors/uniswap/amm-routes/removeLiquidity.ts b/src/connectors/uniswap/amm-routes/removeLiquidity.ts index 422b1c0150..cfe1f9d645 100644 --- a/src/connectors/uniswap/amm-routes/removeLiquidity.ts +++ b/src/connectors/uniswap/amm-routes/removeLiquidity.ts @@ -1,14 +1,16 @@ import { Contract } from '@ethersproject/contracts'; import { Static } from '@sinclair/typebox'; import { Percent } from '@uniswap/sdk-core'; -import { BigNumber, utils } from 'ethers'; +import { utils } from 'ethers'; import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { RemoveLiquidityResponseType, RemoveLiquidityResponse } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { UniswapAmmRemoveLiquidityRequest } from '../schemas'; import { Uniswap } from '../uniswap'; +import { UniswapConfig } from '../uniswap.config'; import { getUniswapV2RouterAddress, IUniswapV2Router02ABI, IUniswapV2PairABI } from '../uniswap.contracts'; import { formatTokenAmount, getUniswapPoolInfo } from '../uniswap.utils'; @@ -17,6 +19,125 @@ import { checkLPAllowance } from './positionInfo'; // Default gas limit for AMM remove liquidity operations const AMM_REMOVE_LIQUIDITY_GAS_LIMIT = 400000; +/** + * Standard AMM remove-liquidity entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. Removes `percentageToRemove` of the wallet's LP position; base/quote follow the pair. + */ +export async function removeLiquidity( + network: string, + walletAddress: string, + 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) { + throw httpErrors.badRequest('Percentage to remove must be between 0 and 100'); + } + + const uniswap = await Uniswap.getInstance(network); + const ethereum = await Ethereum.getInstance(network); + + const poolInfo = await getUniswapPoolInfo(poolAddress, network, 'amm'); + if (!poolInfo) throw httpErrors.notFound(`Pool not found: ${poolAddress}`); + + const baseTokenObj = await uniswap.getToken(poolInfo.baseTokenAddress); + const quoteTokenObj = await uniswap.getToken(poolInfo.quoteTokenAddress); + if (!baseTokenObj || !quoteTokenObj) throw httpErrors.badRequest('Token information not found for pool'); + + const wallet = await ethereum.getWallet(walletAddress); + if (!wallet) throw httpErrors.badRequest('Wallet not found'); + + const pairContract = new Contract(poolAddress, IUniswapV2PairABI.abi, wallet); + const lpBalance = await pairContract.balanceOf(walletAddress); + if (lpBalance.eq(0)) throw httpErrors.badRequest('No liquidity position found for this pool'); + + const [token0, token1, totalSupply, reserves] = await Promise.all([ + pairContract.token0(), + pairContract.token1(), + pairContract.totalSupply(), + pairContract.getReserves(), + ]); + + const token0IsBase = token0.toLowerCase() === baseTokenObj.address.toLowerCase(); + + const liquidityToRemove = lpBalance.mul(Math.floor(percentageToRemove * 100)).div(10000); + const baseTokenReserve = token0IsBase ? reserves[0] : reserves[1]; + const quoteTokenReserve = token0IsBase ? reserves[1] : reserves[0]; + + const expectedBaseTokenAmount = baseTokenReserve.mul(liquidityToRemove).div(totalSupply); + const expectedQuoteTokenAmount = quoteTokenReserve.mul(liquidityToRemove).div(totalSupply); + + const routerAddress = getUniswapV2RouterAddress(network); + const router = new Contract(routerAddress, IUniswapV2Router02ABI.abi, wallet); + + const slippageTolerance = new Percent(Math.floor(slippagePct * 100), 10000); + const slippageMultiplier = new Percent(1).subtract(slippageTolerance); + const baseTokenMinAmount = expectedBaseTokenAmount + .mul(slippageMultiplier.numerator.toString()) + .div(slippageMultiplier.denominator.toString()); + const quoteTokenMinAmount = expectedQuoteTokenAmount + .mul(slippageMultiplier.numerator.toString()) + .div(slippageMultiplier.denominator.toString()); + + 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); + + let tx; + if (baseTokenObj.symbol === 'WETH') { + tx = await router.removeLiquidityETH( + token0IsBase ? token1 : token0, + liquidityToRemove, + token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, + token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } else if (quoteTokenObj.symbol === 'WETH') { + tx = await router.removeLiquidityETH( + token0IsBase ? token0 : token1, + liquidityToRemove, + token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, + token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } else { + tx = await router.removeLiquidity( + token0, + token1, + liquidityToRemove, + token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, + token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } + + const receipt = await ethereum.handleTransactionExecution(tx); + 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, + data: { + fee: gasFee, + baseTokenAmountRemoved, + quoteTokenAmountRemoved, + }, + }; +} + export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { await fastify.register(require('@fastify/sensible')); const walletAddressExample = await Ethereum.getWalletAddressExample(); @@ -47,183 +168,32 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { maxGas, } = request.body; - const networkToUse = network; - - // Validate essential parameters - if (!poolAddress || !percentageToRemove) { - throw fastify.httpErrors.badRequest('Missing required parameters'); - } - - if (percentageToRemove <= 0 || percentageToRemove > 100) { - throw fastify.httpErrors.badRequest('Percentage to remove must be between 0 and 100'); - } - - // Get Uniswap and Ethereum instances - const uniswap = await Uniswap.getInstance(networkToUse); - const ethereum = await Ethereum.getInstance(networkToUse); - - // Get wallet address - either from request or first available let walletAddress = requestedWalletAddress; if (!walletAddress) { - walletAddress = await uniswap.getFirstWalletAddress(); + walletAddress = await Ethereum.getFirstWalletAddress(); if (!walletAddress) { throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet found'); } logger.info(`Using first available wallet address: ${walletAddress}`); } - // Resolve tokens - // Get pool information to determine tokens - const poolInfo = await getUniswapPoolInfo(poolAddress, networkToUse, 'amm'); - if (!poolInfo) { - throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); - } - - const baseTokenObj = await uniswap.getToken(poolInfo.baseTokenAddress); - const quoteTokenObj = await uniswap.getToken(poolInfo.quoteTokenAddress); - - if (!baseTokenObj || !quoteTokenObj) { - throw fastify.httpErrors.badRequest('Token information not found for pool'); - } - - // Get the wallet - const wallet = await ethereum.getWallet(walletAddress); - if (!wallet) { - throw fastify.httpErrors.badRequest('Wallet not found'); - } - - // Check if the user has LP tokens for this pool - const pairContract = new Contract(poolAddress, IUniswapV2PairABI.abi, wallet); - - const lpBalance = await pairContract.balanceOf(walletAddress); - if (lpBalance.eq(0)) { - throw fastify.httpErrors.badRequest(`No liquidity position found for this pool`); - } - - // Get the total supply and reserves - const [token0, token1, totalSupply, reserves] = await Promise.all([ - pairContract.token0(), - pairContract.token1(), - pairContract.totalSupply(), - pairContract.getReserves(), - ]); - - const token0IsBase = token0.toLowerCase() === baseTokenObj.address.toLowerCase(); - - // Calculate expected amounts - const liquidityToRemove = lpBalance.mul(Math.floor(percentageToRemove * 100)).div(10000); - const baseTokenReserve = token0IsBase ? reserves[0] : reserves[1]; - const quoteTokenReserve = token0IsBase ? reserves[1] : reserves[0]; - - const expectedBaseTokenAmount = baseTokenReserve.mul(liquidityToRemove).div(totalSupply); - const expectedQuoteTokenAmount = quoteTokenReserve.mul(liquidityToRemove).div(totalSupply); - - // Get the router contract with signer - const routerAddress = getUniswapV2RouterAddress(networkToUse); - const router = new Contract(routerAddress, IUniswapV2Router02ABI.abi, wallet); - - // Calculate slippage-adjusted amounts (0.5% slippage by default) - const slippageTolerance = new Percent(5, 1000); // 0.5% - const slippageMultiplier = new Percent(1).subtract(slippageTolerance); - - const baseTokenMinAmount = expectedBaseTokenAmount - .mul(slippageMultiplier.numerator.toString()) - .div(slippageMultiplier.denominator.toString()); - - const quoteTokenMinAmount = expectedQuoteTokenAmount - .mul(slippageMultiplier.numerator.toString()) - .div(slippageMultiplier.denominator.toString()); - - // Check LP token allowance - try { - await checkLPAllowance(ethereum, wallet, poolAddress, routerAddress, liquidityToRemove); - } catch (error: any) { - throw fastify.httpErrors.badRequest(error.message); - } - - // Prepare the transaction parameters - const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - - let tx; - - // Prepare gas options - // 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_REMOVE_LIQUIDITY_GAS_LIMIT); - - // Check if one of the tokens is WETH - if (baseTokenObj.symbol === 'WETH') { - // Remove liquidity WETH + Token - tx = await router.removeLiquidityETH( - token0IsBase ? token1 : token0, // The non-WETH token - liquidityToRemove, - token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, // Min amount of the token - token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, // Min amount of WETH - walletAddress, - deadline, - gasOptions, - ); - } else if (quoteTokenObj.symbol === 'WETH') { - // Remove liquidity Token + WETH - tx = await router.removeLiquidityETH( - token0IsBase ? token0 : token1, // The non-WETH token - liquidityToRemove, - token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, // Min amount of the token - token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, // Min amount of WETH - walletAddress, - deadline, - gasOptions, - ); - } else { - // Remove liquidity Token + Token - tx = await router.removeLiquidity( - token0, - token1, - liquidityToRemove, - token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, // Min amount of token0 - token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, // Min amount of token1 - walletAddress, - deadline, - gasOptions, - ); - } - - // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Format amounts for response - const baseTokenAmountRemoved = formatTokenAmount(expectedBaseTokenAmount.toString(), baseTokenObj.decimals); - - const quoteTokenAmountRemoved = formatTokenAmount(expectedQuoteTokenAmount.toString(), quoteTokenObj.decimals); - - // Calculate gas fee - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals + return await removeLiquidity( + network, + walletAddress, + poolAddress, + percentageToRemove, + undefined, + gasPrice, + maxGas, ); - - return { - signature: receipt.transactionHash, - status: receipt.status, - data: { - fee: gasFee, - baseTokenAmountRemoved, - quoteTokenAmountRemoved, - }, - }; } catch (e) { logger.error(e); - if (e.statusCode) { - throw e; - } - - // Handle insufficient funds errors + if (e.statusCode) throw e; if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { throw fastify.httpErrors.badRequest( 'Insufficient ETH balance to pay for gas fees. Please add more ETH to your wallet.', ); } - throw fastify.httpErrors.internalServerError('Failed to remove liquidity'); } }, diff --git a/src/trading/swap/execute.ts b/src/trading/swap/execute.ts index c3ca61c938..00ce8f0b39 100644 --- a/src/trading/swap/execute.ts +++ b/src/trading/swap/execute.ts @@ -173,16 +173,7 @@ async function executeSolanaSwap( } else if (providerKey === 'titan/router') { return await titanRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'raydium/amm') { - return await raydiumAmmExecuteSwap( - network, - walletAddress, - baseToken, - quoteToken, - amount, - side, - poolAddress!, - slippagePct, - ); + return await raydiumAmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'raydium/clmm') { return await raydiumClmmExecuteSwap( network, @@ -285,7 +276,7 @@ async function executeEthereumSwap( if (providerKey === 'uniswap/router') { return await uniswapRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'uniswap/amm') { - return await uniswapAmmExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); + return await uniswapAmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'uniswap/clmm') { return await uniswapClmmExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'pancakeswap/router') { diff --git a/src/trading/swap/quote.ts b/src/trading/swap/quote.ts index df15993b2a..11dd6ae170 100644 --- a/src/trading/swap/quote.ts +++ b/src/trading/swap/quote.ts @@ -157,7 +157,7 @@ async function getSolanaQuoteSwap( } else if (providerKey === 'titan/router') { return await titanRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'raydium/amm') { - return await raydiumAmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); + return await raydiumAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'raydium/clmm') { return await raydiumClmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'meteora/clmm') { @@ -223,7 +223,7 @@ async function getEthereumQuoteSwap( if (providerKey === 'uniswap/router') { return await uniswapRouterQuoteSwap(network, undefined, baseToken, quoteToken, amount, side, slippagePct || 1); } else if (providerKey === 'uniswap/amm') { - return await uniswapAmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); + return await uniswapAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'uniswap/clmm') { return await uniswapClmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'pancakeswap/router') { diff --git a/src/trading/trading-amm-routes/add-liquidity.ts b/src/trading/trading-amm-routes/add-liquidity.ts new file mode 100644 index 0000000000..a63079a154 --- /dev/null +++ b/src/trading/trading-amm-routes/add-liquidity.ts @@ -0,0 +1,87 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { addLiquidity as meteoraAddLiquidity } from '../../connectors/meteora/amm-routes/addLiquidity'; +import { addLiquidity as raydiumAddLiquidity } from '../../connectors/raydium/amm-routes/addLiquidity'; +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'; + +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', + }), + 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' }), + quoteTokenAmount: Type.Number({ description: 'Amount of quote token to add' }), + slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), +}); + +export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: Static; + Reply: AddLiquidityResponseType; + }>( + '/add-liquidity', + { + schema: { + description: 'Add liquidity to an AMM pool from any supported connector', + tags: ['/trading/amm'], + body: UnifiedAmmAddLiquidityRequest, + response: { 200: AddLiquidityResponse }, + }, + }, + async (request) => { + try { + const { connector, chainNetwork, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = + request.body; + const { network } = parseChainNetwork(chainNetwork); + switch (connector) { + case 'meteora': + return await meteoraAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + case 'raydium': + return await raydiumAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + case 'uniswap': + return await uniswapAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + } catch (e: any) { + logger.error('Failed to add AMM liquidity:', e); + if (e.statusCode) throw e; + throw httpErrors.internalServerError('Failed to add liquidity'); + } + }, + ); +}; + +export default addLiquidityRoute; diff --git a/src/trading/trading-amm-routes/common.ts b/src/trading/trading-amm-routes/common.ts new file mode 100644 index 0000000000..f7679685f2 --- /dev/null +++ b/src/trading/trading-amm-routes/common.ts @@ -0,0 +1,25 @@ +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']; + +/** 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/execute-swap.ts b/src/trading/trading-amm-routes/execute-swap.ts new file mode 100644 index 0000000000..3d6efaa811 --- /dev/null +++ b/src/trading/trading-amm-routes/execute-swap.ts @@ -0,0 +1,68 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { executeSwap as meteoraExecuteSwap } from '../../connectors/meteora/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); + 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 59af2003a0..486ebdf9e4 100644 --- a/src/trading/trading-amm-routes/index.ts +++ b/src/trading/trading-amm-routes/index.ts @@ -1 +1,8 @@ export { createPoolRoute } from './create-pool'; +export { poolInfoRoute } from './pool-info'; +export { positionInfoRoute } from './position-info'; +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 new file mode 100644 index 0000000000..a24d3d8ab0 --- /dev/null +++ b/src/trading/trading-amm-routes/pool-info.ts @@ -0,0 +1,61 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { getPoolInfo as meteoraGetPoolInfo } from '../../connectors/meteora/amm-routes/poolInfo'; +import { getPoolInfo as raydiumGetPoolInfo } from '../../connectors/raydium/amm-routes/poolInfo'; +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'; + +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', + }), + poolAddress: Type.String({ description: 'Pool contract address' }), +}); + +export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: Static; + Reply: PoolInfo; + }>( + '/pool-info', + { + schema: { + description: 'Get AMM pool information from any supported connector', + tags: ['/trading/amm'], + querystring: UnifiedAmmPoolInfoRequest, + response: { 200: PoolInfoSchema }, + }, + }, + async (request) => { + try { + const { connector, chainNetwork, poolAddress } = request.query; + const { network } = parseChainNetwork(chainNetwork); + switch (connector) { + case 'meteora': + return await meteoraGetPoolInfo(network, poolAddress); + case 'raydium': + return await raydiumGetPoolInfo(network, poolAddress); + case 'uniswap': + return await uniswapGetPoolInfo(network, poolAddress); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + } 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'); + } + }, + ); +}; + +export default poolInfoRoute; diff --git a/src/trading/trading-amm-routes/position-info.ts b/src/trading/trading-amm-routes/position-info.ts new file mode 100644 index 0000000000..26a6249811 --- /dev/null +++ b/src/trading/trading-amm-routes/position-info.ts @@ -0,0 +1,62 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { getPositionInfo as meteoraGetPositionInfo } from '../../connectors/meteora/amm-routes/positionInfo'; +import { getPositionInfo as raydiumGetPositionInfo } from '../../connectors/raydium/amm-routes/positionInfo'; +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'; + +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', + }), + poolAddress: Type.String({ description: 'Pool contract address' }), + walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), +}); + +export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: Static; + Reply: PositionInfo; + }>( + '/position-info', + { + schema: { + description: "Get a wallet's aggregated AMM liquidity in a pool from any supported connector", + tags: ['/trading/amm'], + querystring: UnifiedAmmPositionInfoRequest, + response: { 200: PositionInfoSchema }, + }, + }, + async (request) => { + try { + const { connector, chainNetwork, poolAddress, walletAddress } = request.query; + const { network } = parseChainNetwork(chainNetwork); + switch (connector) { + case 'meteora': + return await meteoraGetPositionInfo(network, poolAddress, walletAddress); + case 'raydium': + return await raydiumGetPositionInfo(network, poolAddress, walletAddress); + case 'uniswap': + return await uniswapGetPositionInfo(network, poolAddress, walletAddress); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + } 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'); + } + }, + ); +}; + +export default positionInfoRoute; diff --git a/src/trading/trading-amm-routes/quote-liquidity.ts b/src/trading/trading-amm-routes/quote-liquidity.ts new file mode 100644 index 0000000000..cb8754fb30 --- /dev/null +++ b/src/trading/trading-amm-routes/quote-liquidity.ts @@ -0,0 +1,64 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { quoteLiquidity as meteoraQuoteLiquidity } from '../../connectors/meteora/amm-routes/quoteLiquidity'; +import { quoteLiquidity as raydiumQuoteLiquidity } from '../../connectors/raydium/amm-routes/quoteLiquidity'; +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'; + +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', + }), + 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 })), +}); + +export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: Static; + Reply: QuoteLiquidityResponseType; + }>( + '/quote-liquidity', + { + schema: { + description: 'Quote amounts for adding liquidity to an AMM pool from any supported connector', + tags: ['/trading/amm'], + querystring: UnifiedAmmQuoteLiquidityRequest, + response: { 200: QuoteLiquidityResponse }, + }, + }, + async (request) => { + try { + const { connector, chainNetwork, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.query; + const { network } = parseChainNetwork(chainNetwork); + switch (connector) { + case 'meteora': + return await meteoraQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); + case 'raydium': + return await raydiumQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); + case 'uniswap': + return await uniswapQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + } catch (e: any) { + logger.error('Failed to quote AMM liquidity:', e); + if (e.statusCode) throw e; + throw httpErrors.internalServerError('Failed to quote liquidity'); + } + }, + ); +}; + +export default quoteLiquidityRoute; diff --git a/src/trading/trading-amm-routes/quote-swap.ts b/src/trading/trading-amm-routes/quote-swap.ts new file mode 100644 index 0000000000..a48ee0d081 --- /dev/null +++ b/src/trading/trading-amm-routes/quote-swap.ts @@ -0,0 +1,66 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { quoteSwap as meteoraQuoteSwap } from '../../connectors/meteora/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); + 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 new file mode 100644 index 0000000000..8b3100771d --- /dev/null +++ b/src/trading/trading-amm-routes/remove-liquidity.ts @@ -0,0 +1,64 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { removeLiquidity as meteoraRemoveLiquidity } from '../../connectors/meteora/amm-routes/removeLiquidity'; +import { removeLiquidity as raydiumRemoveLiquidity } from '../../connectors/raydium/amm-routes/removeLiquidity'; +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'; + +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', + }), + walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), + poolAddress: Type.String({ description: 'Pool contract address' }), + percentageToRemove: Type.Number({ minimum: 0, maximum: 100, description: 'Percentage of liquidity to remove' }), + slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), +}); + +export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: Static; + Reply: RemoveLiquidityResponseType; + }>( + '/remove-liquidity', + { + schema: { + description: 'Remove liquidity from an AMM pool from any supported connector', + tags: ['/trading/amm'], + body: UnifiedAmmRemoveLiquidityRequest, + response: { 200: RemoveLiquidityResponse }, + }, + }, + async (request) => { + try { + const { connector, chainNetwork, walletAddress, poolAddress, percentageToRemove, slippagePct } = request.body; + const { network } = parseChainNetwork(chainNetwork); + switch (connector) { + case 'meteora': + return await meteoraRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); + case 'raydium': + return await raydiumRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); + case 'uniswap': + return await uniswapRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + } catch (e: any) { + logger.error('Failed to remove AMM liquidity:', e); + if (e.statusCode) throw e; + throw httpErrors.internalServerError('Failed to remove liquidity'); + } + }, + ); +}; + +export default removeLiquidityRoute; diff --git a/src/trading/trading.routes.ts b/src/trading/trading.routes.ts index 5218c6d8e7..82e2cd7cf2 100644 --- a/src/trading/trading.routes.ts +++ b/src/trading/trading.routes.ts @@ -7,7 +7,16 @@ import { positionsOwnedRoute } from './clmm/positions-owned'; import { quotePositionRoute } from './clmm/quote-position'; import { executeSwapRoute } from './swap/execute'; import { quoteSwapRoute } from './swap/quote'; -import { createPoolRoute } from './trading-amm-routes'; +import { + createPoolRoute, + poolInfoRoute as ammPoolInfoRoute, + positionInfoRoute as ammPositionInfoRoute, + quoteSwapRoute as ammQuoteSwapRoute, + executeSwapRoute as ammExecuteSwapRoute, + quoteLiquidityRoute as ammQuoteLiquidityRoute, + addLiquidityRoute as ammAddLiquidityRoute, + removeLiquidityRoute as ammRemoveLiquidityRoute, +} from './trading-amm-routes'; import { openPositionRoute, addLiquidityRoute, @@ -44,7 +53,16 @@ export const tradingClmmRoutes: FastifyPluginAsync = async (fastify) => { export const tradingAmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(sensible); + // Register AMM query routes (unified cross-connector) + fastify.register(ammPoolInfoRoute); + fastify.register(ammPositionInfoRoute); + 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); }; diff --git a/test/connectors/raydium/amm-routes/create-pool.test.ts b/test/connectors/raydium/amm-routes/create-pool.test.ts new file mode 100644 index 0000000000..32f9bc5314 --- /dev/null +++ b/test/connectors/raydium/amm-routes/create-pool.test.ts @@ -0,0 +1,58 @@ +import { Solana } from '../../../../src/chains/solana/solana'; +import { Raydium } from '../../../../src/connectors/raydium/raydium'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/raydium/raydium'); + +const mockSOL = { symbol: 'SOL', address: 'So11111111111111111111111111111111111111112', decimals: 9 }; +const mockWallet = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../../src/connectors/raydium/amm-routes/createPool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /create-pool (Raydium CPMM)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + (Solana.getInstance as jest.Mock).mockResolvedValue({ + // Resolve both tokens to the same mint so the base != quote guard fires. + getToken: jest.fn(() => Promise.resolve(mockSOL)), + }); + (Raydium.getInstance as jest.Mock).mockResolvedValue({ + setOwner: jest.fn().mockResolvedValue(undefined), + }); + }); + + it('rejects when baseToken and quoteToken resolve to the same mint', async () => { + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'mainnet-beta', + walletAddress: mockWallet, + baseToken: 'SOL', + quoteToken: 'SOL', + baseTokenAmount: 1, + quoteTokenAmount: 150, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/must be different/); + }); +}); diff --git a/test/connectors/uniswap/amm-routes/create-pool.test.ts b/test/connectors/uniswap/amm-routes/create-pool.test.ts new file mode 100644 index 0000000000..4a42019083 --- /dev/null +++ b/test/connectors/uniswap/amm-routes/create-pool.test.ts @@ -0,0 +1,60 @@ +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'); + +const mockWETH = { symbol: 'WETH', address: '0x4200000000000000000000000000000000000006', decimals: 18 }; +const mockWallet = '0x0000000000000000000000000000000000000001'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../../src/connectors/uniswap/amm-routes/createPool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /create-pool (Uniswap V2)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + (Ethereum.getInstance as jest.Mock).mockResolvedValue({ + getWallet: jest.fn().mockResolvedValue({ address: mockWallet }), + // Both sides resolve to WETH so the "only one side can be ETH/WETH" guard fires. + getToken: jest.fn(() => Promise.resolve(mockWETH)), + }); + (Uniswap.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn(() => Promise.resolve(mockWETH)), + }); + }); + + it('rejects an invalid pair where both sides resolve to the same (WETH) token', async () => { + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'base', + walletAddress: mockWallet, + baseToken: 'ETH', + quoteToken: 'WETH', + baseTokenAmount: 1, + quoteTokenAmount: 1, + }, + }); + + // ETH and WETH resolve to the same address, so the "must be different" guard fires (400, not 500). + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/different|one side/i); + }); +}); diff --git a/test/trading/trading-amm-routes/create-pool.test.ts b/test/trading/trading-amm-routes/create-pool.test.ts new file mode 100644 index 0000000000..a100a49ba3 --- /dev/null +++ b/test/trading/trading-amm-routes/create-pool.test.ts @@ -0,0 +1,39 @@ +import { fastifyWithTypeProvider } from '../../utils/testUtils'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../src/trading/trading-amm-routes/create-pool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /trading/amm/create-pool (unified dispatch)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + it('rejects an unsupported AMM connector', async () => { + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + connector: 'notaconnector', + chainNetwork: 'solana-mainnet-beta', + walletAddress: '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5', + baseToken: 'SOL', + quoteToken: 'USDC', + baseTokenAmount: 1, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/Unsupported AMM connector/); + }); +}); From ad4cc66e4988559af93c872e13526b6711a1ee1d Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 5 Aug 2026 06:46:22 -0700 Subject: [PATCH 05/10] refactor(clmm): standardize quoteSwap/executeSwap to require poolAddress and drop quoteToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLMM swaps are pool-addressed, so the counter ("quote") token is fully determined by poolAddress + baseToken. Standardizes the exported quoteSwap/executeSwap wrappers for Meteora/Raydium/Uniswap CLMM to the signature (network, [walletAddress,] poolAddress, baseToken, side, amount, slippagePct?) — matching the AMM connectors — and derives the counter token from the pool via a per-connector resolveCounterToken helper (dlmmPool.tokenX/Y, poolInfo.mintA/B, pool-info base/quote). Updates the unified swap router (trading/swap/{quote,execute}) CLMM call sites accordingly, and the Meteora/Raydium execute-swap route handlers + the Uniswap execute route (which now resolves the pool via findDefaultPool before calling the poolAddress-required function). An unknown base token not in the pool now returns 400 (was 404). Tests updated to argument-based token mocks; all CLMM + trading suites pass (69). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY --- .../meteora/clmm-routes/executeSwap.ts | 19 +++++----- .../meteora/clmm-routes/quoteSwap.ts | 28 ++++++++++++-- .../raydium/clmm-routes/executeSwap.ts | 15 ++++---- .../raydium/clmm-routes/quoteSwap.ts | 28 ++++++++++++-- .../uniswap/clmm-routes/executeSwap.ts | 31 +++++++++------- .../uniswap/clmm-routes/quoteSwap.ts | 25 +++++++++++-- src/trading/swap/execute.ts | 24 ++---------- src/trading/swap/quote.ts | 6 +-- .../meteora/clmm-routes/execute-swap.test.ts | 37 +++++++++++-------- 9 files changed, 136 insertions(+), 77 deletions(-) diff --git a/src/connectors/meteora/clmm-routes/executeSwap.ts b/src/connectors/meteora/clmm-routes/executeSwap.ts index 9583070c5b..b35f741ef9 100644 --- a/src/connectors/meteora/clmm-routes/executeSwap.ts +++ b/src/connectors/meteora/clmm-routes/executeSwap.ts @@ -11,7 +11,7 @@ import { sanitizeErrorMessage } from '../../../services/sanitize'; import { MeteoraConfig } from '../meteora.config'; import { MeteoraClmmExecuteSwapRequest, MeteoraClmmExecuteSwapRequestType } from '../schemas'; -import { getRawSwapQuote } from './quoteSwap'; +import { resolveCounterToken, getRawSwapQuote } from './quoteSwap'; const DLMM_PROGRAM_ID = new PublicKey('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'); @@ -38,11 +38,10 @@ export function fixSwapBitmapExtensionMeta { const solana = await Solana.getInstance(network); @@ -51,13 +50,16 @@ export async function executeSwap( // sendAndConfirmTransactionForWallet, which knows how to sign for each type. const walletPublicKey = new PublicKey(address); + // Standardized: quote token is derived from the pool given poolAddress + baseToken. + const quoteToken = await resolveCounterToken(network, poolAddress, baseToken); + const { inputToken, outputToken, swapAmount, quote: swapQuote, dlmmPool, - } = await getRawSwapQuote(network, baseTokenIdentifier, quoteTokenIdentifier, amount, side, poolAddress, slippagePct); + } = await getRawSwapQuote(network, baseToken, quoteToken, amount, side, poolAddress, slippagePct); logger.info(`Executing ${amount.toFixed(4)} ${side} swap in pool ${poolAddress}`); @@ -207,11 +209,10 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { return await executeSwap( networkUsed, walletAddressUsed, + poolAddressUsed, baseToken, - quoteToken, - amount, side as 'BUY' | 'SELL', - poolAddressUsed, + amount, slippagePct, ); } catch (e: any) { diff --git a/src/connectors/meteora/clmm-routes/quoteSwap.ts b/src/connectors/meteora/clmm-routes/quoteSwap.ts index a312dd5077..1baabe6f3c 100644 --- a/src/connectors/meteora/clmm-routes/quoteSwap.ts +++ b/src/connectors/meteora/clmm-routes/quoteSwap.ts @@ -243,15 +243,37 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { export default quoteSwapRoute; -// Export quoteSwap wrapper for chain-level routes +/** + * Resolves the counter ("quote") token for a DLMM pool given the base token. The standardized swap + * wrappers take poolAddress + baseToken and derive the other side from the pool (tokenX/tokenY), + * so callers no longer pass quoteToken. + */ +export async function resolveCounterToken(network: string, poolAddress: string, baseToken: string): Promise { + const solana = await Solana.getInstance(network); + const meteora = await Meteora.getInstance(network); + const dlmmPool = await meteora.getDlmmPool(poolAddress); + if (!dlmmPool) throw httpErrors.notFound(`Pool not found: ${poolAddress}`); + const tokenXAddr = dlmmPool.tokenX.publicKey.toBase58(); + const tokenYAddr = dlmmPool.tokenY.publicKey.toBase58(); + const resolved = await solana.getToken(baseToken); + const baseAddr = resolved ? resolved.address : baseToken; + if (baseAddr === tokenXAddr) return tokenYAddr; + if (baseAddr === tokenYAddr) return tokenXAddr; + throw httpErrors.badRequest(`Token ${baseToken} is not part of pool ${poolAddress}`); +} + +/** + * Standard CLMM quote-swap entry point (network-based) — consumed by the unified swap router. + * Requires poolAddress; the quote token is derived from the pool. + */ export async function quoteSwap( network: string, poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', + amount: number, slippagePct?: number, ): Promise { + const quoteToken = await resolveCounterToken(network, poolAddress, baseToken); return await formatSwapQuote(network, baseToken, quoteToken, amount, side, poolAddress, slippagePct); } diff --git a/src/connectors/raydium/clmm-routes/executeSwap.ts b/src/connectors/raydium/clmm-routes/executeSwap.ts index 2e871b345c..beeb39a39b 100644 --- a/src/connectors/raydium/clmm-routes/executeSwap.ts +++ b/src/connectors/raydium/clmm-routes/executeSwap.ts @@ -11,16 +11,15 @@ import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; import { RaydiumClmmExecuteSwapRequest, RaydiumClmmExecuteSwapRequestType } from '../schemas'; -import { getSwapQuote } from './quoteSwap'; +import { getSwapQuote, resolveCounterToken } from './quoteSwap'; export async function executeSwap( network: string, walletAddress: string, + poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', - poolAddress: string, + amount: number, slippagePct: number = RaydiumConfig.config.slippagePct, ): Promise { const solana = await Solana.getInstance(network); @@ -31,6 +30,9 @@ export async function executeSwap( // sendAndConfirmTransactionForWallet, which signs for the wallet's type. await raydium.setOwner(new PublicKey(walletAddress)); + // Standardized: quote token is derived from the pool given poolAddress + baseToken. + const quoteToken = await resolveCounterToken(network, poolAddress, baseToken); + // Get pool info from address const [poolInfo, poolKeys] = await raydium.getClmmPoolfromAPI(poolAddress); if (!poolInfo) { @@ -235,11 +237,10 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { return await executeSwap( networkToUse, walletAddress, + poolAddressToUse, baseToken, - quoteToken, - amount, side as 'BUY' | 'SELL', - poolAddressToUse, + amount, slippagePct, ); } catch (e) { diff --git a/src/connectors/raydium/clmm-routes/quoteSwap.ts b/src/connectors/raydium/clmm-routes/quoteSwap.ts index 9e41fbe070..a41c2d0b72 100644 --- a/src/connectors/raydium/clmm-routes/quoteSwap.ts +++ b/src/connectors/raydium/clmm-routes/quoteSwap.ts @@ -349,15 +349,37 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { export default quoteSwapRoute; -// Export quoteSwap wrapper for chain-level routes +/** + * Resolves the counter ("quote") token for a Raydium CLMM pool given the base token. The + * standardized swap wrappers take poolAddress + baseToken and derive the other side from the pool + * (mintA/mintB), so callers no longer pass quoteToken. + */ +export async function resolveCounterToken(network: string, poolAddress: string, baseToken: string): Promise { + const solana = await Solana.getInstance(network); + const raydium = await Raydium.getInstance(network); + const [poolInfo] = await raydium.getClmmPoolfromAPI(poolAddress); + if (!poolInfo) throw httpErrors.notFound(sanitizeErrorMessage('Pool not found: {}', poolAddress)); + const mintA = poolInfo.mintA.address; + const mintB = poolInfo.mintB.address; + const resolved = await solana.getToken(baseToken); + const baseAddr = resolved ? resolved.address : baseToken; + if (baseAddr === mintA) return mintB; + if (baseAddr === mintB) return mintA; + throw httpErrors.badRequest(`Token ${baseToken} is not part of pool ${poolAddress}`); +} + +/** + * Standard CLMM quote-swap entry point (network-based) — consumed by the unified swap router. + * Requires poolAddress; the quote token is derived from the pool. + */ export async function quoteSwap( network: string, poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', + amount: number, slippagePct: number = RaydiumConfig.config.slippagePct, ): Promise { + const quoteToken = await resolveCounterToken(network, poolAddress, baseToken); return await formatSwapQuote(network, baseToken, quoteToken, amount, side, poolAddress, slippagePct); } diff --git a/src/connectors/uniswap/clmm-routes/executeSwap.ts b/src/connectors/uniswap/clmm-routes/executeSwap.ts index 67832dd37a..716aa25dc4 100644 --- a/src/connectors/uniswap/clmm-routes/executeSwap.ts +++ b/src/connectors/uniswap/clmm-routes/executeSwap.ts @@ -14,30 +14,27 @@ import { UniswapConfig } from '../uniswap.config'; import { getUniswapV3SwapRouter02Address, ISwapRouter02ABI } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; -import { getUniswapClmmQuote } from './quoteSwap'; +import { getUniswapClmmQuote, resolveCounterToken } from './quoteSwap'; // Default gas limit for CLMM swap operations const CLMM_SWAP_GAS_LIMIT = 350000; export async function executeClmmSwap( - walletAddress: string, network: string, + walletAddress: string, + poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', + amount: number, slippagePct: number = UniswapConfig.config.slippagePct, ): Promise { const ethereum = await Ethereum.getInstance(network); await ethereum.init(); - const uniswap = await Uniswap.getInstance(network); + await Uniswap.getInstance(network); - // Find pool address - const poolAddress = await uniswap.findDefaultPool(baseToken, quoteToken, 'clmm'); - if (!poolAddress) { - throw httpErrors.notFound(`No CLMM pool found for pair ${baseToken}-${quoteToken}`); - } + // Standardized: quote token is derived from the pool given poolAddress + baseToken. + const quoteToken = await resolveCounterToken(network, poolAddress, baseToken); // Get quote using the shared quote function const { quote } = await getUniswapClmmQuote(network, poolAddress, baseToken, quoteToken, amount, side, slippagePct); @@ -339,13 +336,21 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct } = request.body as typeof UniswapExecuteSwapRequest._type; + // This route resolves the pool from the pair (no poolAddress in its request schema); + // executeClmmSwap itself is standardized to require poolAddress. + const uniswap = await Uniswap.getInstance(network); + const poolAddress = await uniswap.findDefaultPool(baseToken, quoteToken, 'clmm'); + if (!poolAddress) { + throw httpErrors.notFound(`No CLMM pool found for pair ${baseToken}-${quoteToken}`); + } + return await executeClmmSwap( - walletAddress, network, + walletAddress, + poolAddress, baseToken, - quoteToken, - amount, side as 'BUY' | 'SELL', + amount, slippagePct, ); } catch (e) { diff --git a/src/connectors/uniswap/clmm-routes/quoteSwap.ts b/src/connectors/uniswap/clmm-routes/quoteSwap.ts index 3496660882..bf2761da26 100644 --- a/src/connectors/uniswap/clmm-routes/quoteSwap.ts +++ b/src/connectors/uniswap/clmm-routes/quoteSwap.ts @@ -389,15 +389,34 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { export default quoteSwapRoute; -// Export quoteSwap wrapper for chain-level routes +/** + * Resolves the counter ("quote") token for a Uniswap V3 pool given the base token. The standardized + * swap wrappers take poolAddress + baseToken and derive the other side from the pool, so callers no + * longer pass quoteToken. + */ +export async function resolveCounterToken(network: string, poolAddress: string, baseToken: string): Promise { + const poolInfo = await getUniswapPoolInfo(poolAddress, network, 'clmm'); + if (!poolInfo) throw httpErrors.notFound(sanitizeErrorMessage('Pool not found: {}', poolAddress)); + const uniswap = await Uniswap.getInstance(network); + const resolved = await uniswap.getToken(baseToken); + const baseAddr = resolved ? resolved.address : baseToken; + if (baseAddr === poolInfo.baseTokenAddress) return poolInfo.quoteTokenAddress; + if (baseAddr === poolInfo.quoteTokenAddress) return poolInfo.baseTokenAddress; + throw httpErrors.badRequest(`Token ${baseToken} is not part of pool ${poolAddress}`); +} + +/** + * Standard CLMM quote-swap entry point (network-based) — consumed by the unified swap router. + * Requires poolAddress; the quote token is derived from the pool. + */ export async function quoteSwap( network: string, poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', + amount: number, slippagePct: number = UniswapConfig.config.slippagePct, ): Promise { + const quoteToken = await resolveCounterToken(network, poolAddress, baseToken); return await formatSwapQuote(network, poolAddress, baseToken, quoteToken, amount, side, slippagePct); } diff --git a/src/trading/swap/execute.ts b/src/trading/swap/execute.ts index 00ce8f0b39..caed349f13 100644 --- a/src/trading/swap/execute.ts +++ b/src/trading/swap/execute.ts @@ -175,27 +175,9 @@ async function executeSolanaSwap( } else if (providerKey === 'raydium/amm') { return await raydiumAmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'raydium/clmm') { - return await raydiumClmmExecuteSwap( - network, - walletAddress, - baseToken, - quoteToken, - amount, - side, - poolAddress!, - slippagePct, - ); + return await raydiumClmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'meteora/clmm') { - return await meteoraClmmExecuteSwap( - network, - walletAddress, - baseToken, - quoteToken, - amount, - side, - poolAddress!, - slippagePct, - ); + return await meteoraClmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'pancakeswap-sol/clmm') { return await pancakeswapSolClmmExecuteSwap( network, @@ -278,7 +260,7 @@ async function executeEthereumSwap( } else if (providerKey === 'uniswap/amm') { return await uniswapAmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'uniswap/clmm') { - return await uniswapClmmExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); + return await uniswapClmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'pancakeswap/router') { return await pancakeswapRouterExecuteSwap( walletAddress, diff --git a/src/trading/swap/quote.ts b/src/trading/swap/quote.ts index 11dd6ae170..d072c5df84 100644 --- a/src/trading/swap/quote.ts +++ b/src/trading/swap/quote.ts @@ -159,9 +159,9 @@ async function getSolanaQuoteSwap( } else if (providerKey === 'raydium/amm') { return await raydiumAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'raydium/clmm') { - return await raydiumClmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); + return await raydiumClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'meteora/clmm') { - return await meteoraClmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); + return await meteoraClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'pancakeswap-sol/clmm') { return await pancakeswapSolClmmQuoteSwap(network, baseToken, quoteToken, amount, side, poolAddress, slippagePct); } else if (providerKey === 'orca/clmm') { @@ -225,7 +225,7 @@ async function getEthereumQuoteSwap( } else if (providerKey === 'uniswap/amm') { return await uniswapAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'uniswap/clmm') { - return await uniswapClmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); + return await uniswapClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'pancakeswap/router') { return await pancakeswapRouterQuoteSwap(network, undefined, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'pancakeswap/amm') { diff --git a/test/connectors/meteora/clmm-routes/execute-swap.test.ts b/test/connectors/meteora/clmm-routes/execute-swap.test.ts index d743b58e7e..18d2eabf52 100644 --- a/test/connectors/meteora/clmm-routes/execute-swap.test.ts +++ b/test/connectors/meteora/clmm-routes/execute-swap.test.ts @@ -98,12 +98,13 @@ describe('POST /execute-swap', () => { it('should execute a CLMM swap for SELL side', async () => { const mockSolanaInstance = { getWallet: jest.fn().mockResolvedValue(mockWallet), - getToken: jest - .fn() - .mockResolvedValueOnce(mockSOL) - .mockResolvedValueOnce(mockUSDC) - .mockResolvedValueOnce({ ...mockSOL }) // For balance extraction - .mockResolvedValueOnce({ ...mockUSDC }), // For balance extraction + // Argument-based (the standardized wrapper derives the counter token, so getToken + // is called more than twice — an ordered mock would resolve the wrong tokens). + 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); + }), findAssociatedTokenAddress: jest.fn().mockResolvedValue('mock-ata-address'), getTxData: jest.fn().mockResolvedValue({ blockTime: Date.now() / 1000, @@ -172,12 +173,11 @@ describe('POST /execute-swap', () => { it('should execute a CLMM swap for BUY side', async () => { const mockSolanaInstance = { getWallet: jest.fn().mockResolvedValue(mockWallet), - getToken: jest - .fn() - .mockResolvedValueOnce(mockSOL) - .mockResolvedValueOnce(mockUSDC) - .mockResolvedValueOnce({ ...mockSOL }) // For balance extraction - .mockResolvedValueOnce({ ...mockUSDC }), // For balance extraction + 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); + }), findAssociatedTokenAddress: jest.fn().mockResolvedValue('mock-ata-address'), getTxData: jest.fn().mockResolvedValue({ blockTime: Date.now() / 1000, @@ -245,12 +245,17 @@ describe('POST /execute-swap', () => { expect(body.data).toHaveProperty('quoteTokenBalanceChange', -15); // USDC negative (spending) }); - it('should return 400 if token not found', async () => { + it('should return 400 if the base token is not part of the pool', async () => { const mockSolanaInstance = { getWallet: jest.fn().mockResolvedValue(mockWallet), - getToken: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(mockUSDC), + getToken: jest.fn((t: string) => { + if (t === 'USDC' || t === mockUSDC.address) return Promise.resolve(mockUSDC); + return Promise.resolve(null); // INVALID resolves to nothing + }), }; (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolanaInstance); + const mockMeteoraInstance = { getDlmmPool: jest.fn().mockResolvedValue(mockDlmmPool) }; + (Meteora.getInstance as jest.Mock).mockResolvedValue(mockMeteoraInstance); const response = await server.inject({ method: 'POST', @@ -267,7 +272,9 @@ describe('POST /execute-swap', () => { }, }); - expect(response.statusCode).toBe(404); // Returns 404 for 'Token not found' + // Standardized wrapper derives the counter token from the pool; an unknown base token that + // isn't one of the pool's tokens is a bad request (400). + expect(response.statusCode).toBe(400); expect(JSON.parse(response.body)).toHaveProperty('error'); }); }); From 11623b11d93be0be29c26d109760bc49d88de96c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 5 Aug 2026 06:59:46 -0700 Subject: [PATCH 06/10] feat(clmm): add create-pool for Meteora DLMM, Raydium CLMM, Uniswap V3 + unified /trading/clmm/create-pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds pool creation to all three CLMM connectors. create-pool CREATES + INITIALIZES a pool at an initial price (no position seeded — concentrated-liquidity positions need a range, opened separately via open-position). Initial price priority: initialPrice -> live market price from the unified swap router (anti-off-market default). Reuses the shared CreatePoolResponse. - Meteora DLMM: DLMM.createCustomizablePermissionlessLbPair2 (Token-2022 aware); activeId derived from initialPrice via the SDK's own getPricePerLamport/getBinIdFromPrice math; canonical mint ordering with price inversion; binStep + feeBps required. - Raydium CLMM: raydiumSDK.clmm.createPool with an ammConfig from api.getClmmConfigs() (ammConfigIndex), initialPrice as Decimal; SDK handles mint ordering + price inversion. - Uniswap V3: NonfungiblePositionManager.createAndInitializePoolIfNecessary (added factory getPool, pool slot0, and NFT-manager ABIs); initialPrice -> sqrtPriceX96 with decimal + token0/token1 orientation handling; fee tier param (100/500/3000/10000). Unified POST /trading/clmm/create-pool dispatches on connector, carrying per-connector extras (binStep/feeBps, ammConfigIndex, fee/gasPrice/maxGas). Tests: per-connector validation + unified dispatch; full suite green (104). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY --- .../meteora/clmm-routes/createPool.ts | 249 +++++++++++++++ src/connectors/meteora/clmm-routes/index.ts | 2 + src/connectors/meteora/schemas.ts | 40 +++ .../raydium/clmm-routes/createPool.ts | 239 +++++++++++++++ src/connectors/raydium/clmm-routes/index.ts | 2 + src/connectors/raydium/schemas.ts | 41 +++ .../uniswap/clmm-routes/createPool.ts | 289 ++++++++++++++++++ src/connectors/uniswap/clmm-routes/index.ts | 2 + src/connectors/uniswap/schemas.ts | 48 +++ src/connectors/uniswap/uniswap.contracts.ts | 65 ++++ .../trading-clmm-routes/create-pool.ts | 134 ++++++++ src/trading/trading-clmm-routes/index.ts | 1 + src/trading/trading.routes.ts | 2 + .../meteora/clmm-routes/create-pool.test.ts | 61 ++++ .../raydium/clmm-routes/create-pool.test.ts | 109 +++++++ .../uniswap/clmm-routes/create-pool.test.ts | 77 +++++ .../trading-clmm-routes/create-pool.test.ts | 38 +++ 17 files changed, 1399 insertions(+) create mode 100644 src/connectors/meteora/clmm-routes/createPool.ts create mode 100644 src/connectors/raydium/clmm-routes/createPool.ts create mode 100644 src/connectors/uniswap/clmm-routes/createPool.ts create mode 100644 src/trading/trading-clmm-routes/create-pool.ts create mode 100644 test/connectors/meteora/clmm-routes/create-pool.test.ts create mode 100644 test/connectors/raydium/clmm-routes/create-pool.test.ts create mode 100644 test/connectors/uniswap/clmm-routes/create-pool.test.ts create mode 100644 test/trading/trading-clmm-routes/create-pool.test.ts diff --git a/src/connectors/meteora/clmm-routes/createPool.ts b/src/connectors/meteora/clmm-routes/createPool.ts new file mode 100644 index 0000000000..c301d9bc46 --- /dev/null +++ b/src/connectors/meteora/clmm-routes/createPool.ts @@ -0,0 +1,249 @@ +import DLMM, { + ActivationType, + deriveCustomizablePermissionlessLbPair, + getTokenDecimals, + LBCLMM_PROGRAM_IDS, +} from '@meteora-ag/dlmm'; +import { PublicKey, Transaction } from '@solana/web3.js'; +import BN from 'bn.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { Solana } from '../../../chains/solana/solana'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { MeteoraClmmCreatePoolRequest } from '../schemas'; + +// A DLMM pool is created with no liquidity; the initial active bin only encodes the starting +// price. binStep/feeBps have no universal default, so both are required request params. + +/** Resolves a token symbol or mint address to a PublicKey. */ +async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { + const tokenInfo = await solana.getToken(tokenOrAddress); + if (tokenInfo) return new PublicKey(tokenInfo.address); + try { + return new PublicKey(tokenOrAddress); + } catch { + throw httpErrors.badRequest(sanitizeErrorMessage('Token not found: {}', tokenOrAddress)); + } +} + +/** + * Fetches the current market price (quote per base) from the unified swap router so a new pool can be + * initialized on-market instead of at an arbitrary price. Uses a SELL quote of 1 base token via the + * network's configured swap provider (Jupiter aggregates existing venues); throws a clear error if no + * market route exists. + */ +async function fetchMarketPrice(network: string, baseToken: string, quoteToken: string): Promise { + const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + let quote: any; + try { + quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); + } catch (e: any) { + throw httpErrors.badRequest( + `Could not fetch a market price for ${baseToken}/${quoteToken} to initialize the pool (${e.message}). ` + + 'Pass initialPrice explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw httpErrors.badRequest(`No market route found for ${baseToken}/${quoteToken}. Pass initialPrice explicitly.`); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} + +/** + * Creates and initializes a new Meteora DLMM (LB pair) pool at an initial price. No liquidity is + * seeded — that is a separate open-position operation, so the returned added amounts are zero. + * + * SDK method: `DLMM.createCustomizablePermissionlessLbPair2` (chosen over the non-`2` variant + * because `2` reads each mint's owner program and therefore supports both SPL Token and Token-2022 + * mints, while the non-`2` variant only supports the legacy Token program). + * + * activeId (initial active bin) encodes the starting price. It is computed with the SDK's own + * static price math so it matches the on-chain program exactly: + * pricePerLamport = DLMM.getPricePerLamport(decimalsX, decimalsY, poolPrice) + * = poolPrice * 10^(decimalsY - decimalsX) + * activeId = DLMM.getBinIdFromPrice(pricePerLamport, binStep, false) + * = ceil( ln(pricePerLamport) / ln(1 + binStep/10000) ) + * This is the inverse of the SDK's `getPriceOfBinByBinId(binId, binStep) = (1 + binStep/10000)^binId` + * (verified in node_modules/@meteora-ag/dlmm/dist/index.js: getPricePerLamport L14796, + * getBinIdFromPrice L14799, getPriceOfBinByBinId L10009). activeId therefore lands within one bin + * step of initialPrice. + * + * Mint ordering: the LB pair program requires the two mints in canonical byte order + * (tokenX < tokenY). `createCustomizablePermissionlessLbPair2` passes tokenX/tokenY straight to the + * instruction (it does NOT sort), so we sort here. DLMM prices are always tokenY-per-tokenX, so when + * the quote token sorts before the base token we invert initialPrice to keep the pool price correct. + */ +export async function createPool( + network: string, + walletAddress: string, + baseToken: string, + quoteToken: string, + initialPrice?: number, + binStep?: number, + feeBps?: number, +): Promise { + if (initialPrice !== undefined && initialPrice <= 0) { + throw httpErrors.badRequest('initialPrice must be greater than zero'); + } + if (binStep === undefined) { + throw httpErrors.badRequest( + 'binStep is required (bin step in bps, e.g. 1, 2, 4, 5, 10, 20, 25, 50, 100). ' + + 'It sets the pool granularity and cannot be changed after creation.', + ); + } + if (!Number.isInteger(binStep) || binStep <= 0) { + throw httpErrors.badRequest('binStep must be a positive integer number of basis points'); + } + if (feeBps === undefined) { + throw httpErrors.badRequest('feeBps is required (base fee in bps, e.g. 20 = 0.20%)'); + } + if (!Number.isInteger(feeBps) || feeBps <= 0) { + throw httpErrors.badRequest('feeBps must be a positive integer number of basis points'); + } + + const solana = await Solana.getInstance(network); + + let walletPublicKey: PublicKey; + try { + walletPublicKey = new PublicKey(walletAddress); + } catch { + throw httpErrors.badRequest(`Invalid wallet address: ${walletAddress}`); + } + + const baseMint = await resolveMint(solana, baseToken); + const quoteMint = await resolveMint(solana, quoteToken); + if (baseMint.equals(quoteMint)) { + throw httpErrors.badRequest('baseToken and quoteToken must be different'); + } + + // Resolve the initial price (quote per base): explicit initialPrice, else the live market price so + // the pool opens on-market. + const seedPrice = initialPrice !== undefined ? initialPrice : await fetchMarketPrice(network, baseToken, quoteToken); + + // Canonical mint ordering required by the LB pair program: tokenX < tokenY by raw bytes. + const compareBytes = (a: Uint8Array, b: Uint8Array): number => { + for (let i = 0; i < a.length && i < b.length; i++) { + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + } + return a.length - b.length; + }; + const baseIsX = compareBytes(baseMint.toBytes(), quoteMint.toBytes()) <= 0; + const tokenX = baseIsX ? baseMint : quoteMint; + const tokenY = baseIsX ? quoteMint : baseMint; + + // DLMM price is always tokenY per tokenX. initialPrice is quote per base, so it maps directly + // when base sorts as X, and is inverted when quote sorts as X. + const poolPrice = baseIsX ? seedPrice : 1 / seedPrice; + + const [decimalsX, decimalsY] = await Promise.all([ + getTokenDecimals(solana.connection, tokenX), + getTokenDecimals(solana.connection, tokenY), + ]); + + // Compute the initial active bin id using the SDK's own price math (see doc comment above). + const pricePerLamport = DLMM.getPricePerLamport(decimalsX, decimalsY, poolPrice); + const activeIdNum = DLMM.getBinIdFromPrice(pricePerLamport, binStep, false); + const activeId = new BN(activeIdNum); + + const cluster = solana.network as any; + const programId = LBCLMM_PROGRAM_IDS[cluster]; + if (!programId) { + throw httpErrors.badRequest(`Meteora DLMM is not available on network: ${network}`); + } + + // Derive the LB pair PDA (order-independent — the helper sorts internally). + const [lbPair] = deriveCustomizablePermissionlessLbPair(tokenX, tokenY, new PublicKey(programId)); + const poolAddress = lbPair.toBase58(); + + const existing = await solana.connection.getAccountInfo(lbPair); + if (existing) { + throw httpErrors.badRequest(`Pool already exists for this token pair and bin step: ${poolAddress}`); + } + + logger.info( + `Creating Meteora DLMM pool ${poolAddress} (${baseToken}/${quoteToken}) at ${seedPrice} ` + + `${quoteToken}/${baseToken} [binStep=${binStep}bps, feeBps=${feeBps}, activeId=${activeIdNum}]`, + ); + + let transaction: Transaction; + try { + transaction = await DLMM.createCustomizablePermissionlessLbPair2( + solana.connection, + new BN(binStep), + tokenX, + tokenY, + activeId, + new BN(feeBps), + ActivationType.Timestamp, + false, // hasAlphaVault + walletPublicKey, // creatorKey + undefined, // activationPoint + undefined, // creatorPoolOnOffControl + { cluster }, + ); + } catch (e: any) { + // computeBaseFactorFromFeeBps throws when feeBps/binStep are incompatible (non-integer base + // factor or over/underflow). Surface as a clear 400 instead of a 500. + const msg = typeof e === 'string' ? e : e?.message || 'unknown error'; + throw httpErrors.badRequest( + `Could not build pool with binStep=${binStep} and feeBps=${feeBps}: ${msg}. ` + + 'The base fee must resolve to a valid factor for the chosen bin step; try a different feeBps.', + ); + } + + const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); + const txData = await solana.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + + if (txData) { + return { + signature, + status: 1, // CONFIRMED + poolAddress, + price: seedPrice, + data: { + fee: txData.meta.fee / 1e9, + baseTokenAmountAdded: 0, + quoteTokenAmountAdded: 0, + }, + }; + } + return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING +} + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: typeof MeteoraClmmCreatePoolRequest.static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: + 'Create and initialize a new Meteora DLMM pool (LB pair) at an initial price (no liquidity seeded)', + tags: ['/connector/meteora'], + body: MeteoraClmmCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { network, walletAddress, baseToken, quoteToken, initialPrice, binStep, feeBps } = request.body; + return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, binStep, feeBps); + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to create pool'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/connectors/meteora/clmm-routes/index.ts b/src/connectors/meteora/clmm-routes/index.ts index 802d180035..c05793c64b 100644 --- a/src/connectors/meteora/clmm-routes/index.ts +++ b/src/connectors/meteora/clmm-routes/index.ts @@ -3,6 +3,7 @@ import { FastifyPluginAsync } from 'fastify'; import { addLiquidityRoute } from './addLiquidity'; import { closePositionRoute } from './closePosition'; import { collectFeesRoute } from './collectFees'; +import { createPoolRoute } from './createPool'; import { executeSwapRoute } from './executeSwap'; import { fetchPoolsRoute } from './fetchPools'; import { openPositionRoute } from './openPosition'; @@ -15,6 +16,7 @@ import { removeLiquidityRoute } from './removeLiquidity'; export const meteoraClmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(fetchPoolsRoute); + await fastify.register(createPoolRoute); await fastify.register(poolInfoRoute); await fastify.register(positionsOwnedRoute); await fastify.register(positionInfoRoute); diff --git a/src/connectors/meteora/schemas.ts b/src/connectors/meteora/schemas.ts index 061c09bd91..be32bdc92f 100644 --- a/src/connectors/meteora/schemas.ts +++ b/src/connectors/meteora/schemas.ts @@ -501,6 +501,46 @@ export const MeteoraClmmQuotePositionRequest = Type.Object({ ), }); +// Meteora CLMM Create Pool Request +export const MeteoraClmmCreatePoolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...MeteoraConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Solana wallet address that will create the pool', + default: solanaChainConfig.defaultWallet, + examples: [solanaChainConfig.defaultWallet], + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Quote token symbol or address', + examples: [QUOTE_TOKEN], + }), + initialPrice: Type.Number({ + description: 'Initial price as quote per base (e.g. USDC per SOL). Encodes the pool active bin.', + examples: [UPPER_PRICE_BOUND], + }), + binStep: Type.Number({ + 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.', + examples: [20], + }), + feeBps: Type.Number({ + description: 'Base swap fee in basis points (e.g. 20 = 0.20%). Must be compatible with binStep.', + examples: [20], + }), +}); + // ======================================== // DAMM v2 (AMM) Request Schemas // ======================================== diff --git a/src/connectors/raydium/clmm-routes/createPool.ts b/src/connectors/raydium/clmm-routes/createPool.ts new file mode 100644 index 0000000000..c6d4ed0344 --- /dev/null +++ b/src/connectors/raydium/clmm-routes/createPool.ts @@ -0,0 +1,239 @@ +import { + ApiClmmConfigInfo, + ApiV3Token, + ClmmConfigInfo, + CLMM_PROGRAM_ID, + DEVNET_PROGRAM_ID, +} from '@raydium-io/raydium-sdk-v2'; +import { Static } from '@sinclair/typebox'; +import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, getMint } from '@solana/spl-token'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import { Decimal } from 'decimal.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { Solana } from '../../../chains/solana/solana'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { Raydium } from '../raydium'; +import { RaydiumClmmCreatePoolRequest } from '../schemas'; + +/** Resolves a token symbol or mint address to a PublicKey. */ +async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { + const tokenInfo = await solana.getToken(tokenOrAddress); + if (tokenInfo) return new PublicKey(tokenInfo.address); + try { + return new PublicKey(tokenOrAddress); + } catch { + throw httpErrors.badRequest(sanitizeErrorMessage('Token not found: {}', tokenOrAddress)); + } +} + +/** Detects whether a mint is owned by the Token or Token-2022 program. */ +async function getMintProgram(solana: Solana, mint: PublicKey): Promise { + const info = await solana.connection.getAccountInfo(mint); + if (!info) throw httpErrors.badRequest(`Mint account not found: ${mint.toBase58()}`); + if (info.owner.equals(TOKEN_2022_PROGRAM_ID)) return TOKEN_2022_PROGRAM_ID; + if (info.owner.equals(TOKEN_PROGRAM_ID)) return TOKEN_PROGRAM_ID; + throw httpErrors.badRequest(`Mint ${mint.toBase58()} is not an SPL token mint`); +} + +/** + * The Raydium CLMM SDK only reads `address`, `decimals` and `programId` off the mint objects passed + * to `clmm.createPool` (the on-chain init instruction needs nothing else). The `ApiV3Token` type + * additionally requires display metadata (symbol, name, logoURI, …) that never touches the chain, so + * we build the object from the authoritative on-chain values and cast — rather than inventing fake + * metadata — to satisfy the type. + */ +function toApiV3Token(mint: PublicKey, decimals: number, programId: PublicKey): ApiV3Token { + return { + address: mint.toBase58(), + decimals, + programId: programId.toBase58(), + } as unknown as ApiV3Token; +} + +/** + * Fetches the current market price (quote per base) from the unified swap router so a new pool can + * be initialized on-market instead of at an arbitrary ratio. Off-market initialization invites + * arbitrage bots to instantly move the price. Uses a SELL quote of 1 base token via the network's + * configured swap provider (Jupiter aggregates existing venues); throws a clear error if no market + * route exists. + */ +async function fetchMarketPrice(network: string, baseToken: string, quoteToken: string): Promise { + const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + let quote: any; + try { + // Probe with 1 base token — we only need the price ratio, not a real trade size. + quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); + } catch (e: any) { + throw httpErrors.badRequest( + `Could not fetch a market price for ${baseToken}/${quoteToken} to initialize the pool (${e.message}). ` + + 'Pass initialPrice explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw httpErrors.badRequest(`No market route found for ${baseToken}/${quoteToken}. Pass initialPrice explicitly.`); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} + +export async function createPool( + network: string, + walletAddress: string, + baseToken: string, + quoteToken: string, + initialPrice?: number, + ammConfigIndex: number = 0, +): Promise { + const solana = await Solana.getInstance(network); + const raydium = await Raydium.getInstance(network); + + // Set the SDK owner to the wallet's public key so createPool derives the right owner. The tx is + // built unsigned; signing/sending is delegated to sendAndConfirmTransactionForWallet. + await raydium.setOwner(new PublicKey(walletAddress)); + + const baseMint = await resolveMint(solana, baseToken); + const quoteMint = await resolveMint(solana, quoteToken); + if (baseMint.equals(quoteMint)) { + throw httpErrors.badRequest('baseToken and quoteToken must be different'); + } + + const [baseProgram, quoteProgram] = await Promise.all([ + getMintProgram(solana, baseMint), + getMintProgram(solana, quoteMint), + ]); + const [baseMintInfo, quoteMintInfo] = await Promise.all([ + getMint(solana.connection, baseMint, undefined, baseProgram), + getMint(solana.connection, quoteMint, undefined, quoteProgram), + ]); + const baseDecimals = baseMintInfo.decimals; + const quoteDecimals = quoteMintInfo.decimals; + + // Resolve the seed price (quote per base). Priority: + // 1) explicit initialPrice + // 2) live market price from the unified swap router — so the pool opens on-market. + let seedPrice: number; + let seedSource: string; + if (initialPrice !== undefined) { + if (initialPrice <= 0) throw httpErrors.badRequest('initialPrice must be greater than zero'); + seedPrice = initialPrice; + seedSource = 'initialPrice'; + } else { + seedPrice = await fetchMarketPrice(network, baseToken, quoteToken); + seedSource = 'market (unified swap router)'; + } + logger.info(`Initializing Raydium CLMM pool at ${seedPrice} ${quoteToken}/${baseToken} [${seedSource}]`); + + // Fetch the CLMM amm-config list dynamically from the Raydium API — no hardcoded fallback. + let ammConfigs: ApiClmmConfigInfo[]; + try { + ammConfigs = await raydium.raydiumSDK.api.getClmmConfigs(); + } catch (e: any) { + throw httpErrors.internalServerError(`Could not fetch Raydium CLMM amm configs: ${e.message}`); + } + if (!ammConfigs || ammConfigs.length === 0) { + throw httpErrors.internalServerError('Raydium API returned no CLMM amm configs'); + } + if (ammConfigIndex < 0 || ammConfigIndex >= ammConfigs.length) { + throw httpErrors.badRequest(`ammConfigIndex ${ammConfigIndex} out of range (0-${ammConfigs.length - 1})`); + } + const apiConfig = ammConfigs[ammConfigIndex]; + + // The API returns ApiClmmConfigInfo (id: string) but clmm.createPool wants ClmmConfigInfo + // (id: PublicKey, plus fundOwner/description which the init instruction never reads). + const ammConfig: ClmmConfigInfo = { + ...apiConfig, + id: new PublicKey(apiConfig.id), + fundOwner: '', + description: '', + }; + + // Select the CLMM program for the connector's cluster, mirroring how the AMM createPool derives + // mainnet vs devnet from solana.network. + const isMainnet = solana.network === 'mainnet-beta'; + const programId = isMainnet ? CLMM_PROGRAM_ID : DEVNET_PROGRAM_ID.CLMM_PROGRAM_ID; + + // NOTE on mint ordering + price: the CLMM program requires canonical mint ordering (mint1 < mint2 + // by byte-compared pubkey). clmm.createPool sorts internally and, when it swaps mint1/mint2, it + // inverts initialPrice in lockstep (initialPrice -> 1/initialPrice) so the on-chain sqrtPrice is + // always correct. We therefore pass base as mint1 / quote as mint2 with initialPrice = seedPrice + // (quote per base = mint2 per mint1) and let the SDK handle ordering. The reported `price` + // (seedPrice) stays quote-per-base regardless of the SDK's internal sort. + const { + transaction, + signers: sdkSigners, + extInfo, + } = await raydium.raydiumSDK.clmm.createPool({ + programId, + owner: new PublicKey(walletAddress), + mint1: toApiV3Token(baseMint, baseDecimals, baseProgram), + mint2: toApiV3Token(quoteMint, quoteDecimals, quoteProgram), + ammConfig, + initialPrice: new Decimal(seedPrice), + txVersion: raydium.txVersion, + feePayer: new PublicKey(walletAddress), + }); + + const poolAddress = extInfo.address.id; + logger.info(`Creating Raydium CLMM pool ${poolAddress} (${baseToken}/${quoteToken})`); + + const { signature } = await solana.sendAndConfirmTransactionForWallet( + transaction, + walletAddress, + (sdkSigners as Keypair[]) ?? [], + ); + const txData = await solana.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + + if (txData) { + return { + signature, + status: 1, // CONFIRMED + poolAddress, + price: seedPrice, + data: { + fee: txData.meta.fee / 1e9, + // Pool created + initialized only — no liquidity/position seeded. + baseTokenAmountAdded: 0, + quoteTokenAmountAdded: 0, + }, + }; + } + return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING +} + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: Static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: + 'Create and initialize a new Raydium CLMM pool at an initial price. Does not open or seed a position.', + tags: ['/connector/raydium'], + body: RaydiumClmmCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { network, walletAddress, baseToken, quoteToken, initialPrice, ammConfigIndex } = request.body; + return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, ammConfigIndex); + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to create pool'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/connectors/raydium/clmm-routes/index.ts b/src/connectors/raydium/clmm-routes/index.ts index 356dcd6c76..d5b2724ac9 100644 --- a/src/connectors/raydium/clmm-routes/index.ts +++ b/src/connectors/raydium/clmm-routes/index.ts @@ -3,6 +3,7 @@ import { FastifyPluginAsync } from 'fastify'; import { addLiquidityRoute } from './addLiquidity'; import { closePositionRoute } from './closePosition'; import { collectFeesRoute } from './collectFees'; +import { createPoolRoute } from './createPool'; import { executeSwapRoute } from './executeSwap'; import { openPositionRoute } from './openPosition'; import { poolInfoRoute } from './poolInfo'; @@ -20,6 +21,7 @@ export const raydiumClmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(quoteSwapRoute); await fastify.register(executeSwapRoute); await fastify.register(openPositionRoute); + await fastify.register(createPoolRoute); await fastify.register(addLiquidityRoute); await fastify.register(removeLiquidityRoute); await fastify.register(collectFeesRoute); diff --git a/src/connectors/raydium/schemas.ts b/src/connectors/raydium/schemas.ts index 6b966056be..60745782b2 100644 --- a/src/connectors/raydium/schemas.ts +++ b/src/connectors/raydium/schemas.ts @@ -505,6 +505,47 @@ export const RaydiumClmmOpenPositionRequest = Type.Object({ ), }); +export const RaydiumClmmCreatePoolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...RaydiumConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Solana wallet address that will create and initialize the pool', + default: solanaChainConfig.defaultWallet, + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address (becomes the pool base)', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Quote token symbol or address (becomes the pool quote)', + examples: [QUOTE_TOKEN], + }), + initialPrice: Type.Optional( + Type.Number({ + 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.', + examples: [QUOTE_TOKEN_AMOUNT / BASE_TOKEN_AMOUNT], + }), + ), + ammConfigIndex: Type.Optional( + Type.Integer({ + 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, + }), + ), +}); + export const RaydiumClmmAddLiquidityRequest = Type.Object({ network: Type.Optional( Type.String({ diff --git a/src/connectors/uniswap/clmm-routes/createPool.ts b/src/connectors/uniswap/clmm-routes/createPool.ts new file mode 100644 index 0000000000..7ef1320b2d --- /dev/null +++ b/src/connectors/uniswap/clmm-routes/createPool.ts @@ -0,0 +1,289 @@ +import { Contract } from '@ethersproject/contracts'; +import { Static } from '@sinclair/typebox'; +import { encodeSqrtRatioX96 } from '@uniswap/v3-sdk'; +import { Decimal } from 'decimal.js'; +import { BigNumber, constants, utils } from 'ethers'; +import { FastifyPluginAsync } from 'fastify'; +import JSBI from 'jsbi'; + +import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { UniswapClmmCreatePoolRequest } from '../schemas'; +import { + IUniswapV3FactoryABI, + IUniswapV3PoolSlot0ABI, + INftManagerCreatePoolABI, + 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]; + +// Default gas limit for CLMM create-pool. Deploying + initializing a V3 pool via the NFT manager +// costs more than a plain swap; a pool deployment is ~4-5M gas on mainnet. +const CLMM_CREATE_POOL_GAS_LIMIT = 6000000; + +/** + * Resolves a token symbol or address to its on-chain TokenInfo. Native ETH is not an ERC20 — a V3 + * pool is always built on WETH — so 'ETH' resolves to WETH for the pool's token address/decimals. + */ +async function resolveToken(ethereum: Ethereum, tokenOrAddress: string): Promise { + const isEthInput = tokenOrAddress.toUpperCase() === 'ETH'; + const lookup = isEthInput ? 'WETH' : tokenOrAddress; + const token = await ethereum.getToken(lookup); + if (!token) { + throw httpErrors.badRequest(`Token not found: ${tokenOrAddress}`); + } + return token; +} + +/** + * Fetches the current market price (quote per base) from the unified swap router so a new pool can + * be seeded on-market instead of at an arbitrary ratio. Seeding off-market invites arbitrage bots to + * instantly rebalance the pool. Uses a SELL quote of the base token via the network's configured swap + * provider (an aggregator that does not require this not-yet-created pool); throws a clear error if no + * market route exists. + */ +async function fetchMarketPrice( + network: string, + baseToken: string, + quoteToken: string, + amount: number, +): Promise { + const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + let quote: any; + try { + quote = await getUnifiedQuoteSwap(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); + } catch (e: any) { + throw httpErrors.badRequest( + `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + + 'Pass initialPrice explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw httpErrors.badRequest(`No market route found for ${baseToken}/${quoteToken}. Pass initialPrice explicitly.`); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} + +export async function createPool( + network: string, + walletAddress: string, + baseToken: string, + 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) { + throw httpErrors.badRequest('fee tier is required (one of 100, 500, 3000, 10000)'); + } + if (!VALID_FEE_TIERS.includes(fee)) { + throw httpErrors.badRequest( + `Invalid fee tier ${fee}. Must be one of 100 (0.01%), 500 (0.05%), 3000 (0.30%), 10000 (1.00%)`, + ); + } + + const ethereum = await Ethereum.getInstance(network); + + const wallet = await ethereum.getWallet(walletAddress); + if (!wallet) { + throw httpErrors.badRequest('Wallet not found'); + } + + const baseTokenInfo = await resolveToken(ethereum, baseToken); + const quoteTokenInfo = await resolveToken(ethereum, quoteToken); + + if (baseTokenInfo.address.toLowerCase() === quoteTokenInfo.address.toLowerCase()) { + throw httpErrors.badRequest('baseToken and quoteToken must be different'); + } + + // V3 requires token0 < token1 by address (ascending). Determine which side is token0. + const baseIsToken0 = baseTokenInfo.address.toLowerCase() < quoteTokenInfo.address.toLowerCase(); + const token0 = baseIsToken0 ? baseTokenInfo : quoteTokenInfo; + const token1 = baseIsToken0 ? quoteTokenInfo : baseTokenInfo; + + // Resolve the seed price (quote per base). Priority: explicit initialPrice → live market price. + let seedPrice: number; + let seedSource: string; + if (initialPrice !== undefined) { + if (initialPrice <= 0) throw httpErrors.badRequest('initialPrice must be greater than zero'); + seedPrice = initialPrice; + seedSource = 'initialPrice'; + } else { + // Use 1 base unit as the probe amount for the market quote. + seedPrice = await fetchMarketPrice(network, baseToken, quoteToken, 1); + seedSource = 'market (unified swap router)'; + } + + // Convert the human seed price (quote per base) into sqrtPriceX96 for the (token0, token1) orientation. + // + // sqrtPriceX96 encodes sqrt(raw token1 per raw token0) * 2^96, where "raw" means base-unit amounts + // (i.e. adjusted for each token's decimals). encodeSqrtRatioX96(amount1, amount0) == sqrt(amount1/amount0) * 2^96, + // so we must supply amount1/amount0 == the RAW token1-per-token0 ratio. + // + // humanRatio (token1 per token0) = seedPrice when base == token0 (quote == token1) + // = 1 / seedPrice when base == token1 (quote == token0) ← price inverted + // rawRatio = humanRatio * 10^token1.decimals / 10^token0.decimals + // + // We therefore pass amount1 = humanRatio * 10^token1.decimals and amount0 = 10^token0.decimals. To keep both + // integers (encodeSqrtRatioX96 requires JSBI integers) we multiply BOTH by a fixed precision factor — this + // leaves the amount1/amount0 ratio unchanged while preserving the fractional part of humanRatio. + // + // This matches openPosition.ts, which converts a human price to a tick via + // `rawPrice = humanPrice * 10^(token1.decimals - token0.decimals)` (rawPrice == rawRatio here), treating the + // input as a token1-per-token0 price — consistent with our base==token0 case, and correctly inverted for base==token1. + const humanRatio = baseIsToken0 ? new Decimal(seedPrice) : new Decimal(1).div(seedPrice); + const precision = new Decimal(10).pow(18); // integer-preserving scale factor applied to both sides + const amount1 = humanRatio.mul(new Decimal(10).pow(token1.decimals)).mul(precision).toFixed(0); + const amount0 = new Decimal(10).pow(token0.decimals).mul(precision).toFixed(0); + if (new Decimal(amount1).isZero() || new Decimal(amount0).isZero()) { + throw httpErrors.badRequest('Computed sqrtPriceX96 inputs are zero — check initialPrice and token decimals'); + } + const sqrtPriceX96 = encodeSqrtRatioX96(JSBI.BigInt(amount1), JSBI.BigInt(amount0)); + const sqrtPriceX96Str = sqrtPriceX96.toString(); + + logger.info( + `Creating Uniswap V3 pool ${baseTokenInfo.symbol}/${quoteTokenInfo.symbol} (fee ${fee}) seeded at ` + + `${seedPrice} ${quoteTokenInfo.symbol}/${baseTokenInfo.symbol} [${seedSource}] — token0=${token0.symbol}, ` + + `token1=${token1.symbol}, sqrtPriceX96=${sqrtPriceX96Str}`, + ); + + const factoryAddress = getUniswapV3FactoryAddress(network); + const factory = new Contract(factoryAddress, IUniswapV3FactoryABI, ethereum.provider); + + // If a pool already exists AND is already initialized (slot0.sqrtPriceX96 != 0), reject — there is + // nothing to create. A created-but-uninitialized pool (zero sqrtPriceX96) is still initialized below. + const existingPool: string = await factory.getPool(token0.address, token1.address, fee); + if (existingPool && existingPool !== constants.AddressZero) { + const poolContract = new Contract(existingPool, IUniswapV3PoolSlot0ABI, ethereum.provider); + const slot0 = await poolContract.slot0(); + if (!BigNumber.from(slot0.sqrtPriceX96).isZero()) { + throw new Error(`Pool already exists and is initialized for this token pair and fee tier: ${existingPool}`); + } + logger.info(`Pool ${existingPool} deployed but uninitialized — initializing it at the seed price`); + } + + // Create + initialize in a single idempotent call via the NonfungiblePositionManager. This deploys the + // pool through the factory (if needed) and initializes it at sqrtPriceX96 (if needed) — preferred over + // the two-tx Factory.createPool + Pool.initialize path. It is available on every V3 NFT manager. + const nftManagerAddress = getUniswapV3NftManagerAddress(network); + const nftManager = new Contract(nftManagerAddress, INftManagerCreatePoolABI, wallet); + + const gasOptions = await ethereum.prepareGasOptions(gasPrice, maxGas || CLMM_CREATE_POOL_GAS_LIMIT); + + const tx = await nftManager.createAndInitializePoolIfNecessary( + token0.address, + token1.address, + fee, + sqrtPriceX96Str, + gasOptions, + ); + + logger.info(`Creating Uniswap V3 pool via tx ${tx.hash}`); + + const receipt = await ethereum.handleTransactionExecution(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 + return { + signature: receipt.transactionHash, + status: 1, // CONFIRMED + 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 + poolAddress, + price: seedPrice, + }; +} + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + await fastify.register(require('@fastify/sensible')); + + fastify.post<{ + Body: Static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: 'Create and initialize a new Uniswap V3 (CLMM) pool at an initial price (no liquidity seeded)', + tags: ['/connector/uniswap'], + body: UniswapClmmCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { + network, + baseToken, + quoteToken, + fee, + initialPrice, + gasPrice, + maxGas, + walletAddress: requestedWalletAddress, + } = request.body; + + if (!baseToken || !quoteToken) { + throw fastify.httpErrors.badRequest('Missing required parameters'); + } + + let walletAddress = requestedWalletAddress; + if (!walletAddress) { + walletAddress = await Ethereum.getFirstWalletAddress(); + if (!walletAddress) { + throw fastify.httpErrors.badRequest('No wallet address provided and no wallets found.'); + } + 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); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + + if (e.message && e.message.includes('already exists')) { + throw fastify.httpErrors.badRequest(e.message); + } + if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { + throw fastify.httpErrors.badRequest( + 'Insufficient ETH balance to pay for gas fees. Please add more ETH to your wallet.', + ); + } + + throw fastify.httpErrors.internalServerError('Failed to create pool'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/connectors/uniswap/clmm-routes/index.ts b/src/connectors/uniswap/clmm-routes/index.ts index 51d6f4412f..82c37be8fd 100644 --- a/src/connectors/uniswap/clmm-routes/index.ts +++ b/src/connectors/uniswap/clmm-routes/index.ts @@ -3,6 +3,7 @@ import { FastifyPluginAsync } from 'fastify'; import addLiquidityRoute from './addLiquidity'; import closePositionRoute from './closePosition'; import collectFeesRoute from './collectFees'; +import createPoolRoute from './createPool'; import executeSwapRoute from './executeSwap'; import openPositionRoute from './openPosition'; import poolInfoRoute from './poolInfo'; @@ -20,6 +21,7 @@ export const uniswapClmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(quoteSwapRoute); await fastify.register(executeSwapRoute); await fastify.register(openPositionRoute); + await fastify.register(createPoolRoute); await fastify.register(addLiquidityRoute); await fastify.register(removeLiquidityRoute); await fastify.register(collectFeesRoute); diff --git a/src/connectors/uniswap/schemas.ts b/src/connectors/uniswap/schemas.ts index cf2d9f0171..e17603a95e 100644 --- a/src/connectors/uniswap/schemas.ts +++ b/src/connectors/uniswap/schemas.ts @@ -271,6 +271,54 @@ export const UniswapAmmCreatePoolRequest = Type.Object({ ), }); +// Uniswap CLMM Create Pool Request (Uniswap V3) +export const UniswapClmmCreatePoolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...UniswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will create and initialize the pool', + default: ethereumChainConfig.defaultWallet, + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address (becomes the pool base)', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Quote token symbol or address (becomes the pool quote)', + examples: [QUOTE_TOKEN], + }), + fee: Type.Number({ + 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], + examples: [3000], + }), + initialPrice: Type.Optional( + Type.Number({ + 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.', + }), + ), + 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 export const UniswapAmmRemoveLiquidityRequest = Type.Object({ network: Type.Optional( diff --git a/src/connectors/uniswap/uniswap.contracts.ts b/src/connectors/uniswap/uniswap.contracts.ts index f1f3c442eb..c4448126bf 100644 --- a/src/connectors/uniswap/uniswap.contracts.ts +++ b/src/connectors/uniswap/uniswap.contracts.ts @@ -796,6 +796,71 @@ export const IUniswapV2FactoryABI = { ], }; +/** + * Uniswap V3 Factory ABI — minimal fragment for reading a deployed pool address. + * `getPool` returns the canonical pool for a (token0, token1, fee) triple, or the zero + * address when no pool has been deployed yet. Used by create-pool to read back the + * authoritative pool address after the create+initialize tx confirms. + */ +export const IUniswapV3FactoryABI = [ + { + inputs: [ + { internalType: 'address', name: 'tokenA', type: 'address' }, + { internalType: 'address', name: 'tokenB', type: 'address' }, + { internalType: 'uint24', name: 'fee', type: 'uint24' }, + ], + name: 'getPool', + outputs: [{ internalType: 'address', name: 'pool', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, +]; + +/** + * Uniswap V3 Pool ABI — minimal `slot0` fragment. `sqrtPriceX96 == 0` means the pool + * contract has been deployed by the factory but not yet initialized with a price. + * create-pool uses this to distinguish an already-initialized (live) pool from a + * created-but-uninitialized one. + */ +export const IUniswapV3PoolSlot0ABI = [ + { + inputs: [], + name: 'slot0', + outputs: [ + { internalType: 'uint160', name: 'sqrtPriceX96', type: 'uint160' }, + { internalType: 'int24', name: 'tick', type: 'int24' }, + { internalType: 'uint16', name: 'observationIndex', type: 'uint16' }, + { internalType: 'uint16', name: 'observationCardinality', type: 'uint16' }, + { internalType: 'uint16', name: 'observationCardinalityNext', type: 'uint16' }, + { internalType: 'uint8', name: 'feeProtocol', type: 'uint8' }, + { internalType: 'bool', name: 'unlocked', type: 'bool' }, + ], + stateMutability: 'view', + type: 'function', + }, +]; + +/** + * Uniswap V3 NonfungiblePositionManager ABI — minimal `createAndInitializePoolIfNecessary` + * fragment. This single, idempotent call deploys the pool via the factory (if it does not + * yet exist) AND initializes it at `sqrtPriceX96` (if not yet initialized), returning the + * pool address. Preferred over the two-tx Factory.createPool + Pool.initialize path. + */ +export const INftManagerCreatePoolABI = [ + { + inputs: [ + { internalType: 'address', name: 'token0', type: 'address' }, + { internalType: 'address', name: 'token1', type: 'address' }, + { internalType: 'uint24', name: 'fee', type: 'uint24' }, + { internalType: 'uint160', name: 'sqrtPriceX96', type: 'uint160' }, + ], + name: 'createAndInitializePoolIfNecessary', + outputs: [{ internalType: 'address', name: 'pool', type: 'address' }], + stateMutability: 'payable', + type: 'function', + }, +]; + /** * Uniswap V4 StateView ABI for querying pool state */ diff --git a/src/trading/trading-clmm-routes/create-pool.ts b/src/trading/trading-clmm-routes/create-pool.ts new file mode 100644 index 0000000000..7ed287c199 --- /dev/null +++ b/src/trading/trading-clmm-routes/create-pool.ts @@ -0,0 +1,134 @@ +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 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 { 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('-') }; +} + +// 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)', + 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'], + }), + 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: 'Uniswap V3 fee tier (100 | 500 | 3000 | 10000)' })), + gasPrice: Type.Optional(Type.Number({ description: 'Uniswap (EVM) gas price in gwei' })), + maxGas: Type.Optional(Type.Number({ description: 'Uniswap (EVM) max gas limit' })), +}); + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: Static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: + 'Create and initialize a new CLMM pool across supported connectors (Meteora DLMM, Raydium CLMM, Uniswap V3)', + tags: ['/trading/clmm'], + body: UnifiedClmmCreatePoolRequest, + response: { 200: CreatePoolResponse }, + }, + }, + async (request) => { + try { + const { + connector, + chainNetwork, + walletAddress, + baseToken, + quoteToken, + initialPrice, + binStep, + feeBps, + ammConfigIndex, + fee, + gasPrice, + maxGas, + } = request.body; + + const { network } = parseChainNetwork(chainNetwork); + + switch (connector) { + case 'meteora': + return await meteoraCreatePool( + network, + walletAddress, + baseToken, + quoteToken, + initialPrice, + binStep, + feeBps, + ); + case 'raydium': + return await raydiumCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, ammConfigIndex); + case 'uniswap': + return await uniswapCreatePool( + network, + walletAddress, + baseToken, + quoteToken, + initialPrice, + fee, + gasPrice, + maxGas, + ); + 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'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/trading/trading-clmm-routes/index.ts b/src/trading/trading-clmm-routes/index.ts index 846ffeeb91..f557f7daf3 100644 --- a/src/trading/trading-clmm-routes/index.ts +++ b/src/trading/trading-clmm-routes/index.ts @@ -3,3 +3,4 @@ export { addLiquidityRoute } from './add'; export { removeLiquidityRoute } from './remove'; export { collectFeesRoute } from './collect-fees'; export { closePositionRoute } from './close'; +export { createPoolRoute } from './create-pool'; diff --git a/src/trading/trading.routes.ts b/src/trading/trading.routes.ts index 82e2cd7cf2..3e5877014e 100644 --- a/src/trading/trading.routes.ts +++ b/src/trading/trading.routes.ts @@ -23,6 +23,7 @@ import { removeLiquidityRoute, collectFeesRoute, closePositionRoute, + createPoolRoute as clmmCreatePoolRoute, } from './trading-clmm-routes'; export const tradingSwapRoutes: FastifyPluginAsync = async (fastify) => { @@ -48,6 +49,7 @@ export const tradingClmmRoutes: FastifyPluginAsync = async (fastify) => { fastify.register(removeLiquidityRoute); fastify.register(collectFeesRoute); fastify.register(closePositionRoute); + fastify.register(clmmCreatePoolRoute); }; export const tradingAmmRoutes: FastifyPluginAsync = async (fastify) => { diff --git a/test/connectors/meteora/clmm-routes/create-pool.test.ts b/test/connectors/meteora/clmm-routes/create-pool.test.ts new file mode 100644 index 0000000000..cc6fa6c2a1 --- /dev/null +++ b/test/connectors/meteora/clmm-routes/create-pool.test.ts @@ -0,0 +1,61 @@ +import { Solana } from '../../../../src/chains/solana/solana'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/chains/solana/solana.config', () => ({ + getSolanaChainConfig: jest.fn().mockReturnValue({ + defaultNetwork: 'mainnet-beta', + defaultWallet: '11111111111111111111111111111111', + }), +})); + +const SAME_MINT = 'So11111111111111111111111111111111111111112'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../../src/connectors/meteora/clmm-routes/createPool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /create-pool (Meteora DLMM)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + // Resolve both baseToken and quoteToken to the SAME mint so the pool would be degenerate. + (Solana.getInstance as jest.Mock).mockResolvedValue({ + network: 'mainnet-beta', + connection: {}, + getToken: jest.fn().mockResolvedValue({ address: SAME_MINT, decimals: 9 }), + }); + }); + + it('returns 400 when baseToken and quoteToken resolve to the same mint', async () => { + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'mainnet-beta', + walletAddress: '82Sg8kkChhY7Qb2ptR4uLGqLg7Zm3z9v9tQ6Zb6Jk4iZ', + baseToken: 'SOL', + quoteToken: 'SOL', + initialPrice: 150, + binStep: 20, + feeBps: 20, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/must be different/); + }); +}); diff --git a/test/connectors/raydium/clmm-routes/create-pool.test.ts b/test/connectors/raydium/clmm-routes/create-pool.test.ts new file mode 100644 index 0000000000..d858f84db9 --- /dev/null +++ b/test/connectors/raydium/clmm-routes/create-pool.test.ts @@ -0,0 +1,109 @@ +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; + +import { Solana } from '../../../../src/chains/solana/solana'; +import { Raydium } from '../../../../src/connectors/raydium/raydium'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/raydium/raydium'); +// Stub getMint so decimals resolve without a real RPC; keep the rest of spl-token real. +jest.mock('@solana/spl-token', () => ({ + ...jest.requireActual('@solana/spl-token'), + getMint: jest.fn(() => Promise.resolve({ decimals: 9 })), +})); + +const mockSOL = { symbol: 'SOL', address: 'So11111111111111111111111111111111111111112', decimals: 9 }; +const mockUSDC = { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }; +const mockWallet = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../../src/connectors/raydium/clmm-routes/createPool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /create-pool (Raydium CLMM)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + (Raydium.getInstance as jest.Mock).mockResolvedValue({ + setOwner: jest.fn().mockResolvedValue(undefined), + }); + }); + + it('rejects when baseToken and quoteToken resolve to the same mint', async () => { + // Resolve both tokens to the same mint so the base != quote guard fires before any SDK call. + (Solana.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn(() => Promise.resolve(mockSOL)), + }); + + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'mainnet-beta', + walletAddress: mockWallet, + baseToken: 'SOL', + quoteToken: 'SOL', + initialPrice: 150, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/must be different/); + }); + + it('rejects when ammConfigIndex is out of range', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue({ + network: 'mainnet-beta', + getToken: jest.fn((t: string) => Promise.resolve(t === 'SOL' ? mockSOL : mockUSDC)), + connection: { + // getMintProgram reads the mint account owner — return the classic SPL Token program. + getAccountInfo: jest.fn(() => Promise.resolve({ owner: TOKEN_PROGRAM_ID })), + }, + }); + + (Raydium.getInstance as jest.Mock).mockResolvedValue({ + setOwner: jest.fn().mockResolvedValue(undefined), + txVersion: 0, + raydiumSDK: { + api: { + // Single config available -> requested index 5 is out of range. + getClmmConfigs: jest.fn(() => + Promise.resolve([ + { id: 'AmmConfig1111111111111111111111111111111111', index: 0, tradeFeeRate: 100, tickSpacing: 1 }, + ]), + ), + }, + clmm: { createPool: jest.fn() }, + }, + }); + + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'mainnet-beta', + walletAddress: mockWallet, + baseToken: 'SOL', + quoteToken: 'USDC', + initialPrice: 150, + ammConfigIndex: 5, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/out of range/); + }); +}); diff --git a/test/connectors/uniswap/clmm-routes/create-pool.test.ts b/test/connectors/uniswap/clmm-routes/create-pool.test.ts new file mode 100644 index 0000000000..8eebf6fe82 --- /dev/null +++ b/test/connectors/uniswap/clmm-routes/create-pool.test.ts @@ -0,0 +1,77 @@ +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); + +const mockWETH = { symbol: 'WETH', address: '0x4200000000000000000000000000000000000006', decimals: 18 }; +const mockUSDC = { symbol: 'USDC', address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', decimals: 6 }; +const mockWallet = '0x0000000000000000000000000000000000000001'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../../src/connectors/uniswap/clmm-routes/createPool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /create-pool (Uniswap V3 CLMM)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + const tokenByLookup: Record = { + WETH: mockWETH, + ETH: mockWETH, + USDC: mockUSDC, + }; + (Ethereum.getInstance as jest.Mock).mockResolvedValue({ + getWallet: jest.fn().mockResolvedValue({ address: mockWallet }), + getToken: jest.fn((sym: string) => Promise.resolve(tokenByLookup[sym.toUpperCase()])), + }); + }); + + it('rejects an invalid fee tier with a clear 400', async () => { + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'base', + walletAddress: mockWallet, + baseToken: 'WETH', + quoteToken: 'USDC', + fee: 1234, // not one of 100 / 500 / 3000 / 10000 + initialPrice: 3000, + }, + }); + + // Fastify schema validation rejects the out-of-enum fee before the handler runs → 400. + expect(response.statusCode).toBe(400); + }); + + it('rejects baseToken == quoteToken with a clear 400', async () => { + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'base', + walletAddress: mockWallet, + baseToken: 'WETH', + quoteToken: 'WETH', + fee: 3000, + initialPrice: 3000, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/different/i); + }); +}); diff --git a/test/trading/trading-clmm-routes/create-pool.test.ts b/test/trading/trading-clmm-routes/create-pool.test.ts new file mode 100644 index 0000000000..4c1da80f74 --- /dev/null +++ b/test/trading/trading-clmm-routes/create-pool.test.ts @@ -0,0 +1,38 @@ +import { fastifyWithTypeProvider } from '../../utils/testUtils'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../src/trading/trading-clmm-routes/create-pool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /trading/clmm/create-pool (unified dispatch)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + it('rejects an unsupported CLMM connector', async () => { + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + connector: 'notaconnector', + chainNetwork: 'solana-mainnet-beta', + walletAddress: '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5', + baseToken: 'SOL', + quoteToken: 'USDC', + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/Unsupported CLMM connector/); + }); +}); From c1e202249621cf978ec53f9f067fe182fd57b6d9 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 5 Aug 2026 08:46:57 -0700 Subject: [PATCH 07/10] refactor(clmm): standardize orca/pancakeswap/pancakeswap-sol quoteSwap/executeSwap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the CLMM swap standardization to the remaining pool-addressed connectors: quoteSwap/ executeSwap now require poolAddress and drop quoteToken (derived from the pool via a per-connector resolveCounterToken) — orca via getWhirlpool tokenMintA/B, pancakeswap via getPancakeswapPoolInfo, pancakeswap-sol via getClmmPoolInfo. Swap-router call sites updated. Router-only connectors (0x/dflow/jupiter/okx/titan) keep quoteToken by design (no pool). All Gateway pool-based DEX swap functions now share one signature. tsc clean; 235 tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY --- .../orca/clmm-routes/executeSwap.ts | 15 +++--- src/connectors/orca/clmm-routes/quoteSwap.ts | 30 ++++++++++-- .../clmm-routes/executeSwap.ts | 49 ++++++++++++++++--- .../pancakeswap-sol/clmm-routes/quoteSwap.ts | 37 +++++++++++++- .../pancakeswap/clmm-routes/executeSwap.ts | 31 +++++++----- .../pancakeswap/clmm-routes/quoteSwap.ts | 25 ++++++++-- src/trading/swap/execute.ts | 26 +++++----- src/trading/swap/quote.ts | 6 +-- .../orca/clmm-routes/executeSwap.test.ts | 5 ++ test/lifecycle/pancakeswap-sol-routes.test.ts | 36 ++++++-------- 10 files changed, 186 insertions(+), 74 deletions(-) diff --git a/src/connectors/orca/clmm-routes/executeSwap.ts b/src/connectors/orca/clmm-routes/executeSwap.ts index 5e354bec43..fcfc1bdcd7 100644 --- a/src/connectors/orca/clmm-routes/executeSwap.ts +++ b/src/connectors/orca/clmm-routes/executeSwap.ts @@ -14,22 +14,26 @@ import { logger } from '../../../services/logger'; import { Orca } from '../orca'; import { OrcaClmmExecuteSwapRequest, OrcaClmmExecuteSwapRequestType } from '../schemas'; +import { resolveCounterToken } from './quoteSwap'; + const COMPUTE_BUDGET_PROGRAM_ID = address('ComputeBudget111111111111111111111111111111'); export async function executeSwap( network: string, walletAddress: string, + poolAddress: string, baseTokenIdentifier: string, - quoteTokenIdentifier: string, - amount: number, side: 'BUY' | 'SELL', - poolAddress: string, + amount: number, slippagePct: number = 1, ): Promise { const solana = await Solana.getInstance(network); const orca = await Orca.getInstance(network); const rpc = orca.solanaKitRpc; + // Standardized: quote token is derived from the pool given poolAddress + baseToken. + const quoteTokenIdentifier = await resolveCounterToken(network, poolAddress, baseTokenIdentifier); + // Resolve token metadata const baseTokenInfo = await solana.getToken(baseTokenIdentifier); const quoteTokenInfo = await solana.getToken(quoteTokenIdentifier); @@ -189,11 +193,10 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { return await executeSwap( networkUsed, walletAddressUsed, + poolAddressUsed, baseToken, - quoteToken, - amount, side as 'BUY' | 'SELL', - poolAddressUsed, + amount, slippagePct, ); } catch (e: any) { diff --git a/src/connectors/orca/clmm-routes/quoteSwap.ts b/src/connectors/orca/clmm-routes/quoteSwap.ts index 526474668c..e2c200d041 100644 --- a/src/connectors/orca/clmm-routes/quoteSwap.ts +++ b/src/connectors/orca/clmm-routes/quoteSwap.ts @@ -156,15 +156,37 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { export default quoteSwapRoute; -// Export quoteSwap wrapper for unified trading routes +/** + * Resolves the counter ("quote") token for an Orca whirlpool given the base token. The standardized + * swap wrappers take poolAddress + baseToken and derive the other side from the pool + * (tokenMintA/tokenMintB), so callers no longer pass quoteToken. + */ +export async function resolveCounterToken(network: string, poolAddress: string, baseToken: string): Promise { + const solana = await Solana.getInstance(network); + const orca = await Orca.getInstance(network); + const whirlpool = await orca.getWhirlpool(poolAddress); + if (!whirlpool) throw httpErrors.notFound(`Pool not found: ${poolAddress}`); + const mintA = whirlpool.tokenMintA.toString(); + const mintB = whirlpool.tokenMintB.toString(); + const resolved = await solana.getToken(baseToken); + const baseAddr = resolved ? resolved.address : baseToken; + if (baseAddr === mintA) return mintB; + if (baseAddr === mintB) return mintA; + throw httpErrors.badRequest(`Token ${baseToken} is not part of pool ${poolAddress}`); +} + +/** + * Standard CLMM quote-swap entry point (network-based) — consumed by the unified swap router. + * Requires poolAddress; the quote token is derived from the pool. + */ export async function quoteSwap( network: string, + poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', - poolAddress: string, + amount: number, slippagePct?: number, ): Promise { + const quoteToken = await resolveCounterToken(network, poolAddress, baseToken); return await formatSwapQuote(network, baseToken, quoteToken, amount, side, poolAddress, slippagePct); } diff --git a/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts b/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts index aefeacb892..a291385bbe 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts @@ -20,16 +20,26 @@ import { PancakeswapSolClmmExecuteSwapRequest, PancakeswapSolClmmExecuteSwapRequ export async function executeSwap( network: string, walletAddress: string, + poolAddress: string, baseTokenSymbol: string, - quoteTokenSymbol: string, - amount: number, side: 'BUY' | 'SELL', - poolAddress?: string, + amount: number, slippagePct?: number, ): Promise { + // Standardized: quote token is derived from the pool given poolAddress + baseToken. + const { getRawSwapQuote, resolveCounterToken } = await import('./quoteSwap'); + const quoteTokenSymbol = await resolveCounterToken(network, poolAddress, baseTokenSymbol); + // Get quote first - this contains all the slippage calculations and pool lookup - const { quoteSwap } = await import('./quoteSwap'); - const quote = await quoteSwap(network, baseTokenSymbol, quoteTokenSymbol, amount, side, poolAddress, slippagePct); + const quote = await getRawSwapQuote( + network, + baseTokenSymbol, + quoteTokenSymbol, + amount, + side, + poolAddress, + slippagePct, + ); const solana = await Solana.getInstance(network); const pancakeswapSol = await PancakeswapSol.getInstance(network); @@ -207,14 +217,37 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { slippagePct, } = request.body; + // executeSwap is standardized to require poolAddress; resolve it from the pair when absent. + let poolAddressToUse = poolAddress; + if (!poolAddressToUse) { + const solana = await Solana.getInstance(network); + const baseTokenInfo = await solana.getToken(baseToken); + const quoteTokenInfo = await solana.getToken(quoteToken); + if (!baseTokenInfo || !quoteTokenInfo) { + throw httpErrors.badRequest(`Token not found: ${!baseTokenInfo ? baseToken : quoteToken}`); + } + const { PoolService } = await import('../../../services/pool-service'); + const poolService = PoolService.getInstance(); + const pool = await poolService.getPool( + 'pancakeswap-sol', + network, + 'clmm', + baseTokenInfo.symbol, + quoteTokenInfo.symbol, + ); + if (!pool) { + throw httpErrors.notFound(`No CLMM pool found for ${baseTokenInfo.symbol}-${quoteTokenInfo.symbol}`); + } + poolAddressToUse = pool.address; + } + return await executeSwap( network, walletAddress!, + poolAddressToUse, baseToken, - quoteToken, - amount, side as 'BUY' | 'SELL', - poolAddress, + amount, slippagePct, ); } catch (e: any) { diff --git a/src/connectors/pancakeswap-sol/clmm-routes/quoteSwap.ts b/src/connectors/pancakeswap-sol/clmm-routes/quoteSwap.ts index 5c5b89230a..4b9d5013b3 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/quoteSwap.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/quoteSwap.ts @@ -23,7 +23,7 @@ import { PancakeswapSolClmmQuoteSwapRequest, PancakeswapSolClmmQuoteSwapRequestT * * For highest precision, this should be replaced with full tick array calculation. */ -export async function quoteSwap( +export async function getRawSwapQuote( network: string, baseTokenSymbol: string, quoteTokenSymbol: string, @@ -171,7 +171,7 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { slippagePct, } = request.query; - return await quoteSwap( + return await getRawSwapQuote( network, baseToken, quoteToken, @@ -195,3 +195,36 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { }; export default quoteSwapRoute; + +/** + * Resolves the counter ("quote") token for a PancakeSwap Solana CLMM pool given the base token. The + * standardized swap wrappers take poolAddress + baseToken and derive the other side from the pool, + * so callers no longer pass quoteToken. + */ +export async function resolveCounterToken(network: string, poolAddress: string, baseToken: string): Promise { + const solana = await Solana.getInstance(network); + const pancakeswapSol = await PancakeswapSol.getInstance(network); + const poolInfo = await pancakeswapSol.getClmmPoolInfo(poolAddress); + if (!poolInfo) throw httpErrors.notFound(`Pool not found: ${poolAddress}`); + const resolved = await solana.getToken(baseToken); + const baseAddr = resolved ? resolved.address : baseToken; + if (baseAddr === poolInfo.baseTokenAddress) return poolInfo.quoteTokenAddress; + if (baseAddr === poolInfo.quoteTokenAddress) return poolInfo.baseTokenAddress; + throw httpErrors.badRequest(`Token ${baseToken} is not part of pool ${poolAddress}`); +} + +/** + * Standard CLMM quote-swap entry point (network-based) — consumed by the unified swap router. + * Requires poolAddress; the quote token is derived from the pool. + */ +export async function quoteSwap( + network: string, + poolAddress: string, + baseToken: string, + side: 'BUY' | 'SELL', + amount: number, + slippagePct: number = PancakeswapSolConfig.config.slippagePct, +): Promise { + const quoteToken = await resolveCounterToken(network, poolAddress, baseToken); + return await getRawSwapQuote(network, baseToken, quoteToken, amount, side, poolAddress, slippagePct); +} diff --git a/src/connectors/pancakeswap/clmm-routes/executeSwap.ts b/src/connectors/pancakeswap/clmm-routes/executeSwap.ts index 9a9d345a36..47fd5e91da 100644 --- a/src/connectors/pancakeswap/clmm-routes/executeSwap.ts +++ b/src/connectors/pancakeswap/clmm-routes/executeSwap.ts @@ -13,30 +13,27 @@ import { getPancakeswapV3SwapRouter02Address, ISwapRouter02ABI } from '../pancak import { formatTokenAmount } from '../pancakeswap.utils'; import { PancakeswapExecuteSwapRequest } from '../schemas'; -import { getPancakeswapClmmQuote } from './quoteSwap'; +import { getPancakeswapClmmQuote, resolveCounterToken } from './quoteSwap'; // Default gas limit for CLMM swap operations const CLMM_SWAP_GAS_LIMIT = 350000; export async function executeClmmSwap( - walletAddress: string, network: string, + walletAddress: string, + poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', + amount: number, slippagePct: number = PancakeswapConfig.config.slippagePct, ): Promise { const ethereum = await Ethereum.getInstance(network); await ethereum.init(); - const pancakeswap = await Pancakeswap.getInstance(network); + await Pancakeswap.getInstance(network); - // Find pool address - const poolAddress = await pancakeswap.findDefaultPool(baseToken, quoteToken, 'clmm'); - if (!poolAddress) { - throw httpErrors.notFound(`No CLMM pool found for pair ${baseToken}-${quoteToken}`); - } + // Standardized: quote token is derived from the pool given poolAddress + baseToken. + const quoteToken = await resolveCounterToken(network, poolAddress, baseToken); // Get quote using the shared quote function const { quote } = await getPancakeswapClmmQuote( @@ -347,13 +344,21 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct } = request.body as typeof PancakeswapExecuteSwapRequest._type; + // This route resolves the pool from the pair (no poolAddress in its request schema); + // executeClmmSwap itself is standardized to require poolAddress. + const pancakeswap = await Pancakeswap.getInstance(network); + const poolAddress = await pancakeswap.findDefaultPool(baseToken, quoteToken, 'clmm'); + if (!poolAddress) { + throw httpErrors.notFound(`No CLMM pool found for pair ${baseToken}-${quoteToken}`); + } + return await executeClmmSwap( - walletAddress, network, + walletAddress, + poolAddress, baseToken, - quoteToken, - amount, side as 'BUY' | 'SELL', + amount, slippagePct, ); } catch (e) { diff --git a/src/connectors/pancakeswap/clmm-routes/quoteSwap.ts b/src/connectors/pancakeswap/clmm-routes/quoteSwap.ts index b40178d54b..6eba0401fc 100644 --- a/src/connectors/pancakeswap/clmm-routes/quoteSwap.ts +++ b/src/connectors/pancakeswap/clmm-routes/quoteSwap.ts @@ -390,15 +390,34 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { export default quoteSwapRoute; -// Export quoteSwap wrapper for chain-level routes +/** + * Resolves the counter ("quote") token for a Pancakeswap V3 pool given the base token. The + * standardized swap wrappers take poolAddress + baseToken and derive the other side from the pool, + * so callers no longer pass quoteToken. + */ +export async function resolveCounterToken(network: string, poolAddress: string, baseToken: string): Promise { + const poolInfo = await getPancakeswapPoolInfo(poolAddress, network, 'clmm'); + if (!poolInfo) throw httpErrors.notFound(sanitizeErrorMessage('Pool not found: {}', poolAddress)); + const pancakeswap = await Pancakeswap.getInstance(network); + const resolved = await pancakeswap.getToken(baseToken); + const baseAddr = resolved ? resolved.address : baseToken; + if (baseAddr === poolInfo.baseTokenAddress) return poolInfo.quoteTokenAddress; + if (baseAddr === poolInfo.quoteTokenAddress) return poolInfo.baseTokenAddress; + throw httpErrors.badRequest(`Token ${baseToken} is not part of pool ${poolAddress}`); +} + +/** + * Standard CLMM quote-swap entry point (network-based) — consumed by the unified swap router. + * Requires poolAddress; the quote token is derived from the pool. + */ export async function quoteSwap( network: string, poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', + amount: number, slippagePct: number = PancakeswapConfig.config.slippagePct, ): Promise { + const quoteToken = await resolveCounterToken(network, poolAddress, baseToken); return await formatSwapQuote(network, poolAddress, baseToken, quoteToken, amount, side, slippagePct); } diff --git a/src/trading/swap/execute.ts b/src/trading/swap/execute.ts index caed349f13..11f35d1ed4 100644 --- a/src/trading/swap/execute.ts +++ b/src/trading/swap/execute.ts @@ -182,24 +182,14 @@ async function executeSolanaSwap( return await pancakeswapSolClmmExecuteSwap( network, walletAddress, + poolAddress!, baseToken, - quoteToken, - amount, side, - poolAddress, - slippagePct, - ); - } else if (providerKey === 'orca/clmm') { - return await orcaClmmExecuteSwap( - network, - walletAddress, - baseToken, - quoteToken, amount, - side, - poolAddress!, slippagePct, ); + } else if (providerKey === 'orca/clmm') { + return await orcaClmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } throw httpErrors.badRequest(`Unsupported swap provider: ${swapProvider}`); @@ -274,7 +264,15 @@ async function executeEthereumSwap( } else if (providerKey === 'pancakeswap/amm') { return await pancakeswapAmmExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'pancakeswap/clmm') { - return await pancakeswapClmmExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); + return await pancakeswapClmmExecuteSwap( + network, + walletAddress, + poolAddress!, + baseToken, + side, + amount, + slippagePct, + ); } else if (providerKey === '0x/router') { return await zeroXRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); } diff --git a/src/trading/swap/quote.ts b/src/trading/swap/quote.ts index d072c5df84..69af5d316f 100644 --- a/src/trading/swap/quote.ts +++ b/src/trading/swap/quote.ts @@ -163,9 +163,9 @@ async function getSolanaQuoteSwap( } else if (providerKey === 'meteora/clmm') { return await meteoraClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'pancakeswap-sol/clmm') { - return await pancakeswapSolClmmQuoteSwap(network, baseToken, quoteToken, amount, side, poolAddress, slippagePct); + return await pancakeswapSolClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'orca/clmm') { - return await orcaClmmQuoteSwap(network, baseToken, quoteToken, amount, side, poolAddress!, slippagePct); + return await orcaClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } throw httpErrors.badRequest(`Unsupported swap provider: ${swapProvider}`); @@ -231,7 +231,7 @@ async function getEthereumQuoteSwap( } else if (providerKey === 'pancakeswap/amm') { return await pancakeswapAmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'pancakeswap/clmm') { - return await pancakeswapClmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); + return await pancakeswapClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === '0x/router') { return await zeroXRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct || 1); } diff --git a/test/connectors/orca/clmm-routes/executeSwap.test.ts b/test/connectors/orca/clmm-routes/executeSwap.test.ts index 1f6bf54fcd..7ec7265bb4 100644 --- a/test/connectors/orca/clmm-routes/executeSwap.test.ts +++ b/test/connectors/orca/clmm-routes/executeSwap.test.ts @@ -355,6 +355,11 @@ describe('POST /execute-swap', () => { send: jest.fn().mockResolvedValue({ value: { blockhash: mockBlockhash, lastValidBlockHeight: 12345n } }), }), }, + // resolveCounterToken (standardized wrapper) derives the counter token from the pool. + getWhirlpool: jest.fn().mockResolvedValue({ + tokenMintA: mockBaseTokenInfo.address, + tokenMintB: mockQuoteTokenInfo.address, + }), }); (fetchWhirlpool as jest.Mock).mockResolvedValue({ data: { tokenMintA: mockBaseTokenInfo.address, tokenMintB: mockQuoteTokenInfo.address }, diff --git a/test/lifecycle/pancakeswap-sol-routes.test.ts b/test/lifecycle/pancakeswap-sol-routes.test.ts index d54698d884..461ce29367 100644 --- a/test/lifecycle/pancakeswap-sol-routes.test.ts +++ b/test/lifecycle/pancakeswap-sol-routes.test.ts @@ -131,7 +131,7 @@ describe('PancakeSwap Solana - Comprehensive Route Tests', () => { describe('Quote Swap Route', () => { it('should quote SELL swap with exact input', async () => { - const quote = await quoteSwap(NETWORK, 'SOL', 'USDC', 0.01, 'SELL', SOL_USDC_POOL); + const quote = await quoteSwap(NETWORK, SOL_USDC_POOL, 'SOL', 'SELL', 0.01); expect(quote).toBeDefined(); expect(quote.amountOut).toBeGreaterThan(0); @@ -148,7 +148,7 @@ describe('PancakeSwap Solana - Comprehensive Route Tests', () => { }, 30000); it('should quote BUY swap with exact output', async () => { - const quote = await quoteSwap(NETWORK, 'SOL', 'USDC', 0.01, 'BUY', SOL_USDC_POOL); + const quote = await quoteSwap(NETWORK, SOL_USDC_POOL, 'SOL', 'BUY', 0.01); expect(quote).toBeDefined(); expect(quote.amountOut).toBeGreaterThan(0); @@ -166,21 +166,19 @@ describe('PancakeSwap Solana - Comprehensive Route Tests', () => { it('should handle different slippage percentages', async () => { const quote1 = await quoteSwap( NETWORK, + SOL_USDC_POOL, 'SOL', - 'USDC', - 0.01, 'SELL', - SOL_USDC_POOL, + 0.01, 1, // 1% slippage ); const quote2 = await quoteSwap( NETWORK, + SOL_USDC_POOL, 'SOL', - 'USDC', - 0.01, 'SELL', - SOL_USDC_POOL, + 0.01, 5, // 5% slippage ); @@ -192,22 +190,17 @@ describe('PancakeSwap Solana - Comprehensive Route Tests', () => { console.log(` 5% slippage - Min: ${quote2.minAmountOut.toFixed(6)} USDC`); }, 30000); - it('should find pool automatically when not specified', async () => { - const quote = await quoteSwap( - NETWORK, - 'SOL', - 'USDC', - 0.01, - 'SELL', - undefined, // no pool address - ); + it('should quote against the specified pool', async () => { + // The standardized wrapper requires poolAddress; the counter token is derived from the pool + // (auto pool discovery from a token pair is now a route-level concern, not the wrapper's). + const quote = await quoteSwap(NETWORK, SOL_USDC_POOL, 'SOL', 'SELL', 0.01); expect(quote).toBeDefined(); - expect(quote.poolAddress).toBeDefined(); + expect(quote.poolAddress).toBe(SOL_USDC_POOL); expect(quote.amountOut).toBeGreaterThan(0); - console.log('\n💱 Auto Pool Discovery:'); - console.log(` Found pool: ${quote.poolAddress}`); + console.log('\n💱 Pool-addressed quote:'); + console.log(` Pool: ${quote.poolAddress}`); }, 30000); }); @@ -328,7 +321,8 @@ describe('PancakeSwap Solana - Comprehensive Route Tests', () => { }, 30000); it('should handle invalid token symbols in quote-swap', async () => { - await expect(quoteSwap(NETWORK, 'INVALID', 'USDC', 0.01, 'SELL')).rejects.toThrow(); + // Standardized wrapper: an unknown base token that isn't part of the pool is rejected. + await expect(quoteSwap(NETWORK, SOL_USDC_POOL, 'INVALID', 'SELL', 0.01)).rejects.toThrow(); }, 30000); it('should handle zero amounts in quote-position', async () => { From 89c01dedcbe327f5d5bfc174ce42dc28253db069 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 5 Aug 2026 09:08:03 -0700 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20complete=20DEX=20standardization?= =?UTF-8?q?=20=E2=80=94=20pancakeswap=20AMM=20ops=20+=20create-pool=20for?= =?UTF-8?q?=20orca/pancakeswap/pancakeswap-sol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes standardizing every pool-based Gateway DEX so all share one interface. Standardization: - PancakeSwap AMM: standardized the 7 ops to network-based signatures (getPoolInfo, getPositionInfo, quoteSwap, executeSwap, quoteLiquidity, addLiquidity, removeLiquidity) mirroring uniswap AMM, and wired pancakeswap into the unified /trading/amm/* dispatch (+ swap-router AMM branches). create-pool (create + initialize at price; no position seeded): - Orca (Whirlpools): client.createPool with tickSpacing + initialTick (PriceMath), PoolUtil.orderMints. - PancakeSwap AMM (V2 fork): router.addLiquidity auto-create; CLMM (V3 fork): NonfungiblePositionManager.createAndInitializePoolIfNecessary, fee tiers 100/500/2500/10000 (+ V3 factory/pool/NFT-manager ABIs). - pancakeswap-sol CLMM: manual create_pool instruction from the bundled IDL (discriminator + PDA seeds decoded from IDL bytes), sqrt_price_x64 from priceToSqrtPriceX64, explicit ammConfig. - Unified /trading/{amm,clmm}/create-pool dispatch extended to all six pool-based connectors. Router-only connectors (0x/dflow/jupiter/okx/titan) are excluded by design (no pool). All connector + trading tests pass (511); tsc/lint/build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY --- src/connectors/orca/clmm-routes/createPool.ts | 201 +++++++++ src/connectors/orca/clmm-routes/index.ts | 2 + src/connectors/orca/schemas.ts | 41 ++ .../pancakeswap-sol/clmm-routes/createPool.ts | 255 ++++++++++++ .../pancakeswap-sol/clmm-routes/index.ts | 2 + .../pancakeswap-sol.instructions.ts | 82 ++++ src/connectors/pancakeswap-sol/schemas.ts | 42 ++ .../pancakeswap/amm-routes/addLiquidity.ts | 46 ++- .../pancakeswap/amm-routes/createPool.ts | 382 ++++++++++++++++++ .../pancakeswap/amm-routes/executeSwap.ts | 19 +- .../pancakeswap/amm-routes/index.ts | 2 + .../pancakeswap/amm-routes/poolInfo.ts | 102 ++--- .../pancakeswap/amm-routes/poolTokens.ts | 49 +++ .../pancakeswap/amm-routes/positionInfo.ts | 62 +++ .../pancakeswap/amm-routes/quoteLiquidity.ts | 32 ++ .../pancakeswap/amm-routes/quoteSwap.ts | 13 +- .../pancakeswap/amm-routes/removeLiquidity.ts | 292 ++++++------- .../pancakeswap/clmm-routes/createPool.ts | 287 +++++++++++++ .../pancakeswap/clmm-routes/index.ts | 2 + .../pancakeswap/pancakeswap.contracts.ts | 78 ++++ src/connectors/pancakeswap/schemas.ts | 111 +++++ src/trading/swap/execute.ts | 10 +- src/trading/swap/quote.ts | 2 +- .../trading-amm-routes/add-liquidity.ts | 10 + src/trading/trading-amm-routes/common.ts | 2 +- src/trading/trading-amm-routes/create-pool.ts | 15 + .../trading-amm-routes/execute-swap.ts | 3 + src/trading/trading-amm-routes/pool-info.ts | 3 + .../trading-amm-routes/position-info.ts | 3 + .../trading-amm-routes/quote-liquidity.ts | 9 + src/trading/trading-amm-routes/quote-swap.ts | 3 + .../trading-amm-routes/remove-liquidity.ts | 9 + .../trading-clmm-routes/create-pool.ts | 41 +- .../orca/clmm-routes/create-pool.test.ts | 86 ++++ .../clmm-routes/create-pool.test.ts | 100 +++++ .../amm-routes/create-pool.test.ts | 55 +++ .../clmm-routes/create-pool.test.ts | 78 ++++ 37 files changed, 2293 insertions(+), 238 deletions(-) create mode 100644 src/connectors/orca/clmm-routes/createPool.ts create mode 100644 src/connectors/pancakeswap-sol/clmm-routes/createPool.ts create mode 100644 src/connectors/pancakeswap/amm-routes/createPool.ts create mode 100644 src/connectors/pancakeswap/amm-routes/poolTokens.ts create mode 100644 src/connectors/pancakeswap/clmm-routes/createPool.ts create mode 100644 test/connectors/orca/clmm-routes/create-pool.test.ts create mode 100644 test/connectors/pancakeswap-sol/clmm-routes/create-pool.test.ts create mode 100644 test/connectors/pancakeswap/amm-routes/create-pool.test.ts create mode 100644 test/connectors/pancakeswap/clmm-routes/create-pool.test.ts diff --git a/src/connectors/orca/clmm-routes/createPool.ts b/src/connectors/orca/clmm-routes/createPool.ts new file mode 100644 index 0000000000..26969aa2af --- /dev/null +++ b/src/connectors/orca/clmm-routes/createPool.ts @@ -0,0 +1,201 @@ +import { ORCA_WHIRLPOOL_PROGRAM_ID, ORCA_WHIRLPOOLS_CONFIG, PoolUtil, PriceMath } from '@orca-so/whirlpools-sdk'; +import { Static } from '@sinclair/typebox'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import { Decimal } from 'decimal.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { Solana } from '../../../chains/solana/solana'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { Orca } from '../orca'; +import { OrcaClmmCreatePoolRequest } from '../schemas'; + +/** Resolves a token symbol or mint address to a PublicKey. */ +async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { + const tokenInfo = await solana.getToken(tokenOrAddress); + if (tokenInfo) return new PublicKey(tokenInfo.address); + try { + return new PublicKey(tokenOrAddress); + } catch { + throw httpErrors.badRequest(sanitizeErrorMessage('Token not found: {}', tokenOrAddress)); + } +} + +/** + * Fetches the current market price (quote per base) from the unified swap router so a new pool can + * be initialized on-market instead of at an arbitrary ratio. Off-market initialization invites + * arbitrage bots to instantly move the price. Uses a SELL quote of 1 base token via the network's + * configured swap provider; throws a clear error if no market route exists. + */ +async function fetchMarketPrice(network: string, baseToken: string, quoteToken: string): Promise { + const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + let quote: any; + try { + // Probe with 1 base token — we only need the price ratio, not a real trade size. + quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); + } catch (e: any) { + throw httpErrors.badRequest( + `Could not fetch a market price for ${baseToken}/${quoteToken} to initialize the pool (${e.message}). ` + + 'Pass initialPrice explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw httpErrors.badRequest(`No market route found for ${baseToken}/${quoteToken}. Pass initialPrice explicitly.`); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} + +export async function createPool( + network: string, + walletAddress: string, + baseToken: string, + quoteToken: string, + initialPrice?: number, + tickSpacing?: number, +): Promise { + // tickSpacing selects the fee tier; a FeeTier account for the config+tickSpacing must exist + // on-chain. Validate it up front so bad input fails fast with a clear 400. + if (tickSpacing === undefined || !Number.isInteger(tickSpacing) || tickSpacing <= 0) { + throw httpErrors.badRequest('tickSpacing must be a positive integer'); + } + + const solana = await Solana.getInstance(network); + const orca = await Orca.getInstance(network); + // Build with the wallet's public key as authority — signing/sending is delegated to + // sendAndConfirmTransactionForWallet, which knows how to sign for each wallet type. + const client = await orca.getWhirlpoolClientForWallet(walletAddress); + const funder = client.getContext().wallet.publicKey; + + const baseMint = await resolveMint(solana, baseToken); + const quoteMint = await resolveMint(solana, quoteToken); + if (baseMint.equals(quoteMint)) { + throw httpErrors.badRequest('baseToken and quoteToken must be different'); + } + + // Fetch decimals dynamically from on-chain mint info (handles Token and Token-2022). + const [baseMintInfo, quoteMintInfo] = await Promise.all([ + client.getFetcher().getMintInfo(baseMint), + client.getFetcher().getMintInfo(quoteMint), + ]); + if (!baseMintInfo) throw httpErrors.badRequest(`Mint account not found: ${baseMint.toBase58()}`); + if (!quoteMintInfo) throw httpErrors.badRequest(`Mint account not found: ${quoteMint.toBase58()}`); + const baseDecimals = baseMintInfo.decimals; + const quoteDecimals = quoteMintInfo.decimals; + + // Resolve the seed price (quote per base). Priority: + // 1) explicit initialPrice + // 2) live market price from the unified swap router — so the pool opens on-market. + let seedPrice: number; + let seedSource: string; + if (initialPrice !== undefined) { + if (initialPrice <= 0) throw httpErrors.badRequest('initialPrice must be greater than zero'); + seedPrice = initialPrice; + seedSource = 'initialPrice'; + } else { + seedPrice = await fetchMarketPrice(network, baseToken, quoteToken); + seedSource = 'market (unified swap router)'; + } + logger.info(`Initializing Orca CLMM pool at ${seedPrice} ${quoteToken}/${baseToken} [${seedSource}]`); + + // Whirlpools require canonical mint ordering (tokenA < tokenB by byte-compared pubkey), and + // client.createPool ASSERTS the order rather than sorting — so we sort here. PriceMath expects + // price expressed as tokenB-per-tokenA. Our seedPrice is quote-per-base, so: + // - base sorts as tokenA (base < quote): price stays quote-per-base = seedPrice. + // - base sorts as tokenB (quote < base): price becomes base-per-quote = 1/seedPrice, + // and the decimals A/B swap with the tokens. + // The reported `price` (seedPrice) stays quote-per-base regardless of the on-chain sort. + const [orderedA] = PoolUtil.orderMints(baseMint, quoteMint); + const mintA = new PublicKey(orderedA.toString()); + const baseIsA = mintA.equals(baseMint); + const mintB = baseIsA ? quoteMint : baseMint; + const decimalsA = baseIsA ? baseDecimals : quoteDecimals; + const decimalsB = baseIsA ? quoteDecimals : baseDecimals; + const priceAB = baseIsA ? seedPrice : 1 / seedPrice; + + const initialTick = PriceMath.priceToInitializableTickIndex(new Decimal(priceAB), decimalsA, decimalsB, tickSpacing); + + logger.info( + `Orca createPool: config=${ORCA_WHIRLPOOLS_CONFIG.toBase58()}, program=${ORCA_WHIRLPOOL_PROGRAM_ID.toBase58()}, ` + + `tokenMintA=${mintA.toBase58()}, tokenMintB=${mintB.toBase58()}, tickSpacing=${tickSpacing}, initialTick=${initialTick}`, + ); + + const { poolKey, tx } = await client.createPool( + ORCA_WHIRLPOOLS_CONFIG, + mintA, + mintB, + tickSpacing, + initialTick, + funder, + ); + const poolAddress = poolKey.toBase58(); + + // Pre-check: refuse to re-initialize an existing pool. createPool only builds the tx (no send), + // so we can derive the pool address and check for an existing account before sending. + const existing = await solana.connection.getAccountInfo(poolKey); + if (existing) { + throw httpErrors.badRequest(`Pool already exists for this token pair and tickSpacing: ${poolAddress}`); + } + + logger.info(`Creating Orca CLMM pool ${poolAddress} (${baseToken}/${quoteToken})`); + + // createPool generates the two token-vault keypairs internally; they are returned on the built + // transaction's `signers` and must co-sign. Pass them as extra signers. + const built = await tx.build(); + const extraSigners = (built.signers as Keypair[]) ?? []; + const { signature } = await solana.sendAndConfirmTransactionForWallet(built.transaction, walletAddress, extraSigners); + + const txData = await solana.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + + if (txData) { + return { + signature, + status: 1, // CONFIRMED + poolAddress, + price: seedPrice, + data: { + fee: txData.meta.fee / 1e9, + // Pool created + initialized only — no liquidity/position seeded. + baseTokenAmountAdded: 0, + quoteTokenAmountAdded: 0, + }, + }; + } + return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING +} + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: Static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: + 'Create and initialize a new Orca (Whirlpools) CLMM pool at an initial price. Does not open or seed a position.', + tags: ['/connector/orca'], + body: OrcaClmmCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { network, walletAddress, baseToken, quoteToken, initialPrice, tickSpacing } = request.body; + return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, tickSpacing); + } catch (e) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to create pool'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/connectors/orca/clmm-routes/index.ts b/src/connectors/orca/clmm-routes/index.ts index e0929d9210..4198fb2b05 100644 --- a/src/connectors/orca/clmm-routes/index.ts +++ b/src/connectors/orca/clmm-routes/index.ts @@ -3,6 +3,7 @@ import { FastifyPluginAsync } from 'fastify'; import { addLiquidityRoute } from './addLiquidity'; import { closePositionRoute } from './closePosition'; import { collectFeesRoute } from './collectFees'; +import { createPoolRoute } from './createPool'; import { executeSwapRoute } from './executeSwap'; import { fetchPoolsRoute } from './fetchPools'; import { openPositionRoute } from './openPosition'; @@ -21,6 +22,7 @@ export const orcaClmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(quotePositionRoute); await fastify.register(quoteSwapRoute); await fastify.register(executeSwapRoute); + await fastify.register(createPoolRoute); await fastify.register(openPositionRoute); await fastify.register(addLiquidityRoute); await fastify.register(removeLiquidityRoute); diff --git a/src/connectors/orca/schemas.ts b/src/connectors/orca/schemas.ts index a5044b83f9..e6c69946ed 100644 --- a/src/connectors/orca/schemas.ts +++ b/src/connectors/orca/schemas.ts @@ -240,6 +240,47 @@ export const OrcaClmmOpenPositionRequest = Type.Object({ ), }); +// Orca CLMM Create Pool Request +export const OrcaClmmCreatePoolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...OrcaConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Solana wallet address that will create and initialize the pool', + default: solanaChainConfig.defaultWallet, + examples: [solanaChainConfig.defaultWallet], + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address (becomes the pool base)', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Quote token symbol or address (becomes the pool quote)', + examples: [QUOTE_TOKEN], + }), + tickSpacing: Type.Integer({ + 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, + examples: [64], + }), + initialPrice: Type.Optional( + Type.Number({ + 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.', + examples: [QUOTE_TOKEN_AMOUNT / BASE_TOKEN_AMOUNT], + }), + ), +}); + // Orca CLMM Add Liquidity Request export const OrcaClmmAddLiquidityRequest = Type.Object({ network: Type.Optional( diff --git a/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts b/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts new file mode 100644 index 0000000000..6660c7eb5f --- /dev/null +++ b/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts @@ -0,0 +1,255 @@ +import { Static } from '@sinclair/typebox'; +import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, getMint } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { Solana } from '../../../chains/solana/solana'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { PancakeswapSol, PANCAKESWAP_CLMM_PROGRAM_ID } from '../pancakeswap-sol'; +import { buildCreatePoolInstruction } from '../pancakeswap-sol.instructions'; +import { priceToSqrtPriceX64 } from '../pancakeswap-sol.math'; +import { buildTransactionWithInstructions } from '../pancakeswap-sol.transactions'; +import { PancakeswapSolClmmCreatePoolRequest } from '../schemas'; + +/** Lexicographic byte comparison (mirrors Buffer.compare) for canonical mint ordering. */ +function compareBytes(a: Buffer, b: Buffer): number { + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + } + return a.length - b.length; +} + +/** Resolves a token symbol or mint address to a PublicKey. */ +async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { + const tokenInfo = await solana.getToken(tokenOrAddress); + if (tokenInfo) return new PublicKey(tokenInfo.address); + try { + return new PublicKey(tokenOrAddress); + } catch { + throw httpErrors.badRequest(sanitizeErrorMessage('Token not found: {}', tokenOrAddress)); + } +} + +/** Detects whether a mint is owned by the Token or Token-2022 program. */ +async function getMintProgram(solana: Solana, mint: PublicKey): Promise { + const info = await solana.connection.getAccountInfo(mint); + if (!info) throw httpErrors.badRequest(`Mint account not found: ${mint.toBase58()}`); + if (info.owner.equals(TOKEN_2022_PROGRAM_ID)) return TOKEN_2022_PROGRAM_ID; + if (info.owner.equals(TOKEN_PROGRAM_ID)) return TOKEN_PROGRAM_ID; + throw httpErrors.badRequest(`Mint ${mint.toBase58()} is not an SPL token mint`); +} + +/** + * Fetches the current market price (quote per base) from the unified swap router so a new pool can be + * initialized on-market instead of at an arbitrary ratio. Off-market initialization invites arbitrage + * bots to instantly move the price. Probes with a SELL quote of 1 base token; throws a clear error if + * no market route exists. + */ +async function fetchMarketPrice(network: string, baseToken: string, quoteToken: string): Promise { + const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + let quote: any; + try { + quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); + } catch (e: any) { + throw httpErrors.badRequest( + `Could not fetch a market price for ${baseToken}/${quoteToken} to initialize the pool (${e.message}). ` + + 'Pass initialPrice explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw httpErrors.badRequest(`No market route found for ${baseToken}/${quoteToken}. Pass initialPrice explicitly.`); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} + +/** + * 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). + */ +export async function createPool( + network: string, + walletAddress: string, + baseToken: string, + quoteToken: string, + initialPrice?: number, + ammConfig?: string, +): 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`, + ); + } + + // Resolve mints, decimals and token programs from authoritative on-chain data. + const baseMint = await resolveMint(solana, baseToken); + const quoteMint = await resolveMint(solana, quoteToken); + if (baseMint.equals(quoteMint)) { + throw httpErrors.badRequest('baseToken and quoteToken must be different'); + } + + const [baseProgram, quoteProgram] = await Promise.all([ + getMintProgram(solana, baseMint), + getMintProgram(solana, quoteMint), + ]); + const [baseMintInfo, quoteMintInfo] = await Promise.all([ + getMint(solana.connection, baseMint, undefined, baseProgram), + getMint(solana.connection, quoteMint, undefined, quoteProgram), + ]); + const baseDecimals = baseMintInfo.decimals; + const quoteDecimals = quoteMintInfo.decimals; + + // Resolve the seed price (quote per base): explicit initialPrice, else live market price. + let seedPrice: number; + let seedSource: string; + if (initialPrice !== undefined) { + if (initialPrice <= 0) throw httpErrors.badRequest('initialPrice must be greater than zero'); + seedPrice = initialPrice; + seedSource = 'initialPrice'; + } else { + seedPrice = await fetchMarketPrice(network, baseToken, quoteToken); + seedSource = 'market (unified swap router)'; + } + logger.info(`Initializing PancakeSwap CLMM pool at ${seedPrice} ${quoteToken}/${baseToken} [${seedSource}]`); + + // Canonical mint ordering: the program requires mint0 < mint1 (byte comparison of pubkeys), and + // sqrt_price_x64 encodes sqrt(amount_mint1 / amount_mint0). We therefore sort the mints, then express + // the price as mint1-per-mint0. seedPrice is quote-per-base: + // - if base sorts first (mint0=base, mint1=quote): mint1/mint0 = quote/base = seedPrice + // - if quote sorts first (mint0=quote, mint1=base): mint1/mint0 = base/quote = 1/seedPrice + const baseIsMint0 = compareBytes(baseMint.toBuffer(), quoteMint.toBuffer()) < 0; + const mint0 = baseIsMint0 ? baseMint : quoteMint; + const mint1 = baseIsMint0 ? quoteMint : baseMint; + const decimals0 = baseIsMint0 ? baseDecimals : quoteDecimals; + const decimals1 = baseIsMint0 ? quoteDecimals : baseDecimals; + const program0 = baseIsMint0 ? baseProgram : quoteProgram; + const program1 = baseIsMint0 ? quoteProgram : baseProgram; + const priceMint1PerMint0 = baseIsMint0 ? seedPrice : 1 / seedPrice; + + // sqrt_price_x64 = sqrt(raw mint1/mint0) * 2^64. priceToSqrtPriceX64(price, decimalDiff) divides the + // human price by 10^decimalDiff to recover the raw ratio before sqrt. Since + // human(mint1/mint0) = raw(mint1/mint0) * 10^(decimals0 - decimals1), + // the correct decimalDiff is (decimals0 - decimals1). Verified against getClmmPoolInfo/sqrtPriceX64ToPrice, + // which inverts this exact relationship (adjustedPrice = rawPrice * 10^(decimals0 - decimals1)). + const sqrtPriceX64 = priceToSqrtPriceX64(priceMint1PerMint0, decimals0 - decimals1); + + const { instruction, poolState } = buildCreatePoolInstruction( + new PublicKey(walletAddress), + ammConfigPubkey, + mint0, + mint1, + program0, + program1, + sqrtPriceX64, + new BN(0), // open_time = 0 → pool opens immediately + ); + const poolAddress = poolState.toBase58(); + + // Fail fast if the pool already exists (same amm_config + mint pair → same PDA). + const existing = await solana.connection.getAccountInfo(poolState); + if (existing) { + throw httpErrors.badRequest(`Pool already exists for this amm_config and token pair: ${poolAddress}`); + } + + logger.info(`Creating PancakeSwap CLMM pool ${poolAddress} (${baseToken}/${quoteToken})`); + + const walletPubkey = new PublicKey(walletAddress); + const wallet = await solana.getWallet(walletAddress); + + const priorityFeeInLamports = await solana.estimateGasPrice(); + const priorityFeePerCU = Math.floor(priorityFeeInLamports * 1e6); + + const transaction = await buildTransactionWithInstructions( + solana, + walletPubkey, + [instruction], + 600000, + priorityFeePerCU, + ); + transaction.sign([wallet]); + + await solana.simulateWithErrorHandling(transaction); + + const { confirmed, signature, txData } = await solana.sendAndConfirmRawTransaction(transaction); + + if (confirmed && txData) { + return { + signature, + status: 1, // CONFIRMED + poolAddress, + price: seedPrice, + data: { + fee: txData.meta.fee / 1e9, + // Pool created + initialized only — no liquidity/position seeded. + baseTokenAmountAdded: 0, + quoteTokenAmountAdded: 0, + }, + }; + } + + return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING +} + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: Static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: + 'Create and initialize a new PancakeSwap Solana CLMM pool at an initial price. Does not open or seed a position.', + tags: ['/connector/pancakeswap-sol'], + body: PancakeswapSolClmmCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { + network = 'mainnet-beta', + walletAddress, + baseToken, + quoteToken, + initialPrice, + ammConfig, + } = request.body; + return await createPool(network, walletAddress!, baseToken, quoteToken, initialPrice, ammConfig); + } catch (e: any) { + logger.error('Create pool error:', e); + if (e.statusCode) throw e; + throw httpErrors.internalServerError(e.message || 'Failed to create pool'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/index.ts b/src/connectors/pancakeswap-sol/clmm-routes/index.ts index 699fdb37de..b25ba188fd 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/index.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/index.ts @@ -3,6 +3,7 @@ import { FastifyPluginAsync } from 'fastify'; import addLiquidityRoute from './addLiquidity'; import closePositionRoute from './closePosition'; import collectFeesRoute from './collectFees'; +import createPoolRoute from './createPool'; import executeSwapRoute from './executeSwap'; import openPositionRoute from './openPosition'; import poolInfoRoute from './poolInfo'; @@ -19,6 +20,7 @@ export const pancakeswapSolClmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(quotePositionRoute); await fastify.register(quoteSwapRoute); await fastify.register(executeSwapRoute); + await fastify.register(createPoolRoute); await fastify.register(openPositionRoute); await fastify.register(addLiquidityRoute); await fastify.register(removeLiquidityRoute); diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.instructions.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.instructions.ts index 3cd081cc21..37a61adb5b 100644 --- a/src/connectors/pancakeswap-sol/pancakeswap-sol.instructions.ts +++ b/src/connectors/pancakeswap-sol/pancakeswap-sol.instructions.ts @@ -33,6 +33,88 @@ import { const clmmIdl = require('./idl/clmm.json') as Idl; +/** + * Build the `create_pool` instruction for the PancakeSwap Solana CLMM program. + * + * The program has no external SDK, so the instruction is assembled manually from the bundled IDL. + * All PDAs and account ordering are taken verbatim from the `create_pool` instruction definition in + * idl/clmm.json: + * - pool_state: seeds ["pool", amm_config, token_mint_0, token_mint_1] + * - token_vault_0: seeds ["pool_vault", pool_state, token_mint_0] + * - token_vault_1: seeds ["pool_vault", pool_state, token_mint_1] + * - observation_state: seeds ["observation", pool_state] + * - tick_array_bitmap: seeds ["pool_tick_array_bitmap_extension", pool_state] + * + * Args: sqrt_price_x64 (u128), open_time (u64). sqrt_price_x64 is sqrt(amount_token_1/amount_token_0) + * as a Q64.64, so `mint0`/`mint1` MUST already be canonically ordered (mint0 < mint1 by bytes) and the + * price used to compute it must be expressed as mint1-per-mint0. + * + * Returns the instruction plus the derived pool_state address (the new pool address). + */ +export function buildCreatePoolInstruction( + poolCreator: PublicKey, + ammConfig: PublicKey, + tokenMint0: PublicKey, + tokenMint1: PublicKey, + tokenProgram0: PublicKey, + tokenProgram1: PublicKey, + sqrtPriceX64: BN, + openTime: BN, +): { instruction: TransactionInstruction; poolState: PublicKey } { + const [poolState] = PublicKey.findProgramAddressSync( + [Buffer.from('pool'), ammConfig.toBuffer(), tokenMint0.toBuffer(), tokenMint1.toBuffer()], + PANCAKESWAP_CLMM_PROGRAM_ID, + ); + + const [tokenVault0] = PublicKey.findProgramAddressSync( + [Buffer.from('pool_vault'), poolState.toBuffer(), tokenMint0.toBuffer()], + PANCAKESWAP_CLMM_PROGRAM_ID, + ); + + const [tokenVault1] = PublicKey.findProgramAddressSync( + [Buffer.from('pool_vault'), poolState.toBuffer(), tokenMint1.toBuffer()], + PANCAKESWAP_CLMM_PROGRAM_ID, + ); + + const [observationState] = PublicKey.findProgramAddressSync( + [Buffer.from('observation'), poolState.toBuffer()], + PANCAKESWAP_CLMM_PROGRAM_ID, + ); + + const [tickArrayBitmap] = PublicKey.findProgramAddressSync( + [Buffer.from('pool_tick_array_bitmap_extension'), poolState.toBuffer()], + PANCAKESWAP_CLMM_PROGRAM_ID, + ); + + const coder = new BorshCoder(clmmIdl); + const instructionData = coder.instruction.encode('create_pool', { + sqrt_price_x64: sqrtPriceX64, + open_time: openTime, + }); + + const instruction = new TransactionInstruction({ + programId: PANCAKESWAP_CLMM_PROGRAM_ID, + keys: [ + { pubkey: poolCreator, isSigner: true, isWritable: true }, // pool_creator + { pubkey: ammConfig, isSigner: false, isWritable: false }, // amm_config + { pubkey: poolState, isSigner: false, isWritable: true }, // pool_state (PDA) + { pubkey: tokenMint0, isSigner: false, isWritable: false }, // token_mint_0 + { pubkey: tokenMint1, isSigner: false, isWritable: false }, // token_mint_1 + { pubkey: tokenVault0, isSigner: false, isWritable: true }, // token_vault_0 (PDA) + { pubkey: tokenVault1, isSigner: false, isWritable: true }, // token_vault_1 (PDA) + { pubkey: observationState, isSigner: false, isWritable: true }, // observation_state (PDA) + { pubkey: tickArrayBitmap, isSigner: false, isWritable: true }, // tick_array_bitmap (PDA) + { pubkey: tokenProgram0, isSigner: false, isWritable: false }, // token_program_0 + { pubkey: tokenProgram1, isSigner: false, isWritable: false }, // token_program_1 + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, // system_program + { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, // rent + ], + data: instructionData, + }); + + return { instruction, poolState }; +} + export async function buildSwapV2Instruction( solana: Solana, poolAddress: string, diff --git a/src/connectors/pancakeswap-sol/schemas.ts b/src/connectors/pancakeswap-sol/schemas.ts index 88ec10fefc..bcf2b1924a 100644 --- a/src/connectors/pancakeswap-sol/schemas.ts +++ b/src/connectors/pancakeswap-sol/schemas.ts @@ -87,6 +87,48 @@ export const PancakeswapSolClmmOpenPositionRequest = Type.Object({ export type PancakeswapSolClmmOpenPositionRequestType = Static; +// CLMM Create Pool Request +export const PancakeswapSolClmmCreatePoolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...PancakeswapSolConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Solana wallet address that will create and initialize the pool', + default: solanaChainConfig.defaultWallet, + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address (becomes the pool base)', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Quote token symbol or address (becomes the pool quote)', + examples: [QUOTE_TOKEN], + }), + initialPrice: Type.Optional( + Type.Number({ + 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.', + 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'], + }), +}); + +export type PancakeswapSolClmmCreatePoolRequestType = Static; + // CLMM Position Info Request export const PancakeswapSolClmmGetPositionInfoRequest = Type.Object({ network: Type.Optional( diff --git a/src/connectors/pancakeswap/amm-routes/addLiquidity.ts b/src/connectors/pancakeswap/amm-routes/addLiquidity.ts index 1959872245..b992bfef1a 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 { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { PancakeswapConfig } from '../pancakeswap.config'; @@ -19,7 +20,7 @@ import { getPancakeswapAmmLiquidityQuote } from './quoteLiquidity'; // Default gas limit for AMM add liquidity operations const AMM_ADD_LIQUIDITY_GAS_LIMIT = 500000; -async function addLiquidity( +async function addLiquidityInternal( fastify: any, network: string, walletAddress: string, @@ -279,6 +280,37 @@ async function addLiquidity( }; } +/** + * Standard AMM add-liquidity entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. Base/quote tokens are derived from the pool; gasPrice/maxGas are optional EVM extras. + */ +export async function addLiquidity( + network: string, + walletAddress: string, + poolAddress: string, + 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}`); + return await addLiquidityInternal( + { httpErrors }, + network, + walletAddress, + poolAddress, + poolInfo.baseTokenAddress, + poolInfo.quoteTokenAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + gasPrice, + maxGas, + ); +} + export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { await fastify.register(require('@fastify/sensible')); @@ -327,22 +359,10 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { logger.info(`Using first available wallet address: ${walletAddress}`); } - // Get pool information to determine tokens - const poolInfo = await getPancakeswapPoolInfo(poolAddress, networkToUse, 'amm'); - if (!poolInfo) { - throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); - } - - const baseToken = poolInfo.baseTokenAddress; - const quoteToken = poolInfo.quoteTokenAddress; - return await addLiquidity( - fastify, networkToUse, walletAddress, poolAddress, - baseToken, - quoteToken, baseTokenAmount, quoteTokenAmount, slippagePct, diff --git a/src/connectors/pancakeswap/amm-routes/createPool.ts b/src/connectors/pancakeswap/amm-routes/createPool.ts new file mode 100644 index 0000000000..148caefae9 --- /dev/null +++ b/src/connectors/pancakeswap/amm-routes/createPool.ts @@ -0,0 +1,382 @@ +import { Contract } from '@ethersproject/contracts'; +import { Static } from '@sinclair/typebox'; +import { Percent } from '@uniswap/sdk-core'; +import { Decimal } from 'decimal.js'; +import { BigNumber, constants, utils } from 'ethers'; +import { FastifyPluginAsync } from 'fastify'; + +import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { PancakeswapConfig } from '../pancakeswap.config'; +import { + IPancakeswapV2FactoryABI, + IPancakeswapV2PairABI, + IPancakeswapV2Router02ABI, + getPancakeswapV2FactoryAddress, + getPancakeswapV2RouterAddress, +} from '../pancakeswap.contracts'; +import { formatTokenAmount } from '../pancakeswap.utils'; +import { PancakeswapAmmCreatePoolRequest } from '../schemas'; + +// Default gas limit for AMM create-pool operations (pair creation + initial mint costs more than a plain add). +// Pancakeswap V2 pools all share a fixed 0.25% swap fee — there is no fee parameter to set. +const AMM_CREATE_POOL_GAS_LIMIT = 600000; + +/** + * Resolves a token symbol or address to its on-chain TokenInfo and flags whether it is the native + * ETH / WETH side. Native ETH is not an ERC20, so a V2 pair is always WETH-based; when the caller + * passes 'ETH' we resolve WETH for the pair address and decimals, and the ETH amount is supplied as + * native value via addLiquidityETH (the router wraps it) — mirroring addLiquidity.ts. + */ +async function resolveToken(ethereum: Ethereum, tokenOrAddress: string): Promise<{ token: TokenInfo; isEth: boolean }> { + const isEthInput = tokenOrAddress.toUpperCase() === 'ETH'; + const lookup = isEthInput ? 'WETH' : tokenOrAddress; + const token = await ethereum.getToken(lookup); + if (!token) { + throw httpErrors.badRequest(`Token not found: ${tokenOrAddress}`); + } + const isEth = isEthInput || token.symbol.toUpperCase() === 'WETH'; + return { token, isEth }; +} + +/** + * Fetches the current market price (quote per base) from the unified swap router so a new pool can be + * seeded on-market instead of at an arbitrary ratio. Seeding off-market invites arbitrage bots to + * instantly rebalance the pool. Uses a SELL quote of the base token via the network's configured swap + * provider (an aggregator that does not require this not-yet-created pool); throws a clear error if no + * market route exists. + */ +async function fetchMarketPrice( + network: string, + baseToken: string, + quoteToken: string, + amount: number, +): Promise { + const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + let quote: any; + try { + quote = await getUnifiedQuoteSwap(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); + } catch (e: any) { + throw httpErrors.badRequest( + `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + + 'Pass initialPrice or quoteTokenAmount explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw httpErrors.badRequest( + `No market route found for ${baseToken}/${quoteToken}. Pass initialPrice or quoteTokenAmount explicitly.`, + ); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} + +export async function createPool( + network: string, + walletAddress: string, + baseToken: string, + quoteToken: string, + baseTokenAmount: number, + quoteTokenAmount?: number, + initialPrice?: number, + gasPrice?: number, + maxGas?: number, + slippagePct: number = PancakeswapConfig.config.slippagePct, +): Promise { + if (baseTokenAmount <= 0) { + throw httpErrors.badRequest('baseTokenAmount must be greater than zero'); + } + + const ethereum = await Ethereum.getInstance(network); + + const wallet = await ethereum.getWallet(walletAddress); + if (!wallet) { + throw httpErrors.badRequest('Wallet not found'); + } + + const { token: baseTokenInfo, isEth: baseIsEth } = await resolveToken(ethereum, baseToken); + const { token: quoteTokenInfo, isEth: quoteIsEth } = await resolveToken(ethereum, quoteToken); + + if (baseTokenInfo.address.toLowerCase() === quoteTokenInfo.address.toLowerCase()) { + throw httpErrors.badRequest('baseToken and quoteToken must be different'); + } + if (baseIsEth && quoteIsEth) { + throw httpErrors.badRequest('Only one side of the pair can be ETH/WETH'); + } + + // Resolve the seed price (quote per base). Priority: + // 1) explicit initialPrice + // 2) explicit quoteTokenAmount (the base:quote ratio sets the price) + // 3) live market price from the unified swap router — so the pool opens on-market and is not + // immediately arbitraged/sniped. + let seedPrice: number; + let seedSource: string; + if (initialPrice !== undefined) { + if (initialPrice <= 0) throw httpErrors.badRequest('initialPrice must be greater than zero'); + seedPrice = initialPrice; + seedSource = 'initialPrice'; + } else if (quoteTokenAmount !== undefined) { + if (quoteTokenAmount <= 0) throw httpErrors.badRequest('quoteTokenAmount must be greater than zero'); + seedPrice = quoteTokenAmount / baseTokenAmount; + seedSource = 'quoteTokenAmount ratio'; + } else { + seedPrice = await fetchMarketPrice(network, baseToken, quoteToken, baseTokenAmount); + seedSource = 'market (unified swap router)'; + } + + const effectiveQuoteAmount = baseTokenAmount * seedPrice; + logger.info( + `Seeding Pancakeswap V2 pool at ${seedPrice} ${quoteTokenInfo.symbol}/${baseTokenInfo.symbol} [${seedSource}]: ` + + `${baseTokenAmount} ${baseTokenInfo.symbol} + ${effectiveQuoteAmount} ${quoteTokenInfo.symbol}`, + ); + + // Convert desired amounts to raw units. Decimal keeps the quote side within its token decimals. + const rawBaseAmount = utils.parseUnits( + new Decimal(baseTokenAmount).toFixed(baseTokenInfo.decimals), + baseTokenInfo.decimals, + ); + const rawQuoteAmount = utils.parseUnits( + new Decimal(effectiveQuoteAmount).toFixed(quoteTokenInfo.decimals), + quoteTokenInfo.decimals, + ); + if (rawBaseAmount.isZero() || rawQuoteAmount.isZero()) { + throw httpErrors.badRequest('Computed token amounts are zero — increase baseTokenAmount'); + } + + // Slippage-adjusted minimums (min amounts accepted into the pair). A brand-new pair has no reserves, + // so the router mints against exactly the desired amounts, but we still pass minimums to match the + // add-liquidity semantics and guard against a same-block seed by someone else. + const slippageTolerance = new Percent(Math.floor(slippagePct * 100), 10000); + const slippageMultiplier = new Percent(1).subtract(slippageTolerance); + const rawBaseMinAmount = rawBaseAmount + .mul(slippageMultiplier.numerator.toString()) + .div(slippageMultiplier.denominator.toString()); + const rawQuoteMinAmount = rawQuoteAmount + .mul(slippageMultiplier.numerator.toString()) + .div(slippageMultiplier.denominator.toString()); + + const factoryAddress = getPancakeswapV2FactoryAddress(network); + const routerAddress = getPancakeswapV2RouterAddress(network); + const factory = new Contract(factoryAddress, IPancakeswapV2FactoryABI.abi, ethereum.provider); + + // Create semantics: a V2 pair is a singleton per token pair. The factory may already have deployed + // the pair contract with ZERO reserves (an empty pair is legal and still needs seeding), so we only + // reject when the pair already holds reserves — i.e. it is a live pool, not a fresh/empty one. + const existingPair: string = await factory.getPair(baseTokenInfo.address, quoteTokenInfo.address); + if (existingPair && existingPair !== constants.AddressZero) { + const pairContract = new Contract(existingPair, IPancakeswapV2PairABI.abi, ethereum.provider); + const reserves = await pairContract.getReserves(); + if (!BigNumber.from(reserves[0]).isZero() || !BigNumber.from(reserves[1]).isZero()) { + throw new Error(`Pool already exists for this token pair with liquidity: ${existingPair}`); + } + logger.info(`Empty V2 pair ${existingPair} already deployed — seeding it with initial liquidity`); + } + + // Router with signer. addLiquidity auto-creates the pair via the factory if it does not yet exist. + const router = new Contract(routerAddress, IPancakeswapV2Router02ABI.abi, wallet); + + 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 + // ETH side is supplied as native value (the router wraps it to WETH), matching addLiquidity.ts. + const ethRawAmount = baseIsEth ? rawBaseAmount : rawQuoteAmount; + const ethRawMinAmount = baseIsEth ? rawBaseMinAmount : rawQuoteMinAmount; + + const erc20TokenInfo = baseIsEth ? quoteTokenInfo : baseTokenInfo; + const erc20RawAmount = baseIsEth ? rawQuoteAmount : rawBaseAmount; + const erc20RawMinAmount = baseIsEth ? rawQuoteMinAmount : rawBaseMinAmount; + + const tokenContract = ethereum.getContract(erc20TokenInfo.address, wallet); + const allowance = await ethereum.getERC20Allowance(tokenContract, wallet, routerAddress, erc20TokenInfo.decimals); + const currentAllowance = BigNumber.from(allowance.value); + if (currentAllowance.lt(erc20RawAmount)) { + throw new Error( + `Insufficient allowance for ${erc20TokenInfo.symbol}. Please approve at least ` + + `${formatTokenAmount(erc20RawAmount.toString(), erc20TokenInfo.decimals)} ${erc20TokenInfo.symbol} ` + + `for the Pancakeswap router (${routerAddress})`, + ); + } + + const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + gasOptions.value = ethRawAmount; + + tx = await router.addLiquidityETH( + erc20TokenInfo.address, + erc20RawAmount, + erc20RawMinAmount, + ethRawMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } else { + // Both sides are ERC20 → addLiquidity. Both need an allowance to the router. + const baseTokenContract = ethereum.getContract(baseTokenInfo.address, wallet); + const baseAllowance = await ethereum.getERC20Allowance( + baseTokenContract, + wallet, + routerAddress, + baseTokenInfo.decimals, + ); + const quoteTokenContract = ethereum.getContract(quoteTokenInfo.address, wallet); + const quoteAllowance = await ethereum.getERC20Allowance( + quoteTokenContract, + wallet, + routerAddress, + quoteTokenInfo.decimals, + ); + + if (BigNumber.from(baseAllowance.value).lt(rawBaseAmount)) { + throw new Error( + `Insufficient allowance for ${baseTokenInfo.symbol}. Please approve at least ` + + `${formatTokenAmount(rawBaseAmount.toString(), baseTokenInfo.decimals)} ${baseTokenInfo.symbol} ` + + `for the Pancakeswap router (${routerAddress})`, + ); + } + if (BigNumber.from(quoteAllowance.value).lt(rawQuoteAmount)) { + throw new Error( + `Insufficient allowance for ${quoteTokenInfo.symbol}. Please approve at least ` + + `${formatTokenAmount(rawQuoteAmount.toString(), quoteTokenInfo.decimals)} ${quoteTokenInfo.symbol} ` + + `for the Pancakeswap router (${routerAddress})`, + ); + } + + const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + + tx = await router.addLiquidity( + baseTokenInfo.address, + quoteTokenInfo.address, + rawBaseAmount, + rawQuoteAmount, + rawBaseMinAmount, + rawQuoteMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } + + logger.info(`Creating Pancakeswap V2 pool ${baseTokenInfo.symbol}/${quoteTokenInfo.symbol} via tx ${tx.hash}`); + + const receipt = await ethereum.handleTransactionExecution(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 + return { + signature: receipt.transactionHash, + status: 1, // CONFIRMED + 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 + poolAddress: pairAddress, + price: seedPrice, + }; +} + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + await fastify.register(require('@fastify/sensible')); + + fastify.post<{ + Body: Static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: 'Create a new Pancakeswap V2 (AMM) pool and seed it with initial liquidity (fixed 0.25% fee)', + tags: ['/connector/pancakeswap'], + body: PancakeswapAmmCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { + network, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + initialPrice, + slippagePct, + gasPrice, + maxGas, + walletAddress: requestedWalletAddress, + } = request.body; + + if (!baseToken || !quoteToken || !baseTokenAmount) { + throw fastify.httpErrors.badRequest('Missing required parameters'); + } + + let walletAddress = requestedWalletAddress; + if (!walletAddress) { + walletAddress = await Ethereum.getFirstWalletAddress(); + if (!walletAddress) { + throw fastify.httpErrors.badRequest('No wallet address provided and no wallets found.'); + } + 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, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + initialPrice, + gasPriceGwei, + maxGas, + slippagePct, + ); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + + if (e.message && e.message.includes('Insufficient allowance')) { + throw fastify.httpErrors.badRequest(e.message); + } + if (e.message && e.message.includes('already exists')) { + throw fastify.httpErrors.badRequest(e.message); + } + if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { + throw fastify.httpErrors.badRequest( + 'Insufficient native balance to pay for gas fees. Please add more funds to your wallet.', + ); + } + + throw fastify.httpErrors.internalServerError('Failed to create pool'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/connectors/pancakeswap/amm-routes/executeSwap.ts b/src/connectors/pancakeswap/amm-routes/executeSwap.ts index c1f31d7505..a0a2584c0a 100644 --- a/src/connectors/pancakeswap/amm-routes/executeSwap.ts +++ b/src/connectors/pancakeswap/amm-routes/executeSwap.ts @@ -13,6 +13,7 @@ import { getPancakeswapV2RouterAddress, IPancakeswapV2Router02ABI } from '../pan import { formatTokenAmount } from '../pancakeswap.utils'; import { PancakeswapAmmExecuteSwapRequest } from '../schemas'; +import { resolveSwapPair } from './poolTokens'; import { getPancakeswapAmmQuote } from './quoteSwap'; // Default gas limit for AMM swap operations @@ -330,7 +331,21 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { ); }; -// Export executeSwap alias for uniform chain route imports -export { executeAmmSwap as executeSwap }; +/** + * Standard AMM execute-swap entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. The quote token is derived from the pool; `amount` is denominated in the base token. + */ +export async function executeSwap( + network: string, + walletAddress: string, + poolAddress: string, + baseToken: string, + side: 'BUY' | 'SELL', + amount: number, + slippagePct: number = PancakeswapConfig.config.slippagePct, +): Promise { + const { baseAddress, quoteAddress } = await resolveSwapPair(network, poolAddress, baseToken); + return await executeAmmSwap(walletAddress, network, baseAddress, quoteAddress, amount, side, slippagePct); +} export default executeSwapRoute; diff --git a/src/connectors/pancakeswap/amm-routes/index.ts b/src/connectors/pancakeswap/amm-routes/index.ts index 881d82539e..2819290e8d 100644 --- a/src/connectors/pancakeswap/amm-routes/index.ts +++ b/src/connectors/pancakeswap/amm-routes/index.ts @@ -1,6 +1,7 @@ import { FastifyPluginAsync } from 'fastify'; import addLiquidityRoute from './addLiquidity'; +import createPoolRoute from './createPool'; import executeSwapRoute from './executeSwap'; import poolInfoRoute from './poolInfo'; import positionInfoRoute from './positionInfo'; @@ -16,6 +17,7 @@ export const pancakeswapAmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(executeSwapRoute); await fastify.register(addLiquidityRoute); await fastify.register(removeLiquidityRoute); + await fastify.register(createPoolRoute); }; export default pancakeswapAmmRoutes; diff --git a/src/connectors/pancakeswap/amm-routes/poolInfo.ts b/src/connectors/pancakeswap/amm-routes/poolInfo.ts index 7c63453ddb..29474d6d18 100644 --- a/src/connectors/pancakeswap/amm-routes/poolInfo.ts +++ b/src/connectors/pancakeswap/amm-routes/poolInfo.ts @@ -3,12 +3,61 @@ import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { IPancakeswapV2PairABI } from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; import { PancakeswapAmmGetPoolInfoRequest } from '../schemas'; +/** + * Standard AMM pool-info accessor: given a network and a Pancakeswap V2 pool (pair) address, returns + * the shared PoolInfo shape. V2 pairs are pool-addressed and carry a fixed 0.30% fee; token0 is + * treated as base and token1 as quote (the pair contract is the authoritative source of ordering). + */ +export async function getPoolInfo(network: string, poolAddress: string): Promise { + const ethereum = await Ethereum.getInstance(network); + const pancakeswap = await Pancakeswap.getInstance(network); + + // For Pancakeswap, read the pair contract to extract the two token addresses. + const pairContract = new Contract(poolAddress, IPancakeswapV2PairABI.abi, ethereum.provider); + + const token0Address = await pairContract.token0(); + const token1Address = await pairContract.token1(); + + const token0 = await pancakeswap.getToken(token0Address); + const token1 = await pancakeswap.getToken(token1Address); + + if (!token0 || !token1) { + throw httpErrors.notFound('Could not find tokens for pool'); + } + + const v2Pair = await pancakeswap.getV2Pool(token0, token1, poolAddress); + if (!v2Pair) { + throw httpErrors.notFound('Pool not found'); + } + + const pairToken0 = v2Pair.token0; + const pairToken1 = v2Pair.token1; + + // Since we only have poolAddress, use token0 as base and token1 as quote. + const baseTokenAmount = formatTokenAmount(v2Pair.reserve0.quotient.toString(), pairToken0.decimals); + const quoteTokenAmount = formatTokenAmount(v2Pair.reserve1.quotient.toString(), pairToken1.decimals); + + // Price is quoteToken per baseToken. + const price = quoteTokenAmount / baseTokenAmount; + + return { + address: poolAddress, + baseTokenAddress: pairToken0.address, + quoteTokenAddress: pairToken1.address, + feePct: 0.3, // Pancakeswap V2 fee is fixed at 0.3% + price, + baseTokenAmount, + quoteTokenAmount, + }; +} + export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: GetPoolInfoRequestType; @@ -27,57 +76,8 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { }, async (request): Promise => { try { - const { poolAddress } = request.query; - const network = request.query.network; - - const ethereum = await Ethereum.getInstance(network); - const pancakeswap = await Pancakeswap.getInstance(network); - - // For Pancakeswap, we need to get the pair contract to extract token addresses - // Create a pair contract instance to read token addresses - const pairContract = new Contract(poolAddress, IPancakeswapV2PairABI.abi, ethereum.provider); - - // Get token addresses from the pair - const token0Address = await pairContract.token0(); - const token1Address = await pairContract.token1(); - - // Get token objects by address - const token0 = await pancakeswap.getToken(token0Address); - const token1 = await pancakeswap.getToken(token1Address); - - if (!token0 || !token1) { - throw new Error('Could not find tokens for pool'); - } - - // Get V2 pair data - const v2Pair = await pancakeswap.getV2Pool(token0, token1, poolAddress); - - if (!v2Pair) { - throw fastify.httpErrors.notFound('Pool not found'); - } - - // Get the tokens from the pair - const pairToken0 = v2Pair.token0; - const pairToken1 = v2Pair.token1; - - // Since we only have poolAddress, use token0 as base and token1 as quote - const actualBaseToken = pairToken0; - const actualQuoteToken = pairToken1; - const baseTokenAmount = formatTokenAmount(v2Pair.reserve0.quotient.toString(), pairToken0.decimals); - const quoteTokenAmount = formatTokenAmount(v2Pair.reserve1.quotient.toString(), pairToken1.decimals); - - // Calculate price (quoteToken per baseToken) - const price = quoteTokenAmount / baseTokenAmount; - - return { - address: poolAddress, - baseTokenAddress: actualBaseToken.address, - quoteTokenAddress: actualQuoteToken.address, - feePct: 0.3, // Pancakeswap V2 fee is fixed at 0.3% - price: price, - baseTokenAmount: baseTokenAmount, - quoteTokenAmount: quoteTokenAmount, - }; + const { poolAddress, network } = request.query; + return await getPoolInfo(network, poolAddress); } catch (e) { logger.error(`Error in pool-info route: ${e.message}`); if (e.stack) { diff --git a/src/connectors/pancakeswap/amm-routes/poolTokens.ts b/src/connectors/pancakeswap/amm-routes/poolTokens.ts new file mode 100644 index 0000000000..014c0f2907 --- /dev/null +++ b/src/connectors/pancakeswap/amm-routes/poolTokens.ts @@ -0,0 +1,49 @@ +import { Contract } from '@ethersproject/contracts'; + +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { httpErrors } from '../../../services/error-handler'; +import { Pancakeswap } from '../pancakeswap'; +import { IPancakeswapV2PairABI } from '../pancakeswap.contracts'; + +/** The token shape returned by Pancakeswap.getToken (address/decimals/symbol). */ +type PancakeToken = NonNullable>>; + +/** + * Reads a Pancakeswap V2 pair's token0/token1 and resolves them. `base` follows the pair's token0 + * orientation and `quote` its token1 — matching how pool-info / position-info report. + */ +export async function getAmmPoolTokens( + network: string, + poolAddress: string, +): Promise<{ base: PancakeToken; quote: PancakeToken }> { + const pancakeswap = await Pancakeswap.getInstance(network); + const ethereum = await Ethereum.getInstance(network); + const pair = new Contract(poolAddress, IPancakeswapV2PairABI.abi, ethereum.provider); + const [t0, t1] = await Promise.all([pair.token0(), pair.token1()]); + const base = await pancakeswap.getToken(t0); + const quote = await pancakeswap.getToken(t1); + if (!base || !quote) { + throw httpErrors.badRequest(`Could not resolve token information for pool ${poolAddress}`); + } + return { base, quote }; +} + +/** + * Given a caller-specified base token (symbol or address) and a pool, returns the base and the + * counter ("quote") token addresses — the quote token is whichever pool token is not the base. + * Used by swap ops where `baseToken` selects the direction. + */ +export async function resolveSwapPair( + network: string, + poolAddress: string, + baseToken: string, +): Promise<{ baseAddress: string; quoteAddress: string }> { + const { base, quote } = await getAmmPoolTokens(network, poolAddress); + const pancakeswap = await Pancakeswap.getInstance(network); + const baseObj = await pancakeswap.getToken(baseToken); + if (!baseObj) throw httpErrors.badRequest(`Token not found: ${baseToken}`); + const addr = baseObj.address.toLowerCase(); + if (addr === base.address.toLowerCase()) return { baseAddress: base.address, quoteAddress: quote.address }; + if (addr === quote.address.toLowerCase()) return { baseAddress: quote.address, quoteAddress: base.address }; + throw httpErrors.badRequest(`Token ${baseToken} is not part of pool ${poolAddress}`); +} diff --git a/src/connectors/pancakeswap/amm-routes/positionInfo.ts b/src/connectors/pancakeswap/amm-routes/positionInfo.ts index a8adbb13e1..d63e176926 100644 --- a/src/connectors/pancakeswap/amm-routes/positionInfo.ts +++ b/src/connectors/pancakeswap/amm-routes/positionInfo.ts @@ -9,11 +9,73 @@ import { PositionInfo, PositionInfoSchema, } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { IPancakeswapV2PairABI } from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; +/** + * Standard AMM position-info entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. V2 positions are fungible LP tokens; base/quote follow the pair's token0/token1. + */ +export async function getPositionInfo( + network: string, + poolAddress: string, + walletAddress: string, +): Promise { + if (!poolAddress) throw httpErrors.badRequest('Pool address is required'); + + const pancakeswap = await Pancakeswap.getInstance(network); + const ethereum = await Ethereum.getInstance(network); + + const pairContract = new Contract(poolAddress, IPancakeswapV2PairABI.abi, ethereum.provider); + const lpBalance = await pairContract.balanceOf(walletAddress); + const [token0, token1] = await Promise.all([pairContract.token0(), pairContract.token1()]); + + const baseTokenObj = await pancakeswap.getToken(token0); + const quoteTokenObj = await pancakeswap.getToken(token1); + if (!baseTokenObj || !quoteTokenObj) { + throw httpErrors.badRequest('Token information not found for pool'); + } + + if (lpBalance.isZero()) { + return { + poolAddress, + walletAddress, + baseTokenAddress: baseTokenObj.address, + quoteTokenAddress: quoteTokenObj.address, + lpTokenAmount: 0, + baseTokenAmount: 0, + quoteTokenAmount: 0, + price: 0, + }; + } + + const [totalSupply, reserves] = await Promise.all([pairContract.totalSupply(), pairContract.getReserves()]); + const token0IsBase = token0.toLowerCase() === baseTokenObj.address.toLowerCase(); + const baseTokenReserve = token0IsBase ? reserves[0] : reserves[1]; + const quoteTokenReserve = token0IsBase ? reserves[1] : reserves[0]; + + const userBaseTokenAmount = baseTokenReserve.mul(lpBalance).div(totalSupply); + const userQuoteTokenAmount = quoteTokenReserve.mul(lpBalance).div(totalSupply); + + const baseTokenAmountFloat = formatTokenAmount(baseTokenReserve.toString(), baseTokenObj.decimals); + const quoteTokenAmountFloat = formatTokenAmount(quoteTokenReserve.toString(), quoteTokenObj.decimals); + const price = baseTokenAmountFloat > 0 ? quoteTokenAmountFloat / baseTokenAmountFloat : 0; + + return { + poolAddress, + walletAddress, + baseTokenAddress: baseTokenObj.address, + quoteTokenAddress: quoteTokenObj.address, + lpTokenAmount: formatTokenAmount(lpBalance.toString(), 18), + baseTokenAmount: formatTokenAmount(userBaseTokenAmount.toString(), baseTokenObj.decimals), + quoteTokenAmount: formatTokenAmount(userQuoteTokenAmount.toString(), quoteTokenObj.decimals), + price, + }; +} + export async function checkLPAllowance( ethereum: any, wallet: any, diff --git a/src/connectors/pancakeswap/amm-routes/quoteLiquidity.ts b/src/connectors/pancakeswap/amm-routes/quoteLiquidity.ts index 9fb2dbbfeb..b58eea303a 100644 --- a/src/connectors/pancakeswap/amm-routes/quoteLiquidity.ts +++ b/src/connectors/pancakeswap/amm-routes/quoteLiquidity.ts @@ -14,6 +14,8 @@ import { Pancakeswap } from '../pancakeswap'; import { IPancakeswapV2PairABI, getPancakeswapV2RouterAddress } from '../pancakeswap.contracts'; import { formatTokenAmount, getPancakeswapPoolInfo } from '../pancakeswap.utils'; +import { getAmmPoolTokens } from './poolTokens'; + export async function getPancakeswapAmmLiquidityQuote( network: string, poolAddress?: string, @@ -254,4 +256,34 @@ export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { ); }; +/** + * Standard AMM quote-liquidity entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. Base/quote follow the pair's token0/token1 orientation. + */ +export async function quoteLiquidity( + network: string, + poolAddress: string, + baseTokenAmount: number, + quoteTokenAmount: number, + slippagePct?: number, +): Promise { + const { base, quote } = await getAmmPoolTokens(network, poolAddress); + const q = await getPancakeswapAmmLiquidityQuote( + network, + poolAddress, + base.address, + quote.address, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + return { + baseLimited: q.baseLimited, + baseTokenAmount: q.baseTokenAmount, + quoteTokenAmount: q.quoteTokenAmount, + baseTokenAmountMax: q.baseTokenAmountMax, + quoteTokenAmountMax: q.quoteTokenAmountMax, + }; +} + export default quoteLiquidityRoute; diff --git a/src/connectors/pancakeswap/amm-routes/quoteSwap.ts b/src/connectors/pancakeswap/amm-routes/quoteSwap.ts index 35edc678c7..0533675f01 100644 --- a/src/connectors/pancakeswap/amm-routes/quoteSwap.ts +++ b/src/connectors/pancakeswap/amm-routes/quoteSwap.ts @@ -16,6 +16,8 @@ import { Pancakeswap } from '../pancakeswap'; import { PancakeswapConfig } from '../pancakeswap.config'; import { formatTokenAmount, getPancakeswapPoolInfo } from '../pancakeswap.utils'; +import { resolveSwapPair } from './poolTokens'; + async function quoteAmmSwap( pancakeswap: Pancakeswap, poolAddress: string, @@ -402,15 +404,18 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { export default quoteSwapRoute; -// Export quoteSwap wrapper for chain-level routes +/** + * Standard AMM quote-swap entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. `amount` is denominated in the base token; the quote token is derived from the pool. + */ export async function quoteSwap( network: string, poolAddress: string, baseToken: string, - quoteToken: string, - amount: number, side: 'BUY' | 'SELL', + amount: number, slippagePct: number = PancakeswapConfig.config.slippagePct, ): Promise { - return await formatSwapQuote(network, poolAddress, baseToken, quoteToken, amount, side, slippagePct); + const { baseAddress, quoteAddress } = await resolveSwapPair(network, poolAddress, baseToken); + return await formatSwapQuote(network, poolAddress, baseAddress, quoteAddress, amount, side, slippagePct); } diff --git a/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts b/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts index add71dc48b..f50da1bfd2 100644 --- a/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts +++ b/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts @@ -6,8 +6,10 @@ import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { RemoveLiquidityResponseType, RemoveLiquidityResponse } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; +import { PancakeswapConfig } from '../pancakeswap.config'; import { getPancakeswapV2RouterAddress, IPancakeswapV2Router02ABI, @@ -21,6 +23,125 @@ import { checkLPAllowance } from './positionInfo'; // Default gas limit for AMM remove liquidity operations const AMM_REMOVE_LIQUIDITY_GAS_LIMIT = 400000; +/** + * Standard AMM remove-liquidity entry point (network-based) — consumed by the unified /trading/amm + * dispatcher. Removes `percentageToRemove` of the wallet's LP position; base/quote follow the pair. + */ +export async function removeLiquidity( + network: string, + walletAddress: string, + 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) { + throw httpErrors.badRequest('Percentage to remove must be between 0 and 100'); + } + + const pancakeswap = await Pancakeswap.getInstance(network); + const ethereum = await Ethereum.getInstance(network); + + const poolInfo = await getPancakeswapPoolInfo(poolAddress, network, 'amm'); + if (!poolInfo) throw httpErrors.notFound(`Pool not found: ${poolAddress}`); + + const baseTokenObj = await pancakeswap.getToken(poolInfo.baseTokenAddress); + const quoteTokenObj = await pancakeswap.getToken(poolInfo.quoteTokenAddress); + if (!baseTokenObj || !quoteTokenObj) throw httpErrors.badRequest('Token information not found for pool'); + + const wallet = await ethereum.getWallet(walletAddress); + if (!wallet) throw httpErrors.badRequest('Wallet not found'); + + const pairContract = new Contract(poolAddress, IPancakeswapV2PairABI.abi, wallet); + const lpBalance = await pairContract.balanceOf(walletAddress); + if (lpBalance.eq(0)) throw httpErrors.badRequest('No liquidity position found for this pool'); + + const [token0, token1, totalSupply, reserves] = await Promise.all([ + pairContract.token0(), + pairContract.token1(), + pairContract.totalSupply(), + pairContract.getReserves(), + ]); + + const token0IsBase = token0.toLowerCase() === baseTokenObj.address.toLowerCase(); + + const liquidityToRemove = lpBalance.mul(Math.floor(percentageToRemove * 100)).div(10000); + const baseTokenReserve = token0IsBase ? reserves[0] : reserves[1]; + const quoteTokenReserve = token0IsBase ? reserves[1] : reserves[0]; + + const expectedBaseTokenAmount = baseTokenReserve.mul(liquidityToRemove).div(totalSupply); + const expectedQuoteTokenAmount = quoteTokenReserve.mul(liquidityToRemove).div(totalSupply); + + const routerAddress = getPancakeswapV2RouterAddress(network); + const router = new Contract(routerAddress, IPancakeswapV2Router02ABI.abi, wallet); + + const slippageTolerance = new Percent(Math.floor(slippagePct * 100), 10000); + const slippageMultiplier = new Percent(1).subtract(slippageTolerance); + const baseTokenMinAmount = expectedBaseTokenAmount + .mul(slippageMultiplier.numerator.toString()) + .div(slippageMultiplier.denominator.toString()); + const quoteTokenMinAmount = expectedQuoteTokenAmount + .mul(slippageMultiplier.numerator.toString()) + .div(slippageMultiplier.denominator.toString()); + + 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); + + let tx; + if (baseTokenObj.symbol === 'WETH') { + tx = await router.removeLiquidityETH( + token0IsBase ? token1 : token0, + liquidityToRemove, + token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, + token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } else if (quoteTokenObj.symbol === 'WETH') { + tx = await router.removeLiquidityETH( + token0IsBase ? token0 : token1, + liquidityToRemove, + token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, + token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } else { + tx = await router.removeLiquidity( + token0, + token1, + liquidityToRemove, + token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, + token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } + + const receipt = await ethereum.handleTransactionExecution(tx); + 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, + data: { + fee: gasFee, + baseTokenAmountRemoved, + quoteTokenAmountRemoved, + }, + }; +} + export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { await fastify.register(require('@fastify/sensible')); @@ -50,183 +171,32 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { maxGas, } = request.body; - const networkToUse = network; - - // Validate essential parameters - if (!poolAddress || !percentageToRemove) { - throw fastify.httpErrors.badRequest('Missing required parameters'); - } - - if (percentageToRemove <= 0 || percentageToRemove > 100) { - throw fastify.httpErrors.badRequest('Percentage to remove must be between 0 and 100'); - } - - // Get Pancakeswap and Ethereum instances - const pancakeswap = await Pancakeswap.getInstance(networkToUse); - const ethereum = await Ethereum.getInstance(networkToUse); - - // Get wallet address - either from request or first available let walletAddress = requestedWalletAddress; if (!walletAddress) { - walletAddress = await pancakeswap.getFirstWalletAddress(); + walletAddress = await Ethereum.getFirstWalletAddress(); if (!walletAddress) { throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet found'); } logger.info(`Using first available wallet address: ${walletAddress}`); } - // Resolve tokens - // Get pool information to determine tokens - const poolInfo = await getPancakeswapPoolInfo(poolAddress, networkToUse, 'amm'); - if (!poolInfo) { - throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); - } - - const baseTokenObj = await pancakeswap.getToken(poolInfo.baseTokenAddress); - const quoteTokenObj = await pancakeswap.getToken(poolInfo.quoteTokenAddress); - - if (!baseTokenObj || !quoteTokenObj) { - throw fastify.httpErrors.badRequest('Token information not found for pool'); - } - - // Get the wallet - const wallet = await ethereum.getWallet(walletAddress); - if (!wallet) { - throw fastify.httpErrors.badRequest('Wallet not found'); - } - - // Check if the user has LP tokens for this pool - const pairContract = new Contract(poolAddress, IPancakeswapV2PairABI.abi, wallet); - - const lpBalance = await pairContract.balanceOf(walletAddress); - if (lpBalance.eq(0)) { - throw fastify.httpErrors.badRequest(`No liquidity position found for this pool`); - } - - // Get the total supply and reserves - const [token0, token1, totalSupply, reserves] = await Promise.all([ - pairContract.token0(), - pairContract.token1(), - pairContract.totalSupply(), - pairContract.getReserves(), - ]); - - const token0IsBase = token0.toLowerCase() === baseTokenObj.address.toLowerCase(); - - // Calculate expected amounts - const liquidityToRemove = lpBalance.mul(Math.floor(percentageToRemove * 100)).div(10000); - const baseTokenReserve = token0IsBase ? reserves[0] : reserves[1]; - const quoteTokenReserve = token0IsBase ? reserves[1] : reserves[0]; - - const expectedBaseTokenAmount = baseTokenReserve.mul(liquidityToRemove).div(totalSupply); - const expectedQuoteTokenAmount = quoteTokenReserve.mul(liquidityToRemove).div(totalSupply); - - // Get the router contract with signer - const routerAddress = getPancakeswapV2RouterAddress(networkToUse); - const router = new Contract(routerAddress, IPancakeswapV2Router02ABI.abi, wallet); - - // Calculate slippage-adjusted amounts (0.5% slippage by default) - const slippageTolerance = new Percent(5, 1000); // 0.5% - const slippageMultiplier = new Percent(1).subtract(slippageTolerance); - - const baseTokenMinAmount = expectedBaseTokenAmount - .mul(slippageMultiplier.numerator.toString()) - .div(slippageMultiplier.denominator.toString()); - - const quoteTokenMinAmount = expectedQuoteTokenAmount - .mul(slippageMultiplier.numerator.toString()) - .div(slippageMultiplier.denominator.toString()); - - // Check LP token allowance - try { - await checkLPAllowance(ethereum, wallet, poolAddress, routerAddress, liquidityToRemove); - } catch (error: any) { - throw fastify.httpErrors.badRequest(error.message); - } - - // Prepare the transaction parameters - const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - - let tx; - - // Prepare gas options - // 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_REMOVE_LIQUIDITY_GAS_LIMIT); - - // Check if one of the tokens is WETH - if (baseTokenObj.symbol === 'WETH') { - // Remove liquidity WETH + Token - tx = await router.removeLiquidityETH( - token0IsBase ? token1 : token0, // The non-WETH token - liquidityToRemove, - token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, // Min amount of the token - token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, // Min amount of WETH - walletAddress, - deadline, - gasOptions, - ); - } else if (quoteTokenObj.symbol === 'WETH') { - // Remove liquidity Token + WETH - tx = await router.removeLiquidityETH( - token0IsBase ? token0 : token1, // The non-WETH token - liquidityToRemove, - token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, // Min amount of the token - token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, // Min amount of WETH - walletAddress, - deadline, - gasOptions, - ); - } else { - // Remove liquidity Token + Token - tx = await router.removeLiquidity( - token0, - token1, - liquidityToRemove, - token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, // Min amount of token0 - token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, // Min amount of token1 - walletAddress, - deadline, - gasOptions, - ); - } - - // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Format amounts for response - const baseTokenAmountRemoved = formatTokenAmount(expectedBaseTokenAmount.toString(), baseTokenObj.decimals); - - const quoteTokenAmountRemoved = formatTokenAmount(expectedQuoteTokenAmount.toString(), quoteTokenObj.decimals); - - // Calculate gas fee - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals + return await removeLiquidity( + network, + walletAddress, + poolAddress, + percentageToRemove, + undefined, + gasPrice, + maxGas, ); - - return { - signature: receipt.transactionHash, - status: receipt.status, - data: { - fee: gasFee, - baseTokenAmountRemoved, - quoteTokenAmountRemoved, - }, - }; } catch (e) { logger.error(e); - if (e.statusCode) { - throw e; - } - - // Handle insufficient funds errors + if (e.statusCode) throw e; if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { throw fastify.httpErrors.badRequest( 'Insufficient ETH balance to pay for gas fees. Please add more ETH to your wallet.', ); } - throw fastify.httpErrors.internalServerError('Failed to remove liquidity'); } }, diff --git a/src/connectors/pancakeswap/clmm-routes/createPool.ts b/src/connectors/pancakeswap/clmm-routes/createPool.ts new file mode 100644 index 0000000000..85e8bda09c --- /dev/null +++ b/src/connectors/pancakeswap/clmm-routes/createPool.ts @@ -0,0 +1,287 @@ +import { Contract } from '@ethersproject/contracts'; +import { Static } from '@sinclair/typebox'; +import { encodeSqrtRatioX96 } from '@uniswap/v3-sdk'; +import { Decimal } from 'decimal.js'; +import { BigNumber, constants, utils } from 'ethers'; +import { FastifyPluginAsync } from 'fastify'; +import JSBI from 'jsbi'; + +import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { + IPancakeswapV3FactoryABI, + IPancakeswapV3PoolSlot0ABI, + INftManagerCreatePoolABI, + 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%. +// NOTE: these differ from Uniswap V3 — Pancakeswap uses 2500 (0.25%) where Uniswap uses 3000 (0.30%). +const VALID_FEE_TIERS = [100, 500, 2500, 10000]; + +// Default gas limit for CLMM create-pool. Deploying + initializing a V3 pool via the NFT manager +// costs more than a plain swap; a pool deployment is ~4-5M gas on mainnet. +const CLMM_CREATE_POOL_GAS_LIMIT = 6000000; + +/** + * Resolves a token symbol or address to its on-chain TokenInfo. Native ETH is not an ERC20 — a V3 + * pool is always built on WETH — so 'ETH' resolves to WETH for the pool's token address/decimals. + */ +async function resolveToken(ethereum: Ethereum, tokenOrAddress: string): Promise { + const isEthInput = tokenOrAddress.toUpperCase() === 'ETH'; + const lookup = isEthInput ? 'WETH' : tokenOrAddress; + const token = await ethereum.getToken(lookup); + if (!token) { + throw httpErrors.badRequest(`Token not found: ${tokenOrAddress}`); + } + return token; +} + +/** + * Fetches the current market price (quote per base) from the unified swap router so a new pool can + * be seeded on-market instead of at an arbitrary ratio. Seeding off-market invites arbitrage bots to + * instantly rebalance the pool. Uses a SELL quote of the base token via the network's configured swap + * provider (an aggregator that does not require this not-yet-created pool); throws a clear error if no + * market route exists. + */ +async function fetchMarketPrice( + network: string, + baseToken: string, + quoteToken: string, + amount: number, +): Promise { + const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + let quote: any; + try { + quote = await getUnifiedQuoteSwap(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); + } catch (e: any) { + throw httpErrors.badRequest( + `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + + 'Pass initialPrice explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw httpErrors.badRequest(`No market route found for ${baseToken}/${quoteToken}. Pass initialPrice explicitly.`); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} + +export async function createPool( + network: string, + walletAddress: string, + baseToken: string, + 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) { + throw httpErrors.badRequest('fee tier is required (one of 100, 500, 2500, 10000)'); + } + if (!VALID_FEE_TIERS.includes(fee)) { + throw httpErrors.badRequest( + `Invalid fee tier ${fee}. Must be one of 100 (0.01%), 500 (0.05%), 2500 (0.25%), 10000 (1.00%)`, + ); + } + + const ethereum = await Ethereum.getInstance(network); + + const wallet = await ethereum.getWallet(walletAddress); + if (!wallet) { + throw httpErrors.badRequest('Wallet not found'); + } + + const baseTokenInfo = await resolveToken(ethereum, baseToken); + const quoteTokenInfo = await resolveToken(ethereum, quoteToken); + + if (baseTokenInfo.address.toLowerCase() === quoteTokenInfo.address.toLowerCase()) { + throw httpErrors.badRequest('baseToken and quoteToken must be different'); + } + + // V3 requires token0 < token1 by address (ascending). Determine which side is token0. + const baseIsToken0 = baseTokenInfo.address.toLowerCase() < quoteTokenInfo.address.toLowerCase(); + const token0 = baseIsToken0 ? baseTokenInfo : quoteTokenInfo; + const token1 = baseIsToken0 ? quoteTokenInfo : baseTokenInfo; + + // Resolve the seed price (quote per base). Priority: explicit initialPrice → live market price. + let seedPrice: number; + let seedSource: string; + if (initialPrice !== undefined) { + if (initialPrice <= 0) throw httpErrors.badRequest('initialPrice must be greater than zero'); + seedPrice = initialPrice; + seedSource = 'initialPrice'; + } else { + // Use 1 base unit as the probe amount for the market quote. + seedPrice = await fetchMarketPrice(network, baseToken, quoteToken, 1); + seedSource = 'market (unified swap router)'; + } + + // Convert the human seed price (quote per base) into sqrtPriceX96 for the (token0, token1) orientation. + // + // sqrtPriceX96 encodes sqrt(raw token1 per raw token0) * 2^96, where "raw" means base-unit amounts + // (i.e. adjusted for each token's decimals). encodeSqrtRatioX96(amount1, amount0) == sqrt(amount1/amount0) * 2^96, + // so we must supply amount1/amount0 == the RAW token1-per-token0 ratio. + // + // humanRatio (token1 per token0) = seedPrice when base == token0 (quote == token1) + // = 1 / seedPrice when base == token1 (quote == token0) ← price inverted + // rawRatio = humanRatio * 10^token1.decimals / 10^token0.decimals + // + // We therefore pass amount1 = humanRatio * 10^token1.decimals and amount0 = 10^token0.decimals. To keep both + // integers (encodeSqrtRatioX96 requires JSBI integers) we multiply BOTH by a fixed precision factor — this + // leaves the amount1/amount0 ratio unchanged while preserving the fractional part of humanRatio. + const humanRatio = baseIsToken0 ? new Decimal(seedPrice) : new Decimal(1).div(seedPrice); + const precision = new Decimal(10).pow(18); // integer-preserving scale factor applied to both sides + const amount1 = humanRatio.mul(new Decimal(10).pow(token1.decimals)).mul(precision).toFixed(0); + const amount0 = new Decimal(10).pow(token0.decimals).mul(precision).toFixed(0); + if (new Decimal(amount1).isZero() || new Decimal(amount0).isZero()) { + throw httpErrors.badRequest('Computed sqrtPriceX96 inputs are zero — check initialPrice and token decimals'); + } + const sqrtPriceX96 = encodeSqrtRatioX96(JSBI.BigInt(amount1), JSBI.BigInt(amount0)); + const sqrtPriceX96Str = sqrtPriceX96.toString(); + + logger.info( + `Creating Pancakeswap V3 pool ${baseTokenInfo.symbol}/${quoteTokenInfo.symbol} (fee ${fee}) seeded at ` + + `${seedPrice} ${quoteTokenInfo.symbol}/${baseTokenInfo.symbol} [${seedSource}] — token0=${token0.symbol}, ` + + `token1=${token1.symbol}, sqrtPriceX96=${sqrtPriceX96Str}`, + ); + + const factoryAddress = getPancakeswapV3FactoryAddress(network); + const factory = new Contract(factoryAddress, IPancakeswapV3FactoryABI, ethereum.provider); + + // If a pool already exists AND is already initialized (slot0.sqrtPriceX96 != 0), reject — there is + // nothing to create. A created-but-uninitialized pool (zero sqrtPriceX96) is still initialized below. + const existingPool: string = await factory.getPool(token0.address, token1.address, fee); + if (existingPool && existingPool !== constants.AddressZero) { + const poolContract = new Contract(existingPool, IPancakeswapV3PoolSlot0ABI, ethereum.provider); + const slot0 = await poolContract.slot0(); + if (!BigNumber.from(slot0.sqrtPriceX96).isZero()) { + throw new Error(`Pool already exists and is initialized for this token pair and fee tier: ${existingPool}`); + } + logger.info(`Pool ${existingPool} deployed but uninitialized — initializing it at the seed price`); + } + + // Create + initialize in a single idempotent call via the NonfungiblePositionManager. Pancakeswap V3 is a + // Uniswap V3 fork, so its NFT manager exposes createAndInitializePoolIfNecessary; it deploys the pool + // through the PoolDeployer (if needed) and initializes it at sqrtPriceX96 (if needed) — preferred over the + // two-tx Factory.createPool + Pool.initialize path. + const nftManagerAddress = getPancakeswapV3NftManagerAddress(network); + const nftManager = new Contract(nftManagerAddress, INftManagerCreatePoolABI, wallet); + + const gasOptions = await ethereum.prepareGasOptions(gasPrice, maxGas || CLMM_CREATE_POOL_GAS_LIMIT); + + const tx = await nftManager.createAndInitializePoolIfNecessary( + token0.address, + token1.address, + fee, + sqrtPriceX96Str, + gasOptions, + ); + + logger.info(`Creating Pancakeswap V3 pool via tx ${tx.hash}`); + + const receipt = await ethereum.handleTransactionExecution(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 + return { + signature: receipt.transactionHash, + status: 1, // CONFIRMED + 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 + poolAddress, + price: seedPrice, + }; +} + +export const createPoolRoute: FastifyPluginAsync = async (fastify) => { + await fastify.register(require('@fastify/sensible')); + + fastify.post<{ + Body: Static; + Reply: CreatePoolResponseType; + }>( + '/create-pool', + { + schema: { + description: 'Create and initialize a new Pancakeswap V3 (CLMM) pool at an initial price (no liquidity seeded)', + tags: ['/connector/pancakeswap'], + body: PancakeswapClmmCreatePoolRequest, + response: { + 200: CreatePoolResponse, + }, + }, + }, + async (request) => { + try { + const { + network, + baseToken, + quoteToken, + fee, + initialPrice, + gasPrice, + maxGas, + walletAddress: requestedWalletAddress, + } = request.body; + + if (!baseToken || !quoteToken) { + throw fastify.httpErrors.badRequest('Missing required parameters'); + } + + let walletAddress = requestedWalletAddress; + if (!walletAddress) { + walletAddress = await Ethereum.getFirstWalletAddress(); + if (!walletAddress) { + throw fastify.httpErrors.badRequest('No wallet address provided and no wallets found.'); + } + 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); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + + if (e.message && e.message.includes('already exists')) { + throw fastify.httpErrors.badRequest(e.message); + } + if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { + throw fastify.httpErrors.badRequest( + 'Insufficient native balance to pay for gas fees. Please add more funds to your wallet.', + ); + } + + throw fastify.httpErrors.internalServerError('Failed to create pool'); + } + }, + ); +}; + +export default createPoolRoute; diff --git a/src/connectors/pancakeswap/clmm-routes/index.ts b/src/connectors/pancakeswap/clmm-routes/index.ts index c191da678c..8847547e3f 100644 --- a/src/connectors/pancakeswap/clmm-routes/index.ts +++ b/src/connectors/pancakeswap/clmm-routes/index.ts @@ -3,6 +3,7 @@ import { FastifyPluginAsync } from 'fastify'; import addLiquidityRoute from './addLiquidity'; import closePositionRoute from './closePosition'; import collectFeesRoute from './collectFees'; +import createPoolRoute from './createPool'; import executeSwapRoute from './executeSwap'; import openPositionRoute from './openPosition'; import poolInfoRoute from './poolInfo'; @@ -24,6 +25,7 @@ export const pancakeswapClmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(removeLiquidityRoute); await fastify.register(collectFeesRoute); await fastify.register(closePositionRoute); + await fastify.register(createPoolRoute); }; export default pancakeswapClmmRoutes; diff --git a/src/connectors/pancakeswap/pancakeswap.contracts.ts b/src/connectors/pancakeswap/pancakeswap.contracts.ts index b90651c111..39f483da3e 100644 --- a/src/connectors/pancakeswap/pancakeswap.contracts.ts +++ b/src/connectors/pancakeswap/pancakeswap.contracts.ts @@ -1213,6 +1213,84 @@ export const IPancakeswapV2FactoryABI = { ], }; +/** + * Pancakeswap V3 Factory ABI — minimal fragment for deploying/reading a pool. + * `getPool` returns the canonical pool for a (token0, token1, fee) triple, or the zero + * address when no pool has been deployed yet. `createPool` deploys the pool (through the + * PoolDeployer internally) — used only as a fallback if the NFT manager path is unavailable. + * create-pool reads back the authoritative pool address from `getPool` after the tx confirms. + */ +export const IPancakeswapV3FactoryABI = [ + { + inputs: [ + { internalType: 'address', name: 'tokenA', type: 'address' }, + { internalType: 'address', name: 'tokenB', type: 'address' }, + { internalType: 'uint24', name: 'fee', type: 'uint24' }, + ], + name: 'getPool', + outputs: [{ internalType: 'address', name: 'pool', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'tokenA', type: 'address' }, + { internalType: 'address', name: 'tokenB', type: 'address' }, + { internalType: 'uint24', name: 'fee', type: 'uint24' }, + ], + name: 'createPool', + outputs: [{ internalType: 'address', name: 'pool', type: 'address' }], + stateMutability: 'nonpayable', + type: 'function', + }, +]; + +/** + * Pancakeswap V3 Pool ABI — minimal `slot0` fragment. `sqrtPriceX96 == 0` means the pool + * contract has been deployed by the factory but not yet initialized with a price. + * create-pool uses this to distinguish an already-initialized (live) pool from a + * created-but-uninitialized one. + */ +export const IPancakeswapV3PoolSlot0ABI = [ + { + inputs: [], + name: 'slot0', + outputs: [ + { internalType: 'uint160', name: 'sqrtPriceX96', type: 'uint160' }, + { internalType: 'int24', name: 'tick', type: 'int24' }, + { internalType: 'uint16', name: 'observationIndex', type: 'uint16' }, + { internalType: 'uint16', name: 'observationCardinality', type: 'uint16' }, + { internalType: 'uint16', name: 'observationCardinalityNext', type: 'uint16' }, + { internalType: 'uint32', name: 'feeProtocol', type: 'uint32' }, + { internalType: 'bool', name: 'unlocked', type: 'bool' }, + ], + stateMutability: 'view', + type: 'function', + }, +]; + +/** + * Pancakeswap V3 NonfungiblePositionManager ABI — minimal `createAndInitializePoolIfNecessary` + * fragment. This single, idempotent call deploys the pool via the factory/PoolDeployer (if it + * does not yet exist) AND initializes it at `sqrtPriceX96` (if not yet initialized), returning + * the pool address. Preferred over the two-tx Factory.createPool + Pool.initialize path. + * Pancakeswap V3 is a Uniswap V3 fork, so its NFT manager exposes this method. + */ +export const INftManagerCreatePoolABI = [ + { + inputs: [ + { internalType: 'address', name: 'token0', type: 'address' }, + { internalType: 'address', name: 'token1', type: 'address' }, + { internalType: 'uint24', name: 'fee', type: 'uint24' }, + { internalType: 'uint160', name: 'sqrtPriceX96', type: 'uint160' }, + ], + name: 'createAndInitializePoolIfNecessary', + outputs: [{ internalType: 'address', name: 'pool', type: 'address' }], + stateMutability: 'payable', + type: 'function', + }, +]; + /** * Standard ERC20 ABI for token operations */ diff --git a/src/connectors/pancakeswap/schemas.ts b/src/connectors/pancakeswap/schemas.ts index f3bc36f8ad..7d01b9a1cf 100644 --- a/src/connectors/pancakeswap/schemas.ts +++ b/src/connectors/pancakeswap/schemas.ts @@ -32,6 +32,67 @@ export const PancakeswapAmmGetPoolInfoRequest = Type.Object({ }), }); +// Pancakeswap AMM Create Pool Request (Pancakeswap V2 — Uniswap V2 fork, fixed 0.25% fee) +export const PancakeswapAmmCreatePoolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...PancakeswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will create and seed the pool', + default: ethereumChainConfig.defaultWallet, + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address (becomes the pool base)', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Quote token symbol or address (becomes the pool quote)', + examples: [QUOTE_TOKEN], + }), + 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 current market price is fetched from the unified swap router.', + }), + ), + initialPrice: Type.Optional( + Type.Number({ + 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.', + }), + ), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + 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], + }), + ), +}); + // ======================================== // CLMM Request Schemas // ======================================== @@ -51,6 +112,56 @@ export const PancakeswapClmmGetPoolInfoRequest = Type.Object({ }), }); +// Pancakeswap CLMM Create Pool Request (Pancakeswap V3 — Uniswap V3 fork) +export const PancakeswapClmmCreatePoolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: 'bsc', + examples: ['bsc'], + enum: [...PancakeswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will create and initialize the pool', + default: ethereumChainConfig.defaultWallet, + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address (becomes the pool base)', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Quote token symbol or address (becomes the pool quote)', + examples: [QUOTE_TOKEN], + }), + fee: Type.Number({ + 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], + examples: [2500], + }), + initialPrice: Type.Optional( + Type.Number({ + 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.', + }), + ), + 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], + }), + ), +}); + // ======================================== // Router Request Schemas // ======================================== diff --git a/src/trading/swap/execute.ts b/src/trading/swap/execute.ts index 11f35d1ed4..ea46dbe360 100644 --- a/src/trading/swap/execute.ts +++ b/src/trading/swap/execute.ts @@ -262,7 +262,15 @@ async function executeEthereumSwap( slippagePct, ); } else if (providerKey === 'pancakeswap/amm') { - return await pancakeswapAmmExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); + return await pancakeswapAmmExecuteSwap( + network, + walletAddress, + poolAddress!, + baseToken, + side, + amount, + slippagePct, + ); } else if (providerKey === 'pancakeswap/clmm') { return await pancakeswapClmmExecuteSwap( network, diff --git a/src/trading/swap/quote.ts b/src/trading/swap/quote.ts index 69af5d316f..db4953c5a5 100644 --- a/src/trading/swap/quote.ts +++ b/src/trading/swap/quote.ts @@ -229,7 +229,7 @@ async function getEthereumQuoteSwap( } else if (providerKey === 'pancakeswap/router') { return await pancakeswapRouterQuoteSwap(network, undefined, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'pancakeswap/amm') { - return await pancakeswapAmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); + return await pancakeswapAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'pancakeswap/clmm') { return await pancakeswapClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === '0x/router') { diff --git a/src/trading/trading-amm-routes/add-liquidity.ts b/src/trading/trading-amm-routes/add-liquidity.ts index a63079a154..51bca2d977 100644 --- a/src/trading/trading-amm-routes/add-liquidity.ts +++ b/src/trading/trading-amm-routes/add-liquidity.ts @@ -2,6 +2,7 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; import { addLiquidity as meteoraAddLiquidity } from '../../connectors/meteora/amm-routes/addLiquidity'; +import { addLiquidity as pancakeswapAddLiquidity } from '../../connectors/pancakeswap/amm-routes/addLiquidity'; import { addLiquidity as raydiumAddLiquidity } from '../../connectors/raydium/amm-routes/addLiquidity'; import { addLiquidity as uniswapAddLiquidity } from '../../connectors/uniswap/amm-routes/addLiquidity'; import { AddLiquidityResponse, AddLiquidityResponseType } from '../../schemas/amm-schema'; @@ -70,6 +71,15 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { quoteTokenAmount, slippagePct, ); + case 'pancakeswap': + return await pancakeswapAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); default: throw httpErrors.badRequest( `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, diff --git a/src/trading/trading-amm-routes/common.ts b/src/trading/trading-amm-routes/common.ts index f7679685f2..a0e702f621 100644 --- a/src/trading/trading-amm-routes/common.ts +++ b/src/trading/trading-amm-routes/common.ts @@ -2,7 +2,7 @@ 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']; +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 } { diff --git a/src/trading/trading-amm-routes/create-pool.ts b/src/trading/trading-amm-routes/create-pool.ts index aa9688e6e4..a72e8c4b24 100644 --- a/src/trading/trading-amm-routes/create-pool.ts +++ b/src/trading/trading-amm-routes/create-pool.ts @@ -4,6 +4,7 @@ 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'; @@ -161,6 +162,20 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { slippagePct, ); + case 'pancakeswap': + return await pancakeswapCreatePool( + network, + walletAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + initialPrice, + gasPrice, + maxGas, + slippagePct, + ); + default: throw httpErrors.badRequest(`Unsupported AMM connector: ${connector}`); } diff --git a/src/trading/trading-amm-routes/execute-swap.ts b/src/trading/trading-amm-routes/execute-swap.ts index 3d6efaa811..bbdceb1912 100644 --- a/src/trading/trading-amm-routes/execute-swap.ts +++ b/src/trading/trading-amm-routes/execute-swap.ts @@ -2,6 +2,7 @@ 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'; @@ -51,6 +52,8 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { 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(', ')}`, diff --git a/src/trading/trading-amm-routes/pool-info.ts b/src/trading/trading-amm-routes/pool-info.ts index a24d3d8ab0..2f553eb89b 100644 --- a/src/trading/trading-amm-routes/pool-info.ts +++ b/src/trading/trading-amm-routes/pool-info.ts @@ -2,6 +2,7 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; import { getPoolInfo as meteoraGetPoolInfo } from '../../connectors/meteora/amm-routes/poolInfo'; +import { getPoolInfo as pancakeswapGetPoolInfo } from '../../connectors/pancakeswap/amm-routes/poolInfo'; import { getPoolInfo as raydiumGetPoolInfo } from '../../connectors/raydium/amm-routes/poolInfo'; import { getPoolInfo as uniswapGetPoolInfo } from '../../connectors/uniswap/amm-routes/poolInfo'; import { PoolInfo, PoolInfoSchema } from '../../schemas/amm-schema'; @@ -44,6 +45,8 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { return await raydiumGetPoolInfo(network, poolAddress); case 'uniswap': return await uniswapGetPoolInfo(network, poolAddress); + case 'pancakeswap': + return await pancakeswapGetPoolInfo(network, poolAddress); default: throw httpErrors.badRequest( `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, diff --git a/src/trading/trading-amm-routes/position-info.ts b/src/trading/trading-amm-routes/position-info.ts index 26a6249811..8cde9b4c73 100644 --- a/src/trading/trading-amm-routes/position-info.ts +++ b/src/trading/trading-amm-routes/position-info.ts @@ -2,6 +2,7 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; import { getPositionInfo as meteoraGetPositionInfo } from '../../connectors/meteora/amm-routes/positionInfo'; +import { getPositionInfo as pancakeswapGetPositionInfo } from '../../connectors/pancakeswap/amm-routes/positionInfo'; import { getPositionInfo as raydiumGetPositionInfo } from '../../connectors/raydium/amm-routes/positionInfo'; import { getPositionInfo as uniswapGetPositionInfo } from '../../connectors/uniswap/amm-routes/positionInfo'; import { PositionInfo, PositionInfoSchema } from '../../schemas/amm-schema'; @@ -45,6 +46,8 @@ export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { return await raydiumGetPositionInfo(network, poolAddress, walletAddress); case 'uniswap': return await uniswapGetPositionInfo(network, poolAddress, walletAddress); + case 'pancakeswap': + return await pancakeswapGetPositionInfo(network, poolAddress, walletAddress); default: throw httpErrors.badRequest( `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, diff --git a/src/trading/trading-amm-routes/quote-liquidity.ts b/src/trading/trading-amm-routes/quote-liquidity.ts index cb8754fb30..49d073ad28 100644 --- a/src/trading/trading-amm-routes/quote-liquidity.ts +++ b/src/trading/trading-amm-routes/quote-liquidity.ts @@ -2,6 +2,7 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; import { quoteLiquidity as meteoraQuoteLiquidity } from '../../connectors/meteora/amm-routes/quoteLiquidity'; +import { quoteLiquidity as pancakeswapQuoteLiquidity } from '../../connectors/pancakeswap/amm-routes/quoteLiquidity'; import { quoteLiquidity as raydiumQuoteLiquidity } from '../../connectors/raydium/amm-routes/quoteLiquidity'; import { quoteLiquidity as uniswapQuoteLiquidity } from '../../connectors/uniswap/amm-routes/quoteLiquidity'; import { QuoteLiquidityResponse, QuoteLiquidityResponseType } from '../../schemas/amm-schema'; @@ -47,6 +48,14 @@ export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { return await raydiumQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); case 'uniswap': return await uniswapQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); + case 'pancakeswap': + return await pancakeswapQuoteLiquidity( + network, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); default: throw httpErrors.badRequest( `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, diff --git a/src/trading/trading-amm-routes/quote-swap.ts b/src/trading/trading-amm-routes/quote-swap.ts index a48ee0d081..72e467a6aa 100644 --- a/src/trading/trading-amm-routes/quote-swap.ts +++ b/src/trading/trading-amm-routes/quote-swap.ts @@ -2,6 +2,7 @@ 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'; @@ -49,6 +50,8 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { 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(', ')}`, diff --git a/src/trading/trading-amm-routes/remove-liquidity.ts b/src/trading/trading-amm-routes/remove-liquidity.ts index 8b3100771d..8005511b95 100644 --- a/src/trading/trading-amm-routes/remove-liquidity.ts +++ b/src/trading/trading-amm-routes/remove-liquidity.ts @@ -2,6 +2,7 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; import { removeLiquidity as meteoraRemoveLiquidity } from '../../connectors/meteora/amm-routes/removeLiquidity'; +import { removeLiquidity as pancakeswapRemoveLiquidity } from '../../connectors/pancakeswap/amm-routes/removeLiquidity'; import { removeLiquidity as raydiumRemoveLiquidity } from '../../connectors/raydium/amm-routes/removeLiquidity'; import { removeLiquidity as uniswapRemoveLiquidity } from '../../connectors/uniswap/amm-routes/removeLiquidity'; import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../schemas/amm-schema'; @@ -47,6 +48,14 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { return await raydiumRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); case 'uniswap': return await uniswapRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); + case 'pancakeswap': + return await pancakeswapRemoveLiquidity( + network, + walletAddress, + poolAddress, + percentageToRemove, + slippagePct, + ); default: throw httpErrors.badRequest( `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, diff --git a/src/trading/trading-clmm-routes/create-pool.ts b/src/trading/trading-clmm-routes/create-pool.ts index 7ed287c199..9aef970949 100644 --- a/src/trading/trading-clmm-routes/create-pool.ts +++ b/src/trading/trading-clmm-routes/create-pool.ts @@ -4,6 +4,9 @@ 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'; @@ -33,7 +36,7 @@ function parseChainNetwork(chainNetwork: string): { chain: string; network: stri // 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)', + description: 'CLMM connector name (meteora, raydium, uniswap, orca, pancakeswap, pancakeswap-sol)', default: 'meteora', examples: ['meteora'], }), @@ -56,9 +59,15 @@ const UnifiedClmmCreatePoolRequest = Type.Object({ 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: 'Uniswap V3 fee tier (100 | 500 | 3000 | 10000)' })), - gasPrice: Type.Optional(Type.Number({ description: 'Uniswap (EVM) gas price in gwei' })), - maxGas: Type.Optional(Type.Number({ description: 'Uniswap (EVM) max gas limit' })), + 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)' })), }); export const createPoolRoute: FastifyPluginAsync = async (fastify) => { @@ -89,6 +98,8 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { feeBps, ammConfigIndex, fee, + tickSpacing, + ammConfig, gasPrice, maxGas, } = request.body; @@ -119,6 +130,28 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { gasPrice, maxGas, ); + case 'orca': + return await orcaCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, tickSpacing); + case 'pancakeswap': + return await pancakeswapCreatePool( + network, + walletAddress, + baseToken, + quoteToken, + initialPrice, + fee, + gasPrice, + maxGas, + ); + case 'pancakeswap-sol': + return await pancakeswapSolCreatePool( + network, + walletAddress, + baseToken, + quoteToken, + initialPrice, + ammConfig, + ); default: throw httpErrors.badRequest(`Unsupported CLMM connector: ${connector}`); } diff --git a/test/connectors/orca/clmm-routes/create-pool.test.ts b/test/connectors/orca/clmm-routes/create-pool.test.ts new file mode 100644 index 0000000000..9272c651f4 --- /dev/null +++ b/test/connectors/orca/clmm-routes/create-pool.test.ts @@ -0,0 +1,86 @@ +import { PublicKey } from '@solana/web3.js'; + +import { Solana } from '../../../../src/chains/solana/solana'; +import { Orca } from '../../../../src/connectors/orca/orca'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/orca/orca'); + +const mockSOL = { symbol: 'SOL', address: 'So11111111111111111111111111111111111111112', decimals: 9 }; +const mockWallet = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../../src/connectors/orca/clmm-routes/createPool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /create-pool (Orca CLMM)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + // Whirlpool client only needs to expose the wallet pubkey used as the createPool funder. + (Orca.getInstance as jest.Mock).mockResolvedValue({ + getWhirlpoolClientForWallet: jest.fn().mockResolvedValue({ + getContext: () => ({ wallet: { publicKey: new PublicKey(mockWallet) } }), + getFetcher: () => ({ getMintInfo: jest.fn() }), + }), + }); + }); + + it('rejects when baseToken and quoteToken resolve to the same mint', async () => { + // Resolve both tokens to the same mint so the base != quote guard fires before any SDK call. + (Solana.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn(() => Promise.resolve(mockSOL)), + }); + + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'mainnet-beta', + walletAddress: mockWallet, + baseToken: 'SOL', + quoteToken: 'SOL', + tickSpacing: 64, + initialPrice: 150, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/must be different/); + }); + + it('rejects when tickSpacing is not a positive integer', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn(() => Promise.resolve(mockSOL)), + }); + + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'mainnet-beta', + walletAddress: mockWallet, + baseToken: 'SOL', + quoteToken: 'USDC', + tickSpacing: 0, + initialPrice: 150, + }, + }); + + expect(response.statusCode).toBe(400); + }); +}); diff --git a/test/connectors/pancakeswap-sol/clmm-routes/create-pool.test.ts b/test/connectors/pancakeswap-sol/clmm-routes/create-pool.test.ts new file mode 100644 index 0000000000..28fd6fc0e0 --- /dev/null +++ b/test/connectors/pancakeswap-sol/clmm-routes/create-pool.test.ts @@ -0,0 +1,100 @@ +import { Solana } from '../../../../src/chains/solana/solana'; +import { + PancakeswapSol, + PANCAKESWAP_CLMM_PROGRAM_ID, +} from '../../../../src/connectors/pancakeswap-sol/pancakeswap-sol'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol', () => { + const actual = jest.requireActual('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol'); + return { + ...actual, + PancakeswapSol: { getInstance: jest.fn() }, + }; +}); +// Stub getMint so decimals resolve without a real RPC; keep the rest of spl-token real. +jest.mock('@solana/spl-token', () => ({ + ...jest.requireActual('@solana/spl-token'), + getMint: jest.fn(() => Promise.resolve({ decimals: 9 })), +})); + +const mockSOL = { symbol: 'SOL', address: 'So11111111111111111111111111111111111111112', decimals: 9 }; +const mockUSDC = { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }; +const mockWallet = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; +const mockAmmConfig = 'E64NGkDLLCdQ2yFNPcavaKptrEgmiQaNykUuLC1Qgwyp'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/createPool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /create-pool (PancakeSwap Solana CLMM)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + (PancakeswapSol.getInstance as jest.Mock).mockResolvedValue({}); + }); + + it('rejects when baseToken and quoteToken resolve to the same mint', async () => { + // amm_config exists (owned by the CLMM program) so validation passes to the base != quote guard. + (Solana.getInstance as jest.Mock).mockResolvedValue({ + network: 'mainnet-beta', + getToken: jest.fn(() => Promise.resolve(mockSOL)), + connection: { + getAccountInfo: jest.fn(() => Promise.resolve({ owner: PANCAKESWAP_CLMM_PROGRAM_ID })), + }, + }); + + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'mainnet-beta', + walletAddress: mockWallet, + baseToken: 'SOL', + quoteToken: 'SOL', + initialPrice: 150, + ammConfig: mockAmmConfig, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/must be different/); + }); + + it('rejects when ammConfig is missing', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue({ + network: 'mainnet-beta', + getToken: jest.fn((t: string) => Promise.resolve(t === 'SOL' ? mockSOL : mockUSDC)), + connection: { getAccountInfo: jest.fn(() => Promise.resolve(null)) }, + }); + + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'mainnet-beta', + walletAddress: mockWallet, + baseToken: 'SOL', + quoteToken: 'USDC', + initialPrice: 150, + // ammConfig intentionally omitted — the schema requires it, so this is rejected as a bad request. + }, + }); + + expect(response.statusCode).toBe(400); + }); +}); diff --git a/test/connectors/pancakeswap/amm-routes/create-pool.test.ts b/test/connectors/pancakeswap/amm-routes/create-pool.test.ts new file mode 100644 index 0000000000..e399b4f467 --- /dev/null +++ b/test/connectors/pancakeswap/amm-routes/create-pool.test.ts @@ -0,0 +1,55 @@ +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); + +const mockWETH = { symbol: 'WETH', address: '0x4200000000000000000000000000000000000006', decimals: 18 }; +const mockWallet = '0x0000000000000000000000000000000000000001'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../../src/connectors/pancakeswap/amm-routes/createPool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /create-pool (Pancakeswap V2 AMM)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + (Ethereum.getInstance as jest.Mock).mockResolvedValue({ + getWallet: jest.fn().mockResolvedValue({ address: mockWallet }), + // Both sides resolve to WETH so the "only one side can be ETH/WETH" / "must be different" guard fires. + getToken: jest.fn(() => Promise.resolve(mockWETH)), + }); + }); + + it('rejects an invalid pair where both sides resolve to the same (WETH) token with a 400', async () => { + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'bsc', + walletAddress: mockWallet, + baseToken: 'ETH', + quoteToken: 'WETH', + baseTokenAmount: 1, + quoteTokenAmount: 1, + }, + }); + + // ETH and WETH resolve to the same address, so the "must be different" / "one side" guard fires (400, not 500). + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/different|one side/i); + }); +}); diff --git a/test/connectors/pancakeswap/clmm-routes/create-pool.test.ts b/test/connectors/pancakeswap/clmm-routes/create-pool.test.ts new file mode 100644 index 0000000000..9cd3a8d8e3 --- /dev/null +++ b/test/connectors/pancakeswap/clmm-routes/create-pool.test.ts @@ -0,0 +1,78 @@ +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); + +const mockWBNB = { symbol: 'WBNB', address: '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c', decimals: 18 }; +const mockUSDT = { symbol: 'USDT', address: '0x55d398326f99059ff775485246999027b3197955', decimals: 18 }; +const mockWallet = '0x0000000000000000000000000000000000000001'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { createPoolRoute } = await import('../../../../src/connectors/pancakeswap/clmm-routes/createPool'); + await server.register(createPoolRoute); + return server; +}; + +describe('POST /create-pool (Pancakeswap V3 CLMM)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + const tokenByLookup: Record = { + WBNB: mockWBNB, + WETH: mockWBNB, + ETH: mockWBNB, + USDT: mockUSDT, + }; + (Ethereum.getInstance as jest.Mock).mockResolvedValue({ + getWallet: jest.fn().mockResolvedValue({ address: mockWallet }), + getToken: jest.fn((sym: string) => Promise.resolve(tokenByLookup[sym.toUpperCase()])), + }); + }); + + it('rejects the Uniswap-only 3000 fee tier (invalid on Pancakeswap V3) with a clear 400', async () => { + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'bsc', + walletAddress: mockWallet, + baseToken: 'WBNB', + quoteToken: 'USDT', + fee: 3000, // valid on Uniswap V3, but NOT one of Pancakeswap's 100 / 500 / 2500 / 10000 + initialPrice: 600, + }, + }); + + // Fastify schema validation rejects the out-of-enum fee before the handler runs → 400. + expect(response.statusCode).toBe(400); + }); + + it('rejects baseToken == quoteToken with a clear 400', async () => { + const response = await server.inject({ + method: 'POST', + url: '/create-pool', + payload: { + network: 'bsc', + walletAddress: mockWallet, + baseToken: 'WBNB', + quoteToken: 'WBNB', + fee: 2500, + initialPrice: 600, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/different/i); + }); +}); From 0516aaf117b96b37ccd66b3475e1bee55cae6c0b Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 5 Aug 2026 10:51:19 -0700 Subject: [PATCH 09/10] feat(meteora): position-addressed DAMM v2 AMM (require positionAddress, add positions-owned) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DAMM v2 positions are NFTs, not fungible LP; a wallet may hold several per pool, each an independent asset differing in size, lock state, and fees. The prior AMM routes aggregated on read but silently acted on the largest position on write, so "remove 100%" was not a true exit when several existed. Make the Meteora AMM routes position-addressed (mirroring CLMM): - remove-liquidity now REQUIRES positionAddress; percentage applies to that position (found via the owner-filtered getUserPositions, which also proves ownership + pool membership). - add-liquidity takes an OPTIONAL positionAddress: provide to add to that position, omit to open a NEW position NFT (never silently the largest). - position-info returns the pool aggregate PLUS a positions[] breakdown (positionAddress, lpTokenAmount, base/quote per NFT) — the discovery path. - new positions-owned route lists all of a wallet's DAMM v2 positions across pools (getPositionsByUser grouped by pool). Unified /trading/amm/* carry positionAddress (required for meteora on remove, optional on add) and expose positions-owned; fungible-LP AMMs (raydium, uniswap, pancakeswap) ignore positionAddress and reject positions-owned with a clear error, since a fungible LP balance has no enumerable positions. Shared PositionInfoSchema gains an optional positions[] (PositionDetail). Adds unified-dispatch tests (meteora remove without positionAddress -> 400; fungible-LP positions-owned -> 400). Updates the DAMM v2 connector doc. typecheck/lint/build clean; 84 meteora+trading tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY --- docs/connectors/meteora-damm-v2.md | 50 ++++++++----- .../meteora/amm-routes/addLiquidity.ts | 27 +++++-- src/connectors/meteora/amm-routes/index.ts | 2 + .../meteora/amm-routes/positionInfo.ts | 25 +++++-- .../meteora/amm-routes/positionsOwned.ts | 74 +++++++++++++++++++ .../meteora/amm-routes/removeLiquidity.ts | 19 +++-- src/connectors/meteora/schemas.ts | 30 +++++++- src/schemas/amm-schema.ts | 18 +++++ .../trading-amm-routes/add-liquidity.ts | 20 ++++- src/trading/trading-amm-routes/index.ts | 1 + .../trading-amm-routes/positions-owned.ts | 65 ++++++++++++++++ .../trading-amm-routes/remove-liquidity.ts | 32 +++++++- src/trading/trading.routes.ts | 2 + .../positions-owned.test.ts | 46 ++++++++++++ .../remove-liquidity.test.ts | 55 ++++++++++++++ 15 files changed, 424 insertions(+), 42 deletions(-) create mode 100644 src/connectors/meteora/amm-routes/positionsOwned.ts create mode 100644 src/trading/trading-amm-routes/positions-owned.ts create mode 100644 test/trading/trading-amm-routes/positions-owned.test.ts create mode 100644 test/trading/trading-amm-routes/remove-liquidity.test.ts diff --git a/docs/connectors/meteora-damm-v2.md b/docs/connectors/meteora-damm-v2.md index 43ae1b3ea4..cbc82961c8 100644 --- a/docs/connectors/meteora-damm-v2.md +++ b/docs/connectors/meteora-damm-v2.md @@ -10,12 +10,13 @@ the Raydium AMM connector: | Endpoint | Method | Notes | |---|---|---| | `/connectors/meteora/amm/pool-info` | GET | Pool reserves, price, base (cliff) fee % | -| `/connectors/meteora/amm/position-info` | GET | Wallet's aggregated liquidity in a pool | +| `/connectors/meteora/amm/position-info` | GET | Wallet's aggregate liquidity in a pool + per-position `positions[]` breakdown | +| `/connectors/meteora/amm/positions-owned` | GET | All of the wallet's DAMM v2 positions across pools | | `/connectors/meteora/amm/quote-swap` | GET | Exact-in (SELL) / exact-out (BUY) quote | | `/connectors/meteora/amm/execute-swap` | POST | Swap | | `/connectors/meteora/amm/quote-liquidity` | GET | Two-sided deposit quote | -| `/connectors/meteora/amm/add-liquidity` | POST | Add to (or open) a position | -| `/connectors/meteora/amm/remove-liquidity` | POST | Remove a % of position liquidity | +| `/connectors/meteora/amm/add-liquidity` | POST | Add to a specific position (`positionAddress`) or open a new one | +| `/connectors/meteora/amm/remove-liquidity` | POST | Remove a % from a specific position (`positionAddress` **required**) | | `/connectors/meteora/amm/create-pool` | POST | Create + seed a new pool | The implementation deliberately keeps to "the basics" so it fits the shared AMM schema. This @@ -33,21 +34,34 @@ exactly one position per (wallet, pool). DAMM v2 instead mints a **position NFT* and a wallet can hold **several positions in the same pool**. Liquidity, fees, and vesting all live on the position account (`PositionState`), keyed by the NFT. -- **`add-liquidity`** — if the wallet already holds a position in the pool, liquidity is added to - its **largest** position; otherwise a new position NFT is minted - (`createPositionAndAddLiquidity`). Minting requires the NFT mint keypair to co-sign; Gateway's - Solana send path already supports ephemeral extra signers, so the keypair is generated in the - route and passed through. -- **`remove-liquidity`** — operates on the wallet's **largest** position and removes the requested - percentage of its *unlocked* liquidity (100% removes the exact unlocked amount). -- **`position-info`** — there is no LP token balance to report, so amounts are **summed across all - of the wallet's positions** in the pool (via `getWithdrawQuote` on each). `lpTokenAmount` is - reported as the aggregate position liquidity (the Q64 liquidity value converted to a decimal), - clearly *not* an SPL token balance. - - **Proposed enhancement:** add position-addressed routes (`positions-owned`, and - `position`/`add`/`remove` that take a `positionAddress`), mirroring the CLMM interface, so callers - can manage multiple positions per pool precisely instead of always defaulting to the largest. +Because positions are individually addressable and can differ arbitrarily in size, lock state, and +accrued fees, the AMM routes are **position-addressed** (mirroring the CLMM interface) rather than +silently defaulting to the largest position: + +- **`position-info`** — there is no LP token balance to report, so the top-level amounts are the + **aggregate** summed across all of the wallet's positions in the pool (via `getWithdrawQuote` on + each), and `positions[]` breaks that out **per NFT** (`positionAddress`, `lpTokenAmount`, base/quote + amounts). `lpTokenAmount` is the Q64 liquidity value converted to a decimal — clearly *not* an SPL + token balance. `positions[]` is the discovery mechanism: read it to get the addresses to pass to + add/remove. +- **`positions-owned`** — lists **all** of the wallet's DAMM v2 positions across every pool + (`getPositionsByUser`, grouped by pool), each entry being that pool's `position-info`. Use it to + discover holdings without enumerating pool addresses. +- **`remove-liquidity`** — requires a **`positionAddress`** and removes the requested percentage of + *that position's* unlocked liquidity (100% removes the exact unlocked amount). Requiring the + address avoids silently draining only the largest position when several exist — so "remove 100%" + means what the caller expects. The lookup goes through the owner-filtered `getUserPositions`, which + also proves the wallet owns the position and that it belongs to the pool. +- **`add-liquidity`** — if a **`positionAddress`** is given, liquidity is added to that specific + position; if omitted, a **new** position NFT is minted (`createPositionAndAddLiquidity`) — we never + silently pick an existing one. Minting requires the NFT mint keypair to co-sign; Gateway's Solana + send path already supports ephemeral extra signers, so the keypair is generated in the route and + passed through. + +The unified `/trading/amm/*` routes carry the same `positionAddress` field (required for meteora on +remove, optional on add) and expose `positions-owned`; fungible-LP AMMs (Raydium CPMM, Uniswap V2, +Pancakeswap V2) ignore `positionAddress` and reject `positions-owned` with a clear error, since a +fungible LP balance has no enumerable positions. ### 2. sqrt-price / concentrated-liquidity accounting DAMM v2 uses Uniswap-v3-style `sqrtPrice` (Q64) math with `sqrtMinPrice`/`sqrtMaxPrice` bounds, diff --git a/src/connectors/meteora/amm-routes/addLiquidity.ts b/src/connectors/meteora/amm-routes/addLiquidity.ts index 2c379f2c68..4decdb7f4d 100644 --- a/src/connectors/meteora/amm-routes/addLiquidity.ts +++ b/src/connectors/meteora/amm-routes/addLiquidity.ts @@ -18,6 +18,7 @@ export async function addLiquidity( baseTokenAmount: number, quoteTokenAmount: number, slippagePct: number = MeteoraConfig.config.slippagePct, + positionAddress?: string, ): Promise { const solana = await Solana.getInstance(network); const meteoraDamm = await MeteoraDamm.getInstance(network); @@ -33,10 +34,6 @@ export async function addLiquidity( const owner = new PublicKey(walletAddress); const pool = new PublicKey(poolAddress); - // DAMM v2 positions are NFTs. Add to the wallet's largest existing position in this pool, or - // create a new position NFT if the wallet has none. - const existing = await meteoraDamm.getUserPositions(poolAddress, walletAddress); - let transaction: Transaction; const extraSigners: Keypair[] = []; @@ -52,8 +49,18 @@ export async function addLiquidity( tokenBProgram, }; - if (existing.length > 0) { - const target = existing[0]; + // DAMM v2 positions are NFTs; a wallet may hold several per pool. If a position address is given, + // add to that specific position (owner-filtered lookup also proves ownership + pool membership). + // If omitted, open a NEW position NFT — we never silently pick an existing one. + if (positionAddress) { + const existing = await meteoraDamm.getUserPositions(poolAddress, walletAddress); + const target = existing.find((p) => p.position.toBase58() === positionAddress); + if (!target) { + throw httpErrors.notFound( + `Position ${positionAddress} not found for wallet in pool ${poolAddress}. ` + + 'List the wallet positions with position-info, or omit positionAddress to open a new position.', + ); + } logger.info(`Adding liquidity to existing DAMM v2 position ${target.position.toBase58()} in pool ${poolAddress}`); transaction = await meteoraDamm.cpAmm.addLiquidity({ owner, @@ -108,7 +115,9 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { '/add-liquidity', { schema: { - description: 'Add liquidity to a Meteora DAMM v2 pool (opens a position NFT if the wallet has none)', + 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.', tags: ['/connector/meteora'], body: MeteoraAmmAddLiquidityRequest, response: { @@ -118,7 +127,8 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { network, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.body; + const { network, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct, positionAddress } = + request.body; const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; return await addLiquidity( network, @@ -127,6 +137,7 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, effectiveSlippage, + positionAddress, ); } catch (e) { logger.error(e); diff --git a/src/connectors/meteora/amm-routes/index.ts b/src/connectors/meteora/amm-routes/index.ts index 3142acb50a..9b93ee334d 100644 --- a/src/connectors/meteora/amm-routes/index.ts +++ b/src/connectors/meteora/amm-routes/index.ts @@ -5,6 +5,7 @@ import { createPoolRoute } from './createPool'; import { executeSwapRoute } from './executeSwap'; import { poolInfoRoute } from './poolInfo'; import { positionInfoRoute } from './positionInfo'; +import { positionsOwnedRoute } from './positionsOwned'; import { quoteLiquidityRoute } from './quoteLiquidity'; import { quoteSwapRoute } from './quoteSwap'; import { removeLiquidityRoute } from './removeLiquidity'; @@ -12,6 +13,7 @@ import { removeLiquidityRoute } from './removeLiquidity'; export const meteoraAmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(poolInfoRoute); await fastify.register(positionInfoRoute); + await fastify.register(positionsOwnedRoute); await fastify.register(quoteSwapRoute); await fastify.register(quoteLiquidityRoute); await fastify.register(executeSwapRoute); diff --git a/src/connectors/meteora/amm-routes/positionInfo.ts b/src/connectors/meteora/amm-routes/positionInfo.ts index 97949371bf..a7a7fae47d 100644 --- a/src/connectors/meteora/amm-routes/positionInfo.ts +++ b/src/connectors/meteora/amm-routes/positionInfo.ts @@ -4,7 +4,12 @@ import BN from 'bn.js'; import { Decimal } from 'decimal.js'; import { FastifyPluginAsync } from 'fastify'; -import { GetPositionInfoRequestType, PositionInfo, PositionInfoSchema } from '../../../schemas/amm-schema'; +import { + GetPositionInfoRequestType, + PositionInfo, + PositionInfoSchema, + PositionDetail, +} from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { MeteoraDamm } from '../meteora-damm'; @@ -12,7 +17,9 @@ import { MeteoraAmmGetPositionInfoRequest } from '../schemas'; /** * Standard AMM position-info entry point (network-based) — consumed by the unified /trading/amm - * dispatcher. DAMM v2 positions are NFTs; amounts are summed across the wallet's positions in the pool. + * dispatcher. DAMM v2 positions are NFTs; a wallet may hold several per pool. The top-level amounts + * are the aggregate; `positions[]` breaks them out per NFT so callers can target a specific position + * (pass its `positionAddress` to remove-liquidity / add-liquidity). */ export async function getPositionInfo( network: string, @@ -32,10 +39,13 @@ export async function getPositionInfo( const positions = await meteoraDamm.getUserPositions(poolAddress, walletAddress); + const toUi = (raw: BN, decimals: number) => new Decimal(raw.toString()).div(new Decimal(10).pow(decimals)).toNumber(); + let totalLiquidity = new BN(0); let baseRaw = new BN(0); let quoteRaw = new BN(0); - for (const { positionState } of positions) { + const breakdown: PositionDetail[] = []; + for (const { position, positionState } of positions) { const liquidity = positionState.unlockedLiquidity; if (liquidity.isZero()) continue; totalLiquidity = totalLiquidity.add(liquidity); @@ -51,10 +61,14 @@ export async function getPositionInfo( }); baseRaw = baseRaw.add(wq.outAmountA); quoteRaw = quoteRaw.add(wq.outAmountB); + breakdown.push({ + positionAddress: position.toBase58(), + lpTokenAmount: Number(q64ToDecimal(liquidity).toString()), + baseTokenAmount: toUi(wq.outAmountA, tokenADecimal), + quoteTokenAmount: toUi(wq.outAmountB, tokenBDecimal), + }); } - const toUi = (raw: BN, decimals: number) => new Decimal(raw.toString()).div(new Decimal(10).pow(decimals)).toNumber(); - return { poolAddress, walletAddress, @@ -65,6 +79,7 @@ export async function getPositionInfo( baseTokenAmount: toUi(baseRaw, tokenADecimal), quoteTokenAmount: toUi(quoteRaw, tokenBDecimal), price, + positions: breakdown, }; } diff --git a/src/connectors/meteora/amm-routes/positionsOwned.ts b/src/connectors/meteora/amm-routes/positionsOwned.ts new file mode 100644 index 0000000000..4be02a47ed --- /dev/null +++ b/src/connectors/meteora/amm-routes/positionsOwned.ts @@ -0,0 +1,74 @@ +import { Type } from '@sinclair/typebox'; +import { PublicKey } from '@solana/web3.js'; +import { FastifyPluginAsync, FastifyInstance } from 'fastify'; + +import { PositionInfo, PositionInfoSchema } from '../../../schemas/amm-schema'; +import { logger } from '../../../services/logger'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraAmmGetPositionsOwnedRequest, MeteoraAmmGetPositionsOwnedRequestType } from '../schemas'; + +import { getPositionInfo } from './positionInfo'; + +/** + * Lists all of a wallet's DAMM v2 positions, grouped by pool. DAMM v2 positions are NFTs, so a + * wallet may hold several per pool; each returned entry is one pool's aggregate PositionInfo with a + * per-position `positions[]` breakdown (pass a breakdown entry's positionAddress to remove/add). + */ +export async function getPositionsOwned( + fastify: FastifyInstance, + network: string, + walletAddress: string, +): Promise { + let owner: PublicKey; + try { + owner = new PublicKey(walletAddress); + } catch { + throw fastify.httpErrors.badRequest(`Invalid wallet address: ${walletAddress}`); + } + + const meteoraDamm = await MeteoraDamm.getInstance(network); + const positions = await meteoraDamm.cpAmm.getPositionsByUser(owner); + + // Collect the distinct pools the wallet has positions in; build one PositionInfo per pool. + const poolAddresses = Array.from(new Set(positions.map((p) => p.positionState.pool.toBase58()))); + logger.info( + `Found ${positions.length} DAMM v2 position(s) across ${poolAddresses.length} pool(s) for wallet ${walletAddress.slice(0, 8)}...`, + ); + + const result: PositionInfo[] = []; + for (const poolAddress of poolAddresses) { + result.push(await getPositionInfo(network, poolAddress, walletAddress)); + } + return result; +} + +export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: MeteoraAmmGetPositionsOwnedRequestType; + Reply: PositionInfo[]; + }>( + '/positions-owned', + { + schema: { + description: "List all of a wallet's DAMM v2 positions across all Meteora AMM pools", + tags: ['/connector/meteora'], + querystring: MeteoraAmmGetPositionsOwnedRequest, + response: { + 200: Type.Array(PositionInfoSchema), + }, + }, + }, + async (request) => { + try { + const { network, walletAddress } = request.query; + return await getPositionsOwned(fastify, network, walletAddress); + } catch (e: any) { + logger.error(e); + if (e.statusCode) throw e; + throw fastify.httpErrors.internalServerError('Failed to fetch positions'); + } + }, + ); +}; + +export default positionsOwnedRoute; diff --git a/src/connectors/meteora/amm-routes/removeLiquidity.ts b/src/connectors/meteora/amm-routes/removeLiquidity.ts index 00dfa069a2..6a8f15839b 100644 --- a/src/connectors/meteora/amm-routes/removeLiquidity.ts +++ b/src/connectors/meteora/amm-routes/removeLiquidity.ts @@ -19,6 +19,7 @@ export async function removeLiquidity( network: string, walletAddress: string, poolAddress: string, + positionAddress: string, percentageToRemove: number, slippagePct: number = MeteoraConfig.config.slippagePct, ): Promise { @@ -32,12 +33,17 @@ export async function removeLiquidity( const poolState = await meteoraDamm.getPoolState(poolAddress); const { tokenAProgram, tokenBProgram } = meteoraDamm.getTokenPrograms(poolState); - // DAMM v2 positions are NFTs; operate on the wallet's largest position in this pool. + // DAMM v2 positions are NFTs; a wallet may hold several per pool. Operate on the specific + // position the caller named. getUserPositions is owner-filtered, so finding it here also proves + // the wallet owns it and that it belongs to this pool (list them with position-info). const positions = await meteoraDamm.getUserPositions(poolAddress, walletAddress); - if (positions.length === 0) { - throw httpErrors.notFound(`No DAMM v2 position found for wallet in pool ${poolAddress}`); + const target = positions.find((p) => p.position.toBase58() === positionAddress); + if (!target) { + throw httpErrors.notFound( + `Position ${positionAddress} not found for wallet in pool ${poolAddress}. ` + + 'List the wallet positions with position-info.', + ); } - const target = positions[0]; const unlocked = target.positionState.unlockedLiquidity; if (unlocked.isZero()) { throw httpErrors.badRequest('Position has no unlocked liquidity to remove'); @@ -121,7 +127,7 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { '/remove-liquidity', { schema: { - description: 'Remove liquidity from the wallet position in a Meteora DAMM v2 pool', + description: 'Remove liquidity from a specific position (NFT) in a Meteora DAMM v2 pool', tags: ['/connector/meteora'], body: MeteoraAmmRemoveLiquidityRequest, response: { @@ -131,11 +137,12 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { network, walletAddress, poolAddress, percentageToRemove } = request.body; + const { network, walletAddress, poolAddress, positionAddress, percentageToRemove } = request.body; return await removeLiquidity( network, walletAddress, poolAddress, + positionAddress, percentageToRemove, MeteoraConfig.config.slippagePct, ); diff --git a/src/connectors/meteora/schemas.ts b/src/connectors/meteora/schemas.ts index be32bdc92f..14d0d2ebb0 100644 --- a/src/connectors/meteora/schemas.ts +++ b/src/connectors/meteora/schemas.ts @@ -728,6 +728,13 @@ export const MeteoraAmmAddLiquidityRequest = Type.Object({ description: 'Amount of quote token to add', examples: [QUOTE_TOKEN_AMOUNT], }), + positionAddress: Type.Optional( + Type.String({ + 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.', + }), + ), slippagePct: Type.Optional( Type.Number({ minimum: 0, @@ -757,14 +764,35 @@ export const MeteoraAmmRemoveLiquidityRequest = Type.Object({ description: 'Meteora DAMM v2 pool address', examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], }), + positionAddress: Type.String({ + 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.', + }), percentageToRemove: Type.Number({ minimum: 0, maximum: 100, - description: 'Percentage of the wallet position liquidity to remove', + description: 'Percentage of this position’s liquidity to remove', examples: [100], }), }); +export const MeteoraAmmGetPositionsOwnedRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...MeteoraConfig.networks], + }), + ), + walletAddress: Type.String({ + description: 'Solana wallet address to list DAMM v2 positions for', + examples: [solanaChainConfig.defaultWallet], + }), +}); +export type MeteoraAmmGetPositionsOwnedRequestType = Static; + export const MeteoraAmmCreatePoolRequest = Type.Object({ network: Type.Optional( Type.String({ diff --git a/src/schemas/amm-schema.ts b/src/schemas/amm-schema.ts index 53107684f7..a9b678ff90 100644 --- a/src/schemas/amm-schema.ts +++ b/src/schemas/amm-schema.ts @@ -152,6 +152,20 @@ export const CreatePoolResponse = Type.Object( ); export type CreatePoolResponseType = Static; +// Per-position breakdown entry. Non-fungible-LP AMMs (e.g. Meteora DAMM v2) let a wallet hold +// several NFT positions in one pool; each is individually addressable. Fungible-LP AMMs (Raydium +// CPMM, Uniswap V2) have a single position per wallet and omit this array. +export const PositionDetailSchema = Type.Object( + { + positionAddress: Type.String({ description: 'Address of the individual position (NFT position account)' }), + lpTokenAmount: Type.Number({ description: 'Liquidity held by this position (LP units)' }), + baseTokenAmount: Type.Number(), + quoteTokenAmount: Type.Number(), + }, + { $id: 'PositionDetail' }, +); +export type PositionDetail = Static; + export const PositionInfoSchema = Type.Object( { poolAddress: Type.String(), @@ -162,6 +176,10 @@ export const PositionInfoSchema = Type.Object( baseTokenAmount: Type.Number(), quoteTokenAmount: Type.Number(), price: Type.Number(), + // Per-position breakdown for non-fungible-LP AMMs. When a wallet holds multiple positions in a + // pool, the top-level amounts are the aggregate and each entry here is individually addressable + // (pass its positionAddress to remove-liquidity / add-liquidity). Omitted for fungible-LP AMMs. + positions: Type.Optional(Type.Array(PositionDetailSchema)), }, { $id: 'PositionInfo' }, ); diff --git a/src/trading/trading-amm-routes/add-liquidity.ts b/src/trading/trading-amm-routes/add-liquidity.ts index 51bca2d977..6b02ee97d6 100644 --- a/src/trading/trading-amm-routes/add-liquidity.ts +++ b/src/trading/trading-amm-routes/add-liquidity.ts @@ -21,6 +21,13 @@ const UnifiedAmmAddLiquidityRequest = Type.Object({ poolAddress: Type.String({ description: 'Pool contract address' }), baseTokenAmount: Type.Number({ description: 'Amount of base token to add' }), quoteTokenAmount: Type.Number({ description: 'Amount of quote token to add' }), + positionAddress: Type.Optional( + Type.String({ + description: + 'meteora only (DAMM v2 positions are NFTs): add to this specific position. Omit to open a new ' + + 'position. Ignored by fungible-LP AMMs.', + }), + ), slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), }); @@ -40,8 +47,16 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { connector, chainNetwork, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = - request.body; + const { + connector, + chainNetwork, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + positionAddress, + slippagePct, + } = request.body; const { network } = parseChainNetwork(chainNetwork); switch (connector) { case 'meteora': @@ -52,6 +67,7 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, slippagePct, + positionAddress, ); case 'raydium': return await raydiumAddLiquidity( diff --git a/src/trading/trading-amm-routes/index.ts b/src/trading/trading-amm-routes/index.ts index 486ebdf9e4..9b621f1548 100644 --- a/src/trading/trading-amm-routes/index.ts +++ b/src/trading/trading-amm-routes/index.ts @@ -1,6 +1,7 @@ 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'; diff --git a/src/trading/trading-amm-routes/positions-owned.ts b/src/trading/trading-amm-routes/positions-owned.ts new file mode 100644 index 0000000000..43aec08d84 --- /dev/null +++ b/src/trading/trading-amm-routes/positions-owned.ts @@ -0,0 +1,65 @@ +import { Type, Static } from '@sinclair/typebox'; +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'; + +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', + }), + walletAddress: Type.String({ description: 'Wallet address to list positions for', default: defaultWallet }), +}); + +export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: Static; + Reply: PositionInfo[]; + }>( + '/positions-owned', + { + schema: { + 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.', + tags: ['/trading/amm'], + querystring: UnifiedAmmPositionsOwnedRequest, + response: { 200: Type.Array(PositionInfoSchema) }, + }, + }, + async (request) => { + try { + const { connector, chainNetwork, walletAddress } = request.query; + const { network } = parseChainNetwork(chainNetwork); + switch (connector) { + case 'meteora': + return await meteoraGetPositionsOwned(fastify, network, walletAddress); + case 'raydium': + case 'uniswap': + case 'pancakeswap': + throw httpErrors.badRequest( + `positions-owned is not supported for ${connector}: fungible-LP AMMs have no enumerable ` + + 'positions. Use position-info with a specific pool address instead.', + ); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + } 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'); + } + }, + ); +}; + +export default positionsOwnedRoute; diff --git a/src/trading/trading-amm-routes/remove-liquidity.ts b/src/trading/trading-amm-routes/remove-liquidity.ts index 8005511b95..f1c9aff3d5 100644 --- a/src/trading/trading-amm-routes/remove-liquidity.ts +++ b/src/trading/trading-amm-routes/remove-liquidity.ts @@ -19,6 +19,13 @@ const UnifiedAmmRemoveLiquidityRequest = Type.Object({ }), walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), poolAddress: Type.String({ description: 'Pool contract address' }), + positionAddress: Type.Optional( + Type.String({ + 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.', + }), + ), percentageToRemove: Type.Number({ minimum: 0, maximum: 100, description: 'Percentage of liquidity to remove' }), slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), }); @@ -39,11 +46,32 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { connector, chainNetwork, walletAddress, poolAddress, percentageToRemove, slippagePct } = request.body; + const { + connector, + chainNetwork, + walletAddress, + poolAddress, + positionAddress, + percentageToRemove, + slippagePct, + } = request.body; const { network } = parseChainNetwork(chainNetwork); switch (connector) { case 'meteora': - return await meteoraRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); + if (!positionAddress) { + throw httpErrors.badRequest( + 'positionAddress is required for meteora: DAMM v2 positions are NFTs and a wallet may hold ' + + 'several per pool. List them with position-info or positions-owned.', + ); + } + return await meteoraRemoveLiquidity( + network, + walletAddress, + poolAddress, + positionAddress, + percentageToRemove, + slippagePct, + ); case 'raydium': return await raydiumRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); case 'uniswap': diff --git a/src/trading/trading.routes.ts b/src/trading/trading.routes.ts index 3e5877014e..b892c6a5f3 100644 --- a/src/trading/trading.routes.ts +++ b/src/trading/trading.routes.ts @@ -11,6 +11,7 @@ import { createPoolRoute, poolInfoRoute as ammPoolInfoRoute, positionInfoRoute as ammPositionInfoRoute, + positionsOwnedRoute as ammPositionsOwnedRoute, quoteSwapRoute as ammQuoteSwapRoute, executeSwapRoute as ammExecuteSwapRoute, quoteLiquidityRoute as ammQuoteLiquidityRoute, @@ -58,6 +59,7 @@ export const tradingAmmRoutes: FastifyPluginAsync = async (fastify) => { // Register AMM query routes (unified cross-connector) fastify.register(ammPoolInfoRoute); fastify.register(ammPositionInfoRoute); + fastify.register(ammPositionsOwnedRoute); fastify.register(ammQuoteSwapRoute); fastify.register(ammQuoteLiquidityRoute); diff --git a/test/trading/trading-amm-routes/positions-owned.test.ts b/test/trading/trading-amm-routes/positions-owned.test.ts new file mode 100644 index 0000000000..73504851cb --- /dev/null +++ b/test/trading/trading-amm-routes/positions-owned.test.ts @@ -0,0 +1,46 @@ +import { fastifyWithTypeProvider } from '../../utils/testUtils'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { positionsOwnedRoute } = await import('../../../src/trading/trading-amm-routes/positions-owned'); + await server.register(positionsOwnedRoute); + return server; +}; + +const WALLET = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; + +describe('GET /trading/amm/positions-owned (unified dispatch)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + it.each(['raydium', 'uniswap', 'pancakeswap'])( + 'rejects %s: fungible-LP AMMs have no enumerable positions', + async (connector) => { + const response = await server.inject({ + method: 'GET', + url: `/positions-owned?connector=${connector}&chainNetwork=solana-mainnet-beta&walletAddress=${WALLET}`, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/not supported for .*fungible-LP/); + }, + ); + + it('rejects an unsupported AMM connector', async () => { + const response = await server.inject({ + method: 'GET', + url: `/positions-owned?connector=notaconnector&chainNetwork=solana-mainnet-beta&walletAddress=${WALLET}`, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/Unsupported AMM connector/); + }); +}); diff --git a/test/trading/trading-amm-routes/remove-liquidity.test.ts b/test/trading/trading-amm-routes/remove-liquidity.test.ts new file mode 100644 index 0000000000..26f6791b4f --- /dev/null +++ b/test/trading/trading-amm-routes/remove-liquidity.test.ts @@ -0,0 +1,55 @@ +import { fastifyWithTypeProvider } from '../../utils/testUtils'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { removeLiquidityRoute } = await import('../../../src/trading/trading-amm-routes/remove-liquidity'); + await server.register(removeLiquidityRoute); + return server; +}; + +describe('POST /trading/amm/remove-liquidity (unified dispatch)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + it('requires positionAddress for meteora (DAMM v2 positions are NFTs)', async () => { + const response = await server.inject({ + method: 'POST', + url: '/remove-liquidity', + payload: { + connector: 'meteora', + chainNetwork: 'solana-mainnet-beta', + walletAddress: '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5', + poolAddress: 'FAKEpoolAddress1111111111111111111111111111', + percentageToRemove: 100, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/positionAddress is required for meteora/); + }); + + it('rejects an unsupported AMM connector', async () => { + const response = await server.inject({ + method: 'POST', + url: '/remove-liquidity', + payload: { + connector: 'notaconnector', + chainNetwork: 'solana-mainnet-beta', + walletAddress: '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5', + poolAddress: 'FAKEpoolAddress1111111111111111111111111111', + percentageToRemove: 100, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/Unsupported AMM connector/); + }); +}); From 51046e586d1249d79e9fc94de475dfbef1593c6c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 5 Aug 2026 20:29:39 -0700 Subject: [PATCH 10/10] fix(meteora): probe market price with 1% of seed to avoid baking price impact into pool seed price Quoting the full seed amount returned the average execution price of that trade (impact + routing fees included), opening the pool below market for large seeds. Probe with a small fraction instead to approximate the marginal price. Co-Authored-By: Claude Fable 5 --- docs/connectors/meteora-damm-v2.md | 5 ++++- src/connectors/meteora/amm-routes/createPool.ts | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/connectors/meteora-damm-v2.md b/docs/connectors/meteora-damm-v2.md index cbc82961c8..fe9f3801d1 100644 --- a/docs/connectors/meteora-damm-v2.md +++ b/docs/connectors/meteora-damm-v2.md @@ -92,7 +92,10 @@ resolves the seed price in this priority order: 2. **`quoteTokenAmount`** if provided — the `baseTokenAmount : quoteTokenAmount` ratio sets the price. 3. **Otherwise, the current market price is fetched** from the unified swap router (`/trading/swap/quote`, i.e. the network's configured `swapProvider` — Jupiter on Solana, which - aggregates existing venues) via a SELL quote of the base token, and the pool is seeded there. + aggregates existing venues) via a SELL quote of a small probe (1% of `baseTokenAmount`), and the + pool is seeded there. The probe is kept small so the quote approximates the marginal market + price; quoting the full seed amount would bake its own price impact into the seed price and + open the pool below market. Only `baseTokenAmount` is required; the quote side is derived. If the base token has **no existing market** (nothing for the router to price against), the fetch fails with a clear error asking you to diff --git a/src/connectors/meteora/amm-routes/createPool.ts b/src/connectors/meteora/amm-routes/createPool.ts index 24f6d03240..abe36ca9bf 100644 --- a/src/connectors/meteora/amm-routes/createPool.ts +++ b/src/connectors/meteora/amm-routes/createPool.ts @@ -33,23 +33,32 @@ async function getMintProgram(solana: Solana, mint: PublicKey): Promise { + const probeAmount = seedAmount * MARKET_PRICE_PROBE_FRACTION; const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); let quote: any; try { - quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, amount, 'SELL'); + quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, probeAmount, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` +