code4rena-2023-09-centrifuge-m05
[M-05] Investors claiming their maxDeposit by using the LiquidityPool.deposit() will cause other users to be unable to claim their maxDeposit/maxMint
Summary
deposit을 이용하여 트랜치 토큰을 요청했을 때, 주어야 하는 트랜치 토큰 개수를 계산할 때 평균 가격 정보를 계산한다. 이 때 평균 가격의 소수점 18자리 아래를 버림한다. 이로 인해 실제보다 약간 많은 트랜치 토큰을 보낸다. 트랜치 토큰은 평균가가 아닌 과거 가격 정보에 의해 발행되며, deposit에서 즉시 발행하는 게 아니라 이미 발행되어 에스크로에 예치된 토큰을 가져가는 것이기 때문에 토큰이 모자라는 상황이 발생한다. 다른 유저는 에스크로에 토큰이 모자라 자신의 몫을 전부 꺼내갈 수 없게 된다.
Keyword
arithmetic error, rounding error, erc4626
Vulnerability
LP에 asset 토큰을 예치하고 트랜치 토큰을 받기 위해서는 다음 절차를 걸쳐야 한다.
투자자는 예치금 입금하고, 다음 epoch에 Centrifuge 체인이 이를 검증하고 허용할 때까지 기다려야 한다. 새로운 입금을 요청하기 위해 기존의 입금을 모두 트랜치 토큰으로 수령할 필요는 없다. maxDeposit과 maxMint 에 투자자의 이전 입금액이 포함된다. 새로이 입금하면 maxDeposit과 maxMint가 증가하여 요구할 수 있는 트랜치 토큰의 양이 늘어난다.
트랜치 토큰은 입금을 요청하고 이를 Centrifuge 체인에서 확인한 후 에스크로에 발행된다. investmentManager.requestDeposit()에서 gateway에 메시지를 보내고, 다시 Centrifuge 체인에서 이에 대한 반응을 브릿지로 메시지를 보내 investmentManager.handleExecutedCollectInvest()를 호출한다. 이 떄 트랜치 토큰이 민팅된다. 발행되는 트랜치 토큰 개수는 Centrifuge 체인에서 계산해 전달한다.
investmentManager.handleExecutedCollectInvest()에서 maxDeposit와 maxMint에는 입금액과 얻을 수 있는 트랜치 토큰 개수를 쌓는다. 이들은 유저가 변환할 수 있는 asset 토큰 수와 이로 얻을 수 있는 트랜치 토큰 수를 의미한다.
function requestDeposit(uint256 currencyAmount, address user) public auth {
address liquidityPool = msg.sender;
LiquidityPoolLike lPool = LiquidityPoolLike(liquidityPool);
address currency = lPool.asset();
uint128 _currencyAmount = _toUint128(currencyAmount);
// Check if liquidity pool currency is supported by the Centrifuge pool
poolManager.isAllowedAsPoolCurrency(lPool.poolId(), currency);
// Check if user is allowed to hold the restricted tranche tokens
_isAllowedToInvest(lPool.poolId(), lPool.trancheId(), currency, user);
if (_currencyAmount == 0) {
// Case: outstanding investment orders only needed to be cancelled
gateway.cancelInvestOrder(
lPool.poolId(), lPool.trancheId(), user, poolManager.currencyAddressToId(lPool.asset())
);
return;
}
// Transfer the currency amount from user to escrow. (lock currency in escrow).
SafeTransferLib.safeTransferFrom(currency, user, address(escrow), _currencyAmount);
@> gateway.increaseInvestOrder(
lPool.poolId(), lPool.trancheId(), user, poolManager.currencyAddressToId(lPool.asset()), _currencyAmount
);
}
function handleExecutedCollectInvest(
uint64 poolId,
bytes16 trancheId,
address recipient,
uint128 currency,
uint128 currencyPayout,
@> uint128 trancheTokensPayout
) public onlyGateway {
require(currencyPayout != 0, "InvestmentManager/zero-invest");
address _currency = poolManager.currencyIdToAddress(currency);
address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, _currency);
require(liquidityPool != address(0), "InvestmentManager/tranche-does-not-exist");
LPValues storage lpValues = orderbook[recipient][liquidityPool];
lpValues.maxDeposit = lpValues.maxDeposit + currencyPayout;
@> lpValues.maxMint = lpValues.maxMint + trancheTokensPayout;
@> LiquidityPoolLike(liquidityPool).mint(address(escrow), trancheTokensPayout); // mint to escrow. Recepeint can claim by calling withdraw / redeem
_updateLiquidityPoolPrice(liquidityPool, currencyPayout, trancheTokensPayout);
}유저는 epoch가 지난 후 deposit 또는 mint 함수를 호출하여 에스크로에 발행된 토큰을 전송받을 수 있다. deposit 함수는 청구할 asset 수를 파라미터로 넘긴다. 내부에서 호출되는 InvestmentManager.processDeposit()에서 현재 트랜치 토큰 가격 정보에 기반하여 asset 수에 상응하는 트랜치 토큰 양을 계산하고 분배한다.
n개의 asset 토큰에 상응하는 트랜치 토큰의 개수를 계산하기 위해서는 적절한 트랜치 토큰의 가격을 알아야 한다. 여러 epoch에 걸쳐 입금을 했다면 각 epoch별로 트랜치 가격이 다를 수 있다. 따라서 InvestmentManager.calculateDepositPrice() 에서 maxMint와 maxDeposit을 이용하여 입금시 트랜치 토큰의 평균가 얻는다. 여러 epoch에 걸쳐 다회 입금시 트랜치 토큰 가격이 서로 다르므로 maxMint와 maxDeposit을 이용하여 이의 평균가를 구한다.
function 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);
@> _deposit(_trancheTokenAmount, _currencyAmount, liquidityPool, user);
trancheTokenAmount = uint256(_trancheTokenAmount);
}
function calculateDepositPrice(address user, address liquidityPool) public view returns (uint256 depositPrice) {
LPValues storage lpValues = orderbook[user][liquidityPool];
if (lpValues.maxMint == 0) {
return 0;
}
@> depositPrice = _calculatePrice(lpValues.maxDeposit, lpValues.maxMint, liquidityPool);
}
function _calculatePrice(uint128 currencyAmount, uint128 trancheTokenAmount, address liquidityPool)
public
view
returns (uint256 depositPrice)
{
(uint8 currencyDecimals, uint8 trancheTokenDecimals) = _getPoolDecimals(liquidityPool);
uint256 currencyAmountInPriceDecimals = _toPriceDecimals(currencyAmount, currencyDecimals, liquidityPool);
uint256 trancheTokenAmountInPriceDecimals =
_toPriceDecimals(trancheTokenAmount, trancheTokenDecimals, liquidityPool);
@> depositPrice = currencyAmountInPriceDecimals.mulDiv(
10 ** PRICE_DECIMALS, trancheTokenAmountInPriceDecimals, MathLib.Rounding.Down
);
}
function _calculateTrancheTokenAmount(uint128 currencyAmount, address liquidityPool, uint256 price)
internal
view
returns (uint128 trancheTokenAmount)
{
(uint8 currencyDecimals, uint8 trancheTokenDecimals) = _getPoolDecimals(liquidityPool);
@> uint256 currencyAmountInPriceDecimals = _toPriceDecimals(currencyAmount, currencyDecimals, liquidityPool).mulDiv(
10 ** PRICE_DECIMALS, price, MathLib.Rounding.Down
);
trancheTokenAmount = _fromPriceDecimals(currencyAmountInPriceDecimals, trancheTokenDecimals, liquidityPool);
}문제는 투자자가 트랜치 토큰을 수령하지 않고 서로 다른 epoch에 서로 다른 가격으로 여러번 입금했을 때 발생한다. InvestmentManager.calculateDepositPrice() 에서 가격을 계산할 때, 반올림 옵션으로 MathLib.Rounding.Down을 설정했다. 이는 나눗셈 계산 후 소수점은 버리라는 의미이다.
function mulDiv(uint256 x, uint256 y, uint256 denominator, Rounding rounding) internal pure returns (uint256) {
uint256 result = mulDiv(x, y, denominator);
if (rounding == Rounding.Up && mulmod(x, y, denominator) > 0) {
result += 1;
}
return result;
}이로 인해 계산된 평균가는 실제 평균가와 가격 차이가 생긴다. 가격 계산에서 내림이 되면 트랜치 토큰의 가격이 실제보다 작게 측정되고, asset 토큰에 상응하는 트랜치 토큰의 개수는 실제보다 좀 더 많게 계산된다. 이 약간의 차이로 인해 실제로 에스크로 컨트랙트에 발행된 트랜치 토큰의 수보다 약간 더 많은 트랜치 토큰을 유저에게 전송하는 상황이 생길 수 있다.
이는 다른 투자자가 자신의 maxMint 또는 maxDeposit만큼을 트랜치 토큰으로 수령하려 할 때, 에스크로에 트랜치 토큰이 모자라 트랜잭션이 취소되는 상황을 야기할 수 있다.
다음은 PoC 코드이다. (출처: https://github.com/code-423n4/2023-09-centrifuge-findings/issues/118)
- 첫번째 1 epoch에는 트랜치 토큰의 가격이 1.25 DAI였다. 유저 Bob이 100개의 DAI를 예치하고 80(100/1.25)개의 트랜치 토큰을 maxMint에 더한다.
- 2 epoch가 되고, 트랜치 토큰의 가격이 2 DAI로 올랐다. 유저 Bob이 2번째 에치를 한다. 100개의 DAI를 예치하고 50(100/2)개의 트랜치 토큰을 maxMint에 더한다.
- 현재 maxDeposit = 200, maxMint = 130이다.
- 이후 두번째 유저인 Alice가 2 epoch에 100 DAI를 입금하고 50 트랜치 토큰을 maxMint에 더했다.
- Bob이
deposit을 호출한다. currencyAmount 파라미터(asset 토큰 양)는 200이라고 하자.calculateDepositPrice에서 트랜치 토큰의 가격은 1.5384615384615384615384615384615.. 로 무한소수이다. (maxDeposit / maxMint = 200 / 130)- 이 무한소수를 소수점 18자리까지만 사용한다. 그 아래는 버림된다. 즉, 가격은 1.538461538461538461이다
- 가격 1.538461538461538461를 기반으로 받을 수 있는 트랜치 토큰 수를 계산하면 200 * 10^18 * 10^18 / 1.538461538461538461 = 130.000000000000000045 이다.(소수점 18자리까지) 이는 실제 maxMint(130)보다 크다. 즉, 입금 시 에스크로에 발행된 트랜치 토큰의 양보다 많다.
- maxDeposit은
deposit을 호출하여 청구할 때 확인하고, maxMint는mint를 호출할 때 확인하므로deposit호출시 maxMint보다 크게 잡히는 것으로 revert 되지 않는다.
- maxDeposit은
- Alice가
deposit또는mint를 호출해 받을 수 있는 최대 트랜치 토큰(50개)을 요구했을 때, 에스크로에는 50개보다 적은 양의 트랜치 토큰이 있어 트랜잭션이 실패한다. 에스크로에는 50 - 0.000000000000000045 개 만큼의 트랜치 토큰이 남아있기 때문이다.
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 / 2, 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 / 2, 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()
uint256 aliceMaxDeposit = lPool.maxDeposit(alice);
@> vm.expectRevert(bytes("ERC20/insufficient-balance"));
@> lPool.deposit(aliceMaxDeposit, alice);
//@audit-info => Run the PoC a second time, but now using LiquidityPool.mint()
// lPool.mint(lPool.maxMint(alice), alice);
vm.stopPrank();
}Impact
실제 민팅된 트랜치 토큰보다 약간 많은 양의 토큰을 분배하여 다른 유저가 자신의 몫을 전부를 꺼낼 수 없게 된다.
Mitigation
deposit으로 요청 시 가져가려는 트랜치 토큰의 양이 maxMint를 초과하는 경우 maxMint만큼으로 제한한다.
function 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);
}Memo
rounding은 ERC4626에서 유명한 이슈인 것 같다. eip-4626 문서에서도 반올림에 대한 조언을 한다.
tags: bughunting, centrifuge, smart contract, solidity, arithmetic error, rounding error, erc4626, severity medium