Typical vulnerabilities in AMM protocols

Polgemy
Decurity
Published in
21 min readAug 7, 2023

--

Introduction

This article discusses the fundamental security aspects of the AMM (automatic market maker) protocols. First of all, the algorithm of the classical AMM protocol and its architecture are described. Next, we will follow the development of the AMM idea through the years. After that, various bugs from the audits are examined. At the end of the article as always there is a link to the checklist that will help auditors during the smart contract audit engagements (check out the previous work on CDP and LSD).

What is an AMM?

Automatic market makers (AMMs) are DeFi protocols that use liquidity pools instead of a traditional market of buyers and sellers. AMMs have gained great popularity due to their efficiency and autonomy. The AMM algorithm allows to optimize the trading and maximize the capital efficiency eliminating the need for the centralized exchanges.

The AMM mechanism was first implemented by the Bancor protocol, which aimed to address the issue of the insufficient token liquidity in the exchange order book — a prevalent problem at that time. In the Bancor v1 Whitepaper released on May 30, 2017, terms like “Constant Reserve Ratio (CRR)” and “smart token” were defined. Users issue and burn smart tokens (which are based on the ERC20 smart contract), continually altering their price.

The price of a smart token is calculated using the formula:

Here reserveTokenBalance is the balance of a reserve token (e.g., ETH) in the smart token, and smartTokenSupply represents the supply of the smart token. The CRR is set by the deployer of the smart token at its creation. Clearly, with a CRR of less than 100%, the price of a smart token in relation to a reserve token will rise when purchased and fall when burned. This mechanism enables the creation of derivative tokens that are partially backed, akin to ETFs, which in 2017 was a groundbreaking innovation in the DeFi space.

But a more relevant example for this article is an ETF backed equally by tokenA and tokenB, resulting in a total CRR of 100%. For instance, a LINKSOL smart token might hold 1,000 LINK and 1,000 SOL as reserve tokens. A user wishing to convert LINK to SOL would purchase a LINKSOL smart token using LINK and then immediately sell it to acquire SOL. In this scenario, the price is determined by the formula mentioned above, and the accuracy of the price is maintained by arbitrageurs. At that time the Bancor project was groundbreaking and managed to raise 150 million USD at the ICO.

It’s worth noting that since its launch Bancor has been hacked twice. In 2018, private keys were exposed, leading to the theft of over 13 million USD. Then, in 2020, there was a vulnerability of the insufficient caller verification in the safeTransferFrom() function.

Afterwards on June 22, 2017, Vitalik Buterin published a blog post titled “On Path Independence.” In this post he delved into the problem of impermanent loss within the context of the Bancor AMM, examining its implications under various market conditions.

Vitalik compares the value of crypto assets in a smart token and the hold of tokens in the same market conditions

Vitalik proposes (with the credit to Martin Köppelmann) an alternative to the Bancor AMM mechanism using the formula x × y = k, where x is the supply of tokenA, y is the supply of tokenB, and k is the constant product. This constant product grows over time due to the accrual of exchange fees.

In this article we decided not to bury into a detailed description of the famous x × y = k formula, assuming the reader is already acquainted with it. For those seeking a deeper understanding, details can be found here.

Uniswap

A year and a half after Vitalik’s article was published, Uniswap v1 was presented at Devcon 4. This protocol enabled ETH ↔ ERC20 swaps using the constant product formula, boasting the market’s lowest gas costs and utilizing contracts written in Vyper. Uniswap v1 empowered any user to become a market maker, earning revenue by providing liquidity and creating pools with any ERC20 tokens through factories.

However, exchanging tokens via intermediary ETH introduces the risk of the impermanent loss for the LPs (liquidity providers) and results in higher exchange fees. In March 2020, the Uniswap v2 whitepaper was unveiled. In this updated version of the protocol, it became possible to create ERC20 ↔ ERC20 pools. Additionally, features like the TWAP (Time-Weighted Average Price) price oracles and flash swaps were introduced. Many DeFi enthusiasts concur that this new protocol marked the beginning of the bullish rally witnessed in 2020–2021. To familiarize with the source code, we recommend watching this video or read the documentation and this article.

