NT Bundle - Neutral Trade


Prepared by:

Halborn Logo

HALBORN

Last Updated Unknown date

Date of Engagement: January 8th, 2025 - January 21st, 2025

Summary

100% of all REPORTED Findings have been addressed

All findings

12

Critical

0

High

0

Medium

1

Low

3

Informational

8


1. Introduction

Neutral Trade engaged Halborn to conduct a security assessment on their Solana Validator program beginning on January 8th, 2025 and ending on January 21st, 2025. The security assessment was scoped to the smart contracts provided in the GitHub repository bundle-audit, commit hashes, and further details can be found in the Scope section of this report.


The Neutral Trade is releasing NT Bundle, a program that allows user to participate in a multi-strategy hedge fund that interacts with multiple protocols, initially with Drift vaults.

2. Assessment Summary

Halborn was provided 2 weeks 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.

    • Validate that the funds are properly managed by the program, avoiding unauthorized access to them

    • Verify that the system has implemented the access control principles to prevent malicious users to access permissioned functions


In summary, Halborn identified some improvements to reduce the likelihood and impact of multiple risks, which were partially addressed by the Neutral Trade team. The main ones were the following:

    • Prevent calling the assign_profit_share entry point if POD token's total supply is zero.

    • Prevent receivers to be allocated an excessive allocation bps.

    • Require the manager of the Drift vaults' related functions to be only the bundle keeper.

    • Prefer using Anchor's Context Accounts to provide accounts to a function.

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

REPOSITORY
(a) Repository: bundle-audit
(b) Assessed Commit ID: 7d6c7af
(c) Items in scope:
  • bundle-audit/programs/ntbundle/src/errors.rs
  • bundle-audit/programs/ntbundle/src/events.rs
  • bundle-audit/programs/ntbundle/src/lib.rs
↓ Expand ↓
Out-of-Scope: 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

1

Low

3

Informational

8

Security analysisRisk levelRemediation Date
First user allocating funds might drain underlying balanceMediumSolved - 01/30/2025
Centralization risk due to manager control over ReceiversLowRisk Accepted - 01/30/2025
Inconsistent constraints on manager for multiple entry pointsLowSolved - 01/30/2025
Risk of passing key accounts as parameters in initialize_bundleLowPartially Solved - 02/07/2025
Lack of validation of new authority on multiple functionsInformationalAcknowledged - 01/30/2025
Arbitrage opportunity if NAV is predictedInformationalPartially Solved - 02/06/2025
Lack of global allocation BPS tracking in NTBundle programInformationalSolved
Lack of validation for allocation_bps in Receiver setupInformationalSolved - 01/30/2025
Lack of functionality to re-enable a Receiver accountInformationalSolved - 01/30/2025
Incorrect manager role validation in update_allocations and remove_strategyInformationalSolved - 01/30/2025
Redundant validation of allocation_bps in distribute_to_receiversInformationalSolved - 01/30/2025
Redundant validation of refill_amount in perform_refillInformationalSolved - 01/30/2025

7. Findings & Tech Details

7.1 First user allocating funds might drain underlying balance

//

Medium

Description

The assign_profit_share function allows the neutral fee incrementer to allocate profit shares to the bundle without verifying if the total supply of the pod token is greater than zero as we can see in the snippet below:


lib.rs

    pub fn assign_profit_share(ctx: Context<AssignProfitShare>, profit_amount: u64) -> Result<()> {
        let bundle_account = &mut ctx.accounts.bundle_account;
        if ctx.accounts.user.key() != bundle_account.neutral_fee_incrementer {
            return Err(ErrorCode::CallerNotNeutralFeeIncrementer.into());
        }
        let net_amount_transfer_accounts = TransferChecked {
            from: ctx.accounts.user_token_account.to_account_info(),
            to: ctx.accounts.bundle_usdc_account.to_account_info(),
            authority: ctx.accounts.user.to_account_info(),
            mint: ctx.accounts.usdc_mint.to_account_info(),
        };
        let net_amount_context = CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            net_amount_transfer_accounts,
        );
        anchor_spl::token::transfer_checked(net_amount_context, profit_amount, 6)?;
        let ga: u128 = profit_amount as u128;
        bundle_account.bundle_underlying_balance += ga;

        emit!(AssignedProfitShare { amount: ga });

        Ok(())
    }

If the pod token total supply is zero when profit shares are assigned, the first user to allocate funds via allocate_funds will receive all the pod tokens, effectively owning 100% of the bundle's share.


This user can then call redeem_collateral to withdraw the entirety of the funds in the bundle, regardless of the size of their initial deposit. This creates a scenario where:

  • A user can exploit the lack of validation and contribute a minimal amount of funds.

  • They gain control of the entire bundle and can drain it by redeeming the collateral.


As a result, a malicious or opportunistic user can withdraw all funds in the bundle for a negligible initial investment.

Proof of Concept
  1. initialize all the corresponding accounts

  2. initialize the bundle

  3. assign profit share, using the neutral fee incrementer

  4. allocate funds with an arbitrary user

  5. redeem the collateral by calling redeem_collateral

BVSS
Recommendation

Consider adding a validation in the assign_profit_share entry point that validates that the total supply of the POD token is not 0, as shown in the snippet below:


lib.rs

    pub fn assign_profit_share(ctx: Context<AssignProfitShare>, profit_amount: u64) -> Result<()> {

        let current_pod_total_supply = fetch_current_pod_total_supply(&ctx.accounts.vault_pod_token_mint)?;
        if current_pod_total_supply == 0 {
            return Err(ErrorCode::PodTokenTotalSupplyZero.into());
        }

        let bundle_account = &mut ctx.accounts.bundle_account;
        if ctx.accounts.user.key() != bundle_account.neutral_fee_incrementer {
            return Err(ErrorCode::CallerNotNeutralFeeIncrementer.into());
        }
        let net_amount_transfer_accounts = TransferChecked {
            from: ctx.accounts.user_token_account.to_account_info(),
            to: ctx.accounts.bundle_usdc_account.to_account_info(),
            authority: ctx.accounts.user.to_account_info(),
            mint: ctx.accounts.usdc_mint.to_account_info(),
        };
        let net_amount_context = CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            net_amount_transfer_accounts,
        );
        anchor_spl::token::transfer_checked(net_amount_context, profit_amount, 6)?;
        let ga: u128 = profit_amount as u128;
        bundle_account.bundle_underlying_balance += ga;

        emit!(AssignedProfitShare { amount: ga });

        Ok(())
    }

This way, the first deposit should be executed by calling the allocate_funds entry point, which will prevent the situation where a small amount of tokens will cause the first user to have access to the complete underlying balance of the bundle.

Remediation Comment

SOLVED: The Neutral Trade team solved the issue by including a validation to check that the pod token total supply is not 0.

7.2 Centralization risk due to manager control over Receivers

//

Low

Description

