From 81353c1278599325c1059fa512919c84ee102d2c Mon Sep 17 00:00:00 2001 From: VeXHarbinger Date: Thu, 13 Aug 2026 12:53:45 -0400 Subject: [PATCH 1/9] fix(pancakeswap): support staked fee collect and live CLMM fee accrual --- .../pancakeswap/clmm-routes/collectFees.ts | 217 +++++++++++++++--- .../pancakeswap/clmm-routes/positionInfo.ts | 139 ++++++++++- .../pancakeswap/pancakeswap.contracts.ts | 15 ++ 3 files changed, 328 insertions(+), 43 deletions(-) diff --git a/src/connectors/pancakeswap/clmm-routes/collectFees.ts b/src/connectors/pancakeswap/clmm-routes/collectFees.ts index 7366604a0b..ad2f5fb4e0 100644 --- a/src/connectors/pancakeswap/clmm-routes/collectFees.ts +++ b/src/connectors/pancakeswap/clmm-routes/collectFees.ts @@ -1,7 +1,7 @@ import { Contract } from '@ethersproject/contracts'; import { CurrencyAmount } from '@pancakeswap/sdk'; import { NonfungiblePositionManager } from '@pancakeswap/v3-sdk'; -import { BigNumber } from 'ethers'; +import { BigNumber, utils } from 'ethers'; import { FastifyPluginAsync } from 'fastify'; import { Address } from 'viem'; @@ -15,11 +15,100 @@ import { import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; -import { POSITION_MANAGER_ABI, getPancakeswapV3NftManagerAddress } from '../pancakeswap.contracts'; +import { + POSITION_MANAGER_ABI, + getPancakeswapV3MasterchefAddress, + getPancakeswapV3NftManagerAddress, +} from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; -// Default gas limit for CLMM collect fees operations -const CLMM_COLLECT_FEES_GAS_LIMIT = 200000; +import { getPositionInfo } from './positionInfo'; + +// Collect on some fee-on-transfer tokens can exceed 200k due transfer hooks. +const CLMM_COLLECT_FEES_GAS_LIMIT = 500000; +const UINT128_MAX = BigNumber.from('0xffffffffffffffffffffffffffffffff'); +const ERC20_BALANCE_OF_ABI = [ + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'balanceOf', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const; + +const NPM_OWNER_OF_ABI = [ + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'ownerOf', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, +] as const; + +const MASTER_CHEF_COLLECT_SELECTOR = '0xfc6f7865'; + +async function getWalletTokenBalance(provider: any, tokenAddress: string, walletAddress: string): Promise { + const tokenContract = new Contract(tokenAddress, ERC20_BALANCE_OF_ABI, provider); + return BigNumber.from((await tokenContract.balanceOf(walletAddress)).toString()); +} + +async function collectFeesFromMasterChef( + network: string, + walletAddress: string, + positionAddress: string, + token0: any, + token1: any, + isBaseToken0: boolean, + ethereum: Ethereum, +): Promise { + const wallet = await ethereum.getWallet(walletAddress); + if (!wallet) { + throw httpErrors.badRequest('Wallet not found'); + } + + const masterChefAddress = getPancakeswapV3MasterchefAddress(network); + const before0 = await getWalletTokenBalance(ethereum.provider, token0.address, walletAddress); + const before1 = await getWalletTokenBalance(ethereum.provider, token1.address, walletAddress); + + const encodedArgs = utils.defaultAbiCoder.encode( + ['uint256', 'address', 'uint128', 'uint128'], + [positionAddress, walletAddress, UINT128_MAX, UINT128_MAX], + ); + const data = `${MASTER_CHEF_COLLECT_SELECTOR}${encodedArgs.slice(2)}`; + + const txParams = await ethereum.prepareGasOptions(undefined, CLMM_COLLECT_FEES_GAS_LIMIT); + const tx = await wallet.sendTransaction({ + to: masterChefAddress, + data, + ...txParams, + }); + const receipt = await ethereum.handleTransactionExecution(tx); + + const after0 = await getWalletTokenBalance(ethereum.provider, token0.address, walletAddress); + const after1 = await getWalletTokenBalance(ethereum.provider, token1.address, walletAddress); + + const rawCollected0 = after0.gte(before0) ? after0.sub(before0) : BigNumber.from(0); + const rawCollected1 = after1.gte(before1) ? after1.sub(before1) : BigNumber.from(0); + + const collectedToken0FeeAmount = formatTokenAmount(rawCollected0.toString(), token0.decimals); + const collectedToken1FeeAmount = formatTokenAmount(rawCollected1.toString(), token1.decimals); + const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + + const baseFeeAmountCollected = isBaseToken0 ? collectedToken0FeeAmount : collectedToken1FeeAmount; + const quoteFeeAmountCollected = isBaseToken0 ? collectedToken1FeeAmount : collectedToken0FeeAmount; + + return { + signature: receipt.transactionHash, + status: receipt.status, + data: { + fee: gasFee, + baseFeeAmountCollected, + quoteFeeAmountCollected, + }, + }; +} export async function collectFees( network: string, @@ -38,14 +127,14 @@ export async function collectFees( } const positionManagerAddress = getPancakeswapV3NftManagerAddress(network); + const masterChefAddress = getPancakeswapV3MasterchefAddress(network); + const ownerReader = new Contract(positionManagerAddress, NPM_OWNER_OF_ABI, ethereum.provider); + const nftOwner = (await ownerReader.ownerOf(positionAddress)).toLowerCase(); + const walletOwner = walletAddress.toLowerCase(); + const isStakedInMasterChef = nftOwner === masterChefAddress.toLowerCase(); - try { - await pancakeswap.checkNFTOwnership(positionAddress, walletAddress); - } catch (error: any) { - if (error.message.includes('is not owned by')) { - throw httpErrors.forbidden(error.message); - } - throw httpErrors.badRequest(error.message); + if (nftOwner !== walletOwner && !isStakedInMasterChef) { + throw httpErrors.forbidden(`Position ${positionAddress} is not owned by wallet ${walletAddress}`); } const positionManager = new Contract(positionManagerAddress, POSITION_MANAGER_ABI, ethereum.provider); @@ -58,24 +147,31 @@ export async function collectFees( token0.symbol === 'WETH' || (token1.symbol !== 'WETH' && token0.address.toLowerCase() < token1.address.toLowerCase()); - const feeAmount0 = position.tokensOwed0; - const feeAmount1 = position.tokensOwed1; - - if (feeAmount0.eq(0) && feeAmount1.eq(0)) { - throw httpErrors.badRequest('No fees to collect'); + if (isStakedInMasterChef) { + logger.info(`Collecting fees for staked NFT ${positionAddress} via MasterChef`); + return await collectFeesFromMasterChef( + network, + walletAddress, + positionAddress, + token0, + token1, + isBaseToken0, + ethereum, + ); } - const expectedCurrencyOwed0 = CurrencyAmount.fromRawAmount(token0, feeAmount0.toString()); - const expectedCurrencyOwed1 = CurrencyAmount.fromRawAmount(token1, feeAmount1.toString()); - - const collectParams = { - tokenId: positionAddress, - expectedCurrencyOwed0, - expectedCurrencyOwed1, - recipient: walletAddress as Address, - }; + const livePositionInfo = await getPositionInfo({ httpErrors } as any, network, positionAddress); + const feeAmount0 = BigNumber.from(position.tokensOwed0.toString()); + const feeAmount1 = BigNumber.from(position.tokensOwed1.toString()); - const { calldata, value } = NonfungiblePositionManager.collectCallParameters(collectParams); + if ( + feeAmount0.eq(0) && + feeAmount1.eq(0) && + Number(livePositionInfo.baseFeeAmount || 0) <= 0 && + Number(livePositionInfo.quoteFeeAmount || 0) <= 0 + ) { + throw httpErrors.badRequest('No fees to collect'); + } const positionManagerWithSigner = new Contract( positionManagerAddress, @@ -91,17 +187,74 @@ export async function collectFees( wallet, ); + const collectCalldataCandidates = [ + { + expectedCurrencyOwed0: CurrencyAmount.fromRawAmount(token0, UINT128_MAX.toString()), + expectedCurrencyOwed1: CurrencyAmount.fromRawAmount(token1, UINT128_MAX.toString()), + mode: 'both' as const, + }, + { + expectedCurrencyOwed0: CurrencyAmount.fromRawAmount(token0, UINT128_MAX.toString()), + expectedCurrencyOwed1: CurrencyAmount.fromRawAmount(token1, '0'), + mode: 'token0-only' as const, + }, + { + expectedCurrencyOwed0: CurrencyAmount.fromRawAmount(token0, '0'), + expectedCurrencyOwed1: CurrencyAmount.fromRawAmount(token1, UINT128_MAX.toString()), + mode: 'token1-only' as const, + }, + ]; + + let selectedCalldata: string | null = null; + let selectedValue = BigNumber.from(0); + let selectedMode: 'both' | 'token0-only' | 'token1-only' = 'both'; + const txParams = await ethereum.prepareGasOptions(undefined, CLMM_COLLECT_FEES_GAS_LIMIT); - txParams.value = BigNumber.from(value.toString()); - const tx = await positionManagerWithSigner.multicall([calldata], txParams); + for (const candidate of collectCalldataCandidates) { + const collectParams = { + tokenId: positionAddress, + expectedCurrencyOwed0: candidate.expectedCurrencyOwed0, + expectedCurrencyOwed1: candidate.expectedCurrencyOwed1, + recipient: walletAddress as Address, + }; + + const { calldata, value } = NonfungiblePositionManager.collectCallParameters(collectParams); + const probeParams = { ...txParams, value: BigNumber.from(value.toString()) }; + + try { + await positionManagerWithSigner.callStatic.multicall([calldata], probeParams); + selectedCalldata = calldata; + selectedValue = BigNumber.from(value.toString()); + selectedMode = candidate.mode; + break; + } catch (probeError: any) { + logger.warn( + `Collect fees probe failed for ${candidate.mode} on position ${positionAddress}: ${probeError?.message || probeError}`, + ); + } + } + + if (!selectedCalldata) { + throw httpErrors.badRequest(`Unable to collect fees for position ${positionAddress}: all collect modes reverted`); + } + + txParams.value = selectedValue; + const tx = await positionManagerWithSigner.multicall([selectedCalldata], txParams); const receipt = await ethereum.handleTransactionExecution(tx); const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); - const token0FeeAmount = formatTokenAmount(feeAmount0.toString(), token0.decimals); - const token1FeeAmount = formatTokenAmount(feeAmount1.toString(), token1.decimals); + const liveBaseFeeAmount = Number(livePositionInfo.baseFeeAmount || 0); + const liveQuoteFeeAmount = Number(livePositionInfo.quoteFeeAmount || 0); + const estimatedToken0Collected = + selectedMode === 'token1-only' ? 0 : isBaseToken0 ? liveBaseFeeAmount : liveQuoteFeeAmount; + const estimatedToken1Collected = + selectedMode === 'token0-only' ? 0 : isBaseToken0 ? liveQuoteFeeAmount : liveBaseFeeAmount; + + const collectedToken0FeeAmount = estimatedToken0Collected; + const collectedToken1FeeAmount = estimatedToken1Collected; - const baseFeeAmountCollected = isBaseToken0 ? token0FeeAmount : token1FeeAmount; - const quoteFeeAmountCollected = isBaseToken0 ? token1FeeAmount : token0FeeAmount; + const baseFeeAmountCollected = isBaseToken0 ? collectedToken0FeeAmount : collectedToken1FeeAmount; + const quoteFeeAmountCollected = isBaseToken0 ? collectedToken1FeeAmount : collectedToken0FeeAmount; return { signature: receipt.transactionHash, diff --git a/src/connectors/pancakeswap/clmm-routes/positionInfo.ts b/src/connectors/pancakeswap/clmm-routes/positionInfo.ts index 754fbde1e6..e98f796278 100644 --- a/src/connectors/pancakeswap/clmm-routes/positionInfo.ts +++ b/src/connectors/pancakeswap/clmm-routes/positionInfo.ts @@ -1,5 +1,5 @@ import { Contract } from '@ethersproject/contracts'; -import { Position, tickToPrice, computePoolAddress } from '@pancakeswap/v3-sdk'; +import { Position, PositionLibrary, tickToPrice, computePoolAddress } from '@pancakeswap/v3-sdk'; import { FastifyPluginAsync, FastifyInstance } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; @@ -18,6 +18,85 @@ import { } from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; +const POOL_STATE_ABI = [ + { + inputs: [], + name: 'slot0', + outputs: [ + { internalType: 'uint160', name: 'sqrtPriceX96', type: 'uint160' }, + { internalType: 'int24', name: 'tick', type: 'int24' }, + { internalType: 'uint16', name: 'observationIndex', type: 'uint16' }, + { internalType: 'uint16', name: 'observationCardinality', type: 'uint16' }, + { internalType: 'uint16', name: 'observationCardinalityNext', type: 'uint16' }, + { internalType: 'uint8', name: 'feeProtocol', type: 'uint8' }, + { internalType: 'bool', name: 'unlocked', type: 'bool' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'feeGrowthGlobal0X128', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'feeGrowthGlobal1X128', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'int24', name: '', type: 'int24' }], + name: 'ticks', + outputs: [ + { internalType: 'uint128', name: 'liquidityGross', type: 'uint128' }, + { internalType: 'int128', name: 'liquidityNet', type: 'int128' }, + { internalType: 'uint256', name: 'feeGrowthOutside0X128', type: 'uint256' }, + { internalType: 'uint256', name: 'feeGrowthOutside1X128', type: 'uint256' }, + { internalType: 'int56', name: 'tickCumulativeOutside', type: 'int56' }, + { internalType: 'uint160', name: 'secondsPerLiquidityOutsideX128', type: 'uint160' }, + { internalType: 'uint32', name: 'secondsOutside', type: 'uint32' }, + { internalType: 'bool', name: 'initialized', type: 'bool' }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +function getFeeGrowthInside( + tickCurrent: number, + tickLower: number, + tickUpper: number, + lowerFeeGrowthOutside0X128: bigint, + lowerFeeGrowthOutside1X128: bigint, + upperFeeGrowthOutside0X128: bigint, + upperFeeGrowthOutside1X128: bigint, + feeGrowthGlobal0X128: bigint, + feeGrowthGlobal1X128: bigint, +): [bigint, bigint] { + if (tickCurrent < tickLower) { + return [ + lowerFeeGrowthOutside0X128 - upperFeeGrowthOutside0X128, + lowerFeeGrowthOutside1X128 - upperFeeGrowthOutside1X128, + ]; + } + + if (tickCurrent < tickUpper) { + return [ + feeGrowthGlobal0X128 - lowerFeeGrowthOutside0X128 - upperFeeGrowthOutside0X128, + feeGrowthGlobal1X128 - lowerFeeGrowthOutside1X128 - upperFeeGrowthOutside1X128, + ]; + } + + return [ + upperFeeGrowthOutside0X128 - lowerFeeGrowthOutside0X128, + upperFeeGrowthOutside1X128 - lowerFeeGrowthOutside1X128, + ]; +} + export async function getPositionInfo( fastify: FastifyInstance, network: string, @@ -44,14 +123,59 @@ export async function getPositionInfo( const liquidity = positionDetails.liquidity; const fee = positionDetails.fee; - const feeAmount0 = formatTokenAmount(positionDetails.tokensOwed0.toString(), token0.decimals); - const feeAmount1 = formatTokenAmount(positionDetails.tokensOwed1.toString(), token1.decimals); - const pool = await pancakeswap.getV3Pool(token0, token1, fee); if (!pool) { throw fastify.httpErrors.notFound('Pool not found for position'); } + const poolAddress = computePoolAddress({ + deployerAddress: getPancakeswapV3PoolDeployerAddress(network), + tokenA: token0, + tokenB: token1, + fee, + }); + + const poolContract = new Contract(poolAddress, POOL_STATE_ABI, ethereum.provider); + const slot0 = await poolContract.slot0(); + const tickCurrent = Number(slot0.tick ?? slot0[1]); + const lowerTick = await poolContract.ticks(tickLower); + const upperTick = await poolContract.ticks(tickUpper); + const feeGrowthGlobal0X128 = BigInt((await poolContract.feeGrowthGlobal0X128()).toString()); + const feeGrowthGlobal1X128 = BigInt((await poolContract.feeGrowthGlobal1X128()).toString()); + + const lowerFeeGrowthOutside0X128 = BigInt((lowerTick.feeGrowthOutside0X128 ?? lowerTick[2]).toString()); + const lowerFeeGrowthOutside1X128 = BigInt((lowerTick.feeGrowthOutside1X128 ?? lowerTick[3]).toString()); + const upperFeeGrowthOutside0X128 = BigInt((upperTick.feeGrowthOutside0X128 ?? upperTick[2]).toString()); + const upperFeeGrowthOutside1X128 = BigInt((upperTick.feeGrowthOutside1X128 ?? upperTick[3]).toString()); + + const [feeGrowthInside0X128, feeGrowthInside1X128] = getFeeGrowthInside( + tickCurrent, + tickLower, + tickUpper, + lowerFeeGrowthOutside0X128, + lowerFeeGrowthOutside1X128, + upperFeeGrowthOutside0X128, + upperFeeGrowthOutside1X128, + feeGrowthGlobal0X128, + feeGrowthGlobal1X128, + ); + + const feeGrowthInside0LastX128 = BigInt(positionDetails.feeGrowthInside0LastX128.toString()); + const feeGrowthInside1LastX128 = BigInt(positionDetails.feeGrowthInside1LastX128.toString()); + const liquidityBigInt = BigInt(positionDetails.liquidity.toString()); + const [deltaOwed0, deltaOwed1] = PositionLibrary.getTokensOwed( + feeGrowthInside0LastX128, + feeGrowthInside1LastX128, + liquidityBigInt, + feeGrowthInside0X128, + feeGrowthInside1X128, + ); + + const totalOwed0 = BigInt(positionDetails.tokensOwed0.toString()) + deltaOwed0; + const totalOwed1 = BigInt(positionDetails.tokensOwed1.toString()) + deltaOwed1; + const feeAmount0 = formatTokenAmount(totalOwed0.toString(), token0.decimals); + const feeAmount1 = formatTokenAmount(totalOwed1.toString(), token1.decimals); + const lowerPrice = tickToPrice(token0, token1, tickLower).toSignificant(6); const upperPrice = tickToPrice(token0, token1, tickUpper).toSignificant(6); const price = pool.token0Price.toSignificant(6); @@ -80,13 +204,6 @@ export async function getPositionInfo( const [baseFeeAmount, quoteFeeAmount] = isBaseToken0 ? [feeAmount0, feeAmount1] : [feeAmount1, feeAmount0]; - const poolAddress = computePoolAddress({ - deployerAddress: getPancakeswapV3PoolDeployerAddress(network), - tokenA: token0, - tokenB: token1, - fee, - }); - return { address: positionAddress, poolAddress, diff --git a/src/connectors/pancakeswap/pancakeswap.contracts.ts b/src/connectors/pancakeswap/pancakeswap.contracts.ts index 39f483da3e..092be3e151 100644 --- a/src/connectors/pancakeswap/pancakeswap.contracts.ts +++ b/src/connectors/pancakeswap/pancakeswap.contracts.ts @@ -23,6 +23,7 @@ export interface PancakeswapContractAddresses { pancakeswapV3QuoterV2ContractAddress: Address; pancakeswapV3FactoryAddress: Address; pancakeswapV3PoolDeployerAddress: Address; + pancakeswapV3MasterchefAddress: Address; // Universal Router V2 (unified router for all protocols) universalRouterV2Address: Address; @@ -43,6 +44,7 @@ export const contractAddresses: NetworkContractAddresses = { pancakeswapV3QuoterV2ContractAddress: '0xB048Bbc1Ee6b733FFfCFb9e9CeF7375518e25997', pancakeswapV3FactoryAddress: '0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865', pancakeswapV3PoolDeployerAddress: '0x41ff9AA7e16B8B1a8a8dc4f0eFacd93D02d071c9', + pancakeswapV3MasterchefAddress: '0x556B9306565093C855AEA9AE92A594704c2Cd59e', // Universal Router V2 - Official Pancakeswap address universalRouterV2Address: '0x13f4EA83D0bd40E75C8222255bc855a974568Dd4', }, @@ -56,6 +58,7 @@ export const contractAddresses: NetworkContractAddresses = { pancakeswapV3QuoterV2ContractAddress: '0xB048Bbc1Ee6b733FFfCFb9e9CeF7375518e25997', pancakeswapV3FactoryAddress: '0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865', pancakeswapV3PoolDeployerAddress: '0x41ff9AA7e16B8B1a8a8dc4f0eFacd93D02d071c9', + pancakeswapV3MasterchefAddress: '0x5e09ACf80C0296740eC5d6F643005a4ef8DaA694', // Universal Router V2 - Official Pancakeswap address universalRouterV2Address: '0x32226588378236Fd0c7c4053999F88aC0e5cAc77', }, @@ -69,6 +72,7 @@ export const contractAddresses: NetworkContractAddresses = { pancakeswapV3QuoterV2ContractAddress: '0xB048Bbc1Ee6b733FFfCFb9e9CeF7375518e25997', pancakeswapV3FactoryAddress: '0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865', pancakeswapV3PoolDeployerAddress: '0x41ff9AA7e16B8B1a8a8dc4f0eFacd93D02d071c9', + pancakeswapV3MasterchefAddress: '0xC6A2Db661D5a5690172d8eB0a7DEA2d3008665A3', // Universal Router V2 - Official Pancakeswap address universalRouterV2Address: '0x678Aa4bF4E210cf2166753e054d5b7c31cc7fa86', }, @@ -82,6 +86,7 @@ export const contractAddresses: NetworkContractAddresses = { pancakeswapV3QuoterV2ContractAddress: '0xB048Bbc1Ee6b733FFfCFb9e9CeF7375518e25997', pancakeswapV3FactoryAddress: '0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865', pancakeswapV3PoolDeployerAddress: '0x41ff9AA7e16B8B1a8a8dc4f0eFacd93D02d071c9', + pancakeswapV3MasterchefAddress: '0x556B9306565093C855AEA9AE92A594704c2Cd59e', // Universal Router V2 - Official Pancakeswap address universalRouterV2Address: '0x13f4EA83D0bd40E75C8222255bc855a974568Dd4', }, @@ -183,6 +188,16 @@ export function getPancakeswapV3PoolDeployerAddress(network: string): Address { return address; } +export function getPancakeswapV3MasterchefAddress(network: string): Address { + const address = contractAddresses[network]?.pancakeswapV3MasterchefAddress; + + if (!address) { + throw new Error(`Pancakeswap V3 MasterChef address not configured for network: ${network}`); + } + + return address; +} + /** * Returns the appropriate spender address based on the connector name * @param network The network name (e.g. 'mainnet', 'base') From d03852d46b7310254e59cdd0415626d73dd450de Mon Sep 17 00:00:00 2001 From: VeXHarbinger Date: Thu, 13 Aug 2026 13:02:29 -0400 Subject: [PATCH 2/9] fix(pancakeswap): include PR646 fixes 1/2/3/5 and masterchef routes --- .../PancakeswapV3Masterchef.abi.json | 363 +++++++++++++++++ .../pancakeswap/clmm-routes/executeSwap.ts | 7 +- .../pancakeswap/clmm-routes/poolInfo.ts | 37 +- .../pancakeswap/clmm-routes/positionsOwned.ts | 21 +- .../pancakeswap/clmm-routes/quotePosition.ts | 15 +- .../pancakeswap/nft-staking/index.ts | 15 + .../nft-staking/masterchef-knows-pool.ts | 65 +++ .../nft-staking/masterchef-stake.ts | 122 ++++++ .../masterchef-unstake-and-close.ts | 136 +++++++ .../nft-staking/masterchef-unstake.ts | 76 ++++ .../pancakeswap/pancakeswap.routes.ts | 17 + src/connectors/pancakeswap/pancakeswap.ts | 373 +++++++++++++++++- src/connectors/pancakeswap/schemas.ts | 10 + 13 files changed, 1236 insertions(+), 21 deletions(-) create mode 100644 src/connectors/pancakeswap/PancakeswapV3Masterchef.abi.json create mode 100644 src/connectors/pancakeswap/nft-staking/index.ts create mode 100644 src/connectors/pancakeswap/nft-staking/masterchef-knows-pool.ts create mode 100644 src/connectors/pancakeswap/nft-staking/masterchef-stake.ts create mode 100644 src/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close.ts create mode 100644 src/connectors/pancakeswap/nft-staking/masterchef-unstake.ts diff --git a/src/connectors/pancakeswap/PancakeswapV3Masterchef.abi.json b/src/connectors/pancakeswap/PancakeswapV3Masterchef.abi.json new file mode 100644 index 0000000000..6048113f85 --- /dev/null +++ b/src/connectors/pancakeswap/PancakeswapV3Masterchef.abi.json @@ -0,0 +1,363 @@ +[ + { + "inputs": [ + { "internalType": "contract IERC20", "name": "_CAKE", "type": "address" }, + { + "internalType": "contract INonfungiblePositionManager", + "name": "_nonfungiblePositionManager", + "type": "address" + }, + { "internalType": "address", "name": "_WETH", "type": "address" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [{ "internalType": "uint256", "name": "pid", "type": "uint256" }], + "name": "DuplicatedPool", + "type": "error" + }, + { "inputs": [], "name": "InconsistentAmount", "type": "error" }, + { "inputs": [], "name": "InsufficientAmount", "type": "error" }, + { "inputs": [], "name": "InvalidNFT", "type": "error" }, + { "inputs": [], "name": "InvalidPeriodDuration", "type": "error" }, + { "inputs": [], "name": "InvalidPid", "type": "error" }, + { "inputs": [], "name": "NoBalance", "type": "error" }, + { "inputs": [], "name": "NoLMPool", "type": "error" }, + { "inputs": [], "name": "NoLiquidity", "type": "error" }, + { "inputs": [], "name": "NotEmpty", "type": "error" }, + { "inputs": [], "name": "NotOwner", "type": "error" }, + { "inputs": [], "name": "NotOwnerOrOperator", "type": "error" }, + { "inputs": [], "name": "NotPancakeNFT", "type": "error" }, + { "inputs": [], "name": "WrongReceiver", "type": "error" }, + { "inputs": [], "name": "ZeroAddress", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "uint256", "name": "pid", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "allocPoint", "type": "uint256" }, + { "indexed": true, "internalType": "contract IPancakeV3Pool", "name": "v3Pool", "type": "address" }, + { "indexed": true, "internalType": "contract ILMPool", "name": "lmPool", "type": "address" } + ], + "name": "AddPool", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "from", "type": "address" }, + { "indexed": true, "internalType": "uint256", "name": "pid", "type": "uint256" }, + { "indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "liquidity", "type": "uint256" }, + { "indexed": false, "internalType": "int24", "name": "tickLower", "type": "int24" }, + { "indexed": false, "internalType": "int24", "name": "tickUpper", "type": "int24" } + ], + "name": "Deposit", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "sender", "type": "address" }, + { "indexed": false, "internalType": "address", "name": "to", "type": "address" }, + { "indexed": true, "internalType": "uint256", "name": "pid", "type": "uint256" }, + { "indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "reward", "type": "uint256" } + ], + "name": "Harvest", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "address", "name": "deployer", "type": "address" }], + "name": "NewLMPoolDeployerAddress", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "address", "name": "operator", "type": "address" }], + "name": "NewOperatorAddress", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "uint256", "name": "periodDuration", "type": "uint256" }], + "name": "NewPeriodDuration", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "address", "name": "receiver", "type": "address" }], + "name": "NewReceiver", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "uint256", "name": "periodNumber", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "startTime", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "endTime", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "cakePerSecond", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "cakeAmount", "type": "uint256" } + ], + "name": "NewUpkeepPeriod", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "previousOwner", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "newOwner", "type": "address" } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "bool", "name": "emergency", "type": "bool" }], + "name": "SetEmergency", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "uint256", "name": "pid", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "allocPoint", "type": "uint256" } + ], + "name": "SetPool", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "farmBoostContract", "type": "address" }], + "name": "UpdateFarmBoostContract", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "from", "type": "address" }, + { "indexed": true, "internalType": "uint256", "name": "pid", "type": "uint256" }, + { "indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256" }, + { "indexed": false, "internalType": "int128", "name": "liquidity", "type": "int128" }, + { "indexed": false, "internalType": "int24", "name": "tickLower", "type": "int24" }, + { "indexed": false, "internalType": "int24", "name": "tickUpper", "type": "int24" } + ], + "name": "UpdateLiquidity", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "uint256", "name": "periodNumber", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "oldEndTime", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "newEndTime", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "remainingCake", "type": "uint256" } + ], + "name": "UpdateUpkeepPeriod", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "from", "type": "address" }, + { "indexed": false, "internalType": "address", "name": "to", "type": "address" }, + { "indexed": true, "internalType": "uint256", "name": "pid", "type": "uint256" }, + { "indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256" } + ], + "name": "Withdraw", + "type": "event" + }, + { + "inputs": [], + "name": "BOOST_PRECISION", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "CAKE", + "outputs": [{ "internalType": "contract IERC20", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "FARM_BOOSTER", + "outputs": [{ "internalType": "contract IFarmBooster", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "LMPoolDeployer", + "outputs": [{ "internalType": "contract ILMPoolDeployer", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_BOOST_PRECISION", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_DURATION", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_DURATION", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "PERIOD_DURATION", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "PRECISION", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "WETH", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_allocPoint", "type": "uint256" }, + { "internalType": "contract IPancakeV3Pool", "name": "_v3Pool", "type": "address" }, + { "internalType": "bool", "name": "_withUpdate", "type": "bool" } + ], + "name": "add", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "owner", "type": "address" }], + "name": "balanceOf", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "_tokenId", "type": "uint256" }], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "cakeAmountBelongToMC", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "_v3Pool", "type": "address" }], + "name": "getLatestPeriodInfo", + "outputs": [ + { "internalType": "uint256", "name": "cakePerSecond", "type": "uint256" }, + { "internalType": "uint256", "name": "endTime", "type": "uint256" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "_pid", "type": "uint256" }], + "name": "getLatestPeriodInfoByPid", + "outputs": [ + { "internalType": "uint256", "name": "cakePerSecond", "type": "uint256" }, + { "internalType": "uint256", "name": "endTime", "type": "uint256" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_tokenId", "type": "uint256" }, + { "internalType": "address", "name": "_to", "type": "address" } + ], + "name": "harvest", + "outputs": [{ "internalType": "uint256", "name": "reward", "type": "uint256" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "latestPeriodCakePerSecond", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestPeriodEndTime", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestPeriodNumber", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestPeriodStartTime", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes[]", "name": "data", "type": "bytes[]" }], + "name": "multicall", + "outputs": [{ "internalType": "bytes[]", "name": "results", "type": "bytes[]" }], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "nonfungiblePositionManager", + "outputs": [{ "internalType": "contract INonfungiblePositionManager", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "v3PoolAddressPid", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_tokenId", "type": "uint256" }, + { "internalType": "address", "name": "_to", "type": "address" } + ], + "name": "withdraw", + "outputs": [{ "internalType": "uint256", "name": "reward", "type": "uint256" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { "stateMutability": "payable", "type": "receive" } +] diff --git a/src/connectors/pancakeswap/clmm-routes/executeSwap.ts b/src/connectors/pancakeswap/clmm-routes/executeSwap.ts index 47fd5e91da..bb63898021 100644 --- a/src/connectors/pancakeswap/clmm-routes/executeSwap.ts +++ b/src/connectors/pancakeswap/clmm-routes/executeSwap.ts @@ -1,4 +1,3 @@ -import { encodeSqrtRatioX96 } from '@uniswap/v3-sdk'; import { BigNumber, Contract, utils } from 'ethers'; import { FastifyPluginAsync } from 'fastify'; @@ -103,10 +102,8 @@ export async function executeClmmSwap( amountOut: 0, amountInMaximum: 0, amountOutMinimum: 0, - sqrtPriceLimitX96: encodeSqrtRatioX96( - quote.trade.executionPrice.numerator, - quote.trade.executionPrice.denominator, - ).toString(), + // Use no-limit sentinel for V3 router to avoid JSBI cross-SDK incompatibility. + sqrtPriceLimitX96: '0', }; let receipt; diff --git a/src/connectors/pancakeswap/clmm-routes/poolInfo.ts b/src/connectors/pancakeswap/clmm-routes/poolInfo.ts index 8efd314f11..da47834c93 100644 --- a/src/connectors/pancakeswap/clmm-routes/poolInfo.ts +++ b/src/connectors/pancakeswap/clmm-routes/poolInfo.ts @@ -1,13 +1,23 @@ +import { Contract as EthersProjectContract } from '@ethersproject/contracts'; +import { abi as IUniswapV3PoolABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/IUniswapV3Pool.sol/IUniswapV3Pool.json'; import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import JSBI from 'jsbi'; +import { Ethereum } from '../../../chains/ethereum/ethereum'; import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { computeUniswapBinDistribution } from '../../uniswap/uniswap.utils'; import { Pancakeswap } from '../pancakeswap'; import { formatTokenAmount, getPancakeswapPoolInfo } from '../pancakeswap.utils'; import { PancakeswapClmmGetPoolInfoRequest } from '../schemas'; -export async function getPoolInfo(fastify: FastifyInstance, network: string, poolAddress: string): Promise { +export async function getPoolInfo( + fastify: FastifyInstance, + network: string, + poolAddress: string, + binCount: number = 0, +): Promise { const pancakeswap = await Pancakeswap.getInstance(network); if (!poolAddress) { @@ -52,7 +62,7 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo const tickSpacing = pool.tickSpacing; const activeBinId = pool.tickCurrent; - return { + const result: PoolInfo = { address: poolAddress, baseTokenAddress: baseTokenObj.address, quoteTokenAddress: quoteTokenObj.address, @@ -63,6 +73,24 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo quoteTokenAmount: quoteTokenAmount, activeBinId: activeBinId, }; + + if (binCount > 0) { + const ethereum = await Ethereum.getInstance(network); + const poolContract = new EthersProjectContract(poolAddress, IUniswapV3PoolABI, ethereum.provider); + result.bins = await computeUniswapBinDistribution({ + poolContract, + tickSpacing, + currentTick: activeBinId, + currentSqrtPriceX96: JSBI.BigInt(pool.sqrtRatioX96.toString()), + activeLiquidity: JSBI.BigInt(pool.liquidity.toString()), + decimals0: token0.decimals, + decimals1: token1.decimals, + isBaseToken0, + binCount, + }); + } + + return result; } export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { @@ -83,9 +111,8 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { }, async (request): Promise => { try { - const { poolAddress } = request.query; - const network = request.query.network; - return await getPoolInfo(fastify, network, poolAddress); + const { poolAddress, binCount = 0, network } = request.query; + return await getPoolInfo(fastify, network, poolAddress, binCount); } catch (e) { logger.error(e); if (e.statusCode) { diff --git a/src/connectors/pancakeswap/clmm-routes/positionsOwned.ts b/src/connectors/pancakeswap/clmm-routes/positionsOwned.ts index 85d4cbacb6..853ca810fe 100644 --- a/src/connectors/pancakeswap/clmm-routes/positionsOwned.ts +++ b/src/connectors/pancakeswap/clmm-routes/positionsOwned.ts @@ -18,6 +18,9 @@ import { formatTokenAmount } from '../pancakeswap.utils'; const PositionsOwnedRequest = Type.Object({ network: Type.Optional(Type.String({ examples: ['bsc'], default: 'bsc' })), walletAddress: Type.String({ examples: [''] }), + activeOnly: Type.Optional( + Type.Boolean({ description: 'If true, only return positions with active liquidity (in range)', default: false }), + ), }); const PositionsOwnedResponse = Type.Array(PositionInfoSchema); @@ -47,6 +50,7 @@ export async function getPositionsOwned( fastify: FastifyInstance, network: string, walletAddress?: string, + activeOnly: boolean = false, ): Promise { const pancakeswap = await Pancakeswap.getInstance(network); const ethereum = await Ethereum.getInstance(network); @@ -92,6 +96,14 @@ export async function getPositionsOwned( continue; } + // Check if position is in range + const inRange = pool.tickCurrent >= positionDetails.tickLower && pool.tickCurrent < positionDetails.tickUpper; + + // If activeOnly is true, skip positions that are not in range + if (activeOnly && !inRange) { + continue; + } + const position = new Position({ pool, tickLower: positionDetails.tickLower, @@ -99,9 +111,8 @@ export async function getPositionsOwned( liquidity: positionDetails.liquidity.toString(), }); - const isBaseToken0 = - token0.symbol === 'WETH' || - (token1.symbol !== 'WETH' && token0.address.toLowerCase() < token1.address.toLowerCase()); + // Match pool token ordering rules consistently across non-WETH pairs. + const isBaseToken0 = token0.address.toLowerCase() < token1.address.toLowerCase(); positions.push({ address: tokenId.toString(), @@ -170,9 +181,9 @@ export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { walletAddress } = request.query; + const { walletAddress, activeOnly } = request.query; const network = request.query.network; - return await getPositionsOwned(fastify, network, walletAddress); + return await getPositionsOwned(fastify, network, walletAddress, activeOnly); } catch (e) { logger.error(e); if (e.statusCode) { diff --git a/src/connectors/pancakeswap/clmm-routes/quotePosition.ts b/src/connectors/pancakeswap/clmm-routes/quotePosition.ts index 801ec25937..780a257af6 100644 --- a/src/connectors/pancakeswap/clmm-routes/quotePosition.ts +++ b/src/connectors/pancakeswap/clmm-routes/quotePosition.ts @@ -454,8 +454,11 @@ export async function quotePosition( if (baseTokenAmount !== undefined && quoteTokenAmount !== undefined) { // Both amounts provided - use fromAmounts to calculate optimal position - const baseAmountRaw = JSBI.BigInt(Math.floor(baseTokenAmount * Math.pow(10, baseTokenObj.decimals)).toString()); - const quoteAmountRaw = JSBI.BigInt(Math.floor(quoteTokenAmount * Math.pow(10, quoteTokenObj.decimals)).toString()); + // Use parseUnits to avoid scientific notation issues with large numbers + const baseAmountRaw = JSBI.BigInt(utils.parseUnits(baseTokenAmount.toString(), baseTokenObj.decimals).toString()); + const quoteAmountRaw = JSBI.BigInt( + utils.parseUnits(quoteTokenAmount.toString(), quoteTokenObj.decimals).toString(), + ); // Create position from both amounts if (isBaseToken0) { @@ -488,7 +491,8 @@ export async function quotePosition( baseLimited = baseRatio <= quoteRatio; } else if (baseTokenAmount !== undefined) { // Only base amount provided - const baseAmountRaw = JSBI.BigInt(Math.floor(baseTokenAmount * Math.pow(10, baseTokenObj.decimals)).toString()); + // Use parseUnits to avoid scientific notation issues with large numbers + const baseAmountRaw = JSBI.BigInt(utils.parseUnits(baseTokenAmount.toString(), baseTokenObj.decimals).toString()); if (isBaseToken0) { position = Position.fromAmount0({ @@ -509,7 +513,10 @@ export async function quotePosition( baseLimited = true; } else if (quoteTokenAmount !== undefined) { // Only quote amount provided - const quoteAmountRaw = JSBI.BigInt(Math.floor(quoteTokenAmount * Math.pow(10, quoteTokenObj.decimals)).toString()); + // Use parseUnits to avoid scientific notation issues with large numbers + const quoteAmountRaw = JSBI.BigInt( + utils.parseUnits(quoteTokenAmount.toString(), quoteTokenObj.decimals).toString(), + ); if (isBaseToken0) { position = Position.fromAmount1({ diff --git a/src/connectors/pancakeswap/nft-staking/index.ts b/src/connectors/pancakeswap/nft-staking/index.ts new file mode 100644 index 0000000000..f4e8ada5a1 --- /dev/null +++ b/src/connectors/pancakeswap/nft-staking/index.ts @@ -0,0 +1,15 @@ +import { FastifyPluginAsync } from 'fastify'; + +import masterchefKnowsPoolRoute from './masterchef-knows-pool'; +import masterchefStakeRoutes from './masterchef-stake'; +import masterchefUnstakeRoutes from './masterchef-unstake'; +import masterchefUnstakeAndCloseRoutes from './masterchef-unstake-and-close'; + +export const pancakeswapNftStakingRoutes: FastifyPluginAsync = async (fastify) => { + await fastify.register(masterchefStakeRoutes); + await fastify.register(masterchefUnstakeRoutes); + await fastify.register(masterchefUnstakeAndCloseRoutes); + await fastify.register(masterchefKnowsPoolRoute); +}; + +export default pancakeswapNftStakingRoutes; diff --git a/src/connectors/pancakeswap/nft-staking/masterchef-knows-pool.ts b/src/connectors/pancakeswap/nft-staking/masterchef-knows-pool.ts new file mode 100644 index 0000000000..85417dba7d --- /dev/null +++ b/src/connectors/pancakeswap/nft-staking/masterchef-knows-pool.ts @@ -0,0 +1,65 @@ +import { Static, Type } from '@sinclair/typebox'; +import { FastifyInstance } from 'fastify'; + +import { Pancakeswap } from '../pancakeswap'; + +const MasterChefKnowsPoolSchema = Type.Object({ + network: Type.String({ + description: 'Blockchain network to use (e.g., "bsc").', + examples: ['bsc'], + default: 'bsc', + }), + poolAddress: Type.String({ + description: 'The address of the PancakeSwap V3 pool to check.', + examples: ['0xA5067360b13Fc7A2685Dc82dcD1bF2B4B8D7868B'], + }), +}); + +type MasterChefKnowsPoolRequest = Static; + +export default async function masterchefKnowsPoolRoute(fastify: FastifyInstance) { + fastify.post<{ Body: MasterChefKnowsPoolRequest }>( + '/masterchef-knows-pool', + { + schema: { + summary: 'Check if MasterChef knows a PancakeSwap V3 pool', + description: + 'Checks if the given PancakeSwap V3 pool address is registered in the MasterChef contract (using v3PoolAddressPid). ' + + 'Returns the pool ID if registered, or 0 if not.', + tags: ['/connector/pancakeswap'], + operationId: 'masterchefKnowsPool', + body: MasterChefKnowsPoolSchema, + response: { + 200: Type.Object({ + poolId: Type.String({ description: 'The pool ID if registered, or 0 if not.' }), + known: Type.Boolean({ description: 'True if the pool is registered in MasterChef.' }), + }), + 400: Type.Object({ error: Type.String() }), + 500: Type.Object({ error: Type.String() }), + }, + consumes: ['application/json'], + produces: ['application/json'], + 'x-examples': { + 'Check Pool': { + value: { + network: 'bsc', + poolAddress: '0xA5067360b13Fc7A2685Dc82dcD1bF2B4B8D7868B', + }, + }, + }, + }, + }, + async (request, reply) => { + const { network, poolAddress } = request.body; + + try { + const pancakeswap = await Pancakeswap.getInstance(network); + const poolId = await pancakeswap.getV3PoolIdFromMasterChef(poolAddress); + reply.status(200).send({ poolId: poolId.toString(), known: poolId !== 0 }); + } catch (error) { + fastify.log.error(`Failed to check pool in MasterChef: ${error.message}`); + reply.status(500).send({ error: `Failed to check pool in MasterChef: ${error.message}` }); + } + }, + ); +} diff --git a/src/connectors/pancakeswap/nft-staking/masterchef-stake.ts b/src/connectors/pancakeswap/nft-staking/masterchef-stake.ts new file mode 100644 index 0000000000..46a0ef3f4a --- /dev/null +++ b/src/connectors/pancakeswap/nft-staking/masterchef-stake.ts @@ -0,0 +1,122 @@ +import { Static, Type } from '@sinclair/typebox'; +import { FastifyInstance } from 'fastify'; + +import { Pancakeswap } from '../pancakeswap'; + +const MasterChefStakeSchema = Type.Object({ + network: Type.String({ + description: 'Blockchain network to use (e.g., "bsc").', + examples: ['bsc'], + default: 'bsc', + }), + walletAddress: Type.String({ + description: 'The wallet address that will sign and send the staking transaction.', + examples: ['0x742d35Cc6634C0532925a3b844Bc9e7595f42e0E'], + }), + tokenId: Type.Number({ + description: 'Token ID of the NFT to stake in the MasterChef contract.', + examples: [6350589], + }), +}); + +type MasterChefStakeRequest = Static; + +const MasterChefStakeResponse = Type.Object({ + message: Type.String({ description: 'Human-readable success message.' }), + txHash: Type.String({ description: 'Transaction hash of the staking transfer.' }), + poolAddress: Type.String({ description: 'V3 pool contract address for this position.' }), + poolId: Type.Number({ description: 'MasterChef pool ID.' }), + baseTokenAddress: Type.String({ description: 'Base token contract address.' }), + baseTokenSymbol: Type.String({ description: 'Base token symbol (e.g. CAKE).' }), + quoteTokenAddress: Type.String({ description: 'Quote token contract address.' }), + quoteTokenSymbol: Type.String({ description: 'Quote token symbol (e.g. USDT).' }), + feePct: Type.Number({ description: 'Pool trading fee as a percentage (e.g. 0.25 for 0.25%).' }), + liquidity: Type.String({ description: 'Raw liquidity units in the staked position.' }), + tickLower: Type.Number({ description: 'Lower tick boundary of the position.' }), + tickUpper: Type.Number({ description: 'Upper tick boundary of the position.' }), + currentPrice: Type.Number({ description: 'Current pool price in base/quote terms.' }), + lowerPrice: Type.Number({ description: 'Lower price bound of the position in base/quote terms.' }), + upperPrice: Type.Number({ description: 'Upper price bound of the position in base/quote terms.' }), + inRange: Type.Boolean({ description: 'Whether the position is currently in range and earning trading fees.' }), + cakePerSecond: Type.Number({ + description: 'CAKE tokens distributed per second across the entire pool.', + }), + rewardEndTime: Type.Number({ description: 'Unix timestamp when the current CAKE reward period ends.' }), + isRewardActive: Type.Boolean({ description: 'Whether the CAKE reward period is currently active.' }), +}); + +export default async function masterchefStakeRoutes(fastify: FastifyInstance) { + fastify.post<{ Body: MasterChefStakeRequest }>( + '/masterchef-stake', + { + schema: { + summary: 'Stake a PancakeSwap CLMM NFT in the MasterChef contract', + description: + 'Stakes a PancakeSwap CLMM position NFT (by tokenId) into the MasterChef contract on the specified network. ' + + 'The transaction is signed and sent by the provided walletAddress, which must own the NFT and have granted MasterChef approval. ' + + 'The response includes full position details (token addresses, price range, liquidity) and MasterChef reward metadata.', + tags: ['/connector/pancakeswap'], + body: MasterChefStakeSchema, + response: { + 200: MasterChefStakeResponse, + 400: Type.Object({ error: Type.String() }), + 500: Type.Object({ error: Type.String() }), + }, + consumes: ['application/json'], + produces: ['application/json'], + operationId: 'stakeMasterChefNFT', + 'x-examples': { + 'Stake NFT': { + value: { + network: 'bsc', + walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f42e0E', + tokenId: 6350589, + }, + }, + }, + }, + }, + async (request, reply) => { + const { network, walletAddress, tokenId } = request.body; + + fastify.log.info( + `Received stake request for tokenId ${tokenId} on network ${network} with wallet ${walletAddress}`, + ); + + try { + const pancakeswap = await Pancakeswap.getInstance(network); + const result = await pancakeswap.stakeNft(tokenId, walletAddress); + + fastify.log.info( + `Successfully staked tokenId ${tokenId}: pool ${result.poolAddress}, ` + + `${result.baseTokenSymbol}/${result.quoteTokenSymbol}, cakePerSecond=${result.cakePerSecond}`, + ); + + reply.status(200).send({ + message: `Successfully staked NFT ${tokenId} (${result.baseTokenSymbol}/${result.quoteTokenSymbol}) in MasterChef pool #${result.poolId}`, + txHash: result.txHash, + poolAddress: result.poolAddress, + poolId: result.poolId, + baseTokenAddress: result.baseTokenAddress, + baseTokenSymbol: result.baseTokenSymbol, + quoteTokenAddress: result.quoteTokenAddress, + quoteTokenSymbol: result.quoteTokenSymbol, + feePct: result.feePct, + liquidity: result.liquidity, + tickLower: result.tickLower, + tickUpper: result.tickUpper, + currentPrice: result.currentPrice, + lowerPrice: result.lowerPrice, + upperPrice: result.upperPrice, + inRange: result.inRange, + cakePerSecond: result.cakePerSecond, + rewardEndTime: result.rewardEndTime, + isRewardActive: result.isRewardActive, + }); + } catch (error) { + fastify.log.error(`Failed to stake tokenId ${tokenId} with wallet ${walletAddress}: ${error.message}`); + reply.status(500).send({ error: `Failed to stake NFT: ${error.message}` }); + } + }, + ); +} diff --git a/src/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close.ts b/src/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close.ts new file mode 100644 index 0000000000..61b7faf619 --- /dev/null +++ b/src/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close.ts @@ -0,0 +1,136 @@ +import { Static, Type } from '@sinclair/typebox'; +import { FastifyInstance } from 'fastify'; + +import { closePosition } from '../clmm-routes/closePosition'; +import { Pancakeswap } from '../pancakeswap'; + +const MasterChefUnstakeAndCloseSchema = Type.Object({ + network: Type.String({ + description: 'Blockchain network to use (e.g., "bsc")', + examples: ['bsc'], + default: 'bsc', + }), + walletAddress: Type.String({ + description: 'The wallet address that will sign the transactions. This must be the owner of the position.', + examples: ['0x742d35Cc6634C0532925a3b844Bc9e7595f42e0E'], + }), + tokenId: Type.Number({ + description: 'Token ID of the NFT position to unstake and close', + examples: [6450873], + }), +}); + +type MasterChefUnstakeAndCloseRequest = Static; + +export default async function masterchefUnstakeAndCloseRoutes(fastify: FastifyInstance) { + fastify.post<{ Body: MasterChefUnstakeAndCloseRequest }>( + '/masterchef-unstake-and-close', + { + schema: { + summary: 'Unstake NFT from MasterChef and close the position', + description: + 'Unstakes a PancakeSwap CLMM position NFT from the MasterChef contract and then closes the position. ' + + 'This is a convenience endpoint that chains two operations: unstake (withdraw from MasterChef, harvesting CAKE) ' + + 'and close-position (remove liquidity and burn NFT). ' + + 'The response includes: CAKE rewards earned during unstake, token symbols and amounts returned when closing, ' + + 'and all collected trading fees.', + tags: ['/connector/pancakeswap'], + body: MasterChefUnstakeAndCloseSchema, + response: { + 200: Type.Object({ + message: Type.String({ description: 'Success message' }), + unstakeTransaction: Type.String({ description: 'Transaction hash from the unstake operation' }), + closeTransaction: Type.String({ description: 'Transaction hash from the close position operation' }), + cakeRewardAmount: Type.Number({ description: 'Amount of CAKE tokens harvested during the unstake' }), + rewardToken: Type.String({ description: 'Reward token symbol (CAKE)' }), + rewardTokenAddress: Type.String({ description: 'Reward token contract address' }), + positionClosed: Type.Object({ + fee: Type.Number({ description: 'Gas fee paid for the close transaction (in ETH/BNB)' }), + positionRentRefunded: Type.Number({ description: 'Position rent refunded (0 on EVM chains)' }), + baseTokenAmountRemoved: Type.Number({ description: 'Amount of base token removed from the position' }), + quoteTokenAmountRemoved: Type.Number({ description: 'Amount of quote token removed from the position' }), + baseFeeAmountCollected: Type.Number({ description: 'Accumulated base token trading fees collected' }), + quoteFeeAmountCollected: Type.Number({ description: 'Accumulated quote token trading fees collected' }), + baseTokenSymbol: Type.Optional(Type.String({ description: 'Base token symbol (e.g. CAKE)' })), + baseTokenAddress: Type.Optional(Type.String({ description: 'Base token contract address' })), + quoteTokenSymbol: Type.Optional(Type.String({ description: 'Quote token symbol (e.g. USDT)' })), + quoteTokenAddress: Type.Optional(Type.String({ description: 'Quote token contract address' })), + }), + }), + 400: Type.Object({ error: Type.String() }), + 500: Type.Object({ error: Type.String() }), + }, + consumes: ['application/json'], + produces: ['application/json'], + operationId: 'unstakeAndCloseMasterChefPosition', + 'x-examples': { + 'Unstake and Close Position': { + value: { + network: 'bsc', + walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f42e0E', + tokenId: 6450873, + }, + }, + }, + }, + }, + async (request, reply) => { + const { network, walletAddress, tokenId } = request.body; + + fastify.log.info( + `Received unstake-and-close request for tokenId ${tokenId} on network ${network} with wallet ${walletAddress}`, + ); + + try { + // Step 1: Unstake the NFT from MasterChef (also harvests accumulated CAKE) + fastify.log.info(`Step 1: Unstaking NFT ${tokenId} from MasterChef...`); + let unstakeResult: { txHash: string; rewardAmount: number; rewardToken: string; rewardTokenAddress: string }; + try { + const pancakeswap = await Pancakeswap.getInstance(network); + unstakeResult = await pancakeswap.unstakeNft(tokenId, walletAddress); + fastify.log.info( + `Successfully unstaked tokenId ${tokenId}: earned ${unstakeResult.rewardAmount} ${unstakeResult.rewardToken}, tx ${unstakeResult.txHash}`, + ); + } catch (unstakeError: any) { + fastify.log.error(`Unstaking failed: ${unstakeError.message}`); + throw new Error(`Failed to unstake NFT: ${unstakeError.message}`); + } + + // Add a small delay to ensure the unstake transaction is settled before closing + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Step 2: Close the position (remove all liquidity, collect fees, burn NFT) + fastify.log.info(`Step 2: Closing position for NFT ${tokenId}...`); + let closeResult; + try { + closeResult = await closePosition(network, walletAddress, tokenId.toString()); + fastify.log.info(`Successfully closed position for tokenId ${tokenId}`); + } catch (closeError: any) { + fastify.log.error(`Closing position failed: ${closeError.message}`); + throw new Error(`Failed to close position: ${closeError.message}`); + } + + const baseSymbol = closeResult.data?.baseTokenSymbol ?? ''; + const quoteSymbol = closeResult.data?.quoteTokenSymbol ?? ''; + + fastify.log.info(`Successfully completed unstake-and-close for tokenId ${tokenId}`); + reply.status(200).send({ + message: + `Successfully unstaked NFT ${tokenId} from MasterChef (harvested ${unstakeResult.rewardAmount} ${unstakeResult.rewardToken}) ` + + `and closed the position (returned ${closeResult.data?.baseTokenAmountRemoved ?? 0} ${baseSymbol} + ` + + `${closeResult.data?.quoteTokenAmountRemoved ?? 0} ${quoteSymbol}). ` + + `The NFT has been burned.`, + unstakeTransaction: unstakeResult.txHash, + closeTransaction: closeResult.signature, + cakeRewardAmount: unstakeResult.rewardAmount, + rewardToken: unstakeResult.rewardToken, + rewardTokenAddress: unstakeResult.rewardTokenAddress, + positionClosed: closeResult.data, + }); + } catch (error: any) { + fastify.log.error(`Failed to unstake and close tokenId ${tokenId}: ${error.message}`); + reply.status(500).send({ error: `Failed to unstake and close position: ${error.message}` }); + } + }, + ); +} diff --git a/src/connectors/pancakeswap/nft-staking/masterchef-unstake.ts b/src/connectors/pancakeswap/nft-staking/masterchef-unstake.ts new file mode 100644 index 0000000000..fc95fa1a0e --- /dev/null +++ b/src/connectors/pancakeswap/nft-staking/masterchef-unstake.ts @@ -0,0 +1,76 @@ +import { Static, Type } from '@sinclair/typebox'; +import { FastifyInstance } from 'fastify'; + +import { Pancakeswap } from '../pancakeswap'; + +const MasterChefUnstakeSchema = Type.Object({ + network: Type.String({ description: 'Blockchain network (e.g., bsc)' }), + walletAddress: Type.String({ + description: 'The wallet address to receive the unstaked NFT and any accumulated CAKE rewards', + examples: ['0x742d35Cc6634C0532925a3b844Bc9e7595f42e0E'], + }), + tokenId: Type.Number({ description: 'Token ID of the NFT to unstake' }), +}); + +type MasterChefUnstakeRequest = Static; + +const MasterChefUnstakeResponse = Type.Object({ + message: Type.String({ description: 'Human-readable success message.' }), + txHash: Type.String({ description: 'Transaction hash of the unstake (withdraw) transaction.' }), + rewardAmount: Type.Number({ + description: 'Amount of CAKE tokens harvested and sent to walletAddress during this unstake.', + }), + rewardToken: Type.String({ description: 'Reward token symbol (CAKE).' }), + rewardTokenAddress: Type.String({ description: 'Reward token contract address.' }), +}); + +export default async function masterchefUnstakeRoutes(fastify: FastifyInstance) { + fastify.post<{ Body: MasterChefUnstakeRequest }>( + '/masterchef-unstake', + { + schema: { + summary: 'Unstake an NFT from the MasterChef contract and collect CAKE rewards', + description: + 'Withdraws a staked PancakeSwap CLMM position NFT from the MasterChef contract. ' + + 'Any accumulated CAKE rewards are automatically harvested and sent to walletAddress. ' + + 'The response includes the actual CAKE amount earned.', + tags: ['/connector/pancakeswap'], + body: MasterChefUnstakeSchema, + response: { + 200: MasterChefUnstakeResponse, + 400: Type.Object({ error: Type.String() }), + 500: Type.Object({ error: Type.String() }), + }, + }, + }, + async (request, reply) => { + const { network, walletAddress, tokenId } = request.body; + + fastify.log.info( + `Received unstake request for tokenId ${tokenId} on network ${network} with wallet ${walletAddress}`, + ); + + try { + const pancakeswap = await Pancakeswap.getInstance(network); + const result = await pancakeswap.unstakeNft(tokenId, walletAddress); + + fastify.log.info( + `Successfully unstaked tokenId ${tokenId}: earned ${result.rewardAmount} ${result.rewardToken}, tx ${result.txHash}`, + ); + + reply.status(200).send({ + message: + `Successfully unstaked NFT ${tokenId} from MasterChef. ` + + `Harvested ${result.rewardAmount} ${result.rewardToken} to ${walletAddress}.`, + txHash: result.txHash, + rewardAmount: result.rewardAmount, + rewardToken: result.rewardToken, + rewardTokenAddress: result.rewardTokenAddress, + }); + } catch (error) { + fastify.log.error(`Failed to unstake tokenId ${tokenId}: ${error.message}`); + reply.status(500).send({ error: `Failed to unstake NFT: ${error.message}` }); + } + }, + ); +} diff --git a/src/connectors/pancakeswap/pancakeswap.routes.ts b/src/connectors/pancakeswap/pancakeswap.routes.ts index 2be31c8386..95d11d1fdb 100644 --- a/src/connectors/pancakeswap/pancakeswap.routes.ts +++ b/src/connectors/pancakeswap/pancakeswap.routes.ts @@ -4,6 +4,7 @@ import { FastifyPluginAsync } from 'fastify'; // Import routes import { pancakeswapAmmRoutes } from './amm-routes'; import { pancakeswapClmmRoutes } from './clmm-routes'; +import { pancakeswapNftStakingRoutes } from './nft-staking'; import { pancakeswapRouterRoutes } from './router-routes'; // Router routes (Universal Router with 4 endpoints) @@ -51,11 +52,27 @@ const pancakeswapClmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { }); }; +// NFT Staking routes (MasterChef integration) +const pancakeswapNftStakingRoutesWrapper: 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/pancakeswap']; + } + }); + + await instance.register(pancakeswapNftStakingRoutes); + }); +}; + // Export routes in the same pattern as other connectors export const pancakeswapRoutes = { router: pancakeswapRouterRoutesWrapper, amm: pancakeswapAmmRoutesWrapper, clmm: pancakeswapClmmRoutesWrapper, + nftStaking: pancakeswapNftStakingRoutesWrapper, }; export default pancakeswapRoutes; diff --git a/src/connectors/pancakeswap/pancakeswap.ts b/src/connectors/pancakeswap/pancakeswap.ts index daa3ad2678..d3db1fd9bf 100644 --- a/src/connectors/pancakeswap/pancakeswap.ts +++ b/src/connectors/pancakeswap/pancakeswap.ts @@ -4,8 +4,8 @@ import { PoolType } from '@pancakeswap/smart-router'; import { Pair as V2Pair } from '@pancakeswap/v2-sdk'; import { abi as IPancakeswapV3FactoryABI } from '@pancakeswap/v3-core/artifacts/contracts/interfaces/IPancakeV3Factory.sol/IPancakeV3Factory.json'; import { abi as IPancakeswapV3PoolABI } from '@pancakeswap/v3-core/artifacts/contracts/interfaces/IPancakeV3Pool.sol/IPancakeV3Pool.json'; -import { FeeAmount, Pool as V3Pool } from '@pancakeswap/v3-sdk'; -import { Contract, constants } from 'ethers'; +import { FeeAmount, Pool as V3Pool, tickToPrice } from '@pancakeswap/v3-sdk'; +import { Contract, constants, utils } from 'ethers'; import { getAddress } from 'ethers/lib/utils'; import { Address } from 'viem'; @@ -22,8 +22,10 @@ import { getPancakeswapV3NftManagerAddress, getPancakeswapV3QuoterV2ContractAddress, getPancakeswapV3FactoryAddress, + getPancakeswapV3MasterchefAddress, } from './pancakeswap.contracts'; import { isValidV2Pool, isValidV3Pool } from './pancakeswap.utils'; +import PancakeswapV3MasterchefABI from './PancakeswapV3Masterchef.abi.json'; import { UniversalRouterService } from './universal-router'; export class Pancakeswap { @@ -48,6 +50,7 @@ export class Pancakeswap { private v3NFTManager: Contract; private v3Quoter: Contract; private universalRouter: UniversalRouterService; + private masterChef: Contract; // Network information private networkName: string; @@ -135,6 +138,13 @@ export class Pancakeswap { // Initialize Universal Router service this.universalRouter = new UniversalRouterService(this.ethereum.provider, this.chainId, this.networkName); + // Initialize MasterChef contract with full ABI + this.masterChef = new Contract( + getPancakeswapV3MasterchefAddress(this.networkName), + PancakeswapV3MasterchefABI, + this.ethereum.provider, + ); + // Ensure ethereum is initialized if (!this.ethereum.ready()) { await this.ethereum.init(); @@ -527,6 +537,365 @@ export class Pancakeswap { } } + /** + * Get the pool ID for a V3 pool address from MasterChef (returns 0 if not registered) + */ + public async getV3PoolIdFromMasterChef(poolAddress: string): Promise { + const contract = new Contract(this.masterChef.address, PancakeswapV3MasterchefABI, this.ethereum.provider); + const pid = await contract.v3PoolAddressPid(poolAddress); + return Number(pid); + } + + /** + * Get MasterChef reward data for a V3 pool, useful for APR estimation. + */ + public async getPoolMasterchefData(poolAddress: string): Promise<{ + poolId: number; + cakePerSecond: number; + rewardEndTime: number; + isRewardActive: boolean; + }> { + try { + const [poolId, periodInfo] = await Promise.all([ + this.getV3PoolIdFromMasterChef(poolAddress), + this.masterChef.getLatestPeriodInfo(poolAddress), + ]); + + const [cakePerSecondRaw, endTime] = periodInfo; + const now = Math.floor(Date.now() / 1000); + const rewardEndTime = Number(endTime); + const isRewardActive = rewardEndTime > now; + const cakePerSecond = parseFloat(cakePerSecondRaw.toString()) / 1e18; + + return { poolId, cakePerSecond, rewardEndTime, isRewardActive }; + } catch (error) { + logger.error(`Failed to get MasterChef data for pool ${poolAddress}: ${error.message}`); + return { poolId: 0, cakePerSecond: 0, rewardEndTime: 0, isRewardActive: false }; + } + } + + /** + * Get a V3 pool by token addresses and fee + */ + private async getV3PoolByTokens(token0: string, token1: string, fee: number): Promise { + try { + const poolAddress = await this.v3Factory.getPool(token0, token1, fee); + if (poolAddress && poolAddress !== constants.AddressZero) { + return poolAddress; + } + return null; + } catch (error) { + logger.error(`Error getting pool: ${error.message}`); + return null; + } + } + + /** + * Stake an NFT in the MasterChef contract using a specific wallet. + */ + public async stakeNft( + tokenId: number, + walletAddress: string, + ): Promise<{ + txHash: string; + poolId: number; + poolAddress: string; + baseTokenAddress: string; + baseTokenSymbol: string; + quoteTokenAddress: string; + quoteTokenSymbol: string; + feePct: number; + liquidity: string; + tickLower: number; + tickUpper: number; + currentPrice: number; + lowerPrice: number; + upperPrice: number; + inRange: boolean; + cakePerSecond: number; + rewardEndTime: number; + isRewardActive: boolean; + }> { + try { + logger.info(`Verifying ownership of NFT ${tokenId} for wallet ${walletAddress}`); + await this.checkNFTOwnership(tokenId.toString(), walletAddress); + + const masterChefAddress = getPancakeswapV3MasterchefAddress(this.networkName); + const nftManagerAddress = getPancakeswapV3NftManagerAddress(this.networkName); + + logger.info(`MasterChef Address: ${masterChefAddress}`); + logger.info(`NFT Manager Address: ${nftManagerAddress}`); + + // Get position details from NFT Manager + const positionContract = new Contract( + nftManagerAddress, + [ + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'positions', + outputs: [ + { internalType: 'uint96', name: 'nonce', type: 'uint96' }, + { internalType: 'address', name: 'operator', type: 'address' }, + { internalType: 'address', name: 'token0', type: 'address' }, + { internalType: 'address', name: 'token1', type: 'address' }, + { internalType: 'uint24', name: 'fee', type: 'uint24' }, + { internalType: 'int24', name: 'tickLower', type: 'int24' }, + { internalType: 'int24', name: 'tickUpper', type: 'int24' }, + { internalType: 'uint128', name: 'liquidity', type: 'uint128' }, + { internalType: 'uint256', name: 'feeGrowthInside0LastX128', type: 'uint256' }, + { internalType: 'uint256', name: 'feeGrowthInside1LastX128', type: 'uint256' }, + { internalType: 'uint128', name: 'tokensOwed0', type: 'uint128' }, + { internalType: 'uint128', name: 'tokensOwed1', type: 'uint128' }, + ], + stateMutability: 'view', + type: 'function', + }, + ], + this.ethereum.provider, + ); + + const position = await positionContract.positions(tokenId); + const liquidity = position.liquidity.toString(); + logger.info( + `Position liquidity: ${liquidity}, Fee: ${position.fee}, Tick range: [${position.tickLower}, ${position.tickUpper}]`, + ); + + if (liquidity === '0') { + throw new Error( + `Position ${tokenId} has zero liquidity and cannot be staked. ` + + `Please add liquidity to the position before staking.`, + ); + } + + // Check if pool is registered in MasterChef + const v3Pool = await this.getV3PoolByTokens(position.token0, position.token1, position.fee); + if (!v3Pool) { + throw new Error( + `Could not find pool for tokens with fee ${position.fee}. ` + + `The pool may not exist or may not be registered in MasterChef.`, + ); + } + + const poolId = await this.getV3PoolIdFromMasterChef(v3Pool); + logger.info(`Pool ID in MasterChef: ${poolId}`); + + if (poolId === 0) { + throw new Error( + `Pool for position ${tokenId} is not registered in MasterChef. ` + + `Only positions in MasterChef-registered pools can be staked.`, + ); + } + + // Check if NFT is already staked + const ownerCheckContract = new Contract( + nftManagerAddress, + [ + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'ownerOf', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + ], + this.ethereum.provider, + ); + + const currentOwner = await ownerCheckContract.ownerOf(tokenId); + if (currentOwner.toLowerCase() === masterChefAddress.toLowerCase()) { + throw new Error(`NFT ${tokenId} is already staked in MasterChef.`); + } + if (currentOwner.toLowerCase() !== walletAddress.toLowerCase()) { + throw new Error(`NFT ${tokenId} is not owned by wallet ${walletAddress}.`); + } + + // Check approval + const approvalCheckContract = new Contract( + nftManagerAddress, + [ + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'address', name: 'operator', type: 'address' }, + ], + name: 'isApprovedForAll', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + ], + this.ethereum.provider, + ); + + const isApproved = await approvalCheckContract.isApprovedForAll(walletAddress, masterChefAddress); + if (!isApproved) { + throw new Error( + `MasterChef is not approved to transfer your NFTs. ` + + `Please approve MasterChef to manage your LP NFTs by calling setApprovalForAll ` + + `on the NonfungiblePositionManager contract (${nftManagerAddress}) ` + + `with operator=${masterChefAddress}, approved=true.`, + ); + } + + // Stake by transferring the NFT to MasterChef + const wallet = await this.ethereum.getWallet(walletAddress); + const nftManagerContract = new Contract( + nftManagerAddress, + [ + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + ], + name: 'safeTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + wallet, + ); + + const tx = await nftManagerContract['safeTransferFrom(address,address,uint256)']( + walletAddress, + masterChefAddress, + tokenId, + { gasLimit: 600000 }, + ); + + logger.info(`Transfer transaction sent: ${tx.hash}`); + const receipt = await tx.wait(); + + if (!receipt || receipt.status !== 1) { + throw new Error(`Staking transaction failed.`); + } + + // Gather enriched response data + const [token0Obj, token1Obj, mcData] = await Promise.all([ + this.getToken(position.token0), + this.getToken(position.token1), + this.getPoolMasterchefData(v3Pool), + ]); + + const pool = token0Obj && token1Obj ? await this.getV3Pool(token0Obj, token1Obj, position.fee) : null; + + const isBaseToken0 = + (token0Obj?.symbol !== 'WETH' && token1Obj?.symbol === 'WETH') || + (token0Obj?.symbol !== 'WETH' && + token1Obj?.symbol !== 'WETH' && + token0Obj?.address.toLowerCase() < token1Obj?.address.toLowerCase()); + + const baseTokenAddress = isBaseToken0 ? (token0Obj?.address ?? '') : (token1Obj?.address ?? ''); + const baseTokenSymbol = isBaseToken0 ? (token0Obj?.symbol ?? '') : (token1Obj?.symbol ?? ''); + const quoteTokenAddress = isBaseToken0 ? (token1Obj?.address ?? '') : (token0Obj?.address ?? ''); + const quoteTokenSymbol = isBaseToken0 ? (token1Obj?.symbol ?? '') : (token0Obj?.symbol ?? ''); + + let currentPrice = 0; + let lowerPrice = 0; + let upperPrice = 0; + let inRange = false; + + if (pool && token0Obj && token1Obj) { + currentPrice = isBaseToken0 + ? parseFloat(pool.token0Price.toSignificant(8)) + : parseFloat(pool.token1Price.toSignificant(8)); + + const lowerTickPrice = tickToPrice(token0Obj, token1Obj, position.tickLower); + const upperTickPrice = tickToPrice(token0Obj, token1Obj, position.tickUpper); + + lowerPrice = isBaseToken0 + ? parseFloat(lowerTickPrice.toSignificant(8)) + : parseFloat(upperTickPrice.invert().toSignificant(8)); + upperPrice = isBaseToken0 + ? parseFloat(upperTickPrice.toSignificant(8)) + : parseFloat(lowerTickPrice.invert().toSignificant(8)); + + inRange = pool.tickCurrent >= position.tickLower && pool.tickCurrent < position.tickUpper; + } + + return { + txHash: tx.hash, + poolId: mcData.poolId, + poolAddress: v3Pool, + baseTokenAddress, + baseTokenSymbol, + quoteTokenAddress, + quoteTokenSymbol, + feePct: position.fee / 10000, + liquidity, + tickLower: position.tickLower, + tickUpper: position.tickUpper, + currentPrice, + lowerPrice, + upperPrice, + inRange, + cakePerSecond: mcData.cakePerSecond, + rewardEndTime: mcData.rewardEndTime, + isRewardActive: mcData.isRewardActive, + }; + } catch (error) { + logger.error(`Failed to stake NFT: ${error.message}`); + throw error; + } + } + + /** + * Unstake an NFT from the MasterChef contract and collect accumulated CAKE rewards. + */ + public async unstakeNft( + tokenId: number, + walletAddress: string, + ): Promise<{ + txHash: string; + rewardAmount: number; + rewardToken: string; + rewardTokenAddress: string; + }> { + try { + const wallet = await this.ethereum.getWallet(walletAddress); + const contractWithSigner = this.masterChef.connect(wallet); + const tx = await contractWithSigner.withdraw(tokenId, walletAddress, { gasLimit: 500000 }); + const receipt = await tx.wait(); + + // Parse the CAKE reward from the Harvest event + let rewardAmount = 0; + try { + const iface = new utils.Interface(PancakeswapV3MasterchefABI); + for (const log of receipt.logs) { + try { + const parsed = iface.parseLog(log); + if (parsed.name === 'Harvest') { + rewardAmount = parseFloat(parsed.args.reward.toString()) / 1e18; + break; + } + } catch { + // Not a Harvest log from this contract, skip + } + } + } catch (parseErr) { + logger.warn(`Could not parse Harvest event from unstake receipt: ${parseErr.message}`); + } + + // Resolve CAKE token address and symbol + let rewardTokenAddress = ''; + let rewardToken = 'CAKE'; + try { + rewardTokenAddress = await this.masterChef.CAKE(); + const cakeToken = await this.getToken(rewardTokenAddress); + if (cakeToken?.symbol) rewardToken = cakeToken.symbol; + } catch (tokenErr) { + logger.warn(`Could not resolve CAKE token info: ${tokenErr.message}`); + } + + logger.info(`Successfully unstaked NFT ${tokenId}: earned ${rewardAmount} ${rewardToken}, tx ${tx.hash}`); + return { txHash: tx.hash, rewardAmount, rewardToken, rewardTokenAddress }; + } catch (error) { + logger.error(`Failed to unstake NFT: ${error.message}`); + throw error; + } + } + /** * Close the Pancakeswap instance and clean up resources */ diff --git a/src/connectors/pancakeswap/schemas.ts b/src/connectors/pancakeswap/schemas.ts index 7d01b9a1cf..c54dcb5800 100644 --- a/src/connectors/pancakeswap/schemas.ts +++ b/src/connectors/pancakeswap/schemas.ts @@ -110,6 +110,16 @@ export const PancakeswapClmmGetPoolInfoRequest = Type.Object({ description: 'Pancakeswap V3 pool address', examples: [CLMM_POOL_ADDRESS_EXAMPLE], }), + binCount: Type.Optional( + Type.Integer({ + description: + 'If > 0, include a `bins` array (per-tickSpacing token amounts around the current tick), ' + + 'mirroring Meteora pool-info.bins[]. Default 0 skips extra eth_call reads.', + default: 0, + minimum: 0, + maximum: 400, + }), + ), }); // Pancakeswap CLMM Create Pool Request (Pancakeswap V3 — Uniswap V3 fork) From f2883083d42de5d7dd2ab2ac6349a8e1e77d41f4 Mon Sep 17 00:00:00 2001 From: VeXHarbinger Date: Thu, 13 Aug 2026 13:22:18 -0400 Subject: [PATCH 3/9] test(pancakeswap): add nftStaking route registration coverage --- .../pancakeswap/pancakeswap.routes.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/test/connectors/pancakeswap/pancakeswap.routes.test.ts b/test/connectors/pancakeswap/pancakeswap.routes.test.ts index 497a1e70d2..68e0aef17d 100644 --- a/test/connectors/pancakeswap/pancakeswap.routes.test.ts +++ b/test/connectors/pancakeswap/pancakeswap.routes.test.ts @@ -20,16 +20,18 @@ describe('Pancakeswap Routes Structure', () => { }); describe('Folder Structure', () => { - it('should have router-routes, amm-routes, and clmm-routes folders', () => { + it('should have router-routes, amm-routes, clmm-routes, and nft-staking folders', () => { const pancakeswapPath = path.join(__dirname, '../../../src/connectors/pancakeswap'); const routerRoutesPath = path.join(pancakeswapPath, 'router-routes'); const ammRoutesPath = path.join(pancakeswapPath, 'amm-routes'); const clmmRoutesPath = path.join(pancakeswapPath, 'clmm-routes'); + const nftStakingRoutesPath = path.join(pancakeswapPath, 'nft-staking'); const oldRoutesPath = path.join(pancakeswapPath, 'routes'); expect(fs.existsSync(routerRoutesPath)).toBe(true); expect(fs.existsSync(ammRoutesPath)).toBe(true); expect(fs.existsSync(clmmRoutesPath)).toBe(true); + expect(fs.existsSync(nftStakingRoutesPath)).toBe(true); expect(fs.existsSync(oldRoutesPath)).toBe(false); }); @@ -68,6 +70,32 @@ describe('Pancakeswap Routes Structure', () => { url: '/connectors/pancakeswap/clmm/pool-info', }); expect(clmmResponse.statusCode).not.toBe(404); + + // Check MasterChef/NFT staking route + const nftStakingResponse = await fastify.inject({ + method: 'POST', + url: '/connectors/pancakeswap/nftStaking/masterchef-knows-pool', + payload: {}, + }); + expect(nftStakingResponse.statusCode).not.toBe(404); + }); + + it('should register all MasterChef nftStaking endpoints', async () => { + const endpointChecks = [ + '/connectors/pancakeswap/nftStaking/masterchef-stake', + '/connectors/pancakeswap/nftStaking/masterchef-unstake', + '/connectors/pancakeswap/nftStaking/masterchef-unstake-and-close', + '/connectors/pancakeswap/nftStaking/masterchef-knows-pool', + ]; + + for (const endpoint of endpointChecks) { + const response = await fastify.inject({ + method: 'POST', + url: endpoint, + payload: {}, + }); + expect(response.statusCode).not.toBe(404); + } }); }); }); From a605717f7484a343bb07ac425e8afd7fcd5d2582 Mon Sep 17 00:00:00 2001 From: VeXHarbinger Date: Thu, 13 Aug 2026 14:13:27 -0400 Subject: [PATCH 4/9] fix(pancakeswap): resolve PR680 MasterChef safety issues - keep NFT token IDs as strings across stake/unstake routes and connector methods - handle pid=0 correctly via explicit MasterChef pool registration check - replace hard-coded staked collect calldata with ABI-backed harvest() call --- .../pancakeswap/clmm-routes/collectFees.ts | 29 +++++++++++-------- .../nft-staking/masterchef-knows-pool.ts | 3 +- .../nft-staking/masterchef-stake.ts | 6 ++-- .../masterchef-unstake-and-close.ts | 6 ++-- .../nft-staking/masterchef-unstake.ts | 2 +- src/connectors/pancakeswap/pancakeswap.ts | 22 ++++++++++++-- 6 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/connectors/pancakeswap/clmm-routes/collectFees.ts b/src/connectors/pancakeswap/clmm-routes/collectFees.ts index ad2f5fb4e0..a0954873b0 100644 --- a/src/connectors/pancakeswap/clmm-routes/collectFees.ts +++ b/src/connectors/pancakeswap/clmm-routes/collectFees.ts @@ -1,7 +1,7 @@ import { Contract } from '@ethersproject/contracts'; import { CurrencyAmount } from '@pancakeswap/sdk'; import { NonfungiblePositionManager } from '@pancakeswap/v3-sdk'; -import { BigNumber, utils } from 'ethers'; +import { BigNumber } from 'ethers'; import { FastifyPluginAsync } from 'fastify'; import { Address } from 'viem'; @@ -47,8 +47,6 @@ const NPM_OWNER_OF_ABI = [ }, ] as const; -const MASTER_CHEF_COLLECT_SELECTOR = '0xfc6f7865'; - async function getWalletTokenBalance(provider: any, tokenAddress: string, walletAddress: string): Promise { const tokenContract = new Contract(tokenAddress, ERC20_BALANCE_OF_ABI, provider); return BigNumber.from((await tokenContract.balanceOf(walletAddress)).toString()); @@ -72,18 +70,25 @@ async function collectFeesFromMasterChef( const before0 = await getWalletTokenBalance(ethereum.provider, token0.address, walletAddress); const before1 = await getWalletTokenBalance(ethereum.provider, token1.address, walletAddress); - const encodedArgs = utils.defaultAbiCoder.encode( - ['uint256', 'address', 'uint128', 'uint128'], - [positionAddress, walletAddress, UINT128_MAX, UINT128_MAX], + const masterChefContract = new Contract( + masterChefAddress, + [ + { + inputs: [ + { internalType: 'uint256', name: '_tokenId', type: 'uint256' }, + { internalType: 'address', name: '_to', type: 'address' }, + ], + name: 'harvest', + outputs: [{ internalType: 'uint256', name: 'reward', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + wallet, ); - const data = `${MASTER_CHEF_COLLECT_SELECTOR}${encodedArgs.slice(2)}`; const txParams = await ethereum.prepareGasOptions(undefined, CLMM_COLLECT_FEES_GAS_LIMIT); - const tx = await wallet.sendTransaction({ - to: masterChefAddress, - data, - ...txParams, - }); + const tx = await masterChefContract.harvest(positionAddress, walletAddress, txParams); const receipt = await ethereum.handleTransactionExecution(tx); const after0 = await getWalletTokenBalance(ethereum.provider, token0.address, walletAddress); diff --git a/src/connectors/pancakeswap/nft-staking/masterchef-knows-pool.ts b/src/connectors/pancakeswap/nft-staking/masterchef-knows-pool.ts index 85417dba7d..ffba104947 100644 --- a/src/connectors/pancakeswap/nft-staking/masterchef-knows-pool.ts +++ b/src/connectors/pancakeswap/nft-staking/masterchef-knows-pool.ts @@ -55,7 +55,8 @@ export default async function masterchefKnowsPoolRoute(fastify: FastifyInstance) try { const pancakeswap = await Pancakeswap.getInstance(network); const poolId = await pancakeswap.getV3PoolIdFromMasterChef(poolAddress); - reply.status(200).send({ poolId: poolId.toString(), known: poolId !== 0 }); + const known = await pancakeswap.isMasterChefPoolRegistered(poolAddress); + reply.status(200).send({ poolId: poolId.toString(), known }); } catch (error) { fastify.log.error(`Failed to check pool in MasterChef: ${error.message}`); reply.status(500).send({ error: `Failed to check pool in MasterChef: ${error.message}` }); diff --git a/src/connectors/pancakeswap/nft-staking/masterchef-stake.ts b/src/connectors/pancakeswap/nft-staking/masterchef-stake.ts index 46a0ef3f4a..1b3ed10f50 100644 --- a/src/connectors/pancakeswap/nft-staking/masterchef-stake.ts +++ b/src/connectors/pancakeswap/nft-staking/masterchef-stake.ts @@ -13,9 +13,9 @@ const MasterChefStakeSchema = Type.Object({ description: 'The wallet address that will sign and send the staking transaction.', examples: ['0x742d35Cc6634C0532925a3b844Bc9e7595f42e0E'], }), - tokenId: Type.Number({ + tokenId: Type.String({ description: 'Token ID of the NFT to stake in the MasterChef contract.', - examples: [6350589], + examples: ['6350589'], }), }); @@ -70,7 +70,7 @@ export default async function masterchefStakeRoutes(fastify: FastifyInstance) { value: { network: 'bsc', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f42e0E', - tokenId: 6350589, + tokenId: '6350589', }, }, }, diff --git a/src/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close.ts b/src/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close.ts index 61b7faf619..d99ffaec42 100644 --- a/src/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close.ts +++ b/src/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close.ts @@ -14,9 +14,9 @@ const MasterChefUnstakeAndCloseSchema = Type.Object({ description: 'The wallet address that will sign the transactions. This must be the owner of the position.', examples: ['0x742d35Cc6634C0532925a3b844Bc9e7595f42e0E'], }), - tokenId: Type.Number({ + tokenId: Type.String({ description: 'Token ID of the NFT position to unstake and close', - examples: [6450873], + examples: ['6450873'], }), }); @@ -68,7 +68,7 @@ export default async function masterchefUnstakeAndCloseRoutes(fastify: FastifyIn value: { network: 'bsc', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f42e0E', - tokenId: 6450873, + tokenId: '6450873', }, }, }, diff --git a/src/connectors/pancakeswap/nft-staking/masterchef-unstake.ts b/src/connectors/pancakeswap/nft-staking/masterchef-unstake.ts index fc95fa1a0e..cfd106984d 100644 --- a/src/connectors/pancakeswap/nft-staking/masterchef-unstake.ts +++ b/src/connectors/pancakeswap/nft-staking/masterchef-unstake.ts @@ -9,7 +9,7 @@ const MasterChefUnstakeSchema = Type.Object({ description: 'The wallet address to receive the unstaked NFT and any accumulated CAKE rewards', examples: ['0x742d35Cc6634C0532925a3b844Bc9e7595f42e0E'], }), - tokenId: Type.Number({ description: 'Token ID of the NFT to unstake' }), + tokenId: Type.String({ description: 'Token ID of the NFT to unstake', examples: ['6350589'] }), }); type MasterChefUnstakeRequest = Static; diff --git a/src/connectors/pancakeswap/pancakeswap.ts b/src/connectors/pancakeswap/pancakeswap.ts index d3db1fd9bf..51d8b523da 100644 --- a/src/connectors/pancakeswap/pancakeswap.ts +++ b/src/connectors/pancakeswap/pancakeswap.ts @@ -546,6 +546,21 @@ export class Pancakeswap { return Number(pid); } + /** + * Check whether a pool is registered in MasterChef. + * + * `v3PoolAddressPid` alone is ambiguous for pid=0 because unknown pools also map to 0. + * `getLatestPeriodInfo(pool)` reverts for unknown pools, so use it as the registration check. + */ + public async isMasterChefPoolRegistered(poolAddress: string): Promise { + try { + await this.masterChef.getLatestPeriodInfo(poolAddress); + return true; + } catch { + return false; + } + } + /** * Get MasterChef reward data for a V3 pool, useful for APR estimation. */ @@ -594,7 +609,7 @@ export class Pancakeswap { * Stake an NFT in the MasterChef contract using a specific wallet. */ public async stakeNft( - tokenId: number, + tokenId: string, walletAddress: string, ): Promise<{ txHash: string; @@ -677,9 +692,10 @@ export class Pancakeswap { } const poolId = await this.getV3PoolIdFromMasterChef(v3Pool); + const isPoolRegistered = await this.isMasterChefPoolRegistered(v3Pool); logger.info(`Pool ID in MasterChef: ${poolId}`); - if (poolId === 0) { + if (!isPoolRegistered) { throw new Error( `Pool for position ${tokenId} is not registered in MasterChef. ` + `Only positions in MasterChef-registered pools can be staked.`, @@ -844,7 +860,7 @@ export class Pancakeswap { * Unstake an NFT from the MasterChef contract and collect accumulated CAKE rewards. */ public async unstakeNft( - tokenId: number, + tokenId: string, walletAddress: string, ): Promise<{ txHash: string; From 6d99ba74424b651a71a298dc71689f60c4eb3ea3 Mon Sep 17 00:00:00 2001 From: VeXHarbinger Date: Thu, 13 Aug 2026 14:28:23 -0400 Subject: [PATCH 5/9] fix(pancakeswap): collect staked CLMM fees via unstake flow Use the correct unstake -> NPM collect -> restake sequence for MasterChef-staked NFTs instead of calling harvest(), which only claims farm rewards and leaves trading fees uncollected. --- .../pancakeswap/clmm-routes/collectFees.ts | 107 ++++++------------ 1 file changed, 32 insertions(+), 75 deletions(-) diff --git a/src/connectors/pancakeswap/clmm-routes/collectFees.ts b/src/connectors/pancakeswap/clmm-routes/collectFees.ts index a0954873b0..8529388b1c 100644 --- a/src/connectors/pancakeswap/clmm-routes/collectFees.ts +++ b/src/connectors/pancakeswap/clmm-routes/collectFees.ts @@ -27,15 +27,6 @@ import { getPositionInfo } from './positionInfo'; // Collect on some fee-on-transfer tokens can exceed 200k due transfer hooks. const CLMM_COLLECT_FEES_GAS_LIMIT = 500000; const UINT128_MAX = BigNumber.from('0xffffffffffffffffffffffffffffffff'); -const ERC20_BALANCE_OF_ABI = [ - { - inputs: [{ internalType: 'address', name: 'account', type: 'address' }], - name: 'balanceOf', - outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], - stateMutability: 'view', - type: 'function', - }, -] as const; const NPM_OWNER_OF_ABI = [ { @@ -47,72 +38,47 @@ const NPM_OWNER_OF_ABI = [ }, ] as const; -async function getWalletTokenBalance(provider: any, tokenAddress: string, walletAddress: string): Promise { - const tokenContract = new Contract(tokenAddress, ERC20_BALANCE_OF_ABI, provider); - return BigNumber.from((await tokenContract.balanceOf(walletAddress)).toString()); -} - async function collectFeesFromMasterChef( network: string, walletAddress: string, positionAddress: string, - token0: any, - token1: any, - isBaseToken0: boolean, - ethereum: Ethereum, ): Promise { - const wallet = await ethereum.getWallet(walletAddress); - if (!wallet) { - throw httpErrors.badRequest('Wallet not found'); + const pancakeswap = await Pancakeswap.getInstance(network); + let collectResult: CollectFeesResponseType | null = null; + let collectError: any = null; + let restakeError: any = null; + + logger.info(`Collecting CLMM trading fees for staked NFT ${positionAddress}: unstake -> collect -> restake`); + await pancakeswap.unstakeNft(positionAddress, walletAddress); + + try { + collectResult = await collectFees(network, walletAddress, positionAddress); + } catch (error: any) { + collectError = error; + } finally { + try { + await pancakeswap.stakeNft(positionAddress, walletAddress); + } catch (error: any) { + restakeError = error; + logger.error(`Failed to restake NFT ${positionAddress} after fee collection: ${error.message}`, error); + } } - const masterChefAddress = getPancakeswapV3MasterchefAddress(network); - const before0 = await getWalletTokenBalance(ethereum.provider, token0.address, walletAddress); - const before1 = await getWalletTokenBalance(ethereum.provider, token1.address, walletAddress); - - const masterChefContract = new Contract( - masterChefAddress, - [ - { - inputs: [ - { internalType: 'uint256', name: '_tokenId', type: 'uint256' }, - { internalType: 'address', name: '_to', type: 'address' }, - ], - name: 'harvest', - outputs: [{ internalType: 'uint256', name: 'reward', type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function', - }, - ], - wallet, - ); - - const txParams = await ethereum.prepareGasOptions(undefined, CLMM_COLLECT_FEES_GAS_LIMIT); - const tx = await masterChefContract.harvest(positionAddress, walletAddress, txParams); - const receipt = await ethereum.handleTransactionExecution(tx); - - const after0 = await getWalletTokenBalance(ethereum.provider, token0.address, walletAddress); - const after1 = await getWalletTokenBalance(ethereum.provider, token1.address, walletAddress); - - const rawCollected0 = after0.gte(before0) ? after0.sub(before0) : BigNumber.from(0); - const rawCollected1 = after1.gte(before1) ? after1.sub(before1) : BigNumber.from(0); + if (restakeError && collectResult) { + throw httpErrors.internalServerError( + `Collected trading fees for staked NFT ${positionAddress}, but failed to restake it: ${restakeError.message}`, + ); + } - const collectedToken0FeeAmount = formatTokenAmount(rawCollected0.toString(), token0.decimals); - const collectedToken1FeeAmount = formatTokenAmount(rawCollected1.toString(), token1.decimals); - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + if (collectError) { + throw collectError; + } - const baseFeeAmountCollected = isBaseToken0 ? collectedToken0FeeAmount : collectedToken1FeeAmount; - const quoteFeeAmountCollected = isBaseToken0 ? collectedToken1FeeAmount : collectedToken0FeeAmount; + if (!collectResult) { + throw httpErrors.internalServerError(`Failed to collect trading fees for staked NFT ${positionAddress}`); + } - return { - signature: receipt.transactionHash, - status: receipt.status, - data: { - fee: gasFee, - baseFeeAmountCollected, - quoteFeeAmountCollected, - }, - }; + return collectResult; } export async function collectFees( @@ -153,16 +119,7 @@ export async function collectFees( (token1.symbol !== 'WETH' && token0.address.toLowerCase() < token1.address.toLowerCase()); if (isStakedInMasterChef) { - logger.info(`Collecting fees for staked NFT ${positionAddress} via MasterChef`); - return await collectFeesFromMasterChef( - network, - walletAddress, - positionAddress, - token0, - token1, - isBaseToken0, - ethereum, - ); + return await collectFeesFromMasterChef(network, walletAddress, positionAddress); } const livePositionInfo = await getPositionInfo({ httpErrors } as any, network, positionAddress); From 21b349a1a114fc012f3b580f83bcfb2df63437a3 Mon Sep 17 00:00:00 2001 From: VeXHarbinger Date: Thu, 13 Aug 2026 14:34:11 -0400 Subject: [PATCH 6/9] fix(pancakeswap): support direct staked fee collect Restore direct MasterChef fee collection for staked NFTs using a typed collect(uint256,address,uint128,uint128) ABI fragment proven by on-chain tx 0x43fbb08a7e6944ae2a1e9bcfcb1fdb4134aed9acc51b809309daca9103c51e99, avoiding both raw calldata and temporary unstake/restake flow. --- .../pancakeswap/clmm-routes/collectFees.ts | 111 +++++++++++++----- 1 file changed, 79 insertions(+), 32 deletions(-) diff --git a/src/connectors/pancakeswap/clmm-routes/collectFees.ts b/src/connectors/pancakeswap/clmm-routes/collectFees.ts index 8529388b1c..cf4be7983a 100644 --- a/src/connectors/pancakeswap/clmm-routes/collectFees.ts +++ b/src/connectors/pancakeswap/clmm-routes/collectFees.ts @@ -38,47 +38,86 @@ const NPM_OWNER_OF_ABI = [ }, ] as const; +const MASTER_CHEF_STAKED_COLLECT_ABI = [ + { + inputs: [ + { internalType: 'uint256', name: '_tokenId', type: 'uint256' }, + { internalType: 'address', name: '_to', type: 'address' }, + { internalType: 'uint128', name: '_amount0Max', type: 'uint128' }, + { internalType: 'uint128', name: '_amount1Max', type: 'uint128' }, + ], + name: 'collect', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; + +async function getWalletTokenBalance(provider: any, tokenAddress: string, walletAddress: string): Promise { + const tokenContract = new Contract( + tokenAddress, + [ + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'balanceOf', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + ], + provider, + ); + + return BigNumber.from((await tokenContract.balanceOf(walletAddress)).toString()); +} + async function collectFeesFromMasterChef( network: string, walletAddress: string, positionAddress: string, + token0: any, + token1: any, + isBaseToken0: boolean, + ethereum: Ethereum, ): Promise { - const pancakeswap = await Pancakeswap.getInstance(network); - let collectResult: CollectFeesResponseType | null = null; - let collectError: any = null; - let restakeError: any = null; - - logger.info(`Collecting CLMM trading fees for staked NFT ${positionAddress}: unstake -> collect -> restake`); - await pancakeswap.unstakeNft(positionAddress, walletAddress); - - try { - collectResult = await collectFees(network, walletAddress, positionAddress); - } catch (error: any) { - collectError = error; - } finally { - try { - await pancakeswap.stakeNft(positionAddress, walletAddress); - } catch (error: any) { - restakeError = error; - logger.error(`Failed to restake NFT ${positionAddress} after fee collection: ${error.message}`, error); - } + const wallet = await ethereum.getWallet(walletAddress); + if (!wallet) { + throw httpErrors.badRequest('Wallet not found'); } - if (restakeError && collectResult) { - throw httpErrors.internalServerError( - `Collected trading fees for staked NFT ${positionAddress}, but failed to restake it: ${restakeError.message}`, - ); - } + const masterChefAddress = getPancakeswapV3MasterchefAddress(network); + const before0 = await getWalletTokenBalance(ethereum.provider, token0.address, walletAddress); + const before1 = await getWalletTokenBalance(ethereum.provider, token1.address, walletAddress); - if (collectError) { - throw collectError; - } + logger.info(`Collecting CLMM trading fees for staked NFT ${positionAddress} directly through MasterChef collect()`); - if (!collectResult) { - throw httpErrors.internalServerError(`Failed to collect trading fees for staked NFT ${positionAddress}`); - } + const masterChefContract = new Contract(masterChefAddress, MASTER_CHEF_STAKED_COLLECT_ABI, wallet); + const txParams = await ethereum.prepareGasOptions(undefined, CLMM_COLLECT_FEES_GAS_LIMIT); + const tx = await masterChefContract.collect(positionAddress, walletAddress, UINT128_MAX, UINT128_MAX, txParams); + const receipt = await ethereum.handleTransactionExecution(tx); + + const after0 = await getWalletTokenBalance(ethereum.provider, token0.address, walletAddress); + const after1 = await getWalletTokenBalance(ethereum.provider, token1.address, walletAddress); + + const rawCollected0 = after0.gte(before0) ? after0.sub(before0) : BigNumber.from(0); + const rawCollected1 = after1.gte(before1) ? after1.sub(before1) : BigNumber.from(0); + + const collectedToken0FeeAmount = formatTokenAmount(rawCollected0.toString(), token0.decimals); + const collectedToken1FeeAmount = formatTokenAmount(rawCollected1.toString(), token1.decimals); + const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + + const baseFeeAmountCollected = isBaseToken0 ? collectedToken0FeeAmount : collectedToken1FeeAmount; + const quoteFeeAmountCollected = isBaseToken0 ? collectedToken1FeeAmount : collectedToken0FeeAmount; - return collectResult; + return { + signature: receipt.transactionHash, + status: receipt.status, + data: { + fee: gasFee, + baseFeeAmountCollected, + quoteFeeAmountCollected, + }, + }; } export async function collectFees( @@ -119,7 +158,15 @@ export async function collectFees( (token1.symbol !== 'WETH' && token0.address.toLowerCase() < token1.address.toLowerCase()); if (isStakedInMasterChef) { - return await collectFeesFromMasterChef(network, walletAddress, positionAddress); + return await collectFeesFromMasterChef( + network, + walletAddress, + positionAddress, + token0, + token1, + isBaseToken0, + ethereum, + ); } const livePositionInfo = await getPositionInfo({ httpErrors } as any, network, positionAddress); From ffda5835a55eacd9fe94d06446d518fe534ab46a Mon Sep 17 00:00:00 2001 From: VeXHarbinger Date: Thu, 13 Aug 2026 14:48:51 -0400 Subject: [PATCH 7/9] fix(pancakeswap): align staked collect with masterchef ABI - add collect(uint256,address,uint128,uint128) to PancakeswapV3Masterchef.abi.json - use the configured MasterChef ABI as the source of truth in collectFees - add regression coverage that staked fee collection calls collect() directly and never unstakeNft() --- .../PancakeswapV3Masterchef.abi.json | 12 ++ .../pancakeswap/clmm-routes/collectFees.ts | 18 +-- .../clmm-routes/collectFees.test.ts | 143 ++++++++++++++++++ 3 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 test/connectors/pancakeswap/clmm-routes/collectFees.test.ts diff --git a/src/connectors/pancakeswap/PancakeswapV3Masterchef.abi.json b/src/connectors/pancakeswap/PancakeswapV3Masterchef.abi.json index 6048113f85..8ae8aa126f 100644 --- a/src/connectors/pancakeswap/PancakeswapV3Masterchef.abi.json +++ b/src/connectors/pancakeswap/PancakeswapV3Masterchef.abi.json @@ -290,6 +290,18 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { "internalType": "uint256", "name": "_tokenId", "type": "uint256" }, + { "internalType": "address", "name": "_to", "type": "address" }, + { "internalType": "uint128", "name": "_amount0Max", "type": "uint128" }, + { "internalType": "uint128", "name": "_amount1Max", "type": "uint128" } + ], + "name": "collect", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { "internalType": "uint256", "name": "_tokenId", "type": "uint256" }, diff --git a/src/connectors/pancakeswap/clmm-routes/collectFees.ts b/src/connectors/pancakeswap/clmm-routes/collectFees.ts index cf4be7983a..64dfc7f780 100644 --- a/src/connectors/pancakeswap/clmm-routes/collectFees.ts +++ b/src/connectors/pancakeswap/clmm-routes/collectFees.ts @@ -21,6 +21,7 @@ import { getPancakeswapV3NftManagerAddress, } from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; +import PancakeswapV3MasterchefABI from '../PancakeswapV3Masterchef.abi.json'; import { getPositionInfo } from './positionInfo'; @@ -38,21 +39,6 @@ const NPM_OWNER_OF_ABI = [ }, ] as const; -const MASTER_CHEF_STAKED_COLLECT_ABI = [ - { - inputs: [ - { internalType: 'uint256', name: '_tokenId', type: 'uint256' }, - { internalType: 'address', name: '_to', type: 'address' }, - { internalType: 'uint128', name: '_amount0Max', type: 'uint128' }, - { internalType: 'uint128', name: '_amount1Max', type: 'uint128' }, - ], - name: 'collect', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, -] as const; - async function getWalletTokenBalance(provider: any, tokenAddress: string, walletAddress: string): Promise { const tokenContract = new Contract( tokenAddress, @@ -91,7 +77,7 @@ async function collectFeesFromMasterChef( logger.info(`Collecting CLMM trading fees for staked NFT ${positionAddress} directly through MasterChef collect()`); - const masterChefContract = new Contract(masterChefAddress, MASTER_CHEF_STAKED_COLLECT_ABI, wallet); + const masterChefContract = new Contract(masterChefAddress, PancakeswapV3MasterchefABI, wallet); const txParams = await ethereum.prepareGasOptions(undefined, CLMM_COLLECT_FEES_GAS_LIMIT); const tx = await masterChefContract.collect(positionAddress, walletAddress, UINT128_MAX, UINT128_MAX, txParams); const receipt = await ethereum.handleTransactionExecution(tx); diff --git a/test/connectors/pancakeswap/clmm-routes/collectFees.test.ts b/test/connectors/pancakeswap/clmm-routes/collectFees.test.ts new file mode 100644 index 0000000000..b0cbb55975 --- /dev/null +++ b/test/connectors/pancakeswap/clmm-routes/collectFees.test.ts @@ -0,0 +1,143 @@ +import { Contract } from '@ethersproject/contracts'; +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { collectFees } from '../../../../src/connectors/pancakeswap/clmm-routes/collectFees'; +import { Pancakeswap } from '../../../../src/connectors/pancakeswap/pancakeswap'; +import PancakeswapV3MasterchefABI from '../../../../src/connectors/pancakeswap/PancakeswapV3Masterchef.abi.json'; + +const MockedContract = Contract as unknown as jest.Mock; + +jest.mock('@ethersproject/contracts', () => ({ + Contract: jest.fn(), +})); + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/pancakeswap/pancakeswap', () => ({ + Pancakeswap: { + getInstance: jest.fn(), + }, +})); +jest.mock('../../../../src/connectors/pancakeswap/clmm-routes/positionInfo', () => ({ + getPositionInfo: jest.fn(), +})); +jest.mock('../../../../src/services/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +describe('collectFees staked MasterChef path', () => { + const positionManagerAddress = '0x46A15B0b27311cedF172AB29E4f4766fbE7F4364'; + const masterChefAddress = '0x556B9306565093C855AEA9AE92A594704c2Cd59e'; + const walletAddress = '0xA57d70a25847A7457ED75E4e04F8d00bf1BE33bC'; + const positionAddress = '7127086'; + const token0 = { + symbol: 'USDT', + address: '0x55d398326f99059fF775485246999027B3197955', + decimals: 18, + }; + const token1 = { + symbol: 'SPCXB', + address: '0xbe9D156892E55e7154BcD3cB0FEA677F9D3103E1', + decimals: 18, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + const mockCollect = jest.fn().mockResolvedValue({ hash: '0xcollect' }); + let token0BalanceCalls = 0; + let token1BalanceCalls = 0; + + MockedContract.mockImplementation((address: string, abi: any) => { + const abiNames = Array.isArray(abi) ? abi.map((entry: any) => entry?.name).filter(Boolean) : []; + + if (address === positionManagerAddress && abiNames.includes('ownerOf')) { + return { + ownerOf: jest.fn().mockResolvedValue(masterChefAddress), + }; + } + + if (address === positionManagerAddress && abiNames.includes('positions')) { + return { + positions: jest.fn().mockResolvedValue({ + token0: token0.address, + token1: token1.address, + tokensOwed0: BigNumber.from(0), + tokensOwed1: BigNumber.from(0), + }), + }; + } + + if (address === token0.address && abiNames.includes('balanceOf')) { + return { + balanceOf: jest.fn().mockImplementation(async () => { + token0BalanceCalls += 1; + return BigNumber.from(token0BalanceCalls === 1 ? '100' : '175'); + }), + }; + } + + if (address === token1.address && abiNames.includes('balanceOf')) { + return { + balanceOf: jest.fn().mockImplementation(async () => { + token1BalanceCalls += 1; + return BigNumber.from(token1BalanceCalls === 1 ? '200' : '260'); + }), + }; + } + + if (address === masterChefAddress && abi === PancakeswapV3MasterchefABI) { + return { + collect: mockCollect, + }; + } + + throw new Error(`Unexpected contract mock for ${address} with ABI names ${abiNames.join(',')}`); + }); + + (Pancakeswap.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn().mockResolvedValueOnce(token0).mockResolvedValueOnce(token1), + unstakeNft: jest.fn(), + }); + + (Ethereum.getInstance as jest.Mock).mockResolvedValue({ + provider: {}, + getWallet: jest.fn().mockResolvedValue({ address: walletAddress }), + prepareGasOptions: jest.fn().mockResolvedValue({ gasLimit: 500000 }), + handleTransactionExecution: jest.fn().mockResolvedValue({ + transactionHash: '0xreceipt', + status: 1, + gasUsed: BigNumber.from('21000'), + effectiveGasPrice: BigNumber.from('1'), + }), + }); + }); + + it('uses MasterChef collect directly for staked NFTs and does not unstake first', async () => { + const pancakeswap = await Pancakeswap.getInstance('bsc'); + + const result = await collectFees('bsc', walletAddress, positionAddress); + + expect(pancakeswap.unstakeNft).not.toHaveBeenCalled(); + + const masterChefInstance = MockedContract.mock.results + .map((result) => result.value) + .find((instance) => typeof instance.collect === 'function'); + + expect(masterChefInstance.collect).toHaveBeenCalledWith( + positionAddress, + walletAddress, + expect.anything(), + expect.anything(), + { gasLimit: 500000 }, + ); + + expect(result.signature).toBe('0xreceipt'); + expect(result.data?.baseFeeAmountCollected).toBeGreaterThan(0); + expect(result.data?.quoteFeeAmountCollected).toBeGreaterThan(0); + }); +}); From 05d0c1f803f9e3e704fa2ef5052d86cd87974bb7 Mon Sep 17 00:00:00 2001 From: VeXHarbinger Date: Thu, 13 Aug 2026 15:05:25 -0400 Subject: [PATCH 8/9] fix(pancakeswap): register nft staking runtime routes Register PancakeSwap nftStaking routes in app.ts and add a /nft-staking alias for the live bot's existing close/rebalance path. --- src/app.ts | 2 ++ test/connectors/pancakeswap/pancakeswap.routes.test.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/app.ts b/src/app.ts index 5ef6fe21dd..eaa9ff059e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -353,6 +353,8 @@ const configureGatewayServer = () => { }); app.register(pancakeswapRoutes.amm, { prefix: '/connectors/pancakeswap/amm' }); app.register(pancakeswapRoutes.clmm, { prefix: '/connectors/pancakeswap/clmm' }); + app.register(pancakeswapRoutes.nftStaking, { prefix: '/connectors/pancakeswap/nftStaking' }); + app.register(pancakeswapRoutes.nftStaking, { prefix: '/connectors/pancakeswap/nft-staking' }); // PancakeSwap Solana routes app.register(pancakeswapSolRoutes, { prefix: '/connectors/pancakeswap-sol' }); diff --git a/test/connectors/pancakeswap/pancakeswap.routes.test.ts b/test/connectors/pancakeswap/pancakeswap.routes.test.ts index 68e0aef17d..9bc20e6149 100644 --- a/test/connectors/pancakeswap/pancakeswap.routes.test.ts +++ b/test/connectors/pancakeswap/pancakeswap.routes.test.ts @@ -86,6 +86,10 @@ describe('Pancakeswap Routes Structure', () => { '/connectors/pancakeswap/nftStaking/masterchef-unstake', '/connectors/pancakeswap/nftStaking/masterchef-unstake-and-close', '/connectors/pancakeswap/nftStaking/masterchef-knows-pool', + '/connectors/pancakeswap/nft-staking/masterchef-stake', + '/connectors/pancakeswap/nft-staking/masterchef-unstake', + '/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close', + '/connectors/pancakeswap/nft-staking/masterchef-knows-pool', ]; for (const endpoint of endpointChecks) { From 211e8431a101318209b882af591343f417f13f45 Mon Sep 17 00:00:00 2001 From: VeXHarbinger Date: Thu, 13 Aug 2026 15:07:49 -0400 Subject: [PATCH 9/9] fix(pancakeswap): use canonical nftStaking route Register only the canonical gateway nftStaking prefix and remove the temporary alias. The live bot has been updated to call the canonical path. --- src/app.ts | 1 - test/connectors/pancakeswap/pancakeswap.routes.test.ts | 4 ---- 2 files changed, 5 deletions(-) diff --git a/src/app.ts b/src/app.ts index eaa9ff059e..5ecc4c6bca 100644 --- a/src/app.ts +++ b/src/app.ts @@ -354,7 +354,6 @@ const configureGatewayServer = () => { app.register(pancakeswapRoutes.amm, { prefix: '/connectors/pancakeswap/amm' }); app.register(pancakeswapRoutes.clmm, { prefix: '/connectors/pancakeswap/clmm' }); app.register(pancakeswapRoutes.nftStaking, { prefix: '/connectors/pancakeswap/nftStaking' }); - app.register(pancakeswapRoutes.nftStaking, { prefix: '/connectors/pancakeswap/nft-staking' }); // PancakeSwap Solana routes app.register(pancakeswapSolRoutes, { prefix: '/connectors/pancakeswap-sol' }); diff --git a/test/connectors/pancakeswap/pancakeswap.routes.test.ts b/test/connectors/pancakeswap/pancakeswap.routes.test.ts index 9bc20e6149..68e0aef17d 100644 --- a/test/connectors/pancakeswap/pancakeswap.routes.test.ts +++ b/test/connectors/pancakeswap/pancakeswap.routes.test.ts @@ -86,10 +86,6 @@ describe('Pancakeswap Routes Structure', () => { '/connectors/pancakeswap/nftStaking/masterchef-unstake', '/connectors/pancakeswap/nftStaking/masterchef-unstake-and-close', '/connectors/pancakeswap/nftStaking/masterchef-knows-pool', - '/connectors/pancakeswap/nft-staking/masterchef-stake', - '/connectors/pancakeswap/nft-staking/masterchef-unstake', - '/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close', - '/connectors/pancakeswap/nft-staking/masterchef-knows-pool', ]; for (const endpoint of endpointChecks) {