Platform: Code4rena
Start Date: 09/01/2024
Pot Size: $100,000 USDC
Total HM: 13
Participants: 28
Period: 28 days
Judge: 0xsomeone
Total Solo HM: 8
Id: 319
League: ETH
Rank: 12/28
Findings: 1
Award: $1,486.04
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: Aymen0909
Also found by: bin2chen, etherhood, jasonxiale, kodyvim, minhquanym
1486.0378 USDC - $1,486.04
https://github.com/code-423n4/2024-01-opus/blob/main/src/core/shrine.cairo#L1382-L1392 https://github.com/code-423n4/2024-01-opus/blob/main/src/core/shrine.cairo#L1421-L1431
The withdraw_helper
function in the shrine
contract handles withdrawal logic for both the withdraw
and seize
functions. It is responsible for updating trove balances, total yang balances, and charging interest for the trove via the charge
function. However, there is an oversight in the current implementation:
fn withdraw_helper(ref self: ContractState, yang: ContractAddress, trove_id: u64, amount: Wad) { ... let new_trove_balance: Wad = trove_balance - amount; let new_total: Wad = self.yang_total.read(yang_id) - amount; self.charge(trove_id); //@audit will no account for exceptional redistribution added to deposits balance in `charge` call self.yang_total.write(yang_id, new_total); self.deposits.write((yang_id, trove_id), new_trove_balance); // Emit events self.emit(YangTotalUpdated { yang, total: new_total }); self.emit(DepositUpdated { yang, trove_id, amount: new_trove_balance }); }
The issue in the code above is that the withdraw_helper
function then proceeds to update the storage variables yang_total
and deposits
using the previously calculated new_total
and new_trove_balance
values without accounting for any new yang balance added to the trove after an exceptional redistribution. This results in neglecting any exceptional redistributions added to the deposits
balance during the charge
call :
fn charge(ref self: ContractState, trove_id: u64) { ... // If there was any exceptional redistribution, write updated yang amounts to trove if updated_trove_yang_balances.is_some() { let mut updated_trove_yang_balances = updated_trove_yang_balances.unwrap(); loop { match updated_trove_yang_balances.pop_front() { Option::Some(yang_balance) => { //@audit will updated the trove yang balance self.deposits.write((*yang_balance.yang_id, trove_id), *yang_balance.amount); }, Option::None => { break; }, }; }; } ... }
Because the trove deposits
map is changed in charge
function but withdraw_helper
uses directly the value new_trove_balance
which was calculated before the charge
call, the exceptional redistribution added to deposits
will be overriden and will be neglected in the trove yang balance.
This oversight could result in financial losses for all protocol users. When users withdraw yang amounts, any exceptional redistributions that should have been added to their trove balances will be neglected and lost.
Users are at risk of losing all yang exceptional redistribution amounts due to an error in the withdraw_helper
function, which causes it to neglect any yang-added redistribution to the trove deposits
map.
Let's take a simple scenario to highlight this issue :
deposits(yang_id, trove_id) = 1000
abbot.withdraw
, withdraw_helper
will be invoked under the hood in the shrine contract which will first calculate the new yang trove balance :new_trove_balance = trove_balance - amount = 1000 - 100 = 900
charge
function is called it will update the trove yang deposits
balance, so now we have (suppose redistribution is 50 yang per trove, for example) :deposits(yang_id, trove_id) = 1000 + 50 = 1050
charge
, the withdraw_helper
function will set the trove yang balance v to the previously calculated new_trove_balance
, so we will have :deposits(yang_id, trove_id) = 1000 + 50 = 900
deposits(yang_id, trove_id) = 1000 - 100 + 50 = 950
Thus as demonstrated in this simplified example the issue will cause the loss of any exceptional redistribution amounts for the users resulting in a financial losses.
Manual review, VS Code
To address this issue, the charge
function should be called before calculating the new trove yang balance (new_trove_balance
). This ensures that any exceptional redistributions are accounted for before updating the trove balance and total yang balance:
fn withdraw_helper(ref self: ContractState, yang: ContractAddress, trove_id: u64, amount: Wad) { let yang_id: u32 = self.get_valid_yang_id(yang); //@audit add exceptional redistribution before calculating `new_trove_balance` ++ self.charge(trove_id); // Fails if amount > amount of yang deposited in the given trove let trove_balance: Wad = self.deposits.read((yang_id, trove_id)); assert(trove_balance >= amount, 'SH: Insufficient yang balance'); let new_trove_balance: Wad = trove_balance - amount; let new_total: Wad = self.yang_total.read(yang_id) - amount; -- self.charge(trove_id); self.yang_total.write(yang_id, new_total); self.deposits.write((yang_id, trove_id), new_trove_balance); // Emit events self.emit(YangTotalUpdated { yang, total: new_total }); self.emit(DepositUpdated { yang, trove_id, amount: new_trove_balance }); }
Context
#0 - tserg
2024-02-07T07:27:18Z
This is valid - duplicate of #211.
#1 - c4-pre-sort
2024-02-09T18:06:28Z
bytes032 marked the issue as duplicate of #211
#2 - c4-pre-sort
2024-02-09T18:06:41Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2024-02-23T20:08:32Z
alex-ppg marked the issue as selected for report
#4 - alex-ppg
2024-02-26T16:34:11Z
The Warden has demonstrated how an exception trove redistribution will not be properly tracked by the withdrawal helper, resulting in an unsynchronized accounting state for the Opus system whereby the user will lose the collateral they acquired in the redistribution.
I believe a high-risk severity is appropriate as it details a scenario in which the collateral balances of users will potentially lose the full redistributed collateral.
#5 - c4-judge
2024-02-26T16:34:17Z
alex-ppg marked the issue as satisfactory