The NTBundle program exhibits significant centralization risks due to the level of control granted to the bundle manager. Specifically, the bundle manager has the authority to create strategies and configure receivers with allocations (allocation_bps) that can access up to 100% of the underlying balance of the bundle without any form of additional control, checks, or guarantees, as shown in the snippets below:


lib.rs

    pub fn add_strategy(
        ctx: Context<AddStrategy>,
        allocation_bps: u16,
        is_raw_transfer_receiver: bool,
    ) -> Result<()> {
        let receiver_account = &mut ctx.accounts.receiver_account;
        let bundle_account = &mut ctx.accounts.bundle_account;

        // Ensure the caller is the manager
        if ctx.accounts.manager.key() != bundle_account.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        // Initialize the receiver account
        receiver_account.strategy_key = ctx.accounts.strategy_key.key();
        receiver_account.allocation_bps = allocation_bps;
        receiver_account.manager = ctx.accounts.manager.key();
        receiver_account.allowed = true;
        receiver_account.is_raw_transfer_receiver = is_raw_transfer_receiver;

        emit!(StrategyAdded {
            strategy_key: ctx.accounts.strategy_key.key(),
            allocation_bps,
        });

        Ok(())
    }

This design introduces the following centralization risks:

  • Systemic Failure: A malicious or compromised bundle manager could configure receivers to drain the entire bundle balance, leading to complete loss of funds.

  • Single Point of Failure: If for any reason a Receiver can't access the network and has under their control a significant amount of underlying balance of the Bundle, then the users are exposed to important lost of opportunities due to lack of access to their funds.

BVSS
Recommendation

Consider the next group of recommendations to reduce the mentioned risks:


  • Introduce Allocation Caps, such as a maximum allocation limit for receivers (e.g., no single receiver can be allocated more than 50% of the bundle's underlying balance).

  • Add Decentralized Oversight, such as a multisig or governance-based approval process for creating or updating strategies and receivers, ensuring that no single entity has unchecked control.

  • Require the receivers to provide a collateral to back the funds while the distribution process is executed.

  • Introduce a global allocation cap, for instance, that the receivers can't access to more than the 50% of the total of the underlying balance of the Bundle

Remediation Comment

RISK ACCEPTED: The Neutral Trade team accepted the risk of this finding.

7.3 Inconsistent constraints on manager for multiple entry points

//

Low

Description

Multiple functions introduce an inconsistent constraint regarding the signer of the instruction. Particularly, the initialize_vault_depositor introduce such inconsistency.


The mentioned function is designed to initialize the Drift Protocol Vault Depositor within the NTBundle program. This depositor serves as a critical component that allows the NTBundle program to integrate with Drift Vaults, enabling interactions such as fund deposits, withdrawals, and other vault-related functionalities.


However, the function introduces an inconsistency in its role constraints:

  • The bundle_account enforces that the manager is the bundle_account.manager (via the has_one = manager constraint), as shown in the snippet below:

state.rs

    #[account(
        mut,
        seeds = [BUNDLE_SEED],
        bump,
        has_one = manager
    )]
    pub bundle_account: Account<'info, Bundle>,

  • The function logic requires the manager to also match bundle_account.keeper , as shown in the snippet below:

lib.rs

        pub fn initialize_vault_depositor<'info>(
        ctx: Context<'_, '_, '_, 'info, InitializeDriftVaultDepositor<'info>>,
    ) -> Result<()> {
        let bundle_data = &mut ctx.accounts.bundle_account;
        //ensure caller is keeper
        if ctx.accounts.manager.key() != bundle_data.keeper {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

This inconsistency contradicts the Neutral Trade's specification that manager and keeper are distinct roles. As a result:

  • The initialize_vault_depositor function cannot be executed successfully because it effectively enforces that the manager and keeper are the same entity.

  • Consequently, the Drift Vault Depositor cannot be initialized, blocking the integration of the NTBundle program with Drift Vaults.


This same inconsistency can be found in 2 other functions:

  • call_drift_vault_deposit

  • withdraw

BVSS
Recommendation

As the Neutral trade team mentioned that these functions should be called by the keeper, consider deleting the has_one = manager in the bundle account for the InitializeDriftVaultDepositor , CallDriftVaultDeposit and WithdrawContext context accounts, highlighted in the snippet below:

state.rs

    #[account(
        mut,
        seeds = [BUNDLE_SEED],
        bump,
        has_one = manager
    )]
    pub bundle_account: Account<'info, Bundle>,
Remediation Comment

SOLVED: The Neutral Trade team solved the issue by deleting the has_one = manager constraint to the mentioned entry points.

7.4 Risk of passing key accounts as parameters in initialize_bundle

//

Low

Description

The initialize_bundle function accepts critical accounts, such as the pod mint (representing the shares) and USDC mint, as function parameters instead of passing them as context accounts. These parameters are directly assigned to the bundle_account without leveraging Anchor’s built-in account validation features, such as ensuring the accounts are valid Mint accounts, as we can see in the snippet below:


lib.rs

    pub fn initialize_bundle(
        ctx: Context<InitializeBundle>,
        neutral_fee_incrementer: Pubkey,
        w_fee_receiver_account: Pubkey,
        usdc_mint: Pubkey,
        keeper: Pubkey,
        pod_mint: Pubkey,
    ) -> Result<()> {
        let bundle_account = &mut ctx.accounts.bundle_account;
        let oracle_data = &mut ctx.accounts.oracle_data;
        let now = Clock::get()?.unix_timestamp;

        require!(
            neutral_fee_incrementer != Pubkey::default(),
            ErrorCode::InvalidNeutralFeeIncrementer
        );
        require!(
            w_fee_receiver_account != Pubkey::default(),
            ErrorCode::WrongReceiver
        );
        require!(usdc_mint != Pubkey::default(), ErrorCode::WrongReceiver);
        require!(keeper != Pubkey::default(), ErrorCode::WrongReceiver);
        require!(pod_mint != Pubkey::default(), ErrorCode::WrongReceiver);
        require!(
            ctx.accounts.manager.key() != Pubkey::default(),
            ErrorCode::WrongReceiver
        );

        bundle_account.bundle_underlying_balance = 0;
        bundle_account.manager = *ctx.accounts.manager.key;
        bundle_account.withdraw_fee = 500;

        bundle_account.w_fee_receiver_account = w_fee_receiver_account;
        bundle_account.neutral_fee_incrementer = neutral_fee_incrementer;
        bundle_account.usdc_mint = usdc_mint;
        bundle_account.keeper = keeper;
        bundle_account.vault_pod_token_mint = pod_mint;
        bundle_account.twap_period = 86400;

        oracle_data.average_external_equity = 0;
        oracle_data.last_update_time = now;
        oracle_data.last_equity_value = 0;
        oracle_data.cumulative_equity = 0;

        emit!(InitializedVault {
            manager: ctx.accounts.manager.key()
        });

        Ok(())
    }

