Platform: Code4rena
Start Date: 03/07/2023
Pot Size: $40,000 USDC
Total HM: 14
Participants: 74
Period: 7 days
Judge: alcueca
Total Solo HM: 9
Id: 259
League: ETH
Rank: 3/74
Findings: 5
Award: $2,795.93
๐ Selected for report: 1
๐ Solo Findings: 1
271.5712 USDC - $271.57
https://github.com/code-423n4/2023-07-basin/blob/c1b72d4e372a6246e0efbd57b47fb4cbb5d77062/src/Well.sol#L352-L358 https://github.com/code-423n4/2023-07-basin/blob/c1b72d4e372a6246e0efbd57b47fb4cbb5d77062/src/Well.sol#L645
In Well.sol, all token trading and liquidity changing operations are invoked with _updatePumps()
hook to pass reserve change to the Pump, except for reserve changes by calling shift()
.
As a result, any reserve modification from shift()
will not be updated in any pumps which easily creates a deep discrepancy between the well and the pump.
Because under normal conditions, any reserve changes from shift()
will not be picked up by any pumps and this can also be easily exploited, I evaluate this to be High in severity.
The intended behavior is to update the pumps with any operation that modifies the Wellโs reserves. This implemented in _swapFrom()
,swapTo()
,_addLiquidity()
, removeLiquidity
etc. In these functions, _updatePumps()
hook will be called, which pass current token reserves to the pumps through 'update()`.
//Well.sol function _swapFrom( IERC20 fromToken, IERC20 toToken, uint256 amountIn, uint256 minAmountOut, address recipient ) internal returns (uint256 amountOut) { IERC20[] memory _tokens = tokens(); |> uint256[] memory reserves = _updatePumps(_tokens.length); ...
//Well.sol function _updatePumps( uint256 _numberOfTokens ) internal returns (uint256[] memory reserves) { ... |> try IPump(_pump.target).update(reserves, _pump.data) {} catch {} ...
However, shift()
which also allows users to trade and modify token reserves doesn't implement _updatePumps()
or other mechanisms to update reserves to the pumps. This creates an easy bypass for token reserve changes not picked up by any pumps, which can be exploited by malicious uers.
//Well.sol function shift( IERC20 tokenOut, uint256 minAmountOut, address recipient ) external nonReentrant returns (uint256 amountOut) { IERC20[] memory _tokens = tokens(); uint256[] memory reserves = new uint256[](_tokens.length); // Use the balances of the pool instead of the stored reserves. // If there is a change in token balances relative to the currently // stored reserves, the extra tokens can be shifted into `tokenOut`. for (uint256 i; i < _tokens.length; ++i) { reserves[i] = _tokens[i].balanceOf(address(this)); } uint256 j = _getJ(_tokens, tokenOut); amountOut = reserves[j] - _calcReserve(wellFunction(), reserves, j, totalSupply()); if (amountOut >= minAmountOut) { tokenOut.safeTransfer(recipient, amountOut); reserves[j] -= amountOut; _setReserves(_tokens, reserves); emit Shift(reserves, tokenOut, amountOut, recipient); } else { revert SlippageOut(amountOut, minAmountOut); } }
Manual Vscode
use _updatePumps()
for shift()
as well since it also modifies the reserve.
Other
#0 - c4-pre-sort
2023-07-11T13:36:50Z
141345 marked the issue as duplicate of #278
#1 - c4-pre-sort
2023-07-11T13:59:52Z
141345 marked the issue as duplicate of #136
#2 - c4-judge
2023-08-03T07:57:20Z
alcueca marked the issue as partial-50
#3 - alcueca
2023-08-03T07:58:49Z
No mention of sync()
๐ Selected for report: oakcobalt
2152.3705 USDC - $2,152.37
https://github.com/code-423n4/2023-07-basin/blob/c1b72d4e372a6246e0efbd57b47fb4cbb5d77062/src/pumps/MultiFlowPump.sol#L36-L37 https://github.com/code-423n4/2023-07-basin/blob/c1b72d4e372a6246e0efbd57b47fb4cbb5d77062/src/pumps/MultiFlowPump.sol#L205-L208 https://github.com/code-423n4/2023-07-basin/blob/c1b72d4e372a6246e0efbd57b47fb4cbb5d77062/src/pumps/MultiFlowPump.sol#L212-L215
When multiple tokens are handled in MultiFlowPump.sol, during trading, the pump might either unfairly reflects volatile assets to be more stale than normal trading activities, Or may fail to have any effects on relatively more stable assets.
This could distort SMA and EMA token reserve values and unfairly reflect some token prices because it uses a one-size-fits-all approach to multiple token asset prices indiscriminate of their normal volatility levels.
In MultiFlowPump.sol, a universal cap of maximum percentage change of a token reserve is set up as immutable variables - LOG_MAX_INCREASE
and LOG_MAX_DECREASE
. These two values are benchmarks for all tokens handled by the pump and are used to cap token reserve change per block in _capReserve()
. And this cap is applied and checked on every single update()
invoked by Well.sol.
//MultiFlowPump.sol bytes16 immutable LOG_MAX_INCREASE; bytes16 immutable LOG_MAX_DECREASE;
//MultiFlowPump.sol-update() ... for (uint256 i; i < numberOfReserves; ++i) { // Use a minimum of 1 for reserve. Geometric means will be set to 0 if a reserve is 0. |> pumpState.lastReserves[i] = _capReserve( pumpState.lastReserves[i], (reserves[i] > 0 ? reserves[i] : 1).fromUIntToLog2(), blocksPassed ); ...
//MultiFlowPump.sol-_capReserve() ... if (lastReserve.cmp(reserve) == 1) { |> bytes16 minReserve = lastReserve.add(blocksPassed.mul(LOG_MAX_DECREASE)); // if reserve < minimum reserve, set reserve to minimum reserve if (minReserve.cmp(reserve) == 1) reserve = minReserve; } // Rerserve Increasing or staying the same. else { |> bytes16 maxReserve = blocksPassed.mul(LOG_MAX_INCREASE); maxReserve = lastReserve.add(maxReserve); // If reserve > maximum reserve, set reserve to maximum reserve if (reserve.cmp(maxReserve) == 1) reserve = maxReserve; } cappedReserve = reserve; ...
As seen from above, whenever a token reserve change is over the percentage dictated by LOG_MAX_INCREASE
or LOG_MAX_DECREASE
, the token reserve change will be capped at the maximum percentage and rewritten as calculated minReserve
or maxReserve
value. This is fine if the pump is only managing one trading pair(two tokens) because their trading volatility level can be determined.
However, this is highly vulnerable when multiple trading pairs and multiple token assets are handled by a pump. And this is the intended scenario, which can be seen in Well.sol _updatePumps()
where all token reserves handled by well will be passed to MultiFlowPump.sol. In this case, either volatile tokens will become more stale compared to their normal trading activities, or some stable tokens are allowed to deviate more than their normal trading activities which are vulnerable to exploits.
See POC below as an example to show the above scenario. Full test file here.
//Pump.HardCap.t.sol ... function setUp() public { mWell = new MockReserveWell(); initUser(); pump = new MultiFlowPump( from18(0.5e18), from18(0.333333333333333333e18), 12, from18(0.9e18) ); } function test_HardCap() public { uint256[] memory initReserves = new uint256[](4); //initiate for mWell and pump initReserves[0] = 100e8; //wBTC initReserves[1] = 1600e18; //wETH initReserves[2] = 99000e4; //USDC initReserves[3] = 98000e4; //USDT mWell.update(address(pump), initReserves, new bytes(0)); increaseTime(12); //Reserve update uint256[] memory updateReserves = new uint256[](4); updateReserves[0] = 160e8; //wBTC updateReserves[1] = 1000e18; //wETH updateReserves[2] = 96000e4; //USDC updateReserves[3] = 101000e4; //USDT mWell.update(address(pump), updateReserves, new bytes(0)); // lastReserves0 reflects initReserves[i] uint256[] memory lastReserves0 = pump.readLastReserves(address(mWell)); increaseTime(12); mWell.update(address(pump), updateReserves, new bytes(0)); // lastReserves1 reflects 1st reserve update uint256[] memory lastReserves1 = pump.readLastReserves(address(mWell)); assertEq( ((lastReserves1[0] - lastReserves0[0]) * 1000) / lastReserves0[0], 500 ); //wBTC: 50% reserve change versus 60% reserve change in well console.log(lastReserves1[0]); //14999999999 assertEq( ((lastReserves0[1] - lastReserves1[1]) * 1000) / lastReserves0[1], 333 ); //wETH: 33% reserve change versus 37.5% reserve change in well console.log(lastReserves1[1]); //1066666666666666667199 assertApproxEqAbs(lastReserves1[2], 96000e4, 1); //USDC: reserve change matches well, 3% change decrease assertApproxEqAbs(lastReserves1[3], 101000e4, 1); //USDT: reserve change matches well, 3% reserve increase } ...
As seen in POC, USDT/USDC reserve changes of more than 3% are allowed, whereas WBTC/WETH reserve changes are restricted. The test is based on 50% for max percentage per block increase and 33% max percentage per block decrease. Although the exact percentage change settings can be different and the actual token reserve changes per block differ by assets, the idea is such universal cap value likely will not fit multiple tokens or trading pairs. The cap can be either set too wide which is then no need to have a cap at all, or be set in a manner that restricts some volatile token assets.
See test results.
Running 1 test for test/pumps/Pump.HardCap.t.sol:PumpHardCapTest [PASS] test_HardCap() (gas: 489346) Logs: 14999999999 1066666666666666667199 Test result: ok. 1 passed; 0 failed; finished in 18.07ms
Manual. Vscode.
Different assets have different levels of volatility, and such variations in volatility are typically taken into account in an AMM or oracle. (Think UniswapV3 with different tick spacings, or chainlink with different price reporting intervals).
Itโs better to avoid setting up LOG_MAX_INCREASE
, LOG_MIN_INCREASE
directly in MultiFlowPump.sol.
(1) Instead, in Well.sol allow MAX_INCREASE
and MAX_DECREASE
to be token specific and included as part of the immutable data created at the time of Well.sol development from Aquifer.sol, such that a token address can be included together with MAX_INCREASE
and MAX_DECREASE
as immutable constants.
(2) Then in _updatePumps()
, pass current token reserves, and pass token MAX_INCREASE
and MAX_DECREASE
as _pump.data
to MultiFlowPump.sol update()
.
(3) In MultiFlowPump.sol update()
, when calculating _capReserve()
, use decoded _pump.data
to pass MAX_INCREASE
, MAX_DECREASE
for token specific cap calcuation.
Oracle
#0 - c4-pre-sort
2023-07-12T02:01:09Z
141345 marked the issue as primary issue
#1 - 141345
2023-07-13T08:43:27Z
each token has different scale of movement, maybe need separate cap
#2 - c4-sponsor
2023-07-18T10:55:08Z
publiuss marked the issue as sponsor acknowledged
#3 - c4-sponsor
2023-07-18T10:55:50Z
publiuss marked the issue as disagree with severity
#4 - publiuss
2023-07-18T10:56:16Z
A separate Beanstalk Pump could always be deployed. Plus, the effects of manipulation are the same whether it is volatile or stable. Recommend changing to low.
#5 - alcueca
2023-08-03T21:31:41Z
The documentation provided makes no mention that the all the tokens in MultiFlowPump should have similar typical volatility. The MultiFlowPump is vulnerable exactly as described.
#6 - c4-judge
2023-08-03T21:31:49Z
alcueca marked the issue as satisfactory
#7 - c4-judge
2023-08-19T18:39:10Z
alcueca marked the issue as selected for report
๐ Selected for report: 0xprinc
Also found by: 0x11singh99, 0xAnah, 0xWaitress, 0xkazim, 2997ms, 33audits, 404Notfound, 8olidity, CRIMSON-RAT-REACH, CyberPunks, DanielWang888, Deekshith99, Eeyore, Eurovickk, Inspecktor, JGcarv, John, Jorgect, Kaysoft, LosPollosHermanos, MohammedRizwan, Qeew, QiuhaoLi, Rolezn, TheSavageTeddy, Topmark, Trust, Udsen, a3yip6, alexzoid, bigtone, codegpt, erebus, fatherOfBlocks, ginlee, glcanvas, hunter_w3b, josephdara, kaveyjoe, kutugu, mahdirostami, max10afternoon, oakcobalt, peanuts, pfapostol, ptsanev, qpzm, radev_sw, ravikiranweb3, sces60107, seth_lawson, te_aut, twcctop, zhaojie, ziyou-
17.5208 USDC - $17.52
MultFlowPump.sol is designed to be minimalistic in a way that only necessary data is stored on-chain. So itโs an intended behavior some data can be easily acquired and stored off-chain for customized use cases.
For example, for the TwaReserves
feature to be sufficient, some off-chain accounting might be needed to store historic CumulativeReserves
values and their timestamps.
However, the current implementation of MultiFlowPump.sol doesnโt provide such easy access for off-chain mechanisms because there are no events that can be emitted.
Itโs recommended to properly emit events of change in these calculated reserve values and their timestamps for proper off-chain accounting.
#0 - c4-pre-sort
2023-07-13T15:03:30Z
141345 marked the issue as high quality report
#1 - c4-pre-sort
2023-07-14T05:53:13Z
141345 marked the issue as low quality report
#2 - c4-judge
2023-08-04T21:04:06Z
alcueca marked the issue as grade-a
๐ Selected for report: SM3_SS
Also found by: 0x11singh99, 0xAnah, 0xSmartContract, 0xn006e7, 0xprinc, DavidGiladi, ElCid, JCN, K42, MIQUINHO, Raihan, Rolezn, SAAJ, SY_S, Strausses, TheSavageTeddy, bigtone, erebus, hunter_w3b, josephdara, lsaudit, mahdirostami, oakcobalt, peanuts, pfapostol, seth_lawson
58.4732 USDC - $58.47
When MultiFlowPump.sol update()
is called, blockPassed
is calculated based on deltaTimestamp
. When the function is called during the same block as the last reserve update, deltaTimestamp
will be 0 and blockPassed
is 0.
This is the condition where wasteful gas occurs.
In MultiFlowPump.sol update()
, lastReserves[i]
is designed to be the same value as the previous update with the same timestamp as the last timestamp. cumulativeReserve[i]
will be the same as the previous cumulativeReserve[i]
.
//MultiFlowPump.sol-update() ... { uint256 deltaTimestamp = _getDeltaTimestamp( pumpState.lastTimestamp ); alphaN = ALPHA.powu(deltaTimestamp); deltaTimestampBytes = deltaTimestamp.fromUInt(); // Relies on the assumption that a block can only occur every `BLOCK_TIME` seconds. blocksPassed = (deltaTimestamp / BLOCK_TIME).fromUInt(); } ... pumpState.lastReserves[i] = _capReserve( pumpState.lastReserves[i], (reserves[i] > 0 ? reserves[i] : 1).fromUIntToLog2(), blocksPassed ); ... slot.storeLastReserves(uint40(block.timestamp), pumpState.lastReserves); ...
//MultiFlowPump.sol-_capReserve() ... if (lastReserve.cmp(reserve) == 1) { bytes16 minReserve = lastReserve.add( blocksPassed.mul(LOG_MAX_DECREASE) ); // if reserve < minimum reserve, set reserve to minimum reserve if (minReserve.cmp(reserve) == 1) reserve = minReserve; } ...
Even though lastReserve[i]
, cumulativeReserve[i]
, and lastTimestamp
donโt change, these values will still be calculated inside _capReserve
and then written to storage. This is a wasteful calculation and storage operation that can be bypassed to save gas.
Bypass lastReserves[i]
cumulativeReserves[i]
calculation and re-writing to storage when deltaTimestamp
is 0.
#0 - c4-pre-sort
2023-07-13T13:15:19Z
141345 marked the issue as high quality report
#1 - c4-sponsor
2023-07-21T15:40:37Z
publiuss marked the issue as sponsor confirmed
#2 - c4-judge
2023-08-05T11:00:06Z
alcueca marked the issue as grade-a
296.0006 USDC - $296.00
The main focus of the audit is to:
Note that library contracts were excluded from my audit.
Since 'well functions' is the core that enables different flavors of trade, it might be debatable whether a design pattern that completely separates 'well functions' and 'wells' will limit the versatility of AMMs.
An example that might challenge this pattern is UniswapV3, or similar AMMs that provide concentrated liquidity. In UniswapV3, liquidity is nonfungible and the current frameworks of Well.sol are insufficient in supporting nonfungible liquidity. In addition, in 'well functions', the relations between total supply and token reserve do not exist in this scenario.
One recommendation is to explore how an ERC721-based well might achieve a clean separation between well and well functions for protocols like UniswapV3. Otherwise, might need to consider a new group of wells with integrated well functions that deal with nonfungible liquidity which compromises 'composability' on some level.
Documentation: The code is largely well documented with some exceptions in Well.sol, MultiFlowPump.sol, ConstantProduct2.sol where public-facing functions have no or insufficient Natspec comments. There are inconsistent Natspec styles in ConstantProduct2.sol.
Tests:
There are uncovered areas, although the extent is not assessed. For example, in Pump.Fuzz.t.sol, there is no testing on EMA values -emaReserves[i]
when there is testing on the last reserves and cumulative reserves.
Gas: Wasteful gas operation is observed. The report is submitted separately.
Very low risk of centralization
One concern on the assumptions in Well.sol is the acceptance of uneven liquidity adding or removing. As an example of a good well implementation, Well.sol allows various uneven and single-sided liquidity modifications by users with no constraints.
Although this is a design choice, it allows token reserve ratios in a market to change outside of normal trading activities, which can be considered a vulnerable feature for an AMM.
Note that as an alternative, single-sided liquidity modification can be achieved by including token swapping, in which case it can be an add-on feature without impacting ratios of token reserves.
In my opinion, the pump design is crucial in ensuring overall system health.
The current MultiFlowPump.sol implementation has some weaknesses in handling multiple token prices.
In addition, there seems to be no implementation in place to allow multiple wells to share a pump. For example, the doc says a pump can be shared by multiple wells if it stores a mapping of well addresses. Such implementation is not found.
Because pumps are intended to be designed in a minimalistic way, further tests and example implementation of how pumps' core functionality such as EMA, SMA, and TWA values can be extended on-chain and off-chain need to be looked into.
11 hours
#0 - c4-pre-sort
2023-07-12T12:22:57Z
141345 marked the issue as high quality report
#1 - c4-sponsor
2023-07-22T10:25:16Z
publiuss marked the issue as sponsor confirmed
#2 - publiuss
2023-07-22T10:25:59Z
In regards to systematic risks #2, the current implementation does support multiple Pumps.
#3 - c4-judge
2023-08-05T20:11:52Z
alcueca marked the issue as grade-a