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: 3/28
Findings: 3
Award: $9,786.31
🌟 Selected for report: 3
🚀 Solo Findings: 2
🌟 Selected for report: 0xTheC0der
4529.9124 USDC - $4,529.91
Once a yang (collateral asset) is suspended on Opus, the following holds true according to the documentation:
- No further deposits can be made. This is enforced by the Sentinel.
- Its threshold will decrease to zero linearly over the
SUSPENSION_GRACE_PERIOD
.
Therefore, a user will naturally deposit healthier collateral to their trove to maintain its threshold, while withdrawing the unhealthy/suspended collateral as it loses value on Opus, effectively replacing the collateral.
However, this is not possible because the underlying assets of a yang are immediately frozen once suspended. It's clear from the documentation that no more deposits of suspended collateral can be made, but this accidentally also affects the withdrawals.
The abbot::withdraw(...) method calls sentinel::convert_to_yang(...) which in turn calls sentinel::assert_can_enter(...):
fn assert_can_enter(self: @ContractState, yang: ContractAddress, gate: IGateDispatcher, enter_amt: u128) { ... let suspension_status: YangSuspensionStatus = self.shrine.read().get_yang_suspension_status(yang); assert(suspension_status == YangSuspensionStatus::None, 'SE: Yang suspended'); ... }
One can see that the assertion will be triggered once the suspension status is Temporary
or Permanent
.
As a consequence, a yang's underlying assets are frozen once suspended:
Temporary
, the admin can unsuspend the yang to make it withdrawable again.Permanent
, the assets are permanently locked from withdrawal.The following PoC demonstrates that a yang's underlying assets cannot be withdrawn once suspended.
Add the test case below to src/tests/abbot/test_abbot.cairo
and run it with snforge test test_withdraw_suspended_fail
.
#[test] #[should_panic(expected: ('SE: Yang suspended',))] fn test_withdraw_suspended_fail() { let (shrine, sentinel, abbot, yangs, _, trove_owner, trove_id, _, _) = abbot_utils::deploy_abbot_and_open_trove( Option::None, Option::None, Option::None, Option::None, Option::None ); let asset_addr: ContractAddress = *yangs.at(0); let amount: u128 = WAD_SCALE; start_prank(CheatTarget::One(sentinel.contract_address), sentinel_utils::admin()); sentinel.suspend_yang(asset_addr); stop_prank(CheatTarget::One(sentinel.contract_address)); start_prank(CheatTarget::One(abbot.contract_address), trove_owner); abbot.withdraw(trove_id, AssetBalance { address: asset_addr, amount }); }
Manual review
Replace the accidental assert_can_enter(..)
check with those that are really necessary at this point:
diff --git a/src/core/sentinel.cairo b/src/core/sentinel.cairo index b18edde..9671ca2 100644 --- a/src/core/sentinel.cairo +++ b/src/core/sentinel.cairo @@ -156,7 +156,8 @@ mod sentinel { // This can be used to simulate the effects of `enter`. fn convert_to_yang(self: @ContractState, yang: ContractAddress, asset_amt: u128) -> Wad { let gate: IGateDispatcher = self.yang_to_gate.read(yang); - self.assert_can_enter(yang, gate, asset_amt); + assert(gate.contract_address.is_non_zero(), 'SE: Yang not added'); // alike to sentinel::convert_to_assets(...) + assert(self.yang_is_live.read(yang), 'SE: Gate is not live'); // to satisfy test_sentinel::test_kill_gate_and_preview_enter() gate.convert_to_yang(asset_amt) }
Invalid Validation
#0 - c4-pre-sort
2024-02-10T18:37:03Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-10T18:37:05Z
bytes032 marked the issue as primary issue
#2 - c4-sponsor
2024-02-15T15:30:40Z
tserg (sponsor) confirmed
#3 - alex-ppg
2024-02-26T17:22:38Z
The Warden has demonstrated how a Yang's suspension (either temporary or permanent) will cause the overall system to deviate from its specification and disallow withdrawals of the Yang (collateral) instead of permitting them, thereby never letting the system gradually recover.
I consider a medium-risk severity to be appropriate for this exhibit as it relates to misbehavior that will arise during an emergency scenario.
#4 - c4-judge
2024-02-26T17:22:41Z
alex-ppg marked the issue as selected for report
#5 - c4-judge
2024-02-26T17:22:44Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: 0xTheC0der
4529.9124 USDC - $4,529.91
Unhealthy troves with ltv > 90%
and threshold < 90%
cannot always be absorbed due to a wrong if-condition.
According to Priority of liquidation methods it should always be possible to absorb unhealthy troves with ltv > 90%
:
Absorption can happen only after an unhealthy trove's LTV has exceeded the LTV at which the maximum possible penalty is reached, or if it has exceeded 90% LTV. The liquidation penalty in this case will similarly be capped to the maximum of 12.5% or the maximum possible penalty.
However, the purger::get_absorption_penalty_internal(...) method mistakenly checks the threshold
instead of the ltv
against the ABSORPTION_THRESHOLD (90%)
in L467:
fn get_absorption_penalty_internal( self: @ContractState, threshold: Ray, ltv: Ray, ltv_after_compensation: Ray ) -> Option<Ray> { if ltv <= threshold { return Option::None; } ... let mut max_possible_penalty: Ray = min( (RAY_ONE.into() - ltv_after_compensation) / ltv_after_compensation, MAX_PENALTY.into() ); if threshold > ABSORPTION_THRESHOLD.into() { // @audit ltv instead let s = self.penalty_scalar.read(); let penalty = min(MIN_PENALTY.into() + s * ltv / threshold - RAY_ONE.into(), max_possible_penalty); return Option::Some(penalty); } let penalty = min(MIN_PENALTY.into() + ltv / threshold - RAY_ONE.into(), max_possible_penalty); if penalty == max_possible_penalty { Option::Some(penalty) } else { Option::None } }
As a consequence, unhealthy troves can only be absorbed if they reach the maximum possible penalty although the condition ltv > 90%
is already satisfied. This is against the protocol's intended liquidation/absorption incentives and therefore endangers the solvency of the protocol.
By observing the sponsor's graph for liquidation penalty it becomes evident that the MAX_PENALTY
can only be achieved for ltv
up to 89%
. For even higher ltv
up to 100%
, the penalty approaches 0%
due to max_possible_penalty
(see code above), which lowers the incentives for liquidation and makes absorption a necessity.
In case of threshold > 83%
there is a window where 90% < ltv < ltv@max_possible_penalty
causing absorptions to be impossible due to the present bug. This linked graph visualizes the present issue.
In the following, a numerical example is provided to demonstrate the above claims.
Initial assumptions:
threshold = 88% (reasonable because shrine::MAX_THRESHOLD is 100%) ltv = 91% (should be eligible for absorption in any case) ltv_after_compensation = 93% (reasonable because ltv becomes worse due to compensation)
Let's do the math step-by-step:
fn get_absorption_penalty_internal( self: @ContractState, threshold: Ray, ltv: Ray, ltv_after_compensation: Ray ) -> Option<Ray> { // @PoC: 91% <= 88% --> false, skip body if ltv <= threshold { return Option::None; } ... let mut max_possible_penalty: Ray = min( (RAY_ONE.into() - ltv_after_compensation) / ltv_after_compensation, MAX_PENALTY.into() ); // @PoC: max_possible_penalty = 7.53% // @PoC: 88% > 90% --> false, skip body due to wrong check if threshold > ABSORPTION_THRESHOLD.into() { // @audit ltv instead let s = self.penalty_scalar.read(); let penalty = min(MIN_PENALTY.into() + s * ltv / threshold - RAY_ONE.into(), max_possible_penalty); return Option::Some(penalty); } let penalty = min(MIN_PENALTY.into() + ltv / threshold - RAY_ONE.into(), max_possible_penalty); // @PoC: penalty = 6.41% // @PoC: 6.41% == 7.53% --> false, go in else if penalty == max_possible_penalty { Option::Some(penalty) } else { Option::None // @PoC: trove is not eligible for absorption } }
We can see that the trove has not reached its maximum possible penalty yet, therefore it cannot be absorbed as expected, although ltv > 90%
.
Manual review
Make sure the absorption threshold is checked against the ltv
as intended:
diff --git a/src/core/purger.cairo b/src/core/purger.cairo index 6a36bbc..820aff5 100644 --- a/src/core/purger.cairo +++ b/src/core/purger.cairo @@ -464,7 +464,7 @@ mod purger { (RAY_ONE.into() - ltv_after_compensation) / ltv_after_compensation, MAX_PENALTY.into() ); - if threshold > ABSORPTION_THRESHOLD.into() { + if ltv > ABSORPTION_THRESHOLD.into() { let s = self.penalty_scalar.read(); let penalty = min(MIN_PENALTY.into() + s * ltv / threshold - RAY_ONE.into(), max_possible_penalty);
Math
#0 - c4-pre-sort
2024-02-09T18:08:28Z
bytes032 marked the issue as duplicate of #13
#1 - c4-pre-sort
2024-02-09T18:08:32Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2024-02-26T16:18:46Z
alex-ppg marked the issue as not a duplicate
#3 - alex-ppg
2024-02-26T16:22:17Z
The Warden has demonstrated how a contradiction between the documentation and the implementation of the project will cause certain troves to not be liquidate-able temporarily.
I confirmed this submission as the documentation of the project states in the priority of liquidation methods chapter that a trove should be liquidate-able if its TVL exceeds 90% (i.e. the ABSORPTION_THRESHOLD
). The code incorrectly validates the trove's threshold rather than LTV, rendering the submission to be valid.
I consider a medium-risk severity apt for this finding as the DoS is temporary.
#4 - c4-judge
2024-02-26T16:22:26Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2024-02-26T16:22:59Z
alex-ppg marked the issue as selected for report
#6 - tserg
2024-02-27T04:55:17Z
This is an error in the documentation.
The correct wording should be:
Absorption can happen only after an unhealthy trove's LTV has exceeded the LTV at which the maximum possible penalty is reached, or if its threshold exceeds 90% LTV and its LTV has exceeded the threshold
#7 - alex-ppg
2024-02-27T17:34:59Z
The Sponsor has clarified that the documentation was incorrect and that the code behaves as expected, however, per C4 standards I will accept this submission as valid given that the documentation serves as the source of truth for the Wardens to validate.
#8 - MarioPoneder
2024-02-27T19:55:02Z
Dear Judge & Sponsor,
First of all, thanks for keeping the issue valid due to the source of truth consideration. I can confirm that's how we handle such cases on C4 and the present case serves as good exmaple of fair judging.
Anyways, I still want to provide further insights about this since it might be relevant for the sponsor.
According to the sponsor's update:
Absorption can happen only after an unhealthy trove's LTV has exceeded the LTV at which the maximum possible penalty is reached, or if its threshold exceeds 90% LTV and its LTV has exceeded the threshold
The following would be true:
This is contradictory and the discrepancy starts arising for thresholds > 83% effectively creating an "absorption gap", see main report and graph.
As far as I understood the mechanics of the protocol, the initially documented LTV criteria of absorption seemed to be the most reasonable while the code is subject to the above discrepancy.
Therefore, I still recommend to go with the implementation according to the main report's mitigation measures.
For anyone wanting to play around with threshold vs. LTV vs. max. penalty, I've created this graph which is based on the sponsor's initial graph from the docs.
I hope I could provide further insights and value!
Kind regards,
0xTheC0der
#9 - tserg
2024-02-28T07:17:57Z
To give some context, the alternative condition of "if its threshold exceeds 90% LTV and its LTV has exceeded the threshold" for absorption was added because:
IMO, the "absorption gap" for thresholds > 83%, while conceptually contradictory, is acceptable because searcher liquidations is already available once LTV > threshold. Of course, the contradiction is more jarring the closer we get to 90% e.g. 88% threshold as you pointed, but a line has to be drawn somewhere and 90% is a convenient round figure.
#10 - c4-sponsor
2024-03-07T15:59:58Z
milancermak (sponsor) confirmed
🌟 Selected for report: 0xTheC0der
726.486 USDC - $726.49
troves_count
troves_count
The abbot::open_trove(...) method allows to open empty troves (with 0 collateral).
Impacts:
troves_count: u64
(very unlikely: DoS due to u64_add Overflow
)PoC:
The test case below demonstrates that an empty trove can be opened.
Add the test case to src/tests/abbot/test_abbot.cairo
and run it with snforge test test_open_empty_trove_pass
.
#[test] fn test_open_empty_trove_pass() { let (shrine, _, abbot, yangs, gates) = abbot_utils::abbot_deploy( Option::None, Option::None, Option::None, Option::None, Option::None ); let mut spy = spy_events(SpyOn::One(abbot.contract_address)); // Deploying the first trove let trove_owner: ContractAddress = common::trove1_owner_addr(); let forge_amt: Wad = 0_u128.into(); //common::fund_user(trove_owner, yangs, abbot_utils::initial_asset_amts()); let asset_amts: Array<u128> = array![0_u128, 0_u128]; let deposited_amts: Span<u128> = asset_amts.span(); let trove_id: u64 = common::open_trove_helper(abbot, trove_owner, yangs, deposited_amts, gates, forge_amt); // Check trove ID let expected_trove_id: u64 = 1; assert(trove_id == expected_trove_id, 'wrong trove ID'); assert( abbot.get_trove_owner(expected_trove_id).expect('should not be zero') == trove_owner, 'wrong trove owner' ); assert(abbot.get_troves_count() == expected_trove_id, 'wrong troves count'); let mut expected_user_trove_ids: Array<u64> = array![expected_trove_id]; assert(abbot.get_user_trove_ids(trove_owner) == expected_user_trove_ids.span(), 'wrong user trove ids'); }
Recommendation:
Require a minumum deposit value when opening a trove.
The protocol only provides functionality to add yangs, but there is no corresponding method for removal.
Furthermore, the protocol allows to suspend yangs, but even if a yang is permanently suspened it cannot be removed.
However, all yangs ever added (not matter if suspended) are iterated by the seer
in order to query the orcale(s) for the underlying assets' prices.
Consider the following scenario:
A collateral asset is hacked and/or there is a scandal about the project team (for example: Terra Luna). Therefore, the asset crashes. It gets delisted from major exchanges and suspended from Opus.
Impacts:
0
(due to lack of data sources or the price is actually zero).shrine
reverts due to an assertion in case of 0
price provided by the seer
.Recommendation:
Allow the protocol's admin to remove yangs or at least exclude them from price updates.
Furthmore, the 0
price assertion is dangerous, even during normal operation. Zero prices should be handled in a graceful way, especially in case of suspended assets, in order to avoid freezing the whole protocol.
According to the documentation:
The Absorber supports distribution of whitelisted rewards. The only requirement is that the vesting contracts adhere to the
Blesser
interface. Caution should be exercised (e.g. checking for non-standard behavior like blacklists) when whitelisting a reward token to ensure that a failure to vest reward tokens does not cause an absorption to revert.
For this purpose, the absorber::set_reward(asset, blesser, is_active) method has an is_active
flag to deactivate a reward asset
.
This flag is also correctly taken into account in absorber::bestow() which is called by absorber::update(...) on absorption in order to avoid permanent absorption DoS on failing reward tokens.
However, the is_active
flag is not taken in account in absorber::get_provider_rewards(...) called by absorber::get_absorbed_and_rewarded_assets_for_provider() which is further called by absorber::reap_helper(...) on absorber::provide(...), absorber::remove(...) and absorber::reap(...).
Impacts:
The methods absorber::provide(...), absorber::remove(...) and absorber::reap(...) are subject to DoS in case a single reward asset becomes faulty (reverts on transfer). Deactivating the asset via is_active = false
cannot avoid this issue in the current implementation.
Recommendation:
Skip inactive reward assets within absorber::get_provider_rewards(...):
diff --git a/src/core/absorber.cairo b/src/core/absorber.cairo index 473275f..710075f 100644 --- a/src/core/absorber.cairo +++ b/src/core/absorber.cairo @@ -997,6 +997,11 @@ mod absorber { } let reward: Reward = self.rewards.read(current_rewards_id); + if !reward.is_active { + current_rewards_id += 1; + continue; + } + let mut reward_amt: u128 = 0; let mut epoch: u32 = provision.epoch; let mut epoch_shares: Wad = provision.shares;
According to the documentation:
As the Gate is an internal-facing module, users will not be able to, and are not expected to, interact with the Gate directly.
Users are expected to handle their troves using the abbot
module which indirectly interacts with the gate
modules for each asset via the sentinel
module:
However, a user still needs to know the address of a respective gate
and give token approval to the gate
instead of the abbot
module, because the gate
pulls the assets from the user on deposit:
fn enter(ref self: ContractState, user: ContractAddress, trove_id: u64, asset_amt: u128) -> Wad { ... let success: bool = self.asset.read().transfer_from(user, get_contract_address(), asset_amt.into()); assert(success, 'GA: Asset transfer failed'); ... }
Impacts:
Complicated UX that partially counters the documentation.
Recommendation:
The user should only have to give token approvals to the abbot
module which pulls the assets from the user and subsequently forwards them to the respective gate
.
The following TODO is still unresolved:
// TODO: when possible in Cairo, fetch_price should be wrapped // in a try-catch block so that an exception does not // prevent all other price updates
Impacts:
It's entirely possible that an oracle pice data query fails/reverts/panics with an exception which causes the whole price update transaction to fail. This causes DoS for all price updates of the protocol, consequently freezing the whole protocol.
Recommendation:
Make sure an oracle's mehods are declared with the nopanic notation, otherwise handle potential errors gracefully with the match syntax instead of directly expecting return data.
The protocol currently allows thresholds up to 100% (see MAX_THRESHOLD) for collateral assets. This is unhealthy because liquidations/absorptions can only happen if ltv > threshold
. Furthermore, the liquidation penalty at ltv >= 100%
is zero, i.e. there is no liquidation incentive and consequently troves with such high thresholds can effectively only be absorbed.
Recommendation: Set the MAX_THRESHOLD
to 90% in order to facilitate profitable searcher liquidations in any case.
The current implementation of the flash_mint module only has methods in snake_case. However, the ERC-3156 specification requires the lenders's and receiver's methods in camelCase which could lead to severe interoperability problems.
Recommendation: Additonally add the camelCase implementation which forwards to the original methods. For reference, see the Shrine's Yin ERC-20 implementation where camelCase support was already implemented.
If a user requests to remove yin from the Absorber whithin the REQUEST_COOLDOWN
period, the timelock
for removal multiplies.
This makes sense in case the user immediately requests again after a yin removal. However, the timelock
is also multiplied in case the user accidentally requests again before a removal, which is simply unfair.
Recommendation:
Only increase the timelock
on request within REQUEST_COOLDOWN
after a removal:
diff --git a/src/core/absorber.cairo b/src/core/absorber.cairo index 473275f..ab4ba07 100644 --- a/src/core/absorber.cairo +++ b/src/core/absorber.cairo @@ -447,7 +447,12 @@ mod absorber { let mut timelock: u64 = REQUEST_BASE_TIMELOCK; if request.timestamp + REQUEST_COOLDOWN > current_timestamp { - timelock = request.timelock * REQUEST_TIMELOCK_MULTIPLIER; + if request.has_removed { + timelock = request.timelock * REQUEST_TIMELOCK_MULTIPLIER; + } + else { + timelock = request.timelock; + } } let capped_timelock: u64 = min(timelock, REQUEST_MAX_TIMELOCK);
Although the fee for yin flash loans is currently zero (FLASH_FEE = 0
), it should still be accounted for when withdrawing the loan amount
from the borrower, just to make sure this step is not forgotten once the flash fee becomes non-zero in the future.
diff --git a/src/core/flash_mint.cairo b/src/core/flash_mint.cairo index d2114d8..97c21d9 100644 --- a/src/core/flash_mint.cairo +++ b/src/core/flash_mint.cairo @@ -138,7 +138,7 @@ mod flash_mint { assert(borrower_resp == ON_FLASH_MINT_SUCCESS, 'FM: on_flash_loan failed'); // This function in Shrine takes care of balance validation - shrine.eject(receiver, amount_wad); + shrine.eject(receiver, amount_wad + FLASH_FEE.try_into().unwrap()); if adjust_ceiling { shrine.set_debt_ceiling(ceiling);
When compiling the protocol's modules via Scarb v2.4.0 as suggested by the README
, the following warning arises:
This version is not yet supported on Starknet! If you want to develop contracts deployable to current Starknet, please stick with Scarb v2.3.1
This info might already be obsolete but definitely deserves a second look to be sure.
According to the protocol's graph for liquidation penalty, it becomes evident that the MAX_PENALTY
can only be achieved for ltv
up to 89%
. For higher ltv
the penalty sinks again towards zero at ltv = 100%
. Therefore, the searcher liquidation incentives are getting lower for ltv >= 89%
.
As a consequence, it would be more ideal to set the ABSORPTION_THRESHOLD to 89%
instead of currently 90%
.
According to Starkscan, the desired collateral tokens WBTC, ETH and wstETH (mainnet addresses from Starknet documentation) do not panic on 0
value transfers which is a good thing.
However, there might be other collateral tokens added in the future which show such behaviour. Therefore, explicitly check for and skip 0
value token transfers throughout the protocol.
According to the liquidation penalty documentation:
s
is a scalar that is introduced to control how quickly the absorption penalty reaches the maximum possible penalty forthresholdsLTV at or greater than 90%. This lets the protocol control how much to incentivize users to callabsorb
depending on how quick and/or desirable absorptions are for the protocol.
For reference, see key functions documentation:
Absorption can happen only after an unhealthy trove's LTV has exceeded the LTV at which the maximum possible penalty is reached, or if it has exceeded 90% LTV. The liquidation penalty in this case will similarly be capped to the maximum of 12.5% or the maximum possible penalty.
The following inline comment is wrong because it contradicts the context
// If the threshold is below the given minimum, we automatically
// return theminimummaximum penalty to avoid division by zero/overflow, or the largest possible penalty,
// whichever is smaller.
and is not in line with the subsequent code:
if ltv >= MIN_THRESHOLD_FOR_PENALTY_CALCS.into() { return Option::Some(min(MAX_PENALTY.into(), (RAY_ONE.into() - ltv) / ltv)); }
#0 - c4-pre-sort
2024-02-09T17:00:28Z
bytes032 marked the issue as sufficient quality report
#1 - tserg
2024-02-21T03:46:18Z
Good quality report.
#2 - c4-judge
2024-02-26T18:05:54Z
alex-ppg marked the issue as grade-a
#3 - c4-judge
2024-02-26T18:05:57Z
alex-ppg marked the issue as selected for report
#4 - alex-ppg
2024-02-26T18:08:05Z
This QA report was selected as the best given that it had almost zero false positives, is well-formatted, and details multiple things of interest to the Sponsor.
To note, the following findings are "OOS":
README
itself specifies that the compiler version the warning arises from should be utilized#5 - alex-ppg
2024-03-01T16:55:28Z
As a follow-up to my previous comment, I will clarify that:
0
value transfersApart from these, everything else aligns with how I would judge these issues.
#6 - thebrittfactor
2024-03-06T17:37:48Z
Just a note that C4 is excluding the invalid/OOS entries from the official report.