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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion contracts/launchpadv2/FPairV2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
154 changes: 154 additions & 0 deletions contracts/launchpadv2/FRouterV2.sol
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ MEDIUM: Stale Reserves After Drain

FPairV2.sol:123-130 & FPairV2.sol:132-136

Problem: transferAsset() and transferTo() transfer tokens out of the pair but don't update internal _pool.reserve0/reserve1 storage. FPairV2 has no sync() mechanism like Uniswap V2.

Note: Rated Medium (not Critical) because no direct fund loss is possible - on-chain swaps correctly revert when balances are insufficient.

Impact after drain:

  • getReserves() returns stale pre-drain values (reads from storage)
  • assetBalance() correctly returns 0 (reads actual ERC20 balance)
  • Off-chain systems reading getReserves() get incorrect liquidity data
  • On-chain swaps correctly revert (no fund loss), but state divergence creates confusion

Counterargument: If drained pools are never reused (as intended for Project60days), stale reserve values may not matter in practice. However, maintaining correct state prevents operational confusion and ensures contract invariants hold.

Fix: Sync reserves after transfer
function transferAsset(address recipient, uint256 amount) external onlyFactory {
    // ... existing checks ...
    IERC20(tokenB).safeTransfer(recipient, amount);

    _pool.reserve1 = uint112(IERC20(tokenB).balanceOf(address(this)));  // ✅ Add
    _pool.k = uint256(_pool.reserve0) * uint256(_pool.reserve1);
    emit Sync(_pool.reserve0, _pool.reserve1);
}

Apply same fix to transferTo() for reserve0.

Alternative: Add sync() function
function sync() external {
    _pool.reserve0 = uint112(IERC20(tokenA).balanceOf(address(this)));
    _pool.reserve1 = uint112(IERC20(tokenB).balanceOf(address(this)));
    _pool.k = uint256(_pool.reserve0) * uint256(_pool.reserve1);
    emit Sync(_pool.reserve0, _pool.reserve1);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, and your fix solution is wrong cuz the buy() & sell() in FRouterV2 will call transferAsset() & transferTo() before call swap(), if we update reserve0 & reserve1 in transferAsset() & transferTo() then swap() will twicely deduct the reserve0 & reserve1.

Conclusion: fixed by adding a new syncAfterDrain() function

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Fixed

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +34,21 @@ contract FRouterV2 is
address public assetToken;
address public taxManager; // deprecated
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() {
Expand Down Expand Up @@ -227,6 +251,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
Expand Down Expand Up @@ -296,4 +329,125 @@ contract FRouterV2 is
// so old pair contract won't be called and thus no issue, but we just be safe here
}
}

// ==================== Liquidity Drain Functions ====================

/**
* @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);
}

// 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,
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
Comment on lines +446 to +447
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💥 HIGH: MEV Sandwich Attack via Zero Slippage

IAgentFactoryV6(agentFactory).removeLpLiquidity(
    veToken, recipient, veTokenAmount,
    0,  // ❌ No slippage protection
    0,  // ❌ No slippage protection
    deadline
);

Problem: Hardcoded amountAMin=0, amountBMin=0 disables slippage protection. The inline comment (L433-434) states "No slippage protection needed since EXECUTOR_ROLE is trusted", but this misunderstands MEV mechanics.

Why this matters: Once the transaction enters the public mempool, MEV bots can front-run with price manipulation → your drain executes at manipulated price → bot back-runs for profit. The entire founder LP balance (L428 drains balanceOf(founder)) is at risk. Trusted caller status doesn't prevent mempool-based attacks.

Attack scenario:

  1. MEV bot detects drain tx in mempool
  2. Front-runs with large swap to manipulate pool price
  3. Drain executes at bad price (accepts any amount due to 0 slippage bounds)
  4. Bot back-runs to restore price and capture 10-30% of LP value
Fix: Accept slippage params from caller
function drainUniV2Pool(
    address agentToken,
    address veToken,
    address recipient,
    uint256 amountAMin,  // ✅ Add
    uint256 amountBMin,  // ✅ Add
    uint256 deadline
) public onlyRole(EXECUTOR_ROLE) nonReentrant {
    // ...
    IAgentFactoryV6(agentFactory).removeLpLiquidity(
        veToken, recipient, veTokenAmount,
        amountAMin, amountBMin, deadline  // ✅ Use params
    );
}

Until fixed: Use Flashbots Protect for all drain operations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional as the whole project is rugged, drainLiquidity is a MUST though users very likely will dump it.
But usually our BE is the first one knows this project is rugged cuz we define the time threshold of RUGGED.

Conclusion: won't fixed

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEV sandwich attack via zero slippage protection

High Severity

The drainUniV2Pool function sets amountAMin=0 and amountBMin=0 when calling removeLpLiquidity, completely disabling slippage protection. The inline comment claims "No slippage protection needed since EXECUTOR_ROLE is trusted" but this misunderstands MEV mechanics. MEV bots can sandwich the transaction in the mempool regardless of who initiated it, extracting significant value from the drain operation. The trust in EXECUTOR_ROLE only prevents unauthorized calls, not front-running attacks.

Additional Locations (1)

Fix in Cursor Fix in Web

deadline
);

emit UniV2PoolDrained(agentToken, veToken, recipient, veTokenAmount);
}
}
4 changes: 4 additions & 0 deletions contracts/launchpadv2/IFPairV2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 2 additions & 1 deletion contracts/virtualPersona/AgentVeTokenV2.sol
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ MEDIUM: Maturity Lock Bypass

AgentVeTokenV2.sol:174-235

Problem: Inconsistent maturity enforcement. The withdraw() function (L148-152) enforces a matureAt time-lock preventing founders from withdrawing LP tokens before maturity (e.g., 10 years per AgentFactoryV6.sol:351). However, removeLpLiquidity() has no such check.

Bypass path:

  • FRouterV2.drainUniV2Pool()AgentFactoryV6.removeLpLiquidity()AgentVeTokenV2.removeLpLiquidity()
  • None of these functions check matureAt, allowing complete bypass of the time-lock

Inconsistency:

// withdraw() - ENFORCES lock
require(block.timestamp >= matureAt ||
        balanceOf(founder) - veTokenAmount >= initialLock, ...);

// removeLpLiquidity() - NO CHECK
_burn(founder, veTokenAmount); // Bypasses lock

Design intent unclear: This may be intentional to allow protocol-controlled recovery of LP from rugged/failed projects before maturity. The attack surface is limited since only EXECUTOR_ROLE (granted to BE_OPS_WALLET) can trigger this path. However, if the bypass is unintentional, it violates the documented 10-year lock commitment and allows premature loss of founder voting power and rewards.

If bypass is unintentional, add check
function removeLpLiquidity(...) external onlyOwnerOrFactory {
    // ... existing requires ...

    require(  // ✅ Add maturity check
        block.timestamp >= matureAt ||
        balanceOf(founder) - veTokenAmount >= initialLock,
        "Cannot reduce balance below initial lock before maturity"
    );

    // ... rest of function ...
}
If intentional, document
/// @dev BYPASSES matureAt for Project60days emergency drains
function removeLpLiquidity(...) external onlyOwnerOrFactory {
    // ... existing implementation ...
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added document for this

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Fixed

Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -161,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, 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
Expand Down
9 changes: 9 additions & 0 deletions contracts/virtualPersona/IAgentFactoryV6.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 10 additions & 1 deletion contracts/virtualPersona/IAgentVeTokenV2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading