From e6910a3f1ab03b0d4c85bd254c8e5c318798c4fc Mon Sep 17 00:00:00 2001 From: Ritesh Date: Sat, 17 Oct 2026 07:00:56 +0000 Subject: [PATCH 1/2] Exempt every delegated queue from the request fee --- api/src/state/queue.rs | 8 ++- program/src/delegate_oracle_queue.rs | 5 +- program/src/fees.rs | 13 ++-- program/src/provide_randomness.rs | 8 ++- program/src/purge_expired_requests.rs | 4 +- program/src/request_randomness.rs | 7 +- program/tests/test.rs | 97 ++++++++++++++++++++++++++- 7 files changed, 127 insertions(+), 15 deletions(-) diff --git a/api/src/state/queue.rs b/api/src/state/queue.rs index 50e82aa..b3ade82 100644 --- a/api/src/state/queue.rs +++ b/api/src/state/queue.rs @@ -21,7 +21,13 @@ pub struct Queue { /// can be drained and closed. 0 = active. Reuses former padding, so /// existing (zeroed) accounts read as active. pub paused: u8, - pub _padding: [u8; 2], + /// 1 if delegated to an ephemeral rollup. `DelegateOracleQueue` sets this + /// before delegation, and the rollup clone preserves it. Such rollups cannot + /// transfer lamports from accounts they do not hold, including the requester + /// wallet and confined queue, so queue requests and payouts incur no fee. + /// Reuses former padding; existing zero-initialized accounts default to 0. + pub ephemeral: u8, + pub _padding: [u8; 1], } /// Single queue entry. This is written into the variable region and diff --git a/program/src/delegate_oracle_queue.rs b/program/src/delegate_oracle_queue.rs index 0043fa1..f5450ae 100644 --- a/program/src/delegate_oracle_queue.rs +++ b/program/src/delegate_oracle_queue.rs @@ -56,12 +56,15 @@ pub fn process_delegate_oracle_queue(accounts: &[AccountInfo<'_>], data: &[u8]) // be pulled into the rollup. A drained queue is not "unused": removed items // leave non-zero bytes behind, so the variable region is checked too. { - let data = oracle_queue_info.try_borrow_data()?; + let mut data = oracle_queue_info.try_borrow_mut_data()?; let queue = Queue::try_from_bytes(&data)?; let variable_region_start = 8 + core::mem::size_of::(); if queue.item_count != 0 || data[variable_region_start..].iter().any(|&b| b != 0) { return Err(SolanaVrfError::QueueAlreadyInUse.into()); } + // Mark the queue before delegating, so the rollup's clone carries the + // mark and its requests move no fee. + QueueAccount::load(&mut data)?.header.ephemeral = 1; } // Delegate diff --git a/program/src/fees.rs b/program/src/fees.rs index b168739..af020eb 100644 --- a/program/src/fees.rs +++ b/program/src/fees.rs @@ -4,12 +4,15 @@ use solana_program::pubkey::Pubkey; use solana_vrf_api::consts::DEFAULT_EPHEMERAL_QUEUE; #[cfg(feature = "ephemeral-test-queue")] use solana_vrf_api::consts::DEFAULT_EPHEMERAL_TEST_QUEUE; +use solana_vrf_api::state::Queue; -/// Whether `queue` is exempt from the per-request fee (and the matching oracle payout). -/// `DEFAULT_EPHEMERAL_QUEUE` is always exempt; the local test queue only with the -/// `ephemeral-test-queue` feature, so production builds never exempt it. -pub fn is_fee_exempt_ephemeral_queue(queue: &Pubkey) -> bool { - if queue == &DEFAULT_EPHEMERAL_QUEUE { +/// Whether the queue is exempt from request fees and matching oracle payouts. +/// Queues delegated to ephemeral rollups are exempt because the rollup cannot +/// transfer lamports from the requester or the confined queue. +/// `DEFAULT_EPHEMERAL_QUEUE` is always exempt. The local test queue is exempt +/// only when the `ephemeral-test-queue` feature is enabled. +pub fn is_fee_exempt_ephemeral_queue(queue: &Pubkey, header: &Queue) -> bool { + if header.ephemeral != 0 || queue == &DEFAULT_EPHEMERAL_QUEUE { return true; } #[cfg(feature = "ephemeral-test-queue")] diff --git a/program/src/provide_randomness.rs b/program/src/provide_randomness.rs index 6b84651..4e10524 100644 --- a/program/src/provide_randomness.rs +++ b/program/src/provide_randomness.rs @@ -153,10 +153,12 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> let metas = removed_item.account_metas(queue_acc.acc).to_vec(); let disc = removed_item.callback_discriminator(queue_acc.acc).to_vec(); let args_bytes = removed_item.callback_args(queue_acc.acc).to_vec(); - (removed_item, metas, disc, args_bytes) + let fee_exempt = + crate::fees::is_fee_exempt_ephemeral_queue(oracle_queue_info.key, queue_acc.header); + (removed_item, metas, disc, args_bytes, fee_exempt) }; - let (removed_item, metas_vec, disc_vec, args_vec) = removed_item_and_buf; + let (removed_item, metas_vec, disc_vec, args_vec, fee_exempt) = removed_item_and_buf; // Invoke the callback with randomness callback_program_info.has_address(&Pubkey::new_from_array(removed_item.callback_program_id))?; @@ -212,7 +214,7 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> } // Collect the fees (unless this is a fee-exempt ephemeral queue) - if !crate::fees::is_fee_exempt_ephemeral_queue(oracle_queue_info.key) { + if !fee_exempt { let cost = if removed_item.priority_request == 1 { VRF_HIGH_PRIORITY_LAMPORTS_COST } else { diff --git a/program/src/purge_expired_requests.rs b/program/src/purge_expired_requests.rs index de67a2d..3ee985a 100644 --- a/program/src/purge_expired_requests.rs +++ b/program/src/purge_expired_requests.rs @@ -59,7 +59,9 @@ pub fn process_purge_expired_requests(accounts: &[AccountInfo<'_>], data: &[u8]) // The oracle also accrues fees on malformed/expired requests to // 1) incentivize queue cleaning and // 2) disincentivize creation of malformed requests - if total_cost > 0 && !crate::fees::is_fee_exempt_ephemeral_queue(oracle_queue_info.key) { + if total_cost > 0 + && !crate::fees::is_fee_exempt_ephemeral_queue(oracle_queue_info.key, queue_acc.header) + { crate::fees::transfer_fee(oracle_queue_info, oracle_info, total_cost)?; } diff --git a/program/src/request_randomness.rs b/program/src/request_randomness.rs index 6e426f7..60bd2b8 100644 --- a/program/src/request_randomness.rs +++ b/program/src/request_randomness.rs @@ -80,7 +80,7 @@ pub fn process_request_randomness( let slot = Clock::get()?.slot; let time = Clock::get()?.unix_timestamp; - { + let fee_exempt = { // Borrow queue account data and load QueueAccount view let mut data = oracle_queue_info.try_borrow_mut_data()?; Queue::try_from_bytes(&data)?; @@ -145,10 +145,11 @@ pub fn process_request_randomness( .to_bytes() }, )?; - } + crate::fees::is_fee_exempt_ephemeral_queue(oracle_queue_info.key, queue_acc.header) + }; // Transfer request cost to the queue PDA (unless this is a fee-exempt ephemeral queue) - if !crate::fees::is_fee_exempt_ephemeral_queue(oracle_queue_info.key) { + if !fee_exempt { let cost = if high_priority { VRF_HIGH_PRIORITY_LAMPORTS_COST } else { diff --git a/program/tests/test.rs b/program/tests/test.rs index b6b97a4..f79944f 100644 --- a/program/tests/test.rs +++ b/program/tests/test.rs @@ -7,7 +7,7 @@ use solana_curve25519::ristretto::PodRistrettoPoint; use solana_curve25519::scalar::PodScalar; use solana_program::rent::Rent; use solana_program::sysvar::slot_hashes; -use solana_program_test::{processor, read_file, ProgramTest, ProgramTestContext}; +use solana_program_test::{processor, read_file, BanksClient, ProgramTest, ProgramTestContext}; use solana_sdk::account::Account; use solana_sdk::{pubkey, signature::Keypair, signer::Signer, transaction::Transaction}; use solana_vrf::vrf::{compute_vrf, generate_vrf_keypair, verify_vrf}; @@ -514,6 +514,101 @@ async fn run_test() { ); } +// A queue delegated to an ephemeral rollup carries the ephemeral mark, and the +// rollup's clone of it takes a request and fulfils it with no fee moving: a +// rollup refuses a transaction that changes the lamports of an account it does +// not hold, the requester's wallet or the confined queue. +#[tokio::test] +async fn delegated_queue_requests_move_no_fee() { + let mut context = setup().await; + let banks = context.banks_client.clone(); + let authority_keypair = Keypair::try_from(&TEST_AUTHORITY[..]).unwrap(); + let oracle_keypair = Keypair::try_from(&TEST_ORACLE[..]).unwrap(); + let (oracle_vrf_sk, oracle_vrf_pk) = generate_vrf_keypair(&oracle_keypair); + + let payer = context.payer.insecure_clone(); + send(&banks, &[initialize(payer.pubkey())], &[&payer]).await; + let ix = add_oracle( + authority_keypair.pubkey(), + oracle_keypair.pubkey(), + oracle_vrf_pk.compress().to_bytes(), + ); + send(&banks, &[ix], &[&authority_keypair]).await; + // Queue 0 is DEFAULT_TEST_QUEUE, the one the callback program accepts. + let ixs = initialize_oracle_queue(payer.pubkey(), oracle_keypair.pubkey(), 0, Some(10_000)); + send(&banks, &ixs, &[&payer, &oracle_keypair]).await; + let queue = oracle_queue_pda(&oracle_keypair.pubkey(), 0).0; + let ix = delegate_oracle_queue(oracle_keypair.pubkey(), queue, 0); + send(&banks, &[ix], &[&oracle_keypair]).await; + + let delegated = banks.get_account(queue).await.unwrap().unwrap(); + assert_eq!(delegated.owner, DELEGATION_PROGRAM_ID); + let mut data = delegated.data.clone(); + assert_eq!( + QueueAccount::load(&mut data[..]).unwrap().header.ephemeral, + 1 + ); + + // The rollup's clone: the same bytes and lamports, owned by this program. + context.set_account( + &queue, + &Account { + owner: solana_vrf_api::ID, + ..delegated.clone() + } + .into(), + ); + + send( + &banks, + &[request_randomness_to_queue(payer.pubkey(), 7, queue)], + &[&payer], + ) + .await; + let requested = banks.get_account(queue).await.unwrap().unwrap(); + let mut data = requested.data.clone(); + let item = QueueAccount::load(&mut data[..]) + .unwrap() + .get_item_by_index(0) + .unwrap() + .id; + assert_eq!( + requested.lamports, delegated.lamports, + "the request moved a fee" + ); + + let slot = banks.get_sysvar::().await.unwrap().slot; + context.warp_to_slot(slot + 1).unwrap(); + let (output, (commitment_base, commitment_hash, s)) = compute_vrf(oracle_vrf_sk, &item); + let provide = provide_randomness( + oracle_keypair.pubkey(), + queue, + TEST_CALLBACK_PROGRAM, + item, + PodRistrettoPoint(output.to_bytes()), + PodRistrettoPoint(commitment_base.to_bytes()), + PodRistrettoPoint(commitment_hash.to_bytes()), + PodScalar(s.to_bytes()), + ); + let compute = ComputeBudgetInstruction::set_compute_unit_limit(2_000_000); + send(&banks, &[compute, provide], &[&oracle_keypair]).await; + let provided = banks.get_account(queue).await.unwrap().unwrap(); + let mut data = provided.data.clone(); + assert_eq!(QueueAccount::load(&mut data[..]).unwrap().len(), 0); + assert_eq!( + provided.lamports, delegated.lamports, + "the fulfilment paid a fee out" + ); +} + +/// send signs with every signer, the first paying, and requires success. +async fn send(banks: &BanksClient, ixs: &[Instruction], signers: &[&Keypair]) { + let blockhash = banks.get_latest_blockhash().await.unwrap(); + let tx = + Transaction::new_signed_with_payer(ixs, Some(&signers[0].pubkey()), signers, blockhash); + banks.process_transaction(tx).await.unwrap(); +} + pub fn request_randomness(signer: Pubkey, client_seed: u8) -> Instruction { // Forward to the generic helper, using the default oracle queue used previously let oracle_queue = pubkey!("GKE6d7iv8kCBrsxr78W3xVdjGLLLJnxsGiuzrsZCGEvb"); From 7f632206fe374a2a57a091d1b75bf3770748067d Mon Sep 17 00:00:00 2001 From: Ritesh Date: Sat, 17 Oct 2026 07:44:56 +0000 Subject: [PATCH 2/2] Addressed coderabbit findings --- program/tests/test.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/program/tests/test.rs b/program/tests/test.rs index f79944f..a9f49ee 100644 --- a/program/tests/test.rs +++ b/program/tests/test.rs @@ -599,6 +599,30 @@ async fn delegated_queue_requests_move_no_fee() { provided.lamports, delegated.lamports, "the fulfilment paid a fee out" ); + + // An expired request is purged with no fee paid out either. + send( + &banks, + &[request_randomness_to_queue(payer.pubkey(), 8, queue)], + &[&payer], + ) + .await; + let mut clock = banks.get_sysvar::().await.unwrap(); + clock.unix_timestamp += QUEUE_TTL_SECONDS as i64 + 1; + context.set_sysvar(&clock); + send( + &banks, + &[purge_expired_requests(oracle_keypair.pubkey(), 0)], + &[&payer], + ) + .await; + let purged = banks.get_account(queue).await.unwrap().unwrap(); + let mut data = purged.data.clone(); + assert_eq!(QueueAccount::load(&mut data[..]).unwrap().len(), 0); + assert_eq!( + purged.lamports, delegated.lamports, + "the purge paid a fee out" + ); } /// send signs with every signer, the first paying, and requires success.