Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -108,6 +109,10 @@ const swaggerOptions = {
name: '/connector/pancakeswap',
description: 'PancakeSwap EVM connector endpoints',
},
{
name: '/connector/hyperswap',
description: 'HyperSwap EVM connector endpoints',
},
],
components: {
parameters: {
Expand Down Expand Up @@ -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' });
};
Expand Down
7 changes: 7 additions & 0 deletions src/config/routes/getConnectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down
288 changes: 288 additions & 0 deletions src/connectors/hyperswap/amm-routes/addLiquidity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
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<AddLiquidityResponseType> {
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

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)}`,
);

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})`,
);
}

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);

// 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) => {
fastify.post<{
Body: Static<typeof HyperswapAmmAddLiquidityRequest>;
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;
Loading
Loading