diff --git a/docs/connectors/meteora-damm-v2.md b/docs/connectors/meteora-damm-v2.md new file mode 100644 index 0000000000..fe9f3801d1 --- /dev/null +++ b/docs/connectors/meteora-damm-v2.md @@ -0,0 +1,147 @@ +# 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 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 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 +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. + +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, +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`**. 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. + +#### 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 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 +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` +→ 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..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' }); @@ -324,6 +328,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..4decdb7f4d --- /dev/null +++ b/src/connectors/meteora/amm-routes/addLiquidity.ts @@ -0,0 +1,151 @@ +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 = MeteoraConfig.config.slippagePct, + positionAddress?: string, +): 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); + + 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, + }; + + // 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, + 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. Provide positionAddress to add to a specific ' + + 'position (NFT); omit it to open a new position.', + tags: ['/connector/meteora'], + body: MeteoraAmmAddLiquidityRequest, + response: { + 200: AddLiquidityResponse, + }, + }, + }, + async (request) => { + try { + const { network, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct, positionAddress } = + request.body; + const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; + return await addLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + effectiveSlippage, + positionAddress, + ); + } 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..abe36ca9bf --- /dev/null +++ b/src/connectors/meteora/amm-routes/createPool.ts @@ -0,0 +1,271 @@ +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`); +} + +/** + * Fraction of the seed amount used to probe the market price. Quoting the full seed amount would + * return the average execution price of that trade (including price impact and routing fees), + * which for large seeds sits below the marginal market price and would open the pool off-market. + * A small probe keeps the quote close to the marginal price. + */ +const MARKET_PRICE_PROBE_FRACTION = 0.01; + +/** + * 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 (see docs/connectors/meteora-damm-v2.md). Uses a SELL quote + * for a small probe fraction of the seed amount 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, + seedAmount: number, +): 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, probeAmount, '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, + configAddress?: string, + initialPrice?: number, +): 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), + ]); + + 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(effectiveQuoteAmount).mul(new Decimal(10).pow(tokenBDecimal)).toFixed(0)); + if (tokenAAmount.isZero() || tokenBAmount.isZero()) { + throw httpErrors.badRequest('Computed token amounts are zero — increase baseTokenAmount'); + } + + // 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(), + price: seedPrice, + data: { + fee: txData.meta.fee / 1e9, + baseTokenAmountAdded: Math.abs(balanceChanges[0]), + quoteTokenAmountAdded: Math.abs(balanceChanges[1]), + }, + }; + } + return { signature, status: 0, poolAddress: pool.toBase58(), price: seedPrice }; // 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, + initialPrice, + } = request.body; + return await createPool( + network, + walletAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + configAddress, + initialPrice, + ); + } 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..8e12fe4031 --- /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 = MeteoraConfig.config.slippagePct, +): 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..9b93ee334d --- /dev/null +++ b/src/connectors/meteora/amm-routes/index.ts @@ -0,0 +1,25 @@ +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 { positionsOwnedRoute } from './positionsOwned'; +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(positionsOwnedRoute); + 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..b03e3b2885 --- /dev/null +++ b/src/connectors/meteora/amm-routes/poolInfo.ts @@ -0,0 +1,43 @@ +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'; + +/** 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; + 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; + return await getPoolInfo(network, 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..a7a7fae47d --- /dev/null +++ b/src/connectors/meteora/amm-routes/positionInfo.ts @@ -0,0 +1,117 @@ +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, + PositionDetail, +} 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; 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, + 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); + + 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); + const breakdown: PositionDetail[] = []; + for (const { position, 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); + breakdown.push({ + positionAddress: position.toBase58(), + lpTokenAmount: Number(q64ToDecimal(liquidity).toString()), + baseTokenAmount: toUi(wq.outAmountA, tokenADecimal), + quoteTokenAmount: toUi(wq.outAmountB, tokenBDecimal), + }); + } + + 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, + positions: breakdown, + }; +} + +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; + 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'); + } + }, + ); +}; + +export default positionInfoRoute; 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/quoteLiquidity.ts b/src/connectors/meteora/amm-routes/quoteLiquidity.ts new file mode 100644 index 0000000000..78544d13e0 --- /dev/null +++ b/src/connectors/meteora/amm-routes/quoteLiquidity.ts @@ -0,0 +1,161 @@ +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, + }; +} + +/** + * 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; + 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; + return await quoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); + } 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..02def03bb2 --- /dev/null +++ b/src/connectors/meteora/amm-routes/quoteSwap.ts @@ -0,0 +1,215 @@ +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; +} + +/** + * 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; + 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; + return await quoteSwap(network, poolAddress, baseToken, side as 'BUY' | 'SELL', amount, slippagePct); + } 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..6a8f15839b --- /dev/null +++ b/src/connectors/meteora/amm-routes/removeLiquidity.ts @@ -0,0 +1,158 @@ +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, + positionAddress: string, + percentageToRemove: number, + slippagePct: number = MeteoraConfig.config.slippagePct, +): 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; 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); + 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 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 a specific position (NFT) in a Meteora DAMM v2 pool', + tags: ['/connector/meteora'], + body: MeteoraAmmRemoveLiquidityRequest, + response: { + 200: RemoveLiquidityResponse, + }, + }, + }, + async (request) => { + try { + const { network, walletAddress, poolAddress, positionAddress, percentageToRemove } = request.body; + return await removeLiquidity( + network, + walletAddress, + poolAddress, + positionAddress, + 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/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/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/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/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/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..14d0d2ebb0 100644 --- a/src/connectors/meteora/schemas.ts +++ b/src/connectors/meteora/schemas.ts @@ -500,3 +500,345 @@ 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 +// ======================================== + +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], + }), + 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, + 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], + }), + 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 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({ + 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.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. Required — many permissionless ' + + 'configs are launch configs with very high starting fees, so Gateway does not auto-select one.', + }), + ), +}); 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/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/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/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/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/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/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/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-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/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/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/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/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/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/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/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/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/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/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/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/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/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/raydium/schemas.ts b/src/connectors/raydium/schemas.ts index 3466d2432a..60745782b2 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 // ======================================== @@ -446,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/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 new file mode 100644 index 0000000000..945078d9b6 --- /dev/null +++ b/src/connectors/uniswap/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 { 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 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 = UniswapConfig.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 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 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 = 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/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/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/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/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/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/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/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/connectors/uniswap/schemas.ts b/src/connectors/uniswap/schemas.ts index 3b8287db06..e17603a95e 100644 --- a/src/connectors/uniswap/schemas.ts +++ b/src/connectors/uniswap/schemas.ts @@ -210,6 +210,115 @@ 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 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/schemas/amm-schema.ts b/src/schemas/amm-schema.ts index a27cfca4e3..a9b678ff90 100644 --- a/src/schemas/amm-schema.ts +++ b/src/schemas/amm-schema.ts @@ -102,6 +102,70 @@ 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.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' }, +); +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' }), + price: Type.Optional(Type.Number({ description: 'Initial price the pool was seeded at (quote per base)' })), + + // 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; + +// 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(), @@ -112,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/swap/execute.ts b/src/trading/swap/execute.ts index c3ca61c938..ea46dbe360 100644 --- a/src/trading/swap/execute.ts +++ b/src/trading/swap/execute.ts @@ -173,60 +173,23 @@ 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, - 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, 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}`); @@ -285,9 +248,9 @@ 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); + return await uniswapClmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'pancakeswap/router') { return await pancakeswapRouterExecuteSwap( walletAddress, @@ -299,9 +262,25 @@ 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(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 df15993b2a..db4953c5a5 100644 --- a/src/trading/swap/quote.ts +++ b/src/trading/swap/quote.ts @@ -157,15 +157,15 @@ 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); + 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); + 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}`); @@ -223,15 +223,15 @@ 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); + 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') { - 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, 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/src/trading/trading-amm-routes/add-liquidity.ts b/src/trading/trading-amm-routes/add-liquidity.ts new file mode 100644 index 0000000000..6b02ee97d6 --- /dev/null +++ b/src/trading/trading-amm-routes/add-liquidity.ts @@ -0,0 +1,113 @@ +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'; +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' }), + 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 })), +}); + +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, + positionAddress, + slippagePct, + } = request.body; + const { network } = parseChainNetwork(chainNetwork); + switch (connector) { + case 'meteora': + return await meteoraAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + positionAddress, + ); + case 'raydium': + return await raydiumAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + case 'uniswap': + return await uniswapAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + 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(', ')}`, + ); + } + } 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..a0e702f621 --- /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', 'pancakeswap']; + +/** Parse a chain-network string (e.g. "solana-mainnet-beta") into its chain and network parts. */ +export function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { + const parts = chainNetwork.split('-'); + if (parts.length < 2) { + throw new Error( + `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, + ); + } + return { chain: parts[0], network: parts.slice(1).join('-') }; +} + +// Default wallet from Solana config, falling back to Ethereum when Solana is unavailable. +let dw: string; +try { + dw = getSolanaChainConfig().defaultWallet; +} catch { + dw = getEthereumChainConfig().defaultWallet; +} +export const defaultWallet = dw; diff --git a/src/trading/trading-amm-routes/create-pool.ts b/src/trading/trading-amm-routes/create-pool.ts new file mode 100644 index 0000000000..a72e8c4b24 --- /dev/null +++ b/src/trading/trading-amm-routes/create-pool.ts @@ -0,0 +1,191 @@ +import { Static, Type } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; +import { getSolanaChainConfig } from '../../chains/solana/solana.config'; +import { createPool as meteoraCreatePool } from '../../connectors/meteora/amm-routes/createPool'; +import { createPool as pancakeswapCreatePool } from '../../connectors/pancakeswap/amm-routes/createPool'; +import { createPool as raydiumCreatePool } from '../../connectors/raydium/amm-routes/createPool'; +import { createPool as uniswapCreatePool } from '../../connectors/uniswap/amm-routes/createPool'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../schemas/amm-schema'; +import { 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, + ); + + case 'pancakeswap': + return await pancakeswapCreatePool( + 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/execute-swap.ts b/src/trading/trading-amm-routes/execute-swap.ts new file mode 100644 index 0000000000..bbdceb1912 --- /dev/null +++ b/src/trading/trading-amm-routes/execute-swap.ts @@ -0,0 +1,71 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { executeSwap as meteoraExecuteSwap } from '../../connectors/meteora/amm-routes/executeSwap'; +import { executeSwap as pancakeswapExecuteSwap } from '../../connectors/pancakeswap/amm-routes/executeSwap'; +import { executeSwap as raydiumExecuteSwap } from '../../connectors/raydium/amm-routes/executeSwap'; +import { executeSwap as uniswapExecuteSwap } from '../../connectors/uniswap/amm-routes/executeSwap'; +import { ExecuteSwapResponse, ExecuteSwapResponseType } from '../../schemas/amm-schema'; +import { httpErrors } from '../../services/error-handler'; +import { logger } from '../../services/logger'; + +import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; + +const UnifiedAmmExecuteSwapRequest = Type.Object({ + connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), + chainNetwork: Type.String({ + description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', + default: 'solana-mainnet-beta', + }), + walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), + poolAddress: Type.String({ description: 'Pool contract address' }), + baseToken: Type.String({ description: 'Base token symbol or address (determines swap direction)' }), + amount: Type.Number({ description: 'Amount denominated in the base token' }), + side: Type.String({ description: 'Trade direction', enum: ['BUY', 'SELL'] }), + slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), +}); + +export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: Static; + Reply: ExecuteSwapResponseType; + }>( + '/execute-swap', + { + schema: { + description: 'Execute a swap against a specific AMM pool from any supported connector', + tags: ['/trading/amm'], + body: UnifiedAmmExecuteSwapRequest, + response: { 200: ExecuteSwapResponse }, + }, + }, + async (request) => { + try { + const { connector, chainNetwork, walletAddress, poolAddress, baseToken, amount, side, slippagePct } = + request.body; + const { network } = parseChainNetwork(chainNetwork); + const s = side as 'BUY' | 'SELL'; + switch (connector) { + case 'meteora': + return await meteoraExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); + case 'raydium': + return await raydiumExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); + case 'uniswap': + return await uniswapExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); + case 'pancakeswap': + return await pancakeswapExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + } catch (e: any) { + logger.error('Failed to execute AMM swap:', e); + if (e.statusCode) throw e; + throw httpErrors.internalServerError('Failed to execute swap'); + } + }, + ); +}; + +export default executeSwapRoute; diff --git a/src/trading/trading-amm-routes/index.ts b/src/trading/trading-amm-routes/index.ts new file mode 100644 index 0000000000..9b621f1548 --- /dev/null +++ b/src/trading/trading-amm-routes/index.ts @@ -0,0 +1,9 @@ +export { createPoolRoute } from './create-pool'; +export { poolInfoRoute } from './pool-info'; +export { positionInfoRoute } from './position-info'; +export { positionsOwnedRoute } from './positions-owned'; +export { quoteSwapRoute } from './quote-swap'; +export { executeSwapRoute } from './execute-swap'; +export { quoteLiquidityRoute } from './quote-liquidity'; +export { addLiquidityRoute } from './add-liquidity'; +export { removeLiquidityRoute } from './remove-liquidity'; diff --git a/src/trading/trading-amm-routes/pool-info.ts b/src/trading/trading-amm-routes/pool-info.ts new file mode 100644 index 0000000000..2f553eb89b --- /dev/null +++ b/src/trading/trading-amm-routes/pool-info.ts @@ -0,0 +1,64 @@ +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'; +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); + case 'pancakeswap': + return await pancakeswapGetPoolInfo(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..8cde9b4c73 --- /dev/null +++ b/src/trading/trading-amm-routes/position-info.ts @@ -0,0 +1,65 @@ +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'; +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); + case 'pancakeswap': + return await pancakeswapGetPositionInfo(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/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/quote-liquidity.ts b/src/trading/trading-amm-routes/quote-liquidity.ts new file mode 100644 index 0000000000..49d073ad28 --- /dev/null +++ b/src/trading/trading-amm-routes/quote-liquidity.ts @@ -0,0 +1,73 @@ +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'; +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); + case 'pancakeswap': + return await pancakeswapQuoteLiquidity( + 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..72e467a6aa --- /dev/null +++ b/src/trading/trading-amm-routes/quote-swap.ts @@ -0,0 +1,69 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { quoteSwap as meteoraQuoteSwap } from '../../connectors/meteora/amm-routes/quoteSwap'; +import { quoteSwap as pancakeswapQuoteSwap } from '../../connectors/pancakeswap/amm-routes/quoteSwap'; +import { quoteSwap as raydiumQuoteSwap } from '../../connectors/raydium/amm-routes/quoteSwap'; +import { quoteSwap as uniswapQuoteSwap } from '../../connectors/uniswap/amm-routes/quoteSwap'; +import { QuoteSwapResponse, QuoteSwapResponseType } from '../../schemas/amm-schema'; +import { httpErrors } from '../../services/error-handler'; +import { logger } from '../../services/logger'; + +import { AMM_CONNECTORS, parseChainNetwork } from './common'; + +const UnifiedAmmQuoteSwapRequest = Type.Object({ + connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), + chainNetwork: Type.String({ + description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', + default: 'solana-mainnet-beta', + }), + poolAddress: Type.String({ description: 'Pool contract address' }), + baseToken: Type.String({ description: 'Base token symbol or address (determines swap direction)' }), + amount: Type.Number({ description: 'Amount denominated in the base token' }), + side: Type.String({ description: 'Trade direction', enum: ['BUY', 'SELL'] }), + slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), +}); + +export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: Static; + Reply: QuoteSwapResponseType; + }>( + '/quote-swap', + { + schema: { + description: 'Get a swap quote against a specific AMM pool from any supported connector', + tags: ['/trading/amm'], + querystring: UnifiedAmmQuoteSwapRequest, + response: { 200: QuoteSwapResponse }, + }, + }, + async (request) => { + try { + const { connector, chainNetwork, poolAddress, baseToken, amount, side, slippagePct } = request.query; + const { network } = parseChainNetwork(chainNetwork); + const s = side as 'BUY' | 'SELL'; + switch (connector) { + case 'meteora': + return await meteoraQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); + case 'raydium': + return await raydiumQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); + case 'uniswap': + return await uniswapQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); + case 'pancakeswap': + return await pancakeswapQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + } catch (e: any) { + logger.error('Failed to get AMM swap quote:', e); + if (e.statusCode) throw e; + throw httpErrors.internalServerError('Failed to get swap quote'); + } + }, + ); +}; + +export default quoteSwapRoute; diff --git a/src/trading/trading-amm-routes/remove-liquidity.ts b/src/trading/trading-amm-routes/remove-liquidity.ts new file mode 100644 index 0000000000..f1c9aff3d5 --- /dev/null +++ b/src/trading/trading-amm-routes/remove-liquidity.ts @@ -0,0 +1,101 @@ +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'; +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' }), + 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 })), +}); + +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, + positionAddress, + percentageToRemove, + slippagePct, + } = request.body; + const { network } = parseChainNetwork(chainNetwork); + switch (connector) { + case 'meteora': + 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': + 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(', ')}`, + ); + } + } 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-clmm-routes/create-pool.ts b/src/trading/trading-clmm-routes/create-pool.ts new file mode 100644 index 0000000000..9aef970949 --- /dev/null +++ b/src/trading/trading-clmm-routes/create-pool.ts @@ -0,0 +1,167 @@ +import { Static, Type } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; +import { getSolanaChainConfig } from '../../chains/solana/solana.config'; +import { createPool as meteoraCreatePool } from '../../connectors/meteora/clmm-routes/createPool'; +import { createPool as orcaCreatePool } from '../../connectors/orca/clmm-routes/createPool'; +import { createPool as pancakeswapCreatePool } from '../../connectors/pancakeswap/clmm-routes/createPool'; +import { createPool as pancakeswapSolCreatePool } from '../../connectors/pancakeswap-sol/clmm-routes/createPool'; +import { createPool as raydiumCreatePool } from '../../connectors/raydium/clmm-routes/createPool'; +import { createPool as uniswapCreatePool } from '../../connectors/uniswap/clmm-routes/createPool'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../schemas/amm-schema'; +import { 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, orca, pancakeswap, pancakeswap-sol)', + default: 'meteora', + examples: ['meteora'], + }), + chainNetwork: Type.String({ + description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', + default: 'solana-mainnet-beta', + examples: ['solana-mainnet-beta'], + }), + walletAddress: Type.String({ description: 'Wallet address (pool creator + payer)', default: defaultWallet }), + baseToken: Type.String({ description: 'Base token symbol or address' }), + quoteToken: Type.String({ description: 'Quote token symbol or address' }), + initialPrice: Type.Optional( + Type.Number({ + description: + 'Initial pool price as quote per base. If omitted, the current market price is fetched from the ' + + 'unified swap router so the pool opens on-market.', + }), + ), + // Connector-specific extras (optional; ignored by connectors that do not use them): + binStep: Type.Optional(Type.Number({ description: 'Meteora DLMM bin step (bps)' })), + feeBps: Type.Optional(Type.Number({ description: 'Meteora DLMM base fee (bps)' })), + ammConfigIndex: Type.Optional(Type.Number({ description: 'Raydium CLMM AMM config index (fee tier)' })), + fee: Type.Optional( + Type.Number({ + description: 'V3 fee tier — Uniswap (100 | 500 | 3000 | 10000) or PancakeSwap (100 | 500 | 2500 | 10000)', + }), + ), + tickSpacing: Type.Optional(Type.Number({ description: 'Orca Whirlpool tick spacing (fee tier)' })), + ammConfig: Type.Optional(Type.String({ description: 'pancakeswap-sol CLMM amm_config account address (required)' })), + gasPrice: Type.Optional(Type.Number({ description: 'EVM gas price in gwei (uniswap/pancakeswap)' })), + maxGas: Type.Optional(Type.Number({ description: 'EVM max gas limit (uniswap/pancakeswap)' })), +}); + +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, + tickSpacing, + ammConfig, + 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, + ); + 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}`); + } + } 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 726401fb80..b892c6a5f3 100644 --- a/src/trading/trading.routes.ts +++ b/src/trading/trading.routes.ts @@ -7,12 +7,24 @@ import { positionsOwnedRoute } from './clmm/positions-owned'; import { quotePositionRoute } from './clmm/quote-position'; import { executeSwapRoute } from './swap/execute'; import { quoteSwapRoute } from './swap/quote'; +import { + createPoolRoute, + poolInfoRoute as ammPoolInfoRoute, + positionInfoRoute as ammPositionInfoRoute, + positionsOwnedRoute as ammPositionsOwnedRoute, + quoteSwapRoute as ammQuoteSwapRoute, + executeSwapRoute as ammExecuteSwapRoute, + quoteLiquidityRoute as ammQuoteLiquidityRoute, + addLiquidityRoute as ammAddLiquidityRoute, + removeLiquidityRoute as ammRemoveLiquidityRoute, +} from './trading-amm-routes'; import { openPositionRoute, addLiquidityRoute, removeLiquidityRoute, collectFeesRoute, closePositionRoute, + createPoolRoute as clmmCreatePoolRoute, } from './trading-clmm-routes'; export const tradingSwapRoutes: FastifyPluginAsync = async (fastify) => { @@ -38,6 +50,24 @@ export const tradingClmmRoutes: FastifyPluginAsync = async (fastify) => { fastify.register(removeLiquidityRoute); fastify.register(collectFeesRoute); fastify.register(closePositionRoute); + fastify.register(clmmCreatePoolRoute); +}; + +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(ammPositionsOwnedRoute); + fastify.register(ammQuoteSwapRoute); + fastify.register(ammQuoteLiquidityRoute); + + // Register AMM transaction routes (unified cross-connector) + fastify.register(ammExecuteSwapRoute); + fastify.register(ammAddLiquidityRoute); + fastify.register(ammRemoveLiquidityRoute); + fastify.register(createPoolRoute); }; // Legacy export for backward compatibility 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/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/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'); }); }); 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/'); }); }); }); 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/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/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); + }); +}); 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/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/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/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/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 () => { 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/); + }); +}); 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/); + }); +}); 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/); + }); +});