From 9aa57e6dee4307b570e2408c719267be9e0b47cb Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Tue, 3 Nov 2026 10:07:36 +0100 Subject: [PATCH] fix: route unrefundable rent dust to the validator vault on undelegation Since the 10x fee raise a session with no prior commit leaves 69,360 lamports of delegation rent after fees. Refunding that to a rent payer that holds no SOL would leave it rent-paying, so the runtime rejects the whole undelegate transaction with InsufficientFundsForRent and the account stays delegated on both layers. Keep the leftover in the validator fees vault whenever the rent payer cannot absorb it while staying rent-exempt. Funded rent payers are still refunded exactly as before. --- src/processor/fast/utils/pda.rs | 23 ++ tests/test_undelegate_rent_payer_dust.rs | 265 +++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 tests/test_undelegate_rent_payer_dust.rs diff --git a/src/processor/fast/utils/pda.rs b/src/processor/fast/utils/pda.rs index 95eed020..9b336d25 100644 --- a/src/processor/fast/utils/pda.rs +++ b/src/processor/fast/utils/pda.rs @@ -131,6 +131,11 @@ pub(crate) fn close_pda( /// Close PDA with fees, distributing the fees to the specified addresses in sequence /// The total fees are calculated as `fee_percentage` of the total lamports in the PDA /// Each fee address receives fee_percentage % of the previous fee address's amount +/// +/// The lamports left after fees are refunded to `rent_reimbursement`. If that +/// refund would leave the account rent-paying (e.g. an empty rent payer +/// receiving a few thousand lamports of dust), the runtime rejects the whole +/// transaction, so the refund goes to the validator fees vault instead. pub(crate) fn close_pda_with_fees( target_account: &AccountView, rent_reimbursement: &AccountView, @@ -153,6 +158,14 @@ pub(crate) fn close_pda_with_fees( .set_lamports(validator_fees_vault.lamports() + validator_fee); } + if destination_amount > 0 + && !is_rent_exempt_after_refund(rent_reimbursement, destination_amount)? + { + validator_fees_vault + .set_lamports(validator_fees_vault.lamports() + destination_amount); + destination_amount = 0; + } + rent_reimbursement .set_lamports(rent_reimbursement.lamports() + destination_amount); target_account.set_lamports(0); @@ -164,6 +177,16 @@ pub(crate) fn close_pda_with_fees( target_account.resize(0) } +/// Whether `account` is rent-exempt once `refund` lamports are added to it +fn is_rent_exempt_after_refund( + account: &AccountView, + refund: u64, +) -> Result { + let minimum_balance = + Rent::get()?.try_minimum_balance(account.data_len())?; + Ok(account.lamports().saturating_add(refund) >= minimum_balance) +} + #[cfg(test)] mod tests { use super::legacy_rent; diff --git a/tests/test_undelegate_rent_payer_dust.rs b/tests/test_undelegate_rent_payer_dust.rs new file mode 100644 index 00000000..430d5316 --- /dev/null +++ b/tests/test_undelegate_rent_payer_dust.rs @@ -0,0 +1,265 @@ +use dlp::solana_program; +use dlp_api::{ + consts::SESSION_FEE_LAMPORTS, + pda::{ + delegation_metadata_pda_from_delegated_account, + delegation_record_pda_from_delegated_account, fees_vault_pda, + validator_fees_vault_pda_from_validator, + }, +}; +use solana_program::{ + hash::Hash, native_token::LAMPORTS_PER_SOL, pubkey::Pubkey, rent::Rent, +}; +use solana_program_test::{read_file, BanksClient, ProgramTest}; +use solana_sdk::{ + account::Account, + signature::{Keypair, Signer}, + transaction::Transaction, +}; +use solana_sdk_ids::system_program; + +use crate::fixtures::{ + create_delegation_metadata_data_with_commit_id, get_delegation_record_data, + DELEGATED_PDA_ID, DELEGATED_PDA_OWNER_ID, TEST_AUTHORITY, +}; + +mod fixtures; + +/// A single 32-byte seed makes the metadata PDA large enough that the +/// delegation rent exceeds the session fee by a few thousand lamports. +const RENT_PAYER_SEED: [u8; 32] = [7; 32]; + +/// Mirrors `process_delegation_cleanup`: the fee is taken from the record +/// first, then from the metadata, and whatever is left is the refund. +struct ExpectedCleanup { + protocol_fee: u64, + validator_fee: u64, + refund: u64, +} + +fn expected_cleanup(record_rent: u64, metadata_rent: u64) -> ExpectedCleanup { + let mut fee_remaining = SESSION_FEE_LAMPORTS; + let record_fee = fee_remaining.min(record_rent); + fee_remaining -= record_fee; + let metadata_fee = fee_remaining.min(metadata_rent); + let protocol_fee = record_fee / 10 + metadata_fee / 10; + ExpectedCleanup { + protocol_fee, + validator_fee: record_fee + metadata_fee - protocol_fee, + refund: record_rent + metadata_rent - record_fee - metadata_fee, + } +} + +#[tokio::test] +async fn test_undelegate_routes_dust_refund_to_validator_vault_when_rent_payer_is_empty( +) { + let rent_payer = Pubkey::new_unique(); + let (banks, validator, blockhash, record_rent, metadata_rent) = + setup_program_test_env(rent_payer, false).await; + let expected = expected_cleanup(record_rent, metadata_rent); + let rent_exempt_min = Rent::default().minimum_balance(0); + assert!(expected.refund > 0 && expected.refund < rent_exempt_min); + + let fees_vault_before = lamports(&banks, fees_vault_pda()).await; + let validator_vault_pda = + validator_fees_vault_pda_from_validator(&validator.pubkey()); + let validator_vault_before = lamports(&banks, validator_vault_pda).await; + + let ix = dlp_api::instruction_builder::undelegate( + validator.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + rent_payer, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{res:?}"); + + // The empty rent payer must not be left rent-paying, so it stays empty + assert!(banks.get_account(rent_payer).await.unwrap().is_none()); + + // The dust that could not be refunded goes to the validator vault + assert_eq!( + lamports(&banks, fees_vault_pda()).await, + fees_vault_before + expected.protocol_fee + ); + assert_eq!( + lamports(&banks, validator_vault_pda).await, + validator_vault_before + expected.validator_fee + expected.refund + ); + assert!(banks.get_account(DELEGATED_PDA_ID).await.unwrap().is_some()); +} + +#[tokio::test] +async fn test_undelegate_still_refunds_dust_to_funded_rent_payer() { + let rent_payer = Pubkey::new_unique(); + let (banks, validator, blockhash, record_rent, metadata_rent) = + setup_program_test_env(rent_payer, true).await; + let expected = expected_cleanup(record_rent, metadata_rent); + + let rent_payer_before = lamports(&banks, rent_payer).await; + let fees_vault_before = lamports(&banks, fees_vault_pda()).await; + let validator_vault_pda = + validator_fees_vault_pda_from_validator(&validator.pubkey()); + let validator_vault_before = lamports(&banks, validator_vault_pda).await; + + let ix = dlp_api::instruction_builder::undelegate( + validator.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + rent_payer, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{res:?}"); + + assert_eq!( + lamports(&banks, rent_payer).await, + rent_payer_before + expected.refund + ); + assert_eq!( + lamports(&banks, fees_vault_pda()).await, + fees_vault_before + expected.protocol_fee + ); + assert_eq!( + lamports(&banks, validator_vault_pda).await, + validator_vault_before + expected.validator_fee + ); +} + +async fn lamports(banks: &BanksClient, pubkey: Pubkey) -> u64 { + banks.get_account(pubkey).await.unwrap().unwrap().lamports +} + +async fn setup_program_test_env( + rent_payer: Pubkey, + fund_rent_payer: bool, +) -> (BanksClient, Keypair, Hash, u64, u64) { + let mut program_test = ProgramTest::new("dlp", dlp_api::ID, None); + program_test.prefer_bpf(true); + let validator = crate::fixtures::keypair_from_bytes(&TEST_AUTHORITY); + + program_test.add_account( + validator.pubkey(), + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + + if fund_rent_payer { + program_test.add_account( + rent_payer, + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + } + + // Setup a delegated PDA (no data, so undelegation takes the fast path) + program_test.add_account( + DELEGATED_PDA_ID, + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegation record PDA + let delegation_record_data = + get_delegation_record_data(validator.pubkey(), None); + let record_rent = + Rent::default().minimum_balance(delegation_record_data.len()); + program_test.add_account( + delegation_record_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: record_rent, + data: delegation_record_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegation metadata PDA: first commit of the session, so the + // fee is exactly the session fee and the rent leaves a small refund + let delegation_metadata_data = + create_delegation_metadata_data_with_commit_id( + rent_payer, + &[&RENT_PAYER_SEED], + true, + 1, + ); + let metadata_rent = + Rent::default().minimum_balance(delegation_metadata_data.len()); + program_test.add_account( + delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: metadata_rent, + data: delegation_metadata_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the owner program + let data = read_file("tests/buffers/test_delegation.so"); + program_test.add_account( + DELEGATED_PDA_OWNER_ID, + Account { + lamports: Rent::default().minimum_balance(data.len()).max(1), + data, + owner: solana_sdk::bpf_loader::id(), + executable: true, + rent_epoch: 0, + }, + ); + + // Setup the protocol fees vault + program_test.add_account( + fees_vault_pda(), + Account { + lamports: Rent::default().minimum_balance(0), + data: vec![], + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the validator fees vault + program_test.add_account( + validator_fees_vault_pda_from_validator(&validator.pubkey()), + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let (banks, _, blockhash) = program_test.start().await; + (banks, validator, blockhash, record_rent, metadata_rent) +}