From 7c4e6f66c4c8c343005cdc6954120183ac03b6ad Mon Sep 17 00:00:00 2001 From: RYB-404 <116948958+RYB-404@users.noreply.github.com> Date: Tue, 26 May 2026 05:14:49 +0700 Subject: [PATCH 1/2] Add HyperSwap AMM connector for HyperEVM --- src/app.ts | 8 + src/config/routes/getConnectors.ts | 7 + .../hyperswap/amm-routes/addLiquidity.ts | 377 +++++++++++ .../hyperswap/amm-routes/executeSwap.ts | 328 ++++++++++ src/connectors/hyperswap/amm-routes/index.ts | 21 + .../hyperswap/amm-routes/poolInfo.ts | 104 +++ .../hyperswap/amm-routes/positionInfo.ts | 176 ++++++ .../hyperswap/amm-routes/quoteLiquidity.ts | 257 ++++++++ .../hyperswap/amm-routes/quoteSwap.ts | 416 ++++++++++++ .../hyperswap/amm-routes/removeLiquidity.ts | 232 +++++++ src/connectors/hyperswap/hyperswap.config.ts | 27 + .../hyperswap/hyperswap.contracts.ts | 93 +++ src/connectors/hyperswap/hyperswap.routes.ts | 24 + src/connectors/hyperswap/hyperswap.ts | 153 +++++ src/connectors/hyperswap/hyperswap.utils.ts | 93 +++ .../hyperswap/hyperswap_v2_router_abi.json | 23 + src/connectors/hyperswap/schemas.ts | 593 ++++++++++++++++++ src/templates/chains/ethereum/hyperevm.yml | 11 + src/templates/connectors/hyperswap.yml | 5 + src/templates/tokens/ethereum/hyperevm.json | 9 + .../hyperswap/hyperswap.routes.test.ts | 63 ++ 21 files changed, 3020 insertions(+) create mode 100644 src/connectors/hyperswap/amm-routes/addLiquidity.ts create mode 100644 src/connectors/hyperswap/amm-routes/executeSwap.ts create mode 100644 src/connectors/hyperswap/amm-routes/index.ts create mode 100644 src/connectors/hyperswap/amm-routes/poolInfo.ts create mode 100644 src/connectors/hyperswap/amm-routes/positionInfo.ts create mode 100644 src/connectors/hyperswap/amm-routes/quoteLiquidity.ts create mode 100644 src/connectors/hyperswap/amm-routes/quoteSwap.ts create mode 100644 src/connectors/hyperswap/amm-routes/removeLiquidity.ts create mode 100644 src/connectors/hyperswap/hyperswap.config.ts create mode 100644 src/connectors/hyperswap/hyperswap.contracts.ts create mode 100644 src/connectors/hyperswap/hyperswap.routes.ts create mode 100644 src/connectors/hyperswap/hyperswap.ts create mode 100644 src/connectors/hyperswap/hyperswap.utils.ts create mode 100644 src/connectors/hyperswap/hyperswap_v2_router_abi.json create mode 100644 src/connectors/hyperswap/schemas.ts create mode 100644 src/templates/chains/ethereum/hyperevm.yml create mode 100644 src/templates/connectors/hyperswap.yml create mode 100644 src/templates/tokens/ethereum/hyperevm.json create mode 100644 test/connectors/hyperswap/hyperswap.routes.test.ts diff --git a/src/app.ts b/src/app.ts index fb5908072c..66f44c0c3f 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 { register0xRoutes } from './connectors/0x/0x.routes'; +import { hyperswapRoutes } from './connectors/hyperswap/hyperswap.routes'; import { jupiterRoutes } from './connectors/jupiter/jupiter.routes'; import { meteoraRoutes } from './connectors/meteora/meteora.routes'; import { orcaRoutes } from './connectors/orca/orca.routes'; @@ -108,6 +109,10 @@ const swaggerOptions = { name: '/connector/pancakeswap', description: 'PancakeSwap EVM connector endpoints', }, + { + name: '/connector/hyperswap', + description: 'HyperSwap EVM connector endpoints', + }, ], components: { parameters: { @@ -275,6 +280,9 @@ const configureGatewayServer = () => { app.register(pancakeswapRoutes.amm, { prefix: '/connectors/pancakeswap/amm' }); app.register(pancakeswapRoutes.clmm, { prefix: '/connectors/pancakeswap/clmm' }); + // HyperSwap routes + app.register(hyperswapRoutes.amm, { prefix: '/connectors/hyperswap/amm' }); + // PancakeSwap Solana routes app.register(pancakeswapSolRoutes, { prefix: '/connectors/pancakeswap-sol' }); }; diff --git a/src/config/routes/getConnectors.ts b/src/config/routes/getConnectors.ts index 2b3ba56021..c8ed8dc20e 100644 --- a/src/config/routes/getConnectors.ts +++ b/src/config/routes/getConnectors.ts @@ -4,6 +4,7 @@ import { FastifyPluginAsync } from 'fastify'; import { PancakeswapConfig } from '#src/connectors/pancakeswap/pancakeswap.config'; import { ZeroXConfig } from '../../connectors/0x/0x.config'; +import { HyperswapConfig } from '../../connectors/hyperswap/hyperswap.config'; import { JupiterConfig } from '../../connectors/jupiter/jupiter.config'; import { MeteoraConfig } from '../../connectors/meteora/meteora.config'; import { OrcaConfig } from '../../connectors/orca/orca.config'; @@ -65,6 +66,12 @@ export const connectorsConfig = [ chain: PancakeswapConfig.chain, networks: [...PancakeswapConfig.networks], }, + { + name: 'hyperswap', + trading_types: [...HyperswapConfig.tradingTypes], + chain: HyperswapConfig.chain, + networks: [...HyperswapConfig.networks], + }, { name: 'pancakeswap-sol', trading_types: [...PancakeswapSolConfig.tradingTypes], diff --git a/src/connectors/hyperswap/amm-routes/addLiquidity.ts b/src/connectors/hyperswap/amm-routes/addLiquidity.ts new file mode 100644 index 0000000000..68e1411aed --- /dev/null +++ b/src/connectors/hyperswap/amm-routes/addLiquidity.ts @@ -0,0 +1,377 @@ +import { Contract } from '@ethersproject/contracts'; +import { Percent } from '@pancakeswap/sdk'; +import { Static } from '@sinclair/typebox'; +import { BigNumber, utils } from 'ethers'; +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 { logger } from '../../../services/logger'; +import { Hyperswap } from '../hyperswap'; +import { HyperswapConfig } from '../hyperswap.config'; +import { IHyperswapV2Router02ABI } from '../hyperswap.contracts'; +import { formatTokenAmount, getHyperswapPoolInfo } from '../hyperswap.utils'; +import { HyperswapAmmAddLiquidityRequest } from '../schemas'; + +import { getHyperswapAmmLiquidityQuote } from './quoteLiquidity'; + +// Default gas limit for AMM add liquidity operations +const AMM_ADD_LIQUIDITY_GAS_LIMIT = 500000; + +async function addLiquidity( + fastify: any, + network: string, + walletAddress: string, + poolAddress: string, + baseToken: string, + quoteToken: string, + baseTokenAmount: number, + quoteTokenAmount: number, + slippagePct: number = HyperswapConfig.config.slippagePct, + gasPrice?: string, + maxGas?: number, +): Promise { + const networkToUse = network; + + // Handle ETH->WETH wrapping if needed for baseToken + let actualBaseToken = baseToken; + let baseWrapTxHash = null; + if (baseToken === 'ETH') { + const hyperswap = await Hyperswap.getInstance(networkToUse); + const wethToken = await hyperswap.getToken('WETH'); + if (!wethToken) { + throw new Error('WETH token not found'); + } + + logger.info(`ETH detected as base token, wrapping ${baseTokenAmount} ETH to WETH first`); + + const wrapResult = await wrapEthereum(fastify, networkToUse, walletAddress, baseTokenAmount.toString()); + baseWrapTxHash = wrapResult.signature; + actualBaseToken = 'WETH'; + + logger.info(`Successfully wrapped ${baseTokenAmount} ETH to WETH, transaction hash: ${baseWrapTxHash}`); + } + + // Handle ETH->WETH wrapping if needed for quoteToken + let actualQuoteToken = quoteToken; + let quoteWrapTxHash = null; + if (quoteToken === 'ETH') { + const hyperswap = await Hyperswap.getInstance(networkToUse); + const wethToken = await hyperswap.getToken('WETH'); + if (!wethToken) { + throw new Error('WETH token not found'); + } + + logger.info(`ETH detected as quote token, wrapping ${quoteTokenAmount} ETH to WETH first`); + + const wrapResult = await wrapEthereum(fastify, networkToUse, walletAddress, quoteTokenAmount.toString()); + quoteWrapTxHash = wrapResult.signature; + actualQuoteToken = 'WETH'; + + logger.info(`Successfully wrapped ${quoteTokenAmount} ETH to WETH, transaction hash: ${quoteWrapTxHash}`); + } + + // Get quote first to calculate optimal amounts and get execution data + const quote = await getHyperswapAmmLiquidityQuote( + networkToUse, + poolAddress, + actualBaseToken, + actualQuoteToken, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + + // Get Ethereum instance + const ethereum = await Ethereum.getInstance(networkToUse); + + // Get wallet + const wallet = await ethereum.getWallet(walletAddress); + if (!wallet) { + throw new Error('Wallet not found'); + } + + // Get the router contract with signer + const router = new Contract(quote.routerAddress, IHyperswapV2Router02ABI.abi, wallet); + + // Calculate slippage-adjusted amounts + const slippageTolerance = new Percent(Math.floor(slippagePct * 100), 10000); + + const slippageMultiplier = new Percent(1).subtract(slippageTolerance); + + const baseTokenMinAmount = quote.rawBaseTokenAmount + .mul(slippageMultiplier.numerator.toString()) + .div(slippageMultiplier.denominator.toString()); + + const quoteTokenMinAmount = quote.rawQuoteTokenAmount + .mul(slippageMultiplier.numerator.toString()) + .div(slippageMultiplier.denominator.toString()); + + // Prepare the transaction parameters + const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now + + let tx; + + // Check if one of the tokens is WETH + if (quote.baseTokenObj.symbol === 'WETH') { + // Check allowance for quote token + const tokenContract = ethereum.getContract(quote.quoteTokenObj.address, wallet); + const allowance = await ethereum.getERC20Allowance( + tokenContract, + wallet, + quote.routerAddress, + quote.quoteTokenObj.decimals, + ); + + const currentAllowance = BigNumber.from(allowance.value); + logger.info( + `Current allowance for ${quote.quoteTokenObj.symbol}: ${formatTokenAmount(currentAllowance.toString(), quote.quoteTokenObj.decimals)}`, + ); + logger.info( + `Amount needed for ${quote.quoteTokenObj.symbol}: ${formatTokenAmount(quote.rawQuoteTokenAmount.toString(), quote.quoteTokenObj.decimals)}`, + ); + + // Check if allowance is sufficient + if (currentAllowance.lt(quote.rawQuoteTokenAmount)) { + throw new Error( + `Insufficient allowance for ${quote.quoteTokenObj.symbol}. Please approve at least ${formatTokenAmount(quote.rawQuoteTokenAmount.toString(), quote.quoteTokenObj.decimals)} ${quote.quoteTokenObj.symbol} for the Hyperswap router (${quote.routerAddress})`, + ); + } + + // Add liquidity ETH + Token + tx = await router.addLiquidityETH( + quote.quoteTokenObj.address, + quote.rawQuoteTokenAmount, + quoteTokenMinAmount, + baseTokenMinAmount, + walletAddress, + deadline, + { + value: quote.rawBaseTokenAmount, + gasLimit: 300000, + }, + ); + } else if (quote.quoteTokenObj.symbol === 'WETH') { + // Check allowance for base token + const tokenContract = ethereum.getContract(quote.baseTokenObj.address, wallet); + const allowance = await ethereum.getERC20Allowance( + tokenContract, + wallet, + quote.routerAddress, + quote.baseTokenObj.decimals, + ); + + const currentAllowance = BigNumber.from(allowance.value); + logger.info( + `Current allowance for ${quote.baseTokenObj.symbol}: ${formatTokenAmount(currentAllowance.toString(), quote.baseTokenObj.decimals)}`, + ); + logger.info( + `Amount needed for ${quote.baseTokenObj.symbol}: ${formatTokenAmount(quote.rawBaseTokenAmount.toString(), quote.baseTokenObj.decimals)}`, + ); + + // Check if allowance is sufficient + if (currentAllowance.lt(quote.rawBaseTokenAmount)) { + throw new Error( + `Insufficient allowance for ${quote.baseTokenObj.symbol}. Please approve at least ${formatTokenAmount(quote.rawBaseTokenAmount.toString(), quote.baseTokenObj.decimals)} ${quote.baseTokenObj.symbol} for the Hyperswap router (${quote.routerAddress})`, + ); + } + + // Add liquidity Token + ETH + // Convert gasPrice from wei to gwei if provided + const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; + const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + gasOptions.value = quote.rawQuoteTokenAmount; + + tx = await router.addLiquidityETH( + quote.baseTokenObj.address, + quote.rawBaseTokenAmount, + baseTokenMinAmount, + quoteTokenMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } else { + // Both tokens are ERC20 - check allowances for both + const baseTokenContract = ethereum.getContract(quote.baseTokenObj.address, wallet); + const baseAllowance = await ethereum.getERC20Allowance( + baseTokenContract, + wallet, + quote.routerAddress, + quote.baseTokenObj.decimals, + ); + + const quoteTokenContract = ethereum.getContract(quote.quoteTokenObj.address, wallet); + const quoteAllowance = await ethereum.getERC20Allowance( + quoteTokenContract, + wallet, + quote.routerAddress, + quote.quoteTokenObj.decimals, + ); + + const currentBaseAllowance = BigNumber.from(baseAllowance.value); + const currentQuoteAllowance = BigNumber.from(quoteAllowance.value); + + logger.info( + `Current base allowance for ${quote.baseTokenObj.symbol}: ${formatTokenAmount(currentBaseAllowance.toString(), quote.baseTokenObj.decimals)}`, + ); + logger.info( + `Amount needed for ${quote.baseTokenObj.symbol}: ${formatTokenAmount(quote.rawBaseTokenAmount.toString(), quote.baseTokenObj.decimals)}`, + ); + logger.info( + `Current quote allowance for ${quote.quoteTokenObj.symbol}: ${formatTokenAmount(currentQuoteAllowance.toString(), quote.quoteTokenObj.decimals)}`, + ); + logger.info( + `Amount needed for ${quote.quoteTokenObj.symbol}: ${formatTokenAmount(quote.rawQuoteTokenAmount.toString(), quote.quoteTokenObj.decimals)}`, + ); + + // Check if both allowances are sufficient + if (currentBaseAllowance.lt(quote.rawBaseTokenAmount)) { + throw new Error( + `Insufficient allowance for ${quote.baseTokenObj.symbol}. Please approve at least ${formatTokenAmount(quote.rawBaseTokenAmount.toString(), quote.baseTokenObj.decimals)} ${quote.baseTokenObj.symbol} for the Hyperswap router (${quote.routerAddress})`, + ); + } + + if (currentQuoteAllowance.lt(quote.rawQuoteTokenAmount)) { + throw new Error( + `Insufficient allowance for ${quote.quoteTokenObj.symbol}. Please approve at least ${formatTokenAmount(quote.rawQuoteTokenAmount.toString(), quote.quoteTokenObj.decimals)} ${quote.quoteTokenObj.symbol} for the Hyperswap router (${quote.routerAddress})`, + ); + } + + // Add liquidity Token + Token + // Convert gasPrice from wei to gwei if provided + const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; + const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + + tx = await router.addLiquidity( + quote.baseTokenObj.address, + quote.quoteTokenObj.address, + quote.rawBaseTokenAmount, + quote.rawQuoteTokenAmount, + baseTokenMinAmount, + quoteTokenMinAmount, + walletAddress, + deadline, + gasOptions, + ); + } + + // Wait for transaction confirmation + const receipt = await ethereum.handleTransactionExecution(tx); + + // Calculate gas fee + const gasFee = formatTokenAmount( + receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), + 18, // ETH has 18 decimals + ); + + return { + signature: receipt.transactionHash, + status: receipt.status, + data: { + fee: gasFee, + baseTokenAmountAdded: quote.baseTokenAmount, + quoteTokenAmountAdded: quote.quoteTokenAmount, + ...(baseWrapTxHash && { baseWrapTxHash }), + ...(quoteWrapTxHash && { quoteWrapTxHash }), + }, + }; +} + +export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { + await fastify.register(require('@fastify/sensible')); + + fastify.post<{ + Body: Static; + Reply: AddLiquidityResponseType; + }>( + '/add-liquidity', + { + schema: { + description: 'Add liquidity to a Hyperswap V2 pool', + tags: ['/connector/hyperswap'], + body: HyperswapAmmAddLiquidityRequest, + response: { + 200: AddLiquidityResponse, + }, + }, + }, + async (request) => { + try { + const { + network, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + walletAddress: requestedWalletAddress, + gasPrice, + maxGas, + } = request.body; + + // Validate essential parameters + if (!poolAddress || !baseTokenAmount || !quoteTokenAmount) { + throw fastify.httpErrors.badRequest('Missing required parameters'); + } + + const networkToUse = network; + + // Get wallet address - either from request or first available + 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}`); + } + + // Get pool information to determine tokens + const poolInfo = await getHyperswapPoolInfo(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, + gasPrice, + maxGas, + ); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + + // Handle specific user-actionable errors + if (e.message && e.message.includes('Insufficient allowance')) { + logger.error('Request error:', e); + throw fastify.httpErrors.badRequest('Invalid request'); + } + + // Handle insufficient funds errors + 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 add liquidity'); + } + }, + ); +}; + +export default addLiquidityRoute; diff --git a/src/connectors/hyperswap/amm-routes/executeSwap.ts b/src/connectors/hyperswap/amm-routes/executeSwap.ts new file mode 100644 index 0000000000..3f700f9a65 --- /dev/null +++ b/src/connectors/hyperswap/amm-routes/executeSwap.ts @@ -0,0 +1,328 @@ +import { BigNumber, Contract, utils } from 'ethers'; +import { FastifyPluginAsync } from 'fastify'; + +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; +import { getEthereumChainConfig } from '../../../chains/ethereum/ethereum.config'; +import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { Hyperswap } from '../hyperswap'; +import { HyperswapConfig } from '../hyperswap.config'; +import { getHyperswapV2RouterAddress, IHyperswapV2Router02ABI } from '../hyperswap.contracts'; +import { formatTokenAmount } from '../hyperswap.utils'; +import { HyperswapAmmExecuteSwapRequest } from '../schemas'; + +import { getHyperswapAmmQuote } from './quoteSwap'; + +// Default gas limit for AMM swap operations +const AMM_SWAP_GAS_LIMIT = 300000; + +export async function executeAmmSwap( + walletAddress: string, + network: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + slippagePct: number = HyperswapConfig.config.slippagePct, +): Promise { + const ethereum = await Ethereum.getInstance(network); + await ethereum.init(); + + const hyperswap = await Hyperswap.getInstance(network); + + // Find pool address + const poolAddress = await hyperswap.findDefaultPool(baseToken, quoteToken, 'amm'); + if (!poolAddress) { + throw httpErrors.notFound(`No AMM pool found for pair ${baseToken}-${quoteToken}`); + } + + // Get quote using the shared quote function + const { quote } = await getHyperswapAmmQuote(network, poolAddress, baseToken, quoteToken, amount, side, slippagePct); + + // Check if this is a hardware wallet + const isHardwareWallet = await ethereum.isHardwareWallet(walletAddress); + + // Get Router02 contract address + const routerAddress = getHyperswapV2RouterAddress(network); + + logger.info(`Executing swap using Router02:`); + logger.info(`Router address: ${routerAddress}`); + logger.info(`Pool address: ${poolAddress}`); + logger.info(`Input token: ${quote.inputToken.address}`); + logger.info(`Output token: ${quote.outputToken.address}`); + logger.info(`Side: ${side}`); + logger.info(`Path: ${quote.pathAddresses.join(' -> ')}`); + + // Check allowance for input token + const amountNeeded = side === 'SELL' ? quote.rawAmountIn : quote.rawMaxAmountIn; + + // Use provider for both hardware and regular wallets to check allowance + const tokenContract = ethereum.getContract(quote.inputToken.address, ethereum.provider); + const allowance = await tokenContract.allowance(walletAddress, routerAddress); + const currentAllowance = BigNumber.from(allowance); + + logger.info( + `Current allowance: ${formatTokenAmount(currentAllowance.toString(), quote.inputToken.decimals)} ${quote.inputToken.symbol}`, + ); + logger.info( + `Amount needed: ${formatTokenAmount(amountNeeded, quote.inputToken.decimals)} ${quote.inputToken.symbol}`, + ); + + // Check if allowance is sufficient + if (currentAllowance.lt(amountNeeded)) { + logger.error(`Insufficient allowance for ${quote.inputToken.symbol}`); + throw httpErrors.badRequest( + `Insufficient allowance for ${quote.inputToken.symbol}. Please approve at least ${formatTokenAmount(amountNeeded, quote.inputToken.decimals)} ${quote.inputToken.symbol} for the Hyperswap router (${routerAddress})`, + ); + } + + logger.info( + `Sufficient allowance exists: ${formatTokenAmount(currentAllowance.toString(), quote.inputToken.decimals)} ${quote.inputToken.symbol}`, + ); + + // Prepare transaction parameters + const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now + + let receipt; + + try { + if (isHardwareWallet) { + // Hardware wallet flow + logger.info(`Hardware wallet detected for ${walletAddress}. Building swap transaction for Ledger signing.`); + + const ledger = new EthereumLedger(); + const nonce = await ethereum.provider.getTransactionCount(walletAddress, 'latest'); + + // Build the swap transaction data + const iface = new utils.Interface(IHyperswapV2Router02ABI.abi); + let data; + + if (side === 'SELL') { + logger.info(`ExactTokensForTokens params:`); + logger.info(` amountIn: ${quote.rawAmountIn}`); + logger.info(` amountOutMin: ${quote.rawMinAmountOut}`); + logger.info(` path: ${quote.pathAddresses}`); + logger.info(` deadline: ${deadline}`); + + data = iface.encodeFunctionData('swapExactTokensForTokens', [ + quote.rawAmountIn, + quote.rawMinAmountOut, + quote.pathAddresses, + walletAddress, + deadline, + ]); + } else { + logger.info(`TokensForExactTokens params:`); + logger.info(` amountOut: ${quote.rawAmountOut}`); + logger.info(` amountInMax: ${quote.rawMaxAmountIn}`); + logger.info(` path: ${quote.pathAddresses}`); + logger.info(` deadline: ${deadline}`); + + data = iface.encodeFunctionData('swapTokensForExactTokens', [ + quote.rawAmountOut, + quote.rawMaxAmountIn, + quote.pathAddresses, + walletAddress, + deadline, + ]); + } + + // Get gas options using estimateGasPrice + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_SWAP_GAS_LIMIT); + + // Build unsigned transaction with gas parameters + const unsignedTx = { + to: routerAddress, + data: data, + nonce: nonce, + chainId: ethereum.chainId, + ...gasOptions, // Include gas parameters from prepareGasOptions + }; + + // Sign with Ledger + const signedTx = await ledger.signTransaction(walletAddress, unsignedTx as any); + + // Send the signed transaction + const txResponse = await ethereum.provider.sendTransaction(signedTx); + + logger.info(`Transaction sent: ${txResponse.hash}`); + + // Wait for confirmation with timeout + receipt = await ethereum.handleTransactionExecution(txResponse); + } else { + // Regular wallet flow + let wallet; + try { + wallet = await ethereum.getWallet(walletAddress); + } catch (err) { + logger.error(`Failed to load wallet: ${err.message}`); + throw httpErrors.internalServerError(`Failed to load wallet: ${err.message}`); + } + + const routerContract = new Contract(routerAddress, IHyperswapV2Router02ABI.abi, wallet); + + // Get gas options using estimateGasPrice + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_SWAP_GAS_LIMIT); + const txOptions: any = { ...gasOptions }; + + logger.info(`Using gas options: ${JSON.stringify(txOptions)}`); + + let tx; + if (side === 'SELL') { + // swapExactTokensForTokens - we know the exact input amount + logger.info(`ExactTokensForTokens params:`); + logger.info(` amountIn: ${quote.rawAmountIn}`); + logger.info(` amountOutMin: ${quote.rawMinAmountOut}`); + logger.info(` path: ${quote.pathAddresses}`); + logger.info(` deadline: ${deadline}`); + + tx = await routerContract.swapExactTokensForTokens( + quote.rawAmountIn, + quote.rawMinAmountOut, + quote.pathAddresses, + walletAddress, + deadline, + txOptions, + ); + } else { + // swapTokensForExactTokens - we know the exact output amount + logger.info(`TokensForExactTokens params:`); + logger.info(` amountOut: ${quote.rawAmountOut}`); + logger.info(` amountInMax: ${quote.rawMaxAmountIn}`); + logger.info(` path: ${quote.pathAddresses}`); + logger.info(` deadline: ${deadline}`); + + tx = await routerContract.swapTokensForExactTokens( + quote.rawAmountOut, + quote.rawMaxAmountIn, + quote.pathAddresses, + walletAddress, + deadline, + txOptions, + ); + } + + logger.info(`Transaction sent: ${tx.hash}`); + + // Wait for transaction confirmation + receipt = await ethereum.handleTransactionExecution(tx); + } + + // Check if the transaction was successful + if (receipt.status === 0) { + logger.error(`Transaction failed on-chain. Receipt: ${JSON.stringify(receipt)}`); + throw httpErrors.internalServerError( + 'Transaction reverted on-chain. This could be due to slippage, insufficient funds, or other blockchain issues.', + ); + } + + logger.info(`Transaction confirmed: ${receipt.transactionHash}`); + logger.info(`Gas used: ${receipt.gasUsed.toString()}`); + + // Calculate amounts using quote values + const amountIn = quote.estimatedAmountIn; + const amountOut = quote.estimatedAmountOut; + + // Calculate balance changes as numbers + const baseTokenBalanceChange = side === 'BUY' ? amountOut : -amountIn; + const quoteTokenBalanceChange = side === 'BUY' ? -amountIn : amountOut; + + // Calculate gas fee (formatTokenAmount already returns a number) + const gasFee = formatTokenAmount( + receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), + 18, // ETH has 18 decimals + ); + + // Determine token addresses for computed fields + const tokenIn = quote.inputToken.address; + const tokenOut = quote.outputToken.address; + + return { + signature: receipt.transactionHash, + status: receipt.status, + data: { + tokenIn, + tokenOut, + amountIn, + amountOut, + fee: gasFee, + baseTokenBalanceChange, + quoteTokenBalanceChange, + }, + }; + } catch (error) { + logger.error(`Swap execution error: ${error.message}`); + + // Handle specific error cases + if (error.message && error.message.includes('insufficient funds')) { + throw httpErrors.badRequest( + 'Insufficient funds for transaction. Please ensure you have enough ETH to cover gas costs.', + ); + } else if (error.message.includes('rejected on Ledger')) { + throw httpErrors.badRequest('Transaction rejected on Ledger device'); + } else if (error.message.includes('Ledger device is locked')) { + throw httpErrors.badRequest(error.message); + } else if (error.message.includes('Wrong app is open')) { + throw httpErrors.badRequest(error.message); + } + + // Re-throw if already a fastify error + if (error.statusCode) { + throw error; + } + + throw httpErrors.internalServerError(`Failed to execute swap: ${error.message}`); + } +} + +export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: ExecuteSwapRequestType; + Reply: SwapExecuteResponseType; + }>( + '/execute-swap', + { + schema: { + description: 'Execute a swap on Hyperswap V2 AMM using Router02', + tags: ['/connector/hyperswap'], + body: HyperswapAmmExecuteSwapRequest, + response: { 200: SwapExecuteResponse }, + }, + }, + async (request) => { + try { + const ethereumConfig = getEthereumChainConfig(); + const { + walletAddress = ethereumConfig.defaultWallet, + network = ethereumConfig.defaultNetwork, + baseToken, + quoteToken, + amount, + side = 'SELL', + slippagePct, + } = request.body as typeof HyperswapAmmExecuteSwapRequest._type; + + return await executeAmmSwap( + walletAddress, + network, + baseToken, + quoteToken || '', // Handle optional quoteToken + amount, + side as 'BUY' | 'SELL', + slippagePct, + ); + } catch (e) { + if (e.statusCode) throw e; + logger.error('Error executing swap:', e); + throw httpErrors.internalServerError(e.message || 'Internal server error'); + } + }, + ); +}; + +// Export executeSwap alias for uniform chain route imports +export { executeAmmSwap as executeSwap }; + +export default executeSwapRoute; diff --git a/src/connectors/hyperswap/amm-routes/index.ts b/src/connectors/hyperswap/amm-routes/index.ts new file mode 100644 index 0000000000..118c0c3370 --- /dev/null +++ b/src/connectors/hyperswap/amm-routes/index.ts @@ -0,0 +1,21 @@ +import { FastifyPluginAsync } from 'fastify'; + +import addLiquidityRoute from './addLiquidity'; +import executeSwapRoute from './executeSwap'; +import poolInfoRoute from './poolInfo'; +import positionInfoRoute from './positionInfo'; +import quoteLiquidityRoute from './quoteLiquidity'; +import quoteSwapRoute from './quoteSwap'; +import removeLiquidityRoute from './removeLiquidity'; + +export const hyperswapAmmRoutes: FastifyPluginAsync = async (fastify) => { + await fastify.register(poolInfoRoute); + await fastify.register(positionInfoRoute); + await fastify.register(quoteSwapRoute); + await fastify.register(quoteLiquidityRoute); + await fastify.register(executeSwapRoute); + await fastify.register(addLiquidityRoute); + await fastify.register(removeLiquidityRoute); +}; + +export default hyperswapAmmRoutes; diff --git a/src/connectors/hyperswap/amm-routes/poolInfo.ts b/src/connectors/hyperswap/amm-routes/poolInfo.ts new file mode 100644 index 0000000000..b438522707 --- /dev/null +++ b/src/connectors/hyperswap/amm-routes/poolInfo.ts @@ -0,0 +1,104 @@ +import { Contract } from '@ethersproject/contracts'; +import { FastifyPluginAsync } from 'fastify'; + +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/amm-schema'; +import { logger } from '../../../services/logger'; +import { Hyperswap } from '../hyperswap'; +import { IHyperswapV2PairABI } from '../hyperswap.contracts'; +import { formatTokenAmount } from '../hyperswap.utils'; +import { HyperswapAmmGetPoolInfoRequest } from '../schemas'; + +export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: GetPoolInfoRequestType; + Reply: Record; + }>( + '/pool-info', + { + schema: { + description: 'Get AMM pool information from Hyperswap V2', + tags: ['/connector/hyperswap'], + querystring: HyperswapAmmGetPoolInfoRequest, + response: { + 200: PoolInfoSchema, + }, + }, + }, + async (request): Promise => { + try { + const { poolAddress } = request.query; + const network = request.query.network; + + const ethereum = await Ethereum.getInstance(network); + const hyperswap = await Hyperswap.getInstance(network); + + // For Hyperswap, 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, IHyperswapV2PairABI.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 hyperswap.getToken(token0Address); + const token1 = await hyperswap.getToken(token1Address); + + if (!token0 || !token1) { + throw new Error('Could not find tokens for pool'); + } + + // Get V2 pair data + const v2Pair = await hyperswap.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, // Hyperswap V2 fee is fixed at 0.3% + price: price, + baseTokenAmount: baseTokenAmount, + quoteTokenAmount: quoteTokenAmount, + }; + } catch (e) { + logger.error(`Error in pool-info route: ${e.message}`); + if (e.stack) { + logger.debug(`Stack trace: ${e.stack}`); + } + + // Return appropriate error based on the error message + if (e.statusCode) { + throw e; // Already a formatted Fastify error + } 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')) { + logger.error('Not found error:', e); + throw fastify.httpErrors.notFound('Resource not found'); + } else { + logger.error('Unexpected error fetching pool info:', e); + throw fastify.httpErrors.internalServerError('Failed to fetch pool info'); + } + } + }, + ); +}; + +export default poolInfoRoute; diff --git a/src/connectors/hyperswap/amm-routes/positionInfo.ts b/src/connectors/hyperswap/amm-routes/positionInfo.ts new file mode 100644 index 0000000000..b7fc121b19 --- /dev/null +++ b/src/connectors/hyperswap/amm-routes/positionInfo.ts @@ -0,0 +1,176 @@ +import { Contract } from '@ethersproject/contracts'; +import { BigNumber } from 'ethers'; +import { FastifyPluginAsync } from 'fastify'; + +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { + GetPositionInfoRequestType, + GetPositionInfoRequest, + PositionInfo, + PositionInfoSchema, +} from '../../../schemas/amm-schema'; +import { logger } from '../../../services/logger'; +import { Hyperswap } from '../hyperswap'; +import { IHyperswapV2PairABI } from '../hyperswap.contracts'; +import { formatTokenAmount } from '../hyperswap.utils'; + +export async function checkLPAllowance( + ethereum: any, + wallet: any, + poolAddress: string, + routerAddress: string, + requiredAmount: BigNumber, +): Promise { + const lpTokenContract = ethereum.getContract(poolAddress, wallet); + const lpAllowance = await ethereum.getERC20Allowance( + lpTokenContract, + wallet, + routerAddress, + 18, // LP tokens typically have 18 decimals + ); + const currentLpAllowance = BigNumber.from(lpAllowance.value); + if (currentLpAllowance.lt(requiredAmount)) { + throw new Error( + `Insufficient LP token allowance. Please approve at least ${formatTokenAmount(requiredAmount.toString(), 18)} LP tokens (${poolAddress}) for the Hyperswap router (${routerAddress})`, + ); + } +} + +export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { + const walletAddressExample = await Ethereum.getWalletAddressExample(); + + fastify.get<{ + Querystring: GetPositionInfoRequestType; + Reply: PositionInfo; + }>( + '/position-info', + { + schema: { + description: 'Get position information for a Hyperswap V2 pool', + tags: ['/connector/hyperswap'], + querystring: { + ...GetPositionInfoRequest, + properties: { + network: { type: 'string', default: 'base' }, + walletAddress: { type: 'string', examples: [walletAddressExample] }, + poolAddress: { + type: 'string', + examples: [''], + }, + baseToken: { type: 'string', examples: ['WETH'] }, + quoteToken: { type: 'string', examples: ['USDC'] }, + }, + }, + response: { + 200: PositionInfoSchema, + }, + }, + }, + async (request) => { + try { + const { network, poolAddress, walletAddress: requestedWalletAddress } = request.query; + + const networkToUse = network; + + // Validate essential parameters + if (!poolAddress) { + throw fastify.httpErrors.badRequest('Pool address is required'); + } + + // Get Hyperswap and Ethereum instances + const hyperswap = await Hyperswap.getInstance(networkToUse); + const ethereum = await Ethereum.getInstance(networkToUse); + + // Get wallet address - either from request or first available + let walletAddress = requestedWalletAddress; + if (!walletAddress) { + walletAddress = await hyperswap.getFirstWalletAddress(); + if (!walletAddress) { + throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet found'); + } + logger.info(`Using first available wallet address: ${walletAddress}`); + } + + // Get the pair contract + const pairContract = new Contract(poolAddress, IHyperswapV2PairABI.abi, ethereum.provider); + + // Get LP token balance for the wallet + const lpBalance = await pairContract.balanceOf(walletAddress); + + // Get token addresses from the pair + const [token0, token1] = await Promise.all([pairContract.token0(), pairContract.token1()]); + + // Get token objects by address + const baseTokenObj = await hyperswap.getToken(token0); + const quoteTokenObj = await hyperswap.getToken(token1); + + if (!baseTokenObj || !quoteTokenObj) { + throw fastify.httpErrors.badRequest('Token information not found for pool'); + } + + // If no position, return early + if (lpBalance.isZero()) { + return { + poolAddress, + walletAddress, + baseTokenAddress: baseTokenObj.address, + quoteTokenAddress: quoteTokenObj.address, + lpTokenAmount: 0, + baseTokenAmount: 0, + quoteTokenAmount: 0, + price: 0, + }; + } + + // Get total supply and reserves + const [totalSupply, reserves] = await Promise.all([pairContract.totalSupply(), pairContract.getReserves()]); + + // Determine which token is base and which is quote + const token0IsBase = token0.toLowerCase() === baseTokenObj.address.toLowerCase(); + + // Calculate token amounts + 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); + + // Calculate price (quoteToken per baseToken) + const baseTokenAmountFloat = formatTokenAmount(baseTokenReserve.toString(), baseTokenObj.decimals); + const quoteTokenAmountFloat = formatTokenAmount(quoteTokenReserve.toString(), quoteTokenObj.decimals); + const price = quoteTokenAmountFloat / baseTokenAmountFloat; + + // Format for response + logger.info(`Raw LP balance: ${lpBalance.toString()}`); + logger.info(`Total supply: ${totalSupply.toString()}`); + + const formattedLpAmount = formatTokenAmount(lpBalance.toString(), 18); // LP tokens have 18 decimals + const formattedBaseAmount = formatTokenAmount(userBaseTokenAmount.toString(), baseTokenObj.decimals); + const formattedQuoteAmount = formatTokenAmount(userQuoteTokenAmount.toString(), quoteTokenObj.decimals); + + logger.info(`Formatted LP amount: ${formattedLpAmount}`); + logger.info(`Formatted base amount: ${formattedBaseAmount}`); + logger.info(`Formatted quote amount: ${formattedQuoteAmount}`); + + return { + poolAddress, + walletAddress, + baseTokenAddress: baseTokenObj.address, + quoteTokenAddress: quoteTokenObj.address, + lpTokenAmount: formattedLpAmount, + baseTokenAmount: formattedBaseAmount, + quoteTokenAmount: formattedQuoteAmount, + price, + }; + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + throw fastify.httpErrors.internalServerError('Failed to get position info'); + } + }, + ); +}; + +export default positionInfoRoute; diff --git a/src/connectors/hyperswap/amm-routes/quoteLiquidity.ts b/src/connectors/hyperswap/amm-routes/quoteLiquidity.ts new file mode 100644 index 0000000000..27146a3c70 --- /dev/null +++ b/src/connectors/hyperswap/amm-routes/quoteLiquidity.ts @@ -0,0 +1,257 @@ +import { Contract } from '@ethersproject/contracts'; +import { BigNumber } from 'ethers'; +import { FastifyPluginAsync } from 'fastify'; + +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { + QuoteLiquidityRequestType, + QuoteLiquidityRequest, + QuoteLiquidityResponseType, + QuoteLiquidityResponse, +} from '../../../schemas/amm-schema'; +import { logger } from '../../../services/logger'; +import { Hyperswap } from '../hyperswap'; +import { IHyperswapV2PairABI, getHyperswapV2RouterAddress } from '../hyperswap.contracts'; +import { formatTokenAmount, getHyperswapPoolInfo } from '../hyperswap.utils'; + +export async function getHyperswapAmmLiquidityQuote( + network: string, + poolAddress?: string, + baseToken?: string, + quoteToken?: string, + baseTokenAmount?: number, + quoteTokenAmount?: number, + _slippagePct?: number, +): Promise<{ + baseLimited: boolean; + baseTokenAmount: number; + quoteTokenAmount: number; + baseTokenAmountMax: number; + quoteTokenAmountMax: number; + baseTokenObj: any; + quoteTokenObj: any; + poolAddress?: string; + rawBaseTokenAmount: BigNumber; + rawQuoteTokenAmount: BigNumber; + routerAddress: string; +}> { + const networkToUse = network; + + // Validate essential parameters + if (!baseToken || !quoteToken) { + throw new Error('Base token and quote token are required'); + } + + if (baseTokenAmount === undefined && quoteTokenAmount === undefined) { + throw new Error('At least one token amount must be provided'); + } + + // Get Hyperswap and Ethereum instances + const hyperswap = await Hyperswap.getInstance(networkToUse); + const ethereum = await Ethereum.getInstance(networkToUse); + + // Resolve tokens + const baseTokenObj = await hyperswap.getToken(baseToken); + const quoteTokenObj = await hyperswap.getToken(quoteToken); + + if (!baseTokenObj || !quoteTokenObj) { + throw new Error(`Token not found: ${!baseTokenObj ? baseToken : quoteToken}`); + } + + // Find pool address if not provided + let poolAddressToUse = poolAddress; + let existingPool = true; + + if (!poolAddressToUse) { + poolAddressToUse = await hyperswap.findDefaultPool(baseToken, quoteToken, 'amm'); + + if (!poolAddressToUse) { + existingPool = false; + logger.info(`No existing pool found for ${baseToken}-${quoteToken}, providing theoretical quote`); + } + } + + let baseTokenAmountOptimal = baseTokenAmount; + let quoteTokenAmountOptimal = quoteTokenAmount; + let baseLimited = false; + + if (existingPool) { + // Get existing pool data to calculate optimal amounts + const pairContract = new Contract(poolAddressToUse, IHyperswapV2PairABI.abi, ethereum.provider); + + // Get token addresses and reserves + const [token0, token1, reserves] = await Promise.all([ + pairContract.token0(), + pairContract.token1(), + pairContract.getReserves(), + ]); + + // Determine which token is base and which is quote + const token0IsBase = token0.toLowerCase() === baseTokenObj.address.toLowerCase(); + + const reserve0 = reserves[0]; + const reserve1 = reserves[1]; + + const baseReserve = token0IsBase ? reserve0 : reserve1; + const quoteReserve = token0IsBase ? reserve1 : reserve0; + + // Convert amounts to BigNumber with proper decimals + const baseAmountRaw = baseTokenAmount + ? BigNumber.from(Math.floor(baseTokenAmount * Math.pow(10, baseTokenObj.decimals)).toString()) + : null; + + const quoteAmountRaw = quoteTokenAmount + ? BigNumber.from(Math.floor(quoteTokenAmount * Math.pow(10, quoteTokenObj.decimals)).toString()) + : null; + + // Calculate optimal amounts based on the reserves ratio + if (baseAmountRaw && quoteAmountRaw) { + // Both amounts provided, check which one is limiting + const quoteOptimal = baseAmountRaw.mul(quoteReserve).div(baseReserve); + + if (quoteOptimal.lte(quoteAmountRaw)) { + // Base token is the limiting factor + baseLimited = true; + quoteTokenAmountOptimal = formatTokenAmount(quoteOptimal.toString(), quoteTokenObj.decimals); + } else { + // Quote token is the limiting factor + baseLimited = false; + const baseOptimal = quoteAmountRaw.mul(baseReserve).div(quoteReserve); + baseTokenAmountOptimal = formatTokenAmount(baseOptimal.toString(), baseTokenObj.decimals); + } + } else if (baseAmountRaw) { + // Only base amount provided, calculate quote amount + const quoteOptimal = baseReserve.isZero() ? BigNumber.from(0) : baseAmountRaw.mul(quoteReserve).div(baseReserve); + + quoteTokenAmountOptimal = formatTokenAmount(quoteOptimal.toString(), quoteTokenObj.decimals); + baseLimited = true; + } else if (quoteAmountRaw) { + // Only quote amount provided, calculate base amount + const baseOptimal = quoteReserve.isZero() ? BigNumber.from(0) : quoteAmountRaw.mul(baseReserve).div(quoteReserve); + + baseTokenAmountOptimal = formatTokenAmount(baseOptimal.toString(), baseTokenObj.decimals); + baseLimited = false; + } + } else { + // No existing pool, the ratio will be set by the first liquidity provider + if (baseTokenAmount && quoteTokenAmount) { + // Both amounts provided, keeping them as is + baseLimited = false; + } else if (baseTokenAmount) { + // Only base amount provided, need quote amount + throw new Error('For new pools, both base and quote token amounts must be provided'); + } else if (quoteTokenAmount) { + // Only quote amount provided, need base amount + throw new Error('For new pools, both base and quote token amounts must be provided'); + } + } + + // Get router address + const routerAddress = getHyperswapV2RouterAddress(networkToUse); + + // Convert final amounts to raw values for execution + const rawBaseTokenAmount = BigNumber.from( + Math.floor(baseTokenAmountOptimal * Math.pow(10, baseTokenObj.decimals)).toString(), + ); + + const rawQuoteTokenAmount = BigNumber.from( + Math.floor(quoteTokenAmountOptimal * Math.pow(10, quoteTokenObj.decimals)).toString(), + ); + + return { + baseLimited, + baseTokenAmount: baseTokenAmountOptimal, + quoteTokenAmount: quoteTokenAmountOptimal, + baseTokenAmountMax: baseTokenAmount || baseTokenAmountOptimal, + quoteTokenAmountMax: quoteTokenAmount || quoteTokenAmountOptimal, + baseTokenObj, + quoteTokenObj, + poolAddress: poolAddressToUse, + rawBaseTokenAmount, + rawQuoteTokenAmount, + routerAddress, + }; +} + +export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { + await fastify.register(require('@fastify/sensible')); + fastify.get<{ + Querystring: QuoteLiquidityRequestType; + Reply: QuoteLiquidityResponseType; + }>( + '/quote-liquidity', + { + schema: { + description: 'Get liquidity quote for a Hyperswap V2 pool', + tags: ['/connector/hyperswap'], + querystring: { + ...QuoteLiquidityRequest, + properties: { + ...QuoteLiquidityRequest.properties, + network: { type: 'string', default: 'base' }, + poolAddress: { + type: 'string', + examples: [''], + }, + baseToken: { type: 'string', examples: ['WETH'] }, + quoteToken: { type: 'string', examples: ['USDC'] }, + baseTokenAmount: { type: 'number', examples: [0.001] }, + quoteTokenAmount: { type: 'number', examples: [2.5] }, + slippagePct: { type: 'number', examples: [1] }, + }, + }, + response: { + 200: QuoteLiquidityResponse, + }, + }, + }, + async (request) => { + try { + const { network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.query; + + if (!poolAddress) { + throw fastify.httpErrors.badRequest('Pool address is required'); + } + + // Get pool information to determine tokens + const poolInfo = await getHyperswapPoolInfo(poolAddress, network, 'amm'); + if (!poolInfo) { + throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); + } + + const baseToken = poolInfo.baseTokenAddress; + const quoteToken = poolInfo.quoteTokenAddress; + + const quote = await getHyperswapAmmLiquidityQuote( + network, + poolAddress, + baseToken, + quoteToken, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + + // Use standard gas limit for liquidity operations + const computeUnits = 500000; + + return { + baseLimited: quote.baseLimited, + baseTokenAmount: quote.baseTokenAmount, + quoteTokenAmount: quote.quoteTokenAmount, + baseTokenAmountMax: quote.baseTokenAmountMax, + quoteTokenAmountMax: quote.quoteTokenAmountMax, + computeUnits, + }; + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + throw fastify.httpErrors.internalServerError('Failed to get liquidity quote'); + } + }, + ); +}; + +export default quoteLiquidityRoute; diff --git a/src/connectors/hyperswap/amm-routes/quoteSwap.ts b/src/connectors/hyperswap/amm-routes/quoteSwap.ts new file mode 100644 index 0000000000..b6b8650027 --- /dev/null +++ b/src/connectors/hyperswap/amm-routes/quoteSwap.ts @@ -0,0 +1,416 @@ +import { Token, CurrencyAmount, Percent, TradeType } from '@pancakeswap/sdk'; +import { Route as V2Route, Trade as V2Trade } from '@pancakeswap/v2-sdk'; +import { BigNumber } from 'ethers'; +import { FastifyPluginAsync } from 'fastify'; + +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { + QuoteSwapRequestType, + QuoteSwapResponseType, + QuoteSwapRequest, + QuoteSwapResponse, +} from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { Hyperswap } from '../hyperswap'; +import { HyperswapConfig } from '../hyperswap.config'; +import { formatTokenAmount, getHyperswapPoolInfo } from '../hyperswap.utils'; + +async function quoteAmmSwap( + hyperswap: Hyperswap, + poolAddress: string, + baseToken: Token, + quoteToken: Token, + amount: number, + side: 'BUY' | 'SELL', + slippagePct: number = HyperswapConfig.config.slippagePct, +): Promise { + try { + // Get the V2 pair + const pair = await hyperswap.getV2Pool(baseToken, quoteToken, poolAddress); + if (!pair) { + throw httpErrors.notFound(`Pool not found for ${baseToken.symbol}-${quoteToken.symbol}`); + } + + // Determine which token is being traded (exact in/out) + const exactIn = side === 'SELL'; + const [inputToken, outputToken] = exactIn ? [baseToken, quoteToken] : [quoteToken, baseToken]; + + // Create a route for the trade + const route = new V2Route([pair], inputToken, outputToken); + + // Create the V2 trade + let trade; + if (exactIn) { + // For SELL (exactIn), we use the input amount and EXACT_INPUT trade type + const inputAmount = CurrencyAmount.fromRawAmount( + inputToken, + Math.floor(amount * Math.pow(10, inputToken.decimals)).toString(), + ); + trade = new V2Trade(route, inputAmount, TradeType.EXACT_INPUT); + } else { + // For BUY (exactOut), we use the output amount and EXACT_OUTPUT trade type + const outputAmount = CurrencyAmount.fromRawAmount( + outputToken, + Math.floor(amount * Math.pow(10, outputToken.decimals)).toString(), + ); + trade = new V2Trade(route, outputAmount, TradeType.EXACT_OUTPUT); + } + + // Calculate slippage-adjusted amounts + const slippageTolerance = new Percent(Math.floor(slippagePct * 100), 10000); + + const minAmountOut = exactIn + ? trade.minimumAmountOut(slippageTolerance).quotient.toString() + : trade.outputAmount.quotient.toString(); + + const maxAmountIn = exactIn + ? trade.inputAmount.quotient.toString() + : trade.maximumAmountIn(slippageTolerance).quotient.toString(); + + // Calculate amounts - trade object has inputAmount and outputAmount for both types + const estimatedAmountIn = formatTokenAmount(trade.inputAmount.quotient.toString(), inputToken.decimals); + + const estimatedAmountOut = formatTokenAmount(trade.outputAmount.quotient.toString(), outputToken.decimals); + + const minAmountOutValue = formatTokenAmount(minAmountOut, outputToken.decimals); + const maxAmountInValue = formatTokenAmount(maxAmountIn, inputToken.decimals); + + // Calculate price impact + const priceImpact = parseFloat(trade.priceImpact.toSignificant(4)); + + return { + poolAddress, + estimatedAmountIn, + estimatedAmountOut, + minAmountOut: minAmountOutValue, + maxAmountIn: maxAmountInValue, + priceImpact, + inputToken, + outputToken, + trade, + // Add raw values for execution + rawAmountIn: trade.inputAmount.quotient.toString(), + rawAmountOut: trade.outputAmount.quotient.toString(), + rawMinAmountOut: minAmountOut, + rawMaxAmountIn: maxAmountIn, + pathAddresses: trade.route.path.map((token) => token.address), + }; + } catch (error) { + logger.error(`Error quoting AMM swap: ${error.message}`); + // Check for insufficient reserves error from Hyperswap SDK + if (error.isInsufficientReservesError || error.name === 'InsufficientReservesError') { + throw httpErrors.badRequest(`Insufficient liquidity in pool for ${baseToken.symbol}-${quoteToken.symbol}`); + } + throw error; + } +} + +export async function getHyperswapAmmQuote( + network: string, + poolAddress: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + slippagePct: number = HyperswapConfig.config.slippagePct, +): Promise<{ + quote: any; + Hyperswap: any; + ethereum: any; + baseTokenObj: any; + quoteTokenObj: any; +}> { + // Get instances + const hyperswap = await Hyperswap.getInstance(network); + const ethereum = await Ethereum.getInstance(network); + + if (!ethereum.ready()) { + logger.info('Ethereum instance not ready, initializing...'); + await ethereum.init(); + } + + // Resolve tokens + const baseTokenObj = await hyperswap.getToken(baseToken); + const quoteTokenObj = await hyperswap.getToken(quoteToken); + + if (!baseTokenObj) { + logger.error(`Base token not found: ${baseToken}`); + throw httpErrors.notFound(`Base token not found: ${baseToken}`); + } + + if (!quoteTokenObj) { + logger.error(`Quote token not found: ${quoteToken}`); + throw httpErrors.notFound(`Quote token not found: ${quoteToken}`); + } + + logger.info(`Base token: ${baseTokenObj.symbol}, address=${baseTokenObj.address}, decimals=${baseTokenObj.decimals}`); + logger.info( + `Quote token: ${quoteTokenObj.symbol}, address=${quoteTokenObj.address}, decimals=${quoteTokenObj.decimals}`, + ); + + // Get the quote + const quote = await quoteAmmSwap( + hyperswap, + poolAddress, + baseTokenObj, + quoteTokenObj, + amount, + side as 'BUY' | 'SELL', + slippagePct, + ); + + if (!quote) { + throw httpErrors.internalServerError('Failed to get swap quote'); + } + + return { + quote, + Hyperswap: hyperswap, + ethereum, + baseTokenObj, + quoteTokenObj, + }; +} + +async function formatSwapQuote( + network: string, + poolAddress: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + slippagePct: number = HyperswapConfig.config.slippagePct, +): Promise { + logger.info( + `formatSwapQuote: poolAddress=${poolAddress}, baseToken=${baseToken}, quoteToken=${quoteToken}, amount=${amount}, side=${side}, network=${network}`, + ); + + try { + // Use the extracted quote function + const { quote, ethereum } = await getHyperswapAmmQuote( + network, + poolAddress, + baseToken, + quoteToken, + amount, + side, + slippagePct, + ); + + logger.info( + `Quote result: estimatedAmountIn=${quote.estimatedAmountIn}, estimatedAmountOut=${quote.estimatedAmountOut}`, + ); + + // Calculate balance changes based on which tokens are being swapped + const baseTokenBalanceChange = side === 'BUY' ? quote.estimatedAmountOut : -quote.estimatedAmountIn; + const quoteTokenBalanceChange = side === 'BUY' ? -quote.estimatedAmountIn : quote.estimatedAmountOut; + + logger.info( + `Balance changes: baseTokenBalanceChange=${baseTokenBalanceChange}, quoteTokenBalanceChange=${quoteTokenBalanceChange}`, + ); + + // Get gas estimate for V2 swap + const pathLength = quote.pathAddresses.length; + const estimatedGasValue = pathLength * 150000; // Approximate gas per swap + const gasPrice = await ethereum.provider.getGasPrice(); + logger.info(`Gas price from provider: ${gasPrice.toString()}`); + + // Calculate gas cost + const estimatedGasBN = BigNumber.from(estimatedGasValue.toString()); + const gasCostRaw = gasPrice.mul(estimatedGasBN); + const gasCost = formatTokenAmount(gasCostRaw.toString(), 18); // ETH has 18 decimals + logger.info(`Gas cost: ${gasCost} ETH`); + + // Calculate price based on side + // For SELL: price = quote received / base sold + // For BUY: price = quote needed / base received + const price = + side === 'SELL' + ? quote.estimatedAmountOut / quote.estimatedAmountIn + : quote.estimatedAmountIn / quote.estimatedAmountOut; + + // Format gas price as Gwei + const gasPriceGwei = formatTokenAmount(gasPrice.toString(), 9); // Convert to Gwei + logger.info(`Gas price in Gwei: ${gasPriceGwei}`); + + // Calculate price impact percentage + const priceImpactPct = quote.priceImpact; + + // Determine token addresses for computed fields + const tokenIn = quote.inputToken.address; + const tokenOut = quote.outputToken.address; + + return { + // Base QuoteSwapResponse fields in correct order + poolAddress, + tokenIn, + tokenOut, + amountIn: quote.estimatedAmountIn, + amountOut: quote.estimatedAmountOut, + price, + slippagePct, + minAmountOut: quote.minAmountOut, + maxAmountIn: quote.maxAmountIn, + // AMM-specific fields + priceImpactPct, + }; + } catch (error) { + logger.error(`Error formatting swap quote: ${error.message}`); + if (error.stack) { + logger.debug(`Stack trace: ${error.stack}`); + } + throw error; + } +} + +export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { + // Import the httpErrors plugin to ensure it's available + await fastify.register(require('@fastify/sensible')); + + fastify.get<{ + Querystring: QuoteSwapRequestType; + Reply: QuoteSwapResponseType; + }>( + '/quote-swap', + { + schema: { + description: 'Get swap quote for Hyperswap V2 AMM', + tags: ['/connector/hyperswap'], + querystring: { + ...QuoteSwapRequest, + properties: { + ...QuoteSwapRequest.properties, + network: { type: 'string', default: 'base' }, + baseToken: { type: 'string', examples: ['WETH'] }, + quoteToken: { type: 'string', examples: ['USDC'] }, + amount: { type: 'number', examples: [0.001] }, + side: { type: 'string', enum: ['BUY', 'SELL'], examples: ['SELL'] }, + poolAddress: { type: 'string', examples: [''] }, + slippagePct: { type: 'number', examples: [1] }, + }, + }, + response: { 200: QuoteSwapResponse }, + }, + }, + async (request) => { + try { + const { network, poolAddress, baseToken, quoteToken, amount, side, slippagePct } = request.query; + + const networkToUse = network; + + // Validate essential parameters + if (!baseToken || !amount || !side) { + throw httpErrors.badRequest('baseToken, amount, and side are required'); + } + + const hyperswap = await Hyperswap.getInstance(networkToUse); + + let poolAddressToUse = poolAddress; + let baseTokenToUse: string; + let quoteTokenToUse: string; + + if (poolAddressToUse) { + // Pool address provided, get pool info to determine tokens + const poolInfo = await getHyperswapPoolInfo(poolAddressToUse, networkToUse, 'amm'); + if (!poolInfo) { + throw httpErrors.notFound(`Pool not found: ${poolAddressToUse}`); + } + + // Determine which token is base and which is quote based on the provided baseToken + if (baseToken === poolInfo.baseTokenAddress) { + baseTokenToUse = poolInfo.baseTokenAddress; + quoteTokenToUse = poolInfo.quoteTokenAddress; + } else if (baseToken === poolInfo.quoteTokenAddress) { + // User specified the quote token as base, so swap them + baseTokenToUse = poolInfo.quoteTokenAddress; + quoteTokenToUse = poolInfo.baseTokenAddress; + } else { + // Try to resolve baseToken as symbol to address + const resolvedToken = await hyperswap.getToken(baseToken); + + if (resolvedToken) { + if (resolvedToken.address === poolInfo.baseTokenAddress) { + baseTokenToUse = poolInfo.baseTokenAddress; + quoteTokenToUse = poolInfo.quoteTokenAddress; + } else if (resolvedToken.address === poolInfo.quoteTokenAddress) { + baseTokenToUse = poolInfo.quoteTokenAddress; + quoteTokenToUse = poolInfo.baseTokenAddress; + } else { + throw httpErrors.badRequest(`Token ${baseToken} not found in pool ${poolAddressToUse}`); + } + } else { + throw httpErrors.badRequest(`Token ${baseToken} not found in pool ${poolAddressToUse}`); + } + } + } else { + // No pool address provided, need quoteToken to find pool + if (!quoteToken) { + throw httpErrors.badRequest('quoteToken is required when poolAddress is not provided'); + } + + baseTokenToUse = baseToken; + quoteTokenToUse = quoteToken; + + // Find pool using findDefaultPool + poolAddressToUse = await hyperswap.findDefaultPool(baseTokenToUse, quoteTokenToUse, 'amm'); + + if (!poolAddressToUse) { + throw httpErrors.notFound(`No AMM pool found for pair ${baseTokenToUse}-${quoteTokenToUse}`); + } + } + + return await formatSwapQuote( + networkToUse, + poolAddressToUse, + baseTokenToUse, + quoteTokenToUse, + amount, + side as 'BUY' | 'SELL', + slippagePct, + ); + } catch (e) { + logger.error(`Error in quote-swap route: ${e.message}`); + + // If it's already a Fastify HTTP error, re-throw it + if (e.statusCode) { + throw e; + } + + // Check for specific error types + if (e.message?.includes('Insufficient liquidity')) { + logger.error('Request error:', e); + throw httpErrors.badRequest('Invalid request'); + } + if (e.message?.includes('Pool not found') || e.message?.includes('No AMM pool found')) { + logger.error('Pool not found error:', e); + throw httpErrors.notFound(e.message || 'Pool not found'); + } + if (e.message?.includes('token not found')) { + logger.error('Request error:', e); + throw httpErrors.badRequest('Invalid request'); + } + + // Default to internal server error + logger.error('Unexpected error getting swap quote:', e); + logger.error('Error stack:', e.stack); + throw httpErrors.internalServerError(e.message || 'Error getting swap quote'); + } + }, + ); +}; + +export default quoteSwapRoute; + +// Export quoteSwap wrapper for chain-level routes +export async function quoteSwap( + network: string, + poolAddress: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + slippagePct: number = HyperswapConfig.config.slippagePct, +): Promise { + return await formatSwapQuote(network, poolAddress, baseToken, quoteToken, amount, side, slippagePct); +} diff --git a/src/connectors/hyperswap/amm-routes/removeLiquidity.ts b/src/connectors/hyperswap/amm-routes/removeLiquidity.ts new file mode 100644 index 0000000000..d631d4426b --- /dev/null +++ b/src/connectors/hyperswap/amm-routes/removeLiquidity.ts @@ -0,0 +1,232 @@ +import { Contract } from '@ethersproject/contracts'; +import { Percent } from '@pancakeswap/sdk'; +import { Static } from '@sinclair/typebox'; +import { utils } from 'ethers'; +import { FastifyPluginAsync } from 'fastify'; + +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { RemoveLiquidityResponseType, RemoveLiquidityResponse } from '../../../schemas/amm-schema'; +import { logger } from '../../../services/logger'; +import { Hyperswap } from '../hyperswap'; +import { getHyperswapV2RouterAddress, IHyperswapV2Router02ABI, IHyperswapV2PairABI } from '../hyperswap.contracts'; +import { formatTokenAmount, getHyperswapPoolInfo } from '../hyperswap.utils'; +import { HyperswapAmmRemoveLiquidityRequest } from '../schemas'; + +import { checkLPAllowance } from './positionInfo'; + +// Default gas limit for AMM remove liquidity operations +const AMM_REMOVE_LIQUIDITY_GAS_LIMIT = 400000; + +export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { + await fastify.register(require('@fastify/sensible')); + + fastify.post<{ + Body: Static; + Reply: RemoveLiquidityResponseType; + }>( + '/remove-liquidity', + { + schema: { + description: 'Remove liquidity from a Hyperswap V2 pool', + tags: ['/connector/hyperswap'], + body: HyperswapAmmRemoveLiquidityRequest, + response: { + 200: RemoveLiquidityResponse, + }, + }, + }, + async (request) => { + try { + const { + network, + poolAddress, + percentageToRemove, + walletAddress: requestedWalletAddress, + gasPrice, + 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 Hyperswap and Ethereum instances + const hyperswap = await Hyperswap.getInstance(networkToUse); + const ethereum = await Ethereum.getInstance(networkToUse); + + // Get wallet address - either from request or first available + let walletAddress = requestedWalletAddress; + if (!walletAddress) { + walletAddress = await hyperswap.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 getHyperswapPoolInfo(poolAddress, networkToUse, 'amm'); + if (!poolInfo) { + throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); + } + + const baseTokenObj = await hyperswap.getToken(poolInfo.baseTokenAddress); + const quoteTokenObj = await hyperswap.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, IHyperswapV2PairABI.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 = getHyperswapV2RouterAddress(networkToUse); + const router = new Contract(routerAddress, IHyperswapV2Router02ABI.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 { + 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.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'); + } + }, + ); +}; + +export default removeLiquidityRoute; diff --git a/src/connectors/hyperswap/hyperswap.config.ts b/src/connectors/hyperswap/hyperswap.config.ts new file mode 100644 index 0000000000..b07381a780 --- /dev/null +++ b/src/connectors/hyperswap/hyperswap.config.ts @@ -0,0 +1,27 @@ +import { AvailableNetworks } from '../../services/base'; +import { ConfigManagerV2 } from '../../services/config-manager-v2'; + +export namespace HyperswapConfig { + export const chain = 'ethereum'; + export const networks = ['hyperevm']; + export type Network = string; + + export const tradingTypes = ['amm'] as const; + + export interface RootConfig { + slippagePct: number; + maximumHops: number; + availableNetworks: Array; + } + + export const config: RootConfig = { + slippagePct: ConfigManagerV2.getInstance().get('hyperswap.slippagePct'), + maximumHops: ConfigManagerV2.getInstance().get('hyperswap.maximumHops') || 4, + availableNetworks: [ + { + chain, + networks, + }, + ], + }; +} diff --git a/src/connectors/hyperswap/hyperswap.contracts.ts b/src/connectors/hyperswap/hyperswap.contracts.ts new file mode 100644 index 0000000000..4f0221fdd3 --- /dev/null +++ b/src/connectors/hyperswap/hyperswap.contracts.ts @@ -0,0 +1,93 @@ +import { Address } from 'viem'; + +export interface HyperswapContractAddresses { + hyperswapV2RouterAddress: Address; + hyperswapV2FactoryAddress: Address; +} + +export interface NetworkContractAddresses { + [network: string]: HyperswapContractAddresses; +} + +export const contractAddresses: NetworkContractAddresses = { + hyperevm: { + // Source: https://docs.hyperswap.pro/technical-reference/contracts/deployment-addresses + hyperswapV2FactoryAddress: '0x4df039804873717bff7d03694fb941cf0469b79e', + hyperswapV2RouterAddress: '0xda0f518d521e0dE83fAdC8500C2D21b6a6C39bF9', + }, +}; + +export function getHyperswapV2RouterAddress(network: string): string { + const address = contractAddresses[network]?.hyperswapV2RouterAddress; + + if (!address) { + throw new Error(`Hyperswap V2 Router address not configured for network: ${network}`); + } + + return address; +} + +export function getHyperswapV2FactoryAddress(network: string): Address { + const address = contractAddresses[network]?.hyperswapV2FactoryAddress; + + if (!address) { + throw new Error(`Hyperswap V2 Factory address not configured for network: ${network}`); + } + + return address; +} + +export function getSpender(network: string, connectorName: string): string { + if (connectorName.includes('/amm') || connectorName === 'hyperswap') { + return getHyperswapV2RouterAddress(network); + } + + throw new Error(`Unsupported Hyperswap connector type: ${connectorName}`); +} + +export const IHyperswapV2Router02ABI = require('./hyperswap_v2_router_abi.json'); + +export const IHyperswapV2FactoryABI = { + abi: [ + { + inputs: [ + { internalType: 'address', name: 'tokenA', type: 'address' }, + { internalType: 'address', name: 'tokenB', type: 'address' }, + ], + name: 'getPair', + outputs: [{ internalType: 'address', name: 'pair', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + ], +}; + +export const IHyperswapV2PairABI = { + abi: [ + { + inputs: [], + name: 'getReserves', + outputs: [ + { internalType: 'uint112', name: '_reserve0', type: 'uint112' }, + { internalType: 'uint112', name: '_reserve1', type: 'uint112' }, + { internalType: 'uint32', name: '_blockTimestampLast', type: 'uint32' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'token0', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'token1', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + ], +}; diff --git a/src/connectors/hyperswap/hyperswap.routes.ts b/src/connectors/hyperswap/hyperswap.routes.ts new file mode 100644 index 0000000000..2ade81f275 --- /dev/null +++ b/src/connectors/hyperswap/hyperswap.routes.ts @@ -0,0 +1,24 @@ +import sensible from '@fastify/sensible'; +import { FastifyPluginAsync } from 'fastify'; + +import { hyperswapAmmRoutes } from './amm-routes'; + +const hyperswapAmmRoutesWrapper: 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/hyperswap']; + } + }); + + await instance.register(hyperswapAmmRoutes); + }); +}; + +export const hyperswapRoutes = { + amm: hyperswapAmmRoutesWrapper, +}; + +export default hyperswapRoutes; diff --git a/src/connectors/hyperswap/hyperswap.ts b/src/connectors/hyperswap/hyperswap.ts new file mode 100644 index 0000000000..bbcd2a8481 --- /dev/null +++ b/src/connectors/hyperswap/hyperswap.ts @@ -0,0 +1,153 @@ +import { CurrencyAmount, Token } from '@pancakeswap/sdk'; +import { Pair as V2Pair } from '@pancakeswap/v2-sdk'; +import { Contract, constants } from 'ethers'; +import { Address } from 'viem'; + +import { Ethereum, TokenInfo } from '../../chains/ethereum/ethereum'; +import { logger } from '../../services/logger'; + +import { HyperswapConfig } from './hyperswap.config'; +import { + IHyperswapV2FactoryABI, + IHyperswapV2PairABI, + IHyperswapV2Router02ABI, + getHyperswapV2FactoryAddress, + getHyperswapV2RouterAddress, +} from './hyperswap.contracts'; + +export class Hyperswap { + private static _instances: { [name: string]: Hyperswap }; + + private ethereum: Ethereum; + public config: HyperswapConfig.RootConfig; + private _ready: boolean = false; + private v2Factory: Contract; + private v2Router: Contract; + private networkName: string; + + private constructor(network: string) { + this.networkName = network; + this.config = HyperswapConfig.config; + } + + public static async getInstance(network: string): Promise { + if (Hyperswap._instances === undefined) { + Hyperswap._instances = {}; + } + + if (!(network in Hyperswap._instances)) { + Hyperswap._instances[network] = new Hyperswap(network); + await Hyperswap._instances[network].init(); + } + + return Hyperswap._instances[network]; + } + + public async init() { + try { + this.ethereum = await Ethereum.getInstance(this.networkName); + + this.v2Factory = new Contract( + getHyperswapV2FactoryAddress(this.networkName), + IHyperswapV2FactoryABI.abi, + this.ethereum.provider, + ); + + this.v2Router = new Contract( + getHyperswapV2RouterAddress(this.networkName), + IHyperswapV2Router02ABI.abi, + this.ethereum.provider, + ); + + if (!this.ethereum.ready()) { + await this.ethereum.init(); + } + + this._ready = true; + logger.info(`Hyperswap AMM connector initialized for network: ${this.networkName}`); + } catch (error) { + logger.error(`Error initializing Hyperswap: ${error.message}`); + throw error; + } + } + + public ready(): boolean { + return this._ready; + } + + public async getToken(symbolOrAddress: string): Promise { + const tokenInfo = await this.ethereum.getToken(symbolOrAddress); + return tokenInfo ? this.getHyperswapToken(tokenInfo) : null; + } + + public getHyperswapToken(tokenInfo: TokenInfo): Token { + return new Token( + this.ethereum.chainId, + tokenInfo.address as Address, + tokenInfo.decimals, + tokenInfo.symbol, + tokenInfo.name, + ); + } + + public async getV2Pool(tokenA: Token, tokenB: Token, poolAddress?: string): Promise { + try { + const pairAddress = poolAddress || (await this.v2Factory.getPair(tokenA.address, tokenB.address)); + if (!pairAddress || pairAddress === constants.AddressZero) { + return null; + } + + const pairContract = new Contract(pairAddress, IHyperswapV2PairABI.abi, this.ethereum.provider); + const reserves = await pairContract.getReserves(); + const token0Address = await pairContract.token0(); + + const [reserve0, reserve1] = reserves; + const [token0, token1] = + tokenA.address.toLowerCase() === token0Address.toLowerCase() ? [tokenA, tokenB] : [tokenB, tokenA]; + + return new V2Pair( + TokenAmountFromRaw(token0, reserve0.toString()), + TokenAmountFromRaw(token1, reserve1.toString()), + ); + } catch (error) { + logger.error(`Error getting Hyperswap V2 pool: ${error.message}`); + return null; + } + } + + public async findDefaultPool( + baseToken: string, + quoteToken: string, + poolType: 'amm' | 'clmm', + ): Promise { + if (poolType !== 'amm') { + return null; + } + + const baseTokenObj = await this.getToken(baseToken); + const quoteTokenObj = await this.getToken(quoteToken); + if (!baseTokenObj || !quoteTokenObj) { + return null; + } + + const pairAddress = await this.v2Factory.getPair(baseTokenObj.address, quoteTokenObj.address); + return pairAddress && pairAddress !== constants.AddressZero ? pairAddress : null; + } + + public getRouter(): Contract { + return this.v2Router; + } + + public async getFirstWalletAddress(): Promise { + try { + return await Ethereum.getFirstWalletAddress(); + } catch (error) { + logger.error(`Error getting first wallet address: ${error.message}`); + return null; + } + } +} + +function TokenAmountFromRaw(token: Token, rawAmount: string) { + return CurrencyAmount.fromRawAmount(token, rawAmount); +} diff --git a/src/connectors/hyperswap/hyperswap.utils.ts b/src/connectors/hyperswap/hyperswap.utils.ts new file mode 100644 index 0000000000..1b586071f6 --- /dev/null +++ b/src/connectors/hyperswap/hyperswap.utils.ts @@ -0,0 +1,93 @@ +import { Contract } from '@ethersproject/contracts'; +import { Token } from '@pancakeswap/sdk'; +import { FastifyInstance } from 'fastify'; + +import { Ethereum } from '../../chains/ethereum/ethereum'; +import { logger } from '../../services/logger'; + +import { Hyperswap } from './hyperswap'; +import { IHyperswapV2PairABI } from './hyperswap.contracts'; + +export const isValidV2Pool = async (poolAddress: string): Promise => { + try { + return poolAddress && poolAddress.length === 42 && poolAddress.startsWith('0x'); + } catch (error) { + logger.error(`Error validating V2 pool: ${error}`); + return false; + } +}; + +export const findPoolAddress = ( + _baseToken: string, + _quoteToken: string, + _poolType: 'amm' | 'clmm', + _network: string, +): string | null => { + return null; +}; + +export const formatTokenAmount = (amount: string | number, decimals: number): number => { + try { + if (typeof amount === 'string') { + return parseFloat(amount) / Math.pow(10, decimals); + } + return amount / Math.pow(10, decimals); + } catch (error) { + logger.error(`Error formatting token amount: ${error}`); + return 0; + } +}; + +export async function getFullTokenFromSymbol( + fastify: FastifyInstance, + ethereum: Ethereum, + hyperswap: Hyperswap, + tokenSymbol: string, +): Promise { + if (!ethereum.ready()) { + await ethereum.init(); + } + + const tokenInfo = await ethereum.getToken(tokenSymbol); + + if (!tokenInfo) { + throw fastify.httpErrors.badRequest(`Token ${tokenSymbol} is not supported`); + } + + return hyperswap.getHyperswapToken(tokenInfo); +} + +export interface HyperswapPoolInfo { + baseTokenAddress: string; + quoteTokenAddress: string; + poolType: 'amm'; +} + +export async function getV2PoolInfo(poolAddress: string, network: string): Promise { + try { + const ethereum = await Ethereum.getInstance(network); + const pairContract = new Contract(poolAddress, IHyperswapV2PairABI.abi, ethereum.provider); + const [token0Address, token1Address] = await Promise.all([pairContract.token0(), pairContract.token1()]); + + return { + baseTokenAddress: token0Address, + quoteTokenAddress: token1Address, + poolType: 'amm', + }; + } catch (error) { + logger.error(`Error getting V2 pool info: ${error.message}`); + return null; + } +} + +export async function getHyperswapPoolInfo( + poolAddress: string, + network: string, + poolType?: 'amm' | 'clmm', +): Promise { + if (poolType && poolType !== 'amm') { + return null; + } + + return getV2PoolInfo(poolAddress, network); +} diff --git a/src/connectors/hyperswap/hyperswap_v2_router_abi.json b/src/connectors/hyperswap/hyperswap_v2_router_abi.json new file mode 100644 index 0000000000..1db522ac19 --- /dev/null +++ b/src/connectors/hyperswap/hyperswap_v2_router_abi.json @@ -0,0 +1,23 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "multicall", + "outputs": [ + { + "internalType": "bytes[]", + "name": "results", + "type": "bytes[]" + } + ], + "stateMutability": "payable", + "type": "function" + } + ] +} diff --git a/src/connectors/hyperswap/schemas.ts b/src/connectors/hyperswap/schemas.ts new file mode 100644 index 0000000000..673f2bb87f --- /dev/null +++ b/src/connectors/hyperswap/schemas.ts @@ -0,0 +1,593 @@ +import { Type } from '@sinclair/typebox'; + +import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; + +import { HyperswapConfig } from './hyperswap.config'; + +// Get chain config for defaults +const ethereumChainConfig = getEthereumChainConfig(); + +// Constants for examples +const BASE_TOKEN = 'USDT'; +const QUOTE_TOKEN = 'WBNB'; +const SWAP_AMOUNT = 10; +const AMM_POOL_ADDRESS_EXAMPLE = '0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C'; // Hyperswap V2 WETH-USDC pool on Base +const CLMM_POOL_ADDRESS_EXAMPLE = '0x172fcd41e0913e95784454622d1c3724f546f849'; // Hyperswap V3 USDT-WBNB pool on BSC + +// ======================================== +// AMM Request Schemas +// ======================================== + +export const HyperswapAmmGetPoolInfoRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...HyperswapConfig.networks], + }), + ), + poolAddress: Type.String({ + description: 'Hyperswap V2 pool address', + examples: [AMM_POOL_ADDRESS_EXAMPLE], + }), +}); + +// ======================================== +// CLMM Request Schemas +// ======================================== + +export const HyperswapClmmGetPoolInfoRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: 'bsc', + examples: ['bsc'], + enum: [...HyperswapConfig.networks], + }), + ), + poolAddress: Type.String({ + description: 'Hyperswap V3 pool address', + examples: [CLMM_POOL_ADDRESS_EXAMPLE], + }), +}); + +// ======================================== +// Router Request Schemas +// ======================================== + +// Hyperswap-specific quote-swap request +export const HyperswapQuoteSwapRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...HyperswapConfig.networks], + }), + ), + baseToken: Type.String({ + description: 'First token in the trading pair', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Second token in the trading pair', + examples: [QUOTE_TOKEN], + }), + amount: Type.Number({ + description: 'Amount of base token to trade', + examples: [SWAP_AMOUNT], + }), + side: Type.String({ + description: + 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', + enum: ['BUY', 'SELL'], + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: HyperswapConfig.config.slippagePct, + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address for more accurate quotes (optional)', + default: ethereumChainConfig.defaultWallet, + }), + ), +}); + +// Hyperswap-specific quote-swap response +export const HyperswapQuoteSwapResponse = Type.Object({ + quoteId: Type.String({ + description: 'Unique identifier for this quote', + }), + tokenIn: Type.String({ + description: 'Address of the token being swapped from', + }), + tokenOut: Type.String({ + description: 'Address of the token being swapped to', + }), + amountIn: Type.Number({ + description: 'Amount of tokenIn to be swapped', + }), + amountOut: Type.Number({ + description: 'Expected amount of tokenOut to receive', + }), + price: Type.Number({ + description: 'Exchange rate between tokenIn and tokenOut', + }), + priceImpactPct: Type.Number({ + description: 'Estimated price impact percentage (0-100)', + }), + minAmountOut: Type.Number({ + description: 'Minimum amount of tokenOut that will be accepted', + }), + maxAmountIn: Type.Number({ + description: 'Maximum amount of tokenIn that will be spent', + }), + routePath: Type.Optional( + Type.String({ + description: 'Human-readable route path', + }), + ), +}); + +// Hyperswap-specific execute-quote request +export const HyperswapExecuteQuoteRequest = Type.Object({ + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will execute the swap', + default: ethereumChainConfig.defaultWallet, + examples: [ethereumChainConfig.defaultWallet], + }), + ), + network: Type.Optional( + Type.String({ + description: 'The blockchain network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...HyperswapConfig.networks], + }), + ), + quoteId: Type.String({ + description: 'ID of the quote to execute', + examples: ['123e4567-e89b-12d3-a456-426614174000'], + }), +}); + +// Hyperswap AMM Add Liquidity Request +export const HyperswapAmmAddLiquidityRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...HyperswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will add liquidity', + default: ethereumChainConfig.defaultWallet, + }), + ), + poolAddress: Type.String({ + description: 'Address of the Hyperswap V2 pool', + }), + baseTokenAmount: Type.Number({ + description: 'Amount of base token to add', + }), + quoteTokenAmount: Type.Number({ + description: 'Amount of quote token to add', + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: HyperswapConfig.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], + }), + ), +}); + +// Hyperswap AMM Remove Liquidity Request +export const HyperswapAmmRemoveLiquidityRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...HyperswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will remove liquidity', + default: ethereumChainConfig.defaultWallet, + }), + ), + poolAddress: Type.String({ + description: 'Address of the Hyperswap V2 pool', + }), + percentageToRemove: Type.Number({ + minimum: 0, + maximum: 100, + description: 'Percentage of liquidity to remove', + }), + gasPrice: Type.Optional( + Type.String({ + description: 'Gas price in wei for the transaction', + }), + ), + maxGas: Type.Optional( + Type.Number({ + description: 'Maximum gas limit for the transaction', + examples: [300000], + }), + ), +}); + +// Hyperswap AMM Execute Swap Request +export const HyperswapAmmExecuteSwapRequest = Type.Object({ + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will execute the swap', + default: ethereumChainConfig.defaultWallet, + }), + ), + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...HyperswapConfig.networks], + }), + ), + poolAddress: Type.Optional( + Type.String({ + description: 'Pool address (optional - can be looked up from tokens)', + default: '', + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address', + examples: [BASE_TOKEN], + }), + quoteToken: Type.Optional( + Type.String({ + description: 'Quote token symbol or address', + examples: [QUOTE_TOKEN], + }), + ), + amount: Type.Number({ + description: 'Amount to swap', + examples: [SWAP_AMOUNT], + }), + side: Type.String({ + enum: ['BUY', 'SELL'], + default: 'SELL', + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: HyperswapConfig.config.slippagePct, + }), + ), +}); + +// Hyperswap-specific execute-swap request +export const HyperswapExecuteSwapRequest = Type.Object({ + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will execute the swap', + default: ethereumChainConfig.defaultWallet, + examples: [ethereumChainConfig.defaultWallet], + }), + ), + network: Type.Optional( + Type.String({ + description: 'The blockchain network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...HyperswapConfig.networks], + }), + ), + baseToken: Type.String({ + description: 'Token to determine swap direction', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'The other token in the pair', + examples: [QUOTE_TOKEN], + }), + amount: Type.Number({ + description: 'Amount of base token to trade', + examples: [SWAP_AMOUNT], + }), + side: Type.String({ + description: + 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', + enum: ['BUY', 'SELL'], + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: HyperswapConfig.config.slippagePct, + examples: [1], + }), + ), +}); + +// Hyperswap CLMM Open Position Request +export const HyperswapClmmOpenPositionRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: 'bsc', + examples: ['bsc'], + enum: [...HyperswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will open the position', + default: ethereumChainConfig.defaultWallet, + }), + ), + lowerPrice: Type.Number({ + description: 'Lower price bound for the position', + }), + upperPrice: Type.Number({ + description: 'Upper price bound for the position', + }), + poolAddress: Type.String({ + description: 'Address of the Hyperswap V3 pool', + }), + baseTokenAmount: Type.Optional( + Type.Number({ + description: 'Amount of base token to deposit', + }), + ), + quoteTokenAmount: Type.Optional( + Type.Number({ + description: 'Amount of quote token to deposit', + }), + ), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: HyperswapConfig.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], + }), + ), +}); + +// Hyperswap CLMM Add Liquidity Request +export const HyperswapClmmAddLiquidityRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: 'bsc', + examples: ['bsc'], + enum: [...HyperswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will add liquidity', + default: ethereumChainConfig.defaultWallet, + }), + ), + positionAddress: Type.String({ + description: 'NFT token ID of the position', + }), + baseTokenAmount: Type.Number({ + description: 'Amount of base token to add', + }), + quoteTokenAmount: Type.Number({ + description: 'Amount of quote token to add', + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: HyperswapConfig.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], + }), + ), +}); + +// Hyperswap CLMM Remove Liquidity Request +export const HyperswapClmmRemoveLiquidityRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: 'bsc', + examples: ['bsc'], + enum: [...HyperswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will remove liquidity', + default: ethereumChainConfig.defaultWallet, + }), + ), + positionAddress: Type.String({ + description: 'NFT token ID of the position', + }), + percentageToRemove: Type.Number({ + minimum: 0, + maximum: 100, + description: 'Percentage of liquidity to remove', + }), + gasPrice: Type.Optional( + Type.String({ + description: 'Gas price in wei for the transaction', + }), + ), + maxGas: Type.Optional( + Type.Number({ + description: 'Maximum gas limit for the transaction', + examples: [300000], + }), + ), +}); + +// Hyperswap CLMM Close Position Request +export const HyperswapClmmClosePositionRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: 'bsc', + examples: ['bsc'], + enum: [...HyperswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will close the position', + default: ethereumChainConfig.defaultWallet, + }), + ), + positionAddress: Type.String({ + description: 'NFT token ID of the position to close', + }), + gasPrice: Type.Optional( + Type.String({ + description: 'Gas price in wei for the transaction', + }), + ), + maxGas: Type.Optional( + Type.Number({ + description: 'Maximum gas limit for the transaction', + examples: [300000], + }), + ), +}); + +// Hyperswap CLMM Collect Fees Request +export const HyperswapClmmCollectFeesRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: 'bsc', + examples: ['bsc'], + enum: [...HyperswapConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will collect fees', + default: ethereumChainConfig.defaultWallet, + }), + ), + positionAddress: Type.String({ + description: 'NFT token ID of the position', + }), + gasPrice: Type.Optional( + Type.String({ + description: 'Gas price in wei for the transaction', + }), + ), + maxGas: Type.Optional( + Type.Number({ + description: 'Maximum gas limit for the transaction', + examples: [300000], + }), + ), +}); + +// Hyperswap CLMM Execute Swap Request +export const HyperswapClmmExecuteSwapRequest = Type.Object({ + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will execute the swap', + default: ethereumChainConfig.defaultWallet, + }), + ), + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: 'bsc', + examples: ['bsc'], + enum: [...HyperswapConfig.networks], + }), + ), + poolAddress: Type.Optional( + Type.String({ + description: 'Pool address (optional - can be looked up from tokens)', + }), + ), + baseToken: Type.String({ + description: 'Base token symbol or address', + examples: [BASE_TOKEN], + }), + quoteToken: Type.Optional( + Type.String({ + description: 'Quote token symbol or address', + examples: [QUOTE_TOKEN], + }), + ), + amount: Type.Number({ + description: 'Amount to swap', + examples: [SWAP_AMOUNT], + }), + side: Type.String({ + enum: ['BUY', 'SELL'], + default: 'SELL', + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + default: HyperswapConfig.config.slippagePct, + }), + ), + gasPrice: Type.Optional( + Type.String({ + description: 'Gas price in wei for the transaction', + }), + ), + maxGas: Type.Optional( + Type.Number({ + description: 'Maximum gas limit for the transaction', + examples: [300000], + }), + ), +}); diff --git a/src/templates/chains/ethereum/hyperevm.yml b/src/templates/chains/ethereum/hyperevm.yml new file mode 100644 index 0000000000..3dc25fa744 --- /dev/null +++ b/src/templates/chains/ethereum/hyperevm.yml @@ -0,0 +1,11 @@ +chainID: 999 +nodeURL: https://rpc.hyperliquid.xyz/evm +nativeCurrencySymbol: HYPE +geckoId: hyperliquid +transactionExecutionTimeoutMs: 10000 # Timeout for waiting for transaction execution (in milliseconds) +swapProvider: hyperswap/amm + +# HyperEVM fee parameters are fetched from the configured RPC by default. +baseFee: +baseFeeMultiplier: 1.2 +priorityFee: diff --git a/src/templates/connectors/hyperswap.yml b/src/templates/connectors/hyperswap.yml new file mode 100644 index 0000000000..a2bc406a57 --- /dev/null +++ b/src/templates/connectors/hyperswap.yml @@ -0,0 +1,5 @@ +# Global settings for HyperSwap +slippagePct: 2 + +# For each AMM swap, the maximum number of hops to consider. +maximumHops: 4 diff --git a/src/templates/tokens/ethereum/hyperevm.json b/src/templates/tokens/ethereum/hyperevm.json new file mode 100644 index 0000000000..c1ab6d9a96 --- /dev/null +++ b/src/templates/tokens/ethereum/hyperevm.json @@ -0,0 +1,9 @@ +[ + { + "chainId": 999, + "name": "Wrapped HYPE", + "symbol": "WHYPE", + "address": "0x5555555555555555555555555555555555555555", + "decimals": 18 + } +] diff --git a/test/connectors/hyperswap/hyperswap.routes.test.ts b/test/connectors/hyperswap/hyperswap.routes.test.ts new file mode 100644 index 0000000000..410ec13d95 --- /dev/null +++ b/test/connectors/hyperswap/hyperswap.routes.test.ts @@ -0,0 +1,63 @@ +import '../../mocks/app-mocks'; + +import fs from 'fs'; +import path from 'path'; + +import { FastifyInstance } from 'fastify'; + +import { gatewayApp } from '../../../src/app'; +import { + getHyperswapV2FactoryAddress, + getHyperswapV2RouterAddress, +} from '../../../src/connectors/hyperswap/hyperswap.contracts'; + +describe('Hyperswap Routes Structure', () => { + let fastify: FastifyInstance; + + beforeAll(async () => { + fastify = gatewayApp; + await fastify.ready(); + }); + + afterAll(async () => { + await fastify.close(); + }); + + it('advertises HyperEVM AMM support in connector config', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/config/connectors', + }); + + const { connectors } = JSON.parse(response.body); + const hyperswapConfig = connectors.find((connector: any) => connector.name === 'hyperswap'); + + expect(hyperswapConfig).toBeDefined(); + expect(hyperswapConfig.chain).toBe('ethereum'); + expect(hyperswapConfig.networks).toContain('hyperevm'); + expect(hyperswapConfig.trading_types).toEqual(['amm']); + }); + + it('registers only the HyperSwap AMM route surface', () => { + const routes = fastify.printRoutes(); + + expect(routes).toContain('hyperswap/amm/'); + expect(routes).toContain('quote-swap'); + expect(routes).toContain('execute-swap'); + expect(routes).not.toContain('hyperswap/router/'); + expect(routes).not.toContain('hyperswap/clmm/'); + }); + + it('keeps the connector folder AMM-only until HyperSwap CLMM/router addresses are configured', () => { + const hyperswapPath = path.join(__dirname, '../../../src/connectors/hyperswap'); + + expect(fs.existsSync(path.join(hyperswapPath, 'amm-routes'))).toBe(true); + expect(fs.existsSync(path.join(hyperswapPath, 'router-routes'))).toBe(false); + expect(fs.existsSync(path.join(hyperswapPath, 'clmm-routes'))).toBe(false); + }); + + it('uses documented HyperSwap V2 contracts on HyperEVM mainnet', () => { + expect(getHyperswapV2FactoryAddress('hyperevm')).toBe('0x4df039804873717bff7d03694fb941cf0469b79e'); + expect(getHyperswapV2RouterAddress('hyperevm')).toBe('0xda0f518d521e0dE83fAdC8500C2D21b6a6C39bF9'); + }); +}); From f82fd2bc7cd49bfb2b3d007b0fb72c647bcb7085 Mon Sep 17 00:00:00 2001 From: RYB-404 <116948958+RYB-404@users.noreply.github.com> Date: Fri, 29 May 2026 20:13:31 +0700 Subject: [PATCH 2/2] Address HyperSwap router review feedback --- .../hyperswap/amm-routes/addLiquidity.ts | 189 +++------ .../hyperswap/amm-routes/executeSwap.ts | 6 +- .../hyperswap/amm-routes/quoteLiquidity.ts | 1 - .../hyperswap/amm-routes/quoteSwap.ts | 3 - .../hyperswap/amm-routes/removeLiquidity.ts | 55 +-- src/connectors/hyperswap/hyperswap.config.ts | 2 +- src/connectors/hyperswap/hyperswap.utils.ts | 11 +- .../hyperswap/hyperswap_v2_router_abi.json | 109 +++++- src/connectors/hyperswap/schemas.ts | 365 +----------------- 9 files changed, 181 insertions(+), 560 deletions(-) diff --git a/src/connectors/hyperswap/amm-routes/addLiquidity.ts b/src/connectors/hyperswap/amm-routes/addLiquidity.ts index 68e1411aed..51e8b047f6 100644 --- a/src/connectors/hyperswap/amm-routes/addLiquidity.ts +++ b/src/connectors/hyperswap/amm-routes/addLiquidity.ts @@ -111,152 +111,65 @@ async function addLiquidity( // Prepare the transaction parameters const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - let tx; - - // Check if one of the tokens is WETH - if (quote.baseTokenObj.symbol === 'WETH') { - // Check allowance for quote token - const tokenContract = ethereum.getContract(quote.quoteTokenObj.address, wallet); - const allowance = await ethereum.getERC20Allowance( - tokenContract, - wallet, - quote.routerAddress, - quote.quoteTokenObj.decimals, - ); - - const currentAllowance = BigNumber.from(allowance.value); - logger.info( - `Current allowance for ${quote.quoteTokenObj.symbol}: ${formatTokenAmount(currentAllowance.toString(), quote.quoteTokenObj.decimals)}`, - ); - logger.info( - `Amount needed for ${quote.quoteTokenObj.symbol}: ${formatTokenAmount(quote.rawQuoteTokenAmount.toString(), quote.quoteTokenObj.decimals)}`, - ); - - // Check if allowance is sufficient - if (currentAllowance.lt(quote.rawQuoteTokenAmount)) { - throw new Error( - `Insufficient allowance for ${quote.quoteTokenObj.symbol}. Please approve at least ${formatTokenAmount(quote.rawQuoteTokenAmount.toString(), quote.quoteTokenObj.decimals)} ${quote.quoteTokenObj.symbol} for the Hyperswap router (${quote.routerAddress})`, - ); - } - - // Add liquidity ETH + Token - tx = await router.addLiquidityETH( - quote.quoteTokenObj.address, - quote.rawQuoteTokenAmount, - quoteTokenMinAmount, - baseTokenMinAmount, - walletAddress, - deadline, - { - value: quote.rawBaseTokenAmount, - gasLimit: 300000, - }, - ); - } else if (quote.quoteTokenObj.symbol === 'WETH') { - // Check allowance for base token - const tokenContract = ethereum.getContract(quote.baseTokenObj.address, wallet); - const allowance = await ethereum.getERC20Allowance( - tokenContract, - wallet, - quote.routerAddress, - quote.baseTokenObj.decimals, - ); - - const currentAllowance = BigNumber.from(allowance.value); - logger.info( - `Current allowance for ${quote.baseTokenObj.symbol}: ${formatTokenAmount(currentAllowance.toString(), quote.baseTokenObj.decimals)}`, - ); - logger.info( - `Amount needed for ${quote.baseTokenObj.symbol}: ${formatTokenAmount(quote.rawBaseTokenAmount.toString(), quote.baseTokenObj.decimals)}`, - ); - - // Check if allowance is sufficient - if (currentAllowance.lt(quote.rawBaseTokenAmount)) { - throw new Error( - `Insufficient allowance for ${quote.baseTokenObj.symbol}. Please approve at least ${formatTokenAmount(quote.rawBaseTokenAmount.toString(), quote.baseTokenObj.decimals)} ${quote.baseTokenObj.symbol} for the Hyperswap router (${quote.routerAddress})`, - ); - } + const baseTokenContract = ethereum.getContract(quote.baseTokenObj.address, wallet); + const baseAllowance = await ethereum.getERC20Allowance( + baseTokenContract, + wallet, + quote.routerAddress, + quote.baseTokenObj.decimals, + ); - // Add liquidity Token + ETH - // Convert gasPrice from wei to gwei if provided - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); - gasOptions.value = quote.rawQuoteTokenAmount; - - tx = await router.addLiquidityETH( - quote.baseTokenObj.address, - quote.rawBaseTokenAmount, - baseTokenMinAmount, - quoteTokenMinAmount, - walletAddress, - deadline, - gasOptions, - ); - } else { - // Both tokens are ERC20 - check allowances for both - const baseTokenContract = ethereum.getContract(quote.baseTokenObj.address, wallet); - const baseAllowance = await ethereum.getERC20Allowance( - baseTokenContract, - wallet, - quote.routerAddress, - quote.baseTokenObj.decimals, - ); + const quoteTokenContract = ethereum.getContract(quote.quoteTokenObj.address, wallet); + const quoteAllowance = await ethereum.getERC20Allowance( + quoteTokenContract, + wallet, + quote.routerAddress, + quote.quoteTokenObj.decimals, + ); - const quoteTokenContract = ethereum.getContract(quote.quoteTokenObj.address, wallet); - const quoteAllowance = await ethereum.getERC20Allowance( - quoteTokenContract, - wallet, - quote.routerAddress, - quote.quoteTokenObj.decimals, - ); + const currentBaseAllowance = BigNumber.from(baseAllowance.value); + const currentQuoteAllowance = BigNumber.from(quoteAllowance.value); - const currentBaseAllowance = BigNumber.from(baseAllowance.value); - const currentQuoteAllowance = BigNumber.from(quoteAllowance.value); + logger.info( + `Current base allowance for ${quote.baseTokenObj.symbol}: ${formatTokenAmount(currentBaseAllowance.toString(), quote.baseTokenObj.decimals)}`, + ); + logger.info( + `Amount needed for ${quote.baseTokenObj.symbol}: ${formatTokenAmount(quote.rawBaseTokenAmount.toString(), quote.baseTokenObj.decimals)}`, + ); + logger.info( + `Current quote allowance for ${quote.quoteTokenObj.symbol}: ${formatTokenAmount(currentQuoteAllowance.toString(), quote.quoteTokenObj.decimals)}`, + ); + logger.info( + `Amount needed for ${quote.quoteTokenObj.symbol}: ${formatTokenAmount(quote.rawQuoteTokenAmount.toString(), quote.quoteTokenObj.decimals)}`, + ); - logger.info( - `Current base allowance for ${quote.baseTokenObj.symbol}: ${formatTokenAmount(currentBaseAllowance.toString(), quote.baseTokenObj.decimals)}`, - ); - logger.info( - `Amount needed for ${quote.baseTokenObj.symbol}: ${formatTokenAmount(quote.rawBaseTokenAmount.toString(), quote.baseTokenObj.decimals)}`, - ); - logger.info( - `Current quote allowance for ${quote.quoteTokenObj.symbol}: ${formatTokenAmount(currentQuoteAllowance.toString(), quote.quoteTokenObj.decimals)}`, - ); - logger.info( - `Amount needed for ${quote.quoteTokenObj.symbol}: ${formatTokenAmount(quote.rawQuoteTokenAmount.toString(), quote.quoteTokenObj.decimals)}`, + if (currentBaseAllowance.lt(quote.rawBaseTokenAmount)) { + throw new Error( + `Insufficient allowance for ${quote.baseTokenObj.symbol}. Please approve at least ${formatTokenAmount(quote.rawBaseTokenAmount.toString(), quote.baseTokenObj.decimals)} ${quote.baseTokenObj.symbol} for the Hyperswap router (${quote.routerAddress})`, ); + } - // Check if both allowances are sufficient - if (currentBaseAllowance.lt(quote.rawBaseTokenAmount)) { - throw new Error( - `Insufficient allowance for ${quote.baseTokenObj.symbol}. Please approve at least ${formatTokenAmount(quote.rawBaseTokenAmount.toString(), quote.baseTokenObj.decimals)} ${quote.baseTokenObj.symbol} for the Hyperswap router (${quote.routerAddress})`, - ); - } - - if (currentQuoteAllowance.lt(quote.rawQuoteTokenAmount)) { - throw new Error( - `Insufficient allowance for ${quote.quoteTokenObj.symbol}. Please approve at least ${formatTokenAmount(quote.rawQuoteTokenAmount.toString(), quote.quoteTokenObj.decimals)} ${quote.quoteTokenObj.symbol} for the Hyperswap router (${quote.routerAddress})`, - ); - } - - // Add liquidity Token + Token - // Convert gasPrice from wei to gwei if provided - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); - - tx = await router.addLiquidity( - quote.baseTokenObj.address, - quote.quoteTokenObj.address, - quote.rawBaseTokenAmount, - quote.rawQuoteTokenAmount, - baseTokenMinAmount, - quoteTokenMinAmount, - walletAddress, - deadline, - gasOptions, + if (currentQuoteAllowance.lt(quote.rawQuoteTokenAmount)) { + throw new Error( + `Insufficient allowance for ${quote.quoteTokenObj.symbol}. Please approve at least ${formatTokenAmount(quote.rawQuoteTokenAmount.toString(), quote.quoteTokenObj.decimals)} ${quote.quoteTokenObj.symbol} for the Hyperswap router (${quote.routerAddress})`, ); } + const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; + const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + + const tx = await router.addLiquidity( + quote.baseTokenObj.address, + quote.quoteTokenObj.address, + quote.rawBaseTokenAmount, + quote.rawQuoteTokenAmount, + baseTokenMinAmount, + quoteTokenMinAmount, + walletAddress, + deadline, + gasOptions, + ); + // Wait for transaction confirmation const receipt = await ethereum.handleTransactionExecution(tx); @@ -280,8 +193,6 @@ async function addLiquidity( } export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - fastify.post<{ Body: Static; Reply: AddLiquidityResponseType; diff --git a/src/connectors/hyperswap/amm-routes/executeSwap.ts b/src/connectors/hyperswap/amm-routes/executeSwap.ts index 3f700f9a65..d8d4264df8 100644 --- a/src/connectors/hyperswap/amm-routes/executeSwap.ts +++ b/src/connectors/hyperswap/amm-routes/executeSwap.ts @@ -304,11 +304,15 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { slippagePct, } = request.body as typeof HyperswapAmmExecuteSwapRequest._type; + if (!quoteToken) { + throw httpErrors.badRequest('quoteToken is required'); + } + return await executeAmmSwap( walletAddress, network, baseToken, - quoteToken || '', // Handle optional quoteToken + quoteToken, amount, side as 'BUY' | 'SELL', slippagePct, diff --git a/src/connectors/hyperswap/amm-routes/quoteLiquidity.ts b/src/connectors/hyperswap/amm-routes/quoteLiquidity.ts index 27146a3c70..9416592a25 100644 --- a/src/connectors/hyperswap/amm-routes/quoteLiquidity.ts +++ b/src/connectors/hyperswap/amm-routes/quoteLiquidity.ts @@ -174,7 +174,6 @@ export async function getHyperswapAmmLiquidityQuote( } export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); fastify.get<{ Querystring: QuoteLiquidityRequestType; Reply: QuoteLiquidityResponseType; diff --git a/src/connectors/hyperswap/amm-routes/quoteSwap.ts b/src/connectors/hyperswap/amm-routes/quoteSwap.ts index b6b8650027..82abe14b58 100644 --- a/src/connectors/hyperswap/amm-routes/quoteSwap.ts +++ b/src/connectors/hyperswap/amm-routes/quoteSwap.ts @@ -265,9 +265,6 @@ async function formatSwapQuote( } export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - // Import the httpErrors plugin to ensure it's available - await fastify.register(require('@fastify/sensible')); - fastify.get<{ Querystring: QuoteSwapRequestType; Reply: QuoteSwapResponseType; diff --git a/src/connectors/hyperswap/amm-routes/removeLiquidity.ts b/src/connectors/hyperswap/amm-routes/removeLiquidity.ts index d631d4426b..4054f3b267 100644 --- a/src/connectors/hyperswap/amm-routes/removeLiquidity.ts +++ b/src/connectors/hyperswap/amm-routes/removeLiquidity.ts @@ -8,6 +8,7 @@ import { Ethereum } from '../../../chains/ethereum/ethereum'; import { RemoveLiquidityResponseType, RemoveLiquidityResponse } from '../../../schemas/amm-schema'; import { logger } from '../../../services/logger'; import { Hyperswap } from '../hyperswap'; +import { HyperswapConfig } from '../hyperswap.config'; import { getHyperswapV2RouterAddress, IHyperswapV2Router02ABI, IHyperswapV2PairABI } from '../hyperswap.contracts'; import { formatTokenAmount, getHyperswapPoolInfo } from '../hyperswap.utils'; import { HyperswapAmmRemoveLiquidityRequest } from '../schemas'; @@ -18,8 +19,6 @@ import { checkLPAllowance } from './positionInfo'; const AMM_REMOVE_LIQUIDITY_GAS_LIMIT = 400000; export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - fastify.post<{ Body: Static; Reply: RemoveLiquidityResponseType; @@ -41,6 +40,7 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { network, poolAddress, percentageToRemove, + slippagePct = HyperswapConfig.config.slippagePct, walletAddress: requestedWalletAddress, gasPrice, maxGas, @@ -121,8 +121,7 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { const routerAddress = getHyperswapV2RouterAddress(networkToUse); const router = new Contract(routerAddress, IHyperswapV2Router02ABI.abi, wallet); - // Calculate slippage-adjusted amounts (0.5% slippage by default) - const slippageTolerance = new Percent(5, 1000); // 0.5% + const slippageTolerance = new Percent(Math.floor(slippagePct * 100), 10000); const slippageMultiplier = new Percent(1).subtract(slippageTolerance); const baseTokenMinAmount = expectedBaseTokenAmount @@ -143,49 +142,21 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { // 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, - ); - } + const tx = await router.removeLiquidity( + token0, + token1, + liquidityToRemove, + token0IsBase ? baseTokenMinAmount : quoteTokenMinAmount, + token0IsBase ? quoteTokenMinAmount : baseTokenMinAmount, + walletAddress, + deadline, + gasOptions, + ); // Wait for transaction confirmation const receipt = await ethereum.handleTransactionExecution(tx); diff --git a/src/connectors/hyperswap/hyperswap.config.ts b/src/connectors/hyperswap/hyperswap.config.ts index b07381a780..2751ffb718 100644 --- a/src/connectors/hyperswap/hyperswap.config.ts +++ b/src/connectors/hyperswap/hyperswap.config.ts @@ -16,7 +16,7 @@ export namespace HyperswapConfig { export const config: RootConfig = { slippagePct: ConfigManagerV2.getInstance().get('hyperswap.slippagePct'), - maximumHops: ConfigManagerV2.getInstance().get('hyperswap.maximumHops') || 4, + maximumHops: ConfigManagerV2.getInstance().get('hyperswap.maximumHops'), availableNetworks: [ { chain, diff --git a/src/connectors/hyperswap/hyperswap.utils.ts b/src/connectors/hyperswap/hyperswap.utils.ts index 1b586071f6..5e6e5dd8c5 100644 --- a/src/connectors/hyperswap/hyperswap.utils.ts +++ b/src/connectors/hyperswap/hyperswap.utils.ts @@ -27,15 +27,10 @@ export const findPoolAddress = ( }; export const formatTokenAmount = (amount: string | number, decimals: number): number => { - try { - if (typeof amount === 'string') { - return parseFloat(amount) / Math.pow(10, decimals); - } - return amount / Math.pow(10, decimals); - } catch (error) { - logger.error(`Error formatting token amount: ${error}`); - return 0; + if (typeof amount === 'string') { + return parseFloat(amount) / Math.pow(10, decimals); } + return amount / Math.pow(10, decimals); }; export async function getFullTokenFromSymbol( diff --git a/src/connectors/hyperswap/hyperswap_v2_router_abi.json b/src/connectors/hyperswap/hyperswap_v2_router_abi.json index 1db522ac19..2548f53622 100644 --- a/src/connectors/hyperswap/hyperswap_v2_router_abi.json +++ b/src/connectors/hyperswap/hyperswap_v2_router_abi.json @@ -2,20 +2,107 @@ "abi": [ { "inputs": [ - { - "internalType": "bytes", - "name": "data", - "type": "bytes" - } + { "internalType": "address", "name": "tokenA", "type": "address" }, + { "internalType": "address", "name": "tokenB", "type": "address" }, + { "internalType": "uint256", "name": "amountADesired", "type": "uint256" }, + { "internalType": "uint256", "name": "amountBDesired", "type": "uint256" }, + { "internalType": "uint256", "name": "amountAMin", "type": "uint256" }, + { "internalType": "uint256", "name": "amountBMin", "type": "uint256" }, + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" } ], - "name": "multicall", + "name": "addLiquidity", + "outputs": [ + { "internalType": "uint256", "name": "amountA", "type": "uint256" }, + { "internalType": "uint256", "name": "amountB", "type": "uint256" }, + { "internalType": "uint256", "name": "liquidity", "type": "uint256" } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "token", "type": "address" }, + { "internalType": "uint256", "name": "amountTokenDesired", "type": "uint256" }, + { "internalType": "uint256", "name": "amountTokenMin", "type": "uint256" }, + { "internalType": "uint256", "name": "amountETHMin", "type": "uint256" }, + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" } + ], + "name": "addLiquidityETH", + "outputs": [ + { "internalType": "uint256", "name": "amountToken", "type": "uint256" }, + { "internalType": "uint256", "name": "amountETH", "type": "uint256" }, + { "internalType": "uint256", "name": "liquidity", "type": "uint256" } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "tokenA", "type": "address" }, + { "internalType": "address", "name": "tokenB", "type": "address" }, + { "internalType": "uint256", "name": "liquidity", "type": "uint256" }, + { "internalType": "uint256", "name": "amountAMin", "type": "uint256" }, + { "internalType": "uint256", "name": "amountBMin", "type": "uint256" }, + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" } + ], + "name": "removeLiquidity", "outputs": [ - { - "internalType": "bytes[]", - "name": "results", - "type": "bytes[]" - } + { "internalType": "uint256", "name": "amountA", "type": "uint256" }, + { "internalType": "uint256", "name": "amountB", "type": "uint256" } ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "token", "type": "address" }, + { "internalType": "uint256", "name": "liquidity", "type": "uint256" }, + { "internalType": "uint256", "name": "amountTokenMin", "type": "uint256" }, + { "internalType": "uint256", "name": "amountETHMin", "type": "uint256" }, + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" } + ], + "name": "removeLiquidityETH", + "outputs": [ + { "internalType": "uint256", "name": "amountToken", "type": "uint256" }, + { "internalType": "uint256", "name": "amountETH", "type": "uint256" } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "amountIn", "type": "uint256" }, + { "internalType": "uint256", "name": "amountOutMin", "type": "uint256" }, + { "internalType": "address[]", "name": "path", "type": "address[]" }, + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" } + ], + "name": "swapExactTokensForTokens", + "outputs": [{ "internalType": "uint256[]", "name": "amounts", "type": "uint256[]" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "amountOut", "type": "uint256" }, + { "internalType": "uint256", "name": "amountInMax", "type": "uint256" }, + { "internalType": "address[]", "name": "path", "type": "address[]" }, + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" } + ], + "name": "swapTokensForExactTokens", + "outputs": [{ "internalType": "uint256[]", "name": "amounts", "type": "uint256[]" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes", "name": "data", "type": "bytes" }], + "name": "multicall", + "outputs": [{ "internalType": "bytes[]", "name": "results", "type": "bytes[]" }], "stateMutability": "payable", "type": "function" } diff --git a/src/connectors/hyperswap/schemas.ts b/src/connectors/hyperswap/schemas.ts index 673f2bb87f..261ddf0e0e 100644 --- a/src/connectors/hyperswap/schemas.ts +++ b/src/connectors/hyperswap/schemas.ts @@ -8,11 +8,10 @@ import { HyperswapConfig } from './hyperswap.config'; const ethereumChainConfig = getEthereumChainConfig(); // Constants for examples -const BASE_TOKEN = 'USDT'; -const QUOTE_TOKEN = 'WBNB'; +const BASE_TOKEN = 'WETH'; +const QUOTE_TOKEN = 'USDC'; const SWAP_AMOUNT = 10; -const AMM_POOL_ADDRESS_EXAMPLE = '0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C'; // Hyperswap V2 WETH-USDC pool on Base -const CLMM_POOL_ADDRESS_EXAMPLE = '0x172fcd41e0913e95784454622d1c3724f546f849'; // Hyperswap V3 USDT-WBNB pool on BSC +const AMM_POOL_ADDRESS_EXAMPLE = '0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C'; // Hyperswap V2 WETH-USDC pool on HyperEVM // ======================================== // AMM Request Schemas @@ -32,25 +31,6 @@ export const HyperswapAmmGetPoolInfoRequest = Type.Object({ }), }); -// ======================================== -// CLMM Request Schemas -// ======================================== - -export const HyperswapClmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...HyperswapConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Hyperswap V3 pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), -}); - // ======================================== // Router Request Schemas // ======================================== @@ -133,28 +113,6 @@ export const HyperswapQuoteSwapResponse = Type.Object({ ), }); -// Hyperswap-specific execute-quote request -export const HyperswapExecuteQuoteRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - examples: [ethereumChainConfig.defaultWallet], - }), - ), - network: Type.Optional( - Type.String({ - description: 'The blockchain network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...HyperswapConfig.networks], - }), - ), - quoteId: Type.String({ - description: 'ID of the quote to execute', - examples: ['123e4567-e89b-12d3-a456-426614174000'], - }), -}); - // Hyperswap AMM Add Liquidity Request export const HyperswapAmmAddLiquidityRequest = Type.Object({ network: Type.Optional( @@ -223,193 +181,6 @@ export const HyperswapAmmRemoveLiquidityRequest = Type.Object({ maximum: 100, description: 'Percentage of liquidity to remove', }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Hyperswap AMM Execute Swap Request -export const HyperswapAmmExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...HyperswapConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'Pool address (optional - can be looked up from tokens)', - default: '', - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: HyperswapConfig.config.slippagePct, - }), - ), -}); - -// Hyperswap-specific execute-swap request -export const HyperswapExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - examples: [ethereumChainConfig.defaultWallet], - }), - ), - network: Type.Optional( - Type.String({ - description: 'The blockchain network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...HyperswapConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Token to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other token in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: HyperswapConfig.config.slippagePct, - examples: [1], - }), - ), -}); - -// Hyperswap CLMM Open Position Request -export const HyperswapClmmOpenPositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...HyperswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will open the position', - default: ethereumChainConfig.defaultWallet, - }), - ), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - }), - poolAddress: Type.String({ - description: 'Address of the Hyperswap V3 pool', - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: HyperswapConfig.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], - }), - ), -}); - -// Hyperswap CLMM Add Liquidity Request -export const HyperswapClmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...HyperswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will add liquidity', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position', - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - }), slippagePct: Type.Optional( Type.Number({ minimum: 0, @@ -431,109 +202,8 @@ export const HyperswapClmmAddLiquidityRequest = Type.Object({ ), }); -// Hyperswap CLMM Remove Liquidity Request -export const HyperswapClmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...HyperswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will remove liquidity', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position', - }), - percentageToRemove: Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Hyperswap CLMM Close Position Request -export const HyperswapClmmClosePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...HyperswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will close the position', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position to close', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Hyperswap CLMM Collect Fees Request -export const HyperswapClmmCollectFeesRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...HyperswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will collect fees', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Hyperswap CLMM Execute Swap Request -export const HyperswapClmmExecuteSwapRequest = Type.Object({ +// Hyperswap AMM Execute Swap Request +export const HyperswapAmmExecuteSwapRequest = Type.Object({ walletAddress: Type.Optional( Type.String({ description: 'Wallet address that will execute the swap', @@ -543,26 +213,24 @@ export const HyperswapClmmExecuteSwapRequest = Type.Object({ network: Type.Optional( Type.String({ description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], + default: ethereumChainConfig.defaultNetwork, enum: [...HyperswapConfig.networks], }), ), poolAddress: Type.Optional( Type.String({ description: 'Pool address (optional - can be looked up from tokens)', + default: '', }), ), baseToken: Type.String({ description: 'Base token symbol or address', examples: [BASE_TOKEN], }), - quoteToken: Type.Optional( - Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - ), + quoteToken: Type.String({ + description: 'Quote token symbol or address', + examples: [QUOTE_TOKEN], + }), amount: Type.Number({ description: 'Amount to swap', examples: [SWAP_AMOUNT], @@ -579,15 +247,4 @@ export const HyperswapClmmExecuteSwapRequest = Type.Object({ default: HyperswapConfig.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], - }), - ), });