Vanguard

First Flight #56
Beginner FriendlyDeFiFoundry
0 EXP
Submission Details
Impact: high
Likelihood: high

Incorrect Use of `sender` in `_beforeSwap()` Breaks Per-User Swap and Cooldown Tracking

Author Revealed upon completion

Root + Impact

  • In _beforeSwap, addressSwappedAmount and addressLastSwapBlock are keyed by the sender parameter, which typically resolves to the Uniswap router rather than the actual end user.

  • As a result, swap limits and cooldowns are enforced at the router level instead of per user, causing users to be penalized based on the cumulative activity of others, or even without having traded themselves at all!

Description

  • _beforeSwap is intended to track per-user swap amounts and last swap blocks across phases in order to enforce limits and cooldowns.

  • However, both addressSwappedAmount and addressLastSwapBlock are updated using the sender parameter, which usually represents a router or intermediary contract rather than the user.

  • This causes most swaps to be attributed to a single address (or a small set of intermediaries), resulting in shared limits and incorrect cooldown enforcement across multiple users.

function _beforeSwap(
address sender, // <-- router or intermediary, not the actual trader/user
PoolKey calldata key,
SwapParams calldata params,
bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24){
//...
@> if (addressLastSwapBlock[sender] > 0) {
uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[sender];
if (blocksSinceLastSwap < phaseCooldown) {
applyPenalty = true;
}
}
@> if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
applyPenalty = true;
}
@> addressSwappedAmount[sender] += swapAmount;
@> addressLastSwapBlock[sender] = block.number;
// ...
}

Risk

Likelihood: High

  • This occurs on every swap executed through routers or other integrator contracts

  • The issue persists across all pools and swap directions

Impact: High

  • Users will be unfairly penalized based on the cumulative swap amounts or cooldowns of all users trading through the same router.

Proof of Concept

  • Note: In the PoC below, user2 is penalized due to a cooldown violation, though the same issue applies to swap amount limits as well.

function test_MappingsOnlyUseRouterAddr() public {
//ARRANGE: Deal some ETH to users and arrange same swap params
vm.deal(user1, 1 ether);
vm.deal(user2, 1 ether);
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(0.001 ether),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
uint256 initialUser1TokenBalance = token.balanceOf(user1);
uint256 initialUser2TokenBalance = token.balanceOf(user2);
// ACT: User1 performs swap
vm.startPrank(user1);
token.approve(address(swapRouter), type(uint256).max);
swapRouter.swap{value: 0.001 ether}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
vm.roll(block.number + 1);
vm.startPrank(user2);
token.approve(address(swapRouter), type(uint256).max);
swapRouter.swap{value: 0.001 ether}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
uint256 user1Delta = token.balanceOf(user1) - initialUser1TokenBalance;
uint256 user2Delta = token.balanceOf(user2) - initialUser2TokenBalance; //user 2 gets penalty fee due to cooldown;
//Assert: User1 should receive more tokens than User2 due to cooldown penalty
assertGt(user1Delta, user2Delta, "User1 should receive more tokens than User2 due to cooldown penalty");
uint256 penaltyBps = phase1PenaltyBps;
uint256 expectedPenalty = (0.001 ether * penaltyBps) / 10_000;
assertGt(user1Delta, user2Delta + expectedPenalty, "User2 should receive fewer tokens due to cooldown penalty");
}

Recommended Mitigation

  • Pass the trader explicitly via the hook's `bytes calldata` and decode it in `_beforeSwap` to get the actual user address.

- function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
+ function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata data)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
// ... existing code ...
bool applyPenalty = false;
+ address trader = sender; // default to sender
+ if (data.length == 32) {
+ trader = abi.decode(data, (address));
+ }
- if (addressLastSwapBlock[sender] > 0) {
+ if (addressLastSwapBlock[trader] > 0) {
- uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[sender];
+ uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[trader];
if (blocksSinceLastSwap < phaseCooldown) {
applyPenalty = true;
}
}
- if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
+ if (!applyPenalty && addressSwappedAmount[trader] + swapAmount > maxSwapAmount) {
applyPenalty = true;
}
- addressSwappedAmount[sender] += swapAmount;
- addressLastSwapBlock[sender] = block.number;
+ addressSwappedAmount[trader] += swapAmount;
+ addressLastSwapBlock[trader] = block.number;
uint24 feeOverride = 0;
if (applyPenalty) {
feeOverride = uint24((phasePenaltyBps * 100));
}
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
}

Support

FAQs

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

Give us feedback!