The Standard

The Standard
DeFiHardhat
20,000 USDC
View results
Submission Details
Severity: low
Invalid

Having no Cap for Liquidated Assets to Sell in `LiquidationPool` Causes Permanent Loss of Stakers EUROs position

Description:
When the total value of the liquidated asset to be sold to the pool exceeds the total value of 'EUROs' available for the trade, the costInEuros is erroneously calculated. This results in the execution of the following if block, which wipes out stakers' balances. The miscalculation causes the if block to run, leading to inaccurate changes in stakers' positions.


Impact:
This error wipes out the EUROs position of holders without compensating them with the corresponding reward.


Proof of Concept:

** Given the following scenario: **
Liquidated Asset: wBtc = 1e8 (limiting to one token for simplicity)
btcusd price = 40000e8
wbtc decimals = 8
eurusd price = 1.12e8
_hundredPC = 1e5
_collateralRate = 110000

Position0.TST value = 1000 * 1e0 *1e8 = 1000e8 (amount x price)
Position0.EUROs value = 100 * 1e0 * 1.12e8 = 112e8
Total Staked value = 1112e8

Position1.TST value = 1000 * 1e0 * 1e8 = 1000e18
Position1.EUROs value = 0 * 1e0 * 1.12e8 = 0
Total Staked value = 1000e8

Position2.TST value = 0 * 1e0 * 1e8 = 0
Position2.EUROs value = 100 * 1e0 * 1.12e8 = 112e8
Total Staked value = 112e8

Position3.TST value = 100 * 1e0 * 1e8 = 100e8
Position3.EUROs value = 5 * 1e0 * 1.12e8 = 5.6e8
Total Staked value = 105.6e8

Position4.TST value = 10 * 1e0 * 1e8 = 10e8
Position4.EUROs value = 100 * 1e0 * 1.12e8 = 112e8
Total Staked value = 122e8

Total Value Locked (TVL) = 2441.6e8

Note:

  • Position0 adds the most value

  • Position4 adds more value to the pool than Position2, Position1 and Positon3

  • Position2 adds more value to the pool than Position1 and Positon3

  • Positon1 adds more value than Position3

  • Position3 adds the least value


distributeAssets() logic breakdown


stakeTotal = getStakeTotal() == 115
|-- getStakeTotal() = 115
|-- stake() = For Loop:

  • position0 returns 100

  • position1 returns 0

  • position2 returns 0

  • position3 returns 5

  • position4 returns 10

Note:

  • stake() compares _position.TST and _position.EUROs amount, not value, which is not a recommended way to compare different tokens.

  • Position3 return value is greater than Position1 and Position2, despite adding the least value to the protocol.

burnEuros == 0; (tracks EUROs used up by the pool, to be burnt for supply balance)
nativePurchased == 0; (tracks native token bought by the pool, used to calculate tokens returned to the liquidationPoolManager)

For Loop: loop through all holders and distribute rewards to them
{
Loop 1 // for position0
_position = Position0
_positionStake = stake(_position) == 100
(if 100 > 0) {
Loop through each accepted collateral asset (which in our simple example is just wBtc)
asset = wbtc
(if 1e8 > 0) {
_portion = 1e8 * 100 / 115 == 86956521
costInEuros = 86956521 * 1e10 * 40000e8 / 1.12e8 * 1e5 / 110000 == 2.8232637e22
(if 28232.637e18 > 100 ) {
_portion = 86956521 * 100 / 2.8232637e+23 == 3.08e-14 == 0
costInEuros = 100
}

Position0.EUROs -= 100 // wipes out stakers position
burnEuros += 100
(if reward native is token) {
// We are not dealing with a native token

            }

(if reward native is erc20) {
// transfers 0 tokens to the pool.
}
}
}
// Update Position0
}
// After looping through all holders it:
// burns the burnEuros
// Return native tokens that weren't bought

Note:
I changed 10e10 to 1e10 in costInEuros calculation to fix a bug that causes the value to incorrectly increase by an order of magnitude.
Position0 EUROs position is drained but receives zero reward
Position0 EUROs are lost permanently as it is added to burnEuros
Position0 updated position:

  • Position0.TST value = 1000 * 1e8 (amount x price)

  • Position0.EUROs value = 0 * 20e8

  • Total Staked value = 1000e8
    Net Loss is 2000

Key:
{logic}: Logic block
bold font : code variables
(conditional statements)


Proof of Code:

Code

The provided test suite demonstrates the vulnerability's validity and severity.

How to Run the Test:

  • Due to the file size required to run this PoC, the suite is hosted on Github.

  • To run the PoC, clone the repository.

  • Minor changes, such as modifying function visibility, were made to enable successful test runs.

  • All changes and additional files made to the original code are documented in the README and the respective files where the changes are made.

Requirements:

  • Install Foundry.

  • Clone the project codebase into your local workspace.

    git clone https://github.com/Renzo1/the-standard-protocol-2.git
  • Run the following commands to install dependencies:

    npm install
    forge install
  • Run the following command to execute the PoC:

    forge test --match-test "testLiquidatedAssetNoCap" -vvv