The key issues identified revolve around the lack of proper validation and the unnecessary flexibility in handling critical mint accounts. Firstly, Anchor provides robust validation for mint accounts through the Account<'info, Mint> type, ensuring that the provided accounts are valid Solana SPL token mints. However, by passing these values as parameters in the form of Pubkey, the program bypasses these safeguards, leaving it exposed to invalid or malicious inputs. Secondly, while the function performs basic checks to ensure the provided Pubkey values are not the default Pubkey, it fails to validate whether the usdc_mint or pod_mint are valid mint accounts. Finally, both the pod mint and USDC mint represent fixed, program-level configurations that do not change over time. Allowing them to be passed as parameters introduces unnecessary risks and complexity, making the program less secure and more error-prone.


The impact of these issues can be categorized into three main areas. Security Risk: Malicious actors could provide invalid or unauthorized mint accounts, potentially leading to the redirection or misappropriation of funds. Operational Failures: If an incorrect or invalid mint is supplied, the program may fail to distribute shares or process transactions as intended, disrupting its functionality. Complexity and Fragility: Allowing these values to be passed as parameters introduces unnecessary complexity, increasing the likelihood of misconfiguration or human error during initialization, ultimately making the program more error-prone and less reliable.

BVSS
Recommendation

Consider implementing the next steps:


  1. Define the hardcoded values for usdc_mint and pod_mint:

lib.rs

const EXPECTED_USDC_MINT: Pubkey = Pubkey::new_from_array([/* insert USDC mint address bytes */]);
const EXPECTED_POD_MINT: Pubkey = Pubkey::new_from_array([/* insert Pod mint address bytes */]);

2. Then, update the context to validate these addresses:

lib.rs

#[derive(Accounts)]
pub struct InitializeBundle<'info> {
    #[account(mut)]
    pub manager: Signer<'info>,
    #[account(init, space = 8 + Bundle::INIT_SPACE, payer = manager, seeds = [BUNDLE_SEED], bump)]
    pub bundle_account: Account<'info, Bundle>,
    #[account(init, space = 8 + OracleData::INIT_SPACE, payer = manager, seeds = [ORACLE_SEED], bump)]
    pub oracle_data: Account<'info, OracleData>,
    #[account(
        constraint = pod_mint.key() == EXPECTED_POD_MINT,
        constraint = pod_mint.owner == spl_token::id(),
        constraint = pod_mint.decimals == EXPECTED_POD_DECIMALS // replace with the expected decimals of the POD mint
    )]
    pub pod_mint: Account<'info, Mint>, // Validate Pod Mint against hardcoded address
    #[account(
        constraint = usdc_mint.key() == EXPECTED_USDC_MINT,
        constraint = usdc_mint.owner == spl_token::id(),
        constraint = usdc_mint.decimals == 6
    )]
    pub usdc_mint: Account<'info, Mint>, // Validate USDC Mint against hardcoded address
    pub system_program: Program<'info, System>,
}

3. Update the initialize_bundle function:

lib.rs

pub fn initialize_bundle(
    ctx: Context<InitializeBundle>,
    neutral_fee_incrementer: Pubkey,
    w_fee_receiver_account: Pubkey,
    keeper: Pubkey,
) -> Result<()> {
    let bundle_account = &mut ctx.accounts.bundle_account;
    let oracle_data = &mut ctx.accounts.oracle_data;
    let now = Clock::get()?.unix_timestamp;

    require!(
        neutral_fee_incrementer != Pubkey::default(),
        ErrorCode::InvalidNeutralFeeIncrementer
    );
    require!(
        w_fee_receiver_account != Pubkey::default(),
        ErrorCode::WrongReceiver
    );
    require!(keeper != Pubkey::default(), ErrorCode::WrongReceiver);
    require!(
        ctx.accounts.manager.key() != Pubkey::default(),
        ErrorCode::WrongReceiver
    );

    bundle_account.bundle_underlying_balance = 0;
    bundle_account.manager = *ctx.accounts.manager.key;
    bundle_account.withdraw_fee = 500;

    bundle_account.w_fee_receiver_account = w_fee_receiver_account;
    bundle_account.neutral_fee_incrementer = neutral_fee_incrementer;
    bundle_account.usdc_mint = *ctx.accounts.usdc_mint.to_account_info().key;
    bundle_account.keeper = keeper;
    bundle_account.vault_pod_token_mint = *ctx.accounts.pod_mint.to_account_info().key;
    bundle_account.twap_period = 86400;

    oracle_data.average_external_equity = 0;
    oracle_data.last_update_time = now;
    oracle_data.last_equity_value = 0;
    oracle_data.cumulative_equity = 0;

    emit!(InitializedVault {
        manager: ctx.accounts.manager.key()
    });

    Ok(())
}

This way, both the USDC token mint and the POD token mint are properly validated according to Anchor checks.

Remediation Comment

PARTIALLY SOLVED: The Neutral Trade team partially solved the issue by including a hardcoded value for the USDC mint and expected decimals for the pod token mint, but didn't include the hardcoded value for the POD token mint. This is due to the fact that the POD token mint has not been deployed yet.

7.5 Lack of validation of new authority on multiple functions

//

Informational

Description

The change_manager and the set_fees functions sets the manager of the bundle and the treasury, accordingly, as shown in the snippets below:


lib.rs

    pub fn change_manager(ctx: Context<ChangerManager>, new_manager: Pubkey) -> Result<()> {
        let bundle = &mut ctx.accounts.bundle_account;
        if ctx.accounts.user.key() != bundle.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        bundle.manager = new_manager;

        emit!(NewManagerSet {
            manager: new_manager
        });

        Ok(())
    }

    pub fn set_fees(ctx: Context<SetFees>, treasury: Pubkey, withdraw_fee: u64) -> Result<()> {
        let bundle = &mut ctx.accounts.bundle_account;
        if ctx.accounts.user.key() != bundle.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }
        if withdraw_fee > 5000 {
            return Err(ErrorCode::ExcessiveFeePercentage.into());
        }

        if treasury == Pubkey::new_from_array([0u8; 32]) {
            return Err(ErrorCode::WrongReceiver.into());
        }
        bundle.withdraw_fee = withdraw_fee;
        bundle.w_fee_receiver_account = treasury;

        emit!(ChangedCoreParam {
            withdraw_fee,
            treasury,
        });

        Ok(())
    }

Neither of these function validate that the new authority account is a valid new account.


The mentioned functions directly overwrite the respective authority without implementing a two-step ownership transfer mechanism. This could allow an incorrect ownership transfer without a formal verification that the next owner is correct.


A two-step ownership transfer process typically requires the following:

  1. The current authority nominates a new authority.

  2. The nominated authority confirms the acceptance of the role before the transfer is finalized.

BVSS
Recommendation

