#35758 [SC-Critical] Loss of yield to the protocol due to incorrect interest rate applied

Submitted on Oct 6th 2024 at 21:02:19 UTC by @SeveritySquad for IOP | Swaylend

  • Report ID: #35758

  • Report Type: Smart Contract

  • Report severity: Critical

  • Target: https://github.com/Swaylend/swaylend-monorepo/blob/develop/contracts/market/src/main.sw

  • Impacts:

    • Theft of unclaimed yield

    • Permanent freezing of unclaimed yield

    • Loss of yield

Description

Brief/Intro

When a liquidator wants to liquidate a position he calls the `absorb()` method. In that method, the position of a given user is checked if it is liquidatable. This check is done however erroneously with Supply Rate rather than Borrow Rate. This prevents the liquidation to happen until the collateral value drops in value more, hence when it can finally be liquidated, the yield that the protocol earns from buying the collateral is less than it should.

Vulnerability Details

The problem lies in the `is_liquidatable_internal()` function, in how the present value is calculated here: ``` let present: u256 = present_value(principal.wrapping_neg()).try_into().unwrap(); ``` The supplied `principal` value to the `present_value()` is turned from negative to positive. If the value is positive then inside `present_value` the Supply Rate is applied instead of Borrow Rate: ``` fn present_value(principal: I256) -> I256 { let market_basic = storage.market_basic.read(); if principal >= I256::zero() { let present_value = present_value_supply( market_basic .base_supply_index, principal .try_into() .unwrap(), ); I256::try_from(present_value).unwrap() } else { let present_value = present_value_borrow( market_basic .base_borrow_index, principal .wrapping_neg() .try_into() .unwrap(), ); I256::neg_try_from(present_value).unwrap() } } ``` And Supply Rate is always smaller than Borrow Rate. This means that the liquidatee `present` value is lower when compared against the collateral value, hence the collateral must drop in value even more to reach the liquidation threshold.

Impact Details

Due to lower rate (Supply Rate instead of Borrow Rate) applied to calculate the `present` value of a position the liquidation can happen only when the collateral drops more in price. The result is that the amount that the protocol receives from liquidation is lower than it should according to the Borrow Rate. The difference is lost to the protocol and the lenders in terms of yield that is not obtained, hence we chose the impact to be High according the Impacts in Scope.

Solution Proposal

The Present value should be calculated as it is done in `is_borrow_collateralized()`. The following line: ``` let present: u256 = present_value(principal.wrapping_neg()).try_into().unwrap(); ``` should be changed to: ``` let present = present_value(principal); ``` and the following line: ``` let borrow_amount = present * base_token_price / base_token_price_scale; ``` to: ``` let borrow_amount = u256::try_from(present.wrapping_neg()).unwrap() * base_token_price / base_token_price_scale; ```

References

Problematic line: https://github.com/Swaylend/swaylend-monorepo/blob/d7fec5cd27bafa4b0d04a0690e71a2751fb66979/contracts/market/src/main.sw#L1379

Proof of Concept

Proof of Concept

