Basin Invitational
Findings & Analysis Report
2024-09-11
Table of contents
- Summary
- Scope
- Severity Criteria
-
Low Risk and Non-Critical Issues
- 01
___self
param introduced byWellUpgradeable
is redundant - 02
calcLpTokenSupply
can’t converge for large reserve ratios, or very small reserves - 03 Potential precision and stability issues with equal values for amplification parameter
- 04
WellUpgraedable#upgradeTo
consider renamingnewImplementation
tonewMinimalProxyImplementation
- 01
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the Basin smart contract system written in Solidity. The audit took place between August 23 — August 27, 2024.
Wardens
In Code4rena’s Invitational audits, the competition is limited to a small group of wardens; for this audit, 4 wardens participated:
This audit was judged by 0xsomeone.
Final report assembled by thebrittfactor.
Summary
The C4 analysis yielded an aggregated total of 1 unique vulnerability in the category of MEDIUM severity.
Additionally, C4 analysis included 2 reports detailing issues with a risk rating of LOW severity or non-critical.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 Basin repository, and is composed of 3 smart contracts written in the Solidity programming language and includes 2433 lines of Solidity code.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
Medium Risk Findings (1)
[M-01] Stable2::calcLpTokenSupply()
function cannot convert under certain circumstances, DoSing calcReserveAtRatioLiquidity
Submitted by Egis_Security
calcLpTokenSupply
is used in both calcReserveAtRatioLiquidity
and calcReserveAtRatioSwap
, we’ll focus on calcReserveAtRatioLiquidity
.
calcLpTokenSupply
is called inside calcRate
here:
function calcReserveAtRatioLiquidity(
uint256[] calldata reserves,
uint256 j,
uint256[] calldata ratios,
bytes calldata data
) external view returns (uint256 reserve) {
...
for (uint256 k; k < 255; k++) {
scaledReserves[j] = updateReserve(pd, scaledReserves[j]);
// calculate new price from reserves:
pd.newPrice = calcRate(scaledReserves, i, j, abi.encode(18, 18));
...
Inside we call the function:
function calcRate(
uint256[] memory reserves,
uint256 i,
uint256 j,
bytes memory data
) public view returns (uint256 rate) {
uint256[] memory decimals = decodeWellData(data);
uint256[] memory scaledReserves = getScaledReserves(reserves, decimals);
// calc lp token supply (note: `scaledReserves` is scaled up, and does not require bytes).
uint256 lpTokenSupply = calcLpTokenSupply(scaledReserves, abi.encode(18, 18));
rate = _calcRate(scaledReserves, i, j, lpTokenSupply);
}
Note that the only change to calcLpTokenSupply
is the added revert on the last line of the function.
function calcLpTokenSupply(
uint256[] memory reserves,
bytes memory data
) public view returns (uint256 lpTokenSupply) {
if (reserves[0] == 0 && reserves[1] == 0) return 0;
uint256[] memory decimals = decodeWellData(data);
// scale reserves to 18 decimals.
uint256[] memory scaledReserves = getScaledReserves(reserves, decimals);
uint256 Ann = a * N * N;
uint256 sumReserves = scaledReserves[0] + scaledReserves[1];
lpTokenSupply = sumReserves;
for (uint256 i = 0; i < 255; i++) {
uint256 dP = lpTokenSupply;
// If division by 0, this will be borked: only withdrawal will work. And that is good
dP = dP * lpTokenSupply / (scaledReserves[0] * N);
dP = dP * lpTokenSupply / (scaledReserves[1] * N);
uint256 prevReserves = lpTokenSupply;
lpTokenSupply = (Ann * sumReserves / A_PRECISION + (dP * N)) * lpTokenSupply
/ (((Ann - A_PRECISION) * lpTokenSupply / A_PRECISION) + ((N + 1) * dP));
// Equality with the precision of 1
if (lpTokenSupply > prevReserves) {
if (lpTokenSupply - prevReserves <= 1) return lpTokenSupply;
} else {
if (prevReserves - lpTokenSupply <= 1) return lpTokenSupply;
}
}
revert("Non convergence: calcLpTokenSupply");
}
The issue here lies that calcLpTokenSupply
reverts under certain circumstances since it cannot converge, which makes the whole call to calcReserveAtRatioLiquidity
revert. We’ll investigate this further inside the PoC section.
Proof of Concept
Paste the following inside BeanstalkStable2LiquidityTest
and run forge test --mt test_calcReserveAtRatioLiquidity_equal_equalPositive -vv
. This is a rough test to loop through some of the cases in Stable2LUT1
.
function test_calcReserveAtRatioLiquidity_equal_equalPositive() public view {
uint256[] memory reserves = new uint256[](2);
reserves[0] = 1987e18;
reserves[1] = 12345e8;
uint256[] memory ratios = new uint256[](2);
ratios[0] = 1e18;
ratios[1] = 1e18;
for(uint i; i < 10000; i++) {
_f.calcReserveAtRatioLiquidity(reserves, 0, ratios, data);
ratios[0] += 0.003e18;
}
}
You’ll notice that the test reverts with [FAIL. Reason: revert: Non convergence: calcLpTokenSupply]
.
The values that it reverts with are:
Ratios[0]: 7195000000000000000
Ratios[1]: 1000000000000000000
Target price: 138927
High Price: 277020
Low Price: 1083
Current price: 1083 (price is closer to lowPrice)
This is when we hit this case in Stable2LUT1
:
if (price < 0.001083e6) {
revert("LUT: Invalid price");
} else {
return
-> PriceData(0.27702e6, 0, 9.646293093274934449e18, 0.001083e6, 0, 2000e18, 1e18);
}
Here are the last few iterations of calcLpTokenSupply
:
Current Lp token supply: 237151473819804594336
Prev Lp token supply: 237151473819804594338
-------------------------------------------
Current Lp token supply: 237151473819804594338
Prev Lp token supply: 237151473819804594336
-------------------------------------------
Current Lp token supply: 237151473819804594336
Prev Lp token supply: 237151473819804594338
-------------------------------------------
Current Lp token supply: 237151473819804594338
Prev Lp token supply: 237151473819804594336
-------------------------------------------
Current Lp token supply: 237151473819804594336
Prev Lp token supply: 237151473819804594338
-------------------------------------------
Current Lp token supply: 237151473819804594338
Prev Lp token supply: 237151473819804594336
-------------------------------------------
As you can see, the values “loop” +-2
; which makes it impossible to converge since the difference has to be <= 1
so it converges.
Something to note is that even if we change the reserves
in the test, the test continues failing with the same error, but every test fails when we hit this specific case in the lookup table and the price is closer to the lowPrice
.
PriceData(0.27702e6, 0, 9.646293093274934449e18, 0.001083e6, 0, 2000e18, 1e18);
It’s unclear what causes this “looping” to occur, so it’s hard to say what the root cause is.
Something interesting is that this started happening once the revert was introduced, the last audit didn’t have it, but the code still worked. Then it didn’t revert it just returned the last lpTokenSupply
.
Also note that the underlying issue is in calcLpTokenSupply
, so if the function is used on its own it can still revert, but it happens much more often when calcReserveAtRatioLiquidity
is used.
Tools Used
Foundry
Recommended Mitigation Steps
It’s very hard to recommend a fix here, as many tweaks can fix the issue. We recommend:
- Passing a flag to
calcLpTokenSupply
, if true then if the function doesn’t converge it won’t revert. - Tweaking the
PriceData
values for the specific case in the lookup table, narrowing down the range seems to fix the issue.
Assessed type
DoS
The Warden has outlined how the system might fail to converge under normal
PriceData
configurations due to looping between the threshold by a deviancy of±2
. I believe the vulnerability is valid as it causes normal operation of the system to result in a revert due to remediations carried out for a submission in the previous audit of Basin.To note, a percentage-based deviation threshold might be better appropriate than a fixed unit (i.e., a permitted deviancy of
1
) to avoid instances whereby the permitted deviation itself is missed by a negligible amount.
Low Risk and Non-Critical Issues
For this audit, 2 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by Egis_Security received the top score from the judge.
The following wardens also submitted reports: ZanyBonzy.
[01] ___self
param introduced by WellUpgradeable
is redundant
WellUpgradeable
inherits from UUPSUpgradeable
, which implements an immutable variable __self
: address private immutable __self = address(this);
It is set to the address of the deployed implementation contract because it an immutable
var and it is stored in the bytecode. The Basin team implements corresponding ___self
param, which is assigned similarly: address private immutable ___self = address(this);
We can summarize that __self == ___self
. However, it is impossible for WellUpgradeable
to use __self
variable, because it is marked as private
.
Recommended Mitigation Steps
Duplicate UUPSUpgradeable
and modify __self
to be marked as internal
, so you can reuse it inside WellUpgradeable
and safe gas for deploying same argument two times.
[02] calcLpTokenSupply
can’t converge for large reserve ratios, or very small reserves
calcLpTokenSupply
is used to calculate the lpTokenSupply
for given reserves. We found some values for reserves (with large ratio difference), or very small reserves, which result in impossibility to converge and repeatedly calculating the same lpTokenSupply
.
Issue 1:
For large ratio reserves - one such reserves
are:
Reserves 0 = 99999999000000000001246507
Reserves 1 = 1000000000000000000
Issue 2:
For very small reserve amounts the same issue also arises - it only happens with the new code:
// update scaledReserve[j] such that calcRate(scaledReserves, i, j) = low/high Price,
// depending on which is closer to targetPrice.
if (pd.lutData.highPrice - pd.targetPrice > pd.targetPrice - pd.lutData.lowPrice) {
// targetPrice is closer to lowPrice.
scaledReserves[j] = scaledReserves[i] * pd.lutData.lowPriceJ / pd.lutData.precision;
// set current price to lowPrice.
pd.currentPrice = pd.lutData.lowPrice;
} else {
// targetPrice is closer to highPrice.
scaledReserves[j] = scaledReserves[i] * pd.lutData.highPriceJ / pd.lutData.precision;
// set current price to highPrice.
pd.currentPrice = pd.lutData.highPrice;
}
// calculate max step size:
// lowPriceJ will always be larger than highPriceJ so a check here is unnecessary.
pd.maxStepSize = scaledReserves[j] * (pd.lutData.lowPriceJ - pd.lutData.highPriceJ) / pd.lutData.lowPriceJ;
Proof of Concept
Testcase for Issue 2:
function setUp() public {
address lut = address(new Stable2LUT1());
_f = new Stable2(lut);
deployMockTokens(2);
data = abi.encode(18, 18);
}
function test_calcReserveAtRatioLiquidity_smallReserveRevert() public view {
uint256[] memory reserves = new uint256[](2);
reserves[0] = 1e7;
reserves[1] = 1e7;
uint256[] memory ratios = new uint256[](2);
ratios[0] = 1027000000000000000;
ratios[1] = 1000000000000000000;
_f.calcReserveAtRatioLiquidity(reserves, 0, ratios, data);
}
Recommended Mitigation Steps
We didn’t manage to identify a solution to this problem, but the behaviour can be documented with a @dev
tag.
[03] Potential precision and stability issues with equal values for amplification parameter
In Stable2
we have an amplification param a
and amplification precision scaling parameter A_PRECISION. a is supposed to be scaled by A_PRECISION
to maintain precision in calculations, but currently, both values are set to 100
. The following results in having amplification factor equal to 1 and will result in an invariant, which looks more like uniswap constant product formula and swaps may be more exposed to slippage.
Proof of Concept
The formula will have the following graph:
- Yellow is the formula in Basin Stable implementation
- Pink is Uniswap constant product formula
- White is Constant sum formula
Note: to view the provided image, please see the original submission here.
Recommended Mitigation Steps
Make A
coefficient larger, or introduce a setter to dynamically adjust the value.
[04] WellUpgraedable#upgradeTo
consider renaming newImplementation
to newMinimalProxyImplementation
In WellUpgraedable#upgradeTo
and upgradeToAndCall
address argument, which is passed is named newImplementation
, but in Basin’s case, it should be a minimal proxy address, which has been deployed through the Aquifier
.
Recommended Mitigation Steps
Consider renaming newImplementation
to newMinimalProxyImplementation
to make the code cleaner for potential integrators.
[01] - While the variable is indeed redundant, modifying
__self
on theUUPSUpgradeable
contract would require us to have a custom implementation of the UUPSUpgradeable contract. The Basin Development Community decided this was preferable at the marginal cost of gas.[02] - Accepted, the docs will be updated to reflect that the Well Function cannot be used for extremely high or low reserves.
[03] - This is a design choice and is intentional. Developers can choose an
A
parameter that suits the need of the protocol by deploying a lookup table with the desiredA
parameter.[04] - Accepted, the parameter will be updated so that it’s clearer for developers that the implementation should be the minimal proxy deployed by an Aquifer.
Disclosures
C4 is an open organization governed by participants in the community.
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.