The recommended way to update the admin of a platform functionality is to use a two-step ownership transfer pattern, with 2 potential implementations:

  • First, implement a function that designates a "new owner candidate." The ownership is not transferred until the new owner "accepts" it by sending a signed transaction.

  • Require the new_admin to be a signer of the transaction.

Remediation Comment

ACKNOWLEDGED: The Neutral Trade team acknowledged this finding.

References

7.6 Arbitrage opportunity if NAV is predicted

//

Informational

Description

The Neutral Trade platform manages user funds through a Solana program that interacts with Drift to invest user funds and update the NAV (Net Asset Value). Additionally, it provides funds to receivers, who manage the funds at their discretion and engage in external profit opportunities. Once these receivers generate returns, they return the principal plus profits to the platform, which increases the bundle_underlying_balance and, consequently, the NAV.


The core functions involved in these operations include:

  • update_oracle: This function updates the average_external_equity, which affects the NAV calculation. It ensures that the new equity value does not exceed a 50% increase from the current value, preventing manipulation:


programs/ntbundle/src/lib.rs

    pub fn update_oracle(ctx: Context<SetOracleData>, new_equity: u128) -> Result<()> {
        let oracle_data = &mut ctx.accounts.oracle_data;
        let bundle_data = &mut ctx.accounts.bundle_account;

        //ensure only the keeper can update the oracle
        if ctx.accounts.user.key() != bundle_data.keeper {
            return Err(ErrorCode::NotAuthorized.into());
        }

        //if current equity exceeds the current equity by 50% then revert
        let current_equity = oracle_data.average_external_equity;
        let current_equity_10_percent = current_equity.checked_mul(50).ok_or(ErrorCode::MathError)?.checked_div(100).ok_or(ErrorCode::MathError)?;
        msg!("Current equity: {}", current_equity);
        msg!("Current equity 10%: {}", current_equity_10_percent);
        if new_equity > current_equity_10_percent.checked_add(current_equity).ok_or(ErrorCode::MathError)? {
            return Err(ErrorCode::OracleDataExceeded.into());
        }

        let average_external_equity = update_oracle_data(oracle_data, new_equity)?;
        msg!("Average equity: {}", average_external_equity);

        emit!(OracleUpdated {
            cumulative_equity: oracle_data.cumulative_equity,
            average_external_equity: average_external_equity,
        });
    
        Ok(())
    }
  • fetch_total_equity: This function calculates the total NAV by summing the average_external_equitybundle_underlying_balance, and receivers_borrowed_amount:


programs/ntbundle/src/lib.rs

fn fetch_total_equity(oracle_data: &OracleData, bundle_data: &Bundle) -> Result<u128> {
    let underlying_balance = bundle_data.bundle_underlying_balance;
    msg!("Underlying balance: {}", underlying_balance);
    let current_equity = oracle_data.average_external_equity;
    msg!("Current external equity: {}", current_equity);
    let current_borrowed_amount = bundle_data.receivers_borrowed_amount;
    msg!("Current borrowed amount: {}", current_borrowed_amount);

    let total_equity = underlying_balance
        .checked_add(current_equity)
        .ok_or(ErrorCode::MathError)? // Handle the Option from checked_add
        .checked_add(current_borrowed_amount)
        .ok_or(ErrorCode::MathError)?; // Handle the Option from checked_sub
    Ok(total_equity)
}
  • call_drift_vault_deposit & withdraw: These functions interact with Drift’s vaults to invest or withdraw funds, respectively. They also update the NAV by modifying the average_external_equity.

  • perform_refill: Receivers manage user funds and deploy them into profit-generating opportunities. When these funds return with profits, the bundle_underlying_balance increases, indirectly increasing NAV:


programs/ntbundle/src/lib.rs

    pub fn perform_refill(ctx: Context<Refill>, refill_amount: u64) -> Result<()> {
        let ra: u128 = refill_amount as u128;
        let bundle = &mut ctx.accounts.bundle_account;
        let receiver = &ctx.accounts.receiver_account;
        if ra <= 0 {
            return err!(ErrorCode::RefillAmountInvalid);
        }

        //ensure the receiver is a raw transfer receiver
        if !receiver.is_raw_transfer_receiver {
            return Err(ErrorCode::RawTransferReceiver.into());
        }
    
        // Ensure the receiver is allowed
        if !receiver.allowed {
            return Err(ErrorCode::MustBeInWhitelist.into());
        }

        //ensure the refill amount isnt greater than the amount borrowed
        if bundle.receivers_borrowed_amount < ra {
            return Err(ErrorCode::RefillAmountExceeded.into());
        }

        bundle.receivers_borrowed_amount = bundle
            .receivers_borrowed_amount
            .checked_sub(ra)
            .ok_or(ErrorCode::MathError)?;
            
        // Handle the transfer
        let net_amount_transfer_accounts = TransferChecked {
            from: ctx.accounts.receiver_ata.to_account_info(),
            to: ctx.accounts.bundle_usdc_account.to_account_info(),
            authority: ctx.accounts.receiver.to_account_info(),
            mint: ctx.accounts.usdc_mint.to_account_info(),
        };
        let net_amount_context = CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            net_amount_transfer_accounts,
        );
        anchor_spl::token::transfer_checked(net_amount_context, refill_amount, 6)?;
    
        refill(bundle, ra)?;
    
        Ok(())
    }
  • allocate_funds: Users call this function to buy shares (pod tokens) by depositing funds. The function updates the bundle_underlying_balance, which is factored into the NAV calculation:


programs/ntbundle/src/lib.rs

    pub fn allocate_funds(ctx: Context<AllocateFunds>, amount: u64) -> Result<()> {
        let oracle_data = &mut ctx.accounts.oracle_data;
        let bundle_account = &mut ctx.accounts.bundle_account;

        if amount <= 0 {
            return err!(ErrorCode::AllocationAmountZero);
        }

        if bundle_account.is_distribution_started {
            return Err(ErrorCode::DistributionAlreadyStarted.into());
        }

        // Derive bump seed
        let (_program_authority, program_authority_bump) = Pubkey::find_program_address(&[AUTHORITY_SEED], &ctx.program_id);
        let authority_seeds = &[AUTHORITY_SEED.as_ref(), &[program_authority_bump]];
        let signer_seeds = &[&authority_seeds[..]];

        let net_amount_transfer_accounts = TransferChecked {
            from: ctx.accounts.user_token_account.to_account_info(),
            to: ctx.accounts.bundle_usdc_account.to_account_info(),
            authority: ctx.accounts.user.to_account_info(),
            mint: ctx.accounts.usdc_mint.to_account_info(),
        };
        let net_amount_context = CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            net_amount_transfer_accounts,
        );
        anchor_spl::token::transfer_checked(net_amount_context, amount, 6)?;

        let deposit_fee = amount.checked_mul(bundle_account.deposit_fee)
            .ok_or(ErrorCode::MathError)?.checked_div(10000)
            .ok_or(ErrorCode::MathError)?;
        msg!("Deposit fee: {}", deposit_fee);
        let deposit_sub_fee = amount.checked_sub(deposit_fee).ok_or(ErrorCode::MathError)?;
        msg!("Deposit sub fee: {}", deposit_sub_fee);

        if deposit_fee > 0 {
            //transfer the deposit fee to the treasury
            let net_amount_transfer_accounts = TransferChecked {
                from: ctx.accounts.bundle_usdc_account.to_account_info(),
                to: ctx.accounts.treasury_address.to_account_info(),
                authority: ctx.accounts.program_authority.to_account_info(),
                mint: ctx.accounts.usdc_mint.to_account_info(),
            };
            let net_amount_context = CpiContext::new_with_signer(
                ctx.accounts.token_program.to_account_info(),
                net_amount_transfer_accounts,
                signer_seeds,
            );
            anchor_spl::token::transfer_checked(net_amount_context, deposit_fee, 6)?;
        }

        let da: u128 = deposit_sub_fee as u128;
        let shares: u128;

        let current_pod_total_supply = fetch_current_pod_total_supply(&ctx.accounts.vault_pod_token_mint)?;
        msg!("Current pod total supply: {}", current_pod_total_supply);
        let total_assets = fetch_total_equity(oracle_data, bundle_account)?;
        msg!("Total assets: {}", total_assets);
        if current_pod_total_supply == 0 {
            shares = da;
        } else {
            shares = da.checked_mul(current_pod_total_supply)
            .ok_or(ErrorCode::MathError)?.checked_div(total_assets)
            .ok_or(ErrorCode::MathError)?;
        }

        msg!("SA: {}", shares);

        bundle_account.bundle_underlying_balance = bundle_account
            .bundle_underlying_balance
            .checked_add(da)
            .ok_or(ErrorCode::MathError)?;
        msg!("VB: {}", bundle_account.bundle_underlying_balance);

        let cpi_accounts = MintTo {
            mint:  ctx.accounts.vault_pod_token_mint.to_account_info(),
            to: ctx.accounts.user_pod_token_account.to_account_info(),
            authority: ctx.accounts.program_authority.to_account_info(),
        };
        let cpi_program = ctx.accounts.token_program.to_account_info();
        let cpi_context = CpiContext::new_with_signer(
            cpi_program,
            cpi_accounts,
            signer_seeds,
        );
        let _ = anchor_spl::token::mint_to(cpi_context, shares as u64);
        msg!("MTU: {}", shares);
        
        let account_balance = fetch_account_balance(&ctx.accounts.bundle_usdc_account)?;
        msg!("Bundle Balance: {}", account_balance);

        emit!(Allocated {
            from: ctx.accounts.user.key(),
            to: ctx.accounts.bundle_account.key(),
            amount: da,
            timestamp: Clock::get()?.unix_timestamp,
        });

        Ok(())
    }
  • redeem_collateral: This function allows users to redeem their shares by converting their pod tokens back into USDC based on the current NAV:


programs/ntbundle/src/lib.rs

    pub fn redeem_collateral(ctx: Context<RedeemCollateral>, redeem_amount: u64) -> Result<()> {
        let bundle_account = &mut ctx.accounts.bundle_account;
        let oracle_data = &mut ctx.accounts.oracle_data;
        let wa: u128 = redeem_amount as u128;
        let total_assets = fetch_total_equity(oracle_data, bundle_account)?;
        msg!("Total assets: {}", total_assets);
        let user_token_balance = fetch_account_balance(&ctx.accounts.user_pod_token_account)? as u128;
        msg!("UTB: {}", user_token_balance);

        //prevent withdraw if distribution is started
        //if someone is trying to withdraw while we are distributing, it will lower the total
        //underlying usdc balance available that was assigned in start_distribution
        //and the keeper wont have enough to distribute the full amount
        //this wont be a blocker as the backend will distribute automatically then set is_distribution_started to false once done
        if bundle_account.is_distribution_started {
            return Err(ErrorCode::DistributionAlreadyStarted.into());
        }

        let current_pod_total_supply = fetch_current_pod_total_supply(&ctx.accounts.vault_pod_token_mint)?;
        msg!("Current pod total supply: {}", current_pod_total_supply);

        let max_withdraw = user_token_balance.checked_mul(total_assets)
        .ok_or(ErrorCode::MathError)?
        .checked_div(current_pod_total_supply)
        .ok_or(ErrorCode::MathError)?;
        msg!("100% WD: {}", max_withdraw);

        if wa > max_withdraw || wa <= 0 {
           return err!(ErrorCode::AmountMustBeBelowMax);
        }

        let bundle_underlying_balance = bundle_account.bundle_underlying_balance;
        msg!("VB: {}", bundle_underlying_balance);
        if bundle_underlying_balance < wa || wa <= 0 {
            return err!(ErrorCode::RedemptionAmountExceeded);
        }

        let computed_user_shares:u128;
        computed_user_shares = wa.checked_mul(current_pod_total_supply)
            .ok_or(ErrorCode::MathError)?
            .checked_div(total_assets)
            .ok_or(ErrorCode::MathError)?;

        if computed_user_shares == 0 {
            return err!(ErrorCode::RedemptionAmountExceeded);
        }
        msg!("CUS: {}", computed_user_shares);

        bundle_account.bundle_underlying_balance = bundle_account
            .bundle_underlying_balance
            .checked_sub(wa)
            .ok_or(ErrorCode::MathError)?;

        let w_fee = redeem_amount.checked_mul(bundle_account.withdraw_fee)
            .ok_or(ErrorCode::MathError)?
            .checked_div(10000)
            .ok_or(ErrorCode::MathError)?;
        msg!("FC: {}", w_fee);

        let with_min_fee = redeem_amount.checked_sub(w_fee).ok_or(ErrorCode::MathError)?;
        msg!("UWG: {}", with_min_fee);

        let (_program_authority, program_authority_bump) = Pubkey::find_program_address(&[AUTHORITY_SEED], &ctx.program_id);
        let authority_seeds = &[AUTHORITY_SEED.as_ref(), &[program_authority_bump]];
        let signer_seeds = &[&authority_seeds[..]];

        let burn_accounts = Burn {
            mint: ctx.accounts.vault_pod_token_mint.to_account_info(),
            from: ctx.accounts.user_pod_token_account.to_account_info(),
            authority: ctx.accounts.user.to_account_info()
        };
        let burn_ctx = CpiContext::new_with_signer(ctx.accounts.token_program.to_account_info(), burn_accounts, signer_seeds);
        anchor_spl::token::burn(burn_ctx, computed_user_shares as u64)?;

        let net_amount_transfer_accounts = TransferChecked {
            from: ctx.accounts.bundle_usdc_account.to_account_info(),
            to: ctx.accounts.user_token_account.to_account_info(),
            authority: ctx.accounts.program_authority.to_account_info(),
            mint: ctx.accounts.usdc_mint.to_account_info(),
        };
        let net_amount_context = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            net_amount_transfer_accounts,
            signer_seeds,
        );
        anchor_spl::token::transfer_checked(net_amount_context, with_min_fee, 6)?;

        let fee_transfer_accounts = TransferChecked {
            from: ctx.accounts.bundle_usdc_account.to_account_info(),
            to: ctx.accounts.treasury_address.to_account_info(),
            authority: ctx.accounts.program_authority.to_account_info(),
            mint: ctx.accounts.usdc_mint.to_account_info(),
        };
        let fee_amount_context = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            fee_transfer_accounts,
            signer_seeds,
        );
        anchor_spl::token::transfer_checked(fee_amount_context, w_fee, 6)?;

        emit!(Redeemed {
            from: ctx.accounts.bundle_account.key(),
            to: ctx.accounts.user.key(),
            amount: wa,
            timestamp: Clock::get()?.unix_timestamp,
        });

        Ok(())
    }

