QuantAMM

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

`ownerFee` becomes locked in `UpliftOnlyExample`

Summary

There is a fee per swap implemented in UpliftOnlyExample.sol which is split between the quantAMM admin and the "owner". The quantAMM fee is correctly sent to the admin's account, but the "owner" fee is sent to the contract itself. There is no withdraw mechanism or way to reapply those funds to the pool, so the tokens are effectively burned.

Vulnerability Details

The onAfterSwap function calculates a hookFee for the swap:

uint256 hookFee = params.amountCalculatedRaw.mulUp(hookSwapFeePercentage);

Source: UpliftOnlyExample.sol#L298

Then it splits that fee between the owner and quantAMM admin:

uint256 adminFee = hookFee / (1e18 / quantAMMFeeTake);
ownerFee = hookFee - adminFee;

Source: UpliftOnlyExample.sol#L335-L336

However the owner's fee is sent to the contract instead of the owner directly:

_vault.sendTo(feeToken, address(this), ownerFee);

Source: UpliftOnlyExample.sol#L343

Unlike the quantAMM fee which is sent to the admin's account as expected:

_vault.sendTo(feeToken, quantAMMAdmin, adminFee);

Source: UpliftOnlyExample.sol#L338

Reviewing the UpliftOnlyExample contract and its dependency shows there is no way to transfer (nor approve) these tokens.

Impact

Tokens such as USDC are locked in UpliftOnlyExample.

POC

The following updates an existing test related to the swap fees to show that USDC is indeed sent to the UpliftOnlyExample contract itself:

diff --git a/pkg/pool-hooks/test/foundry/UpliftExample.t.sol b/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
index d253722..4ae59b8 100644
--- a/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
+++ b/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
@@ -958,6 +958,7 @@ contract UpliftOnlyExampleTest is BaseVaultTest {
uint256 hookFee = swapAmount.mulUp(hookFeePercentage);
BaseVaultTest.Balances memory balancesBefore = getBalances(bob);
+ assertEq(usdc.balanceOf(address(upliftOnlyRouter)), 0, "Pre-req: 0 balance in UpliftOnlyExample.sol");
vm.prank(bob);
vm.expectCall(
@@ -1008,6 +1009,9 @@ contract UpliftOnlyExampleTest is BaseVaultTest {
);
_checkPoolAndVaultBalancesAfterSwap(balancesBefore, balancesAfter, swapAmount);
+ // Money is left in the uplift contract. Now what?
+ // (this test has 0 admin fee, so 100% of the hook fee is attributed to the `ownerFee`)
+ assertEq(usdc.balanceOf(address(upliftOnlyRouter)), hookFee, "Expecting ownerFee to have been sent to the UpliftOnlyExample.sol contract");
}
function testFeeSwapExactOut__Fuzz(uint256 swapAmount, uint64 hookFeePercentage) public {

Original test source: UpliftExample.t.sol#L958-L1010

Tools Used

After confirming fees are sent to the contract and not the owner account, I then manually reviewed the contract for any way to access those funds. forge flatten helped to pull everything together in one file so I did not overlook any dependencies. I found no way to transfer or approve those funds.

Recommendation

Either send the fees to the owner directly (like the quantAMM admin fees are), or introduce a onlyOwner withdraw function.

If these funds are intended for the owner as defined by the OZ Ownable dependency, then you may want to consider how to handle a renounced owner as well. e.g. instead of burning funds you could send the money to the quantAMM admin, reduce the swap fee, or override the renounce capability to require 0 owner fee configuration first.

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!