QuantAMM

QuantAMM
49,600 OP
View results
Submission Details
Severity: high
Valid

Tokens Stuck in Contract Due to Missing Withdrawal Method in `UpliftOnlyExample` Contract

Summary

The UpliftOnlyExample contract retains a portion of the swap fees (ownerFee) in its own address (address(this)) without providing a mechanism for withdrawal. This issue leads to the accumulation of tokens in the contract, rendering them inaccessible and potentially blocking significant funds over time.

Vulnerability Details

The vulnerability arises from the onAfterSwap function, where the contract calculates and transfers the ownerFee to address(this):

if (ownerFee > 0) {
_vault.sendTo(feeToken, address(this), ownerFee);
emit SwapHookFeeCharged(address(this), feeToken, ownerFee);
}

However, no public or internal method exists to allow the owner (or any authorized role) to withdraw these accumulated tokens. As a result, tokens remain stuck in the contract indefinitely, with no means to access or utilize them.

This issue is critical because it undermines the contract’s ability to manage or monetize these fees, effectively locking funds in the contract.

Impact

Tokens sent to address(this) via _vault.sendTo(feeToken, address(this), ownerFee); remain locked because there is no exit mechanism. While end-users are unaffected (since the protocol’s core operations function normally), the owner forfeits any revenue from accrued swap fees, potentially losing significant value over time.

Key points:

  • Blocked Funds: Fees accumulate in the contract and cannot be withdrawn or repurposed, leading to inefficiency and lost revenue.

  • Obscured Monetization: Because the owner cannot retrieve these tokens, the protocol’s fee-collection model effectively fails.

  • Transparency Concerns: The design lacks clarity on the final destination of fees, raising doubts among auditors and users.

  • Inequitable Fee Split: adminFee is properly routed to quantAMMAdmin, but ownerFee is trapped in address(this) with no withdrawal method.

  • Reduced Sustainability: Although user liquidity remains intact, the inability to extract owner fees undermines the protocol’s economic model and trust.

Proof of Concept

Test

  • Add the following test to pkg/pool-hooks/test/foundry/UpliftExample.t.sol:

function testSwapHookFeeStuckInContract() public {
console2.log(
'-------------------------- START testSwapHookFeeStuckInContract --------------------------'
);
// 1) Configure the Hook Swap Fee (e.g., 0.1%).
uint64 feePercentage = 0.1e16; // 0.1%
vm.prank(owner);
upliftOnlyRouter.setHookSwapFeePercentage(feePercentage);
console2.log('Hook Swap Fee set to (bps):', feePercentage);
console2.log('Owner address:', owner);
// 2) Bob performs an EXACT_IN swap to generate fees.
// Choose an arbitrary amount for Bob to swap from DAI to USDC.
uint256 swapAmount = 1_000e18; // Bob swaps 1000 DAI (assuming 18 decimals).
BaseVaultTest.Balances memory balancesBefore = getBalances(bob);
console2.log(
"Bob's DAI balance before swap:",
balancesBefore.userTokens[daiIdx]
);
console2.log(
"Bob's USDC balance before swap:",
balancesBefore.userTokens[usdcIdx]
);
// Expect 'onAfterSwap' to charge the fee and send it to address(upliftOnlyRouter).
vm.prank(bob);
router.swapSingleTokenExactIn(
address(pool),
dai,
usdc,
swapAmount,
0,
MAX_UINT256,
false,
bytes('')
);
// 3) Verify that a portion of the fee is retained in the contract.
BaseVaultTest.Balances memory balancesAfter = getBalances(bob);
uint256 daiSpentByBob = balancesBefore.userTokens[daiIdx] -
balancesAfter.userTokens[daiIdx];
uint256 usdcReceivedByBob = balancesAfter.userTokens[usdcIdx] -
balancesBefore.userTokens[usdcIdx];
uint256 usdcInContract = balancesAfter.hookTokens[usdcIdx] -
balancesBefore.hookTokens[usdcIdx]; // EXACT_IN => fee in tokenOut
console2.log('Bob spent DAI:', daiSpentByBob);
console2.log('Bob received USDC:', usdcReceivedByBob);
console2.log('USDC retained in contract as fee:', usdcInContract);
// 4) Attempt to call a "withdrawFees(...)" method that *does not* exist in UpliftOnlyExample
// to demonstrate that funds are stuck in the contract.
console2.log('Attempting to withdraw fees via a non-existent function...');
vm.prank(owner);
vm.expectRevert(); // Expect revert, as "withdrawFees" is not implemented.
address(upliftOnlyRouter).call(
abi.encodeWithSignature(
'withdrawFees(address,uint256,address)',
address(usdc),
usdcInContract,
owner
)
);
// 5) Confirm that the fee remains stuck in the contract (withdrawal failed).
uint256 usdcInContractAfterFailedWithdrawal = usdc.balanceOf(
address(upliftOnlyRouter)
);
console2.log(
'USDC balance in UpliftOnlyRouter after failed withdrawal:',
usdcInContractAfterFailedWithdrawal
);
assertEq(
usdcInContractAfterFailedWithdrawal,
usdcInContract,
'The fee remains stuck in the contract with no withdrawal method.'
);
console2.log(
'-------------------------- END testSwapHookFeeStuckInContract --------------------------'
);
}