A potential arbitrage opportunity exists if a malicious user can accurately predict when the NAV update occurs. By timing their allocate_funds and redeem_collateral calls strategically, they could profit from fluctuations in the NAV. However, this scenario is highly unlikely due to multiple factors:

  1. Frequent NAV Updates: NAV updates occur at least once daily and potentially more frequently if interactions with Drift or receivers' fund settlements happen multiple times per day. This reduces the feasibility of accurately predicting NAV movements.

  2. Unpredictability of Receiver Profit Events: Unlike Drift interactions, which may follow a pattern, receiver profit opportunities are highly dynamic and unpredictable. Since receivers engage in external profit strategies, there is no fixed pattern to exploit.

  3. Front-Running in Solana is Nearly Impossible: Unlike Ethereum, where front-running is a common problem due to transaction mempools, Solana does not have a public mempool. Transactions are executed almost immediately upon submission, and block producers do not have a window to reorder or sandwich transactions. This makes classic front-running attacks extremely difficult or practically impossible on Solana.

As an impact, we can mention:

  • Users engaging in arbitrage could profit at the expense of long-term investors.

  • The fairness of the platform could be questioned if repeated arbitrage is observed.

  • In extreme cases, frequent arbitrage could slightly affect the returns of other investors.

While the identified arbitrage opportunity exists, it is unlikely to be a significant issue due to the difficulty in accurately predicting NAV movements. The unpredictable nature of receiver profits, the frequent NAV updates, and Solana’s front-running-resistant design make this attack vector impractical. The proposed mitigation measures (fees and frequent NAV updates) help minimize the risk and maintain fairness for all platform users.

BVSS
Recommendation

Arbitrage is a common issue in financial markets and decentralized finance (DeFi). In many cases, it is an accepted phenomenon, and mitigation strategies depend on the specific use case of the platform. In this case, the mitigation consists of:

  • Implementing deposit and withdrawal fees: These fees will reduce the profit margins of potential arbitrageurs, disincentivizing short-term exploitation of NAV updates.

  • Frequent NAV Updates: The client has designed the system to update NAV daily at a minimum. This reduces the window of opportunity for arbitrageurs and ensures that share prices reflect current market conditions. Since the Drift vault interactions (call_drift_vault_deposit and withdraw) can happen in an irregular basis, receiver settlements are irregular, and update_oracle is also called daily, the NAV will remain up to date.

  • Solana’s Design Prevents Front-Running: Since Solana does not have a public mempool and transactions are executed almost instantly, front-running attacks are practically impossible. Arbitrageurs cannot front-run a NAV update or strategically place transactions in the same way they could on Ethereum or other blockchains with open mempools.

Remediation Comment

PARTIALLY SOLVED: The Neutral Trade team partially solved the issue by adding a deposit fee. As previously mentioned, arbitrage is a common challenge in financial markets, including DeFi. The widely accepted approach is to mitigate it by implementing tailored mechanisms suited to the specific issue at hand. In this case, introducing a deposit fee will effectively diminish the profit potential for arbitrageurs, thereby reducing the likelihood of the platform becoming a consistent target for such activities.

7.7 Lack of global allocation BPS tracking in NTBundle program

//

Informational

Description

The NTBundle program does not track the global allocation basis points (BPS), which is the cumulative sum of all allocation BPS values assigned to receivers. This omission leads to a critical issue where multiple receivers can collectively exceed the 100% allocation limit (10,000 BPS).


Specifically:


  1. The add_strategy function allows the bundle manager to create strategies and assign an arbitrary allocation_bps to each receiver without validating the cumulative total of all allocations, as shown in the snippet below:

lib.rs

    pub fn add_strategy(
        ctx: Context<AddStrategy>,
        allocation_bps: u16,
        is_raw_transfer_receiver: bool,
    ) -> Result<()> {
        let receiver_account = &mut ctx.accounts.receiver_account;
        let bundle_account = &mut ctx.accounts.bundle_account;

        // Ensure the caller is the manager
        if ctx.accounts.manager.key() != bundle_account.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        // Initialize the receiver account
        receiver_account.strategy_key = ctx.accounts.strategy_key.key();
        receiver_account.allocation_bps = allocation_bps;
        receiver_account.manager = ctx.accounts.manager.key();
        receiver_account.allowed = true;
        receiver_account.is_raw_transfer_receiver = is_raw_transfer_receiver;

        emit!(StrategyAdded {
            strategy_key: ctx.accounts.strategy_key.key(),
            allocation_bps,
        });

        Ok(())
    }


2. The handle_distribution_conditions function, called by distribute_to_receivers, validates that a single receiver's allocation_bps does not exceed 10,000. However, it does not account for the total allocation across all receivers.

lib.rs

fn handle_distribution_conditions<'info>(
    bundle_data: &mut Bundle,
    allocation_bps: u16,
    receiver_key: Pubkey,
) -> Result<u64> {
    // Ensure the distribution is started
    if !bundle_data.is_distribution_started {
        return Err(ErrorCode::DistributionNotStarted.into());
    }

    // Validate allocation basis points
    if allocation_bps > 10000 {
        return Err(ErrorCode::InvalidAllocations.into());
    }

    // Check if receiver has already been allocated
    if bundle_data.allocated_receivers.contains(&receiver_key) {
        return Err(ErrorCode::ReceiverAlreadyAllocated.into()); // or a new specific error
    }

    let available = bundle_data.distribution_base_amount;
    if available == 0 {
        return Err(ErrorCode::AllocationAmountZero.into());
    }

    // Calculate the share amount
    let share_amount = (available as u128)
        .checked_mul(allocation_bps as u128)
        .ok_or(ErrorCode::MathError)?
        .checked_div(10000)
        .ok_or(ErrorCode::MathError)? as u64;

    if share_amount == 0 || share_amount as u128 > bundle_data.distribution_base_amount {
        return Err(ErrorCode::RedemptionAmountExceeded.into());
    }

    // Update the bundle data
    bundle_data.bundle_underlying_balance = bundle_data
        .bundle_underlying_balance
        .checked_sub(share_amount as u128)
        .ok_or(ErrorCode::MathError)?;
    bundle_data.left_to_distribute = bundle_data
        .left_to_distribute
        .checked_sub(share_amount as u128)
        .ok_or(ErrorCode::MathError)?;
    msg!("Left to distribute: {}", bundle_data.left_to_distribute);
    msg!(
        "Bundle underlying balance: {}",
        bundle_data.bundle_underlying_balance
    );

    // Track the receiver
    bundle_data.allocated_receivers.push(receiver_key);

    msg!(
        "Distribution updated: VB={}, LD={}",
        bundle_data.bundle_underlying_balance,
        bundle_data.left_to_distribute
    );

    emit!(DistributedToReceivers {
        amount: share_amount,
        from: bundle_data.manager,
        receiver: receiver_key,
        timestamp: Clock::get()?.unix_timestamp,
    });

    Ok(share_amount)
}

