Platform: Code4rena
Start Date: 17/02/2022
Pot Size: $75,000 USDC
Total HM: 20
Participants: 39
Period: 7 days
Judges: moose-code, JasoonS
Total Solo HM: 13
Id: 89
League: ETH
Rank: 7/39
Findings: 5
Award: $3,770.90
🌟 Selected for report: 1
🚀 Solo Findings: 1
function getUnderlyingPrice(address underlying) virtual external view returns(int256 answer) { if (stablePrice[underlying] != 0) { return stablePrice[underlying]; } (,answer,,,) = AggregatorV3Interface(chainLinkAggregatorMap[underlying]).latestRoundData(); answer /= 100; }
(uint80 round, uint256 latestPrice, uint256 latestTimestamp) = getLatestRoundData(aggregator); uint256 baseTimestamp = _blockTimestamp() - intervalInSeconds; // if latest updated timestamp is earlier than target timestamp, return the latest price. if (latestTimestamp < baseTimestamp || round == 0) { return formatPrice(latestPrice); }
function _getLiquidationInfo(address trader, uint idx) internal view returns (LiquidationBuffer memory buffer) { require(idx > VUSD_IDX && idx < supportedCollateral.length, "collateral not seizable"); (buffer.status, buffer.repayAble, buffer.incentivePerDollar) = isLiquidatable(trader, false); if (buffer.status == IMarginAccount.LiquidationStatus.IS_LIQUIDATABLE) { Collateral memory coll = supportedCollateral[idx]; buffer.priceCollateral = oracle.getUnderlyingPrice(address(coll.token)).toUint256(); buffer.decimals = coll.decimals; } }
On Oracle.sol
, we are using Chainlink's latestRoundData
API, but there is no check if the return value indicates stale data. This could lead to stale prices according to the Chainlink documentation:
The result of latestRoundData
API will be used for MarginAccount.sol#liquidateExactRepay()
, therefore, a stale price from Chainlink can lead to loss of funds to end-users.
Consider adding missing checks for stale data.
For example:
(uint80 roundID ,answer,, uint256 timestamp, uint80 answeredInRound) = AggregatorV3Interface(chainLinkAggregatorMap[underlying]).latestRoundData(); require(answer > 0, "Chainlink price <= 0"); require(answeredInRound >= roundID, "Stale price"); require(timestamp != 0, "Round not complete");
#0 - atvanguard
2022-02-24T07:00:00Z
Duplicate of #46
🌟 Selected for report: 0xliumin
Also found by: WatchPug, hyh, minhquanym
In MarginAccount.sol#settleBadDebt()
, the collateral assets will be seized and transferred to the insuranceFund
contract.
However, there is no way for the liquidity providers of the insuranceFund
to get back the collateral assets.
In the current implementation, these collateral assets seized during settleBadDebt()
will be frozen in the contract, in essence. They belong to the liquidity providers and they should be able to retrieve them.
for (uint i = 1 /* skip vusd */; i < assets.length; i++) { int amount = margin[i][trader]; if (amount > 0) { margin[i][trader] = 0; assets[i].token.safeTransfer(address(insuranceFund), amount.toUint256()); seized[i] = amount.toUint256(); } }
Consider adding a new method for the liquidity providers to claim certain collateral assets proportionally to the shares they held.
#0 - atvanguard
2022-02-24T04:33:01Z
Duplicate of #128
🌟 Selected for report: WatchPug
2785.5118 USDC - $2,785.51
function syncDeps(IRegistry _registry) public onlyGovernance { vusd = IERC20(_registry.vusd()); marginAccount = _registry.marginAccount(); }
The Governance
address can call InsuranceFund.sol#syncDeps()
to change the contract address of vusd
anytime.
However, since the tx to set a new address for vusd
can get in between users' txs to deposit and withdraw, in some edge cases, it can result in users' loss of funds.
1,000,000 VUSD
to InsuranceFund
;syncDeps()
and set vusd
to the address of VUSDv2
;withdraw()
with all the shares
and get back 0 VUSDv2
.As a result, Alice suffered a fund loss of 1,000,000 VUSD
.
vusd
unchangeable;vusd
must be considered, consider changing the syncDeps()
to:function syncDeps(IRegistry _registry) public onlyGovernance { uint _balance = balance(); vusd = IERC20(_registry.vusd()); require(balance() >= _balance); marginAccount = _registry.marginAccount(); }
#0 - atvanguard
2022-02-24T04:46:35Z
Acknowledging but yes system heavily relies on the admins to do the right thing, the right way. We might remove several such upgradeability rights during a broader refactor of the entire system.
#1 - moose-code
2022-03-10T10:10:39Z
Downgrading to medium as this is largely admin related.
🌟 Selected for report: defsec
Also found by: 0v3rf10w, 0x0x0x, 0x1f8b, 0xwags, CertoraInc, Dravee, IllIllI, Meta0xNull, Nikolay, Omik, WatchPug, bobi, cccz, csanuragjain, danb, gzeon, hubble, hyh, itsmeSTYJ, jayjonah8, kenta, kirk-baird, leastwood, pauliax, peritoflores, rfa, robee, sorrynotsorry, ye0lde
142.3223 USDC - $142.32
Use of Upgradeable Proxy Contract Structure allows the logic of the contract to be arbitrarily changed.
This allows the proxy admin to perform malicious actions e.g., taking funds from users' wallets up to the allowance limit.
This action can be performed by the malicious/compromised proxy admin without any restriction.
Considering that the purpose of this particular contract is for a token, we believe the smart contract should not be structured as an upgradeable contract.
Given:
USDC
VUSD
spending her USDC
and mintWithReserve()
VUSD
;VUSD
spending his USDC
and mintWithReserve()
VUSD
;upgradeToAndCall()
on the proxy contract and set a malicious contract as newImplementation
and stolen all the WBTC in Alice's and Bob's wallets;A smart contract being structured as an upgradeable contract alone is not usually considered as a high severity risk. But given the severe impact (funds in users' wallets got stolen), with the PoC above, once the proxy admin is compromised, users' funds can be at risk. Therefore, we mark it as a High
severity issue.
#0 - atvanguard
2022-02-24T04:52:52Z
Duplicate of #40
#1 - JeeberC4
2022-03-24T20:51:42Z
Since this issue was downgraded to a QA level, and the warden did not submit a separate QA report, we've renamed this one to "QA report" for consistency. The original title, for the record, was [WP-H1] VUSD.sol Proxy admin of the upgradeable proxy contract can steal users' reserveToken
227.4932 USDC - $227.49
[S]: Suggested optimation, save a decent amount of gas without compromising readability;
[M]: Minor optimation, the amount of gas saved is minor, change when you see fit;
[N]: Non-preferred, the amount of gas saved is at cost of readability, only apply when gas saving is a top priority.
assert
statements@external @nonreentrant('lock') def add_liquidity(amounts: uint256[N_COINS], min_mint_amount: uint256) -> (uint256): assert msg.sender == self.amm, 'VAMM: OnlyAMM' assert not self.is_killed # dev: the pool is killed assert msg.sender == self.amm
L669 and L671 are duplicated.
Outdated versions of OpenZeppelin library are used.
It's a best practice to use the latest version of libraries.
New versions of OpenZeppelin libraries can be more gas effeicant.
For exmaple:
ERC20Upgradeable.sol
in @openzeppelin/contracts-upgradeable@4.3.2:
function transferFrom( address sender, address recipient, uint256 amount ) public virtual override returns (bool) { _transfer(sender, recipient, amount); uint256 currentAllowance = _allowances[sender][_msgSender()]; require(currentAllowance >= amount, "ERC20: transfer amount exceeds allowance"); unchecked { _approve(sender, _msgSender(), currentAllowance - amount); } return true; }
A gas optimization upgrade has been added to @openzeppelin/contracts@4.5.0:
function transferFrom( address from, address to, uint256 amount ) public virtual override returns (bool) { address spender = _msgSender(); _spendAllowance(from, spender, amount); _transfer(from, to, amount); return true; }
function _spendAllowance( address owner, address spender, uint256 amount ) internal virtual { uint256 currentAllowance = allowance(owner, spender); if (currentAllowance != type(uint256).max) { require(currentAllowance >= amount, "ERC20: insufficient allowance"); unchecked { _approve(owner, spender, currentAllowance - amount); } } }
transferFrom
when allowance is type(uint256).max
. (#3085)For the storage variables that will be accessed multiple times, especially in loops, cache and read from the stack can save ~100 gas from each extra read (SLOAD
after Berlin).
For example:
function processWithdrawals() external { uint reserve = reserveToken.balanceOf(address(this)); require(reserve >= withdrawals[start].amount, 'Cannot process withdrawals at this time: Not enough balance'); uint i = start; while (i < withdrawals.length && (i - start) <= maxWithdrawalProcesses) { Withdrawal memory withdrawal = withdrawals[i]; if (reserve < withdrawal.amount) { break; } reserveToken.safeTransfer(withdrawal.usr, withdrawal.amount); reserve -= withdrawal.amount; i += 1; } start = i; }
start
and maxWithdrawalProcesses
can be cached.
For the arithmetic operations that will never over/underflow, using the unchecked directive (Solidity v0.8 has default overflow/underflow checks) can save some gas from the unnecessary internal over/underflow checks.
For example:
function _transferInVusd(address from, uint amount) internal { IERC20(address(vusd)).safeTransferFrom(from, address(this), amount); if (credit > 0) { uint toBurn = Math.min(vusd.balanceOf(address(this)), credit); credit -= toBurn; vusd.burn(toBurn); } }
credit -= toBurn
will never overflow.
function _transferOutVusd(address recipient, uint amount) internal { uint bal = vusd.balanceOf(address(this)); if (bal < amount) { // Say there are 2 traders, Alice and Bob. // Alice has a profitable position and realizes their PnL in form of vusd margin. // But bob has not yet realized their -ve PnL. // In that case we'll take a credit from vusd contract, which will eventually be returned when Bob pays their debt back. uint _credit = amount - bal; credit += _credit; vusd.mint(address(this), _credit); } IERC20(address(vusd)).safeTransfer(recipient, amount); }
uint _credit = amount - bal
will never overflow.
IMarginAccount marginAccount; VUSD vusd; IERC20 public reserveToken; constructor(address _marginAccount, address _vusd) { marginAccount = IMarginAccount(_marginAccount); vusd = VUSD(_vusd); reserveToken = vusd.reserveToken(); reserveToken.safeApprove(address(_vusd), type(uint).max); IERC20(_vusd).safeApprove(address(_marginAccount), type(uint).max); }
Considering that marginAccount
, vusd
and reserveToken
will never change, changing them to immutable variables instead of storages variable can save gas.
#0 - atvanguard
2022-02-26T08:09:12Z
Good report but a duplicate of several other issues.
#1 - moose-code
2022-03-05T15:25:43Z
Really like the S M N report nomenclature. Very true that gas optimisations are not one size fits all. I mean, we could write the whole thing in opcodes but readability is destroyed.