From 0855c9641a140131a02de96b1728ae109c5b4f8b Mon Sep 17 00:00:00 2001 From: jonasXchen Date: Mon, 9 Nov 2026 16:21:06 +0800 Subject: [PATCH] fix(rewards-delegated-vrf): authenticate VRF callback with scoped identity consume_random_reward accepted the legacy global VRF_PROGRAM_IDENTITY as signer. That identity signs callbacks for any requester, so anyone could request randomness from their own program with this program's ConsumeRandomReward as callback and receive reward payouts, bypassing the admin/whitelist gate and time window in request_random_reward. - Replace ephemeral-vrf-sdk 0.3.0 with ephemeral-rollups-sdk's `vrf` feature - Request with create_request_high_priority_scoped_randomness_ix - Use #[vrf_callback] so the callback requires scoped_vrf_identity(&crate::ID) - Compute the draw range in u64 (a full u32 range no longer panics on % 0) and return InvalidDrawRange instead of unwrap - Skip winning rewards with no mints left before counting a redemption - Enable anchor-spl/idl-build only under the idl-build feature, which removes regex from the on-chain binary (fixes stack-offset build error) --- rewards-delegated-vrf/anchor/Cargo.lock | 31 ++----------------- .../anchor/dashboard/lib/vrfConstants.ts | 10 +++--- .../programs/rewards-delegated-vrf/Cargo.toml | 7 ++--- .../src/instructions/consume_random_reward.rs | 17 +++++++--- .../src/instructions/request_random_reward.rs | 10 ++++-- .../programs/rewards-delegated-vrf/src/lib.rs | 13 +++++--- 6 files changed, 40 insertions(+), 48 deletions(-) diff --git a/rewards-delegated-vrf/anchor/Cargo.lock b/rewards-delegated-vrf/anchor/Cargo.lock index f68c9375..461ab46a 100644 --- a/rewards-delegated-vrf/anchor/Cargo.lock +++ b/rewards-delegated-vrf/anchor/Cargo.lock @@ -827,8 +827,8 @@ dependencies = [ "ephemeral-rollups-sdk-attribute-delegate", "ephemeral-rollups-sdk-attribute-ephemeral", "ephemeral-rollups-sdk-attribute-ephemeral-accounts", - "ephemeral-vrf-sdk 0.4.1", - "ephemeral-vrf-sdk-vrf-macro 0.4.1", + "ephemeral-vrf-sdk", + "ephemeral-vrf-sdk-vrf-macro", "five8 0.2.1", "getrandom 0.2.16", "magicblock-delegation-program-api", @@ -895,19 +895,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "ephemeral-vrf-sdk" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6f6d796808e62cac2734c58731e8f7a43a7b9725587c0ceb2f94ab8b0a1421" -dependencies = [ - "anchor-lang", - "borsh 1.5.7", - "ephemeral-vrf-sdk-vrf-macro 0.3.0", - "solana-program 3.0.0", - "solana-system-interface 3.2.0", -] - [[package]] name = "ephemeral-vrf-sdk" version = "0.4.1" @@ -916,22 +903,11 @@ checksum = "77f3741c733907c10e0d0b32f9aa485c20a73e0b1e7e673a0ac38185fb2a6c11" dependencies = [ "anchor-lang", "borsh 1.5.7", - "ephemeral-vrf-sdk-vrf-macro 0.4.1", + "ephemeral-vrf-sdk-vrf-macro", "solana-program 3.0.0", "solana-system-interface 3.2.0", ] -[[package]] -name = "ephemeral-vrf-sdk-vrf-macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a2f2f427a08d0a9a3806034fc719f6181dc0a2d0ad990e472707a7093cf3b9d" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "ephemeral-vrf-sdk-vrf-macro" version = "0.4.1" @@ -1782,7 +1758,6 @@ dependencies = [ "anchor-lang", "anchor-spl", "ephemeral-rollups-sdk", - "ephemeral-vrf-sdk 0.3.0", "spl-associated-token-account", ] diff --git a/rewards-delegated-vrf/anchor/dashboard/lib/vrfConstants.ts b/rewards-delegated-vrf/anchor/dashboard/lib/vrfConstants.ts index 6a65de4f..f4284e1c 100644 --- a/rewards-delegated-vrf/anchor/dashboard/lib/vrfConstants.ts +++ b/rewards-delegated-vrf/anchor/dashboard/lib/vrfConstants.ts @@ -26,10 +26,12 @@ export const TEST_WHITELIST_USER = new PublicKey( ); /** - * Derive the VRF Program Identity PDA - * This is used to validate that VRF callbacks come from the official VRF program - * The identity PDA is derived from the callback program (this rewards program), not the VRF program - * Seed: "identity", Program: rewards program (PROGRAM_ID) + * Derive this program's caller identity PDA (seed: "identity", program: PROGRAM_ID). + * The rewards program signs the randomness *request* with it. + * + * Not to be confused with the callback signer: the VRF oracle signs + * consumeRandomReward with the scoped identity PDA(["identity", PROGRAM_ID], VRF_PROGRAM_ID), + * which the program validates on-chain. Clients never pass that account. */ export function getVrfProgramIdentity(): PublicKey { const IDENTITY_SEED = Buffer.from("identity"); diff --git a/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/Cargo.toml b/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/Cargo.toml index 72a62284..092ff4a6 100644 --- a/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/Cargo.toml +++ b/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/Cargo.toml @@ -13,15 +13,14 @@ cpi = ["no-entrypoint"] no-entrypoint = [] no-idl = [] no-log-ix-name = [] -idl-build = ["anchor-lang/idl-build"] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] anchor-debug = [] custom-heap = [] custom-panic = [] [dependencies] anchor-lang = { version = "1.0.2", features = ["init-if-needed"] } -anchor-spl = { version = "1.0.2", features = ["idl-build", "metadata", "associated_token"] } -ephemeral-rollups-sdk = { version = "0.16.2" , features = ["anchor"] } -ephemeral-vrf-sdk = {version = "0.3.0", features = ["anchor"]} +anchor-spl = { version = "1.0.2", features = ["metadata", "associated_token"] } +ephemeral-rollups-sdk = { version = "0.16.2", features = ["anchor", "vrf"] } spl-associated-token-account = { version = "8.0.0", features = ["no-entrypoint"] } diff --git a/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/instructions/consume_random_reward.rs b/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/instructions/consume_random_reward.rs index 3bd7671c..7ff88351 100644 --- a/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/instructions/consume_random_reward.rs +++ b/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/instructions/consume_random_reward.rs @@ -1,6 +1,7 @@ use anchor_lang::prelude::*; use crate::constants; +use crate::errors::RewardError; use crate::instructions::shared::{schedule_transfer_action, TransferSource}; use crate::state::RewardType; use crate::ConsumeRandomReward; @@ -37,12 +38,15 @@ pub fn consume_random_reward( let reward_list = &mut ctx.accounts.reward_list; // Log the raw randomness proof as hex for auditability msg!("Randomness proof: {:?}", randomness.map(|b| b)); - let rnd_u32 = ephemeral_vrf_sdk::rnd::random_u32(&randomness); + let rnd_u32 = ephemeral_rollups_sdk::vrf::rnd::random_u32(&randomness); + // Width of [min, max] inclusive. Kept in u64: a full u32 range (0..=u32::MAX) + // has 2^32 values, which would wrap to 0 as u32 and panic on `% 0`. let range = (reward_list.global_range_max as u64) .checked_sub(reward_list.global_range_min as u64) - .unwrap() + .ok_or(RewardError::InvalidDrawRange)? + 1; - let result = reward_list.global_range_min + (rnd_u32 % range as u32); + // `offset < range <= 2^32`, and `min + offset <= max`, so both casts are lossless. + let result = reward_list.global_range_min + ((rnd_u32 as u64) % range) as u32; msg!("Random result: {:?} for user: {:?}", result, user.key()); for reward in reward_list.rewards.iter() { @@ -60,7 +64,10 @@ pub fn consume_random_reward( for reward in reward_list.rewards.iter_mut() { if result >= reward.draw_range_min && result <= reward.draw_range_max { found_reward = true; - if reward.redemption_count < reward.redemption_limit { + if reward.reward_mints.is_empty() { + // Checked before counting a redemption, and avoids `% 0` on mint selection. + msg!("Reward '{}' has no mints left to distribute", reward.name); + } else if reward.redemption_count < reward.redemption_limit { reward.redemption_count = reward.redemption_count.saturating_add(1); msg!( "Won reward '{}' (range {}-{})", @@ -79,7 +86,7 @@ pub fn consume_random_reward( let mut rnd_bytes = [0u8; 32]; rnd_bytes.copy_from_slice(&randomness); rnd_bytes.rotate_left(4); - let rnd_mint = ephemeral_vrf_sdk::rnd::random_u32(&rnd_bytes); + let rnd_mint = ephemeral_rollups_sdk::vrf::rnd::random_u32(&rnd_bytes); let mint_index = (rnd_mint as usize) % mint_count; reward.reward_mints.remove(mint_index) } diff --git a/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/instructions/request_random_reward.rs b/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/instructions/request_random_reward.rs index 3b4a26ab..0f4dd9b6 100644 --- a/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/instructions/request_random_reward.rs +++ b/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/instructions/request_random_reward.rs @@ -1,7 +1,9 @@ use anchor_lang::prelude::*; use ephemeral_rollups_sdk::consts::{MAGIC_CONTEXT_ID, MAGIC_PROGRAM_ID}; -use ephemeral_vrf_sdk::instructions::{create_request_randomness_ix, RequestRandomnessParams}; -use ephemeral_vrf_sdk::types::SerializableAccountMeta; +use ephemeral_rollups_sdk::vrf::instructions::{ + create_request_high_priority_scoped_randomness_ix, RequestRandomnessParams, +}; +use ephemeral_rollups_sdk::vrf::types::SerializableAccountMeta; use crate::instruction; use crate::RequestRandomReward; @@ -50,7 +52,9 @@ pub fn request_random_reward(ctx: Context, client_seed: u8) &ephemeral_rollups_sdk::id(), ); - let ix = create_request_randomness_ix(RequestRandomnessParams { + // Scoped request: the oracle signs the callback with the per-program identity + // `scoped_vrf_identity(&crate::ID)`, which `ConsumeRandomReward` validates. + let ix = create_request_high_priority_scoped_randomness_ix(RequestRandomnessParams { payer: ctx.accounts.admin.key(), oracle_queue: ctx.accounts.oracle_queue.key(), callback_program_id: ID, diff --git a/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/lib.rs b/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/lib.rs index ff9ffcc7..14e0f70e 100644 --- a/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/lib.rs +++ b/rewards-delegated-vrf/anchor/programs/rewards-delegated-vrf/src/lib.rs @@ -5,7 +5,7 @@ use anchor_spl::associated_token::AssociatedToken; use anchor_spl::metadata::Metadata; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; use ephemeral_rollups_sdk::anchor::{action, commit, delegate, ephemeral}; -use ephemeral_vrf_sdk::anchor::vrf; +use ephemeral_rollups_sdk::anchor::{vrf, vrf_callback}; pub mod constants; pub mod errors; @@ -234,18 +234,23 @@ pub struct RequestRandomReward<'info> { #[account(seeds = [constants::TRANSFER_LOOKUP_TABLE_SEED], bump)] pub transfer_lookup_table: Account<'info, state::TransferLookupTable>, /// CHECK: Validated by address constraint against the known VRF oracle queue - #[account(mut, address = ephemeral_vrf_sdk::consts::DEFAULT_EPHEMERAL_QUEUE)] + #[account(mut, address = ephemeral_rollups_sdk::vrf::consts::DEFAULT_EPHEMERAL_QUEUE)] pub oracle_queue: UncheckedAccount<'info>, /// CHECK: reward_list delegation record; its validator derives magic_fee_vault #[account(address = ephemeral_rollups_sdk::pda::delegation_record_pda_from_delegated_account(&reward_list.key()))] pub delegation_record_reward_list: UncheckedAccount<'info>, } +/// VRF callback. `#[vrf_callback]` injects `vrf_program_identity: Signer` +/// constrained to `scoped_vrf_identity(&crate::ID)`. That PDA is bound to this +/// program, so only randomness requested by this program (i.e. through the +/// gated `request_random_reward`) can reach this handler. Do NOT use the legacy +/// global `VRF_PROGRAM_IDENTITY`: it signs callbacks for any requester, which +/// would let anyone trigger reward payouts. +#[vrf_callback] #[commit] #[derive(Accounts)] pub struct ConsumeRandomReward<'info> { - #[account(address = ephemeral_vrf_sdk::consts::VRF_PROGRAM_IDENTITY)] - pub vrf_program_identity: Signer<'info>, /// CHECK: reward recipient, as passed to request_random_reward pub user: UncheckedAccount<'info>, pub reward_distributor: Account<'info, state::RewardDistributor>,