3. If the total allocation of all receivers exceeds 10,000 BPS, the last receiver(s) to participate in the distribution will not receive funds, as the remaining allocation is already exhausted.


This creates a situation where:

  • The last receiver(s) to be distributed will fail due to insufficient remaining allocation.

  • The protocol does not enforce fairness or guarantee fund distribution for all receivers when the global allocation exceeds 10,000 BPS.

BVSS
Recommendation
  • Introduce Global Allocation Tracking:

    • Add a field in the bundle_account to store the cumulative sum of all allocation_bps values assigned to receivers.

state.rs

pub struct Bundle {
    pub global_allocation_bps: u16,
    // other fields...
}
  • Validate Allocations in add_strategy:

    • Ensure that adding a new strategy does not cause the global_allocation_bps to exceed 10,000.

lib.rs

if bundle_account.global_allocation_bps + allocation_bps > 10000 {
    return Err(ErrorCode::ExceedsGlobalAllocation.into());
}

bundle_account.global_allocation_bps += allocation_bps;
  • Update Allocations Safely:

    • If update_allocations or other functions modify an existing receiver's allocation, ensure that adjustments to the global_allocation_bps remain within limits.

Remediation Comment

SOLVED: The Neutral Trade team solved the issue by implementing a global parameter that tracks the sum of all the allocations of the receivers.

7.8 Lack of validation for allocation_bps in Receiver setup

//

Informational

Description

The add_strategy and update_allocations functions are critical for managing Receivers within the bundle system. These functions serve the following purposes:


  1. add_strategy:

    • Creates a new Receiver account with the specified allocation_bps (basis points) and other configuration settings.

    • Associates the receiver with a bundle, enabling it to participate in distributions.

  2. update_allocations:

    • Updates the allocation_bps for an existing Receiver.

    • Allows dynamic adjustment of allocation percentages for receivers in a bundle.

Both functions allow for the configuration and reconfiguration of receivers that participate in fund distributions. However, neither function validates that the allocation_bps value is within the maximum allowed limit of 10,000 bps (100%), which can lead to invalid receiver setups, as shown in the snippets below:


lib.rs

    pub fn add_strategy(
        ctx: Context<AddStrategy>,
        allocation_bps: u16,
        is_raw_transfer_receiver: bool,
    ) -> Result<()> {
        let receiver_account = &mut ctx.accounts.receiver_account;
        let bundle_account = &mut ctx.accounts.bundle_account;

        // Ensure the caller is the manager
        if ctx.accounts.manager.key() != bundle_account.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        // Initialize the receiver account
        receiver_account.strategy_key = ctx.accounts.strategy_key.key();
        receiver_account.allocation_bps = allocation_bps;
        receiver_account.manager = ctx.accounts.manager.key();
        receiver_account.allowed = true;
        receiver_account.is_raw_transfer_receiver = is_raw_transfer_receiver;

        emit!(StrategyAdded {
            strategy_key: ctx.accounts.strategy_key.key(),
            allocation_bps,
        });

        Ok(())
    }

    pub fn update_allocations(
        ctx: Context<UpdateAllocations>,
        new_allocation_bps: u16,
    ) -> Result<()> {
        let receiver_account = &mut ctx.accounts.receiver_account;

        // Ensure caller is the manager
        if ctx.accounts.manager.key() != receiver_account.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        // Update allocation
        receiver_account.allocation_bps = new_allocation_bps;

        emit!(AllocationsUpdated {
            strategy_key: receiver_account.strategy_key,
            allocation_bps: new_allocation_bps,
        });

        Ok(())
    }


As a result, Receivers with allocation_bps greater than 10,000 bps can exist, creating a state where the receiver's allocation exceeds the total available allocation for distribution, and when the distribute_to_receivers function is called, it will fail for receivers with invalid allocation_bps, as the handle_distribution_conditions function will return an error (ErrorCode::InvalidAllocations).

BVSS
Recommendation

It is recommended the following:


  • Add Validation in add_strategy:

    • Validate the allocation_bps value when creating a receiver to ensure it does not exceed 10,000 bps.

lib.rs

if allocation_bps > 10000 {
    return Err(ErrorCode::InvalidAllocations.into());
}

  • Add Validation in update_allocations:

    • Ensure that new_allocation_bps is validated when updating a receiver's allocation.

lib.rs

if new_allocation_bps > 10000 {
    return Err(ErrorCode::InvalidAllocations.into());
}
Remediation Comment

SOLVED: The Neutral Trade team solved the issue by implementing the globally tracked sum of all the allocations for the receivers. This way, the need to check that a receiver allocation is bigger than 10,000 becomes redundant.

7.9 Lack of functionality to re-enable a Receiver account

//

Informational

Description

The remove_strategy function disables a receiver account by setting its allowed field to false:


lib.rs

    pub fn remove_strategy(ctx: Context<EditStrategy>) -> Result<()> {
        let receiver_account = &mut ctx.accounts.receiver_account;

        //ensure sender is the manager
        if ctx.accounts.manager.key() != receiver_account.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        receiver_account.allowed = false;

        emit!(StrategyRemoved {
            strategy_key: receiver_account.strategy_key,
        });

        Ok(())
    }

This function is designed to prevent a receiver from participating in further operations, such as receiving funds during distribution or performing a refill. However, there is no complementary functionality to re-enable the receiver account by resetting the allowed field to true. Once a receiver is disabled, it remains permanently unusable, which introduces unnecessary limitations and reduces the protocol's flexibility.

BVSS
Recommendation

Consider creating a function that sets the allowed field of a receiver to true:


lib.rs

    pub fn enable_strategy(ctx: Context<EditStrategy>) -> Result<()> {
        let receiver_account = &mut ctx.accounts.receiver_account;

        //ensure sender is the manager
        if ctx.accounts.manager.key() != receiver_account.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        receiver_account.allowed = true;

        emit!(StrategyAllowed {
            strategy_key: receiver_account.strategy_key,
        });

        Ok(())
    }
