Source: #17
0x73696d616f
BancorExchangeProvider::_getScaledAmountOut() decreases the amount out when the token in is the supply token by the exit contribution, that is, scaledAmountOut = (scaledAmountOut * (MAX_WEIGHT - exchange.exitContribution)) / MAX_WEIGHT;
.
Whenever the last supply of the token is withdrawn, it will get all the reserve and store it in scaledAmountOut
, and then apply the exit contribution, leaving these funds forever stuck, as there is 0 supply to redeem it.
In BancorExchangeProvider:345
, the exchange contribution is applied regardless of there being supply left to redeem it.
- All supply must be withdrawn from the exchange.
None.
- Users call
Broker::swapIn()
, that callsGoodDollarExchangeProvider::swapIn()
, which is the exchange contract that holds the token and reserve balances, selling supply tokens until the supply becomes 0.
The last exit contribution will be forever stuck. This amount is unbounded and may be very significant.
Add the following test to BancorExchangeProvider.t.sol
:
function test_POC_swapIn_whenTokenInIsToken_shouldSwapIn() public {
BancorExchangeProvider bancorExchangeProvider = initializeBancorExchangeProvider();
uint256 amountIn = 300_000 * 1e18;
bytes32 exchangeId = bancorExchangeProvider.createExchange(poolExchange1);
vm.startPrank(brokerAddress);
uint256 amountOut = bancorExchangeProvider.swapIn(exchangeId, address(token), address(reserveToken), amountIn);
(, , uint256 tokenSupplyAfter, uint256 reserveBalanceAfter, , ) = bancorExchangeProvider.exchanges(exchangeId);
assertEq(amountOut, 59400e18);
assertEq(reserveBalanceAfter, 600e18);
assertEq(tokenSupplyAfter, 0);
vm.expectRevert("ERR_INVALID_SUPPLY");
bancorExchangeProvider.swapIn(exchangeId, address(token), address(reserveToken), 1e18);
}
The specific mitigation depends on the design.
Issue M-2: GoodDollarExchangeProvider::mintFromExpansion()
will change the price due to a rounding error in the new ratio
Source: #21
0x73696d616f
GoodDollarExchangeProvider::mintFromExpansion() mints supply tokens while keeping the current price constant. To achieve this, a certain formula is used, but in the process it scales the reserveRatioScalar * exchange.reserveRatio
to 1e8
precision (the precision of exchange.reserveRatio
) down from 1e18
.
However, the calculation of the new amount of tokens to mint is based on the full ratio with 1e18, which will mint more tokens than it should and change the price, breaking the readme.
Note1: there is also a slight price change in GoodDollarExchangeProvider::mintFromInterest() due to using mul
and then div
, as mul
divides by 1e18
unnecessarily in this case.
Note2: GoodDollarExchangeProvider::updateRatioForReward() also has precision loss as it calculates the ratio using the formula and then scales it down, changing the price.
In GoodDollarExchangeProvider:147
, newRatio
is calculated with full 1e18
precision and used to calculate the amount of tokens to mint, but exchanges[exchangeId].reserveRatio
is stored with the downscaled value, newRatio / 1e10
, causing an error and price change.
This happens because the price is reserve / (supply * reserveRatio)
. As supply
is increased by a calculation that uses the full precision newRatio
, but reserveRatio
is stored with less precision (1e8
), the price will change due to this call.
None.
None.
GoodDollarExchangeProvider::mintFromExpansion()
is called and a rounding error happens in the calculation ofnewRatio
.
The current price is modified due to the expansion which goes against the readme:
What properties/invariants do you want to hold even if breaking them has a low/unknown impact?
Bancor formula invariant. Price = Reserve / Supply * reserveRatio
Add the following test to GoodDollarExchangeProvider.t.sol
:
function test_POC_mintFromExpansion_priceChangeFix() public {
uint256 priceBefore = exchangeProvider.currentPrice(exchangeId);
vm.prank(expansionControllerAddress);
exchangeProvider.mintFromExpansion(exchangeId, reserveRatioScalar);
uint256 priceAfter = exchangeProvider.currentPrice(exchangeId);
assertEq(priceBefore, priceAfter, "Price should remain exactly equal");
}
If the code is used as is, it fails. but if it is fixed by dividing and multiplying by 1e10
, eliminating the rounding error, the price matches exactly (exact fix show below).
Divide and multiply newRatio
by 1e10
to eliminate the rounding error, keeping the price unchanged.
function mintFromExpansion(
bytes32 exchangeId,
uint256 reserveRatioScalar
) external onlyExpansionController whenNotPaused returns (uint256 amountToMint) {
require(reserveRatioScalar > 0, "Reserve ratio scalar must be greater than 0");
PoolExchange memory exchange = getPoolExchange(exchangeId);
UD60x18 scaledRatio = wrap(uint256(exchange.reserveRatio) * 1e10);
UD60x18 newRatio = wrap(unwrap(scaledRatio.mul(wrap(reserveRatioScalar))) / 1e10 * 1e10);
...
}
Issue M-3: Malicious user may frontrun GoodDollarExpansionController::mintUBIFromReserveBalance()
to make protocol funds stuck
Source: #33
0x73696d616f
GoodDollarExpansionController::mintUBIFromReserveBalance() or GoodDollarExpansionController::mintUBIFromInterest() transfer funds to the reserve and mint $G to the distribution helper. However, GoodDollarExchangeProvider::mintFromInterest() mints 0 tokens whenever the supply is 0. An attacker can buy all $G from the exchange to trigger this.
In GoodDollarExpansionController::142
and GoodDollarExpansionController::161
, amountMinted
is not checked for a null value.
None.
None.
- Attacker calls
Bancor::swapIn()
orBancor::swapOut()
, buying all $G in the exchange, makingPoolExchange.tokenSupply
null. GoodDollarExpansionController::mintUBIFromReserveBalance()
orGoodDollarExpansionController::mintUBIFromInterest()
is called, adding reserve asset funds without minting $G.
Funds are added to the reserve without the corresponding amount of $G being minted.
GoodDollarExpansionController::mintUBIFromInterest()
and GoodDollarExpansionController::mintUBIFromReserveBalance()
do not check if amountToMint
is null:
function mintUBIFromInterest(bytes32 exchangeId, uint256 reserveInterest) external {
require(reserveInterest > 0, "Reserve interest must be greater than 0");
IBancorExchangeProvider.PoolExchange memory exchange = IBancorExchangeProvider(address(goodDollarExchangeProvider))
.getPoolExchange(exchangeId);
uint256 amountToMint = goodDollarExchangeProvider.mintFromInterest(exchangeId, reserveInterest);
require(IERC20(exchange.reserveAsset).transferFrom(msg.sender, reserve, reserveInterest), "Transfer failed"); //@audit safeTransferFrom. //@audit lost if reserve asset is also a stable asset
IGoodDollar(exchange.tokenAddress).mint(address(distributionHelper), amountToMint);
// Ignored, because contracts only interacts with trusted contracts and tokens
// slither-disable-next-line reentrancy-events
emit InterestUBIMinted(exchangeId, amountToMint);
}
...
function mintUBIFromReserveBalance(bytes32 exchangeId) external returns (uint256 amountMinted) {
IBancorExchangeProvider.PoolExchange memory exchange = IBancorExchangeProvider(address(goodDollarExchangeProvider))
.getPoolExchange(exchangeId);
uint256 contractReserveBalance = IERC20(exchange.reserveAsset).balanceOf(reserve);
uint256 additionalReserveBalance = contractReserveBalance - exchange.reserveBalance;
if (additionalReserveBalance > 0) {
amountMinted = goodDollarExchangeProvider.mintFromInterest(exchangeId, additionalReserveBalance);
IGoodDollar(exchange.tokenAddress).mint(address(distributionHelper), amountMinted);
// Ignored, because contracts only interacts with trusted contracts and tokens
// slither-disable-next-line reentrancy-events
emit InterestUBIMinted(exchangeId, amountMinted);
}
}
GoodDollarExchangeProvider::mintFromInterest()
returns 0 if exchange.tokenSupply
is 0.
function mintFromInterest(
bytes32 exchangeId,
uint256 reserveInterest
) external onlyExpansionController whenNotPaused returns (uint256 amountToMint) {
PoolExchange memory exchange = getPoolExchange(exchangeId);
uint256 reserveinterestScaled = reserveInterest * tokenPrecisionMultipliers[exchange.reserveAsset];
uint256 amountToMintScaled = unwrap(
wrap(reserveinterestScaled).mul(wrap(exchange.tokenSupply)).div(wrap(exchange.reserveBalance))
);
amountToMint = amountToMintScaled / tokenPrecisionMultipliers[exchange.tokenAddress];
exchanges[exchangeId].tokenSupply += amountToMintScaled;
exchanges[exchangeId].reserveBalance += reserveinterestScaled;
return amountToMint;
}
Revert if the amountToMint
from the GoodDollarExchangeProvider::mintFromInterest()
call is null. The same should also be done for GoodDollarExpansionController::mintUBIFromExpansion()
amountMinted
from the GoodDollarExchangeProvider.mintFromExpansion()
call.
Issue M-4: TradingLimits::update()
incorrectly only rounds up when deltaFlowUnits
becomes 0, which will silently increase trading limits
Source: #45
0x73696d616f
TradingLimits::update() divides the traded funds by the decimals of the token, int256 _deltaFlowUnits = _deltaFlow / int256((10 ** uint256(decimals)));. In a token with 18 decimals, for example, swapping 1.999...e18 tokens will lead to a
_deltaFlowUnitsof just
1`, taking a major error. This can be exploited to swap up to twice the trading limit, if tokens are swapped 2 by 2 and the state is updated only by 1 each time. Overall, even without malicious intent, the limits will always be bypassed due to the rounding.
In TradingLimits:135
, it only rounds up whenever deltaFlowUnits
becomes 0, but the error is just as big if it becomes 1 from 2, effectively not providing enough protection.
None.
None.
- User calls
Broker::swapIn/Out()
with amounts in and out that produce rounding errors (almost always).
The trading limits may be severely bypassed with malicious intent (by double the amount) or by a smaller but still significant amount organically.
TradingLimits::update()
only rounds up when deltaFlowUnits
becomes 0.
function update(
ITradingLimits.State memory self,
ITradingLimits.Config memory config,
int256 _deltaFlow,
uint8 decimals
) internal view returns (ITradingLimits.State memory) {
int256 _deltaFlowUnits = _deltaFlow / int256((10 ** uint256(decimals)));
require(_deltaFlowUnits <= MAX_INT48, "dFlow too large");
int48 deltaFlowUnits = int48(_deltaFlowUnits);
if (deltaFlowUnits == 0) {
deltaFlowUnits = _deltaFlow > 0 ? int48(1) : int48(-1);
}
...
The correct fix is:
int256 _deltaFlowUnits = (_deltaFlow - 1) / int256((10 ** uint256(decimals))) + 1;
Source: #50
0x73696d616f, 0xc0ffEE, Ollam, Robert, onthehunt
numberOfExpansions = (block.timestamp - config.lastExpansion) / config.expansionFrequency
The calculation divides it by the expansionFrequency, but this will cause significant rounding issues.
If the expansionFrequency is 1 day (as specified in the docs), time may pass without anybody calling the function and the following scenario will be present.
Let's say 30 hours since last expansion and someone decides then to call it, it will be rounded due to the division to be 1 day, producing a smaller value than the hours that've passed.
The root cause is the potential of rounding down numberOfExpansions
, which will give a significantly smaller value, depending on how big will be remainder of the division. (6 for 30 hours, 3 for 27 hours, etc)
mintUBIFromExpansion()
need to be callable.
No response
- Alice calls
mintUBIFromExpansion()
to create an expansion - 30 hours pass and nobody calls the function, Bob sees that he can call
mintUBIFromExpansion()
- Due to the rounding down of the calculation, it will a value equivalent of 24 hours passing.
The protocol will expand slower than intended, thus less $G will be minted, which will become significant overtime.
No response
No response