Platform: Code4rena
Start Date: 23/02/2024
Pot Size: $36,500 USDC
Total HM: 2
Participants: 39
Period: 7 days
Judge: Dravee
Id: 338
League: ETH
Rank: 36/39
Findings: 1
Award: $19.59
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: SBSecurity
Also found by: 0xDemon, 0xLuckyLuke, 14si2o_Flint, ArmedGoose, DarkTower, Shubham, Tigerfrake, cheatc0d3, peanuts, sl1
19.5868 USDC - $19.59
I manually reviewed the code, focusing on logic, external calls, and user inputs. I kicked off with reviewing each contract code manually line by line based on a checklist I drafted for low severity and nuanced issues in solidity smart contracts. This helped quickly flag common problems and areas needing closer inspection.
The principal token and yield token values depend directly on the assets deposited in the IBT vault that backs it. However, there are no checks on the type, quality, or risks associated with the collateral asset choice. Poor collateral selection or liquidations can make the tokens lose complete value.
File: PrincipalToken.sol 120: function initialize( 121: address _ibt, 122: uint256 _duration, 123: address _initialAuthority 124: ) external initializer { 125: if (_ibt == address(0) || _initialAuthority == address(0)) { 126: revert AddressError(); 127: } 128: if (IERC4626(_ibt).totalAssets() == 0) { 129: revert RateError(); 130: } 131: _asset = IERC4626(_ibt).asset(); 132: duration = _duration; 133: expiry = _duration + block.timestamp; 134: string memory _ibtSymbol = IERC4626(_ibt).symbol(); 135: string memory name = NamingUtil.genPTName(_ibtSymbol, expiry); 136: __ERC20_init(name, NamingUtil.genPTSymbol(_ibtSymbol, expiry)); 137: __ERC20Permit_init(name); 138: __Pausable_init(); 139: __ReentrancyGuard_init(); 140: __AccessManaged_init(_initialAuthority); 141: _ibtDecimals = IERC4626(_ibt).decimals(); 142: _assetDecimals = PrincipalTokenUtil._tryGetTokenDecimals(_asset); 143: if ( 144: _assetDecimals < MIN_DECIMALS || 145: _assetDecimals > _ibtDecimals || 146: _ibtDecimals > MAX_DECIMALS 147: ) { 148: revert InvalidDecimals(); 149: } 150: ibt = _ibt; 151: ibtUnit = 10 ** _ibtDecimals; 152: ibtRate = IERC4626(ibt).previewRedeem(ibtUnit).toRay(_assetDecimals); 153: ptRate = RayMath.RAY_UNIT; 154: yt = _deployYT( 155: NamingUtil.genYTName(_ibtSymbol, expiry), 156: NamingUtil.genYTSymbol(_ibtSymbol, expiry) 157: ); 158: }
// Validate and set minimum collateralization ratio function validateAndSetIBT( address _ibt, uint minCollateralRatio ) internal { // Check asset type and volatility require(IBTAssetChecker.validateAsset(_ibt), "Unsafe asset"); // Set minimum collateralization level require(IERC4626(_ibt).collateralRatio() >= minCollateralRatio, "Undercollateralized"); // Approve trusted price feeds for periodic checks IBTPriceFeed(0x...).addConsumer(address(this), _ibt); // Set circuit breaker level circuitBreakerPrice = IBTPriceFeed(_ibt).latestPrice() * 0.8; ibt = _ibt; } // Check price feed and pause if circuit breaker triggered function checkCircuitBreaker() public { if (IBTPriceFeed(ibt).latestPrice() < circuitBreakerPrice) { _pause(); } }
The use of delegatecall
with dynamic user-supplied data (_data
) in claimRewards
introduces risks, such as unexpected behavior or vulnerabilities due to the execution context (storage, msg.sender, etc.) being shared with the called contract. This could potentially be misused if the rewardsProxy
contract has vulnerabilities or if there's an error in the way _data
is handled.
File: PrincipalToken.sol 394: function claimRewards(bytes memory _data) external restricted { 395: if (rewardsProxy == address(0)) { 396: revert NoRewardsProxySet(); 397: } 398: _data = abi.encodeWithSelector(IRewardsProxy(rewardsProxy).claimRewards.selector, _data); 399: (bool success, ) = rewardsProxy.delegatecall(_data); 400: if (!success) { 401: revert ClaimRewardsFailed(); 402: } 403: }
Avoid using delegatecall
with user-supplied data when possible. If delegatecall
must be used, ensure rigorous validation of the input data.
In the PrincipalToken
contract, the code does not emit events when fees are updated or when critical thresholds are reached. In addition, administrative functions such as pause
, unPause
, and setRewardsProxy
are critical but do not emit events upon their invocation.
Functions to add events:
_updateFees
pause
unpause
Lines of code Lines of code Lines of code
Emit events when significant fee-related actions are taken. This ensures that users and external systems can accurately track fee-related changes and their impact on transactions.
event FeesUpdated(uint256 newTokenizationFee, uint256 newYieldFee); event Paused(address indexed account); event Unpaused(address indexed account);
The _duration
parameter in initialize function of PrincipalToken contract is not explicitly checked to ensure it's within a reasonable range, which could lead to unexpected behavior or vulnerabilities.
File: PrincipalToken.sol 120: function initialize( 121: address _ibt, 122: uint256 _duration, 123: address _initialAuthority 124: ) external initializer { 125: if (_ibt == address(0) || _initialAuthority == address(0)) { 126: revert AddressError(); 127: } 128: if (IERC4626(_ibt).totalAssets() == 0) { 129: revert RateError(); 130: } 131: _asset = IERC4626(_ibt).asset(); 132: duration = _duration; 133: expiry = _duration + block.timestamp; 134: string memory _ibtSymbol = IERC4626(_ibt).symbol(); 135: string memory name = NamingUtil.genPTName(_ibtSymbol, expiry); 136: __ERC20_init(name, NamingUtil.genPTSymbol(_ibtSymbol, expiry)); 137: __ERC20Permit_init(name); 138: __Pausable_init(); 139: __ReentrancyGuard_init(); 140: __AccessManaged_init(_initialAuthority); 141: _ibtDecimals = IERC4626(_ibt).decimals(); 142: _assetDecimals = PrincipalTokenUtil._tryGetTokenDecimals(_asset); 143: if ( 144: _assetDecimals < MIN_DECIMALS || 145: _assetDecimals > _ibtDecimals || 146: _ibtDecimals > MAX_DECIMALS 147: ) { 148: revert InvalidDecimals(); 149: } 150: ibt = _ibt; 151: ibtUnit = 10 ** _ibtDecimals; 152: ibtRate = IERC4626(ibt).previewRedeem(ibtUnit).toRay(_assetDecimals); 153: ptRate = RayMath.RAY_UNIT; 154: yt = _deployYT( 155: NamingUtil.genYTName(_ibtSymbol, expiry), 156: NamingUtil.genYTSymbol(_ibtSymbol, expiry) 157: ); 158: }
Add checks to ensure that _duration
results in a future expiry date.
if (_duration <= 0 || _duration > MAX_DURATION) { revert InvalidDuration(); }
Lack of validation for assets
parameter in deposit and withdraw functions of PrincipalToken contract. Transferring zero assets could lead to unintended effects or wasted gas.
File: PrincipalToken.sol 176: function deposit( 177: uint256 assets, 178: address ptReceiver, 179: address ytReceiver 180: ) public override returns (uint256 shares) { 181: IERC20(_asset).safeTransferFrom(msg.sender, address(this), assets); 182: IERC20(_asset).safeIncreaseAllowance(ibt, assets); 183: uint256 ibts = IERC4626(ibt).deposit(assets, address(this)); 184: shares = _depositIBT(ibts, ptReceiver, ytReceiver); 185: }
File: PrincipalToken.sol 278: function withdraw( 279: uint256 assets, 280: address receiver, 281: address owner 282: ) public override returns (uint256 shares) { 283: _beforeWithdraw(assets, owner); 284: (uint256 _ptRate, uint256 _ibtRate) = _getPTandIBTRates(false); 285: uint256 ibts = IERC4626(ibt).withdraw(assets, receiver, address(this)); 286: shares = _withdrawShares(ibts, receiver, owner, _ptRate, _ibtRate); 287: }
Validate that the assets
amount is greater than zero.
if (assets <= 0) { revert InvalidAssetAmount(); } if (ptReceiver == address(0) || ytReceiver == address(0)) { revert InvalidReceiverAddress(); }
The _convertIBTsToShares
function calculates the number of shares based on the IBT amount without validating the output. In scenarios where conversion rates lead to unexpected results (e.g due to extremely low rates leading to zero shares for a non-zero IBT amount), this can lead to loss of funds or denial of service.
Similarly the _convertSharesToIBTs
function converts PT shares to IBTs without validating the result.
File: PrincipalToken.sol 680: function _convertIBTsToShares( 681: uint256 _ibts, 682: bool _roundUp 683: ) internal view returns (uint256 shares) { 684: (uint256 _ptRate, uint256 _ibtRate) = _getPTandIBTRates(false); 685: if (_ptRate == 0) { 686: revert RateError(); 687: } 688: shares = _ibts.mulDiv( 689: _ibtRate, 690: _ptRate, 691: _roundUp ? Math.Rounding.Ceil : Math.Rounding.Floor 692: ); 693: }
File: PrincipalToken.sol 659: function _convertSharesToIBTs( 660: uint256 _shares, 661: bool _roundUp 662: ) internal view returns (uint256 ibts) { 663: (uint256 _ptRate, uint256 _ibtRate) = _getPTandIBTRates(false); 664: if (_ibtRate == 0) { 665: revert RateError(); 666: } 667: ibts = _shares.mulDiv( 668: _ptRate, 669: _ibtRate, 670: _roundUp ? Math.Rounding.Ceil : Math.Rounding.Floor 671: ); 672: }
Add validation to ensure the conversion results are sensible and revert on invalid outputs.
if (ibts == 0 && _shares > 0) { revert InvalidConversionOutput(); }
The _dispatchUpgradeToAndCall
function decodes the newImplementation
address and data
directly from the call data without any form of validation. This approach is vulnerable to front-running attacks where an attacker could potentially front-run the transaction with a malicious newImplementation
contract.
Similar to this issue above, the upgradeandCall function allows the caller to upgrade a proxy to a new implementation and execute a call in a single transaction. Without proper validation of the implementation
address, it's susceptible to front-running attacks, where an attacker could replace a legitimate upgrade transaction with one pointing to a malicious implementation.
File: AMTransparentUpgradeableProxy.sol 120: function _dispatchUpgradeToAndCall() private { 121: (address newImplementation, bytes memory data) = abi.decode(msg.data[4:], (address, bytes)); 122: ERC1967Utils.upgradeToAndCall(newImplementation, data); 123: }
File: AMProxyAdmin.sol 40: function upgradeAndCall( 41: IAMTransparentUpgradeableProxy proxy, 42: address implementation, 43: bytes memory data 44: ) public payable virtual restricted { 45: proxy.upgradeToAndCall{value: msg.value}(implementation, data); 46: }
Implement checks to validate the newImplementation
address before proceeding with the upgrade. This could involve maintaining a whitelist of allowed implementation addresses or requiring a multi-step process for upgrades that include a delay and validation step. Additionaly, use commit-reveal schemes to prevent such attacks from happening.
The QA analysis of the code in Spectra contracts revealed some low severity issues and potential design considerations that could bolster security and operation efficiency of the contracts. You can follow the mitigations outlined in the report for quick fixes to the issues discovered.
#0 - c4-pre-sort
2024-03-03T13:53:19Z
gzeon-c4 marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-03-03T15:30:23Z
gzeon-c4 marked the issue as sufficient quality report
#2 - c4-judge
2024-03-11T00:53:50Z
JustDravee marked the issue as grade-b