Liquid Unstaker - The Vault


Prepared by:

Halborn Logo

HALBORN

Last Updated 06/06/2025

Date of Engagement: January 30th, 2025 - February 7th, 2025

Summary

100% of all REPORTED Findings have been addressed

All findings

4

Critical

0

High

0

Medium

0

Low

1

Informational

3


1. Introduction

The Vault engaged Halborn to conduct a security assessment on their Solana Validator program beginning on January 30th, 2025 and ending on February 7th, 2025. The security assessment was scoped to the smart contracts provided in the GitHub repository liquid-unstaker, commit hashes, and further details can be found in the Scope section of this report.


The Vault team is releasing Liquid Unstaker, a protocol that relies on liquidity providers to supply SOL to a liquidity pool, enabling users to unstake instantly without waiting for the stake deactivation period. In return, liquidity providers earn fees for fronting liquidity and staking rewards.

2. Assessment Summary

Halborn was provided 7 days for the engagement and assigned one full-time security engineer to review the security of the Solana Programs in scope. The engineer is a blockchain and smart contract security expert with advanced smart contract hacking skills, and deep knowledge of multiple blockchain protocols.

The purpose of the assessment is to:

    • Identify potential security issues within the codebase.

    • Verify that the funds of the liquidity providers are safe of any leakage

    • Validate that the platform is not vulnerable to arbitrage

    • Verify that the fees are properly calculated and distributed fairly


In summary, Halborn identified some improvements to reduce the likelihood and impact of multiple risks, which has been addressed by the The Vault team. The main one was the following:

    • Consider the case where an staking account has less lamports than what the user is requiring to withdraw

3. Test Approach and Methodology

Halborn performed a combination of manual review and security testing based on scripts to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:

    • Research into architecture and purpose.

    • Differences analysis using GitLens to have a proper view of the differences between the mentioned commits

    • Graphing out functionality and programs logic/connectivity/functions along with state changes

4. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

4.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILITY METRIC (mem_e)METRIC VALUENUMERICAL VALUE
Attack Origin (AO)Arbitrary (AO:A)
Specific (AO:S)
1
0.2
Attack Cost (AC)Low (AC:L)
Medium (AC:M)
High (AC:H)
1
0.67
0.33
Attack Complexity (AX)Low (AX:L)
Medium (AX:M)
High (AX:H)
1
0.67
0.33
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

4.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL VALUE
Confidentiality (C)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Integrity (I)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Availability (A)None (A:N)
Low (A:L)
Medium (A:M)
High (A:H)
Critical (A:C)
0
0.25
0.5
0.75
1
Deposit (D)None (D:N)
Low (D:L)
Medium (D:M)
High (D:H)
Critical (D:C)
0
0.25
0.5
0.75
1
Yield (Y)None (Y:N)
Low (Y:L)
Medium (Y:M)
High (Y:H)
Critical (Y:C)
0
0.25
0.5
0.75
1
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

4.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

5. SCOPE

Files and Repository
(a) Repository: liquid-unstaker
(b) Assessed Commit ID: be160a8
(c) Items in scope:
  • src/error.rs
  • src/fee.rs
  • src/instructions/create_or_update_token_metadata.rs
↓ Expand ↓
Out-of-Scope: solana-program-library/stake-pool, agave/programs/stake, third party dependencies and economic attacks.
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

0

High

0

Medium

0

Low

1

Informational

3

Security analysisRisk levelRemediation Date
Potential loss of funds when withdrawing from validator removal stake accountsLowSolved - 02/07/2025
Unstaking funds steal in case of manual funds approvalInformationalSolved - 02/07/2025
Incorrect cap validation in deposit_sol entry pointInformationalSolved - 02/10/2025
Unused is_active boolean in StakeAccountInfoInformationalSolved - 02/07/2025

7. Findings & Tech Details

7.1 Potential loss of funds when withdrawing from validator removal stake accounts

//

Low

Description

The liquid_unstake_lst function processes unstaking of liquid staking tokens (LST) by interacting with the stake-pool program. The function handles burning LST tokens and withdrawing SOL from different types of stake accounts, including:

Active stake accounts

Transient stake accounts

