QuantAMM

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

Permanent Fee Lock in UpliftOnlyExample Contract Due to Missing Withdrawal Mechanism

Summary

The UpliftOnlyExample contract collects fees from swaps and stores them in the contract, but lacks any mechanism to withdraw these fees, resulting in permanent lockup of collected fees.

Vulnerability Details

https://github.com/Cyfrin/2024-12-quantamm/blob/a775db4273eb36e7b4536c5b60207c9f17541b92/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L342-L346

In the onAfterSwap function the fee is collected using _vault.sendTo to address(this) (UpliftOnlyExample contract). But there is no function to withdraw the collected fee and there is no mechanism to distribute the fee to the owner or admin which causes the fee to be permanently locked in the contract.

function onAfterSwap(AfterSwapParams calldata params) public override onlyVault returns (bool success, uint256 hookAdjustedAmountCalculatedRaw) {
// ...
if (hookFee > 0) {
_vault.sendTo(feeToken, address(this), ownerFee);
emit SwapHookFeeCharged(address(this), feeToken, ownerFee);
}
// ...
}

When swap occurs, fee is deducted and sent to the contract and then fee is accumulated in the contract balance. But there is no withdrawFees function or similar that causes the owner and admin can not access the fee and the fee is locked forever in the contract.

POC

Add this to UpliftExample.t.sol and run it forge test --match-test testFeeCollectionVulnerability -vvvv.

function testFeeCollectionVulnerability() public {
// Setup initial state
vm.prank(owner);
upliftOnlyRouter.setHookSwapFeePercentage(1e16); // 1% fee
// Track initial balances
uint256 initialRouterUsdcBalance = usdc.balanceOf(address(upliftOnlyRouter));
// Do first swap to accumulate fees
vm.startPrank(bob);
router.swapSingleTokenExactIn(
address(pool),
dai,
usdc,
1e21, // 1000 tokens
0,
type(uint256).max,
false,
""
);
vm.stopPrank();
// Verify fees are collected in router
uint256 expectedFee = 1e19; // 1% of 1e21
assertEq(
usdc.balanceOf(address(upliftOnlyRouter)) - initialRouterUsdcBalance,
expectedFee,
"Fee not collected correctly"
);
// Try to withdraw fees as owner
vm.startPrank(owner);
(bool success,) = address(upliftOnlyRouter).call(
abi.encodeWithSignature("withdrawFees(address,uint256)", usdc, expectedFee)
);
assertFalse(success, "Should not be able to withdraw fees");
vm.stopPrank();
// Verify fees are still stuck
assertEq(
usdc.balanceOf(address(upliftOnlyRouter)),
expectedFee,
"Fees should remain in router"
);
// Try to withdraw fees as non-owner
vm.startPrank(alice);
(success,) = address(upliftOnlyRouter).call(
abi.encodeWithSignature("withdrawFees(address,uint256)", usdc, expectedFee)
);
assertFalse(success, "Non-owner should not be able to withdraw fees");
vm.stopPrank();
// Verify fees remain stuck
assertEq(
usdc.balanceOf(address(upliftOnlyRouter)),
expectedFee,
"Fees should still be stuck in router"
);
// Verify protocol fee distribution
address quantAMMAdmin = updateWeightRunner.getQuantAMMAdmin();
assertEq(
usdc.balanceOf(quantAMMAdmin),
0,
"Protocol should not receive fees without withdrawal mechanism"
);
}

Trace:

├─ [582] USDC::balanceOf(upliftOnlyRouter: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b]) [staticcall]
│ └─ ← 10000000000000000000 [1e19]

Shows that a fee of 1% (10e18) of the swap was successfully collected on the upliftOnlyRouter contract.

├─ [210] upliftOnlyRouter::withdrawFees(USDC: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 10000000000000000000 [1e19])
│ └─ ← EvmError: Revert

Withdrawal attempts by the owner failed with a revert, proving there is no withdrawal mechanism.

├─ [210] upliftOnlyRouter::withdrawFees(USDC: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 10000000000000000000 [1e19])
│ └─ ← EvmError: Revert

Withdrawal attempts by non-owners also failed.

├─ [582] USDC::balanceOf(upliftOnlyRouter: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b]) [staticcall]
│ └─ ← 10000000000000000000 [1e19]

After the withdrawal attempt, the fee balance remains intact in the contract.

├─ [2582] USDC::balanceOf(vaultAdmin: [0x212224D2F2d262cd093eE13240ca4873fcCBbA3C]) [staticcall]
│ └─ ← 0

Shows that the protocol admin balance remains 0, no fee distribution.

This shows that fees are collected but cannot be accessed because there is no distribution mechanism to the protocol admin and no withdrawal function is implemented which causes fees to continue to accumulate and be locked forever in the contract.

Impact

Fee is permanently locked in the contract.

Tools Used

  • Manual review

  • Foundry

Recommendations

Implement Fee Withdrawal.

contract UpliftOnlyExample {
mapping(IERC20 => uint256) public collectedFees;
function withdrawFees(IERC20[] calldata tokens) external onlyOwner {
for (uint256 i = 0; i < tokens.length; i++) {
uint256 amount = collectedFees[tokens[i]];
if (amount > 0) {
collectedFees[tokens[i]] = 0;
// Split fees between protocol and admin
uint256 protocolShare = amount / 2;
uint256 adminShare = amount - protocolShare;
// Transfer to protocol treasury
tokens[i].safeTransfer(treasury, protocolShare);
// Transfer to admin
tokens[i].safeTransfer(
updateWeightRunner.getQuantAMMAdmin(),
adminShare
);
emit FeeWithdrawn(
tokens[i],
treasury,
protocolShare,
adminShare
);
}
}
}
// Update fee collection to track amounts
function onAfterSwap() {
// ... existing code ...
if (ownerFee > 0) {
collectedFees[feeToken] += ownerFee;
_vault.sendTo(feeToken, address(this), ownerFee);
}
}
}
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.

Give us feedback!