Test Result

Logs:
-------------------------- START testSwapHookFeeStuckInContract --------------------------
Hook Swap Fee set to (bps): 1000000000000000
Owner address: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
Bob's DAI balance before swap: 1000000000000000000000000000
Bob's USDC balance before swap: 1000000000000000000000000000
Bob spent DAI: 1000000000000000000000
Bob received USDC: 999000000000000000000
USDC retained in contract as fee: 1000000000000000000
Attempting to withdraw fees via a non-existent function...
USDC balance in UpliftOnlyRouter after failed withdrawal: 1000000000000000000
-------------------------- END testSwapHookFeeStuckInContract --------------------------
  • This result confirms that the tokens (1e18 USDC) remain in the contract with no way to withdraw them.

  • No withdrawFees(...): The contract does not define any method that sends these accumulated tokens out from address(this).

Tools Used

  • Foundry: Used to develop and run the test testSwapHookFeeStuckInContract to confirm the vulnerability.

  • Manual Code Review: Verified that the contract indeed lacks any withdrawal function for the accumulated fees.

Recommendations

  1. Implement a Withdrawal Method
    Add a function that lets an authorized role (e.g., onlyOwner) withdraw any ERC20 tokens that accumulate in the contract, for example:

    function withdrawFees(
    IERC20 token,
    uint256 amount,
    address to
    ) external onlyOwner {
    require(token.balanceOf(address(this)) >= amount, 'Insufficient balance');
    token.safeTransfer(to, amount);
    }
  2. Send Fees Directly to the Owner
    Replace _vault.sendTo(feeToken, address(this), ownerFee); with _vault.sendTo(feeToken, owner(), ownerFee); or another appropriate address. This prevents the contract from holding tokens at all.

    _vault.sendTo(feeToken, owner(), ownerFee);
  3. Document Any Intentional Behavior
    If the project deliberately intends for fees to remain locked (e.g., a burn mechanism or permanent donation), ensure that the contract’s documentation clearly states this behavior to avoid confusion.

  4. Review Security of New Withdrawals
    If a withdrawal function is added, confirm that permissions are properly restricted (onlyOwner) and that the function does not introduce reentrancy or other security flaws.

These recommendations will ensure the protocol can properly manage and access collected fees, mitigating the risk of funds being permanently locked.

Updates

Lead Judging Commences

n0kto Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_ownerFee_cannot_be_withdrawn

Likelihood: High, every swap. Impact: High, funds are stuck.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.