Validator removal stake accounts


A potential issue arises when withdrawing from a Validator Removal stake account. If the actual SOL balance in the stake account is lower than expected, the function still burns the full amount of LST tokens, even though the user receives less SOL.


The mention situation can be seen in the snippet below:

program/src/processor.rs

match withdraw_source {
    StakeWithdrawSource::Active | StakeWithdrawSource::Transient => {
        let remaining_lamports = stake_split_from
            .lamports()
            .saturating_sub(withdraw_lamports);
        if remaining_lamports < required_lamports {
            msg!("Attempting to withdraw {} lamports from validator account with {} stake lamports, {} must remain", withdraw_lamports, stake_split_from.lamports(), required_lamports);
            return Err(StakePoolError::StakeLamportsNotEqualToMinimum.into());
        }
    }
    StakeWithdrawSource::ValidatorRemoval => {
        let split_from_lamports = stake_split_from.lamports();
        let upper_bound = split_from_lamports.saturating_add(lamports_per_pool_token);
        if withdraw_lamports < split_from_lamports || withdraw_lamports > upper_bound {
            msg!(
                "Cannot withdraw a whole account worth {} lamports, \
                    must withdraw at least {} lamports worth of pool tokens \
                    with a margin of {} lamports",
                withdraw_lamports,
                split_from_lamports,
                lamports_per_pool_token
            );
            return Err(StakePoolError::StakeLamportsNotEqualToMinimum.into());
        }
        // truncate the lamports down to the amount in the account
        withdraw_lamports = split_from_lamports;
    }
}
Some((validator_stake_info, withdraw_source))
};

Self::token_burn(
    token_program_info.clone(),
    burn_from_pool_info.clone(),
    pool_mint_info.clone(),
    user_transfer_authority_info.clone(),
    pool_tokens_burnt,
)?;

Self::stake_split(
    stake_pool_info.key,
    stake_split_from.clone(),
    withdraw_authority_info.clone(),
    AUTHORITY_WITHDRAW,
    stake_pool.stake_withdraw_bump_seed,
    withdraw_lamports,
    stake_split_to.clone(),
)?;

For this issue to take place, the following conditions must be met:

  1. A validator must be marked for removal from the stake pool. This happens when the stake pool manager decides to remove a validator due to low performance or other reasons.

  2. The validator removal stake account must have a lower SOL balance than expected.


    This can happen due to:

    • Stake rewards withdrawal before processing the unstake request.

    • Unclaimed or missing staking rewards.

  3. A user attempts to unstake their LST by withdrawing from a Validator Removal stake account. This means the function will burn the full amount of LST as if the user was receiving the expected SOL balance.

  4. The function does not verify the actual available SOL before burning LST. Instead of adjusting the burned LST amount based on the actual SOL withdrawn, it burns the full LST amount even if fewer SOL are withdrawn.


This results in users effectively losing the difference between their expected and actual withdrawal amount in terms of LST tokens because:

  1. When withdrawing from a Validator Removal stake account, the function calculates the withdrawal amount based on the entire account balance.

  2. The function does not verify whether the expected SOL balance matches the actual available SOL in the account.

  3. If the actual SOL balance is lower than expected, the function still burns the full amount of LST tokens equivalent to the initially expected SOL withdrawal amount.

  4. Users receive fewer SOL tokens than expected, resulting in an effective loss of their LST stake.

BVSS
Recommendation

Consider calculating the expected SOL equivalent for the LST tokens being burned and compare it with the actual SOL balance in the stake account. If the stake account has less SOL than expected, return an error instead of proceeding with the withdrawal. For instance, considering that the function that calculates the corresponding SOLs for the LSTs burned is called calculate_expected_sol_for_lst :


src/instructions/liquid_unstake_lst.rs