Remediation Comment

SOLVED: The Neutral Trade team solved the issue by adding an enable_strategy entry point.

7.10 Incorrect manager role validation in update_allocations and remove_strategy

//

Informational

Description

The update_allocations function is responsible for updating the allocation basis points (bps) of a receiver. This allocation determines the share of resources assigned to the receiver during distributions. On the other hand, the remove_strategy function allows the receiver manager to disable the Strategy be used.


In both functions, a validation ensures that the signer is the manager of the receiver_account, as shown in the snippet below:


lib.rs

    pub fn update_allocations(
        ctx: Context<UpdateAllocations>,
        new_allocation_bps: u16,
    ) -> Result<()> {
        let receiver_account = &mut ctx.accounts.receiver_account;

        // Ensure caller is the manager
        if ctx.accounts.manager.key() != receiver_account.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        // Update allocation
        receiver_account.allocation_bps = new_allocation_bps;

        emit!(AllocationsUpdated {
            strategy_key: receiver_account.strategy_key,
            allocation_bps: new_allocation_bps,
        });

        Ok(())
    }

    pub fn remove_strategy(ctx: Context<EditStrategy>) -> Result<()> {
        let receiver_account = &mut ctx.accounts.receiver_account;

        //ensure sender is the manager
        if ctx.accounts.manager.key() != receiver_account.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        receiver_account.allowed = false;

        emit!(StrategyRemoved {
            strategy_key: receiver_account.strategy_key,
        });

        Ok(())
    }

However, Neutral trade has stated that it should be the bundle manager, not the receiver manager, who is responsible for updating the allocation of a receiver. The current state of the NTBundle program sets the value of the receiver manager to the same value of the bundle manager, but the bundle manager can be updated and the receiver manager can't.


If for some reason the bundle manager key needs to be updated due to a leakage or lost of the key, the Receiver allocation bps will be exposed to lost of control.

BVSS
Recommendation

Replace the validation logic to ensure that the caller matches the manager field in the bundle_account rather than the receiver_account, in both functions:


lib.rs

    pub fn update_allocations(
        ctx: Context<UpdateAllocations>,
        new_allocation_bps: u16,
    ) -> Result<()> {
        let receiver_account = &mut ctx.accounts.receiver_account;
        let bundle_account = &mut ctx.accounts.bundle_account;

        // Ensure caller is the manager
        if ctx.accounts.manager.key() != bundle_account.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        // Update allocation
        receiver_account.allocation_bps = new_allocation_bps;

        emit!(AllocationsUpdated {
            strategy_key: receiver_account.strategy_key,
            allocation_bps: new_allocation_bps,
        });

        Ok(())
    }

    pub fn remove_strategy(ctx: Context<EditStrategy>) -> Result<()> {
        let receiver_account = &mut ctx.accounts.receiver_account;

        //ensure sender is the manager
        if ctx.accounts.manager.key() != receiver_account.manager {
            return Err(ErrorCode::UnauthorizedManagerAction.into());
        }

        receiver_account.allowed = false;

        emit!(StrategyRemoved {
            strategy_key: receiver_account.strategy_key,
        });

        Ok(())
    }
Remediation Comment

SOLVED: The Neutral Trade team solved the issue by changing the manager check to the bundle account manager in the mentioned entry points.

7.11 Redundant validation of allocation_bps in distribute_to_receivers

//

Informational

Description

In the distribute_to_receivers function, there is redundant validation for the allocation_bps value of the receiver. The function retrieves allocation_bps from the receiver account, but later in the function, it validates that allocation_bps matches receiver.allocation_bps:


lib.rs

    pub fn distribute_to_receivers(ctx: Context<DistributeToReceivers>) -> Result<()> {
        let bundle_data = &mut ctx.accounts.bundle_account;
        let receiver = &ctx.accounts.receiver_account;
        let allocation_bps = receiver.allocation_bps;

        //ensure the receiver is a raw transfer receiver
        if !receiver.is_raw_transfer_receiver {
            return Err(ErrorCode::RawTransferReceiver.into());
        }

        //ensure the receiver is allowed
        if !receiver.allowed {
            return Err(ErrorCode::MustBeInWhitelist.into());
        }

        // Ensure caller is the keeper
        if ctx.accounts.manager.key() != bundle_data.keeper {
            return Err(ErrorCode::NotAuthorized.into());
        }

        // Ensure the passed-in allocation_bps matches the stored allocation_bps
        if allocation_bps != receiver.allocation_bps {
            return Err(ErrorCode::InvalidAllocations.into());
        }

As an impact, we can mention the next situations:

  • Code Inefficiency: The redundant check increases code complexity without adding value, as it will never fail.

  • Maintenance Overhead: Unnecessary validations can confuse developers and lead to longer debugging or modification times.

  • Readability Issues: Redundant logic reduces code readability and may obscure the intent of the function.

BVSS
Recommendation

Delete the code that redundantly validates the allocation bps of the Receiver account.

Remediation Comment

SOLVED: The Neutral Trade team solved the issue by modifying deleting the mentioned validations.

7.12 Redundant validation of refill_amount in perform_refill

//

Informational

Description

In the perform_refill function, there are two separate validations to ensure that the refill_amount (ra) does not exceed the receivers_borrowed_amount in the bundle_account. These validations are redundant as they perform the same check using equivalent logic:


lib.rs

    pub fn perform_refill(ctx: Context<Refill>, refill_amount: u64) -> Result<()> {
        let ra: u128 = refill_amount as u128;
        let bundle = &mut ctx.accounts.bundle_account;
        let receiver = &ctx.accounts.receiver_account;
        if ra <= 0 {
            return err!(ErrorCode::RefillAmountInvalid);
        }

        //ensure the receiver is a raw transfer receiver
        if !receiver.is_raw_transfer_receiver {
            return Err(ErrorCode::RawTransferReceiver.into());
        }

        // Ensure the receiver is allowed
        if !receiver.allowed {
            return Err(ErrorCode::MustBeInWhitelist.into());
        }

        //ensure the refill amount isnt greater than the amount borrowed
        if bundle.receivers_borrowed_amount < ra {
            return Err(ErrorCode::RefillAmountExceeded.into());
        }

        // Ensure the refill amount isn't greater than the amount borrowed
        if ra > bundle.receivers_borrowed_amount {
            return Err(ErrorCode::RefillAmountExceeded.into());
        }

As an impact, we can mention the next situations:

  • Code Inefficiency: The redundant check increases code complexity without adding value, as it will never fail.

  • Maintenance Overhead: Unnecessary validations can confuse developers and lead to longer debugging or modification times.

  • Readability Issues: Redundant logic reduces code readability and may obscure the intent of the function.

BVSS
Recommendation

Consider removing one of the mentioned validations of the perform_refill function.

Remediation Comment

SOLVED: The Neutral Trade team solved the issue by modifying deleting the mentioned validations.

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.