In addition to Uniswap, the DeFi market features other notable AMM players like Balancer and Curve, each tailored to address specific challenges. For instance, the Balancer protocol facilitates the creation of weighted pools with more than two tokens in any desired ratio, such as 30/30/40 or 60/20/20. On the other hand, the Curve protocol is designed to enable exchanges with minimal fees and slippage (typically 100 times less than on Uniswap) thanks to an optimized constant product formula tailored for token exchanges with roughly equivalent prices, like stablecoins.

On May 5, 2021, the Uniswap v3 protocol was launched on the Ethereum mainnet. The hallmark of this new version is its “concentrated liquidity” feature. This allows liquidity providers to specify the range within which their liquidity will be involved in trades. This breakthrough has significantly optimized capital efficiency, especially for pairs with stablecoins. For such pairs, liquidity can be provided within a narrow range (like ± 3% of 1 USD) instead of the entire spectrum (0 to +∞). For a more in-depth understanding of concentrated liquidity calculations, you can refer to this link. Additionally, the third iteration of Uniswap introduced several other innovations:

  1. Flexibility in pool fees: Now, for a single token pair, there can be multiple pools differentiated by their fees, which could be 0.05%, 0.3%, or 1%.
  2. Enhanced price oracle with multiple checkpoints: unlike in Uniswap v2, where an external contract fulfilled the task of calculating the TWAP price, the new version retains checkpoints within a dedicated mapping.
  3. Non-fungible LP tokens: in this version, LP tokens are ERC721 tokens. Each token issued denotes a distinct user position.
  4. Liquidity oracle: Uniswap now offers TWAP data related to the liquidity of a pair.

However, a drawback of the concentrated liquidity is the heightened impermanent loss relative to when liquidity is provided across the full price spectrum. For a comprehensive mathematical analysis, you can refer to the detailed discussions here and here.

In June 2023, the Uniswap v4 whitepaper was unveiled. This latest iteration of the protocol introduces a revamped architecture: it employs a singleton pattern in place of the previously used factory pattern. This means all protocol pairs will be consolidated into a single contract, sparing users from incurring fees associated with pool creation. Yet, the standout feature in v4 is the introduction of custom hooks. These are external smart contracts created by users, which the protocol will invoke on specific user interactions with the Uniswap contract. Here is the list:

  1. beforeInitialize/afterInitialize
  2. beforeModifyPosition/afterModifyPosition
  3. beforeSwap/afterSwap
  4. beforeDonate/afterDonate

Hooks allow users to implement useful functionality. Examples:

  1. Time-weighted average market maker (TWAMM). More information about this type of AMM is below. An example of an implementation with Uniswap hooks can be found here.
  2. On-chain limit orders
  3. Custom on-chain oracles that allow you to calculate the price of a pair using different formulas. Example — geomean.
  4. Auto-compounded LP fees back into the LP positions

TWAMM

Time-weighted average market maker is a new concept of AMM, first mentioned in the TWAMM article from Paradigm. The problem that the proposed algorithm solves is a high price impact when swapping large volumes in pairs with little liquidity.

The solution to the problem is to split the exchange operation into an infinite number of virtual orders that will be executed by the internal AMM for several blocks. The number of blocks is determined by the trader. The secret of the low price impact is that the price of the internal AMM is restored by arbitrageurs after its change during the execution of a virtual order. At the same time, the amount of exchanged tokens may be greater than the reserves in the internal pool since during the arbitrage process liquidity will be pulled from the other AMM protocols in DeFi. In another way the TWAMM concept can be formulated as follows: TWAMM traders make a long swap of tokens on all DEXs in DeFi at once by the hands of arbitrageurs, thereby instead of one protocol, price impact is distributed across all DEXs in DeFi.

It is worth noting that intermediate virtual orders are executed only mathematically. An infinite number of virtual orders requires an infinite amount of gas. The change of the blockchain storage occurs only when interacting with the internal AMM. Arbitrageurs calculate prices on the internal AMM for a period of time without changing the state of the blockchain. And as soon as the arbitrage operation becomes profitable, arbitrageurs change tokens, adding liquidity and restoring the exchange price.

