Quantstamp reviewed Parity Finance Liquid Staking & Early Purchase on 3 Jun 2025: 32 findings, 0 critical; 22 marked fixed, 10 open.
Report hosted on certificate.quantstamp.com.
Trustblock does not review code.
Statuses are the security firm’s own, from its report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 6 |
| Medium | 2 | 0 | 7 |
| Low | 8 | 0 | 9 |
The `liquid_staking::restake_expired_withdrawal::handler()` function restakes the expired withdrawals by minting LST tokens back to the stakers. However, it does not transfer the relevant user's base tokens (funded by the depositor) from the `window_base_token_account` to the `pair_base_token_account`. Therefore, these base tokens will remain locked in the `window_base_token_account`, preventing the closing of the `withdrawal_window` in the `liquid_staking::close_withdrawal_window::handler()` function.
During the token purchase phase in the `early-purchase` program, buyers transfer their payments (either SPL tokens to the `sale_payment_ata` or SOL directly to the Sale PDA). The sale organizer (acting through a Guardian) deposits the `sale.purchase_mint` tokens for distribution after the sale ends. However, the program lacks a mechanism to transfer the collected buyer payments from the `Sale` PDA and ATA accounts to the sale organizer or a designated treasury. It is also noted that the address of the user initiating the sale is not saved in the `Sale` PDA, and only guardians with `deposit_tokens` permission can deposit the `sale.purchase_mint` tokens.
The `early_purchase::deposit_tokens::handler()` function is intended to enable a Guardian to deposit a specified quantity of `purchase_mint` tokens (`params.amount_to_deposit`) into the sale token vault `sale_purchase_ata`. However, the `token::transfer` CPI within this instruction incorrectly uses `sale.calculate_base_purchase_cost(amount_to_deposit)` as the amount argument for the transfer instead of the `amount_to_deposit`, resulting in a significantly different value: ```rust token::transfer( CpiContext::new( purchase_program.to_account_info(), token::Transfer { from: authority_purchase_ata.to_account_info(), to: sale_purchase_ata.to_account_info(), authority: authority.to_account_info(), }, ), sale.calculate_base_purchase_cost(amount_to_deposit), ) ```
The `early_purchase::redeem_receipt::handler()` function is responsible for transferring the `purchase_mint` tokens from the sale's ATA (`config_purchase_ata`) to the buyer's ATA (`buyer_purchase_ata`) during sale receipt redemption. However, the `token::transfer` CPI is incorrectly configured with the `buyer` as the authority for this transfer: ```rust token::transfer( CpiContext::new( purchase_program.to_account_info(), token::Transfer { from: config_purchase_ata.to_account_info(), to: buyer_purchase_ata.to_account_info(), authority: buyer.to_account_info(), }, ), num_tokens_pending, ) ```
The Early Purchase program appears to be completly missing a test suite. Contracts that are not extensively tested are at high risk of serious bugs that can impact the protocol's proper functioning and security. While audits can help developers to find bugs, they are not a substitute for thorough unit and integration testing.
Sale accounts can be created by any user and they are assigned as their admins. Additionally, receipt PDAs are derived with the following seeds at the buyer's first token purchase: ``` seeds = [ Receipt::PREFIX.as_bytes(), &buyer.key.to_bytes() ] ``` This implies that `Receipt` accounts are initialized in a 1:1 relation to buyer accounts, allowing an attacker to create a receipt and purchase an amount of a worthless token and redeem the receipt against a sale that holds actual, legitimate tokens.
The `early_purchase::end_sale::handler()`function is used by a guardian to end a specific sale. However, the this function does not check that the referenced sale end time has passed (`sale.is_end_time_reached()`), allowing guardians to end any sale even before it has officially ended.
The instructions that initialize PDAs in the `early-purchase` program use the `space` parameter (`#[account(init, payer = ..., space = ...)]`) to specify the space needed for account creation. The `space` attribute expects the total space required for the account's data, including an 8-byte discriminator. However, the 8-byte discriminator is not accounted for in the size calculation. If insufficient space is allocated, it could lead to runtime errors during account initialization or data serialization issues later if data attempts to write past the allocated boundary.
When users purchase tokens in the `early-purchase` program using SOL, the current SOL price is determined dynamically at the time of transaction execution using a Pyth Network oracle price feed. However, the current token purchase implementation does not allow users to specify a maximum amount of SOL they are willing to pay for their desired quantity of purchase tokens, only the number of tokens to buy. While the `sale.max_price_feed_age` check ensures the oracle price is not excessively stale, it does not protect against legitimate, recent price volatility that occurs before transaction confirmation. The `sale.purchase_mint` token price (`sale.payment_amount`) may also change without the buyer's knowledge.
The `early_purchase::update_sale::handler()` function permits `Guardians` with `update_sale` permissions to modify critical parameters such as `sale.purchase_mint`, `sale.payment_mint`, `sale.guard_purchases` and so on, even if the `sale.is_start_time_reached()`is `true`. Buyers typically make purchase decisions based on the observed state of the sale. If these parameters can be altered while the sale is active or just moments before a buyer's transaction is processed, a buyer might inadvertently pay a different price (potentially higher) or face different purchasing conditions than they initially expected. The `purchase_tokens` instruction calculates the cost dynamically at the time of execution based on the current `Sale` state. This could lead to user frustration, loss of trust, and scenarios where buyers feel deceived if, for instance, the token price effectively increases due to a mid-sale update.
`Sale` accounts are supposedly freezable by guardians/admins as they have an `is_frozen()` function that checks if `self.state == SaleState::Frozen`, which is called in the `PurchaseTokens` instruction handler. However there is no mechanism allowing authority to freeze and unfreeze sales as no instruction implements such a logic.
A malicious user may transfer a few base tokens directly to a `window_base_token_account` of the `liquid-staking` program, preventing the `window_authority` to closing the ATA because of the following strict check: ```rust require!( window_base_token_account.amount == 0, StakingError::WindowHasActiveRequests ); ```
The `early_purchase::initialize_sale::handler()` function allows any user to create a new `Sale`. However, it does not implement any fee or significant disincentive beyond standard Solana transaction costs and account rent. This open and low-cost sale creation mechanism that can be exploited by malicious actors to spam the platform by creating a large number of sales or create sales with malicious properties.
In `liquid_staking::create_pair::handler()` function and `liquid_staking::update_pair_limits::handler()` are missing the validations that ensure `minimum_deposit < deposit_cap`.
No checks exist to enforce that the signer initializing the config is a specific pre-determined address. This allows anyone to initialize the config and takeover its control.
The `early_purchase::initialize_guardian::handler()` function allows an admin to create a new Guardian account, but provides no mechanism to update the `authority` Pubkey once set. This creates a significant risk if guardian credentials are compromised or need to be rotated. This is because the `update_guardian` instruction only enables updating the `permissions` but not the `authority`. Additionally, the `GuardianPermissions::manage_guardians` permission exists in the codebase but remains unused, suggesting an incomplete implementation of guardian management functionality.
The `liquid_staking::create_pair::handler()` function does not validate the `interval_apr_rate` value, and the current check in the `liquid_staking::update_pair_yield::handler()` only prevents setting the `interval_apr_rate` value to zero. The improper validations allow setting non-positive yield rate values because the rate is adjusted with `PRECISION` (`1e12`) in the exchange rate calculations. The `liquid_staking::create_pair::handler()` function does not limit the maximum value of `interval_apr_rate`, the `MAX_INTERVAL_APR_RATE` is only enforced in the `instructions::update_pair_yield::handler()` function.
The `liquid_staking::swap_lst::handler()` function enables holders of one LST to swap it for a different LST. However the function call on the `destination_pair` that checks if the deposit is too large incorrectly passes zero for the `base_token_amount` field of the function `destination_pair.check_excessive_deposit(quote_amount, base_token_amount)`. Moreover, it lacks a check to validate the the deposit meets the minimum bar via `check_minimum_deposit()`.
The `early_purchase::initialize_sale::handler()` function accepts a `max_price_feed_age` parameter that determines the maximum allowable staleness of price feed data. However, once set, this critical parameter cannot be modified. If an administrator configures this value incorrectly during initialization, there is no mechanism available to correct it, potentially leading to permanent operational issues with price feed validation.
The `liquid_staking::update_pair_limits::handler()`function creates a pair and takes in a `CreatePairParams` struct instance that holds the `stake_fee_bps`, `swap_fee_bps`, and `withdraw_fee_bps` values. The fee percentage BPS values can be set up to 10000, allowing the program to be configured with a 100% fee percentage.
In `liquid_staking::manage_guardian::handler()`function, an `unseal_authority` can add or remove guardians from the `AccessControl` Account. While the `AccessControl` account supports adding up to five guardians for security redundancy, the implementation lacks duplicate checking for each guardian `Pubkey`. This oversight allows the same guardian `Pubkey` to be added multiple times, potentially compromising the intended redundancy if multiple or all five guardian slots contain identical keys.
The `liquid_staking::create_token_metadata::handler()` function does not call `access_control.verify_unsealed()?;`, preventing the guardians from sealing the function in case of emergency.
The report names a repository or files. Open the report to see its scope.
Repository and files the report names (436)
236 projects reviewed · 332 reports · latest Oct 2026
See Quantstamp’s auditsSomething wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us