pub fn unstake<'a, 'b, 'c>(
    ctx: Context<'a, 'b, 'c, 'info, LiquidUnstakeLst<'info>>, 
    lst_amounts: [u64;5]
) -> Result<()> 
where 'c: 'info {

    if ctx.remaining_accounts.len() % 3 != 0 {
        return Err(error!(LiquidUnstakerErrorCode::InvalidRemainingAccounts));
    }

    let n_validator_stake_accounts = ctx.remaining_accounts.len() / 3;

    let validator_stake_accounts: &'c [AccountInfo<'_>] = &ctx.remaining_accounts[0..n_validator_stake_accounts];
    let destination_stake_accounts: &'c [AccountInfo<'_>] = &ctx.remaining_accounts[n_validator_stake_accounts..];
    let stake_info_accounts: &'c [AccountInfo<'_>] = &ctx.remaining_accounts[n_validator_stake_accounts * 2..];

    let mut total_amount_unstaked = 0u64;

    for i in 0..n_validator_stake_accounts {

        let expected_lamports = calculate_expected_sol_for_lst(
            validator_stake_accounts[i],
            lst_amounts[i], 
        )?;

        let stake_balance = validator_stake_accounts[i].lamports();
        if stake_balance < expected_lamports {
            return Err(error!(LiquidUnstakerErrorCode::InsufficientStakeBalance));
        }

        // Withdraw from the SPL Stake Pool
        let withdraw_instruction = stake_pool_instruction::withdraw_stake(
            &ctx.accounts.stake_pool_program.key(),
            &ctx.accounts.stake_pool.key(),
            &ctx.accounts.stake_pool_validator_list.key(),
            &ctx.accounts.stake_pool_withdraw_authority.key(),
            &validator_stake_accounts[i].key(),
            &destination_stake_accounts[i].key(),
            &ctx.accounts.pool.key(),
            &ctx.accounts.pool.key(),
            &ctx.accounts.user_lst_account.key(),
            &ctx.accounts.stake_pool_manager_fee_account.key(),
            &ctx.accounts.stake_pool_mint.key(),
            &ctx.accounts.token_program.key(),
            lst_amounts[i],
        );
Remediation Comment

SOLVED: The The Vault team solved the issue by adding the ability for users to provide a minimum lamports out. This amount is then checked in the calculate_and_transfer_unstake_fees, and the function reverts in case that the mentioned amount is not met.

7.2 Unstaking funds steal in case of manual funds approval

//

Informational

Description

The liquid_unstake_lst function is designed to allow users to burn Liquid Staking Tokens (LST) and unstake SOL from a stake pool without waiting for the corresponding locking period of the staking account.


In order to do this, the liquid_unstake_lst entry point requires the user to first approve the pool to burn their LST tokens via the SPL Token approve instruction. This approval is currently being executed and granted on the client side (The Vault Web App) and persists until manually revoked or fully used.


However, the liquid_unstake_lst function does not verify on chain that the caller is the owner of the user_lst_account, meaning that as long as an approval exists, anyone can execute the function on behalf of the user, burn their LST tokens, and withdraw unstaked SOL to an attacker-controlled address.


Since the function does not require a signer for user_lst_account, an attacker can construct a transaction referencing a victim’s token account, and as long as the pool has an active approval, the attacker can unstake and redirect the SOL withdrawal without the victim’s consent. This issue arises due to the permissionless nature of the entry point, combined with the persistence of SPL Token approvals, which remain valid until explicitly revoked. While the impact is mitigated by the requirement for prior user approval, many users may unknowingly leave approvals active, exposing them to unauthorized unstaking and loss of funds.


The lack of checking of the signature of the owner of the user_lst_account account can be seen in the snippet below:

src/instructions/liquid_unstake_lst.rs:

pub struct LiquidUnstakeLst<'info> {
    #[account(
        mut,
        seeds = [b"pool", pool.authority.key().as_ref()],
        bump = pool.bump,
        has_one = sol_vault,
        has_one = manager_fee_account
    )]
    pub pool: Account<'info, Pool>,
    pub payer: Signer<'info>,
    #[account(mut)]
    pub user_lst_account: Account<'info, TokenAccount>,
    /// CHECK: The account containing the naked SOL managed by the liquid unstake pool
    #[account(mut,
        seeds = [b"sol_vault", pool.key().as_ref()],
        bump = pool.sol_vault_bump
    )]
    pub sol_vault: AccountInfo<'info>,
    /// CHECK: The user's SOL account to receive the unstaked SOL
    #[account(mut)]
    pub user_sol_account: AccountInfo<'info>,
    /// CHECK: The liquid unstake pool manager fee account that will receive the manager fee
    #[account(mut)]
    pub manager_fee_account: AccountInfo<'info>,