Who might need TWAMM? Large exchange operations take place on the market every day. Most of them fall in the OTC and CEX markets. For the DAO the exchange in OTC or CEX is a problem since it is necessary to choose a person with KYC which introduces risks of centralization. TWAMM is a great alternative.

You may think that TWAMM has a significant disadvantage — information about a large exchange will be available to the entire market at the beginning of the exchange. For example a whale will place an order to buy ETH for $100 million within 1000 blocks. In this case all those who have seen this long-term order know that prices will increase over time due to arbitrage orders. But it should be borne in mind that the whale can cancel a long swap at any time, thereby deceiving those who wanted to earn. Also, sandwich attacks are not very relevant for TWAMM since they must be performed in one block when a long-term trade is performed for several blocks and consists of a set of virtual orders that update the blockchain storage only during arbitrage.

TWAMM protocols examples:

  1. FraxSwap — according to the Frax team this is the first TWAMM protocol in DeFi. FraxSwap was created for internal use in the Frax ecosystem. Namely, it was created to power the algorithmic stablecoin FRAX, which requires the exchange of a large amount of FRAX to FXS and back in certain market conditions.
  2. CronFi — TWAMM relayer of Balancer. Recently through CronFi there was an exchange of stETH to rETH in the test amount of $ 1 million (proposal link). The protocol also plans to implement anonymous long-term swapping using the Aztec protocol.
  3. Aqueduct.fi — is an innovative protocol whose main difference from the above is the ZILMM (Zero-Intermediate-Liquidity Market Maker) mechanism which allows LPs not to lock liquidity in the internal AMM. The protocol is based on the money-streams and super tokens from Superfluid Finance. A super token extends the implementation of ERC20 by adding the ability to define netflow and change the balance of tokens on wallets dynamically. At the time of writing this article the security audit of Aqueduct is being performed by our team. The final report could be found later in the following repository:

Currently, the DEX protocol market is ranked TOP 3, boasting a total TVL (Total Value Locked) of over $14 billion.

Analysis of bugs from AMM audit reports

Uranium hack

On April 28, 2021 Uranium Finance was hacked for $57 million. Uranium Finance was a fork of Uniswap V2 in the BSC network. The inattention of the developers led to a simple error in the swap() function.

The original part of the swap() function code in Uniswap:

function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
/// ......
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
}

_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}

swap() function in Uranium Finance:

function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
/// ......
uint balance0Adjusted = balance0.mul(10000).sub(amount0In.mul(16));
uint balance1Adjusted = balance1.mul(10000).sub(amount1In.mul(16));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UraniumSwap: K');
}

_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}

When calculating balance0Adjusted and balance1Adjusted in the Uranium version a multiplier of 10000 was used. However when checking a constant product developers forgot to change the multiplier. The vulnerability allowed the hacker to exchange 1 wei of a token for 98% of the other tokens in the pool. Such bugs can be spotted using contract-diff.xyz which allows to find the differences in the original and the forked code:

Velodrome Audit

During the audit of the Velodrome protocol by Spearbit, a vulnerability was identified that could potentially allow an attacker to drain all tokens from the pool. Velodrome employs a stable curve formula x3y+y3x >= k, specifically when the pool is structured for the exchange of stablecoins.

function _k(uint256 x, uint256 y) internal view returns (uint256) {
if (stable) {
uint256 _x = (x * 1e18) / decimals0;
uint256 _y = (y * 1e18) / decimals1;
uint256 _a = (_x * _y) / 1e18;
uint256 _b = ((_x * _x) / 1e18 + (_y * _y) / 1e18); return (_a * _b) / 1e18; // x3y+y3x >= k
} else {
return x * y; // xy >= k
}
}

If the value x * y <= 1e18, a rounding error occurs. This error subsequently results in an incorrect and successful validation of the product constants within the swap() function in require(_k())line:

