#47309 [SC-Medium] Type mishandling allows for users to withdraw FAST from vault instead of STANDARD

Submitted on Jun 12th 2025 at 14:43:17 UTC by @Kalogerone for IOP | Paradex

  • Report ID: #47309

  • Report Type: Smart Contract

  • Report severity: Medium

  • Target: https://github.com/tradeparadex/audit-competition-may-2025/tree/main/paraclear

  • Impacts:

    • Protocol insolvency

Description

Brief/Intro

The Paraclear contract implements the get_account_free_balance(...) function which returns an account's free balance (available balance to open new positions). This value can be negative, especially when an account approaches liquidation threshold. However, the get_account_free_balance(...) function converts the integer amount to felt, which is always positive.

Vulnerability Details

This is the get_account_free_balance(...) implementation:

        fn get_account_free_balance(self: @ContractState, account: ContractAddress) -> felt252 {
            let account_state = self._load_account_v2(account);
            account_state.free_balance().into()
        }

We can notice that it returns a felt252 type and it calls free_balance() which returns i128 type:

        fn free_balance(self: @AccountState) -> i128 {
            self.account_value() - self.margin_requirement(MARGIN_CHECK_INITIAL)
        }

If a negative value is returned from free_balance(), the way that cairo handles this conversion, the get_account_free_balance(...) function will return the felt252 max - the negative number.

This function is used by the Vaults and this type mishandling can mess up the withdrawals.

        fn request_withdrawal(ref self: ContractState, shares: u256) {
            let registry_dispatcher = self._get_registry_from_factory();
            let restricted = registry_dispatcher.get_all_vault_withdrawals_paused();
            assert(!restricted, Errors::WITHDRAWALS_RESTRICTED);
			...

            let withdrawal_mode = self.withdrawal_mode.read();
            if withdrawal_mode == WITHDRAWAL_MODE_FAST
                && sub_operators.len() > 0
                && status != VaultStatus::Closed {
                let assets_to_withdraw = self._convert_to_assets(shares_after_profit_share);
@>              let paraclear_free_balance = paraclear_dispatcher
                    .get_account_free_balance(assets_holder);

                let asset = self.asset();
                let erc20_disp = ERC20ABIDispatcher { contract_address: asset };
                let asset_decimals = erc20_disp.decimals();
                let paraclear_decimals: u8 = paraclear_dispatcher.decimals().try_into().unwrap();
                let decimals_diff = paraclear_decimals - asset_decimals;
                let multiplier = MATH::pow(10, decimals_diff.into());
                let assets_paraclear = assets_to_withdraw * multiplier;

@>              if assets_paraclear == 0 || assets_paraclear > paraclear_free_balance.into() {
                    // available assets are less than requested, let's switch to standard mode
                    applied_withdrawal_mode = WITHDRAWAL_MODE_STANDARD;
                } else {
                    applied_withdrawal_mode = WITHDRAWAL_MODE_FAST;
                    self
                        ._grant_transfer_authorization(
                            assets_holder, auxiliary_account, vault_fraction_to_withdraw_paraclear,
                        );
                    // We are passing vault_fraction_to_withdraw_paraclear to grant the transfer,
                    // but given we are passing assets_paraclear the function will transfer
                    // collateral only
                    let partial_transfer = paraclear_dispatcher
                        .account_transfer_partial(
                            assets_holder,
                            auxiliary_account,
                            vault_fraction_to_withdraw_paraclear,
                            assets_paraclear.try_into().unwrap(),
                        );
                    self._revoke_transfer_authorization(assets_holder, auxiliary_account);
                    assert(partial_transfer == 1, Errors::TRANSFER_FAILED);
                }
            }
			...
        }

As we can see, during withdrawal the vault checks the free balance of the assets holder. If the account has a negative free balance, the get_account_free_balance(...) function will return a huge positive value. This will result in this check to fail if assets_paraclear == 0 || assets_paraclear > paraclear_free_balance.into() (since paraclear_free_balance will be a huge number) and the withdrawal mode will be set to WITHDRAWAL_MODE_FAST, which is not intended. Withdrawing from an account with negative free balance can get the account closer to liquidation.

The transaction will succeed since the account_transfer_partial(...) checks that the excess balance is greater than 0.

Impact Details

Users will be able to withdraw fast from a vault with no free balance and possibly get it closer to liquidation.

References

https://book.cairo-lang.org/ch02-02-data-types.html#felt-type

https://github.com/tradeparadex/audit-competition-may-2025/blob/main/vaults/src/vault/vault.cairo

https://github.com/tradeparadex/audit-competition-may-2025/blob/main/paraclear/src/paraclear/paraclear.cairo#L839

Proof of Concept

Proof of Concept

Using the following test we can see in the console that:

Original i128: -42
Converted felt252: 3618502788666131213697322783095070105623107215331596699973092056135872020439
#[test]
fn i128_to_felt252() {
    // Set an i128 variable to a negative value
    let negative_i128: i128 = -42_i128;

    // Verify it's negative
    assert!(negative_i128 < 0, "i128 should be negative");

    // Convert to felt252
    let as_felt252: felt252 = negative_i128.into();

    println!("Original i128: {}", negative_i128);
    println!("Converted felt252: {}", as_felt252);
}

Was this helpful?