diff --git a/package.json b/package.json index ce0b63742f..4392bd5df5 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "fastify": "^4.29.0", "fastify-type-provider-zod": "^2.1.0", "fs-extra": "^10.1.0", + "goosefx-amm-sdk": "^2.0.0", "handle": "link:@oclif/errors/handle", "js-yaml": "^4.1.0", "level": "^8.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ecebb7cf2..60c7969013 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: fs-extra: specifier: ^10.1.0 version: 10.1.0 + goosefx-amm-sdk: + specifier: ^2.0.0 + version: 2.0.0(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) handle: specifier: link:@oclif/errors/handle version: link:@oclif/errors/handle @@ -696,6 +699,10 @@ packages: resolution: {integrity: sha512-YXDVxcWQeN3BA2fWLu/3O8gLyYjJO2KL+MwqmD52CJHNehjvjq/U4RzYe5IUg6UsIs/evcKP3h7pt659HSSwHQ==} hasBin: true + '@coral-xyz/anchor-errors@0.31.1': + resolution: {integrity: sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ==} + engines: {node: '>=10'} + '@coral-xyz/anchor@0.28.0': resolution: {integrity: sha512-kQ02Hv2ZqxtWP30WN1d4xxT4QqlOXYDxmEd3k/bbneqhV3X5QMO4LAtoUFs7otxyivOgoqam5Il5qx81FuI4vw==} engines: {node: '>=11'} @@ -704,6 +711,16 @@ packages: resolution: {integrity: sha512-eny6QNG0WOwqV0zQ7cs/b1tIuzZGmP7U7EcH+ogt4Gdbl8HDmIYVMh/9aTmYZPaFWjtUaI8qSn73uYEXWfATdA==} engines: {node: '>=11'} + '@coral-xyz/anchor@0.31.1': + resolution: {integrity: sha512-QUqpoEK+gi2S6nlYc2atgT2r41TT3caWr/cPUEL8n8Md9437trZ68STknq897b82p5mW0XrTBNOzRbmIRJtfsA==} + engines: {node: '>=17'} + + '@coral-xyz/borsh@0.26.0': + resolution: {integrity: sha512-uCZ0xus0CszQPHYfWAqKS5swS1UxvePu83oOF+TWpUkedsNlg6p2p4azxZNSSqwXb9uXMFgxhuMBX9r3Xoi0vQ==} + engines: {node: '>=10'} + peerDependencies: + '@solana/web3.js': ^1.68.0 + '@coral-xyz/borsh@0.28.0': resolution: {integrity: sha512-/u1VTzw7XooK7rqeD7JLUSwOyRSesPUk0U37BV9zK0axJc1q0nRbKFGFLYCQ16OtdOJTTwGfGp11Lx9B45bRCQ==} engines: {node: '>=10'} @@ -716,6 +733,12 @@ packages: peerDependencies: '@solana/web3.js': ^1.68.0 + '@coral-xyz/borsh@0.31.1': + resolution: {integrity: sha512-9N8AU9F0ubriKfNE3g1WF0/4dtlGXoBN/hd1PvbNBamBNwRgHxH4P+o3Zt7rSEloW1HUs6LfZEchlx9fW7POYw==} + engines: {node: '>=10'} + peerDependencies: + '@solana/web3.js': ^1.69.0 + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1320,6 +1343,10 @@ packages: resolution: {integrity: sha512-oIdm4vTJkUy6GmE6JgqDAuQPKI7XM4TPJkjtoIzp69RZe0iAD9JP2XHx7lV1jLdYXeYHqDXfBt3zcq7W91K6PA==} engines: {node: '>=11'} + '@project-serum/anchor@0.26.0': + resolution: {integrity: sha512-Nq+COIjE1135T7qfnOHEn7E0q39bQTgXLFk837/rgFe6Hkew9WML7eHsS+lSYD2p3OJaTiUOHTAq1lHy36oIqQ==} + engines: {node: '>=11'} + '@project-serum/borsh@0.2.5': resolution: {integrity: sha512-UmeUkUoKdQ7rhx6Leve1SssMR/Ghv8qrEiyywyxSWg7ooV7StdpPBhciiy5eB3T0qU1BXvdRNC8TdrkxK7WC5Q==} engines: {node: '>=10'} @@ -3886,6 +3913,9 @@ packages: google-protobuf@3.21.4: resolution: {integrity: sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==} + goosefx-amm-sdk@2.0.0: + resolution: {integrity: sha512-/LNSsJJYqd47wjRssd5mqq8oKt77zxl0qAccUIYHz6ufWpwZI8jJyHqL4Z9mjVod7TJn5z0iqGpjMNB2MjATVA==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -6340,6 +6370,10 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} @@ -7494,6 +7528,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@coral-xyz/anchor-errors@0.31.1': {} + '@coral-xyz/anchor@0.28.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)': dependencies: '@coral-xyz/borsh': 0.28.0(@solana/web3.js@1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) @@ -7539,6 +7575,33 @@ snapshots: - typescript - utf-8-validate + '@coral-xyz/anchor@0.31.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)': + dependencies: + '@coral-xyz/anchor-errors': 0.31.1 + '@coral-xyz/borsh': 0.31.1(@solana/web3.js@1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@noble/hashes': 1.8.0 + '@solana/web3.js': 1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) + bn.js: 5.2.1 + bs58: 4.0.1 + buffer-layout: 1.2.2 + camelcase: 6.3.0 + cross-fetch: 3.2.0(encoding@0.1.13) + eventemitter3: 4.0.7 + pako: 2.1.0 + superstruct: 0.15.5 + toml: 3.0.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + '@coral-xyz/borsh@0.26.0(@solana/web3.js@1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana/web3.js': 1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) + bn.js: 5.2.1 + buffer-layout: 1.2.2 + '@coral-xyz/borsh@0.28.0(@solana/web3.js@1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))': dependencies: '@solana/web3.js': 1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -7551,6 +7614,12 @@ snapshots: bn.js: 5.2.1 buffer-layout: 1.2.2 + '@coral-xyz/borsh@0.31.1(@solana/web3.js@1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana/web3.js': 1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) + bn.js: 5.2.1 + buffer-layout: 1.2.2 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -8799,6 +8868,29 @@ snapshots: - typescript - utf-8-validate + '@project-serum/anchor@0.26.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)': + dependencies: + '@coral-xyz/borsh': 0.26.0(@solana/web3.js@1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(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) + base64-js: 1.5.1 + bn.js: 5.2.1 + bs58: 4.0.1 + buffer-layout: 1.2.2 + camelcase: 6.3.0 + cross-fetch: 3.2.0(encoding@0.1.13) + crypto-hash: 1.3.0 + eventemitter3: 4.0.7 + js-sha256: 0.9.0 + pako: 2.1.0 + snake-case: 3.0.4 + superstruct: 0.15.5 + toml: 3.0.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + '@project-serum/borsh@0.2.5(@solana/web3.js@1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))': dependencies: '@solana/web3.js': 1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -12198,6 +12290,30 @@ snapshots: google-protobuf@3.21.4: {} + goosefx-amm-sdk@2.0.0(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.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@project-serum/anchor': 0.26.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) + axios: 1.9.0 + big.js: 6.2.2 + bn.js: 5.2.1 + buffer: 6.0.3 + dayjs: 1.11.13 + decimal.js-light: 2.5.1 + dotenv: 16.5.0 + lodash: 4.17.21 + toformat: 2.0.0 + tsconfig-paths: 4.2.0 + transitivePeerDependencies: + - bufferutil + - debug + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -14983,6 +15099,12 @@ snapshots: minimist: 0.2.4 strip-bom: 3.0.0 + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 0.2.4 + strip-bom: 3.0.0 + tslib@1.14.1: {} tslib@2.8.1: {} diff --git a/src/app.ts b/src/app.ts index 687c608164..5c90d1eb97 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,7 @@ import { ethereumRoutes } from './chains/ethereum/ethereum.routes'; import { solanaRoutes } from './chains/solana/solana.routes'; import { configRoutes } from './config/config.routes'; import { connectorsRoutes } from './connectors/connector.routes'; +import { gammaRoutes } from './connectors/gamma/gamma.routes'; import { jupiterRoutes } from './connectors/jupiter/jupiter.routes'; import { meteoraRoutes } from './connectors/meteora/meteora.routes'; import { raydiumRoutes } from './connectors/raydium/raydium.routes'; @@ -90,6 +91,9 @@ const swaggerOptions = { name: 'uniswap/clmm', description: 'Uniswap V3 pool connector (Ethereum)', }, + { name: 'gamma/amm', + description: 'Gamma AMM connector endpoints', + } ], components: { parameters: { @@ -206,6 +210,7 @@ const configureGatewayServer = () => { // Raydium routes app.register(raydiumRoutes.clmm, { prefix: '/connectors/raydium/clmm' }); app.register(raydiumRoutes.amm, { prefix: '/connectors/raydium/amm' }); + app.register(gammaRoutes.amm, { prefix: '/connectors/gamma/amm' }); app.register(uniswapRoutes, { prefix: '/connectors/uniswap' }); diff --git a/src/connectors/connector.routes.ts b/src/connectors/connector.routes.ts index b034e1994e..3e303a9e02 100644 --- a/src/connectors/connector.routes.ts +++ b/src/connectors/connector.routes.ts @@ -3,6 +3,7 @@ import { FastifyPluginAsync } from 'fastify'; import { logger } from '../services/logger'; +import { GammaConfig } from './gamma/gamma.config'; import { JupiterConfig } from './jupiter/jupiter.config'; import { MeteoraConfig } from './meteora/meteora.config'; import { RaydiumConfig } from './raydium/raydium.config'; @@ -46,6 +47,12 @@ export const connectorsRoutes: FastifyPluginAsync = async (fastify) => { logger.info('Getting available DEX connectors and networks'); const connectors = [ + { + name: 'gamma/amm', + trading_types: ['amm'], + chain: GammaConfig.chain, + networks: GammaConfig.networks + }, { name: 'jupiter', trading_types: ['swap'], diff --git a/src/connectors/gamma/amm-routes/addLiquidity.ts b/src/connectors/gamma/amm-routes/addLiquidity.ts new file mode 100644 index 0000000000..960566603c --- /dev/null +++ b/src/connectors/gamma/amm-routes/addLiquidity.ts @@ -0,0 +1,203 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify' +import { Gamma } from '../gamma' +import { Solana, BASE_FEE } from '../../../chains/solana/solana' +import { logger } from '../../../services/logger' +import { + AddLiquidityRequest, + AddLiquidityResponse, + AddLiquidityRequestType, + AddLiquidityResponseType, + QuoteLiquidityResponseType, +} from '../../../schemas/amm-schema' +import { Percent, PoolInfo, PoolKeys, TxVersion } from 'goosefx-amm-sdk' +import { quoteLiquidity } from './quoteLiquidity' +import Decimal from 'decimal.js' +import BN from 'bn.js' +import { VersionedTransaction, Transaction } from '@solana/web3.js' + +async function createAddLiquidityTransaction( + gamma: Gamma, + poolInfo: PoolInfo, + poolKeys: PoolKeys, + baseTokenAmountAdded: number, + quoteTokenAmountAdded: number, + baseLimited: boolean, + slippage: Percent, + computeBudgetConfig: { units: number; microLamports: number } +): Promise { + const inputAmount = new BN( + new Decimal(baseLimited ? baseTokenAmountAdded : quoteTokenAmountAdded) + .mul(10 ** (baseLimited ? poolInfo.mintA.decimals : poolInfo.mintB.decimals)) + .toFixed(0) + ); + const response = await gamma.client.cpmm.addLiquidity({ + poolInfo: poolInfo, + poolKeys: poolKeys, + inputAmount, + slippage, + baseSpecified: baseLimited, + txVersion: TxVersion.V0, + computeBudgetConfig, + }) + return response.transaction +} + +async function addLiquidity( + _fastify: FastifyInstance, + network: string, + walletAddress: string, + poolAddress: string, + baseTokenAmount: number, + quoteTokenAmount: number, + slippagePct?: number + ): Promise { + const solana = await Solana.getInstance(network) + const gamma = await Gamma.getInstance(network) + const wallet = await solana.getWallet(walletAddress); + + const { poolInfo, poolKeys } = await gamma.client.cpmm.getPoolInfoFromRpc(poolAddress) + + const { baseLimited, baseTokenAmountMax, quoteTokenAmountMax } = await quoteLiquidity( + _fastify, + network, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct + ) as QuoteLiquidityResponseType; + + const baseTokenAmountAdded = baseLimited ? baseTokenAmount : baseTokenAmountMax; + const quoteTokenAmountAdded = baseLimited ? quoteTokenAmount : quoteTokenAmountMax; + + logger.info(`Adding liquidity to Gamma...`); + const COMPUTE_UNITS = 600000 + const slippage = new Percent( + Math.floor(((slippagePct === 0 ? 0 : slippagePct || gamma.getSlippagePct('amm')) * 100) / 10000) + ); + + let currentPriorityFee = (await solana.estimateGas() * 1e9) - BASE_FEE + while (currentPriorityFee <= solana.config.maxPriorityFee * 1e9) { + const priorityFeePerCU = Math.floor(currentPriorityFee * 1e6 / COMPUTE_UNITS) + + const transaction = await createAddLiquidityTransaction( + gamma, + poolInfo, + poolKeys, + baseTokenAmountAdded, + quoteTokenAmountAdded, + baseLimited, + slippage, + { + units: COMPUTE_UNITS, + microLamports: priorityFeePerCU, + } + ) + console.log('transaction', transaction); + + if (transaction instanceof VersionedTransaction) { + (transaction as VersionedTransaction).sign([wallet]); + } else { + const txAsTransaction = transaction as Transaction; + const { blockhash, lastValidBlockHeight } = await solana.connection.getLatestBlockhash(); + txAsTransaction.recentBlockhash = blockhash; + txAsTransaction.lastValidBlockHeight = lastValidBlockHeight; + txAsTransaction.feePayer = wallet.publicKey; + txAsTransaction.sign(wallet); + } + + await solana.simulateTransaction(transaction); + + console.log('signed transaction', transaction); + + const { confirmed, signature, txData } = await solana.sendAndConfirmRawTransaction(transaction); + if (confirmed && txData) { + const { baseTokenBalanceChange, quoteTokenBalanceChange } = + await solana.extractPairBalanceChangesAndFee( + signature, + await solana.getToken(poolInfo.mintA.address), + await solana.getToken(poolInfo.mintB.address), + wallet.publicKey.toBase58() + ); + return { + signature, + fee: txData.meta.fee / 1e9, + baseTokenAmountAdded: baseTokenBalanceChange, + quoteTokenAmountAdded: quoteTokenBalanceChange, + } + } + currentPriorityFee = currentPriorityFee * solana.config.priorityFeeMultiplier + logger.info(`Increasing max priority fee to ${(currentPriorityFee / 1e9).toFixed(6)} SOL`); + } + throw new Error(`Add liquidity failed after reaching max priority fee of ${(solana.config.maxPriorityFee / 1e9).toFixed(6)} SOL`); + } + + export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { + // Get first wallet address for example + const solana = await Solana.getInstance('mainnet-beta'); + let firstWalletAddress = ''; + + const foundWallet = await solana.getFirstWalletAddress(); + if (foundWallet) { + firstWalletAddress = foundWallet; + } else { + logger.debug('No wallets found for examples in schema'); + } + + // Update schema example + AddLiquidityRequest.properties.walletAddress.examples = [firstWalletAddress]; + + fastify.post<{ + Body: AddLiquidityRequestType + Reply: AddLiquidityResponseType + }>( + '/add-liquidity', + { + schema: { + description: 'Add liquidity to a Gamma AMM/CPMM pool', + tags: ['gamma/amm'], + body: { + ...AddLiquidityRequest, + properties: { + ...AddLiquidityRequest.properties, + network: { type: 'string', default: 'mainnet-beta' }, + poolAddress: { type: 'string', examples: ['Hjm1F98vgVdN7Y9L46KLqcZZWyTKS9tj9ybYKJcXnSng'] }, // SOL-USDC + slippagePct: { type: 'number', examples: [1] }, + baseTokenAmount: { type: 'number', examples: [1] }, + quoteTokenAmount: { type: 'number', examples: [1] }, + } + }, + response: { + 200: AddLiquidityResponse + }, + } + }, + async (request) => { + try { + const { + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct + } = request.body + + return await addLiquidity( + fastify, + network || 'mainnet-beta', + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct + ) + } catch (e) { + logger.error(e) + throw fastify.httpErrors.internalServerError('Internal server error') + } + } + ) + } + + export default addLiquidityRoute + \ No newline at end of file diff --git a/src/connectors/gamma/amm-routes/executeSwap.ts b/src/connectors/gamma/amm-routes/executeSwap.ts new file mode 100644 index 0000000000..a56c310b63 --- /dev/null +++ b/src/connectors/gamma/amm-routes/executeSwap.ts @@ -0,0 +1,181 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify' +import { Solana, BASE_FEE } from '../../../chains/solana/solana' +import { Gamma } from '../gamma' +import { logger } from '../../../services/logger' +import { + ExecuteSwapResponse, + ExecuteSwapResponseType, + ExecuteSwapRequest, + ExecuteSwapRequestType +} from '../../../schemas/swap-schema' +import { getRawSwapQuote } from './quoteSwap' +import { VersionedTransaction } from '@solana/web3.js' +import { TxVersion } from 'goosefx-amm-sdk' + +async function executeSwap( + fastify: FastifyInstance, + network: string, + walletAddress: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + poolAddress: string, + slippagePct?: number +): Promise { + // Convert side to exactIn + const exactIn = side === 'SELL'; + if (!exactIn) { + throw new Error(`BUY side(Exact Out) not supported by Gamma oracle swaps`) + } + + const solana = await Solana.getInstance(network) + const gamma = await Gamma.getInstance(network) + const wallet = await solana.getWallet(walletAddress) + + // Get pool info from address + const poolInfo = await gamma.getAmmPoolInfo(poolAddress) + if (!poolInfo) { + throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`) + } + + // Use configured slippage if not provided + const effectiveSlippage = slippagePct || gamma.getSlippagePct('amm') + + // Get swap quote + const quote = await getRawSwapQuote( + network, + poolAddress, + baseToken, + quoteToken, + amount, + side, + effectiveSlippage + ) + + const inputToken = quote.inputToken + const outputToken = quote.outputToken + + logger.info(`Executing ${amount.toFixed(4)} ${side} swap in pool ${poolAddress}`) + + const COMPUTE_UNITS = 600000; + let currentPriorityFee = (await solana.estimateGas() * 1e9) - BASE_FEE; + while (currentPriorityFee <= solana.config.maxPriorityFee * 1e9) { + const priorityFeePerCU = Math.floor(currentPriorityFee * 1e6 / COMPUTE_UNITS); + let { transaction } = await gamma.client.cpmm.swapWithOracle({ + poolInfo: quote.poolInfo, + poolKeys: quote.poolKeys, + zeroForOne: quote.zeroForOne, + swapResult: quote, + slippage: effectiveSlippage / 100, + txVersion: TxVersion.V0, + computeBudgetConfig: { + units: COMPUTE_UNITS, + microLamports: priorityFeePerCU, + }, + }) + + transaction.sign([wallet]); + await solana.simulateTransaction(transaction as VersionedTransaction); + + const { confirmed, signature, txData } = await solana.sendAndConfirmRawTransaction(transaction); + if (confirmed && txData) { + const { baseTokenBalanceChange, quoteTokenBalanceChange } = + await solana.extractPairBalanceChangesAndFee( + signature, + await solana.getToken(poolInfo.baseTokenAddress), + await solana.getToken(poolInfo.quoteTokenAddress), + wallet.publicKey.toBase58() + ); + + logger.info(`Swap executed successfully: ${Math.abs(side === 'SELL' ? baseTokenBalanceChange : quoteTokenBalanceChange).toFixed(4)} ${inputToken.symbol} -> ${Math.abs(side === 'SELL' ? quoteTokenBalanceChange : baseTokenBalanceChange).toFixed(4)} ${outputToken.symbol}`); + + return { + signature, + totalInputSwapped: Math.abs(side === 'SELL' ? baseTokenBalanceChange : quoteTokenBalanceChange), + totalOutputSwapped: Math.abs(side === 'SELL' ? quoteTokenBalanceChange : baseTokenBalanceChange), + fee: txData.meta.fee / 1e9, + baseTokenBalanceChange, + quoteTokenBalanceChange, + } + } + currentPriorityFee = currentPriorityFee * solana.config.priorityFeeMultiplier + logger.info(`Increasing priority fee to ${currentPriorityFee} lamports/CU (max fee of ${(currentPriorityFee / 1e9).toFixed(6)} SOL)`); + } + throw new Error(`Swap execution failed after reaching max priority fee of ${(solana.config.maxPriorityFee / 1e9).toFixed(6)} SOL`); +} + +export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { + // Get first wallet address for example + const solana = await Solana.getInstance('mainnet-beta') + let firstWalletAddress = '' + + try { + firstWalletAddress = await solana.getFirstWalletAddress() || firstWalletAddress + } catch (error) { + logger.warn('No wallets found for examples in schema') + } + + fastify.post<{ + Body: ExecuteSwapRequestType; + Reply: ExecuteSwapResponseType; + }>( + '/execute-swap', + { + schema: { + description: 'Execute a swap on Gamma', + tags: ['gamma/amm'], + body: { + ...ExecuteSwapRequest, + properties: { + ...ExecuteSwapRequest.properties, + network: { type: 'string', default: 'mainnet-beta' }, + walletAddress: { type: 'string', examples: [firstWalletAddress] }, + baseToken: { type: 'string', examples: ['SOL'] }, + quoteToken: { type: 'string', examples: ['USDC'] }, + amount: { type: 'number', examples: [0.01] }, + side: { type: 'string', examples: ['SELL'] }, + poolAddress: { type: 'string', examples: ['Hjm1F98vgVdN7Y9L46KLqcZZWyTKS9tj9ybYKJcXnSng'] }, + slippagePct: { type: 'number', examples: [1] } + } + }, + response: { 200: ExecuteSwapResponse } + } + }, + async (request) => { + try { + const { network, walletAddress, baseToken, quoteToken, amount, side, poolAddress, slippagePct } = request.body + const networkToUse = network || 'mainnet-beta' + + // If no pool address provided, find default pool + let poolAddressToUse = poolAddress; + if (!poolAddressToUse) { + const gamma = await Gamma.getInstance(networkToUse); + poolAddressToUse = await gamma.findDefaultPool(baseToken, quoteToken, 'amm'); + if (!poolAddressToUse) { + throw fastify.httpErrors.notFound( + `No AMM pool found for pair ${baseToken}-${quoteToken}` + ); + } + } + + return await executeSwap( + fastify, + networkToUse, + walletAddress, + baseToken, + quoteToken, + amount, + side as 'BUY' | 'SELL', + poolAddressToUse, + slippagePct + ) + } catch (e) { + logger.error(e); + throw fastify.httpErrors.internalServerError('Swap execution failed') + } + } + ) +} + +export default executeSwapRoute \ No newline at end of file diff --git a/src/connectors/gamma/amm-routes/poolInfo.ts b/src/connectors/gamma/amm-routes/poolInfo.ts new file mode 100644 index 0000000000..a124c681cd --- /dev/null +++ b/src/connectors/gamma/amm-routes/poolInfo.ts @@ -0,0 +1,51 @@ +import { FastifyPluginAsync } from 'fastify'; +import { Gamma } from '../gamma'; +import { logger } from '../../../services/logger'; +import { + GetPoolInfoRequestType, + GetPoolInfoRequest, + PoolInfo, + PoolInfoSchema +} from '../../../schemas/amm-schema'; + +export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: GetPoolInfoRequestType; + Reply: Record; + }>( + '/pool-info', + { + schema: { + description: `Get AMM pool information from goosefx's Gamma dex`, + tags: ['gamma/amm'], + querystring: { + ...GetPoolInfoRequest, + properties: { + network: { type: 'string', examples: ['mainnet-beta'] }, + poolAddress: { + type: 'string', + examples: ['Hjm1F98vgVdN7Y9L46KLqcZZWyTKS9tj9ybYKJcXnSng'] + } + } + }, + response: { + 200: PoolInfoSchema + }, + } + }, + async (request): Promise => { + try { + const { poolAddress } = request.query; + const network = request.query.network || 'mainnet-beta'; + + const gamma = await Gamma.getInstance(network); + const poolInfo = await gamma.getAmmPoolInfo(poolAddress); + if (!poolInfo) throw fastify.httpErrors.notFound('Pool not found'); + return poolInfo; + } catch (e) { + logger.error(e); + throw fastify.httpErrors.internalServerError('Failed to fetch pool info'); + } + } + ); +}; diff --git a/src/connectors/gamma/amm-routes/quoteLiquidity.ts b/src/connectors/gamma/amm-routes/quoteLiquidity.ts new file mode 100644 index 0000000000..f521b5ceb0 --- /dev/null +++ b/src/connectors/gamma/amm-routes/quoteLiquidity.ts @@ -0,0 +1,173 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { Gamma } from '../gamma'; +import { Solana } from '../../../chains/solana/solana'; +import { logger } from '../../../services/logger'; +import { + QuoteLiquidityRequest, + QuoteLiquidityRequestType, + QuoteLiquidityResponse, + QuoteLiquidityResponseType, +} from '../../../schemas/amm-schema'; +import BN from 'bn.js'; +import { Percent } from 'goosefx-amm-sdk'; + +interface CpmmComputePairResult { + anotherAmount: { amount: BN }; + maxAnotherAmount: { amount: BN }; + liquidity: BN; + inputAmountFee: { amount: BN }; +} + +export async function quoteLiquidity( + _fastify: FastifyInstance, + network: string, + poolAddress: string, + baseTokenAmount?: number, + quoteTokenAmount?: number, + slippagePct?: number +): Promise { + try { + const solana = await Solana.getInstance(network); + const gamma = await Gamma.getInstance(network); + + const { poolInfo, poolKeys, rpcData } = await gamma.client.cpmm.getPoolInfoFromRpc(poolAddress) + if (!rpcData || !poolKeys || !rpcData) { + throw new Error(`Pool not found: ${poolAddress}`) + } + + const baseToken = await solana.getToken(poolInfo.mintA.address); + const quoteToken = await solana.getToken(poolInfo.mintB.address); + + if (!baseTokenAmount && !quoteTokenAmount) { + throw new Error('Must provide baseTokenAmount or quoteTokenAmount'); + } + + const baseAmount = baseTokenAmount?.toString(); + const quoteAmount = quoteTokenAmount?.toString(); + + const epochInfo = await solana.connection.getEpochInfo(); + // Convert percentage to basis points (multiply by 100 to handle decimals) + // e.g., 0.5% becomes 50/10000, 0% becomes 0/10000 + const slippage = new Percent( + Math.floor(((slippagePct === 0 ? 0 : slippagePct || gamma.getSlippagePct('amm')) * 100) / 10000) + ); + + let resBase: CpmmComputePairResult | undefined = undefined; + if (baseAmount) { + resBase = gamma.client.cpmm.computePairAmount({ + poolInfo, + amount: baseAmount, + baseSpecified: true, + slippage: slippage, + epochInfo: epochInfo, + baseReserve: rpcData.baseReserve, + quoteReserve: rpcData.quoteReserve, + }) + } + + let resQuote: CpmmComputePairResult | undefined = undefined; + if (quoteAmount) { + resQuote = gamma.client.cpmm.computePairAmount({ + poolInfo, + amount: quoteAmount, + baseSpecified: false, + slippage: slippage, + epochInfo: epochInfo, + baseReserve: rpcData.baseReserve, + quoteReserve: rpcData.quoteReserve, + }) + } + + const useBaseResult = resBase && (!resQuote || resBase.liquidity.lte(resQuote.liquidity)); + const cpmmRes = useBaseResult ? resBase as CpmmComputePairResult : resQuote as CpmmComputePairResult; + const isBaseIn = useBaseResult; + + const resParsed = { + anotherAmount: Number(cpmmRes.anotherAmount.amount.toString()), + maxAnotherAmount: Number(cpmmRes.maxAnotherAmount.amount.toString()), + anotherAmountToken: isBaseIn ? baseToken.symbol : quoteToken.symbol, + maxAnotherAmountToken: isBaseIn ? baseToken.symbol : quoteToken.symbol, + liquidity: cpmmRes.liquidity.toString(), + } + console.log('resParsed:cpmm', resParsed); + if (isBaseIn) { + return { + baseLimited: true, + baseTokenAmount: baseTokenAmount, + quoteTokenAmount: resParsed.anotherAmount / 10 ** quoteToken.decimals, + baseTokenAmountMax: baseTokenAmount, + quoteTokenAmountMax: resParsed.maxAnotherAmount / 10 ** quoteToken.decimals, + }; + } else { + return { + baseLimited: false, + baseTokenAmount: resParsed.anotherAmount / 10 ** baseToken.decimals, + quoteTokenAmount: quoteTokenAmount, + baseTokenAmountMax: resParsed.maxAnotherAmount / 10 ** baseToken.decimals, + quoteTokenAmountMax: quoteTokenAmount, + }; + } + + } catch (error) { + logger.error(error); + throw error; + } +} + +export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: QuoteLiquidityRequestType; + Reply: QuoteLiquidityResponseType | { error: string }; + }>( + '/quote-liquidity', + { + schema: { + description: 'Quote amounts for a new Gamma AMM liquidity position', + tags: ['gamma/amm'], + querystring: { + ...QuoteLiquidityRequest, + properties: { + ...QuoteLiquidityRequest.properties, + network: { type: 'string', default: 'mainnet-beta' }, + poolAddress: { type: 'string', examples: ['Hjm1F98vgVdN7Y9L46KLqcZZWyTKS9tj9ybYKJcXnSng'] }, // SOL-USDC + baseTokenAmount: { type: 'number', examples: [1] }, + quoteTokenAmount: { type: 'number', examples: [1] }, + slippagePct: { type: 'number', examples: [1] }, + } + }, + response: { + 200: QuoteLiquidityResponse, + 500: { + type: 'object', + properties: { error: { type: 'string' } } + } + }, + }, + }, + async (request) => { + try { + const { + network = 'mainnet-beta', + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct + } = request.query; + + return await quoteLiquidity( + fastify, + network, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct + ); + } catch (e) { + logger.error(e); + throw fastify.httpErrors.internalServerError('Failed to quote position'); + } + } + ); +}; + +export default quoteLiquidityRoute; \ No newline at end of file diff --git a/src/connectors/gamma/amm-routes/quoteSwap.ts b/src/connectors/gamma/amm-routes/quoteSwap.ts new file mode 100644 index 0000000000..9d6b3a28e6 --- /dev/null +++ b/src/connectors/gamma/amm-routes/quoteSwap.ts @@ -0,0 +1,303 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify' +import { Gamma } from '../gamma' +import { Solana } from '../../../chains/solana/solana' +import { logger } from '../../../services/logger' +import { + GetSwapQuoteResponseType, + GetSwapQuoteResponse, + GetSwapQuoteRequestType, + GetSwapQuoteRequest +} from '../../../schemas/swap-schema' +import { OracleBasedCurveCalculator, PoolKeys, PoolInfo, SwapResult } from 'goosefx-amm-sdk' +import BN from 'bn.js' +import Decimal from 'decimal.js' +import { estimateGasSolana } from '../../../chains/solana/routes/estimate-gas' +import { TokenInfo } from '../../../services/base' + +type RawQuoteResponse = SwapResult & { + zeroForOne: boolean; + minAmountOut: BN; + maxAmountIn: BN; + inputToken: TokenInfo; + outputToken: TokenInfo; + price: number; + poolInfo: PoolInfo; + poolKeys: PoolKeys; +} + +export async function getRawSwapQuote( + network: string, + poolId: string, + baseTokenSymbol: string, + quoteTokenSymbol: string, + amount: number, + side: 'BUY' | 'SELL', + slippagePct?: number +): Promise { + // Convert side to exactIn + const exactIn = side === 'SELL'; + if (!exactIn) { + throw new Error(`FixedOut swaps not supported by Gamma oracle-based AMM`) + } + + const solana = await Solana.getInstance(network); + const gamma = await Gamma.getInstance(network); + + // Resolve tokens from symbols or addresses + const resolvedBaseToken = await solana.getToken(baseTokenSymbol); + const resolvedQuoteToken = await solana.getToken(quoteTokenSymbol); + + if (!resolvedBaseToken || !resolvedQuoteToken) { + throw new Error(`Token not found: ${!resolvedBaseToken ? baseTokenSymbol : quoteTokenSymbol}`) + } + + logger.info(`Base token: ${resolvedBaseToken.symbol}, address=${resolvedBaseToken.address}, decimals=${resolvedBaseToken.decimals}`) + logger.info(`Quote token: ${resolvedQuoteToken.symbol}, address=${resolvedQuoteToken.address}, decimals=${resolvedQuoteToken.decimals}`) + + const baseTokenAddress = resolvedBaseToken.address + const quoteTokenAddress = resolvedQuoteToken.address + + logger.info(`getRawSwapQuote: poolId=${poolId}, baseToken=${baseTokenSymbol}, quoteToken=${quoteTokenSymbol}, amount=${amount}, side=${side}, exactIn=${exactIn}`) + + // Get pool info + const { poolInfo, poolKeys, rpcData } = await gamma.client.cpmm.getPoolInfoFromRpc(poolId) + + const observationState = await gamma.client.cpmm.getObservationStates([rpcData.observationKey]).then((res) => res.at(0)) + if (!observationState) { + throw new Error(`Pool observations not found. Pool: ${poolId}`) + } + const ammPoolInfo = gamma.rpcToPoolInfo(poolId, rpcData) + + // Verify input and output tokens match pool tokens + if (baseTokenAddress !== ammPoolInfo.baseTokenAddress && baseTokenAddress !== ammPoolInfo.quoteTokenAddress) { + throw new Error(`Base token ${baseTokenSymbol} is not in pool ${poolId}`) + } + + if (quoteTokenAddress !== ammPoolInfo.baseTokenAddress && quoteTokenAddress !== ammPoolInfo.quoteTokenAddress) { + throw new Error(`Quote token ${quoteTokenSymbol} is not in pool ${poolId}`) + } + + // `side` specifies if the amount(in base tokens) is what we're buying or what we're selling + // - For buys, output is in base tokens + // - For sells, input is in base tokens + const [inputToken, outputToken, inputTokenReserves, outputTokenReserves] = + [resolvedBaseToken, resolvedQuoteToken, ammPoolInfo.baseTokenAmount, ammPoolInfo.quoteTokenAmount] + const zeroForOne = inputToken.address === ammPoolInfo.baseTokenAddress + + logger.info(`Input token: ${inputToken.symbol}, address=${inputToken.address}, decimals=${inputToken.decimals}`) + logger.info(`Output token: ${outputToken.symbol}, address=${outputToken.address}, decimals=${outputToken.decimals}`) + + // Convert amount to string with proper decimals based on which token we're using + const inputDecimals = inputToken.decimals + const outputDecimals = outputToken.decimals + + // Create amount with proper decimals for the token being used (input for exactIn, output for exactOut) + const amountInWithDecimals = exactIn + ? new Decimal(amount).mul(10 ** inputDecimals).toFixed(0) + : undefined + + const amountOutWithDecimals = !exactIn + ? new Decimal(amount).mul(10 ** outputDecimals).toFixed(0) + : undefined + + logger.info(`Amount in human readable: ${amount}`) + logger.info(`Amount in with decimals: ${amountInWithDecimals}, Amount out with decimals: ${amountOutWithDecimals}`) + + const result = OracleBasedCurveCalculator.swap( + new BN(amountInWithDecimals!), + zeroForOne, + new BN(inputTokenReserves), + new BN(outputTokenReserves), + new BN(ammPoolInfo.feePct), + observationState, + rpcData + ) + + const slippage = slippagePct === undefined ? 0.01 : slippagePct / 100 + const otherAmountThreshold = exactIn + ? result.destinationAmountSwapped.mul(new BN((1 - slippage) * 10000)).div(new BN(10000)) + : result.sourceAmountSwapped.mul(new BN((1 + slippage) * 10000)).div(new BN(10000)) + + logger.info(`Raw quote result: amountIn=${result.sourceAmountSwapped.toString()}, amountOut=${result.destinationAmountSwapped.toString()}, inputMint=${inputToken.address}, outputMint=${outputToken.address}`) + + // Add price calculation + const price = side === 'SELL' + ? result.destinationAmountSwapped.div(result.sourceAmountSwapped).toNumber() + : result.sourceAmountSwapped.div(result.destinationAmountSwapped).toNumber() + + return { + ...result, + zeroForOne, + maxAmountIn: exactIn ? result.sourceAmountSwapped : otherAmountThreshold, + minAmountOut: exactIn ? otherAmountThreshold : result.destinationAmountSwapped, + inputToken, + outputToken, + price, + poolInfo, + poolKeys, + }; +} + +async function formatSwapQuote( + _fastify: FastifyInstance, + network: string, + poolAddress: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + slippagePct?: number +): Promise { + logger.info(`formatSwapQuote: poolAddress=${poolAddress}, baseToken=${baseToken}, quoteToken=${quoteToken}, amount=${amount}, side=${side}`) + + const quote = await getRawSwapQuote( + network, + poolAddress, + baseToken, + quoteToken, + amount, + side as 'BUY' | 'SELL', + slippagePct + ) + + logger.info(`Quote result: amountIn=${quote.sourceAmountSwapped.toString()}, amountOut=${quote.destinationAmountSwapped.toString()}`) + + // Use the token objects returned from getRawSwapQuote + const inputToken = quote.inputToken + const outputToken = quote.outputToken + + logger.info(`Using input token decimals: ${inputToken.decimals}, output token decimals: ${outputToken.decimals}`) + + // Convert BN values to numbers with correct decimal precision + const estimatedAmountIn = new Decimal(quote.sourceAmountSwapped.toString()) + .div(10 ** inputToken.decimals) + .toNumber() + + const estimatedAmountOut = new Decimal(quote.destinationAmountSwapped.toString()) + .div(10 ** outputToken.decimals) + .toNumber() + + const minAmountOut = new Decimal(quote.minAmountOut.toString()) + .div(10 ** outputToken.decimals) + .toNumber() + + const maxAmountIn = new Decimal(quote.maxAmountIn.toString()) + .div(10 ** inputToken.decimals) + .toNumber() + + logger.info(`Converted amounts: estimatedAmountIn=${estimatedAmountIn}, estimatedAmountOut=${estimatedAmountOut}, minAmountOut=${minAmountOut}, maxAmountIn=${maxAmountIn}`) + + // Calculate balance changes correctly based on which tokens are being swapped + // SELL: amount(base tokens) is input, base tokens decrease(+ve), quote tokens increase(-ve) + // BUY: _amount(base tokens) is output, base tokens increase(+ve), quote tokens decrease(-ve) + const baseTokenBalanceChange = side === 'BUY' ? estimatedAmountOut : -estimatedAmountIn + const quoteTokenBalanceChange = side === 'BUY' ? -estimatedAmountIn : estimatedAmountOut + + logger.info(`Balance changes: baseTokenBalanceChange=${baseTokenBalanceChange}, quoteTokenBalanceChange=${quoteTokenBalanceChange}`) + + // Add price calculation + const price = side === 'SELL' + ? estimatedAmountOut / estimatedAmountIn + : estimatedAmountIn / estimatedAmountOut; + + return { + poolAddress, + estimatedAmountIn, + estimatedAmountOut, + minAmountOut, + maxAmountIn, + baseTokenBalanceChange, + quoteTokenBalanceChange, + price, + gasPrice: 0, + gasLimit: 0, + gasCost: 0 + } +} + +export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: GetSwapQuoteRequestType; + Reply: GetSwapQuoteResponseType; + }>( + '/quote-swap', + { + schema: { + description: 'Get swap quote for Gamma AMM', + tags: ['gamma/amm'], + querystring:{ + ...GetSwapQuoteRequest, + properties: { + ...GetSwapQuoteRequest.properties, + network: { type: 'string', default: 'mainnet-beta' }, + baseToken: { type: 'string', examples: ['SOL'] }, + quoteToken: { type: 'string', examples: ['USDC'] }, + amount: { type: 'number', examples: [0.01] }, + side: { type: 'string', enum: ['BUY', 'SELL'], examples: ['SELL'] }, + poolAddress: { type: 'string', examples: ['Hjm1F98vgVdN7Y9L46KLqcZZWyTKS9tj9ybYKJcXnSng'] }, + slippagePct: { type: 'number', examples: [1] } + } + }, + response: { + 200: { + properties: { + ...GetSwapQuoteResponse.properties, + } + } + }, + } + }, + async (request) => { + try { + const { network, poolAddress: requestedPoolAddress, baseToken, quoteToken, amount, side, slippagePct } = request.query + const networkToUse = network || 'mainnet-beta' + + const gamma = await Gamma.getInstance(networkToUse); + let poolAddress = requestedPoolAddress; + + if (!poolAddress) { + poolAddress = await gamma.findDefaultPool(baseToken, quoteToken, 'amm'); + + if (!poolAddress) { + throw fastify.httpErrors.notFound( + `No AMM pool found for pair ${baseToken}-${quoteToken}` + ); + } + } + + const result = await formatSwapQuote( + fastify, + networkToUse, + poolAddress, + baseToken, + quoteToken, + amount, + side as 'BUY' | 'SELL', + slippagePct + ) + + let gasEstimation = null; + try { + gasEstimation = await estimateGasSolana(fastify, networkToUse); + } catch (error) { + logger.warn(`Failed to estimate gas for swap quote: ${error.message}`); + } + + return { + ...result, + gasPrice: gasEstimation?.gasPrice, + gasLimit: gasEstimation?.gasLimit, + gasCost: gasEstimation?.gasCost + } + } catch (e) { + logger.error(e) + if (e.statusCode) { + throw e; + } + throw fastify.httpErrors.internalServerError('Internal server error') + } + } + ) +} + +export default quoteSwapRoute \ No newline at end of file diff --git a/src/connectors/gamma/amm-routes/removeLiquidity.ts b/src/connectors/gamma/amm-routes/removeLiquidity.ts new file mode 100644 index 0000000000..d0cea104c6 --- /dev/null +++ b/src/connectors/gamma/amm-routes/removeLiquidity.ts @@ -0,0 +1,214 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify' +import { Gamma } from '../gamma' +import { Solana, BASE_FEE } from '../../../chains/solana/solana' +import { logger } from '../../../services/logger' +import { + RemoveLiquidityRequest, + RemoveLiquidityResponse, + RemoveLiquidityRequestType, + RemoveLiquidityResponseType, +} from '../../../schemas/amm-schema' +import { getPdaUserLiquidity, Percent, PoolInfo, PoolKeys, TxVersion } from "goosefx-amm-sdk" +import BN from 'bn.js' +import Decimal from 'decimal.js' +import { Keypair, VersionedTransaction, Transaction, PublicKey } from '@solana/web3.js' + +async function createRemoveLiquidityTransaction( + gamma: Gamma, + poolInfo: PoolInfo, + poolKeys: PoolKeys, + lpAmount: BN, + computeBudgetConfig: { units: number; microLamports: number } +): Promise { + // Use default slippage from Gamma class + const slippage = new Percent( + Math.floor(gamma.getSlippagePct('amm') * 100) / 10000 + ) + + const { transaction } = await gamma.client.cpmm.withdrawLiquidity({ + poolInfo: poolInfo, + poolKeys: poolKeys, + lpAmount: lpAmount, + txVersion: TxVersion.V0, + slippage, + computeBudgetConfig, + }) + return transaction +} + +/** + * Calculate the LP token amount to remove based on percentage + */ +async function calculateLpAmountToRemove( + gamma: Gamma, + wallet: Keypair, + poolInfo: PoolInfo, + percentageToRemove: number +): Promise { + const userLiquidityPda = getPdaUserLiquidity( + new PublicKey(poolInfo.programId), + new PublicKey(poolInfo.id), + wallet.publicKey, + ).publicKey; + const userLiquidity = await gamma.client.cpmm.getRpcUserLiquidityAccounts([userLiquidityPda]).then((res) => res.at(0)) + if (!userLiquidity) { + throw new Error(`User has no positions for this pool - nothing to remove`) + } + + const lpBalance = userLiquidity.lpTokensOwned + if (lpBalance.isZero()) { + throw new Error('User LP balance is zero - nothing to remove') + } + + // Calculate LP amount to remove based on percentage + return new BN( + new Decimal(lpBalance.toString()) + .mul(percentageToRemove / 100) + .toFixed(0) + ) +} + +async function removeLiquidity( + _fastify: FastifyInstance, + network: string, + walletAddress: string, + poolAddress: string, + percentageToRemove: number +): Promise { + const solana = await Solana.getInstance(network) + const gamma = await Gamma.getInstance(network) + const wallet = await solana.getWallet(walletAddress) + + const { poolInfo, poolKeys } = await gamma.client.cpmm.getPoolInfoFromRpc(poolAddress) + + if (percentageToRemove <= 0 || percentageToRemove > 100) { + throw new Error('Invalid percentageToRemove - must be between 0 and 100') + } + + // Calculate LP amount to remove + const lpAmountToRemove = await calculateLpAmountToRemove( + gamma, + wallet, + poolInfo, + percentageToRemove + ) + + logger.info(`Removing ${percentageToRemove.toFixed(4)}% liquidity from pool ${poolAddress}...`) + const COMPUTE_UNITS = 600000 + + let currentPriorityFee = (await solana.estimateGas() * 1e9) - BASE_FEE + while (currentPriorityFee <= solana.config.maxPriorityFee * 1e9) { + const priorityFeePerCU = Math.floor(currentPriorityFee * 1e6 / COMPUTE_UNITS) + + const transaction = await createRemoveLiquidityTransaction( + gamma, + poolInfo, + poolKeys, + lpAmountToRemove, + { + units: COMPUTE_UNITS, + microLamports: priorityFeePerCU, + } + ) + + if (transaction instanceof VersionedTransaction) { + (transaction as VersionedTransaction).sign([wallet]) + } else { + const txAsTransaction = transaction as Transaction + const { blockhash, lastValidBlockHeight } = await solana.connection.getLatestBlockhash() + txAsTransaction.recentBlockhash = blockhash + txAsTransaction.lastValidBlockHeight = lastValidBlockHeight + txAsTransaction.feePayer = wallet.publicKey + txAsTransaction.sign(wallet) + } + + await solana.simulateTransaction(transaction) + + const { confirmed, signature, txData } = await solana.sendAndConfirmRawTransaction(transaction) + if (confirmed && txData) { + const { baseTokenBalanceChange, quoteTokenBalanceChange } = + await solana.extractPairBalanceChangesAndFee( + signature, + await solana.getToken(poolInfo.mintA.address), + await solana.getToken(poolInfo.mintB.address), + wallet.publicKey.toBase58() + ) + + logger.info(`Liquidity removed from pool ${poolAddress}: ${Math.abs(baseTokenBalanceChange).toFixed(4)} ${poolInfo.mintA.symbol}, ${Math.abs(quoteTokenBalanceChange).toFixed(4)} ${poolInfo.mintB.symbol}`) + + return { + signature, + fee: txData.meta.fee / 1e9, + baseTokenAmountRemoved: Math.abs(baseTokenBalanceChange), + quoteTokenAmountRemoved: Math.abs(quoteTokenBalanceChange), + } + } + currentPriorityFee = currentPriorityFee * solana.config.priorityFeeMultiplier + logger.info(`Increasing max priority fee to ${(currentPriorityFee / 1e9).toFixed(6)} SOL`) + } + throw new Error(`Remove liquidity failed after reaching max priority fee of ${(solana.config.maxPriorityFee / 1e9).toFixed(6)} SOL`) +} + +export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { + // Get first wallet address for example + const solana = await Solana.getInstance('mainnet-beta') + let firstWalletAddress = '' + + const foundWallet = await solana.getFirstWalletAddress() + if (foundWallet) { + firstWalletAddress = foundWallet + } else { + logger.debug('No wallets found for examples in schema') + } + + // Update schema example + RemoveLiquidityRequest.properties.walletAddress.examples = [firstWalletAddress] + + fastify.post<{ + Body: RemoveLiquidityRequestType + Reply: RemoveLiquidityResponseType + }>( + '/remove-liquidity', + { + schema: { + description: 'Remove liquidity from a Gamma AMM/CPMM pool', + tags: ['gamma/amm'], + body: { + ...RemoveLiquidityRequest, + properties: { + ...RemoveLiquidityRequest.properties, + network: { type: 'string', default: 'mainnet-beta' }, + poolAddress: { type: 'string', examples: ['Hjm1F98vgVdN7Y9L46KLqcZZWyTKS9tj9ybYKJcXnSng'] }, // SOL-USDC + percentageToRemove: { type: 'number', examples: [100] }, + } + }, + response: { + 200: RemoveLiquidityResponse + }, + } + }, + async (request) => { + try { + const { + network, + walletAddress, + poolAddress, + percentageToRemove + } = request.body + + return await removeLiquidity( + fastify, + network || 'mainnet-beta', + walletAddress, + poolAddress, + percentageToRemove + ) + } catch (e) { + logger.error(e) + throw fastify.httpErrors.internalServerError('Internal server error') + } + } + ) +} + +export default removeLiquidityRoute \ No newline at end of file diff --git a/src/connectors/gamma/gamma.config.ts b/src/connectors/gamma/gamma.config.ts new file mode 100644 index 0000000000..4b2bf096d7 --- /dev/null +++ b/src/connectors/gamma/gamma.config.ts @@ -0,0 +1,63 @@ +import { ConfigManagerV2 } from '../../services/config-manager-v2'; + +interface AvailableNetworks { + chain: string; + networks: Array; +} + +export namespace GammaConfig { + // Supported networks for Gamma + export const chain = 'solana'; + export const networks = ['mainnet-beta', 'devnet']; + + export interface PoolsConfig { + [pairKey: string]: string; + } + + export interface NetworkConfig { + // Pool configurations + amm: PoolsConfig; + clmm: PoolsConfig; + } + + export interface NetworkPoolsConfig { + // Dictionary of predefined pool addresses and settings by network + [network: string]: NetworkConfig; + } + + export interface RootConfig { + // Global configuration + allowedSlippage: string; + + // Network-specific configurations + networks: NetworkPoolsConfig; + + // Available networks + availableNetworks: Array; + } + + export const config: RootConfig = { + // Global configuration + allowedSlippage: ConfigManagerV2.getInstance().get( + 'gamma.allowedSlippage', + ), + + // Network-specific pools + networks: ConfigManagerV2.getInstance().get('gamma.networks'), + + availableNetworks: [ + { + chain: 'solana', + networks: ['mainnet-beta', 'devnet'], + }, + ], + }; + + // Helper methods to get pools for a specific network + export const getNetworkPools = ( + network: string, + poolType: 'amm' | 'clmm', + ): PoolsConfig => { + return config.networks[network]?.[poolType] || {}; + }; +} diff --git a/src/connectors/gamma/gamma.routes.ts b/src/connectors/gamma/gamma.routes.ts new file mode 100644 index 0000000000..dc37fe28f9 --- /dev/null +++ b/src/connectors/gamma/gamma.routes.ts @@ -0,0 +1,35 @@ +import type { FastifyPluginAsync } from 'fastify'; +import sensible from '@fastify/sensible'; + +// AMM routes +import { poolInfoRoute as ammPoolInfoRoute } from './amm-routes/poolInfo'; +import { quoteLiquidityRoute } from './amm-routes/quoteLiquidity'; +import { quoteSwapRoute as ammQuoteSwapRoute } from './amm-routes/quoteSwap'; +import { executeSwapRoute as ammExecuteSwapRoute } from './amm-routes/executeSwap'; +import { addLiquidityRoute as ammAddLiquidityRoute } from './amm-routes/addLiquidity'; +import { removeLiquidityRoute as ammRemoveLiquidityRoute } from './amm-routes/removeLiquidity'; + +// AMM routes including swap endpoints +const gammaAmmRoutes: 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 = ['gamma/amm']; + } + }); + + await instance.register(ammPoolInfoRoute); + await instance.register(quoteLiquidityRoute); + await instance.register(ammQuoteSwapRoute); + await instance.register(ammExecuteSwapRoute); + await instance.register(ammAddLiquidityRoute); + await instance.register(ammRemoveLiquidityRoute); + }); +}; + +// Main export that combines all routes +export const gammaRoutes = { + amm: gammaAmmRoutes +}; \ No newline at end of file diff --git a/src/connectors/gamma/gamma.ts b/src/connectors/gamma/gamma.ts new file mode 100644 index 0000000000..7e9af23735 --- /dev/null +++ b/src/connectors/gamma/gamma.ts @@ -0,0 +1,121 @@ +import { CpmmRpcData, FEE_RATE_DENOMINATOR_VALUE, GfxCpmmClient } from 'goosefx-amm-sdk' +import { logger } from '../../services/logger' +import { GammaConfig } from './gamma.config' +import { Solana } from '../../chains/solana/solana' +import { Keypair } from '@solana/web3.js' +import { PoolInfo as AmmPoolInfo } from '../../schemas/amm-schema' +import { PublicKey } from '@solana/web3.js' +import { percentRegexp } from '../../services/config-manager-v2'; +import { ConfigManagerV2 } from '../../services/config-manager-v2'; + +export class Gamma { + private static _instances: { [name: string]: Gamma } + private solana: Solana + public client: GfxCpmmClient + public config: GammaConfig.NetworkConfig + private owner?: Keypair + + private constructor() { + this.config = + GammaConfig.config as unknown as GammaConfig.NetworkConfig + this.solana = null + } + + /** Gets singleton instance of Gamma */ + public static async getInstance(network: string): Promise { + if (!Gamma._instances) { + Gamma._instances = {} + } + + if (!Gamma._instances[network]) { + const instance = new Gamma() + await instance.init(network) + Gamma._instances[network] = instance + } + + return Gamma._instances[network] + } + + /** Initializes Gamma instance */ + private async init(network: string) { + try { + this.solana = await Solana.getInstance(network); + + // Load first wallet if available + const walletAddress = await this.solana.getFirstWalletAddress(); + if (walletAddress) { + this.owner = await this.solana.getWallet(walletAddress); + } + + // Initialize client with optional owner + this.client = await GfxCpmmClient.load({ + connection: this.solana.connection, + owner: this.owner, // undefined if no wallet present + disableFeatureCheck: true, + disableLoadToken: true, + blockhashCommitment: 'confirmed', + urlConfigs: {} + }); + + logger.info(`Initialized Gamma. Wallet: ${walletAddress ? walletAddress : 'none'}`) + } catch (error) { + logger.error("Failed to initialize Gamma:", error); + throw error; + } + } + + async getAmmPoolInfo(poolAddress: string): Promise { + try { + const pool = await this.client.cpmm.getRpcPoolInfo(poolAddress, true) + if (!pool.configInfo) { + throw new Error(`Failed to get config info`) + } + return this.rpcToPoolInfo(poolAddress, pool) + } catch(error) { + logger.error(`Error getting AMM pool info for ${poolAddress}:`, error) + return null + } + } + + rpcToPoolInfo(poolAddress: string, pool: CpmmRpcData): AmmPoolInfo { + return { + address: poolAddress, + baseTokenAddress: pool.token0Mint.toBase58(), + quoteTokenAddress: pool.token1Mint.toBase58(), + feePct: (pool.configInfo.tradeFeeRate.toNumber() / FEE_RATE_DENOMINATOR_VALUE.toNumber()) * 100, + price: Number(pool.poolPrice), + baseTokenAmount: pool.baseReserve.toNumber(), + quoteTokenAmount: pool.quoteReserve.toNumber(), + lpMint: { + address: PublicKey.default.toBase58(), + decimals: 0 + }, + poolType: 'amm' + } + } + + // General Slippage Settings + getSlippagePct(routeType: 'amm' | 'clmm'): number { + const allowedSlippage = this.config[routeType].allowedSlippage; + const nd = allowedSlippage.match(percentRegexp); + let slippage = 0.0; + if (nd) { + slippage = Number(nd[1]) / Number(nd[2]); + } else { + logger.error('Failed to parse slippage value:', allowedSlippage); + } + return slippage * 100; + } + + private getPairKey(baseToken: string, quoteToken: string): string { + return `${baseToken}-${quoteToken}`; + } + + async findDefaultPool(baseToken: string, quoteToken: string, routeType: 'amm' | 'clmm'): Promise { + const pools = this.config[routeType].pools; + const pairKey = this.getPairKey(baseToken, quoteToken); + const reversePairKey = this.getPairKey(quoteToken, baseToken); + + return pools[pairKey] || pools[reversePairKey] || null; + } +} \ No newline at end of file diff --git a/src/templates/gamma.yml b/src/templates/gamma.yml new file mode 100644 index 0000000000..0281375cad --- /dev/null +++ b/src/templates/gamma.yml @@ -0,0 +1,26 @@ +# Global settings for Gamma +# how much the execution price is allowed to move unfavorably +allowedSlippage: '1/100' + +# Network-specific pool configurations +networks: + # Solana mainnet pools + mainnet-beta: + # AMM (Gamma) pools for Solana mainnet + amm: + # Format: base-quote: pool_address + SOL-FARTCOIN: 'CM681mP5GjxrzFWg452RfJ2W4zEnshR9kkgg34NdAthi' + SOL-JITOSOL: 'CyK256TZTwELBABZ8vpnAKbKo3pD8oQgvW4RSt8PCRJ8' + MSOL-PYTH: '9WQYffYDVvCwmNdxB7QJX64ueuGQuEP8tfpcNzQ7AMF6' + SOL-GOFX: '4jN2hoeY9neREyifv8xU5i8XNotC563e78pJ7CkazdoJ' + SOL-WBTC: 'Cr3AWsnmXyrEJh3DSrF1xYRct4LSWPh1qGuUUo8JNnTt' + SOL-PWEASE: '39yqW5xvumoMN6LtEVaP5xjndamHacE4fKnYbQXkvJXV' + SOL-USDC: 'Hjm1F98vgVdN7Y9L46KLqcZZWyTKS9tj9ybYKJcXnSng' + SOL-JUPSOL: '9Pmk5Wa9LdVDNtwBzSU9GVfRrcdFyBi6XKBsZykCfrRS' + + # Solana devnet pools + devnet: + # AMM (Gamma) pools for Solana devnet + amm: {} + + \ No newline at end of file diff --git a/src/templates/json/gamma-schema.json b/src/templates/json/gamma-schema.json new file mode 100644 index 0000000000..2c932ec061 --- /dev/null +++ b/src/templates/json/gamma-schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "allowedSlippage": { "type": "string" }, + "networks": { + "type": "object", + "patternProperties": { + "^\\w+(-\\w+)?$": { + "type": "object", + "properties": { + "amm": { + "type": "object", + "patternProperties": { + "^[A-Za-z0-9.-]+-[A-Za-z0-9.-]+$": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["amm"], + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": ["allowedSlippage", "networks"] +} \ No newline at end of file diff --git a/src/templates/root.yml b/src/templates/root.yml index f7dca86f9f..bac1eeb918 100644 --- a/src/templates/root.yml +++ b/src/templates/root.yml @@ -27,3 +27,7 @@ configurations: $namespace raydium: configurationPath: raydium.yml schemaPath: raydium-schema.json + + $namespace gamma: + configurationPath: gamma.yml + schemaPath: gamma-schema.json