function swap(
uint256 amount0Out,
uint256 amount1Out,
address to,
bytes calldata data
) external nonReentrant {
require(!PairFactory(factory).isPaused(), "Pair: paused");
require(amount0Out > 0 || amount1Out > 0, "Pair: insufficient output amount");
(uint256 _reserve0, uint256 _reserve1) = (reserve0, reserve1);
require(amount0Out < _reserve0 && amount1Out < _reserve1, "Pair: insufficient liquidity");

uint256 _balance0;
uint256 _balance1;
{
// scope for _token{0,1}, avoids stack too deep errors
(address _token0, address _token1) = (token0, token1);
require(to != _token0 && to != _token1, "Pair: invalid to");
if (amount0Out > 0) IERC20(_token0).safeTransfer(to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) IERC20(_token1).safeTransfer(to, amount1Out); // optimistically transfer tokens
if (data.length > 0) IPairCallee(to).hook(_msgSender(), amount0Out, amount1Out, data); // callback, used for flash loans
_balance0 = IERC20(_token0).balanceOf(address(this));
_balance1 = IERC20(_token1).balanceOf(address(this));
}
uint256 amount0In = _balance0 > _reserve0 - amount0Out ? _balance0 - (_reserve0 - amount0Out) : 0;
uint256 amount1In = _balance1 > _reserve1 - amount1Out ? _balance1 - (_reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, "Pair: insufficient input amount");
{
// scope for reserve{0,1}Adjusted, avoids stack too deep errors
(address _token0, address _token1) = (token0, token1);
if (amount0In > 0) _update0((amount0In * PairFactory(factory).getFee(address(this), stable)) / 10000); // accrue fees for token0 and move them out of pool
if (amount1In > 0) _update1((amount1In * PairFactory(factory).getFee(address(this), stable)) / 10000); // accrue fees for token1 and move them out of pool
_balance0 = IERC20(_token0).balanceOf(address(this)); // since we removed tokens, we need to reconfirm balances, can also simply use previous balance - amountIn/ 10000, but doing balanceOf again as safety check
_balance1 = IERC20(_token1).balanceOf(address(this));
// The curve, either x3y+y3x for stable pools, or x*y for volatile pools
require(_k(_balance0, _balance1) >= _k(_reserve0, _reserve1), "Pair: K");
}

_update(_balance0, _balance1, _reserve0, _reserve1);
emit Swap(_msgSender(), amount0In, amount1In, amount0Out, amount1Out, to);
}

To eliminate the vulnerability, the developers made a check for MINIMUM_K = 1e10 similar to the MINIMUM_LIQUIDITY check.

Sushiswap whitehat re-entrancy hack

On April 9, 2023 a whitehat re-creacted an unsuccessful attack transaction on the RouteProcessor contract. RouteProcessor is a router capable of interacting with SushiSwap, Uniswap, QuickSwap and Trident simultaneously, combining their liquidity. The contract calls swap methods in supported DEXs and manages token balances. The contract allowed changing tokens in several modes: exchange of the ERC20 tokens from the user’s balance, exchange in one pool, exchange of the tokens from the RouteProcessor contract balance.

function processMyERC20(uint256 stream) private {
address token = stream.readAddress();
uint256 amountTotal = IERC20(token).balanceOf(address(this));
unchecked {
if (amountTotal > 0) amountTotal -= 1; // slot undrain protection
}
distributeAndSwap(stream, address(this), token, amountTotal);
}

The processMyERC20() function fetches the contract’s own token balance. The token address is obtained by processing the stream argument in which various data is encoded including the address of the token being exchanged, the address of the pool for exchange, etc. Each time the stream is processed there is a byte shift to the left to get subsequent data from the stream.

In unchecked segment the code addresses a scenario where amountTotal becomes zero after exchanging tokens across all DEXs. This amountTotal will modify the balances[] array in the ERC20 token contract. Notably, updating the balance from a value of zero to a non-zero value consumes more gas than updating from one non-zero value to another. This is because rewriting storage from a zero value is more expensive than from a non-zero value.

Next, the distributeAndSwap() function is called:

function distributeAndSwap(uint256 stream, address from, address tokenIn, uint256 amountTotal) private {
uint8 num = stream.readUint8();
unchecked {
for (uint256 i = 0; i < num; ++i) {
uint16 share = stream.readUint16();
uint256 amount = (amountTotal * share) / 65535;
amountTotal -= amount;
swap(stream, from, tokenIn, amount);
}
}
}

