#37343 [SC-Insight] inaccurate check leading to debt miscalculation
#37343 [SC-Insight] Inaccurate Check Leading to Debt Miscalculation
Submitted on Dec 2nd 2024 at 16:03:30 UTC by @Blockian for IOP | Fluid Protocol
Report ID: #37343
Report Type: Smart Contract
Report severity: Insight
Target: https://github.com/Hydrogen-Labs/fluid-protocol/tree/main/contracts/trove-manager-contract/src/main.sw
Impacts:
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Contract fails to deliver promised returns, but doesn't lose value
Description
Fluid Bug Report
Inaccurate Check Leading to Debt Miscalculation
Overview
An edge case arises during reward application where a user's pending_usdf
is not added to their debt due to an oversight. This miscalculation enables users to withdraw funds without accounting for the additional debt, potentially shifting the burden onto other protocol participants or causing minor fluctuations in the value of USDF due to inaccurate collateral backing.
Deep Dive
The issue stems from the internal_has_pending_rewards
function, which verifies changes in the l_asset
value but overlooks updates to the l_usdf
value. Consequently, in scenarios where l_usdf
changes while l_asset
remains unchanged, the function erroneously returns false. This prevents the internal_apply_pending_rewards
function from updating the pending rewards accurately.
How does l_usdf
change without l_asset
? The internal_redistribute_debt_and_coll
function updates these values based on the latest liquidation data. Here's the critical implementation:
fn internal_redistribute_debt_and_coll(debt: u64, coll: u64) {
// not really interesting
let asset_numerator: U128 = U128::from(coll) * U128::from(DECIMAL_PRECISION) + U128::from(storage.last_asset_error_redistribution.read());
let usdf_numerator: U128 = U128::from(debt) * U128::from(DECIMAL_PRECISION) + U128::from(storage.last_usdf_error_redistribution.read());
let asset_reward_per_unit_staked = asset_numerator / U128::from(storage.total_stakes.read());
let usdf_reward_per_unit_staked = usdf_numerator / U128::from(storage.total_stakes.read());
// not really interesting
storage
.l_asset
.write(storage.l_asset.read() + asset_reward_per_unit_staked.as_u64().unwrap());
storage
.l_usdf
.write(storage.l_usdf.read() + usdf_reward_per_unit_staked.as_u64().unwrap());
// not really interesting
}
In cases where usdf_reward_per_unit_staked != 0
but asset_reward_per_unit_staked == 0
, l_usdf
is updated independently. This disparity can occur due to:
Significant price differences between the asset and USDF.
High
total_stakes
values combined with a higher USDF price compared to the asset.Growth in
last_usdf_error_redistribution
.
During this interim period, pending rewards might need adjustment due to events such as liquidations, withdrawals, or redemptions.
Impact
The issue self-corrects once internal_redistribute_debt_and_coll
is called again, updating l_asset
. However, during the lag:
Users can perform actions (e.g., withdrawals) at a lower debt than intended.
Accumulated inaccuracies in the debt-to-collateral ratio can destabilize the USDF price if enough users act during this window.
Proposed Solution
Modify internal_has_pending_rewards
to also evaluate changes in l_usdf:
fn internal_has_pending_rewards(address: Identity) -> bool {
if (storage.troves.get(address).read().status != Status::Active)
{
return false;
}
return (storage.reward_snapshots.get(address).read().asset < storage.l_asset.read() || storage.reward_snapshots.get(address).read().usdf_debt < storage.l_usdf.read());
}
Proof of Concept
Proof of Concept
Run forc test
after applying the following steps:
Add the following methods to the
trove_manager_interface
:
#[storage(read, write)]
fn test_internal_redistribute_debt_and_coll(debt: u64, coll: u64);
#[storage(read, write)]
fn test_issue_setup(total_stake: u64);
#[storage(read)]
fn test_get_l_values() -> (u64, u64);
Apply the git patch below:
diff --git a/contracts/trove-manager-contract/src/main.sw b/contracts/trove-manager-contract/src/main.sw
index 7e02245..6b93a1e 100644
--- a/contracts/trove-manager-contract/src/main.sw
+++ b/contracts/trove-manager-contract/src/main.sw
@@ -291,6 +291,23 @@ impl TroveManager for Contract {
fn get_pending_asset_rewards(id: Identity) -> u64 {
internal_get_pending_asset_reward(id)
}
+
+ #[storage(read, write)]
+ fn test_internal_redistribute_debt_and_coll(debt: u64, coll: u64) {
+ internal_redistribute_debt_and_coll(debt, coll);
+ }
+
+ #[storage(read, write)]
+ fn test_issue_setup(total_stake: u64) {
+ storage
+ .total_stakes
+ .write(total_stake);
+ }
+
+ #[storage(read)]
+ fn test_get_l_values() -> (u64, u64) {
+ (storage.l_asset.read(), storage.l_usdf.read())
+ }
}
#[storage(read, write)]
fn internal_update_trove_reward_snapshots(id: Identity) {
@@ -676,7 +693,7 @@ fn internal_apply_liquidation(
}
#[storage(read, write)]
fn internal_redistribute_debt_and_coll(debt: u64, coll: u64) {
- let asset_contract_cache = storage.asset_contract.read();
+ // let asset_contract_cache = storage.asset_contract.read();
if (debt == 0) {
return;
}
@@ -704,11 +721,11 @@ fn internal_redistribute_debt_and_coll(debt: u64, coll: u64) {
storage
.l_usdf
.write(storage.l_usdf.read() + usdf_reward_per_unit_staked.as_u64().unwrap());
- let active_pool = abi(ActivePool, storage.active_pool_contract.read().into());
- let default_pool = abi(DefaultPool, storage.default_pool_contract.read().into());
- active_pool.decrease_usdf_debt(debt, asset_contract_cache);
- default_pool.increase_usdf_debt(debt, asset_contract_cache);
- active_pool.send_asset_to_default_pool(coll, asset_contract_cache);
+ // let active_pool = abi(ActivePool, storage.active_pool_contract.read().into());
+ // let default_pool = abi(DefaultPool, storage.default_pool_contract.read().into());
+ // active_pool.decrease_usdf_debt(debt, asset_contract_cache);
+ // default_pool.increase_usdf_debt(debt, asset_contract_cache);
+ // active_pool.send_asset_to_default_pool(coll, asset_contract_cache);
}
#[storage(read, write)]
fn internal_update_stake_and_total_stakes(address: Identity) -> u64 {
@@ -979,3 +996,35 @@ fn internal_update_system_snapshots_exclude_coll_remainder(coll_remainder: u64)
.total_collateral_snapshot
.write(active_pool_coll - coll_remainder + liquidated_coll);
}
+
+#[test]
+fn test_issue_1() {
+ /*
+ for the following price, coll and debt, the ICR will be 100% which makes this theoretical position liquidatable
+ */
+ let price = 3_000_000_000_000;
+ let coll = 1_000_000_000;
+ let debt = 3_000_000_000_000;
+ let usdf_in_stab_pool = 0;
+
+ let needed_total_stake = 996_000_000_000_000_000;
+
+ let caller = abi(TroveManager, CONTRACT_ID);
+
+ caller.test_issue_setup(needed_total_stake);
+
+ let l_before = caller.test_get_l_values();
+ let l_asset_before = l_before.0;
+ let l_usdf_before = l_before.1;
+
+ let mut single_liquidation = LiquidationValues::default();
+ single_liquidation = get_offset_and_redistribution_vals(coll, debt, usdf_in_stab_pool, price);
+ caller.test_internal_redistribute_debt_and_coll(single_liquidation.debt_to_redistribute, single_liquidation.coll_to_redistribute);
+
+ let l_after = caller.test_get_l_values();
+ let l_asset_after = l_after.0;
+ let l_usdf_after = l_after.1;
+
+ assert(l_asset_after == l_asset_before); // no change in l_asset
+ assert(l_usdf_after > l_usdf_before); // there is change in l_usdf
+}
\ No newline at end of file
Last updated
Was this helpful?