The permissionless nature of the liquid_unstake_lst function, combined with the need for users to grant token approval to the pool, allows any caller to execute the unstake process on behalf of any user, provided an active approval exists. Since the function does not enforce a signer check on user_lst_account, an attacker can submit a transaction specifying a victim’s LST token account for unstaking while redirecting the unstaked SOL to their own controlled account by supplying a user_sol_account they own. The attacker effectively exploits the lack of ownership verification in the unstake process, leveraging the SPL Token approval mechanism to burn the victim’s LST tokens and withdraw the corresponding SOL to an address under their control, enabling unauthorized asset extraction without requiring access to the victim’s private key.

BVSS
Recommendation

To prevent unauthorized unstaking, the function should introduce an unstaker_user account as a signer and enforce that:

• user_lst_account is owned by unstaker_user

• user_sol_account is owned by unstaker_user


This ensures that only the rightful owner of the LST tokens can initiate the unstake process, as shown in the snippet below:

src/instructions/liquid_unstake_lst.rs

pub struct LiquidUnstakeLst<'info> {
    #[account(
        mut,
        seeds = [b"pool", pool.authority.key().as_ref()],
        bump = pool.bump,
        has_one = sol_vault,
        has_one = manager_fee_account
    )]
    pub pool: Account<'info, Pool>,

    #[account(signer)]
    pub unstaker_user: Signer<'info>,

    #[account(mut,
        constraint = user_lst_account.owner == unstaker_user.key() @ LiquidUnstakerErrorCode::InvalidUserLstOwner
    )]
    pub user_lst_account: Account<'info, TokenAccount>,

    /// CHECK: The account containing the naked SOL managed by the liquid unstake pool
    #[account(mut,
        seeds = [b"sol_vault", pool.key().as_ref()],
        bump = pool.sol_vault_bump
    )]
    pub sol_vault: AccountInfo<'info>,

    /// CHECK: The user's SOL account to receive the unstaked SOL
    #[account(mut,
        constraint = user_sol_account.owner == unstaker_user.key() @ LiquidUnstakerErrorCode::InvalidUserSolOwner
    )]
    pub user_sol_account: AccountInfo<'info>,

    /// CHECK: The liquid unstake pool manager fee account that will receive the manager fee
    #[account(mut)]
    pub manager_fee_account: AccountInfo<'info>,
}
Remediation Comment

SOLVED: The The Vault team solved the issue by requiring the owner of he usr_lst_account to be a signer of the instruction, and passing it as a user_transfer_authority for the withdraw_stake instruction.

7.3 Incorrect cap validation in deposit_sol entry point

//

Informational

Description

The deposit_sol entry point is responsible for allowing users to deposit SOL into the liquidity pool. In return, users receive LP tokens proportional to their deposit based on the current pool exchange rate.


This liquidity pool is designed to allow liquidity providers to supply SOL so that other users can unstake their Solana staking positions instantly without waiting for the normal unlock period, in exchange for a fee.


The function includes a validation to ensure that deposits do not exceed the vault’s SOL cap:


programs/liquid-unstaker/src/instructions/deposit_sol.rs

    pub fn deposit(ctx: Context<DepositSol>, amount: u64) -> Result<()> {

        let total_sol_in_vault_plus_pending = 
            ctx.accounts.pool.sol_vault_lamports
            + ctx.accounts.pool.total_deactivating_stake;

        if total_sol_in_vault_plus_pending + amount >= ctx.accounts.pool.sol_vault_lamports_cap {
            return Err(LiquidUnstakerErrorCode::SolVaultLamportsCapReached.into());
        }

        // Mint LP tokens to the user according to the current "exchange rate"
        let lp_tokens_to_mint = lp_math::calculate_tokens_to_mint(
            ctx.accounts.pool.total_lp_tokens,
            amount,
            total_sol_in_vault_plus_pending,
        )?;

        if lp_tokens_to_mint == 0 {
            return Err(LiquidUnstakerErrorCode::LpTokensToMintIsZero.into());
        }

        // Transfer SOL from user to sol_vault
        invoke(
            &system_instruction::transfer(
                &ctx.accounts.user.key(),
                &ctx.accounts.sol_vault.key(),
                amount,
            ),
            &[
                ctx.accounts.user.to_account_info(),
                ctx.accounts.sol_vault.clone(),
                ctx.accounts.system_program.to_account_info(),
            ],
        )?;

        // Mint LP tokens to user
        let cpi_accounts = MintTo {
            mint: ctx.accounts.lp_mint.to_account_info(),
            to: ctx.accounts.user_lp_account.to_account_info(),
            authority: ctx.accounts.pool.to_account_info(),
        };
        let cpi_program = ctx.accounts.token_program.to_account_info();

        token::mint_to(
            CpiContext::new_with_signer(cpi_program, cpi_accounts, pool_seeds!(ctx.accounts.pool.authority, ctx.accounts.pool.bump)),
            lp_tokens_to_mint,
        )?;

        let pool = &mut ctx.accounts.pool;
    
        // Update pool state
        pool.deposit_liquidity_provider(amount)?;

        pool.total_lp_tokens = pool
            .total_lp_tokens
            .checked_add(lp_tokens_to_mint)
            .ok_or(LiquidUnstakerErrorCode::MathOverflow)?;
   
        Ok(())

    }

However, since the validation uses greater than or equal to (>=), the cap can never be fully reached, as any deposit that would bring the total exactly to the cap will be rejected.

BVSS
Recommendation

Consider changing the validation to be strictly greater than, as shown in the snippet below:

if total_sol_in_vault_plus_pending + amount > ctx.accounts.pool.sol_vault_lamports_cap {
    return Err(LiquidUnstakerErrorCode::SolVaultLamportsCapReached.into());
}
Remediation Comment

SOLVED: The The Vault team solved the issue by correcting the inequality to be strictly bigger than.

7.4 Unused is_active boolean in StakeAccountInfo

//

Informational

Description

The StakeAccountInfo struct, which is used to store information about unstaked staking accounts, and it includes a boolean field is_active, as shown in the snippet below:

programs/liquid-unstaker/src/state.rs

#[account]
pub struct StakeAccountInfo {
    // The stake account that has been unstaked and is now owned by the pool
    pub stake_account: Pubkey,
    // The pool that manages the stake account
    pub pool: Pubkey,
    // The amount of lamports in the stake account when it was unstaked
    pub stake_lamports: u64,
    // If the stake account is active or not
    pub is_active: bool,
}

This boolean is never used in the program:

• When a StakeAccountInfo account is created, is_active is always set to true.

• When the update function withdraws the stake, both the stake account and StakeAccountInfo are closed, making the boolean unnecessary.

• The update function includes a check for is_active, but since the account is always closed after unstaking, this check has no effect.

BVSS
Recommendation

Consider removing the is_active field from the StakeAccountInfo, along with any references to it in the program to reduce unnecessary state tracking and code complexity.

Remediation Comment

SOLVED: The The Vault team solved the issue by deleting the mentioned code and references to the is_active field.

8. Automated Testing

Description

Halborn used automated security scanners to assist with the detection of well-known security issues and vulnerabilities. Among the tools used was cargo-audit, a security scanner for vulnerabilities reported to the RustSec Advisory Database. All vulnerabilities published in https://crates.io are stored in a repository named The RustSec Advisory Database. cargo audit is a human-readable version of the advisory database which performs a scanning on Cargo.lock. Security Detections are only in scope. All vulnerabilities shown here were already disclosed in the above report. However, to better assist the developers maintaining this code, the reviewers are including the output with the dependencies tree, and this is included in the cargo audit output to better know the dependencies affected by unmaintained and vulnerable crates.

Results

ID

package

Short Description

RUSTSEC-2022-0093

ed25519-dalek

Double Public Key Signing Function Oracle Attack on ed25519-dalek

RUSTSEC-2024-0344

curve25519-dalek

Timing variability in curve25519-dalek's Scalar29::sub/`Scalar52::sub`

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.

© Halborn 2025. All rights reserved.