The function determines the number of pools, the weight of each pool and the number of tokens that need to be exchanged. Next, the swap() function is called:

/// @param amountIn Amount of tokenIn to take for swap
function swap(uint256 stream, address from, address tokenIn, uint256 amountIn) private {
uint8 poolType = stream.readUint8();
if (poolType == 0) swapUniV2(stream, from, tokenIn, amountIn);
else if (poolType == 1) swapUniV3(stream, from, tokenIn, amountIn);
else if (poolType == 2) wrapNative(stream, from, tokenIn, amountIn);
else if (poolType == 3) bentoBridge(stream, from, tokenIn, amountIn);
else if (poolType == 4) swapTrident(stream, from, tokenIn, amountIn);
else if (poolType == 5) swapTridentCL(stream, from, tokenIn, amountIn);
else revert('RouteProcessor: Unknown pool type');
}

Here the protocol in which the exchange should take place is determined from the stream. The Uniswap V3 pool was selected during the attack.

function swapUniV3(uint256 stream, address from, address tokenIn, uint256 amountIn) private {
address pool = stream.readAddress();
bool zeroForOne = stream.readUint8() > 0;
address recipient = stream.readAddress();

lastCalledPool = pool;
IUniswapV3Pool(pool).swap(
recipient,
zeroForOne,
int256(amountIn),
zeroForOne ? MIN_SQRT_RATIO + 1 : MAX_SQRT_RATIO - 1,
abi.encode(tokenIn, from)
);
require(lastCalledPool == IMPOSSIBLE_POOL_ADDRESS, 'RouteProcessor.swapUniV3: unexpected'); // Just to be sure
}

In this function tokenIn tokens are sent from the contract to the address of the caller. The attacker created his own pool with ERC777 tokens and called uniswapV3SwapCallback() in the swap() UniV3 function, grabbing the tokens. The peculiarity of ERC777 is the presence of hooks that allow to perform operations when receiving and sending tokens. So the tokensReceived() function is called when tokens are received on the recipient, if it is a contract.

Beanstalk Wells read-only re-entrancy

A read-only re-entrancy vulnerability was found during the audit of the Beanstalk’s Basin AMM. In the removeLiquidity() function in which liquidity providers can burn their LP tokens and get tokens from the reserves, the Checks-Effects-Interactions pattern is not followed.

function removeLiquidity(
uint lpAmountIn,
uint[] calldata minTokenAmountsOut,
address recipient,
uint deadline
) external nonReentrant expire(deadline) returns (uint[] memory tokenAmountsOut) {
IERC20[] memory _tokens = tokens();
uint[] memory reserves = _updatePumps(_tokens.length);
uint lpTokenSupply = totalSupply();

tokenAmountsOut = new uint[](_tokens.length); //450
_burn(msg.sender, lpAmountIn); //451
for (uint i; i < _tokens.length; ++i) {
tokenAmountsOut[i] = (lpAmountIn * reserves[i]) / lpTokenSupply; //452
if (tokenAmountsOut[i] < minTokenAmountsOut[i]) { //453
revert SlippageOut(tokenAmountsOut[i], minTokenAmountsOut[i]); //454
} //455
_tokens[i].safeTransfer(recipient, tokenAmountsOut[i]); //456
reserves[i] = reserves[i] - tokenAmountsOut[i];
}

_setReserves(_tokens, reserves);
emit RemoveLiquidity(lpAmountIn, tokenAmountsOut, recipient);
}

After calculating the tokens to send the contract sends the tokens via safeTransfer() to the recipient address. The token being sent may be an ERC777 token created by an attacker and have a re-entrancy exploit in the callback function. The reserves[] update occurs in the _setReserves() function after sending tokens to the user thus violating CEI. Although the removeLiquidity() function is protected by the nonReentrant modifier, the third-party contracts that look up the reserves of this contract can be exploited through a call to getReserves() which will return incorrect values.

