From 219a01a5022a942b21b725a05c364e59adbf8d2c Mon Sep 17 00:00:00 2001 From: koo-virtuals Date: Thu, 5 Feb 2026 12:15:26 +0800 Subject: [PATCH 1/4] support drain liquidity when dev rugged 60days project --- contracts/launchpadv2/FRouterV2.sol | 148 ++++ contracts/virtualPersona/AgentVeTokenV2.sol | 1 + contracts/virtualPersona/IAgentFactoryV6.sol | 9 + contracts/virtualPersona/IAgentVeTokenV2.sol | 11 +- test/project60days/drainLiquidity.js | 695 +++++++++++++++++++ 5 files changed, 863 insertions(+), 1 deletion(-) create mode 100644 test/project60days/drainLiquidity.js diff --git a/contracts/launchpadv2/FRouterV2.sol b/contracts/launchpadv2/FRouterV2.sol index 83e7b54..3844dca 100644 --- a/contracts/launchpadv2/FRouterV2.sol +++ b/contracts/launchpadv2/FRouterV2.sol @@ -10,6 +10,15 @@ import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol import "./FFactoryV2.sol"; import "./IFPairV2.sol"; import "../tax/IBondingTax.sol"; +import "../virtualPersona/IAgentFactoryV6.sol"; +import "../virtualPersona/IAgentVeTokenV2.sol"; +import "../pool/IUniswapV2Pair.sol"; + +// Minimal interface for BondingV2 to avoid circular dependency +interface IBondingV2ForRouter { + function isProject60days(address token) external view returns (bool); + function agentFactory() external view returns (address); +} contract FRouterV2 is Initializable, @@ -25,6 +34,7 @@ contract FRouterV2 is address public assetToken; address public taxManager; // deprecated address public antiSniperTaxManager; // deprecated + IBondingV2ForRouter public bondingV2; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -227,6 +237,15 @@ contract FRouterV2 is antiSniperTaxManager = newManager; } + /** + * @notice Set BondingV2 contract address for isProject60days check + * @param bondingV2_ The address of the BondingV2 contract + */ + function setBondingV2(address bondingV2_) public onlyRole(ADMIN_ROLE) { + require(bondingV2_ != address(0), "Invalid BondingV2 address"); + bondingV2 = IBondingV2ForRouter(bondingV2_); + } + function resetTime( address tokenAddress, uint256 newStartTime @@ -296,4 +315,133 @@ contract FRouterV2 is // so old pair contract won't be called and thus no issue, but we just be safe here } } + + // ==================== Liquidity Drain Functions ==================== + + event PrivatePoolDrained( + address indexed token, + address indexed recipient, + uint256 assetAmount, + uint256 tokenAmount + ); + + event UniV2PoolDrained( + address indexed token, + address indexed veToken, + address indexed recipient, + uint256 veTokenAmount + ); + + /** + * @dev Drain all assets and tokens from a private pool (FPairV2) + * Only callable by EXECUTOR_ROLE and only for Project60days tokens + * @param tokenAddress The address of the fun token (must be isProject60days) + * @param recipient The address that will receive the drained assets and tokens + * @return assetAmount Amount of asset tokens drained + * @return tokenAmount Amount of agent tokens drained + */ + function drainPrivatePool( + address tokenAddress, + address recipient + ) public onlyRole(EXECUTOR_ROLE) nonReentrant returns (uint256, uint256) { + require(address(bondingV2) != address(0), "BondingV2 not set"); + require(tokenAddress != address(0), "Zero addresses are not allowed."); + require(recipient != address(0), "Zero addresses are not allowed."); + + // Check isProject60days restriction + require( + bondingV2.isProject60days(tokenAddress), + "Token does not allow liquidity drain" + ); + + address pairAddress = factory.getPair(tokenAddress, assetToken); + require(pairAddress != address(0), "Pair not found"); + + IFPairV2 pair = IFPairV2(pairAddress); + + uint256 assetAmount = pair.assetBalance(); + uint256 tokenAmount = pair.balance(); + + if (assetAmount > 0) { + pair.transferAsset(recipient, assetAmount); + } + if (tokenAmount > 0) { + pair.transferTo(recipient, tokenAmount); + } + + emit PrivatePoolDrained( + tokenAddress, + recipient, + assetAmount, + tokenAmount + ); + + return (assetAmount, tokenAmount); + } + + /** + * @dev Drain ALL liquidity from a UniswapV2 pool (for graduated tokens) + * Only callable by EXECUTOR_ROLE and only for Project60days tokens + * @param agentToken The token address (same as agentToken in single token model, must be isProject60days) + * @param veToken The veToken address (staked LP token) to drain from + * @param recipient The address that will receive the drained liquidity + * @param deadline Transaction deadline + * @notice This function drains ALL liquidity (full founder balance) + * @notice amountAMin and amountBMin are set to 0 since this is a privileged drain operation + */ + function drainUniV2Pool( + address agentToken, + address veToken, + address recipient, + uint256 deadline + ) public onlyRole(EXECUTOR_ROLE) nonReentrant { + require(address(bondingV2) != address(0), "BondingV2 not set"); + require(agentToken != address(0), "Invalid agentToken"); + require(veToken != address(0), "Invalid veToken"); + require(recipient != address(0), "Invalid recipient"); + + // Check isProject60days restriction + require( + bondingV2.isProject60days(agentToken), + "agentToken does not allow liquidity drain" + ); + + // Verify veToken corresponds to the provided token + // veToken.assetToken() returns the LP pair address + address lpPair = IAgentVeTokenV2(veToken).assetToken(); + IUniswapV2Pair pair = IUniswapV2Pair(lpPair); + address token0 = pair.token0(); + address token1 = pair.token1(); + + require( + token0 == agentToken || token1 == agentToken, + "veToken does not match token" + ); + require( + token0 == assetToken || token1 == assetToken, // assetToken is $Virtual + "veToken does not match assetToken" + ); + + // Get the FULL founder balance to drain ALL liquidity + IAgentVeTokenV2 veTokenContract = IAgentVeTokenV2(veToken); + address founder = veTokenContract.founder(); + uint256 veTokenAmount = IERC20(veToken).balanceOf(founder); + + require(veTokenAmount > 0, "No liquidity to drain"); + + // Call removeLpLiquidity through AgentFactoryV6 + // amountAMin and amountBMin set to 0 - this is a privileged drain operation + // No slippage protection needed since EXECUTOR_ROLE is trusted + address agentFactory = bondingV2.agentFactory(); + IAgentFactoryV6(agentFactory).removeLpLiquidity( + veToken, + recipient, + veTokenAmount, + 0, // amountAMin - accept any amount + 0, // amountBMin - accept any amount + deadline + ); + + emit UniV2PoolDrained(agentToken, veToken, recipient, veTokenAmount); + } } diff --git a/contracts/virtualPersona/AgentVeTokenV2.sol b/contracts/virtualPersona/AgentVeTokenV2.sol index 79a3d4b..ad80870 100644 --- a/contracts/virtualPersona/AgentVeTokenV2.sol +++ b/contracts/virtualPersona/AgentVeTokenV2.sol @@ -61,6 +61,7 @@ contract AgentVeTokenV2 is * @dev {onlyOwnerOrFactory} * * Throws if called by any account other than the owner, factory or pool. + * owner has not been set yet, _factory = agentFactoryV6 */ modifier onlyOwnerOrFactory() { if (owner() != _msgSender() && address(_factory) != _msgSender()) { diff --git a/contracts/virtualPersona/IAgentFactoryV6.sol b/contracts/virtualPersona/IAgentFactoryV6.sol index 9919db3..2ab75ad 100644 --- a/contracts/virtualPersona/IAgentFactoryV6.sol +++ b/contracts/virtualPersona/IAgentFactoryV6.sol @@ -55,4 +55,13 @@ interface IAgentFactoryV6 { function addBlacklistAddress(address token, address blacklistAddress) external; function removeBlacklistAddress(address token, address blacklistAddress) external; + + function removeLpLiquidity( + address veToken, + address recipient, + uint256 veTokenAmount, + uint256 amountAMin, + uint256 amountBMin, + uint256 deadline + ) external; } diff --git a/contracts/virtualPersona/IAgentVeTokenV2.sol b/contracts/virtualPersona/IAgentVeTokenV2.sol index 0ae1115..d23adb6 100644 --- a/contracts/virtualPersona/IAgentVeTokenV2.sol +++ b/contracts/virtualPersona/IAgentVeTokenV2.sol @@ -32,7 +32,16 @@ interface IAgentVeTokenV2 is IAgentVeToken { uint256 timepoint ) external view returns (uint256); - function removeLpLiquidity(address uniswapRouter, uint256 veTokenAmount, address recipient, uint256 amountAMin, uint256 amountBMin, uint256 deadline) external; + function removeLpLiquidity( + address uniswapRouter, + uint256 veTokenAmount, + address recipient, + uint256 amountAMin, + uint256 amountBMin, + uint256 deadline + ) external; function assetToken() external view returns (address); + + function founder() external view returns (address); } diff --git a/test/project60days/drainLiquidity.js b/test/project60days/drainLiquidity.js new file mode 100644 index 0000000..529d6cb --- /dev/null +++ b/test/project60days/drainLiquidity.js @@ -0,0 +1,695 @@ +const { expect } = require("chai"); +const { ethers, upgrades } = require("hardhat"); +const { time } = require("@nomicfoundation/hardhat-network-helpers"); +const { + loadFixture, +} = require("@nomicfoundation/hardhat-toolbox/network-helpers"); +const { setupNewLaunchpadTest } = require("../launchpadv2/setup"); + +describe("Project60days - Drain Liquidity", function () { + let setup; + let contracts; + let accounts; + let addresses; + let bondingV2; + let fRouterV2; + let virtualToken; + let agentNftV2; + let agentFactoryV6; + + before(async function () { + setup = await loadFixture(setupNewLaunchpadTest); + contracts = setup.contracts; + accounts = setup.accounts; + addresses = setup.addresses; + + bondingV2 = contracts.bondingV2; + fRouterV2 = contracts.fRouterV2; + virtualToken = contracts.virtualToken; + agentNftV2 = contracts.agentNftV2; + agentFactoryV6 = contracts.agentFactoryV6; + + // Set BondingV2 address in FRouterV2 + console.log("\n--- Setting BondingV2 in FRouterV2 ---"); + const ADMIN_ROLE = await fRouterV2.ADMIN_ROLE(); + await fRouterV2.connect(accounts.owner).grantRole(ADMIN_ROLE, accounts.owner.address); + await fRouterV2.connect(accounts.owner).setBondingV2(await bondingV2.getAddress()); + console.log("BondingV2 address set in FRouterV2"); + + // Grant EXECUTOR_ROLE to admin for testing drain functions + const EXECUTOR_ROLE = await fRouterV2.EXECUTOR_ROLE(); + await fRouterV2.connect(accounts.owner).grantRole(EXECUTOR_ROLE, accounts.admin.address); + console.log("EXECUTOR_ROLE granted to admin for drain tests"); + + // Grant REMOVE_LIQUIDITY_ROLE to FRouterV2 for drainUniV2Pool + const REMOVE_LIQUIDITY_ROLE = await agentFactoryV6.REMOVE_LIQUIDITY_ROLE(); + await agentFactoryV6.connect(accounts.owner).grantRole(REMOVE_LIQUIDITY_ROLE, await fRouterV2.getAddress()); + console.log("REMOVE_LIQUIDITY_ROLE granted to FRouterV2 for drainUniV2Pool"); + }); + + describe("drainPrivatePool", function () { + let tokenAddress; + let pairAddress; + + beforeEach(async function () { + const { user1 } = accounts; + + // Create a Project60days token + const tokenName = "Drain Test Token"; + const tokenTicker = "DRN"; + const cores = [0, 1, 2]; + const description = "Drain test description"; + const image = "https://example.com/image.png"; + const urls = [ + "https://twitter.com/test", + "https://t.me/test", + "https://youtube.com/test", + "https://example.com", + ]; + const purchaseAmount = ethers.parseEther("1000"); + const launchParamsData = await bondingV2.launchParams(); + const startTimeDelay = BigInt(launchParamsData.startTimeDelay.toString()); + const currentTime = BigInt((await time.latest()).toString()); + const startTime = currentTime + startTimeDelay + 100n; + + await virtualToken + .connect(user1) + .approve(await bondingV2.getAddress(), purchaseAmount); + + const tx = await bondingV2 + .connect(user1) + .preLaunchProject60days( + tokenName, + tokenTicker, + cores, + description, + image, + urls, + purchaseAmount, + startTime + ); + + const receipt = await tx.wait(); + const event = receipt.logs.find((log) => { + try { + const parsed = bondingV2.interface.parseLog(log); + return parsed && parsed.name === "PreLaunched"; + } catch (e) { + return false; + } + }); + + const parsedEvent = bondingV2.interface.parseLog(event); + tokenAddress = parsedEvent.args.token; + pairAddress = parsedEvent.args.pair; + + // Launch the token + const pair = await ethers.getContractAt("FPairV2", pairAddress); + const pairStartTime = await pair.startTime(); + const currentTimeForLaunch = await time.latest(); + if (currentTimeForLaunch < pairStartTime) { + const waitTime = BigInt(pairStartTime.toString()) - BigInt(currentTimeForLaunch.toString()) + 1n; + await time.increase(waitTime); + } + await bondingV2.connect(user1).launch(tokenAddress); + }); + + it("Should drain private pool for Project60days token", async function () { + const { admin } = accounts; + const recipient = ethers.Wallet.createRandom().address; + + // Get initial balances + const pair = await ethers.getContractAt("FPairV2", pairAddress); + const initialAssetBalance = await pair.assetBalance(); + const initialTokenBalance = await pair.balance(); + + expect(initialAssetBalance).to.be.gt(0); + expect(initialTokenBalance).to.be.gt(0); + + // Drain the pool + const tx = await fRouterV2 + .connect(admin) + .drainPrivatePool(tokenAddress, recipient); + + await expect(tx) + .to.emit(fRouterV2, "PrivatePoolDrained") + .withArgs(tokenAddress, recipient, initialAssetBalance, initialTokenBalance); + + // Verify balances are drained + const finalAssetBalance = await pair.assetBalance(); + const finalTokenBalance = await pair.balance(); + + expect(finalAssetBalance).to.equal(0); + expect(finalTokenBalance).to.equal(0); + + // Verify recipient received the tokens + const recipientAssetBalance = await virtualToken.balanceOf(recipient); + expect(recipientAssetBalance).to.equal(initialAssetBalance); + }); + + it("Should revert buy and sell after private pool is drained", async function () { + const { admin, user2, beOpsWallet } = accounts; + // Use beOpsWallet as recipient (a real signer) so we can transfer tokens later + const recipient = beOpsWallet; + + // Get pair contract + const pair = await ethers.getContractAt("FPairV2", pairAddress); + const initialAssetBalance = await pair.assetBalance(); + const initialTokenBalance = await pair.balance(); + + // Verify pool has liquidity before drain + expect(initialAssetBalance).to.be.gt(0); + expect(initialTokenBalance).to.be.gt(0); + + // Drain the pool - recipient receives the drained tokens + await fRouterV2.connect(admin).drainPrivatePool(tokenAddress, recipient.address); + + // Verify pool is empty + expect(await pair.assetBalance()).to.equal(0); + expect(await pair.balance()).to.equal(0); + + // Give user2 some Virtual tokens for testing + const buyAmount = ethers.parseEther("100"); + await virtualToken.connect(user2).approve(await bondingV2.getAddress(), buyAmount); + + // Try to buy - should revert (no tokens in pool to receive) + const initialUser2VirtualBalance = await virtualToken.balanceOf(user2.address); + console.log("Initial user2 Virtual balance:", initialUser2VirtualBalance); + await expect( + bondingV2.connect(user2).buy(buyAmount, tokenAddress, 0, (await time.latest()) + 300) + ).to.be.reverted; + const finalUser2VirtualBalance = await virtualToken.balanceOf(user2.address); + console.log("Final user2 Virtual balance:", finalUser2VirtualBalance); + // User2's Virtual balance should remain unchanged (tx reverted) + expect(finalUser2VirtualBalance).to.equal(initialUser2VirtualBalance); + + // Get agent token contract and check user2's balance + const agentTokenContract = await ethers.getContractAt("IERC20", tokenAddress); + let user2AgentTokenBalance = await agentTokenContract.balanceOf(user2.address); + + // user2 doesn't have any agent tokens to sell, transfer some from recipient (who received drained tokens) + if (user2AgentTokenBalance === 0n) { + const transferAmount = ethers.parseEther("2000"); + // recipient is user3, a real signer who received drained tokens + await agentTokenContract.connect(recipient).transfer(user2.address, transferAmount); + user2AgentTokenBalance = await agentTokenContract.balanceOf(user2.address); + } + + expect(user2AgentTokenBalance).to.be.gt(0); + + // Approve router for sell + await agentTokenContract.connect(user2).approve(await fRouterV2.getAddress(), user2AgentTokenBalance); + + // Check balance before sell attempt + const initialUser2AgentBalance = await agentTokenContract.balanceOf(user2.address); + console.log("Initial user2 agent token balance:", initialUser2AgentBalance); + + // Try to sell - should revert (no asset tokens in pool to receive) + await expect( + bondingV2.connect(user2).sell(user2AgentTokenBalance, tokenAddress, 0, (await time.latest()) + 300) + ).to.be.reverted; + + // Verify user2's agent token balance is unchanged (tx reverted, tokens not lost) + const finalUser2AgentBalance = await agentTokenContract.balanceOf(user2.address); + console.log("Final user2 agent token balance:", finalUser2AgentBalance); + expect(finalUser2AgentBalance).to.equal(initialUser2AgentBalance); + }); + + it("Should revert if token is not a Project60days token", async function () { + const { admin, user1 } = accounts; + const recipient = ethers.Wallet.createRandom().address; + + // Create a regular token (not Project60days) + const tokenName = "Regular Token"; + const tokenTicker = "REG"; + const cores = [0, 1, 2]; + const description = "Regular description"; + const image = "https://example.com/image.png"; + const urls = [ + "https://twitter.com/test", + "https://t.me/test", + "https://youtube.com/test", + "https://example.com", + ]; + const purchaseAmount = ethers.parseEther("1000"); + const launchParamsData = await bondingV2.launchParams(); + const startTimeDelay = BigInt(launchParamsData.startTimeDelay.toString()); + const currentTime = BigInt((await time.latest()).toString()); + const startTime = currentTime + startTimeDelay + 100n; + + await virtualToken + .connect(user1) + .approve(await bondingV2.getAddress(), purchaseAmount); + + const tx = await bondingV2 + .connect(user1) + .preLaunch( + tokenName, + tokenTicker, + cores, + description, + image, + urls, + purchaseAmount, + startTime + ); + + const receipt = await tx.wait(); + const event = receipt.logs.find((log) => { + try { + const parsed = bondingV2.interface.parseLog(log); + return parsed && parsed.name === "PreLaunched"; + } catch (e) { + return false; + } + }); + + const parsedEvent = bondingV2.interface.parseLog(event); + const regularTokenAddress = parsedEvent.args.token; + + // Try to drain - should revert + await expect( + fRouterV2.connect(admin).drainPrivatePool(regularTokenAddress, recipient) + ).to.be.revertedWith("Token does not allow liquidity drain"); + }); + + it("Should revert if called without EXECUTOR_ROLE", async function () { + const { user1 } = accounts; + const recipient = ethers.Wallet.createRandom().address; + + await expect( + fRouterV2.connect(user1).drainPrivatePool(tokenAddress, recipient) + ).to.be.revertedWithCustomError(fRouterV2, "AccessControlUnauthorizedAccount"); + }); + + it("Should revert if BondingV2 is not set", async function () { + const { admin, owner } = accounts; + + // Deploy a new FRouterV2 without setting BondingV2 + const FRouterV2 = await ethers.getContractFactory("FRouterV2"); + const newFRouterV2 = await upgrades.deployProxy( + FRouterV2, + [await contracts.fFactoryV2.getAddress(), await virtualToken.getAddress()], + { initializer: "initialize" } + ); + await newFRouterV2.waitForDeployment(); + + const EXECUTOR_ROLE = await newFRouterV2.EXECUTOR_ROLE(); + await newFRouterV2.grantRole(EXECUTOR_ROLE, admin.address); + + const recipient = ethers.Wallet.createRandom().address; + + await expect( + newFRouterV2.connect(admin).drainPrivatePool(tokenAddress, recipient) + ).to.be.revertedWith("BondingV2 not set"); + }); + + it("Should revert with zero address recipient", async function () { + const { admin } = accounts; + + await expect( + fRouterV2.connect(admin).drainPrivatePool(tokenAddress, ethers.ZeroAddress) + ).to.be.revertedWith("Zero addresses are not allowed."); + }); + }); + + describe("drainUniV2Pool", function () { + let tokenAddress; + let agentToken; + let veToken; + + beforeEach(async function () { + const { user1, user2 } = accounts; + + // Create a Project60days token + const tokenName = "Graduate Drain Token"; + const tokenTicker = "GDT"; + const cores = [0, 1, 2]; + const description = "Graduate drain test"; + const image = "https://example.com/image.png"; + const urls = [ + "https://twitter.com/test", + "https://t.me/test", + "https://youtube.com/test", + "https://example.com", + ]; + const purchaseAmount = ethers.parseEther("1000"); + const launchParamsData = await bondingV2.launchParams(); + const startTimeDelay = BigInt(launchParamsData.startTimeDelay.toString()); + const currentTime = BigInt((await time.latest()).toString()); + const startTime = currentTime + startTimeDelay + 100n; + + await virtualToken + .connect(user1) + .approve(await bondingV2.getAddress(), purchaseAmount); + + const tx = await bondingV2 + .connect(user1) + .preLaunchProject60days( + tokenName, + tokenTicker, + cores, + description, + image, + urls, + purchaseAmount, + startTime + ); + + const receipt = await tx.wait(); + const event = receipt.logs.find((log) => { + try { + const parsed = bondingV2.interface.parseLog(log); + return parsed && parsed.name === "PreLaunched"; + } catch (e) { + return false; + } + }); + + const parsedEvent = bondingV2.interface.parseLog(event); + tokenAddress = parsedEvent.args.token; + const pairAddress = parsedEvent.args.pair; + + // Launch the token + const pair = await ethers.getContractAt("FPairV2", pairAddress); + const pairStartTime = await pair.startTime(); + const currentTimeForLaunch = await time.latest(); + if (currentTimeForLaunch < pairStartTime) { + const waitTime = BigInt(pairStartTime.toString()) - BigInt(currentTimeForLaunch.toString()) + 1n; + await time.increase(waitTime); + } + await bondingV2.connect(user1).launch(tokenAddress); + + // Buy enough to graduate + await time.increase(100 * 60); // Wait for anti-sniper tax to expire + const buyAmount = ethers.parseEther("202020.2044906205"); + await virtualToken + .connect(user2) + .approve(addresses.fRouterV2, buyAmount); + await bondingV2 + .connect(user2) + .buy(buyAmount, tokenAddress, 0, (await time.latest()) + 300); + + // Get agentToken from tokenInfo + const tokenInfo = await bondingV2.tokenInfo(tokenAddress); + agentToken = tokenInfo.agentToken; + + // Find the veToken from agentNft + const nextVirtualId = await agentNftV2.nextVirtualId(); + for (let i = 1; i < nextVirtualId; i++) { + try { + const virtualInfo = await agentNftV2.virtualInfo(i); + if (virtualInfo.token === agentToken) { + const virtualLP = await agentNftV2.virtualLP(i); + veToken = virtualLP.veToken; + break; + } + } catch (e) { + continue; + } + } + }); + + it("Should have graduated token with agentToken and veToken", async function () { + expect(agentToken).to.not.equal(ethers.ZeroAddress); + expect(veToken).to.not.be.undefined; + expect(veToken).to.not.equal(ethers.ZeroAddress); + + // Verify token is graduated + const tokenInfo = await bondingV2.tokenInfo(tokenAddress); + expect(tokenInfo.tradingOnUniswap).to.be.true; + }); + + it("Should drain ALL UniV2 pool liquidity for Project60days token", async function () { + const { admin } = accounts; + const recipient = ethers.Wallet.createRandom().address; + + // Get veToken contract + const veTokenContract = await ethers.getContractAt("AgentVeTokenV2", veToken); + + // Get founder and check their veToken balance + const founder = await veTokenContract.founder(); + const founderVeTokenBalance = await veTokenContract.balanceOf(founder); + console.log("Founder veToken balance before drain:", ethers.formatEther(founderVeTokenBalance)); + + expect(founderVeTokenBalance).to.be.gt(0); + + const deadline = (await time.latest()) + 300; + + // Drain ALL liquidity from the UniV2 pool (no need to specify amount) + const tx = await fRouterV2 + .connect(admin) + .drainUniV2Pool( + tokenAddress, // In single token model, tokenAddress == agentToken + veToken, + recipient, + deadline + ); + + // Verify UniV2PoolDrained event - should drain the FULL balance + await expect(tx) + .to.emit(fRouterV2, "UniV2PoolDrained") + .withArgs(tokenAddress, veToken, recipient, founderVeTokenBalance); + + // Verify LiquidityRemoved event from veToken + const receipt = await tx.wait(); + const liquidityRemovedEvent = receipt.logs.find((log) => { + try { + const parsed = veTokenContract.interface.parseLog(log); + return parsed && parsed.name === "LiquidityRemoved"; + } catch (e) { + return false; + } + }); + + expect(liquidityRemovedEvent).to.not.be.undefined; + + if (liquidityRemovedEvent) { + const parsedEvent = veTokenContract.interface.parseLog(liquidityRemovedEvent); + expect(parsedEvent.args.veTokenHolder).to.equal(founder); + expect(parsedEvent.args.veTokenAmount).to.equal(founderVeTokenBalance); + expect(parsedEvent.args.recipient).to.equal(recipient); + + console.log("✅ ALL UniV2 pool liquidity drained successfully:"); + console.log("- veTokenAmount (LP tokens):", ethers.formatEther(parsedEvent.args.veTokenAmount)); + console.log("- amountA (tokenA received):", ethers.formatEther(parsedEvent.args.amountA)); + console.log("- amountB (tokenB received):", ethers.formatEther(parsedEvent.args.amountB)); + } + + // Verify founder's veToken balance is now ZERO (all drained) + const founderVeTokenBalanceAfter = await veTokenContract.balanceOf(founder); + console.log("Founder veToken balance after drain:", ethers.formatEther(founderVeTokenBalanceAfter)); + expect(founderVeTokenBalanceAfter).to.equal(0); + }); + + it("Should revert drainUniV2Pool if token is not a Project60days token", async function () { + const { admin, user1, user2 } = accounts; + + // Create and graduate a regular token + const tokenName = "Regular Graduate"; + const tokenTicker = "RGD"; + const cores = [0, 1, 2]; + const description = "Regular description"; + const image = "https://example.com/image.png"; + const urls = [ + "https://twitter.com/test", + "https://t.me/test", + "https://youtube.com/test", + "https://example.com", + ]; + const purchaseAmount = ethers.parseEther("1000"); + const launchParamsData = await bondingV2.launchParams(); + const startTimeDelay = BigInt(launchParamsData.startTimeDelay.toString()); + const currentTime = BigInt((await time.latest()).toString()); + const startTime = currentTime + startTimeDelay + 100n; + + await virtualToken + .connect(user1) + .approve(await bondingV2.getAddress(), purchaseAmount); + + const tx = await bondingV2 + .connect(user1) + .preLaunch( + tokenName, + tokenTicker, + cores, + description, + image, + urls, + purchaseAmount, + startTime + ); + + const receipt = await tx.wait(); + const event = receipt.logs.find((log) => { + try { + const parsed = bondingV2.interface.parseLog(log); + return parsed && parsed.name === "PreLaunched"; + } catch (e) { + return false; + } + }); + + const parsedEvent = bondingV2.interface.parseLog(event); + const regularTokenAddress = parsedEvent.args.token; + const regularPairAddress = parsedEvent.args.pair; + + // Launch and graduate + const pair = await ethers.getContractAt("FPairV2", regularPairAddress); + const pairStartTime = await pair.startTime(); + const currentTimeForLaunch = await time.latest(); + if (currentTimeForLaunch < pairStartTime) { + const waitTime = BigInt(pairStartTime.toString()) - BigInt(currentTimeForLaunch.toString()) + 1n; + await time.increase(waitTime); + } + await bondingV2.connect(user1).launch(regularTokenAddress); + await time.increase(100 * 60); + + const buyAmount = ethers.parseEther("202020.2044906205"); + await virtualToken + .connect(user2) + .approve(addresses.fRouterV2, buyAmount); + await bondingV2 + .connect(user2) + .buy(buyAmount, regularTokenAddress, 0, (await time.latest()) + 300); + + const regularTokenInfo = await bondingV2.tokenInfo(regularTokenAddress); + const regularAgentToken = regularTokenInfo.agentToken; + + // Find veToken + let regularVeToken; + const nextVirtualId = await agentNftV2.nextVirtualId(); + for (let i = 1; i < nextVirtualId; i++) { + try { + const virtualInfo = await agentNftV2.virtualInfo(i); + if (virtualInfo.token === regularAgentToken) { + const virtualLP = await agentNftV2.virtualLP(i); + regularVeToken = virtualLP.veToken; + break; + } + } catch (e) { + continue; + } + } + + const recipient = ethers.Wallet.createRandom().address; + const deadline = (await time.latest()) + 300; + + // Try to drain - should revert + await expect( + fRouterV2 + .connect(admin) + .drainUniV2Pool( + regularTokenAddress, + regularVeToken, + recipient, + deadline + ) + ).to.be.revertedWith("Token does not allow liquidity drain"); + }); + + it("Should revert if called without EXECUTOR_ROLE", async function () { + const { user1 } = accounts; + const recipient = ethers.Wallet.createRandom().address; + const deadline = (await time.latest()) + 300; + + await expect( + fRouterV2 + .connect(user1) + .drainUniV2Pool( + tokenAddress, + veToken, + recipient, + deadline + ) + ).to.be.revertedWithCustomError(fRouterV2, "AccessControlUnauthorizedAccount"); + }); + + it("Should revert when token does not match veToken", async function () { + const { admin } = accounts; + const recipient = ethers.Wallet.createRandom().address; + const deadline = (await time.latest()) + 300; + + // Use a wrong token address that doesn't match the veToken's LP pair + const wrongToken = ethers.Wallet.createRandom().address; + + await expect( + fRouterV2 + .connect(admin) + .drainUniV2Pool( + wrongToken, + veToken, + recipient, + deadline + ) + ).to.be.revertedWith("Token does not allow liquidity drain"); + }); + + it("Should revert when there is no liquidity to drain (already drained)", async function () { + const { admin } = accounts; + const recipient = ethers.Wallet.createRandom().address; + const deadline = (await time.latest()) + 300; + + // First drain - should succeed + await fRouterV2 + .connect(admin) + .drainUniV2Pool( + tokenAddress, + veToken, + recipient, + deadline + ); + + // Second drain - should revert because no liquidity left + await expect( + fRouterV2 + .connect(admin) + .drainUniV2Pool( + tokenAddress, + veToken, + recipient, + (await time.latest()) + 300 + ) + ).to.be.revertedWith("No liquidity to drain"); + }); + }); + + describe("setBondingV2", function () { + it("Should set BondingV2 address", async function () { + const newBondingV2Address = ethers.Wallet.createRandom().address; + + // Deploy a new FRouterV2 for this test + const FRouterV2 = await ethers.getContractFactory("FRouterV2"); + const newFRouterV2 = await upgrades.deployProxy( + FRouterV2, + [await contracts.fFactoryV2.getAddress(), await virtualToken.getAddress()], + { initializer: "initialize" } + ); + await newFRouterV2.waitForDeployment(); + + const ADMIN_ROLE = await newFRouterV2.ADMIN_ROLE(); + await newFRouterV2.grantRole(ADMIN_ROLE, accounts.owner.address); + + await newFRouterV2.connect(accounts.owner).setBondingV2(newBondingV2Address); + + expect(await newFRouterV2.bondingV2()).to.equal(newBondingV2Address); + }); + + it("Should revert if called without ADMIN_ROLE", async function () { + const { user1 } = accounts; + const newBondingV2Address = ethers.Wallet.createRandom().address; + + await expect( + fRouterV2.connect(user1).setBondingV2(newBondingV2Address) + ).to.be.revertedWithCustomError(fRouterV2, "AccessControlUnauthorizedAccount"); + }); + + it("Should revert with zero address", async function () { + const { owner } = accounts; + + await expect( + fRouterV2.connect(owner).setBondingV2(ethers.ZeroAddress) + ).to.be.revertedWith("Invalid BondingV2 address"); + }); + }); +}); From db51643e16e6841c7486def25235690e6df36d33 Mon Sep 17 00:00:00 2001 From: koo-virtuals Date: Thu, 5 Feb 2026 17:00:39 +0800 Subject: [PATCH 2/4] fix by comments --- contracts/launchpadv2/FRouterV2.sol | 28 ++++++++++++++-------------- test/project60days/drainLiquidity.js | 6 +++--- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/contracts/launchpadv2/FRouterV2.sol b/contracts/launchpadv2/FRouterV2.sol index 3844dca..f190661 100644 --- a/contracts/launchpadv2/FRouterV2.sol +++ b/contracts/launchpadv2/FRouterV2.sol @@ -36,6 +36,20 @@ contract FRouterV2 is address public antiSniperTaxManager; // deprecated IBondingV2ForRouter public bondingV2; + event PrivatePoolDrained( + address indexed token, + address indexed recipient, + uint256 assetAmount, + uint256 tokenAmount + ); + + event UniV2PoolDrained( + address indexed token, + address indexed veToken, + address indexed recipient, + uint256 veTokenAmount + ); + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); @@ -318,20 +332,6 @@ contract FRouterV2 is // ==================== Liquidity Drain Functions ==================== - event PrivatePoolDrained( - address indexed token, - address indexed recipient, - uint256 assetAmount, - uint256 tokenAmount - ); - - event UniV2PoolDrained( - address indexed token, - address indexed veToken, - address indexed recipient, - uint256 veTokenAmount - ); - /** * @dev Drain all assets and tokens from a private pool (FPairV2) * Only callable by EXECUTOR_ROLE and only for Project60days tokens diff --git a/test/project60days/drainLiquidity.js b/test/project60days/drainLiquidity.js index 529d6cb..14b4051 100644 --- a/test/project60days/drainLiquidity.js +++ b/test/project60days/drainLiquidity.js @@ -270,7 +270,7 @@ describe("Project60days - Drain Liquidity", function () { // Try to drain - should revert await expect( fRouterV2.connect(admin).drainPrivatePool(regularTokenAddress, recipient) - ).to.be.revertedWith("Token does not allow liquidity drain"); + ).to.be.revertedWith("agentToken does not allow liquidity drain"); }); it("Should revert if called without EXECUTOR_ROLE", async function () { @@ -585,7 +585,7 @@ describe("Project60days - Drain Liquidity", function () { recipient, deadline ) - ).to.be.revertedWith("Token does not allow liquidity drain"); + ).to.be.revertedWith("agentToken does not allow liquidity drain"); }); it("Should revert if called without EXECUTOR_ROLE", async function () { @@ -622,7 +622,7 @@ describe("Project60days - Drain Liquidity", function () { recipient, deadline ) - ).to.be.revertedWith("Token does not allow liquidity drain"); + ).to.be.revertedWith("agentToken does not allow liquidity drain"); }); it("Should revert when there is no liquidity to drain (already drained)", async function () { From e74baa020f70f70b8e3ad6e158689fa607d035ac Mon Sep 17 00:00:00 2001 From: koo-virtuals Date: Fri, 6 Feb 2026 10:24:15 +0800 Subject: [PATCH 3/4] fix: update FPairV2.reserves after drain liquidity --- contracts/launchpadv2/FPairV2.sol | 27 ++++++++- contracts/launchpadv2/FRouterV2.sol | 6 ++ contracts/launchpadv2/IFPairV2.sol | 4 ++ contracts/virtualPersona/AgentVeTokenV2.sol | 2 +- test/project60days/drainLiquidity.js | 65 +++++++++++++++++++++ 5 files changed, 102 insertions(+), 2 deletions(-) diff --git a/contracts/launchpadv2/FPairV2.sol b/contracts/launchpadv2/FPairV2.sol index a057dfc..d940d4c 100644 --- a/contracts/launchpadv2/FPairV2.sol +++ b/contracts/launchpadv2/FPairV2.sol @@ -56,6 +56,7 @@ contract FPairV2 is IFPairV2, ReentrancyGuard { event TimeReset(uint256 oldStartTime, uint256 newStartTime); event TaxStartTimeSet(uint256 taxStartTime); + event Sync(uint256 reserve0, uint256 reserve1); modifier onlyRouter() { require(router == msg.sender, "Only router can call this function"); @@ -135,6 +136,27 @@ contract FPairV2 is IFPairV2, ReentrancyGuard { IERC20(tokenA).safeTransfer(recipient, amount); } + /** + * @dev Sync reserves after drain operations to maintain state consistency + * Should be called after drainPrivatePool to update reserves + * @param assetAmount Amount of asset tokens (tokenB) transferred out + * @param tokenAmount Amount of agent tokens (tokenA) transferred out + */ + function syncAfterDrain( + uint256 assetAmount, + uint256 tokenAmount + ) public onlyRouter { + // Subtract transferred amounts (don't use balanceOf due to virtual liquidity) + _pool.reserve0 = _pool.reserve0 >= tokenAmount + ? _pool.reserve0 - tokenAmount + : 0; + _pool.reserve1 = _pool.reserve1 >= assetAmount + ? _pool.reserve1 - assetAmount + : 0; + _pool.k = _pool.reserve0 * _pool.reserve1; + emit Sync(_pool.reserve0, _pool.reserve1); + } + function getReserves() public view returns (uint256, uint256) { return (_pool.reserve0, _pool.reserve1); } @@ -175,7 +197,10 @@ contract FPairV2 is IFPairV2, ReentrancyGuard { function setTaxStartTime(uint256 _taxStartTime) public onlyRouter { // BE will input the _taxStartTime = time when call Launch(), so it's always after or at least equal to the startTime - require(_taxStartTime >= startTime, "Tax start time must be greater than startTime"); + require( + _taxStartTime >= startTime, + "Tax start time must be greater than startTime" + ); taxStartTime = _taxStartTime; emit TaxStartTimeSet(_taxStartTime); } diff --git a/contracts/launchpadv2/FRouterV2.sol b/contracts/launchpadv2/FRouterV2.sol index f190661..0085363 100644 --- a/contracts/launchpadv2/FRouterV2.sol +++ b/contracts/launchpadv2/FRouterV2.sol @@ -369,6 +369,12 @@ contract FRouterV2 is pair.transferTo(recipient, tokenAmount); } + // Sync reserves after drain to maintain state consistency + // Use try-catch for backward compatibility with old FPairV2 contracts + try pair.syncAfterDrain(assetAmount, tokenAmount) {} catch { + // Old FPairV2 contracts don't have syncAfterDrain - drain still works, + // but reserves won't be synced (only affects getReserves() view function) + } emit PrivatePoolDrained( tokenAddress, recipient, diff --git a/contracts/launchpadv2/IFPairV2.sol b/contracts/launchpadv2/IFPairV2.sol index cece5af..8dac7f2 100644 --- a/contracts/launchpadv2/IFPairV2.sol +++ b/contracts/launchpadv2/IFPairV2.sol @@ -36,4 +36,8 @@ interface IFPairV2 { function setTaxStartTime(uint256 _taxStartTime) external; function taxStartTime() external view returns (uint256); + + function tokenA() external view returns (address); + + function syncAfterDrain(uint256 assetAmount, uint256 tokenAmount) external; } diff --git a/contracts/virtualPersona/AgentVeTokenV2.sol b/contracts/virtualPersona/AgentVeTokenV2.sol index ad80870..2b263d6 100644 --- a/contracts/virtualPersona/AgentVeTokenV2.sol +++ b/contracts/virtualPersona/AgentVeTokenV2.sol @@ -162,7 +162,7 @@ contract AgentVeTokenV2 is /** * @dev Removes liquidity from Uniswap V2 pair and burns corresponding staked LP tokens - * Only callable by admin + * Only callable by admin, BYPASSES matureAt for draining rugged Project60days * * @param uniswapRouter The address of the Uniswap V2 router * @param veTokenAmount The amount of veToken (underlying lpToken) to remove liquidity for diff --git a/test/project60days/drainLiquidity.js b/test/project60days/drainLiquidity.js index 14b4051..e432efe 100644 --- a/test/project60days/drainLiquidity.js +++ b/test/project60days/drainLiquidity.js @@ -147,6 +147,71 @@ describe("Project60days - Drain Liquidity", function () { expect(recipientAssetBalance).to.equal(initialAssetBalance); }); + it("Should sync reserves correctly after draining private pool", async function () { + const { admin } = accounts; + const recipient = ethers.Wallet.createRandom().address; + + const pair = await ethers.getContractAt("FPairV2", pairAddress); + + // Get initial state + const [initialReserve0, initialReserve1] = await pair.getReserves(); + const initialAssetBalance = await pair.assetBalance(); + const initialTokenBalance = await pair.balance(); + + console.log("Before drain:"); + console.log(" reserve0 (tokenA):", ethers.formatEther(initialReserve0)); + console.log(" reserve1 (tokenB/asset - virtual):", ethers.formatEther(initialReserve1)); + console.log(" balance() (tokenA - actual):", ethers.formatEther(initialTokenBalance)); + console.log(" assetBalance() (tokenB - actual):", ethers.formatEther(initialAssetBalance)); + + expect(initialReserve0).to.be.gt(0); + expect(initialReserve1).to.be.gt(0); + + // Drain the pool - should emit Sync events + const tx = await fRouterV2 + .connect(admin) + .drainPrivatePool(tokenAddress, recipient); + + // Verify Sync events were emitted + await expect(tx).to.emit(pair, "Sync"); + + // Get final state + const [finalReserve0, finalReserve1] = await pair.getReserves(); + const finalAssetBalance = await pair.assetBalance(); + const finalTokenBalance = await pair.balance(); + + console.log("After drain:"); + console.log(" reserve0 (tokenA):", ethers.formatEther(finalReserve0)); + console.log(" reserve1 (tokenB/asset):", ethers.formatEther(finalReserve1)); + console.log(" balance() (tokenA):", ethers.formatEther(finalTokenBalance)); + console.log(" assetBalance() (tokenB):", ethers.formatEther(finalAssetBalance)); + + // Verify actual balances are 0 (all drained) + expect(finalAssetBalance).to.equal(0); + expect(finalTokenBalance).to.equal(0); + + // Verify reserves are reduced by the transferred amounts + // reserve0: reduced by initialTokenBalance (clamped to 0 if underflow) + const expectedReserve0 = initialReserve0 > initialTokenBalance + ? initialReserve0 - initialTokenBalance + : 0n; + expect(finalReserve0).to.equal(expectedReserve0); + + // reserve1: reduced by initialAssetBalance + // Note: reserve1 may be virtual liquidity, so it may not be 0 + const expectedReserve1 = initialReserve1 > initialAssetBalance + ? initialReserve1 - initialAssetBalance + : 0n; + expect(finalReserve1).to.equal(expectedReserve1); + + // Verify k is updated + const kLast = await pair.kLast(); + expect(kLast).to.equal(finalReserve0 * finalReserve1); + + console.log("✅ Reserves synced correctly: reduced by transferred amounts"); + }); + + it("Should revert buy and sell after private pool is drained", async function () { const { admin, user2, beOpsWallet } = accounts; // Use beOpsWallet as recipient (a real signer) so we can transfer tokens later From 638e0983db6ebca79c21d30fd1475aa9c942ad29 Mon Sep 17 00:00:00 2001 From: koo-virtuals Date: Fri, 6 Feb 2026 10:26:18 +0800 Subject: [PATCH 4/4] fix: add comments for removeLpLiquidity, intentionally BYPASSES matureAt --- contracts/virtualPersona/AgentVeTokenV2.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/virtualPersona/AgentVeTokenV2.sol b/contracts/virtualPersona/AgentVeTokenV2.sol index 2b263d6..24ddb90 100644 --- a/contracts/virtualPersona/AgentVeTokenV2.sol +++ b/contracts/virtualPersona/AgentVeTokenV2.sol @@ -162,7 +162,7 @@ contract AgentVeTokenV2 is /** * @dev Removes liquidity from Uniswap V2 pair and burns corresponding staked LP tokens - * Only callable by admin, BYPASSES matureAt for draining rugged Project60days + * Only callable by admin, draining rugged Project60days (intentionally BYPASSES matureAt) * * @param uniswapRouter The address of the Uniswap V2 router * @param veTokenAmount The amount of veToken (underlying lpToken) to remove liquidity for