Rain v2 - Rain Protocol


Prepared by:

Halborn Logo

HALBORN

Last Updated 08/15/2025

Date of Engagement: June 30th, 2025 - July 25th, 2025

Summary

100% of all REPORTED Findings have been addressed

All findings

14

Critical

0

High

0

Medium

2

Low

4

Informational

8


1. Introduction

Rain Protocol engaged Halborn to conduct a security assessment of the bank and defi-lending programs from June 30th to July 25th, 2025. The security assessment was scoped to the smart contracts provided in the GitHub repository rain-v2, commit hashes and further details can be found in the Scope section of this report.


Rain Protocol is a modular lending platform composed of two programs Bank and Defi-Lending. The Bank program enables users to create either shared or personal banks, where liquidity providers (LPs) can deposit funds that are either borrowed by lending pools or delegated to MarginFi Protocol. The Defi-Lending program provides the core lending mechanism for facilitating loans, repayments, loan extensions, and liquidations through the use of pools. It also enables borrowers to repay their loans by selling collateral directly for the borrowed amount. A distinctive feature of the protocol is its time based liquidation model, where loans are liquidated strictly based on their expiration time, rather than the supplied collateral simplifying the liquidation logic.

2. Assessment Summary


Halborn was provided 4 weeks for the engagement and assigned 3 full-time security engineers to review the security of the Solana Programs in scope. The engineers are blockchain and smart contract security experts 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 Solana Program.

    • Ensure that smart contract functionality operates as intended.


In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the Rain Protocol team. The main ones were the following:

    • Introduce a prior validation of the 'is_compounding_enabled' flag from the pool configuration before invoking 'pool.new_loan' in extend_loan.

    • Iterate over the provided 'currency' vector during pool creation and explicitly validate each entry by calling the 'is_correct' method.

    • Provide the corresponding bank's token program to the 'repay' CPI call.

    • Reorder the logic in deposit function in the vault to perform the validation before modifying the 'deposited' field ensuring the unlimited deposit case is still allowed.

    • Add a validation in 'check_swap_instruction' to ensure the provided destination token account to the swap matches the 'quote_vault'.

    • Add a validation to forbid delegate operations in shared banks.



3. Test Approach and Methodology

Halborn performed a combination of a manual review of the source code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of the program assessment. While manual testing is recommended to uncover flaws in business logic, processes, and implementation; automated testing techniques help enhance coverage of programs and can quickly identify items that do not follow security best practices.


The following phases and associated tools were used throughout the term of the assessment:

    • Research into the architecture, purpose, and use of the platform.

    • Manual program source code review to identify business logic issues.

    • Mapping out possible attack vectors.

    • Thorough assessment of safety and usage of critical Rust variables and functions in scope that could lead to arithmetic vulnerabilities.

    • Scanning dependencies for known vulnerabilities (cargo audit).

    • Local runtime testing (anchor test).


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: rain-v2
(b) Assessed Commit ID: 55c6cab
(c) Items in scope:
  • defi-lending/program/src/error.rs
  • defi-lending/program/src/instructions/admin.rs
  • defi-lending/program/src/instructions/currency.rs
↓ Expand ↓
Out-of-Scope: Third party dependencies and economic attacks.
Remediation Commit ID:
  • 1846fad
  • a9e11d1
  • 6541c87
  • 67ae963
  • 82373bb
  • 07014a5
  • d502e8b
  • 7f66f76
  • 3324dd9
  • 81041ee
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

0

High

0

Medium

2

Low

4

Informational

8

Security analysisRisk levelRemediation Date
Exposure validation may overestimate available liquidity due to included unavailable interestMediumSolved - 07/31/2025
Lack of validation of the vector currencies during pool creationMediumSolved - 08/01/2025
Inconsistent Token Program Alignment Can Break LiquidationsLowSolved - 07/31/2025
Max deposit enforcement may fail due to early state mutationLowSolved - 07/31/2025
Missing validation of swap destination token account may lead to loan loss LowPartially Solved - 07/31/2025
Allowing delegation in shared banks may lead to withdrawal abuse and unfair loss distributionLowSolved - 07/31/2025
Lack of validation for token 2022 extensions InformationalAcknowledged - 08/06/2025
Multiple missing validations could lead to program panicInformationalSolved - 07/31/2025
Missing validation on withdrawal window during vault update can block withdrawalsInformationalSolved - 07/31/2025
Missing validation in config updates can block new pool creationInformationalSolved - 07/31/2025
Incomplete validation during admins and managers addition InformationalSolved - 07/31/2025
Unnecessary Account included in margin_swap_standalone Instruction InformationalAcknowledged - 08/01/2025
Fee account not enforced as canonical associated token accountInformationalAcknowledged - 08/01/2025
Unused quote field adds redundancy in quote structInformationalAcknowledged - 08/01/2025

7. Findings & Tech Details

7.1 Exposure validation may overestimate available liquidity due to included unavailable interest

//

Medium

Description

The extend_loan instruction calculates the dynamic interest accrued on the existing loan, from which 5% is deducted as a protocol fee. The remaining interest is then transferred either to the bank or to the lender’s token account, depending on the is_compounding_enabled flag in the pool configuration.


However, before evaluating the is_compounding_enabled flag, the instruction calls pool.new_loan() to perform calculations related to Loan-to-Value (LTV) and currency exposure limits for the new loan. In this call, the total_liquidity parameter is calculated as the current bank total liquidity plus the remaining dynamic interest, without verifying whether this interest will actually remain in the bank or be transferred to the lender later in the instruction.


defi-lending/program/src/instructions/loan/extend_loan.rs

 pool.new_loan(
        &ctx.accounts.loan,
        (bank.total_liquidity + dynamic_interest_minus_fees).into(),
        Some(&LtvParams {
            collateral_oracle: &collateral_oracle,
            principal_oracle: &principal_oracle,
            collateral_decimals: ctx.accounts.mint.decimals,
            principal_decimals: ctx.accounts.bank.mint.decimals,
        }),
    )?;

defi-lending/program/src/state/pool.rs

 pub fn new_loan(
        &mut self,
        loan: &Loan,
        total_liquidity: u128,
        ltv_params: Option<&LtvParams>,
    ) -> Result<()> {
       
 require_gte!(
            total_liquidity * currency.exposure as u128 / 10000,
            (currency.borrowed_amount + loan.borrowed_amount) as u128,
            LendingError::ExposureExceeded
        );

This lack of validation causes an inaccurate calculation of total liquidity used for exposure enforcement. While the LTV check for individual loans may be correctly applied, the exposure limit, which is enforced at the global pool level, may be inaccurately represented.


Consequently, the pool may permit multiple loans that individually satisfy LTV constraints but collectively exceed the configured exposure threshold. This can lead to systemic overexposure to specific currencies, increasing protocol-wide risk.


Proof of Concept
describe("take and extend USDC/SOL loan (without compounding interest)", () => {
    const LOAN = Keypair.generate();
    const EXTENDED_LOAN = Keypair.generate();

    let pool: PublicKey,
      poolOwner: PublicKey,
      bank: PublicKey,
      authorization: PublicKey,
      currency: PublicKey,
      mint: PublicKey,
      currencyId: number;
    let decimals = 6;

    let bankAccounts: TakeLoanBankAccounts;

    before(async () => {
      ({ mint, currency, currencyId } = await initMintAndCurrency(program, {
        admin: DEPLOYER,
        oracle: USDC_PRICE_FEED_ID,
        decimals,
        mintTo: [USER.publicKey],
      }));

     
      ({ pool, bank, authorization, poolOwner } = await initBankAndPool(
        program,
        bankProgram,
        {
          mint: NATIVE_MINT,
          amount: new BN(100 * LAMPORTS_PER_SOL),
          currencyId,
          currencyMint: mint,
          admin: DEPLOYER,
          oracle: PYTH_SOL_PRICE_FEED,
          isCompoundingInterest: false,
        }
      ));

      bankAccounts = await createTakeLoanBankAccounts(
        bank,
        NATIVE_MINT,
        authorization,
        SOL_ORACLES[0]
      );
    });

    it("Should take loan", async () => {
      const interest = new BN(0.2 * LAMPORTS_PER_SOL);
      const borrowedAmount = new BN(1 * LAMPORTS_PER_SOL);
      const duration = 86400 * 2;
      const collateralAmount = new BN(
        (SOL_PRICE / USDC_PRICE) * 1.5 * 10 ** decimals
      );

      const takeLoanParam: TakeLoanParam = [
        duration,
        borrowedAmount,
        collateralAmount,
        interest,
        10000,
        0,
      ];
      await sendV0Transaction(
        connection,
        [
          await createTakeLoanInstruction(
            program,
            takeLoanParam,
            USER.publicKey,
            LOAN.publicKey,
            pool,
            mint,
            currency,
            USDC_ORACLES[0],
            bankAccounts
          ),
        ],
        [USER, LOAN]
      );

      const loan = await program.account.loan.fetch(LOAN.publicKey);

      assert.deepEqual(loan.status, { ongoing: {} });
      assert.equal(Object.keys(loan.kind)[0], "classic");
    });

    it("Should extend loan", async () => {
      const interest = new BN(0.2 * LAMPORTS_PER_SOL);
      const borrowedAmount = new BN(1 * LAMPORTS_PER_SOL);
      const duration = 86400 * 2;

      const collateralAmount = new BN(
        (SOL_PRICE / USDC_PRICE) * 1.5 * 10 ** decimals
      );

      const extendLoanParam: ExtendLoanParam = [
        duration,
        borrowedAmount,
        collateralAmount,
        interest,
        10000,
        0,
      ];

      const bankBefore = await bankProgram.account.bank.fetch(
        bankAccounts.bank
      );
      const poolOwnerTokenAccount = getAssociatedTokenAddressSync(
        bankAccounts.mint as PublicKey,
        poolOwner
      );
      const poolOwnerTokenAccountBalance =
        await connection.getTokenAccountBalance(poolOwnerTokenAccount);

      await sendV0Transaction(
        connection,
        [
          await createExtendLoanInstruction(
            program,
            extendLoanParam,
            USER.publicKey,
            LOAN.publicKey,
            EXTENDED_LOAN.publicKey,
            pool,
            mint,
            currency,
            USDC_ORACLES[0],
            bankAccounts,
            [
              {
                pubkey: poolOwnerTokenAccount,
                isWritable: true,
                isSigner: false,
              },
            ]
          ),
        ],
        [USER, EXTENDED_LOAN]
      );

      const loan = await program.account.loan.fetch(LOAN.publicKey);
      const bankAfter = await bankProgram.account.bank.fetch(bankAccounts.bank);
      const extendedLoan = await program.account.loan.fetch(
        EXTENDED_LOAN.publicKey
      );
      const poolOwnerTokenAccountBalanceAfter =
        await connection.getTokenAccountBalance(poolOwnerTokenAccount);
      const interestMinusFees = loan.interest.sub(
        loan.interest.mul(LENDER_PROTOCOL_FEE).div(BASIS_POINT)
      );

      assert.deepEqual(loan.status, { repaid: {} });
      assert.deepEqual(extendedLoan.status, { ongoing: {} });
      
   assert.isTrue(bankAfter.totalLiquidity.eq(bankBefore.totalLiquidity));
      assert.isTrue(
        new BN(poolOwnerTokenAccountBalanceAfter.value.amount).eq(
          new BN(poolOwnerTokenAccountBalance.value.amount).add(
            interestMinusFees
          )
        )
      );
    });

Result





BVSS
Recommendation

To address this issue, it is recommended to validate the is_compounding_enabled flag from the pool configuration before calling pool.new_loan(). This validation should determine whether the remaining dynamic interest is included in the total_liquidity calculation. Specifically, if is_compounding_enabled is not enabled, the dynamic interest amount must not be added to the liquidity value passed to the exposure enforcement logic.

Remediation Comment

SOLVED: The Rain Protocol team solved this issye by calling the should_compound() function before invoking pool.new_loan. This ensures that the total bank liquidity passed to new_loan correctly includes or excludes dynamic_interest_minus_fees, depending on whether should_compound() returns true (compounding enabled) or false (non-compounding).

Remediation Hash
1846fad851205639096f271cc0efe7d65ddb84a3

7.2 Lack of validation of the vector currencies during pool creation

//

Medium

Description

The create_pool instruction requires several configuration parameters, including curve, limits, and a vector of CurrencyConfig entries. This currencies vector is used to initialize the pool’s internal list of supported currencies, where each entry contains currency, currency_ltvand exposure.


While currency_ltv and exposure are properly validated when updated through a pool configuration updates, no such validation occurs during the initial pool creation. Consequently, it is possible to initialize a pool with invalid or extreme values for these fields—such as currency_ltv or exposure set to 100% or higher.


This lack of validation can lead to several issues:

  • The pool becoming unusable due to overly restrictive parameters,

  • Excessively risky loans being allowed, or

  • Incorrect position or risk calculations throughout the protocol.

defi-lending/program/src/instructions/pool/mod.rs

***pool = Pool {
        owner: ctx.accounts.owner.key(),
        seed,

        bump: ctx.bumps.pool,
        curve: curve.into(),
        limits: limits.into(),

        created_at: timestamp,
        ..Default::default()
    };

    if !currencies.is_empty() {
        for (i, c) in currencies.into_iter().enumerate() {
            pool.currencies[i] = c.into();
        }
        pool.currencies_updated_at = timestamp;
    }

    Ok(())

Proof of Concept
it("Creating a misconfigured pool", async () => {
    const apr = 1000;

    const poolParam: CreatePoolParam = [
      Array.from(POOL_SEED),
      {
        apr: [{ apr }],
      },
      {
        minDuration: 60 * 60 * 24,
        maxDuration: 60 * 60 * 24 * 20,
        maxAmountUsd: 6000,
        minAmountUsd: 100,
      },
      [
        {
          currency: 1,
          currencyLtv: 10000, 
          exposure: 12000, 
        },
      ],
    ];

    await sendV0Transaction(
      connection,
      [await createCreatePoolInstruction(program, poolParam, ADMIN.publicKey)],
      [ADMIN]
    );

    const pool = await program.account.pool.fetch(findPool(POOL_SEED));
    assert.isTrue(pool.owner.equals(ADMIN.publicKey));
    assert.deepEqual(pool.seed, Array.from(POOL_SEED));
    assert.deepEqual(pool.status, { disabled: {} });

    assert.isTrue(pool.curve.apr[0].apr == apr);
    assert.isTrue(pool.conditions.isEnabled == 0);

    delete pool.limits["reserved"];
    assert.deepEqual(pool.limits, poolParam[2]);

    assert.isTrue(pool.currentLoan.eq(ZERO));
    assert.isTrue(pool.totalLiquidations.eq(ZERO));
    assert.isTrue(pool.totalLoans.eq(ZERO));
    assert.isTrue(pool.totalInterest.eq(ZERO));
    assert.isTrue(pool.createdAt.gt(ZERO));
    assert.isTrue(pool.updatedAt.eq(ZERO));

    delete pool.currencies[0].borrowedAmount;
    assert.deepEqual(pool.currencies[0], poolParam[3][0]);
    assert.isTrue(pool.currencies[0].currencyLtv == 10000);
    assert.isTrue(pool.currencies[0].exposure == 12000);
  });

Result



BVSS
Recommendation

To address this issue, it is recommended to iterate over the provided currencies vector during pool creation and explicitly validate each entry by invoking the is_correct() method. This ensures that each CurrencyConfig entry’s currency_ltv and exposure values remain within expected and safe bounds, maintaining consistency with the update logic and preventing the creation of misconfigured or high-risk pools.

Remediation Comment

SOLVED: The Rain Protocol team solved this issue by adding a validation within the existing iteration over the currencies vector, invoking is_correct() to ensure each entry’s values are within the defined acceptable ranges.


Remediation Hash
a9e11d1f494e03edb1cf66a7ec8d538731194993

7.3 Inconsistent Token Program Alignment Can Break Liquidations

//

Low

Description

In the DeFi lending protocol, authorized managers can liquidate expired loans through a sequence of instructions: liquidate_open, liquidate_startswapliquidate_end, and finally liquidate_close, which repays the loan and closes both the loan and quote vaults.


However, the program assumes that the collateral token (stored in quote_vault after the swap) belongs to the same token program as the borrow token (bank.mint). If the collateral and borrow tokens are from different token programs, the cross-program invocation (CPI) call to bank::repay or the call to close loan_vault will fail. This leads to the following risks:


  1. Liquidation cannot be completed for such loans.

  2. The swapped repayment amount remains stuck in the quote_vault.

defi-lending/program/src/instructions/liquidation/liquidate_close.rs

#[derive(Accounts)]
pub struct LiquidateClose<'info> {
    /// CHECK: using has_one constraint on quote
    #[account(mut)]
    pub payer: UncheckedAccount<'info>,
    /// CHECK: using has_one constraint on loan
    pub borrower: UncheckedAccount<'info>,
    #[account(
        mut,
        constraint = keys_equal(&borrower_stats.load()?.owner, borrower.key),
    )]
    pub borrower_stats: AccountLoader<'info, UserStats>,
    pub mint: Box<InterfaceAccount<'info, Mint>>,

    #[account(
        mut,
        has_one = payer,
        has_one = loan,
        constraint = keys_equal(&quote.in_vault, &loan_vault.key()),
        constraint = keys_equal(&quote.out_vault, &quote_vault.key()),
        close = payer
    )]
    pub quote: Box<Account<'info, Quote>>,
    #[account(mut)]
    pub quote_vault: Box<InterfaceAccount<'info, TokenAccount>>,

    #[account(
        mut,
        has_one = borrower,
        has_one = pool,
        constraint = keys_equal(&loan.bank, &bank.bank.key()),
        constraint = keys_equal(&loan.principal, &bank.mint.key()),
        constraint = keys_equal(&loan.collateral, &mint.key()),
        constraint = loan.status == LoanStatus::Ongoing @LendingError::LoanIsClosed,
    )]
    pub loan: Box<Account<'info, Loan>>,
    #[account(mut)]
    pub loan_vault: Box<InterfaceAccount<'info, TokenAccount>>,

    #[account(
        mut,
        has_one = mint,
        constraint = currency.load()?.currency_id == loan.currency,
    )]
    pub currency: AccountLoader<'info, Currency>,

    #[account(mut)]
    pub pool: Box<Account<'info, Pool>>,
    pub bank: BankAccounts<'info>,

    pub system_program: Program<'info, System>,
    pub token_program: Interface<'info, TokenInterface>,
}
    fn repay(&self) -> CpiContext<'_, '_, '_, 'info, Repay<'info>> {
        CpiContext::new(
            self.bank.bank_program.to_account_info(),
            Repay {
                trustee: self.pool.to_account_info(),
                sender: self.quote.to_account_info(),
                vault: self.bank.vault.to_account_info(),
                mint: self.bank.mint.to_account_info(),
                token_account: self.bank.token_account.to_account_info(),
                bank: self.bank.bank.to_account_info(),
                authorization: self.bank.authorization.to_account_info(),
                sender_token_account: Some(self.quote_vault.to_account_info()),
                token_program: self.token_program.to_account_info(),
            },
        )
    }

    fn close_loan_vault(&self) -> CpiContext<'_, '_, '_, 'info, CloseAccount<'info>> {
        let cpi_accounts = CloseAccount {
            authority: self.pool.to_account_info(),
            account: self.loan_vault.to_account_info(),
            destination: self.payer.to_account_info(),
        };
        let cpi_program = self.token_program.to_account_info();
        CpiContext::new(cpi_program, cpi_accounts)
    }
pub struct BankAccounts<'info> {
    #[account(
        mut,
        // owner = bank::ID,
        has_one = vault,
        has_one = mint,
    )]
    pub bank: Box<Account<'info, Bank>>,
    /// CHECK: UncheckedAccount will consume less CU
    pub authorization: UncheckedAccount<'info>,
    // pub authorization: AccountLoader<'info, Authorization>,
    #[account(
        mut,
        owner = bank::ID,
        has_one = mint,
        has_one = token_program
    )]
    pub vault: AccountLoader<'info, Vault>,
    pub mint: Box<InterfaceAccount<'info, Mint>>,

    #[account(mut)]
    /// CHECK: UncheckedAccount will consume less CU
    pub token_account: UncheckedAccount<'info>,

    pub bank_program: Program<'info, BankProgram>,
    pub token_program: Interface<'info, TokenInterface>,
}

NOTE: A similar issue exists in instant_sell_standalone and instant_sell_close, although it currently does not pose a security risk.

Proof of Concept
describe("defi-lending:liquidate (Shared bank)", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());
  const bankProgram = anchor.workspace.Bank as Program<Bank>;
  const program = anchor.workspace.DefiLending as Program<DefiLending>;

  const jupiterProgram = new LegacyProgram<Jupiter>(IDL, JUPITER_PROGRAM_ID);

  const provider = program.provider;
  const connection = provider.connection;

  const pyth = new PythSolanaReceiver({
    connection,
    wallet: {} as anchor.Wallet,
  });

  // @ts-ignore
  const DEPLOYER: Keypair = provider.wallet.payer;
  const USER: Keypair = Keypair.generate();
  let LOOKUP_TABLE: PublicKey = PublicKey.default;

  let USDC_PRICE = 0;
  let SOL_PRICE = 0;

  it("Setting up", async () => {
    USDC_PRICE = await getPythPrice(pyth, USDC_ORACLES[0]);
    SOL_PRICE = await getPythPrice(pyth, SOL_ORACLES[0]);

    const userPrincipalTokenAccount = getAssociatedTokenAddressSync(
      NATIVE_MINT,
      USER.publicKey
    );
    const [lookupTableInst, lookupTableAddress] =
      AddressLookupTableProgram.createLookupTable({
        authority: DEPLOYER.publicKey,
        payer: DEPLOYER.publicKey,
        recentSlot: await connection.getSlot("finalized"),
      });

    LOOKUP_TABLE = lookupTableAddress;

    await sendV0Transaction(
      connection,
      [
        SystemProgram.transfer({
          fromPubkey: DEPLOYER.publicKey,
          toPubkey: USER.publicKey,
          lamports: 100 * LAMPORTS_PER_SOL,
        }),
        await createCreateUserStatsInstruction(
          program,
          USER.publicKey,
          null,
          DEPLOYER.publicKey
        ),
        createAssociatedTokenAccountInstruction(
          DEPLOYER.publicKey,
          userPrincipalTokenAccount,
          USER.publicKey,
          NATIVE_MINT
        ),
        lookupTableInst,
      ],
      [DEPLOYER, USER]
    );

    const addAddressesInstruction = AddressLookupTableProgram.extendLookupTable(
      {
        payer: DEPLOYER.publicKey,
        authority: DEPLOYER.publicKey,
        lookupTable: LOOKUP_TABLE,
        addresses: [
          PublicKey.default,
          SystemProgram.programId,
          TOKEN_PROGRAM_ID,
          ASSOCIATED_TOKEN_PROGRAM_ID,
          program.programId,
          bankProgram.programId,
          SYSVAR_RENT_PUBKEY,
          NATIVE_MINT,
          SYSVAR_INSTRUCTIONS_PUBKEY,
          JUPITER_PROGRAM_ID,
          USDC_ORACLES[0],
          SOL_ORACLES[0],
          getAssociatedTokenAddressSync(NATIVE_MINT, RAIN_AUTHORITY),
        ],
      }
    );
    await sendV0Transaction(connection, [addAddressesInstruction], [DEPLOYER]);
  });

  describe("Multistep liquidate on classic loan SOL/USDC", () => {
    const LOAN = Keypair.generate();
    let pool: PublicKey,
      bank: PublicKey,
      authorization: PublicKey,
      currency: PublicKey,
      mint: PublicKey,
      currencyId: number;
    let decimals = 6;

    let bankAccounts: TakeLoanBankAccounts;
    let raydiumPoolKeys: RaydiumPoolKeys;
    let lpMint = Keypair.generate();

    before(async () => {
      ({ mint, currency, currencyId } = await initMint2022AndCurrency(program, {
        admin: DEPLOYER,
        oracle: USDC_PRICE_FEED_ID,
        decimals,
        mintTo: [USER.publicKey],
      }));

      
      // Initialize bank and pool, then destructure the returned object to assign values to pool, bank, and authorization
      ({ pool, bank, authorization } = await initSharedBankAndPool(
        program,
        bankProgram,
        {
          mint: NATIVE_MINT,
          amount: new BN(100 * LAMPORTS_PER_SOL),
          currencyId,
          admin: DEPLOYER,
          oracle: PYTH_SOL_PRICE_FEED,
          lpMint
        }
      ));

      bankAccounts = await createTakeLoanBankAccounts(
        bank,
        NATIVE_MINT,
        authorization,
        SOL_ORACLES[0]
      );

      const solAmount = 100;
      const usdcAmount = (solAmount * SOL_PRICE) / USDC_PRICE;

      raydiumPoolKeys = await initRaydiumPoolwith2022(connection, {
        mintAuthority: DEPLOYER,
        mintA: NATIVE_MINT,
        mintB: mint,
        amountA: new BN(solAmount * LAMPORTS_PER_SOL),
        amountB: new BN(usdcAmount * 10 ** decimals),
      });

      await new Promise((f) => setTimeout(f, 2000));
    });

    it("Should take loan", async () => {
      const interest = new BN(0.2 * LAMPORTS_PER_SOL);
      const borrowedAmount = new BN(1 * LAMPORTS_PER_SOL);
      const collateralAmount = new BN(
        (SOL_PRICE / USDC_PRICE) * 1.5 * 10 ** decimals
      );
      const duration = 86400 * 2;

      const takeLoanParam: TakeLoanParam = [
        duration,
        borrowedAmount,
        collateralAmount,
        interest,
        10000,
        0,
      ];

      await sendV0Transaction(
        connection,
        [
          await createTakeLoanInstruction(
            program,
            takeLoanParam,
            USER.publicKey,
            LOAN.publicKey,
            pool,
            mint,
            currency,
            USDC_ORACLES[0],
            bankAccounts,
            USER.publicKey,
            TOKEN_2022_PROGRAM_ID
          ),
        ],
        [USER, LOAN]
      );
    });

    it("Liquidate multistep", async () => {
      const collateralAmount = new BN(
        (SOL_PRICE / USDC_PRICE) * 1.5 * 10 ** decimals
      );
      const quotedOutAmount = collateralAmount
        .mul(new BN((USDC_PRICE * 10 ** 9) / SOL_PRICE))
        .div(new BN(10 ** decimals));

      const jupiterSwapInstruction = await makeJupiterSwapInstruction(
        jupiterProgram,
        {
          amountIn: collateralAmount,
          quotedOutAmount,
          plateformFees: 100,
          slippageBps: new BN(10000),
        },
        DEPLOYER.publicKey,
        raydiumPoolKeys,
        mint,
        NATIVE_MINT
      );

      await sendV0Transaction(
        connection,
        [
          await createliquidateOpenInstruction(
            program,
            [jupiterSwapInstruction.data],
            DEPLOYER.publicKey,
            LOAN.publicKey,
            pool,
            bankAccounts.mint as PublicKey
          ),
        ],
        [DEPLOYER]
      );

      await sendV0Transaction(
        connection,
        [
          await createLiquidateStartInstruction(
            program,
            DEPLOYER.publicKey,
            LOAN.publicKey,
            pool,
            mint,
            TOKEN_2022_PROGRAM_ID
          ),
          jupiterSwapInstruction,
          await createLiquidateEndInstruction(
            program,
            DEPLOYER.publicKey,
            LOAN.publicKey
          ),
        ],
        [DEPLOYER]
      );

      await sendV0Transaction(
        connection,
        [
          await createLiquidateCloseInstruction(
            program,
            USER.publicKey,
            LOAN.publicKey,
            pool,
            mint,
            currency,
            DEPLOYER.publicKey,
            bankAccounts,
            TOKEN_2022_PROGRAM_ID
          ),
        ],
        [DEPLOYER]
      );
    });
  });
});