function getReserves() external view returns (uint[] memory reserves) {
reserves = _getReserves(numberOfTokens());
}

Balancer read-only re-entrancy

In 2022 the ChainSecurity team announced a vulnerability in the Balancer contracts similar to the read-only re-entrancy described above.

In Balancer there are two main contracts: the Pool which processes the swaps and LP tokens, and the Vault contract in which the balances of tokens are stored. Both contracts are individually protected by the nonReentrant modifier which protects them from an internal re-entrancy. But the modifier cannot protect contracts from being called again from another contract.

function _joinOrExit(
PoolBalanceChangeKind kind,
bytes32 poolId,
address sender,
address payable recipient,
PoolBalanceChange memory change
) private nonReentrant withRegisteredPool(poolId) authenticateFor(sender) {
// This function uses a large number of stack variables (poolId, sender and recipient, balances, amounts, fees,
// etc.), which leads to 'stack too deep' issues. It relies on private functions with seemingly arbitrary
// interfaces to work around this limitation.

InputHelpers.ensureInputLengthMatch(change.assets.length, change.limits.length);

// We first check that the caller passed the Pool's registered tokens in the correct order, and retrieve the
// current balance for each.
IERC20[] memory tokens = _translateToIERC20(change.assets);
bytes32[] memory balances = _validateTokensAndGetBalances(poolId, tokens);

// The bulk of the work is done here: the corresponding Pool hook is called, its final balances are computed,
// assets are transferred, and fees are paid.
(
bytes32[] memory finalBalances,
uint256[] memory amountsInOrOut,
uint256[] memory paidProtocolSwapFeeAmounts
) = _callPoolBalanceChange(kind, poolId, sender, recipient, change, balances);

// All that remains is storing the new Pool balances.
PoolSpecialization specialization = _getPoolSpecialization(poolId);
if (specialization == PoolSpecialization.TWO_TOKEN) {
_setTwoTokenPoolCashBalances(poolId, tokens[0], finalBalances[0], tokens[1], finalBalances[1]);
} else if (specialization == PoolSpecialization.MINIMAL_SWAP_INFO) {
_setMinimalSwapInfoPoolBalances(poolId, tokens, finalBalances);
} else {
// PoolSpecialization.GENERAL
_setGeneralPoolBalances(poolId, finalBalances);
}

bool positive = kind == PoolBalanceChangeKind.JOIN; // Amounts in are positive, out are negative
emit PoolBalanceChanged(
poolId,
sender,
tokens,
// We can unsafely cast to int256 because balances are actually stored as uint112
_unsafeCastToInt256(amountsInOrOut, positive),
paidProtocolSwapFeeAmounts
);
}

The _callPoolBalanceChange() function transfers tokens. During the transfer the _handleRemainingEth() function is called which sends back the user’s ETH that was not spent. It is worth noting that the _setMinimalSwapInfoPoolBalances() function which updates the balances in the Vault is called after the token transfer function, which makes it possible for the attacker to manipulate the balance of their tokens in the callback function.

Sushi Trident Audit Phase 1. Flashswap bug

Sushiswap Trident is an AMM and routing system that allows users to develop and deploy new types of pools adhering to the IPool interface.

At the Phase 1 Code4rena contest a bug was discovered that led to flashSwap being inoperable.

/// @dev Swaps one token for another. The router must support swap callbacks and ensure there isn't too much slippage.
function flashSwap(bytes calldata data) public override lock returns (uint256 amountOut) {
(
address tokenIn,
address tokenOut,
address recipient,
bool unwrapBento,
uint256 amountIn,
bytes memory context
) = abi.decode(data, (address, address, address, bool, uint256, bytes));

Record storage inRecord = records[tokenIn];
Record storage outRecord = records[tokenOut];

require(amountIn <= _mul(inRecord.reserve, MAX_IN_RATIO), "MAX_IN_RATIO");

amountOut = _getAmountOut(amountIn, inRecord.reserve, inRecord.weight, outRecord.reserve, outRecord.weight);

ITridentCallee(msg.sender).tridentSwapCallback(context);
// @dev Check Trident router has sent `amountIn` for skim into pool.
unchecked { // @dev This is safe from under/overflow - only logged amounts handled.
require(_balance(tokenIn) >= amountIn + inRecord.reserve, "NOT_RECEIVED");
inRecord.reserve += uint120(amountIn);
outRecord.reserve -= uint120(amountOut);
}
_transfer(tokenOut, amountOut, recipient, unwrapBento);
emit Swap(recipient, tokenIn, tokenOut, amountIn, amountOut);
}

