H-1: Harvest Reward CPI Lacks Optional Scope Accounts
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");
}