Prepared by:
HALBORN
Last Updated 08/15/2025
Date of Engagement: June 30th, 2025 - July 25th, 2025
100% of all REPORTED Findings have been addressed
All findings
14
Critical
0
High
0
Medium
2
Low
4
Informational
8
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.
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.
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
).
EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL 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 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL 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 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
0
Medium
2
Low
4
Informational
8
Security analysis | Risk level | Remediation Date |
---|---|---|
Exposure validation may overestimate available liquidity due to included unavailable interest | Medium | Solved - 07/31/2025 |
Lack of validation of the vector currencies during pool creation | Medium | Solved - 08/01/2025 |
Inconsistent Token Program Alignment Can Break Liquidations | Low | Solved - 07/31/2025 |
Max deposit enforcement may fail due to early state mutation | Low | Solved - 07/31/2025 |
Missing validation of swap destination token account may lead to loan loss | Low | Partially Solved - 07/31/2025 |
Allowing delegation in shared banks may lead to withdrawal abuse and unfair loss distribution | Low | Solved - 07/31/2025 |
Lack of validation for token 2022 extensions | Informational | Acknowledged - 08/06/2025 |
Multiple missing validations could lead to program panic | Informational | Solved - 07/31/2025 |
Missing validation on withdrawal window during vault update can block withdrawals | Informational | Solved - 07/31/2025 |
Missing validation in config updates can block new pool creation | Informational | Solved - 07/31/2025 |
Incomplete validation during admins and managers addition | Informational | Solved - 07/31/2025 |
Unnecessary Account included in margin_swap_standalone Instruction | Informational | Acknowledged - 08/01/2025 |
Fee account not enforced as canonical associated token account | Informational | Acknowledged - 08/01/2025 |
Unused quote field adds redundancy in quote struct | Informational | Acknowledged - 08/01/2025 |
//
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.
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
)
)
);
});
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.
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).
//
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_ltv
and 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(())
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);
});
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.
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.
//
In the DeFi lending protocol, authorized managers can liquidate expired loans through a sequence of instructions: liquidate_open
, liquidate_start
→ swap
→ liquidate_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:
Liquidation cannot be completed for such loans.
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("e.in_vault, &loan_vault.key()),
constraint = keys_equal("e.out_vault, "e_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.
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]
);
});
});
});
CloseAccount TokenProgram Mismatch
Repay CPI TokenProgram Mismatch
It is recommended to provide the corresponding bank's token program to the repay
CPI call to ensure proper token program alignment.
SOLVED: The Rain Protocol team solved the issue by providing correct token program for CPI
//
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.
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.
SOLVED: The Rain Protocol team solved this issue by implementing the suggested remediation.
//
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(())
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));
});
});
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
.
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
.
//
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));
}
To address this issue, it is recommended to add a validation to forbid delegate operations in shared banks.
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.
//
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.
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.
ACKNOWLEDGED: The Rain Protocol
team acknowledged this finding.
//
process_withdraw_request
may cause panic due to arithmetic overflowIn 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 withdraw
which 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;
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)?))
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)
}
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
SOLVED: The Rain Protocol team solved the issue by implementing proper checks as suggested.
//
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(())
}
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.
SOLVED: The Rain Protocol team solved the issue by removing the ability to update withdrawal_window_accumulator
and withdrawal_window_start
fields
//
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.
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
.
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.
//
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.
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.
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
//
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.
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.
ACKNOWLEDGED: The Rain Protocol team acknowledged this finding.
//
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.
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.
ACKNOWLEDGED: The Rain Protocol team acknowledged this finding.
//
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],
}
}
}
It is recommended to remove the unused quote
field from the Quote
struct and adjusting the account size accordingly.
ACKNOWLEDGED: The Rain Protocol team acknowledged this finding.
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.
ID | package | Short Description |
---|---|---|
RUSTSEC-2024-0344 | curve25519-dalek | Timing variability in |
RUSTSEC-2022-0093 | ed25519-dalek | Double Public Key Signing Function Oracle Attack on |
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.
// Download the full report
Rain v2
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed