Reports

Smart Contract Security Assessment

vbSOL Security Review

vbSOL is a wrapper around the Kamino vault that simplifies interactions.

6
Issues
1
C/H/M
Period
Oct 14, 2025 - Oct 17, 2025
Auditors
ret2basic.eth

Review Summary

Protocol Overview

vbSOL is a wrapper around the Kamino vault that simplifies interactions.

Protocol
vbSOL
Timeline
Oct 14, 2025 - Oct 17, 2025
Audit Team
ret2basic.eth

Audit Overview

Scope and Resources

Scope

This audit covers 15 smart contracts totaling approximately 1000 lines of code across 4 days of review.

Overall Assessment

Great overall code quality; identified bugs have been fixed.

Evaluation Matrix

access control
Good

Key functions are properly access-controlled.

mathematics
Good

No severe mathematical bugs were found.

complexity
Average

Overall complexity is moderate, though low-level CPI calls introduce some intricacies.

libraries
Good

The Kamino utility library is well designed.

decentralization
Good

The degree of decentralization largely depends on the Kamino vault implementation.

code stability
Good

Some edge cases are not yet handled gracefully.

documentation
Average

Documentation is clear but sparse and should expand on developer-focused technical details.

monitoring
Good

Integrated projects should implement monitoring as described in the documentation.

testing
Good

Test suite coverage is solid overall.

Key Findings

Findings Summary

0
Critical
1
High
0
Medium
0
Low
5
Informational
0
Gas
H-1 Finding

H-1: Harvest Reward CPI Lacks Optional Scope Accounts

High

Summary:

harvest_reward forwards only the fixed Kamino Farms accounts and never passes optional remaining accounts (e.g., scope_prices). Farms configured with Scope oracles reject the CPI with FarmError::InvalidOracleConfig, so vbSOL cannot harvest rewards for those vaults.

Description:

programs/vbsol/src/instructions/harvest_reward.rs defines a CPI that only includes the 11 fixed Kamino accounts and the struct never surfaces optional accounts such as scope_prices. Kamino’s handler (kfarms/programs/kfarms/src/handlers/handler_harvest_reward.rs) expects optional accounts like Scope price feeds:

pub scope_prices: Option<AccountLoader<'info, scope::OraclePrices>>,
...
let scope_price = load_scope_price(&ctx.accounts.scope_prices, farm_state)?;

When a farm is configured with Scope, load_scope_price unwraps the optional loader and returns Err(FarmError::InvalidOracleConfig.into()) if the caller didn’t supply it. vbSOL’s account struct has no scope_prices field and the CPI metas are hard-coded, so the optional account is never forwarded. The instruction fails before any rewards are transferred. Once the loader succeeds, downstream math guards still call scope_price.ok_or(FarmError::MissingScopePrices)?, so both errors remain reachable depending on where the omission is detected.

// kfarms/programs/kfarms/src/utils/scope.rs
pub fn load_scope_price(
    scope_prices_account: &Option<AccountLoader<'_, scope::OraclePrices>>,
    farm_state: &FarmState,
) -> Result<Option<DatedPrice>> {
    if farm_state.scope_oracle_price_id == u64::MAX {
        Ok(None)
    } else if let Some(scope_prices_account) = scope_prices_account {
        ...existing code...
    } else {
        Err(FarmError::InvalidOracleConfig.into())
    }
}

This is reproducible by wiring a farm configuration whose FarmState references Scope prices; invoking vbSOL’s harvest_reward immediately returns InvalidOracleConfig because the optional account is missing. The regression test tests/src/test_program.rs::test_harvest_reward_missing_scope_prices_account demonstrates the failure path.

Impact:

Vaults relying on Scope-priced farms cannot harvest rewards through vbSOL. Keepers or users attempting to trigger the instruction encounter hard failures, leaving rewards stranded until a different integration handles the CPI.

Kamino’s harvest handler requires the user_state.owner field to sign the CPI, and vbSOL constructs its Kamino user state with the vbSOL ProgramState PDA as that owner. External users cannot sign for this PDA, so they cannot bypass the integration and harvest directly against Kamino. Rewards remain stranded until vbSOL forwards the optional accounts—or the integration is reworked to let end users own their Kamino state.

Recommendation:

