The Standard

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

Uneven Asset Reward Distribution Leading to Disproportionate Risk Exposure

Summary

The distributeAssets function in the LiquidationPool smart contract is designed to distribute rewards among participants.

However, its current implementation results in an uneven distribution based on the order of assets in the tokenManager. This can lead to scenarios where either the users or the protocol disproportionately inherit bad debt exposure, particularly in volatile market conditions.

For example, if ETH (the first asset in a pool) appreciates in value while an exotic asset like LUNA (added later in the tokenManager) depreciates sharply, users may end up with a significant amount of appreciated ETH while the protocol is left holding devalued LUNA. (Example in the POC)

Conversely, if a pool contains IN ORDER a small amount of ETH, a larger quantities of LUNA and another stable asset like WBTC in large quantity, users might receive a majority of LUNA (now a bad asset) and minimal WBTC, leaving the protocol with the more stable WBTC, which will be consider unfair for the users.

Vulnerability Details

To be able to run the test in this finding follow the following recommendation

INITIAL FOUNDRY SETUP FOR POC

Follow the following tutorial to install foundry in your local repo

https://hardhat.org/hardhat-runner/docs/advanced/hardhat-and-foundry

When hardhat-foundry is installed, rename the current test folder to hardhat_test so forge won't look at it.

Create/Update the foundry.toml fil with the current setup, to allow compilation with viaIR

[profile.default]
src = 'contracts'
out = 'out'
libs = ['node_modules', 'lib']
test = 'test'
cache_path = 'cache_forge'
viaIR= true

Create a new test folder, with the following file test contract in it, this contract deploy the whole contract stack.

The smartVaultManager is deployed with the proxy.
NFTMetadataGenerator is replaced by a Dummy address to lower the compilation time.

2 helpers are used in all the pocs:

_deployVault: Used to deploy the user Vault and retrieve the vault address
_send: Is used to send ETH

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
import "./utils/ERC20Mock.sol";
import "./utils/EUROsMock.sol";
import "./utils/ChainlinkMock.sol";
import "./utils/TokenManagerMock.sol";
import "./utils/SmartVaultDeployerV3.sol";
import "./utils/SwapRouterMock.sol";
import "./utils/SmartVaultIndex.sol";
import "../contracts/LiquidationPoolManager.sol";
import "./utils/SmartVaultManager.sol";
import "../contracts/SmartVaultManagerV5.sol";
import "openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol";
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {console2} from "forge-std/console2.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
contract ImplDeployerProxy is ERC1967Proxy {
constructor(address impl, bytes memory data) ERC1967Proxy(impl, data) {}
function upgradeTo(address newImplementation) public {
_upgradeTo(newImplementation);
}
}
contract ContractBTest is Test {
uint256 baseTime = 1710000000;
uint256 DEFAULT_COLLATERAL_RATE = 120000; // 120%
uint256 PROTOCOL_FEE_RATE = 500; // 0.5%
uint32 POOL_FEE_PERCENTAGE = 50000; // 50%
bytes32 immutable WETH = "ETH";
address WETH_ADDRESS = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
address WBTC_ADDRESS = 0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f;
EUROsMock EUROs;
ERC20Mock TST;
ERC20Mock WBTC;
ERC20Mock LUNA;
ChainlinkMock ClEthUsd;
ChainlinkMock ClBtcUsd;
ChainlinkMock ClEurUsd;
ChainlinkMock ClLunaUsd;
SmartVaultManager smartVaultManagerV1;
ImplDeployerProxy proxy;
SmartVaultManagerV5 smartVaultManagerV5;
SmartVaultIndex smartVaultIndex;
SmartVaultV3 smartVault;
LiquidationPoolManager liquidationPoolManager;
LiquidationPool liquidationPool;
TokenManagerMock tokenManagerMock;
address user = makeAddr("user");
address deployer = makeAddr("deployer");
address protocol = makeAddr("protocol");
address userLiquidator = makeAddr("userLiquidator");
address maliciousUser = makeAddr("maliciousUser");
address NFTMetadataGenerator = makeAddr("Dum");
address liquidator = makeAddr("liquidator");
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
function setUp() public {
vm.startPrank(deployer);
address implementationV1 = address(smartVaultManagerV1);
WBTC = new ERC20Mock('Wrapped BTC', 'WBTC', 8);
LUNA = new ERC20Mock('LUNA Ponzi', 'LUNA', 18);
// Set chainlink price Mock to 2200$
ClEthUsd = new ChainlinkMock('ETH / USD');
ClBtcUsd = new ChainlinkMock('BTC / USD');
ClEurUsd = new ChainlinkMock('EUR / USD');
ClLunaUsd = new ChainlinkMock('LUNA / USD');
vm.warp(baseTime);
ClEthUsd.setPrice(220000000000); // 2200$
vm.warp(baseTime);
ClBtcUsd.setPrice(4400000000000); // 44000$
vm.warp(baseTime);
ClEurUsd.setPrice(110000000); // 1.10$
vm.warp(baseTime);
ClLunaUsd.setPrice(2000000000); // 20$
EUROs = new EUROsMock();
tokenManagerMock = new TokenManagerMock(WETH, address(ClEthUsd));
tokenManagerMock.addAcceptedToken(address(WBTC), address(ClBtcUsd));
tokenManagerMock.addAcceptedToken(address(LUNA), address(ClLunaUsd));
address smartVaultDeployerV3 = address(new SmartVaultDeployerV3(WETH, address(ClEurUsd)));
smartVaultIndex = new SmartVaultIndex();
address swapRouterMock = address(new SwapRouterMock());
// Make sure everything is deployed correctly
bytes memory data = abi.encodeWithSignature(
"initialize(uint256,uint256,address,address,address,address,address,address,address)",
DEFAULT_COLLATERAL_RATE,
PROTOCOL_FEE_RATE,
address(EUROs),
protocol,
liquidator,
address(tokenManagerMock),
smartVaultDeployerV3,
address(smartVaultIndex),
NFTMetadataGenerator
);
// Set contract and proxy
proxy = new ImplDeployerProxy(address(new SmartVaultManager()), data);
proxy.upgradeTo(address(new SmartVaultManagerV5()));
smartVaultManagerV5 = SmartVaultManagerV5(address(proxy));
// Settup vault manager
smartVaultIndex.setVaultManager(address(smartVaultManagerV5));
bytes32 adminRole = EUROs.DEFAULT_ADMIN_ROLE();
EUROs.grantRole(adminRole, address(smartVaultManagerV5));
// For testing purpose we allow this contract to mint EUROs
EUROs.grantRole(adminRole, address(this));
EUROs.grantRole(EUROs.MINTER_ROLE(), address(this));
// Liquidation setup
TST = new ERC20Mock('The Standard Token', 'TST', 18);
liquidationPoolManager =
new LiquidationPoolManager(address(TST), address(EUROs), address(smartVaultManagerV5), address(ClEurUsd), payable(protocol), POOL_FEE_PERCENTAGE);
liquidationPool = LiquidationPool(liquidationPoolManager.pool());
smartVaultManagerV5.setLiquidatorAddress(address(liquidationPoolManager));
smartVaultManagerV5.setProtocolAddress(address(liquidationPoolManager));
EUROs.grantRole(EUROs.BURNER_ROLE(), address(liquidationPool));
vm.stopPrank();
}
function _send(address target, uint256 amount) private {
(bool ok,) = address(target).call{value: amount}("");
require(ok, "Send ETH fail");
}
function _deployVault() public returns (address payable vaultAddress) {
vm.recordLogs();
//Mint vault through smartVaultManager and extract address
smartVaultManagerV5.mint();
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 vaultAddressBytes = logs[4].topics[1];
vaultAddress = payable(address(uint160(uint256(vaultAddressBytes))));
smartVault = SmartVaultV3(vaultAddress);
}
function testSetup() public {
startHoax(user, 100 ether);
address vaultAddress = _deployVault();
_send(address(smartVault), 1 ether); // User send 1 ETH to the contract
smartVault.mint(user, 100 ether);
vm.stopPrank();
assert(EUROs.balanceOf(user) == 100 ether);
}

The provided Proof of Concept demonstrates a scenario where uneven asset distribution leads to the protocol acquiring 'bad' assets (devalued LUNA) and the user acquiring 'good' assets (appreciated ETH).
This situation arises when the price of LUNA crashes while the price of ETH rises significantly.

In this PoC, a user mints EUROs against a collateral mix of ETH and LUNA.
When the price of LUNA crashes, the liquidation process is triggered, but due to the order of asset distribution, Stackers ends up with only discounted ETH, while the protocol absorbs the loss by acquiring the devalued LUNA.

function testNonEvenAssetDistribution() public {
// Start prank and credit 20 ETH and 2000 LUNA to the user
startHoax(user, 20 ether);
LUNA.mint(user, 2200 ether); // Mint 1 LUNA for the user
address vaultAddress = _deployVault();
console2.log(vaultAddress);
//Send 1 LUNA as collateral to allow the user to mint EUROs
LUNA.transfer(address(smartVault), 2200 ether);
//Send 20 ETH as collateral to allow the user to mint EUROs
_send(address(smartVault), 20 ether);
assert(user.balance == 0 ether);
assert(LUNA.balanceOf(user) == 0 ether);
assert(address(smartVault).balance == 20 ether);
assert(LUNA.balanceOf(address(smartVault)) == 2200 ether);
uint256 amounToMint = 66300 ether; // User Mint 66300 EURO
smartVault.mint(user, amounToMint);
vm.stopPrank();
address userStake = makeAddr("userStake");
EUROs.mint(userStake, 2000 ether);
// userStake stake 1000 EUROs and 1000 TST
vm.startPrank(userStake);
TST.mint(userStake, 2000 ether);
TST.approve(address(liquidationPool), 2000 ether);
EUROs.approve(address(liquidationPool), 2000 ether);
vm.warp(baseTime);
liquidationPool.increasePosition(2000 ether, 2000 ether);
vm.stopPrank();
address userStake2 = makeAddr("userStake2");
EUROs.mint(userStake2, 2000 ether);
// userStake2 stake 1000 EUROs and 1000 TST
vm.startPrank(userStake2);
TST.mint(userStake2, 2000 ether);
TST.approve(address(liquidationPool), 2000 ether);
EUROs.approve(address(liquidationPool), 2000 ether);
vm.warp(baseTime);
liquidationPool.increasePosition(2000 ether, 2000 ether);
vm.stopPrank();
// Crash the price of LUNA divide by 200
baseTime += 1 days + 1 minutes;
vm.warp(baseTime);
ClLunaUsd.setPrice(10000000); // LUNA price 0.1$
vm.warp(baseTime);
ClEthUsd.setPrice(230000000000); // ETH price is up to 2300$
vm.stopPrank();
vm.warp(baseTime);
liquidationPoolManager.runLiquidation(1);
liquidationPool.position(userStake);
liquidationPool.position(userStake2);
console2.log("protocol.balance: %s", protocol.balance);
assert(LUNA.balanceOf(protocol) == 2200 ether); // 2200 LUNA
assert(protocol.balance == 17.704347826086956522 ether);
vm.prank(userStake);
liquidationPool.claimRewards();
console2.log("userStake.balance: %s", userStake.balance);
assert(userStake.balance == 1.147826086956521739 ether);
assert(LUNA.balanceOf(userStake) == 0); // 0 LUNA
vm.prank(userStake2);
liquidationPool.claimRewards();
console2.log("userStake2.balance: %s", userStake2.balance);
assert(userStake2.balance == 1.147826086956521739 ether);
assert(LUNA.balanceOf(userStake) == 0); // 0 LUNA
}

Impact

This uneven distribution mechanism can significantly impact both the protocol's financial health and the users' asset values.

It can lead to scenarios where the protocol or users are left with assets that have severely depreciated if the liquidation is trigger during the crash and users don't have time to liquidate the asset on time, while the others party endup in a safer position with an increasing asset.

Potentially leading to considerable financial losses and undermining trust in the protocol's fairness and risk management capabilities.

Tools Used

Manual Review

Recommended Mitigation

A critical refactor of the distributeAssets function is recommended.
The new implementation should ensure an equitable distribution of assets between the protocol and the user so risk could be shared between the protocol and the user.

Updates

Lead Judging Commences

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

distributeAssets-issue

0xrektified Submitter
over 1 year ago
hrishibhat Lead Judge
over 1 year ago
hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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