A trivial mistake was made — the tridentSwapCallback() function on the user’s contract was called before the _transfer(). It means that the user’s contract will not receive the desired tokens when the callback function is called.

Derby Finance. No slippage protection

Derby Finance allows to optimize yield by diversifying tokens into various DeFi tools. The vulnerability which was discovered in Derby Finance is not in the AMM itself but in the way the protocol interacted with an AMM.

function withdrawRewards() external nonReentrant onlyWhenIdle returns (uint256 value) {
UserInfo storage user = userInfo[msg.sender];
require(user.rewardAllowance > 0, allowanceError);
require(rebalancingPeriod > user.rewardRequestPeriod, "!Funds");

value = user.rewardAllowance;
value = checkForBalance(value);

reservedFunds -= value;
delete user.rewardAllowance;
delete user.rewardRequestPeriod;

if (swapRewards) {
uint256 tokensReceived = Swap.swapTokensMulti(
Swap.SwapInOut(value, address(vaultCurrency), derbyToken),
controller.getUniswapParams(),
true
);
IERC20(derbyToken).safeTransfer(msg.sender, tokensReceived);
} else {
vaultCurrency.safeTransfer(msg.sender, value);
}
}

The user can call the withdrawRewards() function to receive the rewards from the protocol. During this operation a part of the reward will be exchanged for DERBY tokens through the Uniswap pool which will be transferred to the user. The error is that the code does not calculate the slippage after the swap. An attacker can carry out a classic sandwich attack in the following order:

  1. Detect a transaction with the withdrawRewards() call in the mempool
  2. Buy DERBY tokens on Uniswap
  3. Wrap the transaction with the withdrawRewards() call
  4. Sell DERBY tokens on Uniswap

The contract must calculate expected amount of tokens to receive before the swap() operation using external oracles.

Spartan protocol audit. Result of transfer / transferFrom not checked

In the Spartan Protocol contest auditors found a common error of not checking the return value of transfer() and transferFrom(). The fact is that in case of the insufficient balance these functions in the standard implementation of ERC20 return false instead of revert(). It is always necessary to check the result of the function execution.

Sushi Trident Audit Phase 2

During the second phase of the audit researchers discovered an error in the burn() function of the contract ConcentratedLiquidityPool:

function burn(bytes calldata data) public override lock returns (IPool.TokenAmount[] memory withdrawnAmounts) {
(int24 lower, int24 upper, uint128 amount, address recipient, bool unwrapBento) = abi.decode(
data,
(int24, int24, uint128, address, bool)
);

uint160 priceLower = TickMath.getSqrtRatioAtTick(lower);
uint160 priceUpper = TickMath.getSqrtRatioAtTick(upper);
uint160 currentPrice = price;

unchecked {
if (priceLower < currentPrice && currentPrice < priceUpper) liquidity -= amount;
}

(uint256 amount0, uint256 amount1) = _getAmountsForLiquidity(
uint256(priceLower),
uint256(priceUpper),
uint256(currentPrice),
uint256(amount)
);

(uint256 amount0fees, uint256 amount1fees) = _updatePosition(msg.sender, lower, upper, -int128(amount)); //252

unchecked {
amount0 += amount0fees;
amount1 += amount1fees;
}

withdrawnAmounts = new TokenAmount[](2);
withdrawnAmounts[0] = TokenAmount({token: token0, amount: amount0});
withdrawnAmounts[1] = TokenAmount({token: token1, amount: amount1});

unchecked {
reserve0 -= uint128(amount0fees); //264
reserve1 -= uint128(amount1fees); //265
}

_transferBothTokens(recipient, amount0, amount1, unwrapBento);

nearestTick = Ticks.remove(ticks, lower, upper, amount, nearestTick);
emit Burn(msg.sender, amount0, amount1, recipient);
}

