#47316 [SC-Low] account_transfer_partial(...) function doesn't check that receiver has a registered account in the system
Submitted on Jun 12th 2025 at 14:50:36 UTC by @Kalogerone for IOP | Paradex
Report ID: #47316
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/tradeparadex/audit-competition-may-2025/tree/main/paraclear
Impacts:
Protocol insolvency
Description
Brief/Intro
The Paraclear contract implements the account_transfer_partial(...) function that allows partial or full transfers of positions or collateral between accounts. However, there is no check that the recipient address has an account in the system.
Vulnerability Details
This is the implementation of the function:
/// Transfers a portion of an account's positions and collateral to another account
/// @param account The account to transfer positions from
/// @param receiver The account to transfer positions to
/// @param account_share The percentage of positions to transfer (between 0 and 1 in fixed
/// point format)
/// @return felt252 1 if successful
fn account_transfer_partial(
ref self: ContractState,
account: ContractAddress,
receiver: ContractAddress,
account_share: felt252,
amount_collateral: felt252,
) -> felt252 {
// Validate account share is between 0 and 1
assert!(
account_share.try_into().unwrap() > 0_i128
&& account_share.try_into().unwrap() <= ONE,
"AccountTransfer: account_share must be within [1,100000000]",
);
// detect transfer restriction
self
.token
._detect_account_transfer_restriction(account, receiver, account_share.into());
// Load account state and verify account is healthy
let account_state = self._load_account_v2(account);
let excess_balance = account_state.excess_balance(MARGIN_CHECK_MAINTENANCE);
assert!(excess_balance >= 0_i128, "AccountTransfer: account must be healthy");
// Get account value and settlement token info
let account_value = account_state.account_value();
// Standard transfer mode, % of both collateral and positions
if amount_collateral == 0 {
// Transfer each perpetual position
self._transfer_positions_internal(account_state, receiver, account_share, 0);
// Transfer proportional collateral
let token_transfer_share = mul_128(
account_value, account_share.try_into().unwrap(),
);
let token_transfer = div_128(
token_transfer_share, account_state.asset_data.settlement_token_price,
);
self
.token
.transfer_internal(
account, receiver, self.getSettlementTokenAsset(), token_transfer.into(), 1,
);
// Fast transfer mode, collateral only
} else {
self
.token
.transfer_internal(
account, receiver, self.getSettlementTokenAsset(), amount_collateral, 1,
);
}
// Emit transfer event
self
.emit(
AccountComponent::Event::AccountTransfer(
AccountTransfer {
account: account, receiver: receiver, share: account_share,
},
),
);
return 1;
}As we can see, there is no check that the recipient address has an account in the system using the _add_new_account_if_not_exists(...) function. This should follow the same design as the transfer(...) function that correctly checks for it:
fn transfer(
ref self: ContractState,
recipient: ContractAddress,
token_address: ContractAddress,
amount: felt252,
) -> felt252 {
let sender = get_caller_address();
@> self.account._add_new_account_if_not_exists(recipient);
self.token._transfer(sender, recipient, token_address, amount);
let account_state = self._load_account_v2(sender);
let free_balance: i128 = account_state.free_balance().try_into().unwrap();
assert!(free_balance >= 0, "Transfer: Sender is unhealthy after transfer");
return 1;
}Impact Details
Having an account with open positions can be very dangerous to the system as the account can operate normally but it won't appear in many accounting tracking functions like getSettlementAssetTotalBalance(...) and some collateral will be missing from this. Also, it's possible that there won't be correct tracking of this account's positions to correctly flag it for liquidation.
References
https://github.com/tradeparadex/audit-competition-may-2025/blob/main/paraclear/src/paraclear/paraclear.cairo#L1477
Proof of Concept
Proof of Concept
Follow this scenario:
Bob uses
account_transfer_partial(...)to transfer some of his positions to his other address which isn't registered in the system.His second address becomes unhealthy with those positions, but since it's not a registered account it doesn't get flagged for liquidation.
Was this helpful?