Result

CloseAccount TokenProgram Mismatch


Repay CPI TokenProgram Mismatch


BVSS
Recommendation

It is recommended to provide the corresponding bank's token program to the repay CPI call to ensure proper token program alignment.

Remediation Comment

SOLVED: The Rain Protocol team solved the issue by providing correct token program for CPI

Remediation Hash
6541c8784092be29935aa42fbca4a3d19be26dfa

7.4 Max deposit enforcement may fail due to early state mutation

//

Low

Description

In the vault logic, the max_deposit parameter defines the maximum amount of tokens that can be deposited into a vault. It is initially set to u64::MAX by default during vault creation and can later be reduced by the admin via the update_vault instruction.


However, when a deposit occurs after modifying max_deposit, the vault update logic incorrectly applies the deposit amount before performing the validation check. Specifically:

  • The vault’s deposited field is incremented by the deposit amount first.

  • The program then checks whether deposited + amount > max_deposit.

As a result, the validation condition unintentionally counts the deposit amount twice.

bank/program/src/state/vault.rs

    pub fn deposit(&mut self, amount: u64, now: u64) -> Result<()> {
        self.deposited_at = now;
        self.deposited += amount;

        if self.max_deposit == u64::MAX {
            return Ok(());
        }

        if self.deposited + amount > self.max_deposit {
            return err!(BankError::DepositLimitExceeded);
        }

This causes premature rejection of valid deposits. Even when a user’s deposit should be allowed under the actual limit, it may fail due to this double counting.


This issue becomes particularly problematic when max_deposit is lowered to a stricter threshold, resulting in unexpected failures and reducing the vault’s usable capacity.

BVSS
Recommendation

To address this issue, it is recommended to reorder the logic to perform the validation before modifying the deposited field ensuring the unlimited deposit case is still allowed as shown below.


if self.max_deposit != u64::MAX && self.deposited + amount > self.max_deposit {
    return err!(BankError::DepositLimitExceeded);
}

self.deposited_at = now;
self.deposited += amount;


This prevents double-counting the deposit amount during validation.

Remediation Comment

SOLVED: The Rain Protocol team solved this issue by implementing the suggested remediation.

Remediation Hash
67ae96366db42d9a0742077874159de1bc6e6a83

7.5 Missing validation of swap destination token account may lead to loan loss

//

Low

Description

The liquidate_open instruction allows a Config manager to initiate a Liquidate Quote, during which both the Quote account and its quote_vault token account are initialized for the loan being targeted. The quote_vault is set up with the mint corresponding to the bank’s main mint (the loan’s principal mint).


The Quote account contains fields such as min_swapped_amount, which is initialized to 0 by default, and borrower, which is assigned to the liqor account. The liqor must sign the transaction alongside the Config manager, and can either be the same signer or a separate account.


The liquidation process then follows the liquidate_introspective instruction, which enforces a specific instruction sequence: liquidate_open → swap operation → liquidate_end. Importantly, only the Quote.borrower is authorized to invoke this sequence.


However, several key validations are missing:

  • There is no verification that the swap operation is executed using the quote_vault's mint (i.e., the principal currency expected by the bank).

  • There is no enforcement that the output of the swap is directed to the quote_vault.


Consequently, a malicious quote's borrower could perform a swap that exchanges the collateral for a different token than the loan's principal one, and route the output to an account it controls instead of the intended quote_vault, due to the liquidate_end instruction only verifies that the balance of the quote_vault is greater than or equal to min_swapped_amount. Since this field is initialized to 0 and never updated during the flow, this check will always pass, even if the vault holds no actual tokens.


This can lead to a total loss of the loan value for the bank, and unauthorized asset diversion by the quote borrower.


defi-lending/program/src/instructions/liquidation/liquidate_introspection.rs

fn check_swap_instruction(
    index: usize,
    liqor: &Pubkey,
    quote: &Quote,
    instructions_sysvar: &AccountInfo,
) -> Result<()> {
    let instruction = load_instruction_at_checked(index, instructions_sysvar)?;
    if !keys_equal(&instruction.program_id, &Jupiter::id()) {
        return err!(LendingError::NotAuthorized);
    }

    if !keys_equal(&instruction.accounts[2].pubkey, liqor)
        && !keys_equal(&instruction.accounts[1].pubkey, liqor)
    {
        return err!(LendingError::NotAuthorized);
    }

    if !instruction.accounts[2].is_signer && !instruction.accounts[1].is_signer {
        return err!(LendingError::NotAuthorized);
    }

    let data_length = quote.data_length.try_into()?;
    if instruction.data.len() != data_length {
        return err!(LendingError::NotAuthorized);
    }


defi-lending/program/src/instructions/liquidation/liquidate_introspection.rs

pub fn liquidate_end(ctx: Context<LiquidateEnd>) -> Result<()> {
    let min_swapped_amount = {
        let clock = Clock::get()?;
        let quote = &mut ctx.accounts.quote;
        quote.validate_quote(&clock, QuoteStep::Start, QuoteType::Liquidate)?;
        quote.min_swapped_amount
    };

    require_gte!(
        ctx.accounts.quote_vault.amount,
        min_swapped_amount,
        LendingError::WrongAmount
    );
    Ok(())

Proof of Concept
 it("Liquidate multistep - swapping to another user token account", async () => {
      const collateralAmount = new BN(
        (SOL_PRICE / USDC_PRICE) * 1.5 * 10 ** decimals
      );
      const quotedOutAmount = collateralAmount
        .mul(new BN((USDC_PRICE * 10 ** 9) / SOL_PRICE))
        .div(new BN(10 ** decimals));

        const user2DestinationAccount = 
        getAssociatedTokenAddressSync(NATIVE_MINT, USER2.publicKey);

      const jupiterSwapInstruction = await makeJupiterSwapInstruction( 
        jupiterProgram,
        {
          amountIn: collateralAmount,
          quotedOutAmount,
          plateformFees: 100,
          slippageBps: new BN(10000),
        },
        DEPLOYER.publicKey,
        raydiumPoolKeys,
        mint,
        NATIVE_MINT,
        user2DestinationAccount
      
      );
    
      await sendV0Transaction(
        connection,
        [
          await createliquidateOpenInstruction(
            program,
            [jupiterSwapInstruction.data],
            DEPLOYER.publicKey,
            LOAN.publicKey,
            pool,
            bankAccounts.mint as PublicKey
          ),
        ],
        [DEPLOYER]
      );
    

      await sendV0Transaction(
        connection,
        [
          await createLiquidateStartInstruction(
            program,
            DEPLOYER.publicKey,
            LOAN.publicKey,
            pool,
            mint
          ),
          jupiterSwapInstruction,
          await createLiquidateEndInstruction(
            program,
            DEPLOYER.publicKey,
            LOAN.publicKey
          ),
        ],
        [DEPLOYER]
      );
      

      await sendV0Transaction(
        connection,
        [
          await createLiquidateCloseInstruction(
            program,
            USER.publicKey,
            LOAN.publicKey,
            pool,
            mint,
            currency,
            DEPLOYER.publicKey,
            bankAccounts
          ),
        ],
        [DEPLOYER]
      );
      

      const loan = await program.account.loan.fetch(LOAN.publicKey);

      assert.deepEqual(loan.status, { liquidated: {} });
      assert.isTrue(loan.liquidatedAt.gt(ZERO));
    });
  });

Result



BVSS
Recommendation

To address this issue, it is recommended to add a validation in check_swap_instruction to ensure the provided destination token account to the swap matches the quote_vault.

Remediation Comment

PARTIALLY SOLVED: The Rain Protocol team partially solved this issue by adding a validation to ensure the sixth account, corresponding to the destination token account, in the swap instruction (sharedAccountsRoute) matches the quote_vault.

Remediation Hash
82373bba594ebc7dcc7541364cf2dd2416f9544e

7.6 Allowing delegation in shared banks may lead to withdrawal abuse and unfair loss distribution

//

Low

Description

The current design of shared banks permits liquidity delegation exclusively by trusted config managers.


bank/program/src/instructions/delegate/marginfi.rs

pub struct DepositMarginfi<'info> {
    #[account(
        constraint = config.load()?.is_manager(authority_or_admin.key) || bank.can_delegate(&authority_or_admin.key()) @ BankError::NotAuthorized,
    )]
    pub authority_or_admin: Signer<'info>,
    pub config: AccountLoader<'info, Config>,

While this restriction reduces the risk surface, the protocol lacks adequate controls to ensure that delegation and subsequent liquidity withdrawals uphold correct accounting guarantees, especially when active delegated positions and potential losses are involved.


Banks operate under a liquidity accounting model where both available and delegated liquidity contribute to the total pool. However, losses from delegated positions are only reflected in the system once those positions are explicitly refreshed. Until that refresh occurs, any reduction in value due to unrealized losses remains invisible to the protocol’s internal state.


This design allows a window of inconsistency where users may initiate withdrawals based on outdated accounting. If a user withdraws before the delegated position is refreshed, the protocol may allow it to exit the shared bank at the full nominal value, as the delegated funds are still counted at face value.


Once the delegated position is updated and losses are recognized, the remaining users in the pool absorb the full impact through a downward adjustment in the LP token rate—despite one or more users having already exited without bearing any share of the loss. This results in uneven and unfair loss distribution among participants.


Conversely, if a delegated position is refreshed before a user withdraws, the user will correctly absorb a proportional share of the realized loss via the updated LP rate. However, this does not guarantee fair access to the remaining available liquidity. Since the protocol does not reserve or isolate withdrawable liquidity on a per-user basis, early withdrawers may deplete the pool’s liquid funds, preventing others from withdrawing, even if they have accepted the same proportional losses.


This leads to liquidity contention, where users remaining in the shared bank after delegation face liquidity starvation and potentially residual losses from further negative events affecting the delegated position.


In both scenarios, the protocol’s lack of enforcement regarding the timing of withdrawals relative to delegated position updates, isolation of loss impact per depositor, and proper liquidity allocation during concurrent withdrawals creates significant risks.



bank/program/src/instructions/bank/withdraw.rs

pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    require_gt!(amount, 0);

    let clock: Clock = Clock::get()?;
    let timestamp: u64 = clock.unix_timestamp.try_into()?;

    let amount_to_transfer = match &mut ctx.accounts.bank.bank_type {
        BankType::Personal(_) => {
            // For personnal withdraw`amount` is in the bank token
            require!(
                ctx.accounts.withdraw_request.is_none(),
                BankError::NotAuthorized
            );
            ctx.accounts.bank.personnal_withdraw(amount, timestamp)?;

            amount
        }
        BankType::Shared(shared) => {
            let lp_rate = shared.lp_rate;

            let (withdrawable_amount, held_amount, remaining_shares) =
                ctx.accounts.bank.shared_withdraw(amount, timestamp)?;

bank/program/src/state/bank.rs

 pub fn shared_withdraw(&mut self, shares: u64, now: u64) -> Result<(u64, u64, u64)> {
        // For shared pool, withdraw `amount` is in share token
        let amount = {
            let shared_bank = self
                .get_shared_bank()
                .expect("BankType should be SharedBank");
            shared_bank.compute_token_amount(shares)?
        };

        // Asking for more than deposited +/- pnl
        if amount > self.total_liquidity {
            return err!(BankError::AmountTooBig);
        }

        if amount > self.available_liquidity {
            return err!(BankError::NotEnoughLiquidity);
        }

        // No loans, everybody can withdraw any amount
        if self.available_liquidity + self.delegated_liquidity >= self.total_liquidity {
            self.withdraw(amount, now)?;
            return Ok((amount, 0, 0));
        }

BVSS
Recommendation

To address this issue, it is recommended to add a validation to forbid delegate operations in shared banks.

Remediation Comment

SOLVED: The Rain Protocol team solved this issue by removing the constraint which allows the config manager to be able to call InitMarginfi ensuring only delegations are allowed for Personal Banks and only the bank owner can perform such operations.


Remediation Hash
07014a5c28d480b6f52437ec82f83a884db45387

7.7 Lack of validation for token 2022 extensions

//

Informational

Description

During vault creation, the admin must provide several accounts, including the mint that defines the vault’s token and its associated token account.


This mint is subsequently used for all token-based operations related to the bank, such as deposits and withdrawals. While the program supports both SPL Token and SPL Token-2022 standards, it does not validate the mint's extension types during vault initialization.


This lack of validation introduces risks in edge cases where unsupported extensions are present.


If the mint includes the TransferFeeConfig extension, deposits to the vault will result in a lower actual token transfer than expected. However, the program currently uses the user-specified amount to update liquidity balances, causing a discrepancy between expected and actual vault balances. Additionally, in shared bank configurations, this discrepancy can allow users to receive LP tokens based on the input amount rather than the effective transferred amount, leading to over-minting and liquidity drift.


Accurate token accounting is essential for maintaining consistency and integrity across operations. Any mismatch can affect other sensitive calculations and potentially be exploited.


Although the risk surface is limited—since only Config administrators are authorized to provide the mint during vault creation—the absence of explicit validation for supported mint extensions introduces the possibility of subtle, systemic inconsistencies that may be difficult to detect or trace during protocol operation.

BVSS
Recommendation

It is recommended to explicitly validating the extensions present in the provided mint account during vault creation. This validation should ensure that only supported extensions compatible with the vault's accounting and operational logic are allowed.

Remediation Comment

ACKNOWLEDGED: The Rain Protocol team acknowledged this finding.

7.8 Multiple missing validations could lead to program panic

//

Informational

Description

Lack of liquidity availability check during process_withdraw_request may cause panic due to arithmetic overflow


In shared banks, users who have deposited funds are allowed to fully withdraw their balance only if there are no active loans. Otherwise, they may perform a partial withdrawal, while a portion of their position, representing potential exposure to active loan risk, remains locked. This locked amount can be reclaimed only after the cooldown period by invoking the process_withdraw_request instruction.


When executed, this instruction recalculates the withdrawable amount using the current LP rate, accounting for any realized losses from liquidations during the cooldown period.


However, if active loans exist and multiple users have pending withdrawal requests, the bank's remaining available liquidity in the bank may be insufficient to satisfy all outstanding requests. In such cases, the amount a user is eligible to withdraw by calling process_withdraw_request may exceed the bank’s current available liquidity.


bank/program/src/instructions/process_withdraw.rs

pub fn process_withdraw_request(ctx: Context<ProcessWithdrawRequest>) -> Result<()> {
    let bank = &mut ctx.accounts.bank;
    let withdraw_request = &mut ctx.accounts.withdraw_request;

    let clock: Clock = Clock::get()?;
    let timestamp: u64 = clock.unix_timestamp.try_into()?;

    let amount = bank.withdraw_request(withdraw_request, timestamp)?;
    let mut vault = ctx.accounts.vault.load_mut()?;

    vault.withdraw(amount, timestamp)?;


It has been identified that the withdraw_request function, called internally by process_withdraw_request, does not verify that the requested withdrawal amount is within the available liquidity. Instead, it calls withdrawwhich performs a direct subtraction (available_liquidity -= amount_to_withdraw) without prior bounds checking. This can cause an arithmetic underflow and lead to a program panic.



bank/program/src/state/bank.rs

pub fn withdraw_request(
        &mut self,
        withdraw_request: &WithdrawRequest,
        now: u64,
    ) -> Result<u64> {
        let shared_bank = self
            .get_shared_bank()
            .expect("BankType should be SharedBank");

        let amount = if shared_bank.lp_rate >= withdraw_request.lp_rate {
            withdraw_request.amount_remaining
        } else {
            shared_bank.compute_token_amount(withdraw_request.remaining_shares)?
        };

        shared_bank.burn(withdraw_request.remaining_shares);
        self.withdraw(amount, now)?;

bank/program/src/state/bank.rs

 pub fn withdraw(&mut self, amount: u64, now: u64) -> Result<()> {
        require_gt!(now, self.frozen_until, BankError::BankIsFrozen);

        self.available_liquidity -= amount;
        self.total_liquidity -= amount;

Potential out-of-bounds memory access due to missing size validation in decode_jupiter_route


The decode_jupiter_route function is invoked across multiple instructions, such as instant_sell, liquidation, and margin_swap, to deserialize and interpret the Jupiter-encoded swap route data.


This function receives a serialized byte vector and readthe first 8 bytes to get the the discriminator, which determines the type of swap route being used, either SharedAccountRoute (for routes that share accounts across multiple swaps) or Route (for simple, single-path swaps).


However, the function attempts to read the discriminator directly without first validating that the input data is at least 8 bytes in length. This introduces the risk of an out-of-bounds memory access if a byte slice shorter than 8 bytes is provided. In such cases, the program may panic due to a failed slice operation, potentially resulting in transaction failure or denial of service


defi-lending/program/src/jupiter.rs

    pub fn decode_jupiter_route(jup_data: &Vec<u8>) -> Result<Box<dyn JupiterRoute>> {
        let ix_data: &mut &[u8] = &mut jup_data.as_slice();

        let mut discriminator: [u8; 8] = [0; 8];
        //extrae el discriminator de la jup_data
        discriminator.copy_from_slice(&jup_data[0..8]);
        //compara el discriminator con el de SharedAccountsRoute o Route
        match discriminator.as_ref() {
            d if d == SharedAccountsRoute::DISCRIMINATOR => {
                Ok(Box::new(SharedAccountsRoute::try_from_slice(ix_data)?))

Missing validation on optional fields may cause unwrap() panic


The deposit and withdraw instructions allow users to interact with a bank by depositing or withdrawing assets. For personal banks, only the bank's authority can perform these operations. In contrast, shared banks are open to all users; anyone can deposit assets into the vault and receive LP shares, which can later be burned to withdraw the original amount adjusted for profit or loss (PnL).


When the bank type is Shared, the program attempts to mint or burn LP shares using the associated lp_mint and user_lp_token_account. However, if lp_mint is provided but user_lp_token_account is missing, the instruction attempts to unwrap a None value. This results in a runtime panic instead of returning an error gracefully.


bank/program/src/instructions/bank/deposit.rs

fn mint_shares(&self) -> CpiContext<'_, '_, '_, 'info, MintTo<'info>> {
    let cpi_accounts = MintTo {
        mint: self.lp_mint.as_ref().unwrap().to_account_info(),
        to: self
            .user_lp_token_account
            .as_ref()
            .unwrap()
            .to_account_info(),
        authority: self.vault.to_account_info(),
    };
    let cpi_program = self.token_program.to_account_info();
    CpiContext::new(cpi_program, cpi_accounts)
}

bank/program/src/instructions/bank/withdraw.rs

fn burn_shares(&self) -> CpiContext<'_, '_, '_, 'info, Burn<'info>> {
    let cpi_accounts = Burn {
        mint: self.lp_mint.as_ref().unwrap().to_account_info(),
        from: self
            .user_lp_token_account
            .as_ref()
            .unwrap()
            .to_account_info(),
        authority: self.user.to_account_info(),
    };
    let cpi_program = self.token_program.to_account_info();
    CpiContext::new(cpi_program, cpi_accounts)
}

BVSS
Recommendation

To address this issue, consider to implement the following measures:

  • Add a validation in withdraw_request to ensure the amount to withdraw does not exceed the available liquidity.

  • Add a validation in decode_jupiter_route to ensure the jup_date size is greater that the discriminator expected and handle gracefully the error if not.

  • Explicitly validate the presence of both lp_mint and user_lp_token_account, and return the error gracefully if either is missing


Remediation Comment

SOLVED: The Rain Protocol team solved the issue by implementing proper checks as suggested.

Remediation Hash
d502e8b2bdcb3c56f3dcf7088c5b6d715a53ebf1

7.9 Missing validation on withdrawal window during vault update can block withdrawals

//

Informational

Description

The update_vault instruction allows any existing administrator defined in the config to update various parameters of the vault, including the ix_gate, max_deposit, oracle configurations, and withdrawal window settings.


bank/program/src/instructions/vault/update_vault.rs

pub fn update_vault(ctx: Context<UpdateVault>, set_vault_config: SetVaultConfig) -> Result<()> {
    let mut vault = ctx.accounts.vault.load_mut()?;

    match set_vault_config {
        SetVaultConfig::UpdateIxGate { ix_gate } => vault.ix_gate = ix_gate,
        SetVaultConfig::UpdateMaxDeposit { max_deposit } => vault.max_deposit = max_deposit,
        SetVaultConfig::UpdateWithdrawalWindow {
            withdrawal_window,
            withdrawal_window_limit,
            withdrawal_window_start,
            withdrawal_window_accumulator,
        } => {
            vault.withdrawal_window = withdrawal_window;
            vault.withdrawal_window_limit = withdrawal_window_limit;
            vault.withdrawal_window_start = withdrawal_window_start;
            vault.withdrawal_window_accumulator = withdrawal_window_accumulator;
        }
        SetVaultConfig::UpdateOracle { index, oracle } => {
            let i = index.into();
            require_gt!(MAX_ORACLE, i);
            vault.update_oracle(i, &oracle.into());
        }
    }
    Ok(())
}


However, the absence of validation on the withdrawal window parameters specifically withdrawal_window_accumulator and withdrawal_window_start can lead to unintended behaviors. Specifically,

  • If withdrawal_window_accumulator is set to a value equal to or greater than withdrawal_window_limit, all subsequent withdrawals will fail.

  • If withdrawal_window_start is set to a timestamp far in the future, the calculation now - self.withdrawal_window_start in the withdrawal logic will underflow preventing withdrawals from executing as intended.


bank/program/src/state/vault.rs

   pub fn withdraw(&mut self, amount: u64, now: u64) -> Result<()> {
        self.withdrawn_at = now;
        self.deposited -= amount;

        if self.withdrawal_window_limit == u64::MAX {
            return Ok(());
        }

        if now - self.withdrawal_window_start > self.withdrawal_window {
            self.withdrawal_window_accumulator = 0;
            self.withdrawal_window_start = now;
        }

        if self.withdrawal_window_accumulator + amount > self.withdrawal_window_limit {
            return err!(BankError::WithdrawaWindowLimitExceeded);
        }

        self.withdrawal_window_accumulator += amount;

        Ok(())
    }

BVSS
Recommendation

To address this issue, the following validations should be implemented:

  • withdrawal_window_accumulator should only be allowed to be set to zero, as it represents runtime state and should not be arbitrarily modified.

  • withdrawal_window_start should be validated to ensure it is not set to a future timestamp, preventing underflows and ensuring correct withdrawal logic.


Remediation Comment

SOLVED: The Rain Protocol team solved the issue by removing the ability to update withdrawal_window_accumulator and withdrawal_window_start fields

Remediation Hash
7f66f763346bd702d7cc3f67ee57a9751812f69f

7.10 Missing validation in config updates can block new pool creation

//

Informational

Description

The update_config instruction allows an administrator to modify Config's values, including min_duration, max_duration, min_amount_usd, and max_amount_usd.


However, this instruction lacks proper validation to ensure logical consistency among these parameters.


defi-lending/program/src/instructions/admin.rs

pub fn update_config(ctx: Context<UpdateConfig>, update_config: SetConfig) -> Result<()> {
    let mut config = ctx.accounts.config.load_mut()?;

    match update_config {
        SetConfig::UpdateMinDuration { min_duration } => config.min_duration = min_duration,
        SetConfig::UpdateMaxDuration { max_duration } => config.max_duration = max_duration,
        SetConfig::UpdateMaxAmountUsd { max_amount_usd } => config.max_amount_usd = max_amount_usd,
        SetConfig::UpdateMinAmountUsd { min_amount_usd } => config.min_amount_usd = min_amount_usd,
    };

As a result, it is possible to set a minimum value that exceeds its corresponding maximum.


This misconfiguration causes a critical inconsistency, as subsequent pool creation will fail due to the requirement that a pool’s limits must fall within the valid bounds defined by the Config and be internally consistent. Consequently, this issue can render the protocol partially unusable by preventing the deployment of new pools.


defi-lending/program/src/instructions/pool/mod.rs

impl LimitConfig {
    pub fn is_correct(&self, config: &Config) -> bool {
        if self.min_duration < config.min_duration
            || self.max_duration > config.max_duration
            || self.min_duration > self.max_duration
        {
            return false;
        }

        if self.min_amount_usd < config.min_amount_usd
            || self.max_amount_usd > config.max_amount_usd
            || self.min_amount_usd > self.max_amount_usd
        {
            return false;
        }

        true
    }
}

While this issue does not pose a direct security risk—since only authorized administrators can perform this update and it can be reverted at any time—it can cause temporary protocol unavailability or malfunction.

BVSS
Recommendation

It is recommended to implement explicit validation within the update_config instruction to ensure that updated parameters remain internally consistent. Specifically, the protocol should enforce that min_duration does not exceed max_duration, and that min_amount_usd does not surpass max_amount_usd.

Remediation Comment

SOLVED: The Rain Protocol team solved this issue by adding a validation in the config update to ensure min_amount_usd and min_duration are greater than zero and to prevent to exceed the max_amount_usd and max_duration, respectively.

Remediation Hash
3324dd92eab99daa1b8466adcaf00a36c9a929c4

7.11 Incomplete validation during admins and managers addition

//

Informational

Description

The add_admin instruction permits any existing administrator in the config to add a new admin, provided the maximum allowed number of administrators is not exceeded. It performs a basic check to ensure the new admin’s public key differs from the signer’s key, aiming to prevent duplicates.


However, this validation is incomplete. Specifically:

  • If a new admin is added with a public key different from the signer’s but already present in the list of admins, the program still accepts it. This creates a duplicate entry that occupies a valid slot in the fixed-size array. Consequently, the duplicate admin must be manually removed to free the slot.

  • The program does not validate against the default public key (Pubkey::default()), which represents an unused slot. Adding this default key has no effect since it neither modifies the state nor consumes a new slot, but it still results in an unnecessary transaction.

bank/program/src/instructions/admin.rs

pub fn add_admin(ctx: Context<UpdateConfig>, admin: Pubkey) -> Result<()> {
    // Already admin
    require!(
        !keys_equal(&admin, ctx.accounts.admin.key),
        BankError::DuplicateRessources
    );
    
    let mut config = ctx.accounts.config.load_mut()?;
    config.add_admin(&admin)

bank/program/src/state/config.rs

pub fn add_admin(&mut self, pubkey: &Pubkey) -> Result<()> {
        for i in 0..MAX_ADMINS {
            
            if keys_equal(&self.admins[i], &Pubkey::default()) {
                self.admins[i] = *pubkey;
                msg!("admin added");
                return Ok(());
            }
        }
        error_msg!("Admin array is full.")
    }

The same incomplete validations exist in the add_manager instruction.


While these issues do not pose direct security risks, they cause:

  • Unnecessary compute costs for transactions that have no effect or value.

  • Reduced effective admin capacity due to silent duplication.

  • Degraded data consistency, requiring manual cleanup to restore usable admin slots.


BVSS
Recommendation

It is recommended to add a validation to prevent duplicate entries in the admins and managers arrays and rejecting attempts to add the default public key.

Remediation Comment

SOLVED: The Rain Protocol team solved this issue by adding validation to prevent:

  • adding a current admin or manager as a new one

  • adding the default public key as an admin or manager


Remediation Hash
81041ee7cdb2beb4384a48122145205173cdd1fe

7.12 Unnecessary Account included in margin_swap_standalone Instruction

//

Informational

Description

The defi-lending program enables users to perform margin swaps. The margin_swap_standalone instruction allows a user to take out a loan and immediately swap the borrowed principal for a different token, which is then locked as collateral for the loan.


The MarginSwapStandalone accounts struct, which defines the accounts required for this instruction, includes rain_collateral_token_account. This account is marked with mut and constrained to be a token account for the collateral mint owned by the protocol's fee address. However, this account is neither read from nor written to within the instruction's logic.


Best practice dictates that all accounts required by an instruction should be actively used for validation, state changes, or cross-program invocations (CPIs). Unnecessary accounts should be removed to simplify the instruction interface and reduce potential risks. The relevant account is shown in the snippet below:


program/src/instructions/margin_swap/margin_swap_standalone.rs

    #[account(
        mut,
        token::mint = mint,
        token::authority = FEES_ADDRESS
    )]
    pub rain_collateral_token_account: Box<InterfaceAccount<'info, TokenAccount>>,

The primary risk of including an unused account is increased code complexity. This can cause confusion for developers integrating with the protocol and for future maintainers, raising the likelihood of integration errors.


No other direct security risks were identified related to this issue during the review.

BVSS
Recommendation

Remove the rain_collateral_token_account from the MarginSwapStandalone accounts struct and any client-side code that passes it. This will simplify the instruction and eliminate potential confusion.

Remediation Comment

ACKNOWLEDGED: The Rain Protocol team acknowledged this finding.

7.13 Fee account not enforced as canonical associated token account

//

Informational

Description

The defi-lending program enables margin swaps via its margin_swap_standalone instruction. During this process, protocol fees are collected and transferred to the rain_principal_token_account.


The instruction correctly verifies that this account is owned by the FEES_ADDRESS and matches the correct principal mint. However, it does not enforce that the provided account is the canonical Associated Token Account (ATA) for the fee address and mint.


This omission, demonstrated in the code below, permits any valid token account owned by the FEES_ADDRESS to receive fees. The intended behavior is to strictly enforce the use of the ATA to ensure all fees are deposited into a single, predictable account.


program/src/instructions/margin_swap/margin_swap_standalone.rs

    #[account(
        mut,
        token::mint = bank.mint,
        token::authority = FEES_ADDRESS
    )]
    pub rain_principal_token_account: Box<InterfaceAccount<'info, TokenAccount>>,

The primary risk is fee fragmentation. A user or integrating protocol can create a new, valid token account for the FEES_ADDRESS and use it in the transaction. The instruction will succeed, but fees will be sent to this non-standard account.


This scatters the protocol's collected fees across multiple token accounts, complicating accounting, tracking, and management of protocol revenue. It increases the risk of errors, complicates treasury management, and may obscure the actual amount of fees collected.

BVSS
Recommendation

Enforce that rain_principal_token_account is the canonical Associated Token Account (ATA). This can be done by replacing the token:: constraints with the associated_token:: constraint. The associated_token constraint ensures the provided account is the correct ATA.

Remediation Comment

ACKNOWLEDGED: The Rain Protocol team acknowledged this finding.

7.14 Unused quote field adds redundancy in quote struct

//

Informational

Description

The instant_sell_open, margin_swap_open, and liquidate_open instructions initialize a Quote with various fields that describe the swap route, vaults, and loan metadata.


However, the quote field within the Quote struct is never assigned a value during initialization and remains unused throughout the instruction logic. Although this does not present a direct security risk, it unnecessarily increases code complexity and adds minor execution overhead.


defi-lending/program/src/instructions/instant_sell/instant_sell_open.rs

pub fn instant_sell_open(ctx: Context<InstantSellOpen>, jup_data: Vec<u8>) -> Result<()> {
    let clock = Clock::get()?;

    let jupiter_route = decode_jupiter_route(&jup_data)?;
    let in_amount_jup: u64 = jupiter_route.in_amount();

    require_gte!(
        in_amount_jup,
        ctx.accounts.loan_vault.amount,
        LendingError::WrongAmount
    );

    let timestamp = clock.unix_timestamp.try_into()?;
    let data_length = jup_data.len();

    let dynamic_interest = ctx.accounts.loan.compute_dynamic_interest(timestamp);

    let quote = &mut ctx.accounts.quote;
    ***quote = Quote {
        quote_type: QuoteType::InstantSell,
        payer: ctx.accounts.payer.key(),
        borrower: ctx.accounts.borrower.key(),
        loan: ctx.accounts.loan.key(),
        in_vault: ctx.accounts.loan_vault.key(),
        out_vault: ctx.accounts.quote_vault.key(),

        bump: ctx.bumps.quote,

        slot: clock.slot,
        created_at: timestamp,

        borrowed_amount: 0,
        in_amount_jup,
        min_swapped_amount: ctx.accounts.loan.borrowed_amount + dynamic_interest,

        data_length: data_length.try_into()?,

        ..Default::default()
    };
    quote.jup_vec[..data_length].copy_from_slice(&jup_data[..data_length]);

    Ok(())
}

defi-lending/program/src/state/quote.rs

impl Default for Quote {
    fn default() -> Self {
        Self {
            quote_type: QuoteType::None,
            quote_step: QuoteStep::Open,

            quote: Pubkey::default(),
            payer: Pubkey::default(),
            borrower: Pubkey::default(),
            loan: Pubkey::default(),
            in_vault: Pubkey::default(),
            out_vault: Pubkey::default(),

            borrowed_amount: 0,
            in_amount_jup: 0,
            min_swapped_amount: 0,
            temp_value: 0,

            data_length: 0,
            jup_vec: [0; 512],

            slot: 0,
            created_at: 0,

            bump: 0,
            padding2: [0; 7],

            padding1: [0; 6],
        }
    }
}

BVSS
Recommendation

It is recommended to remove the unused quote field from the Quote struct and adjusting the account size accordingly.

Remediation Comment

ACKNOWLEDGED: The Rain Protocol team acknowledged this finding.

8. Automated Testing

Static Analysis Report

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-2024-0344

curve25519-dalek

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

RUSTSEC-2022-0093

ed25519-dalek

Double Public Key Signing Function Oracle Attack on ed25519-dalek


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.