The PoC: ``` #[tokio::test] async fn poc_not_liquidatable() { let TestData { wallets, alice, alice_account, bob, bob_account, chad, market, assets, usdc, eth, oracle, price_feed_ids, publish_time, prices, usdc_contract, .. } = setup().await;

let price_data_update = PriceDataUpdate {
    update_fee: 0,
    price_feed_ids,
    publish_times: vec![publish_time; assets.len()],
    update_data: oracle.create_update_data(&prices).await.unwrap(),
};

// =================================================
// ==================== Step #0 ====================
// 👛 Wallet: Alice 🧛
// 🤙 Call: supply_base
// 💰 Amount: 3000.00 USDC
let alice_supply_amount = parse_units(3000 * AMOUNT_COEFFICIENT, usdc.decimals);
let alice_mint_amount = parse_units(4000 * AMOUNT_COEFFICIENT, usdc.decimals);
let alice_supply_log_amount = format!("{} USDC", alice_supply_amount as f64 / SCALE_6);
print_case_title(0, "Alice", "supply_base", alice_supply_log_amount.as_str());
println!("💸 Alice + {alice_supply_log_amount}");
usdc_contract
    .mint(alice_account, alice_mint_amount)
    .await
    .unwrap();
let balance = alice.get_asset_balance(&usdc.asset_id).await.unwrap();
assert!(balance == alice_mint_amount);

let alice_supply_res = market
    .with_account(&alice)
    .await
    .unwrap()
    .supply_base(usdc.asset_id, alice_supply_amount)
    .await;
assert!(alice_supply_res.is_ok());

market.debug_increment_timestamp().await.unwrap();
// =================================================
// ==================== Step #1 ====================
// 👛 Wallet: Bob 🧛
// 🤙 Call: supply_collateral
// 💰 Amount: 1.00 ETH
let bob_supply_amount = parse_units(1 * AMOUNT_COEFFICIENT, eth.decimals);
let bob_supply_res = market
    .with_account(&bob)
    .await
    .unwrap()
    .supply_collateral(eth.asset_id, bob_supply_amount)
    .await;
assert!(bob_supply_res.is_ok());

let bob_user_collateral = market
    .get_user_collateral(bob_account, eth.asset_id)
    .await
    .unwrap()
    .value;
assert!(bob_user_collateral == bob_supply_amount);

market
    .print_debug_state(&wallets, &usdc, &eth)
    .await
    .unwrap();

market.debug_increment_timestamp().await.unwrap();
// =================================================
// ==================== Step #2 ====================
// 👛 Wallet: Bob 🧛
// 🤙 Call: withdraw_base
// 💰 Amount: <MAX HE CAN BORROW>
let max_borrow_amount = market
    .available_to_borrow(&[&oracle.instance], bob_account)
    .await
    .unwrap();
let log_amount = format!("{} USDC", max_borrow_amount as f64 / SCALE_6);
print_case_title(2, "Bob", "withdraw_base", &log_amount.as_str());
let bob_borrow_res = market
    .with_account(&bob)
    .await
    .unwrap()
    .withdraw_base(
        &[&oracle.instance],
        max_borrow_amount.try_into().unwrap(),
        &price_data_update,
    )
    .await;
assert!(bob_borrow_res.is_ok());

let balance = bob.get_asset_balance(&usdc.asset_id).await.unwrap();
assert!(balance == max_borrow_amount as u64);
market
    .print_debug_state(&wallets, &usdc, &eth)
    .await
    .unwrap();

// =================================================
// ==================== Step #3 ====================
// 👛 Wallet: Admin 🗿
// 🤙 Drop of ETH price
// 💰 Amount: -50%
print_case_title(3, "Admin", "Drop of ETH price", "-50%");
let res = oracle.price(eth.price_feed_id).await.unwrap().value;
let new_price = (res.price as f64 * 0.99) as u64;
let prices = Vec::from([(
    eth.price_feed_id,
    (
        new_price,
        eth.price_feed_decimals,
        res.publish_time,
        res.confidence,
    ),
)]);

oracle.update_prices(&prices).await.unwrap();

let price_data_update = PriceDataUpdate {
    update_fee: 0,
    price_feed_ids: vec![eth.price_feed_id],
    publish_times: vec![tai64::Tai64::from_unix(Utc::now().timestamp().try_into().unwrap()).0],
    update_data: oracle.create_update_data(&prices).await.unwrap(),
};

println!(
    "🔻 ETH price drops: ${}  -> ${}",
    res.price as f64 / 10_u64.pow(eth.price_feed_decimals) as f64,
    new_price as f64 / 10_u64.pow(eth.price_feed_decimals) as f64
);
let res = oracle.price(eth.price_feed_id).await.unwrap().value;
assert!(new_price == res.price);

market
    .print_debug_state(&wallets, &usdc, &eth)
    .await
    .unwrap();
// =================================================
// ==================== Step #4 ====================
// 👛 Wallet: Chad 🧛
// 🤙 Call: absorb
// 🔥 Target: Bob
print_case_title(4, "Chad", "absorb", "Bob");

assert!(
    market
        .is_liquidatable(&[&oracle.instance], bob_account)
        .await
        .unwrap()
        .value
);

let chad_absorb_bob_res = market
    .with_account(&chad)
    .await
    .unwrap()
    .absorb(&[&oracle.instance], vec![bob_account], &price_data_update)
    .await;
assert!(chad_absorb_bob_res.is_ok());

// Check if absorb was ok
let (_, borrow) = market.get_user_supply_borrow(bob_account).await.unwrap();
assert!(borrow == 0);

let amount = market
    .get_user_collateral(bob_account, eth.asset_id)
    .await
    .unwrap()
    .value;
assert!(amount == 0);

market
    .print_debug_state(&wallets, &usdc, &eth)
    .await
    .unwrap();

// =================================================
// ==================== Step #5 ====================
// 👛 Wallet: Alice 🧛
// 🤙 Call: buy_collateral
// 💰 Amount: <MAX HE CAN BUY>
let reserves = market
    .with_account(&alice)
    .await
    .unwrap()
    .get_collateral_reserves(eth.asset_id)
    .await
    .unwrap()
    .value;
assert!(!is_i256_negative(&reserves));

let amount = market
    .collateral_value_to_sell(
        &[&oracle.instance],
        eth.asset_id,
        convert_i256_to_u64(&reserves),
    )
    .await
    .unwrap()
    .value;

let log_amount = format!("{} USDC", amount as f64 / SCALE_6);
print_case_title(5, "Alice", "buy_collateral", log_amount.as_str());

// Prepare calls for multi_call_handler
let tx_policies = TxPolicies::default().with_script_gas_limit(1_000_000);

// Params for update_price_feeds_if_necessary
let call_params_update_price =
    CallParameters::default().with_amount(price_data_update.update_fee);

// Update price feeds if necessary
let update_balance_call = market
    .instance
    .methods()
    .update_price_feeds_if_necessary(price_data_update.clone())
    .with_contracts(&[&oracle.instance])
    .with_tx_policies(tx_policies)
    .call_params(call_params_update_price)
    .unwrap();

// Params for buy_collateral
let call_params_base_asset = CallParameters::default()
    .with_amount(amount as u64)
    .with_asset_id(usdc.asset_id);

// Buy collateral with base asset
usdc_contract
    .mint(alice_account, amount.try_into().unwrap())
    .await
    .unwrap();

let buy_collateral_call = market
    .instance
    .methods()
    .buy_collateral(eth.asset_id, 1u64.into(), alice_account)
    .with_contracts(&[&oracle.instance])
    .with_tx_policies(tx_policies)
    .call_params(call_params_base_asset)
    .unwrap();

let mutli_call_handler = CallHandler::new_multi_call(alice.clone())
    .add_call(update_balance_call)
    .add_call(buy_collateral_call)
    .with_variable_output_policy(VariableOutputPolicy::Exactly(2));

// Sumbit tx
let submitted_tx = mutli_call_handler.submit().await.unwrap();

// Wait for response
let _: CallResponse<((), ())> = submitted_tx.response().await.unwrap();
let alice_balance = alice.get_asset_balance(&eth.asset_id).await.unwrap();
assert!(alice_balance == 10_999_999_997 * AMOUNT_COEFFICIENT);

// check reserves
let reserves = market
    .with_account(&alice)
    .await
    .unwrap()
    .get_collateral_reserves(eth.asset_id)
    .await
    .unwrap()
    .value;
let normalized_reserves: u64 = convert_i256_to_i128(&reserves).try_into().unwrap();
assert!(normalized_reserves == 0);

market
    .print_debug_state(&wallets, &usdc, &eth)
    .await
    .unwrap();

} ``` We can see that the test fails at: ``` failures: local_tests::scenarios::severity_squad_pocs::poc_not_liquidatable ``` because the position is not liquidatable.