Instead of deducting the tokens from reserve0 and reserve1 only commissions are deducted from them. After burning LP tokens the actual balances and the reserves differ significantly which lead to accounting errors.

Another error was found in the burn() function. The amount is determined by the user and if amount is equal to 2¹²⁸ — 1 then int(amount) will be -1 . Since a negative -int128 is passed to the _updatePosition() function after calculations it will be equal to 1. This will allow the attacker to receive free LP tokens.

Findings from FraxSwap audit

A vulnerability related to the processing of rebasing tokens was discovered in the FraxSwap protocol. Rebasing tokens can dynamically adjust the user balances.

///@notice stop the execution of a long term order
function cancelLongTermSwap(uint256 orderId) external lock execVirtualOrders {
(address sellToken, uint256 unsoldAmount, address buyToken, uint256
purchasedAmount) = longTermOrders.cancelLongTermSwap(orderId);
bool buyToken0 = buyToken == token0;
twammReserve0 -= uint112(buyToken0 ? purchasedAmount : unsoldAmount);
twammReserve1 -= uint112(buyToken0 ? unsoldAmount : purchasedAmount);
// transfer to owner of order
_safeTransfer(buyToken, msg.sender, purchasedAmount);
_safeTransfer(sellToken, msg.sender, unsoldAmount);
// update order. Used for tracking / informational
longTermOrders.orderMap[orderId].isComplete = true;
emit CancelLongTermOrder(msg.sender, orderId, sellToken, unsoldAmount,
buyToken, purchasedAmount);
}

A user can cancel the long-term swap at any time and return unsold tokens via the cancelLongTermSwap() function. At the same time, the amount of tokens is calculated from the internal mapping of reserves[]. If there is a change of the contract’s balance during the swap of the rebasing tokens, the current balance and the internal accounting of reserves[] will be out of sync which will lead to the loss of user tokens. Rebasing tokens have to be built the proper way as explained in the Uniswap docs.

Another vulnerability found in this audit is related to the lack of a liquidity check before a long-term swap.

function performLongTermSwap(LongTermOrders storage longTermOrders, address from, address to, uint256 amount, uint256 numberOfTimeIntervals) private returns (uint256) {
uint256 currentTime = block.timestamp;
uint256 lastExpiryTimestamp = currentTime - (currentTime % longTermOrders.orderTimeInterval);
uint256 orderExpiry = longTermOrders.orderTimeInterval * (numberOfTimeIntervals + 1) + lastExpiryTimestamp;
uint256 sellingRate = SELL_RATE_ADDITIONAL_PRECISION * amount / (orderExpiry - currentTime);
require(sellingRate > 0); // tokenRate cannot be zero

Before the the swap the contract checks only that the price of the pair is greater than zero, while there is no check for the sufficient liquidity in the pair. A user can initiate a long-term swap in a newly created pool without liquidity which will lead to the denial of service as other trades will be reverted.

Impact of the blockchain pause on long time swaps in a TWAMM

This is not a bug, but an interesting question from the CronFi documentation: “Can LT Swaps stay active when the Ethereum blockchain network is dead?”.

The difference between block.timestamp and block.number is significant in the price calculation if the new blocks production temporarily stops. Let’s assume that the Ethereum network stopped for 5 hours at block 514. If the TWAMM protocol used block.timestamp to calculate the execution of virtual orders then, when the blockchain is resumed, the TWAMM algorithm will take into account that virtual blocks were executed during these 7200 seconds which may lead to an increased price impact. If block.number is used then, when the blockchain is resumed on block 515, virtual orders will not be executed.

https://docs.cronfi.com/twamm/research/attack-vectors/the-living-dead

The checklist

As always we have compiled an audit checklist, this time for AMM protocols. This list includes only more or less universal issues, however each AMM protocol has nuanced features that cannot be generalized. Check it out here:

If you need a professional security audit for your blockchain software, smart contracts or a dApp, do not hesitate to submit the contact form. Check out our previous audits at GitHub.

--

--