Extend HarvestReward to accept optional accounts (e.g., add an Option<AccountInfo<'_>> field for scope_prices) or, at minimum, propagate ctx.remaining_accounts into the CPI so callers can supply Scope price feeds and future auxiliaries.

Developer Response:

Fixes at https://github.com/exo-tech-xyz/vbSOL/pull/3

Appendix Poc:

Add the following code to program/vbsol/tests/test_program.rs and run cargo test -p tests test_harvest_reward_missing_scope_prices_account -- --nocapture

const KFARM_MAX_REWARDS_TOKENS: usize = 10;
const KFARM_REWARD_CURVE_POINTS: usize = 20;

#[repr(C)]
struct RewardPerTimeUnitPointLayout {
    ts_start: u64,
    reward_per_time_unit: u64,
}

#[repr(C)]
struct RewardScheduleCurveLayout {
    points: [RewardPerTimeUnitPointLayout; KFARM_REWARD_CURVE_POINTS],
}

#[repr(C)]
struct TokenInfoLayout {
    mint: Pubkey,
    decimals: u64,
    token_program: Pubkey,
    padding: [u64; 6],
}

#[repr(C)]
struct RewardInfoLayout {
    token: TokenInfoLayout,
    rewards_vault: Pubkey,
    rewards_available: u64,
    reward_schedule_curve: RewardScheduleCurveLayout,
    min_claim_duration_seconds: u64,
    last_issuance_ts: u64,
    rewards_issued_unclaimed: u64,
    rewards_issued_cumulative: u64,
    reward_per_share_scaled: u128,
    placeholder_0: u64,
    reward_type: u8,
    rewards_per_second_decimals: u8,
    padding0: [u8; 6],
    padding1: [u64; 20],
}

#[repr(C)]
struct FarmStateLayout {
    farm_admin: Pubkey,
    global_config: Pubkey,
    token: TokenInfoLayout,
    reward_infos: [RewardInfoLayout; KFARM_MAX_REWARDS_TOKENS],
    num_reward_tokens: u64,
    num_users: u64,
    total_staked_amount: u64,
    farm_vault: Pubkey,
    farm_vaults_authority: Pubkey,
    farm_vaults_authority_bump: u64,
    delegate_authority: Pubkey,
    time_unit: u8,
    is_farm_frozen: u8,
    is_farm_delegated: u8,
    padding0: [u8; 5],
    withdraw_authority: Pubkey,
    deposit_warmup_period: u32,
    withdrawal_cooldown_period: u32,
    total_active_stake_scaled: u128,
    total_pending_stake_scaled: u128,
    total_pending_amount: u64,
    slashed_amount_current: u64,
    slashed_amount_cumulative: u64,
    slashed_amount_spill_address: Pubkey,
    locking_mode: u64,
    locking_start_timestamp: u64,
    locking_duration: u64,
    locking_early_withdrawal_penalty_bps: u64,
    deposit_cap_amount: u64,
    scope_prices: Pubkey,
    scope_oracle_price_id: u64,
    scope_oracle_max_age: u64,
    pending_farm_admin: Pubkey,
    strategy_id: Pubkey,
    delegated_rps_admin: Pubkey,
    vault_id: Pubkey,
    second_delegated_authority: Pubkey,
    padding: [u64; 74],
}

fn farm_state_scope_offsets() -> (usize, usize, usize) {
    use std::{mem::MaybeUninit, ptr::addr_of};

    let uninit = MaybeUninit::<FarmStateLayout>::uninit();
    let base = uninit.as_ptr();
    unsafe {
        (
            addr_of!((*base).scope_prices) as usize - base as usize,
            addr_of!((*base).scope_oracle_price_id) as usize - base as usize,
            addr_of!((*base).scope_oracle_max_age) as usize - base as usize,
        )
    }
}

fn require_scope_prices_on_farm(svm: &mut LiteSVM) {
    let (scope_offset, scope_id_offset, scope_max_age_offset) = farm_state_scope_offsets();
    println!(
        "scope_offsets scope={} id={} max_age={}",
        scope_offset, scope_id_offset, scope_max_age_offset
    );
    let mut farm_account = svm.get_account(&MNDE_FARM_STATE).unwrap();
    let scope_oracle = Pubkey::new_unique();

    let (_prefix, scope_bytes) = farm_account.data.split_at_mut(scope_offset);
    let (scope_bytes, _) = scope_bytes.split_at_mut(32);
    scope_bytes.copy_from_slice(scope_oracle.as_ref());

    let (_prefix, scope_id_bytes) = farm_account.data.split_at_mut(scope_id_offset);
    let (scope_id_bytes, _) = scope_id_bytes.split_at_mut(8);
    scope_id_bytes.copy_from_slice(&0u64.to_le_bytes());

    let (_prefix, scope_max_bytes) = farm_account.data.split_at_mut(scope_max_age_offset);
    let (scope_max_bytes, _) = scope_max_bytes.split_at_mut(8);
    scope_max_bytes.copy_from_slice(&600u64.to_le_bytes());

    svm.set_account(MNDE_FARM_STATE, farm_account).unwrap();
}


...

#[test]
fn test_harvest_reward_missing_scope_prices_account() {
    setup_test_env!(svm, payer, admin, user, client, program);

    let program_state_pda = ProgramState::pda().0;

    // Seed a deposit so the farm has state to harvest against.
    let deposit_ixs = build_user_deposit_ixs(&program, &user, 100_000_000);
    send_tx(&mut svm, &payer, &[&payer, &user], deposit_ixs).expect("deposit");

    // Configure a reward recipient so harvest wiring matches production.
    let recipient = Keypair::new();
    let update_roles_ixs = build_update_roles_ixs(
        &program,
        &admin,
        None,
        Some(vec![payer.pubkey()]),
        None,
        Some(recipient.pubkey()),
    );
    send_tx(&mut svm, &payer, &[&payer, &admin], update_roles_ixs).expect("update roles");

    // Prepare the recipient and vault ATAs Kamino expects.
    let recipient_account =
        get_associated_token_address_with_program_id(&recipient.pubkey(), &MNDE_MINT, &Token::id());
    setup_token_account(
        &mut svm,
        &recipient_account,
        &MNDE_MINT,
        &recipient.pubkey(),
        0,
        None,
    );

    let vault_reward_account =
        get_associated_token_address_with_program_id(&program_state_pda, &MNDE_MINT, &Token::id());
    setup_token_account(
        &mut svm,
        &vault_reward_account,
        &MNDE_MINT,
        &program_state_pda,
        0,
        None,
    );

    require_scope_prices_on_farm(&mut svm);

    let user_state_pda = UserState::mnde_pda(&program_state_pda);
    let ixs = build_harvest_mnde_reward_ix(&mut svm, &program, &payer, 0, user_state_pda);
    let res = send_tx(&mut svm, &payer, &[&payer], ixs);
    assert_error(res, "InvalidOracleConfig");
}
I-1 Finding

I-1: Hard-Coded Kamino Event Authority Blocks Staging Withdrawals

Informational

Summary:

K_WITHDRAW_EVENT_AUTHORITY in programs/vbsol/src/constants.rs is derived from the production Kamino Vault program id. When the KVault program id changes (e.g. the staging build behind Kamino’s feature flag), the event authority PDA also changes. vbSOL does not adjust the constant, so any deployment wired to the staging KVault signs with the wrong authority and every withdraw CPI fails signature checks.

Description:

  • programs/vbsol/src/constants.rs line 14 hard-codes K_WITHDRAW_EVENT_AUTHORITY = pubkey!("24tHwQyJJ9akVXxnvkekGfAoeUJXXS7mE6kQNioNySsK"), which corresponds to the production KVAULT_PROGRAM (KvauGMspG5k6rtzrqqn7WNn3oZdyKqLKwK2XWQ8FLjd).
  • vbSOL uses this constant anywhere Kamino withdraw/CPI emits events (for example user_redeem, user_deposit, deposit_balance). The account is constrained with #[account(address = K_WITHDRAW_EVENT_AUTHORITY)], so the program enforces an exact match.
  • Kamino’s staging feature flag compiles the vault under a different program id. Anchor derives the event authority PDA from that id, yielding a different pubkey. When vbSOL is pointed at the staging vault, the hard-coded mainnet authority fails the address constraint.
  • During withdraws, the CPI instruction still includes withdraw_event_authority and attempts to sign with it, but Kamino’s runtime rejects the CPI before reaching the lending logic because the provided authority does not match the PDA derived from the staging program id.

Impact:

Any vbSOL configuration targeting the staging (non-mainnet) Kamino vault cannot redeem or deposit via the vault: Kamino rejects the CPI at the signature check stage. This blocks QA, integration tests, or any deployment where the KVault id intentionally differs from production

Recommendation:

Make the event authority configurable based on the selected KVault program id. Options include:

  1. Derive the PDA at runtime using Pubkey::find_program_address(&[b"withdraw_event"], kvault_program.key()).
  2. Provide separate constants or feature-gated values for staging vs. mainnet builds.
  3. Load the authority from configuration stored in ProgramState during setup_program.

Ensuring the authority matches the active KVault program id restores CPI compatibility across environments.

Developer Response:

Fixed in https://github.com/exo-tech-xyz/vbSOL/pull/8

I-2 Finding

I-2: Zero-Balance Deposit Triggers Kamino DepositAmountsZero

Informational

Summary:

deposit_balance reads the vault SOL ATA balance, but even when it is already zero it still builds a Kamino Vault deposit CPI with max_amount = 0. Kamino rejects zero-amount deposits, so the CPI aborts and vbSOL surfaces a hard failure instead of cleanly no-oping.

Description:

Inside programs/vbsol/src/instructions/deposit_balance.rs, the refreshed SPL ATA balance (vault_sol_token_account.amount) is copied directly into the Kamino deposit instruction payload. When that balance is zero, the code still executes the CPI without any guard. Kamino’s handler (kvault/programs/kvault/src/handlers/handler_deposit.rs) has an explicit DepositAmountsZero guard and immediately returns an error once it sees max_amount == 0, so the vbSOL instruction fails.

This is reproducible via tests/src/test_program.rs::test_deposit_balance: issuing deposit_balance twice back-to-back (after the first drains the ATA) causes the second invocation to revert with DepositAmountsZero coming from Kamino.

Impact:

Any off-chain keeper or integration that periodically calls deposit_balance to flush idle SOL must first inspect the ATA balance. A naive "call every slot" loop will encounter transaction failures whenever the ATA is already empty. This complicates automation and can lead to pager noise or skipped maintenance if wrappers treat the failure as fatal.

Recommendation:

Short-circuit the handler when the refreshed vault_sol_token_account.amount is zero (e.g., return Ok(()) before assembling the CPI vectors). This allows a zero-balance call to behave as an idempotent no-op, matching expectations for keeper-style flows.

Developer Response:

Fix at https://github.com/exo-tech-xyz/vbSOL/pull/4

I-3 Finding

I-3: Claim Yield Panics When Kamino Shares Are Zero

Informational

Summary:

claim_yield divides the vault’s cached NAV by the total Kamino shares issued without guarding against a zero-supply vault. When total_shares_issued == 0—the state immediately after setup_program or after all deposits have been redeemed—the instruction unwraps a checked_div(0) and panics.

Description:

In programs/vbsol/src/instructions/claim_yield.rs, the code retrieves total_shares_issued via get_shares_issued. The code immediately computes

let shares_balance_nav = shares_balance
    .checked_mul(kvault_aum)
    .unwrap()
    .checked_div(total_shares_issued)
    .unwrap();

Kamino’s VaultState allows a zero share count (see kvault/programs/kvault/src/state.rs). During the initial bootstrap—or any time every depositor has redeemed—get_shares_issued returns 0. The subsequent .checked_div(...).unwrap() triggers a panic, aborting the entire instruction.

This is reproducible by calling claim_yield right after setup_program in the LiteSVM harness: no deposits exist, shares_balance is zero, and the division unwrap panics before any result bubbles back to the caller.

Impact:

Any authorized yield claimer can brick the instruction until a deposit occurs. Automation that invokes claim_yield periodically will crash during empty-vault epochs, preventing keepers from rebalancing vbSOL supply against NAV.

Recommendation:

Short-circuit the handler when total_shares_issued == 0 (e.g., return Ok(())). That keeps the instruction idempotent for empty vaults and avoids panics. Once shares exist, proceed with the NAV division as implemented today.

Developer Response:

Fix in https://github.com/exo-tech-xyz/vbSOL/pull/5

I-4 Finding

I-4: Admin Can Brick Claim Yield by Setting aum_cache_max_age to Zero

Informational

Summary:

update_config lets the admin set aum_cache_max_age to 0, but the get_kvault_aum helper treats zero as fatal and returns InvalidCacheTimestamp. Once the config is zeroed, every claim_yield invocation errors, effectively disabling the feature until an admin fixes the setting.

Description:

programs/vbsol/src/instructions/update_config.rs writes any supplied aum_cache_max_age into ProgramState without validation. Later, claim_yield calls get_kvault_aum (see programs/vbsol/src/instructions/claim_yield.rs). That helper (programs/vbsol/src/kamino_utils.rs) begins with:

if aum_cache_max_age == 0 {
    return err!(ErrorCode::InvalidCacheTimestamp);
}

So if the admin ever sets the config to zero, claim_yield always returns InvalidCacheTimestamp before doing any work. There is no way for keepers or users to mint yield until the admin remembers to change the value back to a positive number.

Impact:

A single misconfiguration from the admin permanently bricks claim_yield. Keepers relying on automated adjustments will fail every time, and downstream integrations must treat the instruction as unavailable until governance intervenes.

Recommendation:

Reject aum_cache_max_age == 0 inside update_config (mirroring the existing redemption slippage guard), or teach get_kvault_aum to treat zero as “disabled cache expiry” instead of erroring. Either approach prevents the config from soft-bricking yield claims.

Developer Response:

Fixed in https://github.com/exo-tech-xyz/vbSOL/pull/6

I-5 Finding

I-5: Harvest Reward Rejects Treasury-Only Distributions

Informational

Summary:

harvest_reward aborts with ErrorCode::InvalidAmount whenever Kamino routes the entire reward to its treasury vault. The handler insists that vbSOL's reward ATA balance must increase (require_gt!(tokens_post, tokens_pre)), but treasury-only harvests leave that account unchanged even though the CPI succeeded.

Description:

Kamino lets its global admin set the fee cut up to 100%:

// kfarms/programs/kfarms/src/farm_operations.rs
GlobalConfigOption::SetTreasuryFeeBps => {
    let value = u64::from_le_bytes(value[..8].try_into().unwrap());
    if value > 10_000 {
        return Err(FarmError::InvalidConfigValue.into());
    }
    global_config.treasury_fee_bps = value;
}

During harvest the rewards are split using that basis-point value:

// kfarms/programs/kfarms/src/farm_operations.rs
let reward_treasury = u64_mul_div(reward, global_config.treasury_fee_bps, BPS_DIV_FACTOR);
let reward_user = reward.checked_sub(reward_treasury)?;

When treasury_fee_bps == 10_000, the split becomes reward_user = 0 and all tokens remain at rewards_treasury_vault. vbSOL then performs a safety check:

// programs/vbsol/src/instructions/harvest_reward.rs:118-132
let tokens_pre = vault_reward_ata.amount;
vault_reward_ata.reload()?;
let tokens_post = vault_reward_ata.amount;
require_gt!(tokens_post, tokens_pre, ErrorCode::InvalidAmount);

Because Kamino delivered nothing to vault_reward_ata, tokens_post == tokens_pre and vbSOL errors out despite the CPI succeeding. The caller observes InvalidAmount and the harvest result is discarded.

Impact:

Any farm that directs 100% (or BPS rounding that yields reward_user == 0) of its rewards to the treasury cannot be harvested through vbSOL. Keepers operating vbSOL can no longer process harvests for those markets, even though Kamino distributes treasury rewards successfully to its own vault.

Recommendation:

Relax the post-CPI guard so treasury-only harvests are treated as success. Options:

  • Replace require_gt! with a require!(tokens_post >= tokens_pre, ...) guard to keep the safety check but allow zero deltas.
  • Short-circuit when tokens_post == tokens_pre so the handler exits early without issuing a zero-amount transfer.
  • Alternatively, consume Kamino's emitted events or use the CPI return data to detect zero-user payouts and avoid relying solely on ATA deltas.

Developer Response:

Fixed in https://github.com/exo-tech-xyz/vbSOL/pull/7

Final Remarks

This security review should be considered a peer review since one of the auditors assigned wasn't able to complete it and there was no fee charged to the customer for this service. We never do solo audits. The customer must do another full audit with another company.

Methodology

Severity Classification

Critical

Immediate threat to user funds or protocol integrity

Direct loss of funds, protocol compromise

High

Significant security risk requiring urgent attention

Potential fund loss, major functionality disruption

Medium

Important issue that should be addressed

Limited fund risk, functionality concerns

Low

Minor issue with minimal impact

Best practice violations, minor inefficiencies

Gas

Findings that improve gas efficiency

Increased transaction costs

Informational

Code quality and best practice recommendations

Reduced maintainability and readability