function testLiquidatedAssetNoCap() public {
ISmartVault[] memory vaults = new ISmartVault[](1);
vaults = createVaultOwners(1);
//////// Owner 1 variables ////////
ISmartVault vault1 = vaults[0];
address owner1 = vault1.owner();
uint256 tstBalance1 = TST.balanceOf(owner1);
uint256 euroBalance1 = EUROs.balanceOf(owner1);
// Assert owner has EUROs and TST
// i.e., mint vault.mint() in TSBuilder::createVaultOwners is activated
assertGt(tstBalance1, 45 * 1e18);
assertGt(euroBalance1, 45_000 * 1e18);
//////// Create two random accounts Transfer tokens to them ////////
address account1 = vm.addr(111222);
address account2 = vm.addr(888999);
vm.startPrank(owner1);
TST.transfer(account1, 20 * 1e18);
TST.transfer(account2, 20 * 1e18);
EUROs.transfer(account1, 20_000 * 1e18);
EUROs.transfer(account2, 20_000 * 1e18);
vm.stopPrank();
uint256 account1TstBalance = TST.balanceOf(account1);
uint256 account2TstBalance = TST.balanceOf(account2);
uint256 account1EurosBalance = EUROs.balanceOf(account1);
uint256 account2EurosBalance = EUROs.balanceOf(account2);
assertEq(account1TstBalance, 20 * 1e18, "TEST 1");
assertEq(account2TstBalance, 20 * 1e18, "TEST 2");
assertEq(account1EurosBalance, 20_000 * 1e18, "TEST 3");
assertEq(account2EurosBalance, 20_000 * 1e18, "TEST 4");
//////// Stake Tokens ////////
vm.warp(block.timestamp + 2 days);
vm.startPrank(account1);
TST.approve(pool, account1TstBalance);
EUROs.approve(pool, account1EurosBalance);
liquidationPool.increasePosition(account1TstBalance, account1EurosBalance);
vm.stopPrank();
vm.startPrank(account2);
TST.approve(pool, account2TstBalance);
EUROs.approve(pool, account2EurosBalance);
liquidationPool.increasePosition(account2TstBalance, account2EurosBalance);
vm.stopPrank();
vm.warp(block.timestamp + 2 days);
// Assert LiquidationPool received the deposits
assertEq(EUROs.balanceOf(pool), account1EurosBalance * 2, "TEST 5");
assertEq(TST.balanceOf(pool), account1TstBalance * 2, "TEST 6");
// starting prices: EUR/USD $11037; ETH/USD $2200; BTC/USD $42000; PAXGUSD $2000
setPriceAndTime(11037, 1100, 20000, 1000); // Drop collateral value
//////// Liquidate vault ////////
// struct Reward { bytes32 symbol; uint256 amount; uint8 dec; }
// Position { address holder; uint256 TST; uint256 EUROs; }
// Account1 pre-liquidation Position
ILiquidationPool.Position memory account1Position0;
ILiquidationPool.Reward[] memory account1Reward0 = new ILiquidationPool.Reward[](3);
(account1Position0, account1Reward0) = liquidationPool.position(account1);
uint256 EurosPosition = account1Position0.EUROs;
assertEq(account1EurosBalance, EurosPosition, "TEST 7");
// Bug fix: Without granting pool BURNER_ROLE, distributeAssets() reverts
vm.startPrank(SmartVaultManager);
IEUROs(euros_).grantRole(IEUROs(euros_).BURNER_ROLE(), pool);
vm.stopPrank();
vm.startPrank(liquidator);
liquidationPoolManagerContract.runLiquidation(1);
vm.stopPrank();
// account rewards
ILiquidationPool.Position memory account1Position1;
ILiquidationPool.Reward[] memory account1Reward1 = new ILiquidationPool.Reward[](3);
(account1Position1, account1Reward1) = liquidationPool.position(owner1);
// Assert account1 EUROs Position is wiped
uint256 EurosPosition1 = account1Position1.EUROs;
assertEq(EurosPosition1, 0, "TEST 8");
// Assert account1 receive no Reward
assertEq(account1Reward0[0].amount, account1Reward1[0].amount, "TEST 9");
assertEq(account1Reward0[1].amount, account1Reward1[1].amount, "TEST 10");
assertEq(account1Reward0[2].amount, account1Reward1[2].amount, "TEST 11");
}


Tools Used:

  • Manual review

  • Foundry


Recommended Mitigation Steps:

  • Stake() should return the USD value of stakers' positions.

  • Add a condition to check if the Euro value of the total asset to be sold is greater than the Euros available to purchase them (Euro position of stakers eligible for reward). If true, cap the value of the amount of assets to sell to the total Euro available for trade.

function stake(Position memory _position) private pure returns (uint256) {
- return _position.TST > _position.EUROs ? _position.EUROs : _position.TST;
+ return _position.EUROs;
}
+ if (totalAssetValue() > stakeTotal value) totalAssetValue = capTotalAssetToStakeTotal() // This is pseudocode
for (uint256 i = 0; i < _assets.length; i++) {
ILiquidationPoolManager.Asset memory asset = _assets[i];
if (asset.amount > 0) {
(,int256 assetPriceUsd,,,) = Chainlink.AggregatorV3Interface(asset.token.clAddr).latestRoundData();
uint256 _portion = asset.amount * _positionStake / stakeTotal;
uint256 costInEuros = _portion * 10 ** (18 - asset.token.dec) * uint256(assetPriceUsd) / uint256(priceEurUsd)
* _hundredPC / _collateralRate;
if (costInEuros > _position.EUROs) {
_portion = _portion * _position.EUROs / costInEuros;
costInEuros = _position.EUROs;
}
_position.EUROs -= costInEuros;
rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;
burnEuros += costInEuros;
if (asset.token.addr == address(0)) {
nativePurchased += _portion;
} else {
// IERC20(asset.token.addr).safeTransferFrom(manager, address(this), _portion);
// Only comment the above line and uncomment this one when running testAssetsDistribution
IERC20(asset.token.addr).safeTransferFrom(msg.sender, address(this), _portion);
}
}
}
Updates

Lead Judging Commences

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

informational/invalid

krisrenzo Submitter
over 1 year ago
krisrenzo Submitter
over 1 year ago
hrishibhat Lead Judge
over 1 year ago
hrishibhat Lead Judge
over 1 year ago
hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

informational/invalid

Support

FAQs

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