Platform: Code4rena
Start Date: 08/09/2023
Pot Size: $70,000 USDC
Total HM: 8
Participants: 84
Period: 6 days
Judge: gzeon
Total Solo HM: 2
Id: 285
League: ETH
Rank: 7/84
Findings: 1
Award: $1,486.01
🌟 Selected for report: 1
🚀 Solo Findings: 0
1486.0116 USDC - $1,486.01
https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L141-L144 https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L427-L441
LiquidityPool.deposit()
will cause the Escrow contract doesn't have enough shares to allow other investors to claim their maxDeposit or maxMint values for their deposited assetsBefore an investor can claim their deposits, they first needs to request the deposit and wait for the Centrigue Chain to validate it in the next epoch.
Investors can request deposits at different epochs without the need to claim all the approved deposits before requesting a new deposit, in the end, the maxDeposit and maxMint values that the investor can claim will be increased accordingly based on all the request deposits that the investor makes.
When the requestDeposit of the investor is processed in the Centrifuge chain, a number of TrancheShares will be minted based on the price at the moment when the request was processed and the total amount of deposited assets, this TrancheShares will be deposited to the Escrow contract, and the TrancheShares will be waiting for the investors to claim their deposits.
When investors decide to claim their deposit they can use the LiquidityPool.deposit()
function, this function receives as arguments the number of assets that are being claimed and the address of the account to claim the deposits for.
function deposit(uint256 assets, address receiver) public returns (uint256 shares) { shares = investmentManager.processDeposit(receiver, assets); emit Deposit(address(this), receiver, assets, shares); }
LiquidityPool.deposit()
function calls the InvestmentManager::processDeposit()
which will validate that the amount of assets being claimed doesn't exceed the investor's deposit limits, will compute the deposit price in the InvestmentManager::calculateDepositPrice()
, which basically computes an average price for all the request deposits that have been accepted in the Centrifuge Chain, each of those request deposits could've been executed at a different price, so, this function, based on the values of maxDeposit and maxMint will estimate an average price for all the unclaimed deposits, later, using this computed price for the deposits will compute the equivalent of TrancheTokens for the CurrencyAmount being claimed, and finally, processDeposit() will transferFrom the escrow to the investor account the computed amount of TranchTokens.function processDeposit(address user, uint256 currencyAmount) public auth returns (uint256 trancheTokenAmount) { address liquidityPool = msg.sender; uint128 _currencyAmount = _toUint128(currencyAmount); require( //@audit-info => orderbook[][].maxDeposit is updated when the handleExecutedCollectInvest() was executed! //@audit-info => The orderbook keeps track of the number of TrancheToken shares that have been minted to the Escrow contract on the user's behalf! (_currencyAmount <= orderbook[user][liquidityPool].maxDeposit && _currencyAmount != 0), "InvestmentManager/amount-exceeds-deposit-limits" ); //@audit-info => computes an average price for all the request deposits that have been accepted in the Centrifuge Chain and haven't been claimed yet! uint256 depositPrice = calculateDepositPrice(user, liquidityPool); require(depositPrice != 0, "LiquidityPool/deposit-token-price-0"); //@audit-info => Based on the computed depositPrice will compute the equivalent of TrancheTokens for the CurrencyAmount being claimed uint128 _trancheTokenAmount = _calculateTrancheTokenAmount(_currencyAmount, liquidityPool, depositPrice); //@audit-info => transferFrom the escrow to the investor account the computed amount of TranchTokens. _deposit(_trancheTokenAmount, _currencyAmount, liquidityPool, user); trancheTokenAmount = uint256(_trancheTokenAmount); }
The problem occurs when an investor hasn't claimed their deposits and has requested multiple deposits on different epochs at different prices. The InvestmentManager::calculateDepositPrice()
function will compute an equivalent/average price for all the requestDeposits that haven't been claimed yet. Because of the different prices that the request deposits where processed at, the computed price will compute the most accurate average of the deposit's price, but there is a slight rounding error that causes the computed value of trancheTokenAmount to be slightly different from what it should exactly be.
LiquidityPool.deposit()
I used the LiquidityPool.t.sol
test file as the base file for this PoC, please add the below testPoC to the LiquidityPool.t.sol file
In this PoC I demonstrate that Alice (A second investor) won't be able to claim her maxDeposit or maxMint amounts after the first investor uses the LiquidityPool.deposit()
function to claim his maxDeposit() assets. The first investor makes two requestDeposit, each of them at a different epoch and at a different price, Alice on the other hand only does 1 requestDeposit in the second epoch.
Run this PoC two times, check the comments on the last 4 lines, one time we want to test Alice claiming her deposits using LiquidityPool::deposit(), and the second time using LiquidityPool::mint()
function testDepositAtDifferentPricesPoC(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { vm.assume(currencyId > 0); uint8 TRANCHE_TOKEN_DECIMALS = 18; // Like DAI uint8 INVESTMENT_CURRENCY_DECIMALS = 6; // 6, like USDC ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); address lPool_ = deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); LiquidityPool lPool = LiquidityPool(lPool_); homePools.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1000000000000000000); //@audit-info => Add Alice as a Member address alice = address(0x23232323); homePools.updateMember(poolId, trancheId, alice, type(uint64).max); // invest uint256 investmentAmount = 100000000; // 100 * 10**6 homePools.updateMember(poolId, trancheId, self, type(uint64).max); currency.approve(address(investmentManager), investmentAmount); currency.mint(self, investmentAmount); lPool.requestDeposit(investmentAmount, self); // trigger executed collectInvest at a price of 1.25 uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId uint128 currencyPayout = 100000000; // 100 * 10**6 uint128 firstTrancheTokenPayout = 80000000000000000000; // 100 * 10**18 / 1.25, rounded down homePools.isExecutedCollectInvest( poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, firstTrancheTokenPayout ); // assert deposit & mint values adjusted assertEq(lPool.maxDeposit(self), currencyPayout); assertEq(lPool.maxMint(self), firstTrancheTokenPayout); // deposit price should be ~1.25*10**18 === 1250000000000000000 assertEq(investmentManager.calculateDepositPrice(self, address(lPool)), 1250000000000000000); // second investment in a different epoch => different price currency.approve(address(investmentManager), investmentAmount); currency.mint(self, investmentAmount); lPool.requestDeposit(investmentAmount, self); // trigger executed collectInvest at a price of 2 currencyPayout = 100000000; // 100 * 10**6 uint128 secondTrancheTokenPayout = 50000000000000000000; // 100 * 10**18 / 1.4, rounded down homePools.isExecutedCollectInvest( poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, secondTrancheTokenPayout ); // Alice invests the same amount as the other investor in the second epoch - Price is at 2 currency.mint(alice, investmentAmount); vm.startPrank(alice); currency.approve(address(investmentManager), investmentAmount); lPool.requestDeposit(investmentAmount, alice); vm.stopPrank(); homePools.isExecutedCollectInvest( poolId, trancheId, bytes32(bytes20(alice)), _currencyId, currencyPayout, secondTrancheTokenPayout ); uint128 AliceTrancheTokenPayout = 50000000000000000000; // 100 * 10**18 / 1.4, rounded down //@audit-info => At this point, the Escrow contract should have the firstTrancheTokenPayout + secondTrancheTokenPayout + AliceTrancheTokenPayout assertEq(lPool.balanceOf(address(escrow)),firstTrancheTokenPayout + secondTrancheTokenPayout + AliceTrancheTokenPayout); // Investor collects his the deposited assets using the LiquidityPool::deposit() lPool.deposit(lPool.maxDeposit(self), self); // Alice tries to collect her deposited assets and gets her transactions reverted because the Escrow doesn't have the required TokenShares for Alice! vm.startPrank(alice); //@audit-info => Run the PoC one time to test Alice trying to claim their deposit using LiquidityPool.deposit() lPool.deposit(lPool.maxDeposit(alice), alice); //@audit-info => Run the PoC a second time, but now using LiquidityPool.mint() // lPool.mint(lPool.maxMint(alice), alice); vm.stopPrank(); }
Manual Audit
_trancheTokenAmount
in the InvestmentManager::processDeposit()
, if the _trancheTokenAmount
exceeds the maxMint()
of the user, update it and set it to be the maxMint(), in this way, the rounding differences will be discarded before doing the actual transfer of shares from the Escrow to the user, and this will prevent the Escrow from not having all the required TranchToken for the other investorsfunction processDeposit(address user, uint256 currencyAmount) public auth returns (uint256 trancheTokenAmount) { address liquidityPool = msg.sender; uint128 _currencyAmount = _toUint128(currencyAmount); require( (_currencyAmount <= orderbook[user][liquidityPool].maxDeposit && _currencyAmount != 0), "InvestmentManager/amount-exceeds-deposit-limits" ); uint256 depositPrice = calculateDepositPrice(user, liquidityPool); require(depositPrice != 0, "LiquidityPool/deposit-token-price-0"); uint128 _trancheTokenAmount = _calculateTrancheTokenAmount(_currencyAmount, liquidityPool, depositPrice); //@audit => Add this check to prevent any rounding errors from causing problems when transfering shares from the Escrow to the Investor! + if (_trancheTokenAmount > orderbook[user][liquidityPool].maxMint) _trancheTokenAmount = orderbook[user][liquidityPool].maxMint; _deposit(_trancheTokenAmount, _currencyAmount, liquidityPool, user); trancheTokenAmount = uint256(_trancheTokenAmount); }
Math
#0 - raymondfam
2023-09-15T00:08:15Z
Accounting mess up indeed.
#1 - c4-pre-sort
2023-09-15T00:08:22Z
raymondfam marked the issue as low quality report
#2 - c4-pre-sort
2023-09-15T00:08:26Z
raymondfam marked the issue as primary issue
#3 - c4-pre-sort
2023-09-15T01:04:32Z
raymondfam marked the issue as remove high or low quality report
#4 - c4-pre-sort
2023-09-15T01:05:30Z
raymondfam marked the issue as high quality report
#5 - c4-sponsor
2023-09-20T22:34:51Z
hieronx (sponsor) confirmed
#6 - gzeon-c4
2023-09-25T12:20:40Z
#7 - gzeon-c4
2023-09-25T13:34:30Z
This issue described a protocol specific issue with multiple deposit/withdrawal where #34 is a generic 4626 rounding issue, and therefore not marked as duplicate of #34. In terms of severity, this does not directly lead to a loss of fund but will affect the availability of the protocol, hence Med.
#8 - c4-judge
2023-09-25T13:34:41Z
gzeon-c4 marked the issue as selected for report
#9 - c4-judge
2023-09-25T13:34:50Z
gzeon-c4 marked the issue as satisfactory
#10 - hieronx
2023-10-03T13:54:21Z