diff --git a/.github/packages/npm-package/package.json.tmpl b/.github/packages/npm-package/package.json.tmpl index b1f750b..830e523 100644 --- a/.github/packages/npm-package/package.json.tmpl +++ b/.github/packages/npm-package/package.json.tmpl @@ -1,13 +1,13 @@ { "name": "@magicblock-labs/${node_pkg}", - "description": "MagicBlock Ephemeral VRF Oracle (${node_pkg})", - "version": "0.4.1", + "description": "MagicBlock SolanaVrf Oracle (${node_pkg})", + "version": "1.0.0", "repository": { "type": "git", - "url": "git+https://github.com/magicblock-labs/ephemeral-vrf.git" + "url": "git+https://github.com/magicblock-labs/solana-vrf.git" }, "bugs": { - "url": "https://github.com/magicblock-labs/ephemeral-vrf/issues" + "url": "https://github.com/magicblock-labs/solana-vrf/issues" }, "license": "Business Source License 1.1", "private": false, diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 6d8b0a7..cbb650d 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -1,4 +1,4 @@ -name: Publish ephemeral vrf packages +name: Publish SolanaVrf packages on: release: diff --git a/Cargo.lock b/Cargo.lock index bfef17d..1841752 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1997,61 +1997,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "ephemeral-vrf" -version = "0.4.1" -dependencies = [ - "curve25519-dalek 4.1.3", - "hkdf", - "sha2 0.10.9", - "solana-sdk", -] - -[[package]] -name = "ephemeral-vrf-api" -version = "0.4.1" -dependencies = [ - "bincode", - "borsh 1.5.7", - "bytemuck", - "curve25519-dalek 4.1.3", - "ephemeral-rollups-sdk", - "num_enum", - "solana-curve25519 3.0.0", - "solana-loader-v3-interface 6.1.1", - "solana-program 3.0.0", - "solana-sdk-ids 3.1.0", - "solana-system-interface 3.2.0", - "thiserror 1.0.69", -] - -[[package]] -name = "ephemeral-vrf-oracle" -version = "0.4.1" -dependencies = [ - "ephemeral-vrf", - "solana-sdk", -] - -[[package]] -name = "ephemeral-vrf-program" -version = "0.4.1" -dependencies = [ - "base64 0.22.1", - "curve25519-dalek 4.1.3", - "ephemeral-rollups-sdk", - "ephemeral-vrf", - "ephemeral-vrf-api", - "solana-compute-budget-interface", - "solana-curve25519 3.0.0", - "solana-program 3.0.0", - "solana-program-test", - "solana-sdk", - "solana-sdk-ids 3.1.0", - "solana-system-interface 3.2.0", - "tokio", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -9095,6 +9040,53 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "solana-vrf" +version = "1.0.0" +dependencies = [ + "curve25519-dalek 4.1.3", + "hkdf", + "sha2 0.10.9", + "solana-sdk", +] + +[[package]] +name = "solana-vrf-api" +version = "1.0.0" +dependencies = [ + "bincode", + "borsh 1.5.7", + "bytemuck", + "curve25519-dalek 4.1.3", + "ephemeral-rollups-sdk", + "num_enum", + "solana-curve25519 3.0.0", + "solana-loader-v3-interface 6.1.1", + "solana-program 3.0.0", + "solana-sdk-ids 3.1.0", + "solana-system-interface 3.2.0", + "thiserror 1.0.69", +] + +[[package]] +name = "solana-vrf-program" +version = "1.0.0" +dependencies = [ + "base64 0.22.1", + "curve25519-dalek 4.1.3", + "ephemeral-rollups-sdk", + "solana-compute-budget-interface", + "solana-curve25519 3.0.0", + "solana-program 3.0.0", + "solana-program-test", + "solana-sdk", + "solana-sdk-ids 3.1.0", + "solana-system-interface 3.2.0", + "solana-vrf", + "solana-vrf-api", + "tokio", +] + [[package]] name = "solana-zero-copy" version = "1.0.1" @@ -10395,22 +10387,22 @@ checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" [[package]] name = "vrf-cli" -version = "0.4.1" +version = "1.0.0" dependencies = [ "anyhow", "clap", - "ephemeral-vrf", - "ephemeral-vrf-api", "solana-client", "solana-commitment-config", "solana-compute-budget-interface", "solana-sdk", + "solana-vrf", + "solana-vrf-api", "tokio", ] [[package]] name = "vrf-oracle" -version = "0.4.1" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", @@ -10418,8 +10410,6 @@ dependencies = [ "crossbeam-channel", "curve25519-dalek 4.1.3", "env_logger", - "ephemeral-vrf", - "ephemeral-vrf-api", "futures-core", "futures-util", "helius-laserstream", @@ -10433,6 +10423,8 @@ dependencies = [ "solana-compute-budget-interface", "solana-curve25519 3.0.0", "solana-sdk", + "solana-vrf", + "solana-vrf-api", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index c1680fa..691f01a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [workspace] resolver = "2" -members = ["api", "program", "src", "vrf", "vrf-cli", "vrf-oracle"] +members = ["api", "program", "vrf", "vrf-cli", "vrf-oracle"] [workspace.package] -version = "0.4.1" +version = "1.0.0" edition = "2021" license = "Business Source License 1.1" authors = ["MagicBlock Labs "] homepage = "https://www.magicblock.xyz/" documentation = "https://docs.magicblock.xyz/" -repository = "https://github.com/magicblock-labs/ephemeral-vrf" +repository = "https://github.com/magicblock-labs/solana-vrf" readme = "./README.md" keywords = ["solana", "vrf", "ephemeral-rollups", "magicblock"] @@ -30,9 +30,8 @@ crossbeam-channel = "0.5.15" curve25519-dalek = { version = "4.1.3", default-features = false } env_logger = "0.11.7" ephemeral-rollups-sdk = "0.15.1" -ephemeral-vrf = { path = "./vrf" } -ephemeral-vrf-api = { path = "./api" } -ephemeral-vrf-sdk-vrf-macro = { path = "sdk/vrf-macro", version = "0.4.1" } +solana-vrf = { path = "./vrf" } +solana-vrf-api = { path = "./api" } futures = "0.3.31" futures-core = "0.3.31" futures-util = "0.3.31" diff --git a/README.md b/README.md index 3556572..7c9a820 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Common errors include: - InvalidProof – The provided VRF proof is invalid. - InvalidCallbackAccounts - Invalid executable account in the callback -See [`EphemeralVrfError`](api/src/error.rs) for the full error list. +See [`SolanaVrfError`](api/src/error.rs) for the full error list. ## State diff --git a/api/Cargo.toml b/api/Cargo.toml index 7c8a990..e80e423 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "ephemeral-vrf-api" -description = "API for interacting with the EphemeralVrf program" +name = "solana-vrf-api" +description = "API for interacting with the SolanaVrf program" version.workspace = true edition.workspace = true license.workspace = true diff --git a/api/idl/idl.json b/api/idl/idl.json index bbe5b6b..2a4f709 100644 --- a/api/idl/idl.json +++ b/api/idl/idl.json @@ -1,17 +1,16 @@ { "address": "Vrf1RNUjXmQGjmQrQLvJHs9SNkvDJEsRVFPkfSQUwGz", "metadata": { - "name": "ephemeral_vrf", - "version": "0.1.0", + "name": "solana_vrf", + "version": "1.0.0", "spec": "0.1.0", - "description": "Ephemeral VRF" + "description": "SolanaVrf" }, "instructions": [ { "name": "initialize", - "discriminator": [ - 1 - ], + "docs": ["Initialize the oracles registry PDA."], + "discriminator": [0, 0, 0, 0, 0, 0, 0, 0], "accounts": [ { "name": "signer", @@ -19,19 +18,23 @@ "signer": true }, { - "name": "oraclesPda" + "name": "oraclesPda", + "writable": true }, { - "name": "systemProgram" + "name": "systemProgram", + "address": "11111111111111111111111111111111" } ], "args": [] }, { - "name": "initializeOracleQueue", - "discriminator": [ - 2 + "name": "modifyOracle", + "docs": [ + "Add (operation = 0) or remove (operation = 1) an oracle.", + "Admin-gated: the signer must be the program upgrade authority." ], + "discriminator": [1, 0, 0, 0, 0, 0, 0, 0], "accounts": [ { "name": "signer", @@ -39,37 +42,89 @@ "signer": true }, { - "name": "identity" + "name": "oraclesPda", + "writable": true }, { - "name": "oracleDataPda" + "name": "oracleDataPda", + "writable": true }, { - "name": "oracleQueuePda" + "name": "programData" }, { - "name": "systemProgram" + "name": "systemProgram", + "address": "11111111111111111111111111111111" } ], "args": [ { - "name": "index", + "name": "identity", + "type": "pubkey" + }, + { + "name": "oraclePubkey", + "type": { + "array": ["u8", 32] + } + }, + { + "name": "operation", "type": "u8" } ] }, { - "name": "requestRandomness", - "discriminator": [ - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 0 + "name": "initializeOracleQueue", + "docs": [ + "Create an oracle queue PDA. Repeated in one transaction to allocate in", + "10240-byte chunks up to targetSize (capped at MAX_QUEUE_ACCOUNT_SIZE, 512 KiB)." ], + "discriminator": [2, 0, 0, 0, 0, 0, 0, 0], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "oracleIdentity", + "signer": true + }, + { + "name": "oracleDataPda", + "writable": true + }, + { + "name": "oracleQueuePda", + "writable": true + }, + { + "name": "systemProgram", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "targetSize", + "type": "u32" + }, + { + "name": "index", + "type": "u8" + }, + { + "name": "padding", + "type": { + "array": ["u8", 3] + } + } + ] + }, + { + "name": "requestHighPriorityRandomness", + "docs": ["Request randomness at the high-priority fee, using the legacy global program identity."], + "discriminator": [3, 0, 0, 0, 0, 0, 0, 0], "accounts": [ { "name": "caller", @@ -77,7 +132,8 @@ "signer": true }, { - "name": "programIdentity" + "name": "programIdentity", + "signer": true }, { "name": "oracleQueue", @@ -88,7 +144,7 @@ "address": "11111111111111111111111111111111" }, { - "name": "Slot Hashes", + "name": "slotHashes", "address": "SysvarS1otHashes111111111111111111111111111" } ], @@ -96,10 +152,7 @@ { "name": "callerSeed", "type": { - "array": [ - "u8", - 32 - ] + "array": ["u8", 32] } }, { @@ -113,11 +166,11 @@ } }, { - "name": "account_metas", + "name": "callbackAccountsMetas", "type": { "vec": { "defined": { - "name": "AccountMeta" + "name": "SerializableAccountMeta" } } } @@ -132,9 +185,11 @@ }, { "name": "provideRandomness", - "discriminator": [ - 4, 13, 203, 241, 93, 225, 85, 54 + "docs": [ + "Oracle fulfills a request: verifies the VRF proof and CPIs into the", + "callback program. Remaining accounts are the callback's accounts." ], + "discriminator": [4, 0, 0, 0, 0, 0, 0, 0], "accounts": [ { "name": "oracleIdentity", @@ -145,62 +200,194 @@ "name": "vrfProgramIdentity" }, { - "name": "oracleDataPda", - "writable": true + "name": "oracleDataPda" }, { "name": "oracleQueue", "writable": true }, { - "name": "callbackProgramId" + "name": "callbackProgram" + } + ], + "args": [ + { + "name": "input", + "type": { + "array": ["u8", 32] + } }, { - "name": "systemProgram" + "name": "output", + "type": { + "array": ["u8", 32] + } }, { - "name": "accountCallback", + "name": "commitmentBaseCompressed", + "type": { + "array": ["u8", 32] + } + }, + { + "name": "commitmentHashCompressed", + "type": { + "array": ["u8", 32] + } + }, + { + "name": "scalar", + "type": { + "array": ["u8", 32] + } + } + ] + }, + { + "name": "delegateOracleQueue", + "docs": ["Delegate a never-used oracle queue to the ephemeral rollups delegation program."], + "discriminator": [5, 0, 0, 0, 0, 0, 0, 0], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "queue", + "writable": true + }, + { + "name": "buffer", + "writable": true + }, + { + "name": "delegationRecord", "writable": true + }, + { + "name": "delegationMetadata", + "writable": true + }, + { + "name": "delegationProgram" + }, + { + "name": "ownerProgram", + "address": "Vrf1RNUjXmQGjmQrQLvJHs9SNkvDJEsRVFPkfSQUwGz" + }, + { + "name": "systemProgram", + "address": "11111111111111111111111111111111" } ], "args": [ { - "name": "rndSeed", - "type": "bytes" + "name": "index", + "type": "u8" } ] }, { - "name": "delegateOracleQueue", - "discriminator": [ - 5 + "name": "closeOracleQueue", + "docs": ["Close an empty oracle queue and reclaim its rent. Oracle-gated."], + "discriminator": [7, 0, 0, 0, 0, 0, 0, 0], + "accounts": [ + { + "name": "oracleIdentity", + "writable": true, + "signer": true + }, + { + "name": "oracleDataPda", + "writable": true + }, + { + "name": "oracleQueue", + "writable": true + } ], + "args": [ + { + "name": "index", + "type": "u8" + } + ] + }, + { + "name": "requestRandomness", + "docs": ["Request randomness at the base fee, using the legacy global program identity."], + "discriminator": [8, 0, 0, 0, 0, 0, 0, 0], "accounts": [ { - "name": "signer", + "name": "caller", "writable": true, "signer": true }, { - "name": "queue" + "name": "programIdentity", + "signer": true + }, + { + "name": "oracleQueue", + "writable": true }, { - "name": "buffer" + "name": "systemProgram", + "address": "11111111111111111111111111111111" }, { - "name": "delegationRecord" + "name": "slotHashes", + "address": "SysvarS1otHashes111111111111111111111111111" + } + ], + "args": [ + { + "name": "callerSeed", + "type": { + "array": ["u8", 32] + } + }, + { + "name": "callbackProgramId", + "type": "pubkey" }, { - "name": "delegationMetadata" + "name": "callbackDiscriminator", + "type": { + "vec": "u8" + } }, { - "name": "delegationProgramId" + "name": "callbackAccountsMetas", + "type": { + "vec": { + "defined": { + "name": "SerializableAccountMeta" + } + } + } }, { - "name": "programId" + "name": "callbackArgs", + "type": { + "vec": "u8" + } + } + ] + }, + { + "name": "purgeExpiredRequests", + "docs": ["Remove requests older than QUEUE_TTL_SECONDS from a queue. Permissionless."], + "discriminator": [9, 0, 0, 0, 0, 0, 0, 0], + "accounts": [ + { + "name": "oracleIdentity", + "writable": true }, { - "name": "systemProgram" + "name": "oracleQueue", + "writable": true } ], "args": [ @@ -211,30 +398,155 @@ ] }, { - "name": "undelegateOracleQueue", - "discriminator": [ - 6 + "name": "requestRandomnessScoped", + "docs": ["Request randomness at the base fee, using the per-callback scoped identity PDA."], + "discriminator": [10, 0, 0, 0, 0, 0, 0, 0], + "accounts": [ + { + "name": "caller", + "writable": true, + "signer": true + }, + { + "name": "programIdentity", + "signer": true + }, + { + "name": "oracleQueue", + "writable": true + }, + { + "name": "systemProgram", + "address": "11111111111111111111111111111111" + }, + { + "name": "slotHashes", + "address": "SysvarS1otHashes111111111111111111111111111" + } ], + "args": [ + { + "name": "callerSeed", + "type": { + "array": ["u8", 32] + } + }, + { + "name": "callbackProgramId", + "type": "pubkey" + }, + { + "name": "callbackDiscriminator", + "type": { + "vec": "u8" + } + }, + { + "name": "callbackAccountsMetas", + "type": { + "vec": { + "defined": { + "name": "SerializableAccountMeta" + } + } + } + }, + { + "name": "callbackArgs", + "type": { + "vec": "u8" + } + } + ] + }, + { + "name": "requestHighPriorityRandomnessScoped", + "docs": ["Request randomness at the high-priority fee, using the per-callback scoped identity PDA."], + "discriminator": [11, 0, 0, 0, 0, 0, 0, 0], "accounts": [ { - "name": "signer", + "name": "caller", "writable": true, "signer": true }, { - "name": "queue" + "name": "programIdentity", + "signer": true + }, + { + "name": "oracleQueue", + "writable": true + }, + { + "name": "systemProgram", + "address": "11111111111111111111111111111111" + }, + { + "name": "slotHashes", + "address": "SysvarS1otHashes111111111111111111111111111" + } + ], + "args": [ + { + "name": "callerSeed", + "type": { + "array": ["u8", 32] + } + }, + { + "name": "callbackProgramId", + "type": "pubkey" + }, + { + "name": "callbackDiscriminator", + "type": { + "vec": "u8" + } + }, + { + "name": "callbackAccountsMetas", + "type": { + "vec": { + "defined": { + "name": "SerializableAccountMeta" + } + } + } }, { - "name": "magicContextId" + "name": "callbackArgs", + "type": { + "vec": "u8" + } + } + ] + }, + { + "name": "setQueuePaused", + "docs": [ + "Pause (paused = 1) or unpause (paused = 0) an oracle queue; other values", + "are rejected. A paused queue rejects new requests so it can be drained", + "and closed. Oracle-gated." + ], + "discriminator": [12, 0, 0, 0, 0, 0, 0, 0], + "accounts": [ + { + "name": "oracleIdentity", + "signer": true }, { - "name": "magicProgramId" + "name": "oracleQueue", + "writable": true } ], "args": [ { "name": "index", "type": "u8" + }, + { + "name": "paused", + "type": "u8" } ] } @@ -254,11 +566,56 @@ "code": 2, "name": "InvalidProof", "msg": "Invalid proof" + }, + { + "code": 3, + "name": "InvalidCallbackAccounts", + "msg": "Invalid callback accounts" + }, + { + "code": 5, + "name": "InvalidQueueIndex", + "msg": "Invalid queue index" + }, + { + "code": 8, + "name": "ArgumentSizeTooLarge", + "msg": "Argument size exceeds the maximum allowed" + }, + { + "code": 11, + "name": "QueueNotEmpty", + "msg": "Queue is not empty - cannot close queue with pending requests" + }, + { + "code": 12, + "name": "InvalidOracleIdentity", + "msg": "Invalid oracle identity" + }, + { + "code": 13, + "name": "OracleMustProvideInDifferentSlot", + "msg": "Oracle must fulfill in a different slot than the request slot" + }, + { + "code": 14, + "name": "QueueAlreadyInUse", + "msg": "Queue has already been used and can no longer be delegated" + }, + { + "code": 15, + "name": "QueuePaused", + "msg": "Queue is paused and not accepting new requests" + }, + { + "code": 16, + "name": "QueueSizeTooLarge", + "msg": "Queue account size exceeds the maximum allowed" } ], "types": [ { - "name": "AccountMeta", + "name": "SerializableAccountMeta", "type": { "kind": "struct", "fields": [ @@ -278,4 +635,4 @@ } } ] -} \ No newline at end of file +} diff --git a/api/src/consts.rs b/api/src/consts.rs index 16691ca..ea52525 100644 --- a/api/src/consts.rs +++ b/api/src/consts.rs @@ -20,8 +20,14 @@ pub const VRF_PREFIX_HASH_TO_SCALAR: &[u8] = b"VRF-Ephem-HashToScalar"; pub const VRF_HIGH_PRIORITY_LAMPORTS_COST: u64 = 800000; pub const VRF_LAMPORTS_COST: u64 = 500000; -// ~2 minutes on Solana (~500ms/slot) ≈ 240 slots. Round to 240. -pub const QUEUE_TTL_SLOTS: u64 = 240; +/// Requests expire after this many seconds. Measured in wall-clock time so +/// expiry stays correct if Solana's slot duration changes in the future. +pub const QUEUE_TTL_SECONDS: u64 = 120; + +/// Hard cap on the oracle queue account size (512 KiB). Keeps every O(n) +/// queue operation — including the multi-scan request and fulfillment paths — +/// within the 1.4M CU per-transaction budget, structurally. +pub const MAX_QUEUE_ACCOUNT_SIZE: u32 = 512 * 1024; pub const RISTRETTO_BASEPOINT_POINT: PodRistrettoPoint = PodRistrettoPoint([ 226, 242, 174, 10, 106, 188, 78, 113, 168, 132, 169, 97, 197, 0, 81, 95, 88, 227, 11, 106, 165, diff --git a/api/src/error.rs b/api/src/error.rs index b1be189..472ecc3 100644 --- a/api/src/error.rs +++ b/api/src/error.rs @@ -2,39 +2,35 @@ use crate::steel::*; #[derive(Debug, Error, Clone, Copy, PartialEq, Eq, IntoPrimitive)] #[repr(u32)] -pub enum EphemeralVrfError { +pub enum SolanaVrfError { #[error("Unauthorized authority")] Unauthorized = 0, #[error("Randomness request not found")] RandomnessRequestNotFound = 1, #[error("Invalid proof")] InvalidProof = 2, - #[error("Invalid vrf-macro accounts")] + #[error("Invalid callback accounts")] InvalidCallbackAccounts = 3, - #[error("Queue is full and cannot accept more items")] - QueueFull = 4, #[error("Invalid queue index")] InvalidQueueIndex = 5, - #[error("Invalid account data")] - InvalidAccountData = 6, - #[error("Account is already initialized")] - AccountAlreadyInitialized = 7, #[error("Argument size exceeds the maximum allowed")] ArgumentSizeTooLarge = 8, - #[error("Oracle is not registered")] - OracleNotRegistered = 9, - #[error("Oracle is not authorized")] - OracleNotAuthorized = 10, #[error("Queue is not empty - cannot close queue with pending requests")] QueueNotEmpty = 11, #[error("Invalid oracle identity")] InvalidOracleIdentity = 12, #[error("Oracle must fulfill in a different slot than the request slot")] OracleMustProvideInDifferentSlot = 13, + #[error("Queue has already been used and can no longer be delegated")] + QueueAlreadyInUse = 14, + #[error("Queue is paused and not accepting new requests")] + QueuePaused = 15, + #[error("Queue account size exceeds the maximum allowed")] + QueueSizeTooLarge = 16, } -impl From for ProgramError { - fn from(error: EphemeralVrfError) -> Self { +impl From for ProgramError { + fn from(error: SolanaVrfError) -> Self { ProgramError::Custom(error as u32) } } diff --git a/api/src/instruction.rs b/api/src/instruction.rs index 6954a9c..1d360a2 100644 --- a/api/src/instruction.rs +++ b/api/src/instruction.rs @@ -6,20 +6,19 @@ use solana_curve25519::scalar::PodScalar; #[repr(u8)] #[derive(Clone, Copy, Debug, Eq, PartialEq, TryFromPrimitive)] -pub enum EphemeralVrfInstruction { +pub enum SolanaVrfInstruction { Initialize = 0, ModifyOracle = 1, InitializeOracleQueue = 2, RequestHighPriorityRandomness = 3, ProvideRandomness = 4, DelegateOracleQueue = 5, - UndelegateOracleQueue = 6, - ProcessUndelegation = 196, CloseOracleQueue = 7, RequestRandomness = 8, PurgeExpiredRequests = 9, RequestRandomnessScoped = 10, RequestHighPriorityRandomnessScoped = 11, + SetQueuePaused = 12, } #[repr(C)] @@ -61,13 +60,6 @@ pub struct RequestRandomness { pub callback_args: Vec, } -pub struct PdaSeeds; -impl PdaSeeds { - pub fn parse(data: &[u8]) -> Result>, ProgramError> { - Vec::>::try_from_slice(data).map_err(|_| ProgramError::InvalidInstructionData) - } -} - #[repr(C)] #[derive(Clone, Copy, Debug, Pod, Zeroable)] pub struct ProvideRandomness { @@ -86,47 +78,34 @@ pub struct DelegateOracleQueue { #[repr(C)] #[derive(Clone, Copy, Debug, Pod, Zeroable)] -pub struct UndelegateOracleQueue { +pub struct CloseOracleQueue { pub index: u8, } -#[repr(C)] +#[repr(C, packed)] #[derive(Clone, Copy, Debug, Pod, Zeroable)] -pub struct CloseOracleQueue { +pub struct PurgeExpiredRequests { pub index: u8, } -#[repr(C, packed)] +#[repr(C)] #[derive(Clone, Copy, Debug, Pod, Zeroable)] -pub struct PurgeExpiredRequests { +pub struct SetQueuePaused { pub index: u8, + /// 1 = pause, 0 = unpause; any other value is rejected. + pub paused: u8, } -instruction8!(EphemeralVrfInstruction, Initialize); -instruction8!(EphemeralVrfInstruction, ModifyOracle); -instruction8!(EphemeralVrfInstruction, InitializeOracleQueue); -instruction8!(EphemeralVrfInstruction, ProvideRandomness); -instruction8!(EphemeralVrfInstruction, DelegateOracleQueue); -instruction8!(EphemeralVrfInstruction, UndelegateOracleQueue); -instruction8!(EphemeralVrfInstruction, CloseOracleQueue); -instruction8!(EphemeralVrfInstruction, PurgeExpiredRequests); +instruction8!(SolanaVrfInstruction, Initialize); +instruction8!(SolanaVrfInstruction, ModifyOracle); +instruction8!(SolanaVrfInstruction, InitializeOracleQueue); +instruction8!(SolanaVrfInstruction, ProvideRandomness); +instruction8!(SolanaVrfInstruction, DelegateOracleQueue); +instruction8!(SolanaVrfInstruction, CloseOracleQueue); +instruction8!(SolanaVrfInstruction, PurgeExpiredRequests); +instruction8!(SolanaVrfInstruction, SetQueuePaused); impl RequestRandomness { - pub fn to_bytes(&self) -> Vec { - let mut bytes = vec![ - EphemeralVrfInstruction::RequestHighPriorityRandomness as u8, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ]; - self.serialize(&mut bytes).unwrap(); - bytes - } - pub fn try_from_bytes(mut bytes: &[u8]) -> Result { Self::deserialize(&mut bytes) } diff --git a/api/src/macros.rs b/api/src/macros.rs index ad1a096..8acbca3 100644 --- a/api/src/macros.rs +++ b/api/src/macros.rs @@ -2,7 +2,6 @@ // Align instruction discriminators to 8 bytes: [tag, 0,0,0,0,0,0,0] // Usage: instruction8!(EnumDiscriminator, StructName); // This implements to_bytes() adding the 8-byte header and try_from_bytes() for the struct. -#[allow(unused_macros)] macro_rules! instruction8 { ($discriminator_name:ident, $struct_name:ident) => { impl $struct_name { diff --git a/api/src/pda.rs b/api/src/pda.rs index f80e0dd..bbf7fda 100644 --- a/api/src/pda.rs +++ b/api/src/pda.rs @@ -7,13 +7,13 @@ use solana_system_interface::instruction as system_instruction; #[inline(always)] pub fn create_pda<'a, 'info>( target_account: &'a AccountInfo<'info>, - owner: &Pubkey, space: usize, pda_seeds: &[&[u8]], pda_bump: u8, system_program: &'a AccountInfo<'info>, payer: &'a AccountInfo<'info>, ) -> ProgramResult { + let owner = &crate::ID; // Generate the PDA's signer seeds let pda_bump_slice = &[pda_bump]; let pda_signer_seeds = [pda_seeds, &[pda_bump_slice]].concat(); diff --git a/api/src/sdk.rs b/api/src/sdk.rs index 5abb1b5..5161f4e 100644 --- a/api/src/sdk.rs +++ b/api/src/sdk.rs @@ -1,7 +1,7 @@ use crate::prelude::*; use crate::steel::*; use crate::ID; -use ephemeral_rollups_sdk::consts::{DELEGATION_PROGRAM_ID, MAGIC_CONTEXT_ID, MAGIC_PROGRAM_ID}; +use ephemeral_rollups_sdk::consts::DELEGATION_PROGRAM_ID; use ephemeral_rollups_sdk::pda::{ delegate_buffer_pda_from_delegated_account_and_owner_program, delegation_metadata_pda_from_delegated_account, delegation_record_pda_from_delegated_account, @@ -214,28 +214,33 @@ pub fn delegate_oracle_queue(signer: Pubkey, queue: Pubkey, index: u8) -> Instru } } -pub fn undelegate_oracle_queue(signer: Pubkey, queue: Pubkey, index: u8) -> Instruction { +pub fn close_oracle_queue(identity: Pubkey, index: u8) -> Instruction { Instruction { program_id: crate::ID, accounts: vec![ - AccountMeta::new(signer, true), - AccountMeta::new(queue, false), - AccountMeta::new(MAGIC_CONTEXT_ID, false), - AccountMeta::new_readonly(MAGIC_PROGRAM_ID, false), + AccountMeta::new(identity, true), + AccountMeta::new(oracle_data_pda(&identity).0, false), + AccountMeta::new(oracle_queue_pda(&identity, index).0, false), ], - data: UndelegateOracleQueue { index }.to_bytes(), + data: CloseOracleQueue { index }.to_bytes(), } } -pub fn close_oracle_queue(identity: Pubkey, index: u8) -> Instruction { +/// Pause (or unpause) an oracle queue. A paused queue rejects new randomness +/// requests, letting the oracle drain and then close it. Oracle-gated: the +/// oracle identity must sign. +pub fn set_queue_paused(identity: Pubkey, index: u8, paused: bool) -> Instruction { Instruction { program_id: crate::ID, accounts: vec![ - AccountMeta::new(identity, true), - AccountMeta::new(oracle_data_pda(&identity).0, false), + AccountMeta::new_readonly(identity, true), AccountMeta::new(oracle_queue_pda(&identity, index).0, false), ], - data: CloseOracleQueue { index }.to_bytes(), + data: SetQueuePaused { + index, + paused: paused as u8, + } + .to_bytes(), } } diff --git a/api/src/state/oracle.rs b/api/src/state/oracle.rs index b38efe5..b2f555c 100644 --- a/api/src/state/oracle.rs +++ b/api/src/state/oracle.rs @@ -7,7 +7,7 @@ use solana_curve25519::ristretto::PodRistrettoPoint; pub struct Oracle { pub vrf_pubkey: PodRistrettoPoint, pub registration_slot: u64, - pub open_queue: u64, + pub open_queues: u64, } impl Oracle { diff --git a/api/src/state/queue.rs b/api/src/state/queue.rs index f58cbaf..ae1e2ed 100644 --- a/api/src/state/queue.rs +++ b/api/src/state/queue.rs @@ -1,8 +1,7 @@ -use crate::prelude::{AccountDiscriminator, EphemeralVrfError}; +use crate::prelude::{AccountDiscriminator, SolanaVrfError}; use crate::steel::{AccountMeta, Pod, ProgramError, Pubkey, Zeroable}; use borsh::{BorshDeserialize, BorshSerialize}; use core::mem::{size_of, size_of_val}; -use core::ptr; const MAX_CALLBACK_ACCOUNTS: usize = 25; @@ -18,7 +17,11 @@ pub struct Queue { pub cursor: u32, /// Logical index or shard id of the queue. pub index: u8, - pub _padding: [u8; 3], + /// 1 = paused: the oracle has stopped accepting new requests so the 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], } /// Single queue entry. This is written into the variable region and @@ -46,9 +49,6 @@ impl QueueItem { pub fn callback_discriminator<'a>(&self, acc: &'a [u8]) -> &'a [u8] { let start = self.callback_discriminator_offset as usize; let end = start + self.callback_discriminator_len as usize; - if end > acc.len() { - return &[]; - } &acc[start..end] } @@ -58,21 +58,14 @@ impl QueueItem { let byte_len = count * size_of::(); let end = start + byte_len; - if end > acc.len() || start > end { - return &[]; - } - let bytes = &acc[start..end]; - unsafe { core::slice::from_raw_parts(bytes.as_ptr() as *const CompactAccountMeta, count) } + bytemuck::cast_slice(bytes) } pub fn callback_args<'a>(&self, acc: &'a [u8]) -> &'a [u8] { let start = self.args_offset as usize; let end = start + self.args_len as usize; - if end > acc.len() || start > end { - return &[]; - } &acc[start..end] } } @@ -143,24 +136,37 @@ struct QueueItemLayout { impl<'a> QueueAccount<'a> { #[inline] - fn align_up(x: usize, align: usize) -> usize { + fn align_up(x: usize) -> usize { + let align = core::mem::align_of::(); (x + align - 1) & !(align - 1) } #[inline] fn items_start() -> usize { - Self::align_up(size_of::(), core::mem::align_of::()) + Self::align_up(size_of::()) } - /// Load from an account data slice (without discriminator). - /// Caller is responsible for stripping the 8-byte discriminator if present. + /// Load from full account data, including the 8-byte discriminator. + /// The discriminator is validated and skipped here so call sites pass the + /// account data as-is and cannot mis-slice the offset. Validating it also + /// makes a caller that mistakenly passes already-stripped data (starting at + /// the header) fail loudly instead of silently reading the wrong offset. pub fn load(acc: &'a mut [u8]) -> Result { + if acc.len() < 8 { + return Err(ProgramError::InvalidAccountData); + } + if AccountDiscriminator::Queue.to_bytes() != acc[..8] { + return Err(ProgramError::InvalidAccountData); + } + // Skip the 8-byte account discriminator. + let (_discriminator, body) = acc.split_at_mut(8); + let header_size = size_of::(); - if acc.len() < header_size { + if body.len() < header_size { return Err(ProgramError::InvalidAccountData); } - let (header_bytes, _rest) = acc.split_at_mut(header_size); + let (header_bytes, _rest) = body.split_at_mut(header_size); // Validate alignment and size using a safe checked conversion first if bytemuck::try_from_bytes_mut::(header_bytes).is_err() { return Err(ProgramError::InvalidAccountData); @@ -173,66 +179,79 @@ impl<'a> QueueAccount<'a> { header.cursor = Self::items_start() as u32; } - Ok(Self { header, acc }) + Ok(Self { header, acc: body }) } #[inline] fn read_item_unaligned(bytes: &[u8]) -> QueueItem { - unsafe { ptr::read_unaligned(bytes.as_ptr() as *const QueueItem) } + bytemuck::pod_read_unaligned(bytes) } #[inline] fn write_item_unaligned(dst: &mut [u8], item: &QueueItem) { - let src = unsafe { - core::slice::from_raw_parts( - item as *const QueueItem as *const u8, - size_of::(), - ) - }; - dst.copy_from_slice(src); + dst.copy_from_slice(bytemuck::bytes_of(item)); } #[inline] - fn item_next(cursor: usize, item: &QueueItem, align: usize) -> usize { + fn item_next(cursor: usize, item: &QueueItem) -> usize { let metas_bytes = (item.metas_len as usize) * size_of::(); let item_end = cursor + size_of::() + (item.callback_discriminator_len as usize) + metas_bytes + (item.args_len as usize); - Self::align_up(item_end, align) + Self::align_up(item_end) } - fn scan_for_reusable_span(&self, required_span: usize) -> QueueScan { + /// Walk the queue items once, calling `visit(logical_index, item_pos, next, item)` + /// for each item, where `logical_index` is the count of used items seen so far, + /// `item_pos` is the item's byte offset and `next` is the byte offset of the + /// following item. Returns early with the first `Some(_)` a visit produces. + /// + /// Centralizes the raw byte-offset / unaligned-read / alignment traversal that + /// every queue scan needs, so the delicate logic lives in exactly one place. + fn scan_items( + &self, + mut visit: impl FnMut(usize, usize, usize, &QueueItem) -> Option, + ) -> Option { + let mut used_index = 0usize; let mut cursor = Self::items_start(); let end = core::cmp::min(self.acc.len(), self.header.cursor as usize); - let align = core::mem::align_of::(); let item_size = size_of::(); - let mut last_used_end_aligned = Self::items_start(); - let mut current_index = 0usize; - let mut reusable_span = None; while cursor + item_size <= end { let bytes = &self.acc[cursor..cursor + item_size]; let item = Self::read_item_unaligned(bytes); - let next = Self::item_next(cursor, &item, align); + let next = Self::item_next(cursor, &item); - if next <= cursor { - break; + if let Some(result) = visit(used_index, cursor, next, &item) { + return Some(result); } + if item.used == 1 { + used_index += 1; + } + + cursor = next; + } + None + } + + fn scan_for_reusable_span(&self, required_span: usize) -> QueueScan { + let mut last_used_end_aligned = Self::items_start(); + let mut reusable_span = None; + + self.scan_items(|logical_index, item_pos, next, item| { if item.used == 1 { last_used_end_aligned = next; - current_index += 1; - } else if reusable_span.is_none() && next - cursor == required_span { + } else if reusable_span.is_none() && next - item_pos == required_span { reusable_span = Some(ReusableSpan { - item_pos: cursor, - logical_index: current_index, + item_pos, + logical_index, }); } - - cursor = next; - } + None::<()> + }); QueueScan { last_used_end_aligned, @@ -261,8 +280,7 @@ impl<'a> QueueAccount<'a> { self.acc[disc_off..metas_off].copy_from_slice(discriminator); - let metas_bytes = - unsafe { core::slice::from_raw_parts(metas.as_ptr() as *const u8, metas_bytes_len) }; + let metas_bytes: &[u8] = bytemuck::cast_slice(metas); self.acc[metas_off..args_off].copy_from_slice(metas_bytes); self.acc[args_off..args_end].copy_from_slice(args); @@ -287,7 +305,7 @@ impl<'a> QueueAccount<'a> { args: &[u8], ) -> Result { if metas.len() > MAX_CALLBACK_ACCOUNTS || args.len() > 512 { - return Err(ProgramError::from(EphemeralVrfError::ArgumentSizeTooLarge)); + return Err(ProgramError::from(SolanaVrfError::ArgumentSizeTooLarge)); } let total_needed = size_of::() @@ -297,73 +315,56 @@ impl<'a> QueueAccount<'a> { Ok(QueueItemLayout { total_needed, - required_span: Self::align_up(total_needed, core::mem::align_of::()), + required_span: Self::align_up(total_needed), }) } /// Recompute the end of the last used item and shrink the cursor to it, /// effectively removing all trailing holes. If no items are used, reset to items_start(). fn trim_trailing_holes(&mut self) { - let mut cursor = Self::items_start(); - let end = core::cmp::min(self.acc.len(), self.header.cursor as usize); - let align = core::mem::align_of::(); - - // Default to empty queue start; if we see used items we’ll update this + // Default to empty queue start; if we see used items we’ll update this. let mut last_used_end_aligned = Self::items_start(); - while cursor + size_of::() <= end { - let bytes = &self.acc[cursor..cursor + size_of::()]; - let item = Self::read_item_unaligned(bytes); - - let next = Self::item_next(cursor, &item, align); - + self.scan_items(|_, _, next, item| { if item.used == 1 { last_used_end_aligned = next; } + None::<()> + }); - // Corruption guard - if next <= cursor { - break; - } - cursor = next; - } - - // If nothing was used, this becomes items_start(); otherwise end of last used. - let new_cursor = last_used_end_aligned; - if (new_cursor as u32) < self.header.cursor { - self.header.cursor = new_cursor as u32; + // If nothing was used, this stays items_start(); otherwise end of last used. + if (last_used_end_aligned as u32) < self.header.cursor { + self.header.cursor = last_used_end_aligned as u32; } } - pub fn insertion_position( - &self, + /// Append a new item to the queue with a fixed id. + pub fn add_item( + &mut self, + base_item: &QueueItem, discriminator: &[u8], metas: &[CompactAccountMeta], args: &[u8], - ) -> Result { - let layout = Self::item_layout(discriminator, metas, args)?; - let scan = self.scan_for_reusable_span(layout.required_span); - let cursor = core::cmp::min(self.header.cursor as usize, scan.last_used_end_aligned); - - if let Some(span) = scan.reusable_span { - if span.item_pos < cursor { - return Ok(span.item_pos as u32); - } - } - - Ok(Self::align_up(cursor, core::mem::align_of::()) as u32) + ) -> Result { + let id = base_item.id; + self.add_item_with_id(base_item, discriminator, metas, args, move |_pos| id) } - /// Append a new item to the queue. - pub fn add_item( + /// Append a new item, deriving its id from the byte position it is written to. + /// + /// `id_from_pos(item_pos)` is invoked exactly once, with the final insertion + /// offset, and its result becomes the item's `id`. This lets a caller bind the + /// id to the insertion position in a single scan, instead of computing the + /// position up front (a first scan) and then writing the item (a second scan). + pub fn add_item_with_id( &mut self, base_item: &QueueItem, discriminator: &[u8], metas: &[CompactAccountMeta], args: &[u8], + mut id_from_pos: impl FnMut(u32) -> [u8; 32], ) -> Result { let layout = Self::item_layout(discriminator, metas, args)?; - let items_align = core::mem::align_of::(); let scan = self.scan_for_reusable_span(layout.required_span); if (scan.last_used_end_aligned as u32) < self.header.cursor { @@ -372,20 +373,22 @@ impl<'a> QueueAccount<'a> { if let Some(span) = scan.reusable_span { if span.item_pos < self.header.cursor as usize { - self.write_item_at(span.item_pos, base_item, discriminator, metas, args)?; + let mut item = *base_item; + item.id = id_from_pos(span.item_pos as u32); + self.write_item_at(span.item_pos, &item, discriminator, metas, args)?; self.header.item_count = self.header.item_count.saturating_add(1); return Ok(span.logical_index); } } - // Ensure we have enough room in the account before mutating any state - let aligned = Self::align_up(self.header.cursor as usize, items_align); + // `aligned` is where the item will start (cursor may have been advanced + // already). Ensure we have enough room before mutating any state. + let aligned = Self::align_up(self.header.cursor as usize); if aligned.saturating_add(layout.total_needed) > self.acc.len() { return Err(ProgramError::AccountDataTooSmall); } - // Ensure items area starts at aligned offset; cursor may have been advanced already - let aligned = Self::align_up(self.header.cursor as usize, items_align); + // Ensure items area starts at the aligned offset. if aligned != self.header.cursor as usize { let start = self.header.cursor as usize; let end = aligned; @@ -396,7 +399,9 @@ impl<'a> QueueAccount<'a> { // Reserve space for the item so items are contiguous let item_pos = self.header.cursor as usize; - let end = self.write_item_at(item_pos, base_item, discriminator, metas, args)?; + let mut item = *base_item; + item.id = id_from_pos(item_pos as u32); + let end = self.write_item_at(item_pos, &item, discriminator, metas, args)?; self.header.cursor = end as u32; // Item index is logical position among used items. @@ -407,125 +412,85 @@ impl<'a> QueueAccount<'a> { /// Iterate over all used items. pub fn iter_items(&self) -> impl Iterator + '_ { - let mut cursor = Self::items_start(); - let end = core::cmp::min(self.acc.len(), self.header.cursor as usize); - let align = core::mem::align_of::(); - let mut out = Vec::new(); - - while cursor + size_of::() <= end { - let bytes = &self.acc[cursor..cursor + size_of::()]; - let item = Self::read_item_unaligned(bytes); - + self.scan_items(|_, _, _, item| { if item.used == 1 { - out.push(item); - } - - let next = Self::item_next(cursor, &item, align); - - // Prevent infinite loop in case of corrupted lengths - if next <= cursor { - break; + out.push(*item); } - cursor = next; - } - + None::<()> + }); out.into_iter() } /// Find the nth used item (logical index) and return its value. pub fn get_item_by_index(&self, index: usize) -> Option { - let mut current = 0usize; - - let mut cursor = Self::items_start(); - let end = core::cmp::min(self.acc.len(), self.header.cursor as usize); - let align = core::mem::align_of::(); - - while cursor + size_of::() <= end { - let bytes = &self.acc[cursor..cursor + size_of::()]; - let item = Self::read_item_unaligned(bytes); - - if item.used == 1 { - if current == index { - return Some(item); - } - current += 1; - } - - let next = Self::item_next(cursor, &item, align); - if next <= cursor { - break; - } - cursor = next; - } - - None + self.scan_items(|logical_index, _, _, item| { + (item.used == 1 && logical_index == index).then_some(*item) + }) } /// Remove the nth used item (logical index). pub fn remove_item(&mut self, index: usize) -> Result { - let mut current = 0usize; + let (item_pos, mut item) = self + .scan_items(|logical_index, item_pos, _, item| { + (item.used == 1 && logical_index == index).then_some((item_pos, *item)) + }) + .ok_or::(SolanaVrfError::InvalidQueueIndex.into())?; + + // Logically remove: clear the used flag in place and trim trailing holes. + item.used = 0; + self.header.item_count = self.header.item_count.saturating_sub(1); + let bytes = &mut self.acc[item_pos..item_pos + size_of::()]; + Self::write_item_unaligned(bytes, &item); + self.trim_trailing_holes(); + + Ok(item) + } + /// Remove in a single pass all used items matching `pred`, invoking + /// `on_removed` for each removed item. Trailing holes are trimmed once at + /// the end. Runs in O(n) regardless of how many items are removed, so the + /// purge can never be priced out of the compute budget. + pub fn remove_items_matching(&mut self, mut pred: P, mut on_removed: R) + where + P: FnMut(&QueueItem) -> bool, + R: FnMut(&QueueItem), + { let mut cursor = Self::items_start(); let end = core::cmp::min(self.acc.len(), self.header.cursor as usize); - let align = core::mem::align_of::(); + let mut last_used_end_aligned = Self::items_start(); while cursor + size_of::() <= end { let bytes = &mut self.acc[cursor..cursor + size_of::()]; let mut item = Self::read_item_unaligned(bytes); + let next = Self::item_next(cursor, &item); if item.used == 1 { - if current == index { - // Logically remove + if pred(&item) { + // Logically remove in place; offsets stay valid. item.used = 0; - self.header.item_count = self.header.item_count.saturating_sub(1); - // Write back modified item using unaligned write Self::write_item_unaligned(bytes, &item); - - self.trim_trailing_holes(); - - return Ok(item); + self.header.item_count = self.header.item_count.saturating_sub(1); + on_removed(&item); + } else { + last_used_end_aligned = next; } - current += 1; } - let next = Self::item_next(cursor, &item, align); - if next <= cursor { - break; - } cursor = next; } - Err(EphemeralVrfError::InvalidQueueIndex.into()) + // Trim trailing holes once. + if (last_used_end_aligned as u32) < self.header.cursor { + self.header.cursor = last_used_end_aligned as u32; + } } /// Find first used item by id, returning its logical index and value. pub fn find_item_by_id(&self, id: &[u8; 32]) -> Option<(usize, QueueItem)> { - let mut current = 0usize; - - let mut cursor = Self::items_start(); - let end = core::cmp::min(self.acc.len(), self.header.cursor as usize); - let align = core::mem::align_of::(); - - while cursor + size_of::() <= end { - let bytes = &self.acc[cursor..cursor + size_of::()]; - let item = Self::read_item_unaligned(bytes); - - if item.used == 1 { - if &item.id == id { - return Some((current, item)); - } - current += 1; - } - - let next = Self::item_next(cursor, &item, align); - if next <= cursor { - break; - } - cursor = next; - } - - None + self.scan_items(|logical_index, _, _, item| { + (item.used == 1 && &item.id == id).then_some((logical_index, *item)) + }) } pub fn is_empty(&self) -> bool { @@ -609,9 +574,10 @@ mod tests { let args = [3u8; 48]; let span = QueueAccount::align_up( size_of::() + discriminator.len() + size_of_val(&metas) + args.len(), - core::mem::align_of::(), ); - let mut data = vec![0u8; QueueAccount::items_start() + (span * 4)]; + // 8 leading bytes for the account discriminator, which load() validates and skips. + let mut data = vec![0u8; 8 + QueueAccount::items_start() + (span * 4)]; + data[..8].copy_from_slice(&AccountDiscriminator::Queue.to_bytes()); let mut queue = QueueAccount::load(&mut data).unwrap(); queue @@ -629,29 +595,21 @@ mod tests { let removed_pos = removed.callback_discriminator_offset as usize - size_of::(); assert_eq!(removed.id, [1; 32]); assert_eq!(queue.header.cursor, cursor_after_fill); - assert_eq!( - queue - .insertion_position(&discriminator, &metas, &args) - .unwrap() as usize, - removed_pos - ); + // The next insertion reuses the freed span: the id is derived from the + // exact byte position it is written to, which is the removed item's slot. + let mut reused_pos = None; let reused_index = queue - .add_item(&test_item(9), &discriminator, &metas, &args) + .add_item_with_id(&test_item(9), &discriminator, &metas, &args, |pos| { + reused_pos = Some(pos as usize); + [9; 32] + }) .unwrap(); + assert_eq!(reused_pos, Some(removed_pos)); assert_eq!(reused_index, 1); assert_eq!(queue.header.cursor, cursor_after_fill); assert_eq!(queue.len(), 3); - assert_eq!( - queue - .insertion_position(&discriminator, &metas, &args) - .unwrap(), - QueueAccount::align_up( - cursor_after_fill as usize, - core::mem::align_of::() - ) as u32 - ); let reused = queue.get_item_by_index(1).unwrap(); assert_eq!(reused.id, [9; 32]); @@ -661,6 +619,84 @@ mod tests { let tail = queue.get_item_by_index(2).unwrap(); assert_eq!(tail.id, [2; 32]); + + // With no holes left, the next item appends past the last used item. + let mut appended_pos = None; + let appended_index = queue + .add_item_with_id(&test_item(11), &discriminator, &metas, &args, |pos| { + appended_pos = Some(pos as usize); + [11; 32] + }) + .unwrap(); + assert_eq!(appended_index, 3); + assert_eq!( + appended_pos, + Some(QueueAccount::align_up(cursor_after_fill as usize)) + ); + } + + #[test] + fn bytemuck_casts_match_previous_unsafe_forms() { + let item = QueueItem { + slot: 42, + callback_discriminator_offset: 1, + metas_offset: 2, + args_offset: 3, + callback_discriminator_len: 4, + metas_len: 5, + args_len: 6, + priority_request: 1, + used: 1, + ..test_item(9) + }; + let metas = [ + CompactAccountMeta { + pubkey: [2; 32], + is_writable: 1, + }, + CompactAccountMeta { + pubkey: [3; 32], + is_writable: 0, + }, + ]; + + // Writing an item: bytes_of vs raw parts over the struct + let safe_item_bytes = bytemuck::bytes_of(&item); + let unsafe_item_bytes = unsafe { + core::slice::from_raw_parts( + &item as *const QueueItem as *const u8, + size_of::(), + ) + }; + assert_eq!(safe_item_bytes, unsafe_item_bytes); + + // Reading an item back: pod_read_unaligned vs ptr::read_unaligned (offset by 1 to + // exercise the unaligned path) + let mut buf = vec![0u8; 1 + size_of::()]; + buf[1..].copy_from_slice(safe_item_bytes); + let safe_read: QueueItem = bytemuck::pod_read_unaligned(&buf[1..]); + let unsafe_read = + unsafe { core::ptr::read_unaligned(buf[1..].as_ptr() as *const QueueItem) }; + assert_eq!(safe_read, unsafe_read); + assert_eq!(safe_read, item); + + // Metas as bytes: cast_slice vs raw parts over the slice + let safe_metas_bytes: &[u8] = bytemuck::cast_slice(&metas); + let unsafe_metas_bytes = unsafe { + core::slice::from_raw_parts(metas.as_ptr() as *const u8, size_of_val(&metas)) + }; + assert_eq!(safe_metas_bytes, unsafe_metas_bytes); + + // Bytes as metas: cast_slice vs raw parts back to the typed slice + let safe_typed: &[CompactAccountMeta] = bytemuck::cast_slice(safe_metas_bytes); + let unsafe_typed = unsafe { + core::slice::from_raw_parts( + safe_metas_bytes.as_ptr() as *const CompactAccountMeta, + metas.len(), + ) + }; + assert!(safe_typed == unsafe_typed); + assert!(safe_typed == metas); } #[test] @@ -671,7 +707,9 @@ mod tests { pubkey: [2; 32], is_writable: 1, }; MAX_CALLBACK_ACCOUNTS]; - let mut data = vec![0u8; 2048]; + // 8 leading bytes for the account discriminator, which load() validates and skips. + let mut data = vec![0u8; 8 + 2048]; + data[..8].copy_from_slice(&AccountDiscriminator::Queue.to_bytes()); let mut queue = QueueAccount::load(&mut data).unwrap(); queue @@ -687,4 +725,120 @@ mod tests { .add_item(&test_item(1), &discriminator, &too_many_metas, &args) .is_err()); } + + #[test] + fn remove_items_matching_purges_full_mainnet_size_queue() { + // Worst case on mainnet: 30,000-byte account (minus discriminator) + // filled with minimal 96-byte items = 312 requests. + let mut data = vec![0u8; 30_000]; + data[..8].copy_from_slice(&AccountDiscriminator::Queue.to_bytes()); + let mut queue = QueueAccount::load(&mut data).unwrap(); + + let mut added = 0usize; + while queue.add_item(&test_item(1), &[], &[], &[]).is_ok() { + added += 1; + } + assert_eq!(added, 312); + assert_eq!(queue.len(), 312); + + let mut removed = 0usize; + queue.remove_items_matching(|_| true, |_| removed += 1); + + assert_eq!(removed, 312); + assert_eq!(queue.len(), 0); + assert!(queue.is_empty()); + assert_eq!(queue.header.cursor, QueueAccount::items_start() as u32); + } + + #[test] + fn remove_items_matching_keeps_survivors_and_trims_trailing_holes() { + let discriminator = [1u8; 8]; + let metas = [CompactAccountMeta { + pubkey: [2; 32], + is_writable: 1, + }]; + let args = [3u8; 48]; + let span = QueueAccount::align_up( + size_of::() + discriminator.len() + size_of_val(&metas) + args.len(), + ); + let mut data = vec![0u8; 8 + QueueAccount::items_start() + span * 6]; + data[..8].copy_from_slice(&AccountDiscriminator::Queue.to_bytes()); + let mut queue = QueueAccount::load(&mut data).unwrap(); + + for i in 0..6u8 { + queue + .add_item(&test_item(i), &discriminator, &metas, &args) + .unwrap(); + } + let cursor_after_fill = queue.header.cursor; + + // Remove one middle item (id 1) and the trailing items (ids 4, 5). + let mut removed_ids = Vec::new(); + queue.remove_items_matching( + |item| item.id[0] == 1 || item.id[0] >= 4, + |item| removed_ids.push(item.id[0]), + ); + + assert_eq!(removed_ids, vec![1, 4, 5]); + assert_eq!(queue.len(), 3); + // Cursor trimmed to the end of the last surviving item (id 3). + assert_eq!( + queue.header.cursor as usize, + QueueAccount::items_start() + span * 4 + ); + + // Survivors are intact at their logical indices. + for (logical, id) in [0u8, 2, 3].iter().enumerate() { + let item = queue.get_item_by_index(logical).unwrap(); + assert_eq!(item.id, [*id; 32]); + assert_eq!(item.callback_discriminator(queue.acc), discriminator); + assert!(item.account_metas(queue.acc) == metas); + assert_eq!(item.callback_args(queue.acc), args); + } + + // The middle hole is reusable. + let reused_index = queue + .add_item(&test_item(9), &discriminator, &metas, &args) + .unwrap(); + assert_eq!(reused_index, 1); + assert_eq!(queue.len(), 4); + assert_eq!( + queue.header.cursor as usize, + QueueAccount::items_start() + span * 4 + ); + + // Appending past the survivors extends from the trimmed cursor. + let tail_index = queue + .add_item(&test_item(10), &discriminator, &metas, &args) + .unwrap(); + assert_eq!(tail_index, 4); + assert!(queue.header.cursor <= cursor_after_fill); + } + + #[test] + fn remove_items_matching_noop_preserves_queue() { + let discriminator = [1u8; 8]; + let metas = [CompactAccountMeta { + pubkey: [2; 32], + is_writable: 1, + }]; + let args = [3u8; 48]; + let mut data = vec![0u8; 8 + 2048]; + data[..8].copy_from_slice(&AccountDiscriminator::Queue.to_bytes()); + let mut queue = QueueAccount::load(&mut data).unwrap(); + + for i in 0..3u8 { + queue + .add_item(&test_item(i), &discriminator, &metas, &args) + .unwrap(); + } + let cursor_before = queue.header.cursor; + + let mut removed = 0usize; + queue.remove_items_matching(|_| false, |_| removed += 1); + + assert_eq!(removed, 0); + assert_eq!(queue.len(), 3); + assert_eq!(queue.header.cursor, cursor_before); + } } diff --git a/api/src/steel.rs b/api/src/steel.rs index 4639436..43e8434 100644 --- a/api/src/steel.rs +++ b/api/src/steel.rs @@ -176,11 +176,6 @@ pub fn close_account<'info>( account_info.resize(0) } -#[inline(always)] -pub fn log(msg: String) { - solana_program::msg!("{}", msg); -} - #[inline(always)] pub fn create_program_account<'a, 'info, T: Discriminator + Pod>( target_account: &'a AccountInfo<'info>, diff --git a/program/Cargo.toml b/program/Cargo.toml index 82a7b3a..5655fe4 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "ephemeral-vrf-program" +name = "solana-vrf-program" description = "" version.workspace = true edition.workspace = true @@ -16,11 +16,11 @@ crate-type = ["cdylib", "lib"] [features] default = [] no-entrypoint = [] -test-sbf = ["ephemeral-vrf-api/unit_test_config"] -ephemeral-test-queue = ["ephemeral-vrf-api/ephemeral-test-queue"] +test-sbf = ["solana-vrf-api/unit_test_config"] +ephemeral-test-queue = ["solana-vrf-api/ephemeral-test-queue"] [dependencies] -ephemeral-vrf-api = { workspace = true, features = [] } +solana-vrf-api = { workspace = true, features = [] } ephemeral-rollups-sdk.workspace = true solana-curve25519.workspace = true curve25519-dalek.workspace = true @@ -28,7 +28,7 @@ solana-program.workspace = true solana-system-interface.workspace = true [dev-dependencies] -ephemeral-vrf = { workspace = true} +solana-vrf = { workspace = true} solana-program-test = { workspace = true } solana-sdk = { workspace = true } solana-compute-budget-interface = { workspace = true } diff --git a/program/src/close_oracle_queue.rs b/program/src/close_oracle_queue.rs index 952483d..619d0c3 100644 --- a/program/src/close_oracle_queue.rs +++ b/program/src/close_oracle_queue.rs @@ -1,4 +1,4 @@ -use ephemeral_vrf_api::prelude::*; +use solana_vrf_api::prelude::*; /// Process the closing of an Oracle queue account /// @@ -16,7 +16,7 @@ use ephemeral_vrf_api::prelude::*; /// - The Oracle (account 0) must be a signer. /// - The Oracle data account (account 1) must be a valid PDA with seeds [ORACLE_DATA, oracle.key]. /// - The Oracle queue (account 2) must be a valid PDA with seeds [QUEUE, oracle.key, index]. -/// - The queue account must be owned by the ephemeral VRF program. +/// - The queue account must be owned by the SolanaVrf program. /// - The queue must be empty (no unprocessed requests). /// /// Process: @@ -25,7 +25,7 @@ use ephemeral_vrf_api::prelude::*; /// 2. Verify the Oracle account is a signer. /// 3. Validate the Oracle data and queue account PDA seeds with the provided index. /// 4. Ensure the queue is empty. -/// 5. Decrement Oracle.open_queue and close the queue account, transferring lamports to the Oracle. +/// 5. Decrement Oracle.open_queues and close the queue account, transferring lamports to the Oracle. pub fn process_close_oracle_queue(accounts: &[AccountInfo], data: &[u8]) -> ProgramResult { // Parse args. let args = CloseOracleQueue::try_from_bytes(data)?; @@ -40,19 +40,19 @@ pub fn process_close_oracle_queue(accounts: &[AccountInfo], data: &[u8]) -> Prog // Validate Oracle data PDA oracle_data_info .is_writable()? - .has_owner(&ephemeral_vrf_api::ID)? + .has_owner(&solana_vrf_api::ID)? .has_seeds( &[ORACLE_DATA, oracle_info.key.to_bytes().as_ref()], - &ephemeral_vrf_api::ID, + &solana_vrf_api::ID, )?; // Validate queue PDA oracle_queue_info .is_writable()? - .has_owner(&ephemeral_vrf_api::ID)? + .has_owner(&solana_vrf_api::ID)? .has_seeds( &[QUEUE, oracle_info.key.to_bytes().as_ref(), &[args.index]], - &ephemeral_vrf_api::ID, + &solana_vrf_api::ID, )?; // Ensure the queue has no pending items before closing. @@ -60,16 +60,15 @@ pub fn process_close_oracle_queue(accounts: &[AccountInfo], data: &[u8]) -> Prog // Borrow data and load QueueAccount view to check emptiness let mut data = oracle_queue_info.try_borrow_mut_data()?; Queue::try_from_bytes(&data)?; - let queue_data = &mut data[8..]; - let queue_acc = QueueAccount::load(queue_data)?; + let queue_acc = QueueAccount::load(&mut data)?; if !queue_acc.is_empty() { - return Err(EphemeralVrfError::QueueNotEmpty.into()); + return Err(SolanaVrfError::QueueNotEmpty.into()); } } // Decrement oracle's open queue count - let mut oracle_data_mut = oracle_data_info.as_account_mut::(&ephemeral_vrf_api::ID)?; - oracle_data_mut.open_queue = oracle_data_mut.open_queue.saturating_sub(1); + let mut oracle_data_mut = oracle_data_info.as_account_mut::(&solana_vrf_api::ID)?; + oracle_data_mut.open_queues = oracle_data_mut.open_queues.saturating_sub(1); close_account(oracle_queue_info, oracle_info)?; diff --git a/program/src/delegate_oracle_queue.rs b/program/src/delegate_oracle_queue.rs index 2c3b7a0..0043fa1 100644 --- a/program/src/delegate_oracle_queue.rs +++ b/program/src/delegate_oracle_queue.rs @@ -1,11 +1,11 @@ use ephemeral_rollups_sdk::cpi::{ delegate_account_with_any_validator, DelegateAccounts, DelegateConfig, }; -use ephemeral_vrf_api::prelude::*; +use solana_vrf_api::prelude::*; /// Process the delegation of an Oracle queue to the delegation program /// -/// This instruction allows an authority to vrf-macro an Oracle queue to the delegation program, +/// This instruction allows an authority to delegate an Oracle queue to the delegation program, /// enabling other programs to interact with the queue through the delegation mechanism. /// /// Accounts: @@ -16,22 +16,22 @@ use ephemeral_vrf_api::prelude::*; /// 3. `[writable]` The delegation record account /// 4. `[writable]` The delegation metadata account /// 5. `[]` The delegation program -/// 6. `[]` The owner program (must be the ephemeral VRF program) +/// 6. `[]` The owner program (must be the SolanaVrf program) /// 7. `[]` The system program /// /// Requirements: /// /// - The authority (account 0) must be a signer. /// - The Oracle queue (account 1) must be a valid PDA with seeds [QUEUE, authority.key, index]. -/// - The owner program (account 2) must be the ephemeral VRF program. -/// - The delegation accounts (3-5) must have the correct PDAs for the delegation program. +/// - The owner program (account 6) must be the SolanaVrf program. +/// - The delegation accounts (2-4) must have the correct PDAs for the delegation program. /// /// 1. Parse the instruction data and extract arguments (DelegateOracleQueue). -/// 2. Verify the owner program is the ephemeral VRF program. +/// 2. Verify the owner program is the SolanaVrf program. /// 3. Set up the delegation accounts structure. /// 4. Create the PDA seeds for the Oracle queue. /// 5. Configure the delegation parameters. -/// 6. Call the delegation program to vrf-macro the Oracle queue account. +/// 6. Call the delegation program to delegate the Oracle queue account. pub fn process_delegate_oracle_queue(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult { let args = DelegateOracleQueue::try_from_bytes(data)?; @@ -44,13 +44,25 @@ pub fn process_delegate_oracle_queue(accounts: &[AccountInfo<'_>], data: &[u8]) // Checks authority_info.is_signer()?; - owner_program.has_address(&ephemeral_vrf_api::ID)?; + owner_program.has_address(&solana_vrf_api::ID)?; let pda_seeds: &[&[u8]] = &[QUEUE, &authority_info.key.to_bytes(), &[args.index]]; oracle_queue_info .is_writable()? - .has_owner(&ephemeral_vrf_api::ID)? - .has_seeds(pda_seeds, &ephemeral_vrf_api::ID)?; - Queue::try_from_bytes(&oracle_queue_info.try_borrow_data()?)?; + .has_owner(&solana_vrf_api::ID)? + .has_seeds(pda_seeds, &solana_vrf_api::ID)?; + + // Delegation is a one-time setup step: only allow it for a queue that has + // never been used, so a queue already relied upon by mainnet projects can't + // 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 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()); + } + } // Delegate let delegate_accounts = DelegateAccounts { diff --git a/program/src/fees.rs b/program/src/fees.rs index 417e296..b168739 100644 --- a/program/src/fees.rs +++ b/program/src/fees.rs @@ -1,9 +1,9 @@ -use ephemeral_vrf_api::consts::DEFAULT_EPHEMERAL_QUEUE; -#[cfg(feature = "ephemeral-test-queue")] -use ephemeral_vrf_api::consts::DEFAULT_EPHEMERAL_TEST_QUEUE; use solana_program::account_info::AccountInfo; use solana_program::program_error::ProgramError; 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; /// 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 diff --git a/program/src/initialize.rs b/program/src/initialize.rs index 0e4ac99..c9561fe 100644 --- a/program/src/initialize.rs +++ b/program/src/initialize.rs @@ -1,6 +1,6 @@ -use ephemeral_vrf_api::prelude::*; +use solana_vrf_api::prelude::*; -/// Process the initialization of the EphemeralVrf program +/// Process the initialization of the SolanaVrf program /// /// Accounts: /// @@ -25,14 +25,13 @@ pub fn process_initialize(accounts: &[AccountInfo<'_>], _data: &[u8]) -> Program oracles_info .is_empty()? .is_writable()? - .has_seeds(&[ORACLES], &ephemeral_vrf_api::ID)?; + .has_seeds(&[ORACLES], &solana_vrf_api::ID)?; let oracles = Oracles::default(); let oracles_bytes = oracles.to_bytes_with_discriminator()?; create_pda( oracles_info, - &ephemeral_vrf_api::ID, oracles_bytes.len(), &[ORACLES], oracles_pda().1, diff --git a/program/src/initialize_oracle_queue.rs b/program/src/initialize_oracle_queue.rs index 7d99bda..621767d 100644 --- a/program/src/initialize_oracle_queue.rs +++ b/program/src/initialize_oracle_queue.rs @@ -1,7 +1,6 @@ -use ephemeral_vrf_api::loaders::is_empty_or_zeroed; -use ephemeral_vrf_api::prelude::EphemeralVrfError::Unauthorized; -use ephemeral_vrf_api::prelude::*; use solana_program::msg; +use solana_vrf_api::loaders::is_empty_or_zeroed; +use solana_vrf_api::prelude::*; const MAX_EXTRA_BYTES: usize = 10_240; /// Process the initialization of the Oracle queue @@ -11,28 +10,27 @@ const MAX_EXTRA_BYTES: usize = 10_240; /// This is due to the max allocation size of 10_240 bytes per instruction and the queue possibly /// being larger than 10_240 bytes. /// -/// The queue uses zero-copy serialization and can be as big as the max account size on Solana +/// The queue uses zero-copy serialization and can be as big as +/// `MAX_QUEUE_ACCOUNT_SIZE` (512 KiB). /// /// /// Accounts: /// /// 0; `[signer]` The payer of the transaction fees -/// 1; `[]` The Oracle public key +/// 1; `[signer]` The Oracle public key /// 2; `[]` The Oracle data account -/// 3; `[]` The Oracle queue account (PDA to be created) +/// 3; `[writable]` The Oracle queue account (PDA to be created) /// 4; `[]` The System program /// /// Requirements: /// -/// - The payer (account 0) mus be a signer. +/// - The payer (account 0) must be a signer. /// - The Oracle data account (account 2) must have the correct seeds ([ORACLE_DATA, oracle.key]). /// - The Oracle queue account (account 3) must be empty and use the correct seeds ([QUEUE, oracle.key, index]). -/// - The Oracle must have been registered for at least 200 slots. /// /// 1. Parse the instruction data and extract arguments (InitializeOracleQueue). -/// 2. Confirm the Oracle is authorized (enough time has passed since registration). -/// 3. Create the Oracle queue PDA. -/// 4. Write the default QueueAccount data to the new PDA. +/// 2. Create the Oracle queue PDA. +/// 3. Write the default QueueAccount data to the new PDA. pub fn process_initialize_oracle_queue(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult { // Parse args let args = InitializeOracleQueue::try_from_bytes(data)?; @@ -51,48 +49,35 @@ pub fn process_initialize_oracle_queue(accounts: &[AccountInfo<'_>], data: &[u8] let oracle_key_ref = oracle_key_bytes.as_ref(); // Validate seeds - oracle_data_info.has_seeds(&[ORACLE_DATA, oracle_key_ref], &ephemeral_vrf_api::ID)?; - oracle_queue_info.is_writable()?.has_seeds( - &[QUEUE, oracle_key_ref, &[args.index]], - &ephemeral_vrf_api::ID, - )?; + oracle_data_info.has_seeds(&[ORACLE_DATA, oracle_key_ref], &solana_vrf_api::ID)?; + oracle_queue_info + .is_writable()? + .has_seeds(&[QUEUE, oracle_key_ref, &[args.index]], &solana_vrf_api::ID)?; is_empty_or_zeroed(oracle_queue_info)?; - let oracle_registration_slot = { - let oracle_data = oracle_data_info.as_account::(&ephemeral_vrf_api::ID)?; - oracle_data.registration_slot - }; - - // Check slot timing - let current_slot = Clock::get()?.slot; - - let slots_since_registration = current_slot.saturating_sub(oracle_registration_slot); - - if slots_since_registration < 200 { - log(format!( - "Oracle {} not yet authorized – wait {} more slots", - oracle_info.key, - 200 - slots_since_registration - )); - return Err(Unauthorized.into()); - } - // PDA creation or reallocation let seeds: &[&[u8]] = &[QUEUE, oracle_key_ref, &[args.index]]; - let bump = Pubkey::find_program_address(seeds, &ephemeral_vrf_api::ID).1; + let bump = Pubkey::find_program_address(seeds, &solana_vrf_api::ID).1; let target_size = args.target_size as usize; + // Hard cap: keep every O(n) queue operation (above all the single-pass + // purge) well within the per-transaction compute budget. + if args.target_size > MAX_QUEUE_ACCOUNT_SIZE { + return Err(SolanaVrfError::QueueSizeTooLarge.into()); + } let current_size = oracle_queue_info.data_len(); let extra_bytes = target_size.saturating_sub(current_size); if extra_bytes > MAX_EXTRA_BYTES { + // The account is not yet owned by the program here, so it is still empty + // (current_size == 0) and realloc_size == MAX_EXTRA_BYTES. Use realloc_size + // in both branches so the size increment is defined consistently. let realloc_size = current_size + MAX_EXTRA_BYTES; - if oracle_queue_info.owner != &ephemeral_vrf_api::ID { + if oracle_queue_info.owner != &solana_vrf_api::ID { create_pda( oracle_queue_info, - &ephemeral_vrf_api::ID, - MAX_EXTRA_BYTES, + realloc_size, seeds, bump, system_program, @@ -110,10 +95,9 @@ pub fn process_initialize_oracle_queue(accounts: &[AccountInfo<'_>], data: &[u8] } // Finalize PDA size if needed - if oracle_queue_info.owner != &ephemeral_vrf_api::ID { + if oracle_queue_info.owner != &solana_vrf_api::ID { create_pda( oracle_queue_info, - &ephemeral_vrf_api::ID, target_size, seeds, bump, @@ -129,14 +113,13 @@ pub fn process_initialize_oracle_queue(accounts: &[AccountInfo<'_>], data: &[u8] let mut data = oracle_queue_info.data.borrow_mut(); let disc = AccountDiscriminator::Queue.to_bytes(); data[..8].copy_from_slice(&disc); - let acc_without_disc = &mut data[8..]; - let qacc = QueueAccount::load(acc_without_disc)?; + let qacc = QueueAccount::load(&mut data)?; qacc.header.index = args.index; } // Increment oracle's open queue count - let mut oracle_data_mut = oracle_data_info.as_account_mut::(&ephemeral_vrf_api::ID)?; - oracle_data_mut.open_queue = oracle_data_mut.open_queue.saturating_add(1); + let mut oracle_data_mut = oracle_data_info.as_account_mut::(&solana_vrf_api::ID)?; + oracle_data_mut.open_queues = oracle_data_mut.open_queues.saturating_add(1); Ok(()) } diff --git a/program/src/lib.rs b/program/src/lib.rs index 1c58a28..c22aa1c 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -5,31 +5,31 @@ mod fees; mod initialize; mod initialize_oracle_queue; mod modify_oracles; -mod process_undelegation; mod provide_randomness; mod purge_expired_requests; mod request_randomness; -mod undelegate_oracle_queue; +mod set_queue_paused; use close_oracle_queue::*; use delegate_oracle_queue::*; use initialize::*; use initialize_oracle_queue::*; use modify_oracles::*; -use process_undelegation::*; use provide_randomness::*; use purge_expired_requests::*; use request_randomness::*; -use undelegate_oracle_queue::*; +use set_queue_paused::*; -use ephemeral_vrf_api::prelude::*; +use solana_vrf_api::prelude::*; + +// Program entrypoint, declared up front rather than after the helpers it dispatches to. +solana_program::entrypoint!(process_instruction); fn parse_instruction8<'a, T: std::convert::TryFrom>( - api_id: &'a Pubkey, program_id: &'a Pubkey, data: &'a [u8], ) -> Result<(T, &'a [u8]), ProgramError> { - if program_id.ne(api_id) { + if program_id.ne(&solana_vrf_api::ID) { return Err(ProgramError::IncorrectProgramId); } if data.len() < 8 { @@ -45,39 +45,33 @@ pub fn process_instruction( accounts: &[AccountInfo], data: &[u8], ) -> ProgramResult { - let (ix, data) = parse_instruction8(&ephemeral_vrf_api::ID, program_id, data)?; + let (ix, data) = parse_instruction8(program_id, data)?; match ix { - EphemeralVrfInstruction::Initialize => process_initialize(accounts, data)?, - EphemeralVrfInstruction::ModifyOracle => process_modify_oracles(accounts, data)?, - EphemeralVrfInstruction::InitializeOracleQueue => { + SolanaVrfInstruction::Initialize => process_initialize(accounts, data)?, + SolanaVrfInstruction::ModifyOracle => process_modify_oracles(accounts, data)?, + SolanaVrfInstruction::InitializeOracleQueue => { process_initialize_oracle_queue(accounts, data)? } - EphemeralVrfInstruction::RequestHighPriorityRandomness => { + SolanaVrfInstruction::RequestHighPriorityRandomness => { process_request_randomness(accounts, data, true, false)? } - EphemeralVrfInstruction::RequestRandomness => { + SolanaVrfInstruction::RequestRandomness => { process_request_randomness(accounts, data, false, false)? } - EphemeralVrfInstruction::RequestHighPriorityRandomnessScoped => { + SolanaVrfInstruction::RequestHighPriorityRandomnessScoped => { process_request_randomness(accounts, data, true, true)? } - EphemeralVrfInstruction::RequestRandomnessScoped => { + SolanaVrfInstruction::RequestRandomnessScoped => { process_request_randomness(accounts, data, false, true)? } - EphemeralVrfInstruction::ProvideRandomness => process_provide_randomness(accounts, data)?, - EphemeralVrfInstruction::DelegateOracleQueue => { - process_delegate_oracle_queue(accounts, data)? - } - EphemeralVrfInstruction::UndelegateOracleQueue => { - process_undelegate_oracle_queue(accounts, data)? - } - EphemeralVrfInstruction::ProcessUndelegation => process_undelegation(accounts, data)?, - EphemeralVrfInstruction::CloseOracleQueue => process_close_oracle_queue(accounts, data)?, - EphemeralVrfInstruction::PurgeExpiredRequests => { + SolanaVrfInstruction::ProvideRandomness => process_provide_randomness(accounts, data)?, + SolanaVrfInstruction::DelegateOracleQueue => process_delegate_oracle_queue(accounts, data)?, + SolanaVrfInstruction::CloseOracleQueue => process_close_oracle_queue(accounts, data)?, + SolanaVrfInstruction::PurgeExpiredRequests => { process_purge_expired_requests(accounts, data)? } + SolanaVrfInstruction::SetQueuePaused => process_set_queue_paused(accounts, data)?, } Ok(()) } -solana_program::entrypoint!(process_instruction); diff --git a/program/src/modify_oracles.rs b/program/src/modify_oracles.rs index fc9c233..1d519cc 100644 --- a/program/src/modify_oracles.rs +++ b/program/src/modify_oracles.rs @@ -1,11 +1,9 @@ -use ephemeral_vrf_api::loaders::load_program_upgrade_authority; -use ephemeral_vrf_api::prelude::EphemeralVrfError::{ - InvalidOracleIdentity, QueueNotEmpty, Unauthorized, -}; -use ephemeral_vrf_api::prelude::*; -use ephemeral_vrf_api::verify::is_on_curve; use solana_curve25519::ristretto::validate_ristretto; use solana_program::msg; +use solana_vrf_api::loaders::load_program_upgrade_authority; +use solana_vrf_api::prelude::SolanaVrfError::{InvalidOracleIdentity, QueueNotEmpty, Unauthorized}; +use solana_vrf_api::prelude::*; +use solana_vrf_api::verify::is_on_curve; /// Process the modification of oracles (add or remove) /// @@ -14,8 +12,8 @@ use solana_program::msg; /// 0. `[signer]` signer - Must be the admin /// 1. `[writable]` oracles_info - PDA that stores the list of oracle identities /// 2. `[writable]` oracle_data_info - PDA that stores the oracle data -/// 2. `[]` program data account - Used to read the program's upgrade authority -/// 3. `[]` system_program - System program for account creation/closing +/// 3. `[]` program data account - Used to read the program's upgrade authority +/// 4. `[]` system_program - System program for account creation/closing /// /// Requirements: /// @@ -58,29 +56,31 @@ pub fn process_modify_oracles(accounts: &[AccountInfo<'_>], data: &[u8]) -> Prog // Check that the signer is the admin. // The admin is the program upgrade authority, which should be a multi-sig. - let admin_pubkey = load_program_upgrade_authority(&ephemeral_vrf_api::ID, program_data_info)? + let admin_pubkey = load_program_upgrade_authority(&solana_vrf_api::ID, program_data_info)? .ok_or(Unauthorized)?; if !signer_info.key.eq(&admin_pubkey) { - log(format!( + msg!( "Signer not authorized, expected: {}, got: {}", - admin_pubkey, signer_info.key - )); + admin_pubkey, + signer_info.key + ); return Err(Unauthorized.into()); } oracles_info .is_writable()? - .has_seeds(&[ORACLES], &ephemeral_vrf_api::ID)?; + .has_seeds(&[ORACLES], &solana_vrf_api::ID)?; oracle_data_info.is_writable()?.has_seeds( &[ORACLE_DATA, args.identity.to_bytes().as_ref()], - &ephemeral_vrf_api::ID, + &solana_vrf_api::ID, )?; - let oracles_data = oracles_info.try_borrow_data()?; - let mut oracles = Oracles::try_from_bytes_with_discriminator(&oracles_data)?; - drop(oracles_data); + let mut oracles = { + let oracles_data = oracles_info.try_borrow_data()?; + Oracles::try_from_bytes_with_discriminator(&oracles_data)? + }; if args.operation == 0 { oracles.oracles.push(args.identity); @@ -88,20 +88,20 @@ pub fn process_modify_oracles(accounts: &[AccountInfo<'_>], data: &[u8]) -> Prog oracle_data_info, system_program, signer_info, - &ephemeral_vrf_api::ID, + &solana_vrf_api::ID, &[ORACLE_DATA, args.identity.to_bytes().as_ref()], )?; - let mut oracle_data = oracle_data_info.as_account_mut::(&ephemeral_vrf_api::ID)?; + let mut oracle_data = oracle_data_info.as_account_mut::(&solana_vrf_api::ID)?; oracle_data.vrf_pubkey = args.oracle_pubkey; oracle_data.registration_slot = Clock::get()?.slot; } else if args.operation == 1 { // Ensure oracle has no open queues before removal - let open_queue = { - let oracle_data = oracle_data_info.as_account::(&ephemeral_vrf_api::ID)?; - oracle_data.open_queue + let open_queues = { + let oracle_data = oracle_data_info.as_account::(&solana_vrf_api::ID)?; + oracle_data.open_queues }; - if open_queue != 0 { - msg!("Oracle has {} open queues", open_queue); + if open_queues != 0 { + msg!("Oracle has {} open queues", open_queues); return Err(QueueNotEmpty.into()); } oracles.oracles.retain(|oracle| oracle.ne(&args.identity)); diff --git a/program/src/process_undelegation.rs b/program/src/process_undelegation.rs deleted file mode 100644 index b1926a5..0000000 --- a/program/src/process_undelegation.rs +++ /dev/null @@ -1,61 +0,0 @@ -use ephemeral_rollups_sdk::cpi::undelegate_account; -use ephemeral_vrf_api::prelude::*; - -/// Process the undelegation of an Oracle queue from the delegation program -/// -/// This instruction is a vrf-macro from the delegation program to complete the undelegation process -/// for an Oracle queue that was previously delegated. It's called by the delegation program as part -/// of the undelegation flow. -/// -/// Accounts: -/// -/// 0. `[signer]` The payer for the transaction -/// 1. `[writable]` The Oracle queue account being undelegated -/// 2. `[]` The delegation buffer account -/// 3. `[]` The system program -/// -/// Requirements: -/// -/// - The payer (account 0) must be a signer. -/// - The Oracle queue (account 1) must have been previously delegated to the delegation program. -/// - The delegation buffer (account 2) must be the correct PDA for this delegation. -/// -/// Process: -/// 1. Load and validate the required accounts. -/// 2. Call the delegation program's undelegate_account function to complete the undelegation process. -pub fn process_undelegation(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult { - let pda_seeds: Vec> = PdaSeeds::parse(data)?; - - // Load accounts. - let [oracle_queue_info, delegation_buffer, payer, system_program] = accounts else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - - if pda_seeds.len() != 3 - || pda_seeds[0].as_slice() != QUEUE - || pda_seeds[1].len() != 32 - || pda_seeds[2].len() != 1 - { - return Err(ProgramError::InvalidSeeds); - } - let authority = pda_seeds[1].as_slice(); - let index = [pda_seeds[2][0]]; - oracle_queue_info - .is_writable()? - .has_seeds(&[QUEUE, authority, &index], &ephemeral_vrf_api::ID)?; - - // Undelegate - undelegate_account( - oracle_queue_info, - &ephemeral_vrf_api::ID, - delegation_buffer, - payer, - system_program, - pda_seeds, - )?; - - let queue_data = oracle_queue_info.try_borrow_data()?; - Queue::try_from_bytes(&queue_data)?; - - Ok(()) -} diff --git a/program/src/provide_randomness.rs b/program/src/provide_randomness.rs index c84a962..6b84651 100644 --- a/program/src/provide_randomness.rs +++ b/program/src/provide_randomness.rs @@ -1,6 +1,6 @@ -use ephemeral_vrf_api::prelude::*; -use ephemeral_vrf_api::verify::verify_vrf; use solana_program::{hash::hash, pubkey}; +use solana_vrf_api::prelude::*; +use solana_vrf_api::verify::verify_vrf; const ALLOWED_EXECUTABLE_CALLBACK_ACCOUNTS: [Pubkey; 8] = [ pubkey!("11111111111111111111111111111111"), @@ -13,35 +13,54 @@ const ALLOWED_EXECUTABLE_CALLBACK_ACCOUNTS: [Pubkey; 8] = [ pubkey!("Magic11111111111111111111111111111111111111"), ]; +/// BPF/native loaders. An account owned by one of these is a program (or its +/// program-data/buffer account), even when the `executable` flag is not set. +const LOADERS: [Pubkey; 5] = [ + pubkey!("NativeLoader1111111111111111111111111111111"), + pubkey!("BPFLoader1111111111111111111111111111111111"), + pubkey!("BPFLoader2111111111111111111111111111111111"), + pubkey!("BPFLoaderUpgradeab1e11111111111111111111111"), + pubkey!("LoaderV411111111111111111111111111111111111"), +]; + +/// Detect a program account. The `executable` flag alone is not reliable +/// (runtime checks on it were removed, legacy programs may leave it false, and +/// future loaders might not set it), so also treat any account owned by a BPF +/// loader as a program. This is a conservative superset (it also matches +/// program-data and buffer accounts), which is fine for a callback filter. +fn is_program(account: &AccountInfo<'_>) -> bool { + account.executable || LOADERS.contains(account.owner) +} + fn has_disallowed_executable_callback_account(accounts: &[AccountInfo<'_>]) -> bool { accounts.iter().any(|account| { - account.executable && !ALLOWED_EXECUTABLE_CALLBACK_ACCOUNTS.contains(account.key) + is_program(account) && !ALLOWED_EXECUTABLE_CALLBACK_ACCOUNTS.contains(account.key) }) } -/// Process the provide randomness instruction which verifies VRF proof and executes vrf-macro +/// Process the provide randomness instruction which verifies VRF proof and executes the callback /// /// Accounts: /// /// 0. `[signer]` signer - The oracle signer providing randomness -/// 1. `[]` program_identity_info - Used to allow the vrf-macro program to verify the identity of the oracle program +/// 1. `[]` program_identity_info - Used to allow the callback program to verify the identity of the oracle program /// 2. `[]` oracle_data_info - Oracle data account associated with the signer /// 3. `[writable]` oracle_queue_info - Queue storing randomness requests /// 4. `[]` callback_program_info - Program to call with the randomness -/// 5. `[varies]` remaining_accounts - Accounts needed for the vrf-macro +/// 5. `[varies]` remaining_accounts - Accounts needed for the callback /// /// Requirements: /// /// - Signer must be a registered oracle with valid VRF keypair /// - VRF proof must be valid for the given input and output /// - Request must exist in the oracle queue -/// - Oracle signer must not be included in vrf-macro accounts +/// - Oracle signer must not be included in callback accounts /// - Callback remaining accounts must not include disallowed executable program accounts /// /// 1. Verify the oracle signer and load oracle data /// 2. Verify the VRF proof /// 3. Remove the request from the queue -/// 4. Invoke the vrf-macro with the randomness +/// 4. Invoke the callback with the randomness pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult { // Parse args let args = ProvideRandomness::try_from_bytes(data)?; @@ -61,11 +80,11 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> // Load oracle data oracle_data_info.has_seeds( &[ORACLE_DATA, oracle_info.key.to_bytes().as_ref()], - &ephemeral_vrf_api::ID, + &solana_vrf_api::ID, )?; let oracle_vrf_pubkey = { - let oracle_data = oracle_data_info.as_account::(&ephemeral_vrf_api::ID)?; + let oracle_data = oracle_data_info.as_account::(&solana_vrf_api::ID)?; oracle_data.vrf_pubkey }; @@ -77,10 +96,10 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> }; oracle_queue_info .is_writable()? - .has_owner(&ephemeral_vrf_api::ID)? + .has_owner(&solana_vrf_api::ID)? .has_seeds( &[QUEUE, oracle_info.key.to_bytes().as_ref(), &[queue_index]], - &ephemeral_vrf_api::ID, + &solana_vrf_api::ID, )?; let output = &args.output; @@ -89,19 +108,15 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> let s = &args.scalar; let removed_item_and_buf = { - let mut data = oracle_queue_info.try_borrow_mut_data()?; - if data.len() < 8 { - return Err(ProgramError::InvalidAccountData); - } - let queue_data = &mut data[8..]; - let mut queue_acc = QueueAccount::load(queue_data)?; + let mut raw_data = oracle_queue_info.try_borrow_mut_data()?; + let mut queue_acc = QueueAccount::load(&mut raw_data)?; let (index, _item) = { let (index, item) = queue_acc .find_item_by_id(&args.input) - .ok_or::(EphemeralVrfError::RandomnessRequestNotFound.into())?; + .ok_or::(SolanaVrfError::RandomnessRequestNotFound.into())?; - // Check that the oracle signer is not in the vrf-macro accounts + // Check that the oracle signer is not in the callback accounts let oracle_in_accounts = { let metas = item.account_metas(queue_acc.acc); metas @@ -109,13 +124,13 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> .any(|acc| Pubkey::new_from_array(acc.pubkey).eq(oracle_info.key)) }; if oracle_in_accounts { - return Err(EphemeralVrfError::InvalidCallbackAccounts.into()); + return Err(SolanaVrfError::InvalidCallbackAccounts.into()); } // Ensure that fulfillment happens in a different (later) slot than the request if Clock::get()?.slot <= item.slot { return Err(ProgramError::from( - EphemeralVrfError::OracleMustProvideInDifferentSlot, + SolanaVrfError::OracleMustProvideInDifferentSlot, )); } @@ -130,7 +145,7 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> (commitment_base_compressed, commitment_hash_compressed, s), ); if !verified { - return Err(EphemeralVrfError::InvalidProof.into()); + return Err(SolanaVrfError::InvalidProof.into()); } // Remove the item from the queue (capture removed item for building callback) @@ -143,7 +158,7 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> let (removed_item, metas_vec, disc_vec, args_vec) = removed_item_and_buf; - // Invoke vrf-macro with randomness + // Invoke the callback with randomness callback_program_info.has_address(&Pubkey::new_from_array(removed_item.callback_program_id))?; let mut accounts_metas = vec![AccountMeta { pubkey: *program_identity_info.key, @@ -167,7 +182,7 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> all_accounts.extend(vec![program_identity_info.clone()]); all_accounts.extend_from_slice(remaining_accounts); - // Invoke the vrf-macro with randomness, signing with the appropriate identity. + // Invoke the callback with randomness, signing with the appropriate identity. let callback_id = Pubkey::new_from_array(removed_item.callback_program_id); match removed_item.identity_mode { 1 => { @@ -176,7 +191,7 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> let bump = removed_item.identity_bump; let scoped = Pubkey::create_program_address( &[IDENTITY, callback_id.as_ref(), &[bump]], - &ephemeral_vrf_api::ID, + &solana_vrf_api::ID, ) .map_err(|_| ProgramError::InvalidSeeds)?; // Require the scoped identity for a scoped request (no fallback to the global one). @@ -187,7 +202,7 @@ pub fn process_provide_randomness(accounts: &[AccountInfo<'_>], data: &[u8]) -> _ => { // Legacy global identity (deprecated): keep the executable allow-list check. if has_disallowed_executable_callback_account(remaining_accounts) { - return Err(EphemeralVrfError::InvalidCallbackAccounts.into()); + return Err(SolanaVrfError::InvalidCallbackAccounts.into()); } let id = program_identity_pda(); program_identity_info.has_address(&id.0)?; diff --git a/program/src/purge_expired_requests.rs b/program/src/purge_expired_requests.rs index ea2e1ee..fb16c0a 100644 --- a/program/src/purge_expired_requests.rs +++ b/program/src/purge_expired_requests.rs @@ -1,8 +1,9 @@ -use ephemeral_vrf_api::prelude::*; +use solana_program::epoch_schedule::EpochSchedule; use solana_program::msg; +use solana_vrf_api::prelude::*; -/// Remove all requests in the queue whose age (current_slot - item.slot) -/// exceeds the TTL. +/// Remove all requests in the queue whose wall-clock age exceeds +/// `QUEUE_TTL_SECONDS`. /// /// Accounts: /// 0. `[]` oracle_info – The oracle public key used in the queue PDA seeds @@ -22,51 +23,68 @@ pub fn process_purge_expired_requests(accounts: &[AccountInfo<'_>], data: &[u8]) // Validate queue PDA seeds and ownership / writability oracle_queue_info .is_writable()? - .has_owner(&ephemeral_vrf_api::ID)? + .has_owner(&solana_vrf_api::ID)? .has_seeds( &[QUEUE, oracle_info.key.to_bytes().as_ref(), &[args.index]], - &ephemeral_vrf_api::ID, + &solana_vrf_api::ID, )?; - let current_slot = Clock::get()?.slot; + // Measure request age in wall-clock seconds rather than a fixed slot count, + // using the current epoch's average slot duration, so expiry stays correct + // if the slot duration changes. + let clock = Clock::get()?; + let epoch_start_slot = EpochSchedule::get()?.get_first_slot_in_epoch(clock.epoch); // Borrow queue data and scan/remove expired items using QueueAccount view let mut acc_data = oracle_queue_info.try_borrow_mut_data()?; Queue::try_from_bytes(&acc_data)?; - let queue_data = &mut acc_data[8..]; - let mut queue_acc = QueueAccount::load(queue_data)?; + let mut queue_acc = QueueAccount::load(&mut acc_data)?; - // Scan and remove expired items by logical index + // Age is measured in wall-clock seconds, derived from the epoch's average + // slot duration. The epoch-relative rate (elapsed_secs / elapsed_slots) is + // loop-invariant, so compute it once here rather than per item — that keeps + // a single-pass purge of a full queue well within the compute budget. + // + // All values are bounded by a single epoch (elapsed_slots <= slots-per-epoch, + // elapsed_secs <= the epoch's wall-clock length), so native u64 arithmetic + // cannot overflow — much cheaper on SBF than emulated 128-bit math. The + // saturating_mul below is purely defensive. Clamps keep values >= 0/1. + let current_slot = clock.slot; + let elapsed_slots = current_slot.saturating_sub(epoch_start_slot).max(1); + let elapsed_secs = clock + .unix_timestamp + .saturating_sub(clock.epoch_start_timestamp) + .max(0) as u64; + + // Scan and remove expired items in a single O(n) pass, so purging stays + // within the compute budget even when the queue is completely full. let mut total_cost: u64 = 0; - let mut i: usize = 0; + let mut removed: usize = 0; msg!("Items in the queue: {}", queue_acc.len()); - while i < queue_acc.len() { - // Safe to unwrap: index < len() - let item = queue_acc - .get_item_by_index(i) - .ok_or(ProgramError::InvalidAccountData)?; - let age = current_slot.saturating_sub(item.slot); - if age > QUEUE_TTL_SLOTS { + queue_acc.remove_items_matching( + |item| { + // Only age accrued within the current epoch is counted (slot_age is + // capped at elapsed_slots), so the estimate can never exceed the real + // time elapsed since the epoch start — a request is never expired + // before its TTL, even for requests that span an epoch boundary. + let slot_age = current_slot.saturating_sub(item.slot).min(elapsed_slots); + let age_secs = slot_age.saturating_mul(elapsed_secs) / elapsed_slots; + age_secs > QUEUE_TTL_SECONDS + }, + |item| { let cost = if item.priority_request == 1 { VRF_HIGH_PRIORITY_LAMPORTS_COST } else { VRF_LAMPORTS_COST }; total_cost = total_cost.saturating_add(cost); - let _ = queue_acc.remove_item(i)?; - msg!( - "Removing item {} from queue, new size {}", - i, - queue_acc.len() - ); - // do not increment i; next item shifts into this index - } else { - i += 1; - } - } + removed += 1; + }, + ); + msg!("Removed {} expired items from the queue", removed); // Send the fees to the oracle. - // The oracle also accrue fees on malformed/expired requests to + // 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) { diff --git a/program/src/request_randomness.rs b/program/src/request_randomness.rs index b386234..d1c5344 100644 --- a/program/src/request_randomness.rs +++ b/program/src/request_randomness.rs @@ -1,9 +1,9 @@ -use ephemeral_vrf_api::prelude::*; use solana_program::hash::hashv; use solana_program::msg; use solana_program::program::invoke; use solana_program::sysvar::slot_hashes; use solana_system_interface::instruction as system_instruction; +use solana_vrf_api::prelude::*; /// Process a request for randomness /// @@ -11,21 +11,23 @@ use solana_system_interface::instruction as system_instruction; /// /// 0. `[signer]` signer - The account requesting randomness and paying for the transaction /// 1. `[signer]` program_identity_info - The identity PDA of the calling program -/// 2. `[]` oracle_queue_info - The oracle queue account that will store the randomness request +/// 2. `[writable]` oracle_queue_info - The oracle queue account that will store the randomness request /// 3. `[]` system_program_info - The system program /// 4. `[]` slothashes_account_info - The SlotHashes sysvar account /// /// Requirements: /// /// - The signer must be a valid signer -/// - The program identity must be a valid signer and derived from the vrf-macro program ID +/// - The program identity must be a valid signer and derived from the callback program ID /// - The oracle queue must be properly initialized /// - The request is stored in the oracle queue with a combined hash derived from: /// - caller_seed /// - current slot /// - slot hash -/// - vrf-macro discriminator -/// - vrf-macro program ID +/// - callback discriminator +/// - callback program ID +/// - Unix timestamp +/// - queue insertion position (byte offset after the 8-byte account discriminator) /// /// 1. Verify the signer /// 2. Verify the program identity @@ -68,7 +70,7 @@ pub fn process_request_randomness( oracle_queue_info .is_writable()? - .has_owner(&ephemeral_vrf_api::ID)?; + .has_owner(&solana_vrf_api::ID)?; // Load slot and slothash slothashes_account_info.is_sysvar(&slot_hashes::id())?; @@ -82,13 +84,16 @@ pub fn process_request_randomness( // Borrow queue account data and load QueueAccount view let mut data = oracle_queue_info.try_borrow_mut_data()?; Queue::try_from_bytes(&data)?; - // Skip 8-byte discriminator - let queue_data = &mut data[8..]; - let mut queue_acc = QueueAccount::load(queue_data)?; + let mut queue_acc = QueueAccount::load(&mut data)?; + + // Reject new requests on a paused queue so the oracle can drain and close it. + if queue_acc.header.paused != 0 { + return Err(ProgramError::from(SolanaVrfError::QueuePaused)); + } // Optionally validate discriminator length to 8 bytes max (borsh Vec allows larger, but callbacks typically use 8) if args.callback_discriminator.len() > 8 { - return Err(ProgramError::from(EphemeralVrfError::ArgumentSizeTooLarge)); + return Err(ProgramError::from(SolanaVrfError::ArgumentSizeTooLarge)); } let metas = args @@ -97,29 +102,13 @@ pub fn process_request_randomness( .map(|ca| (*ca).into()) .collect::>(); - // Compute a combined hash that includes the actual queue insertion position. - let idx = queue_acc.insertion_position( - &args.callback_discriminator, - &metas, - &args.callback_args, - )?; - let combined_hash = hashv(&[ - &args.caller_seed, - &slot.to_le_bytes(), - &slothash, - &args.callback_discriminator, - &args.callback_program_id.to_bytes(), - &time.to_le_bytes(), - &idx.to_le_bytes(), - ]); - - // Log to simplify gathering all the information needed to recreate the combined_hash. - msg!("Idx: {}", idx); - - // Build the base item; variable-length parts are appended by add_item() + // Build the base item; its id is a combined hash that includes the actual + // queue insertion position. That position is only known once the item is + // placed, so the id is derived inside add_item_with_id during the single + // insertion scan, rather than pre-computed here with a separate scan. let base_item = QueueItem { slot, - id: combined_hash.to_bytes(), + id: [0u8; 32], callback_program_id: args.callback_program_id.to_bytes(), callback_discriminator_offset: 0, metas_offset: 0, @@ -128,18 +117,33 @@ pub fn process_request_randomness( metas_len: 0, args_len: 0, priority_request: high_priority as u8, - used: 0, + used: 1, identity_mode: scoped as u8, identity_bump, _padding: [0u8; 2], }; - // Append the item to the queue (writes discriminator, metas, args into the variable region) - let _logical_index = queue_acc.add_item( + // Append the item to the queue (writes discriminator, metas, args into the + // variable region) and bind its id to the chosen insertion position. + queue_acc.add_item_with_id( &base_item, &args.callback_discriminator, &metas, &args.callback_args, + |idx| { + // Log the position so the combined hash can be recreated off-chain. + msg!("Idx: {}", idx); + hashv(&[ + &args.caller_seed, + &slot.to_le_bytes(), + &slothash, + &args.callback_discriminator, + &args.callback_program_id.to_bytes(), + &time.to_le_bytes(), + &idx.to_le_bytes(), + ]) + .to_bytes() + }, )?; } diff --git a/program/src/set_queue_paused.rs b/program/src/set_queue_paused.rs new file mode 100644 index 0000000..93b54a7 --- /dev/null +++ b/program/src/set_queue_paused.rs @@ -0,0 +1,46 @@ +use solana_vrf_api::prelude::*; + +/// Pause or unpause an Oracle queue. +/// +/// Pausing lets the owning oracle stop accepting new requests so the queue can +/// be drained and then closed; without it, anyone could keep adding requests +/// and prevent the queue from ever being closed. +/// +/// Accounts: +/// +/// 0. `[signer]` The Oracle account that owns the queue +/// 1. `[writable]` The Oracle queue account +/// +/// Requirements: +/// +/// - The Oracle (account 0) must be a signer. +/// - The queue (account 1) must be a valid PDA with seeds [QUEUE, oracle.key, index], +/// owned by the SolanaVrf program. +/// - `paused` must be 0 or 1; any other value is rejected. +pub fn process_set_queue_paused(accounts: &[AccountInfo], data: &[u8]) -> ProgramResult { + let args = SetQueuePaused::try_from_bytes(data)?; + if args.paused > 1 { + return Err(ProgramError::InvalidInstructionData); + } + + let [oracle_info, oracle_queue_info] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + oracle_info.is_signer()?; + + oracle_queue_info + .is_writable()? + .has_owner(&solana_vrf_api::ID)? + .has_seeds( + &[QUEUE, oracle_info.key.to_bytes().as_ref(), &[args.index]], + &solana_vrf_api::ID, + )?; + + let mut data = oracle_queue_info.try_borrow_mut_data()?; + Queue::try_from_bytes(&data)?; + let queue_acc = QueueAccount::load(&mut data)?; + queue_acc.header.paused = args.paused; + + Ok(()) +} diff --git a/program/src/undelegate_oracle_queue.rs b/program/src/undelegate_oracle_queue.rs deleted file mode 100644 index 9faea31..0000000 --- a/program/src/undelegate_oracle_queue.rs +++ /dev/null @@ -1,55 +0,0 @@ -use ephemeral_rollups_sdk::ephem::commit_and_undelegate_accounts; -use ephemeral_vrf_api::prelude::*; - -/// Process the undelegation of an Oracle queue from the delegation program -/// -/// This instruction allows an authority to undelegate an Oracle queue that was previously -/// delegated to the delegation program, removing the ability for other programs to interact -/// with the queue through the delegation mechanism. -/// -/// Accounts: -/// -/// 0. `[signer]` The authority that controls the Oracle queue -/// 1. `[writable]` The Oracle queue account to be undelegated -/// 2. `[]` The Magic context account -/// 3. `[]` The Magic program -/// -/// Requirements: -/// -/// - The authority (account 0) must be a signer. -/// - The Oracle queue (account 1) must be a valid PDA with seeds [QUEUE, authority.key, index]. -/// - The Oracle queue must have been previously delegated to the delegation program. -/// -/// Process: -/// 1. Parse the instruction data and extract arguments (UndelegateOracleQueue). -/// 2. Verify the authority is a signer. -/// 3. Verify the Oracle queue account has the correct PDA structure. -/// 4. Call the delegation program to commit and undelegate the Oracle queue account. -pub fn process_undelegate_oracle_queue(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult { - let args = UndelegateOracleQueue::try_from_bytes(data)?; - - // Load accounts. - let [authority_info, oracle_queue_info, magic_context, magic_program] = accounts else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - - // Checks - authority_info.is_signer()?; - magic_context.has_address(&MAGIC_CONTEXT_ID)?; - magic_program.has_address(&MAGIC_PROGRAM_ID)?; - let pda_seeds: &[&[u8]] = &[QUEUE, &authority_info.key.to_bytes(), &[args.index]]; - oracle_queue_info - .is_writable()? - .has_seeds(pda_seeds, &ephemeral_vrf_api::ID)?; - - // Undelegate - commit_and_undelegate_accounts( - authority_info, - vec![oracle_queue_info], - magic_context, - magic_program, - None, - )?; - - Ok(()) -} diff --git a/program/tests/delegate_unused_test.rs b/program/tests/delegate_unused_test.rs new file mode 100644 index 0000000..5ddca87 --- /dev/null +++ b/program/tests/delegate_unused_test.rs @@ -0,0 +1,69 @@ +//! Delegation is a one-time setup step: a queue that has already been used +//! (so it may back mainnet integrations) must not be delegatable. + +use solana_program_test::{processor, ProgramTest}; +use solana_sdk::account::Account; +use solana_sdk::{signature::Keypair, signer::Signer, transaction::Transaction}; +use solana_vrf_api::prelude::*; + +/// A program-owned queue holding one request (variable region no longer zero). +fn used_queue(size: usize) -> Vec { + let mut data = vec![0u8; size]; + data[..8].copy_from_slice(&AccountDiscriminator::Queue.to_bytes()); + { + let mut queue = QueueAccount::load(&mut data).unwrap(); + queue.header.index = 0; + queue + .add_item(&QueueItem::default(), &[], &[], &[]) + .unwrap(); + } + data +} + +#[tokio::test] +async fn delegation_rejected_for_used_queue() { + let mut program_test = ProgramTest::new( + "solana_vrf_program", + solana_vrf_api::ID, + processor!(solana_vrf_program::process_instruction), + ); + + let authority = Keypair::new(); + let queue_addr = oracle_queue_pda(&authority.pubkey(), 0).0; + program_test.add_account( + queue_addr, + Account { + lamports: 1_000_000_000, + data: used_queue(9_500), + owner: solana_vrf_api::ID, + executable: false, + rent_epoch: 0, + }, + ); + + let ctx = program_test.start_with_context().await; + let banks = ctx.banks_client.clone(); + + let ix = delegate_oracle_queue(authority.pubkey(), queue_addr, 0); + let bh = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&ctx.payer.pubkey()), + &[&ctx.payer, &authority], + bh, + ); + + let err = banks + .process_transaction(tx) + .await + .expect_err("delegating a used queue must be rejected"); + assert_eq!( + err.unwrap(), + solana_sdk::transaction::TransactionError::InstructionError( + 0, + solana_program::instruction::InstructionError::Custom( + SolanaVrfError::QueueAlreadyInUse as u32 + ) + ) + ); +} diff --git a/program/tests/fixtures/mod.rs b/program/tests/fixtures/mod.rs index 5821a4b..eb19788 100644 --- a/program/tests/fixtures/mod.rs +++ b/program/tests/fixtures/mod.rs @@ -2,8 +2,8 @@ pub mod accounts; #[allow(unused_imports)] pub(crate) use accounts::*; -use ephemeral_vrf_api::steel::Pubkey; use solana_program::pubkey; +use solana_vrf_api::steel::Pubkey; pub(crate) const TEST_CALLBACK_PROGRAM: Pubkey = pubkey!("Cz9eXYRuhR7fxmhEvYRY5X19qhybJ29wpaervEHHz32s"); diff --git a/program/tests/integration/use-randomness/Anchor.toml b/program/tests/integration/use-randomness/Anchor.toml index c97c1c7..4448813 100644 --- a/program/tests/integration/use-randomness/Anchor.toml +++ b/program/tests/integration/use-randomness/Anchor.toml @@ -31,7 +31,7 @@ upgradeable = false [[test.genesis]] address = "Vrf1RNUjXmQGjmQrQLvJHs9SNkvDJEsRVFPkfSQUwGz" -program = "../../../../target/deploy/ephemeral_vrf_program.so" +program = "../../../../target/deploy/solana_vrf_program.so" upgradeable = false [test.validator] diff --git a/program/tests/pause_queue_test.rs b/program/tests/pause_queue_test.rs new file mode 100644 index 0000000..d4e427f --- /dev/null +++ b/program/tests/pause_queue_test.rs @@ -0,0 +1,106 @@ +//! An oracle can pause its queue to stop accepting new requests (so it can be +//! drained and closed), and unpause it again. Exercises the real instruction +//! against a program-owned queue account. + +use solana_program_test::{processor, ProgramTest}; +use solana_sdk::account::Account; +use solana_sdk::{signature::Keypair, signer::Signer, transaction::Transaction}; +use solana_vrf_api::prelude::*; + +/// Build a valid, empty program-owned queue account for `oracle` at index 0. +fn empty_queue(size: usize) -> Vec { + let mut data = vec![0u8; size]; + data[..8].copy_from_slice(&AccountDiscriminator::Queue.to_bytes()); + { + let queue = QueueAccount::load(&mut data).unwrap(); + queue.header.index = 0; + } + data +} + +async fn paused_flag( + banks: &solana_program_test::BanksClient, + queue: solana_program::pubkey::Pubkey, +) -> u8 { + let acct = banks.get_account(queue).await.unwrap().unwrap(); + Queue::try_from_bytes(&acct.data).unwrap().paused +} + +#[tokio::test] +async fn oracle_can_pause_and_unpause_queue() { + let program_test = ProgramTest::new( + "solana_vrf_program", + solana_vrf_api::ID, + processor!(solana_vrf_program::process_instruction), + ); + + let oracle = Keypair::new(); + let queue_addr = oracle_queue_pda(&oracle.pubkey(), 0).0; + + let mut program_test = program_test; + program_test.add_account( + queue_addr, + Account { + lamports: 1_000_000_000, + data: empty_queue(9_500), + owner: solana_vrf_api::ID, + executable: false, + rent_epoch: 0, + }, + ); + + let ctx = program_test.start_with_context().await; + let banks = ctx.banks_client.clone(); + + // A freshly created queue is active (backward compatible: zeroed = active). + assert_eq!(paused_flag(&banks, queue_addr).await, 0); + + // Oracle pauses its queue. + let bh = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[set_queue_paused(oracle.pubkey(), 0, true)], + Some(&ctx.payer.pubkey()), + &[&ctx.payer, &oracle], + bh, + ); + banks.process_transaction(tx).await.expect("pause failed"); + assert_eq!(paused_flag(&banks, queue_addr).await, 1); + + // Oracle unpauses its queue. + let bh = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[set_queue_paused(oracle.pubkey(), 0, false)], + Some(&ctx.payer.pubkey()), + &[&ctx.payer, &oracle], + bh, + ); + banks.process_transaction(tx).await.expect("unpause failed"); + assert_eq!(paused_flag(&banks, queue_addr).await, 0); + + // A non-oracle signer cannot pause the queue. + let attacker = Keypair::new(); + let mut ix = set_queue_paused(oracle.pubkey(), 0, true); + ix.accounts[0].pubkey = attacker.pubkey(); // real queue, wrong signer + let bh = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&ctx.payer.pubkey()), + &[&ctx.payer, &attacker], + bh, + ); + assert!(banks.process_transaction(tx).await.is_err()); + assert_eq!(paused_flag(&banks, queue_addr).await, 0); + + // A `paused` value other than 0 or 1 is rejected. + let mut ix = set_queue_paused(oracle.pubkey(), 0, true); + *ix.data.last_mut().unwrap() = 2; // the trailing byte is the paused flag + let bh = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&ctx.payer.pubkey()), + &[&ctx.payer, &oracle], + bh, + ); + assert!(banks.process_transaction(tx).await.is_err()); + assert_eq!(paused_flag(&banks, queue_addr).await, 0); +} diff --git a/program/tests/purge_stress_test.rs b/program/tests/purge_stress_test.rs new file mode 100644 index 0000000..763445e --- /dev/null +++ b/program/tests/purge_stress_test.rs @@ -0,0 +1,382 @@ +//! Live end-to-end regression test for the O(n) purge fix (GHSA-88jr-67m8-w8cq). +//! +//! Stages the worst-case queue from the advisory — a 30,000-byte account +//! filled with 312 minimal, expired requests — and executes the real +//! `purge_expired_requests` instruction through the Banks runtime with the +//! mainnet compute cap (1.4M CU). Before the fix this transaction could +//! never land; after the fix it must succeed and fully drain the queue. + +use solana_compute_budget_interface::ComputeBudgetInstruction; +use solana_program_test::{processor, ProgramTest}; +use solana_sdk::account::Account; +use solana_sdk::{signature::Keypair, signer::Signer, transaction::Transaction}; +use solana_vrf_api::prelude::*; + +/// Mainnet queue account size (advisory: 29,976 usable bytes after +/// discriminator + header, i.e. 312 minimal 96-byte requests). +const QUEUE_ACCOUNT_SIZE: usize = 30_000; +/// Mainnet per-transaction compute unit cap. +const MAINNET_CU_LIMIT: u32 = 1_400_000; + +/// Build the exact byte content of a full queue account whose items are all +/// expired (slot 0), using the real `QueueAccount` implementation so the +/// layout is identical to what on-chain requests produce. +fn full_expired_queue(index: u8, account_size: usize) -> (Vec, usize) { + let mut data = vec![0u8; account_size]; + data[..8].copy_from_slice(&AccountDiscriminator::Queue.to_bytes()); + let mut queue = QueueAccount::load(&mut data).unwrap(); + queue.header.index = index; + + let expired_item = QueueItem { + slot: 0, + ..QueueItem::default() + }; + let mut added = 0usize; + while queue.add_item(&expired_item, &[], &[], &[]).is_ok() { + added += 1; + } + (data, added) +} + +fn align_up(x: usize, align: usize) -> usize { + (x + align - 1) & !(align - 1) +} + +#[tokio::test] +async fn purge_full_queue_within_mainnet_compute_budget() { + let mut program_test = ProgramTest::new( + "solana_vrf_program", + solana_vrf_api::ID, + processor!(solana_vrf_program::process_instruction), + ); + + let oracle_keypair = Keypair::new(); + let (queue_address, _) = oracle_queue_pda(&oracle_keypair.pubkey(), 0); + + // Oracle account (receives the reclaimed fees). + program_test.add_account( + oracle_keypair.pubkey(), + Account { + lamports: 1_000_000_000, + data: vec![], + owner: solana_sdk_ids::system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Worst-case queue: completely full of expired minimal requests. + let (queue_data, item_count) = full_expired_queue(0, QUEUE_ACCOUNT_SIZE); + assert_eq!(item_count, 312, "advisory worst case is 312 requests"); + let queue_lamports = 10_000_000_000; // rent + reclaimable fees + program_test.add_account( + queue_address, + Account { + lamports: queue_lamports, + data: queue_data, + owner: solana_vrf_api::ID, + executable: false, + rent_epoch: 0, + }, + ); + + let mut context = program_test.start_with_context().await; + let banks = context.banks_client.clone(); + + // Sanity: queue is full. + let queue_account = banks.get_account(queue_address).await.unwrap().unwrap(); + assert_eq!( + Queue::try_from_bytes(&queue_account.data).unwrap().len(), + 312 + ); + + // Advance past the TTL so every staged request is expired. + let slot = banks + .get_sysvar::() + .await + .unwrap() + .slot; + context.warp_to_slot(slot + 1_000).unwrap(); + let mut clock = context + .banks_client + .get_sysvar::() + .await + .unwrap(); + // Expiry is wall-clock based; push the Clock timestamp well past the TTL. + clock.unix_timestamp = clock.epoch_start_timestamp + 1_000_000; + context.set_sysvar(&clock); + + // Execute the real purge instruction under the mainnet CU cap. + let budget_ix = ComputeBudgetInstruction::set_compute_unit_limit(MAINNET_CU_LIMIT); + let purge_ix = purge_expired_requests(oracle_keypair.pubkey(), 0); + let blockhash = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[budget_ix, purge_ix], + Some(&context.payer.pubkey()), + &[&context.payer], + blockhash, + ); + let outcome = banks + .process_transaction_with_metadata(tx) + .await + .expect("purge transaction failed to execute"); + let metadata = outcome.metadata.expect("missing transaction metadata"); + assert!( + outcome.result.is_ok(), + "purge of a full queue must succeed: {:?}\nlogs: {:?}", + outcome.result, + metadata.log_messages + ); + let cu = metadata.compute_units_consumed; + println!("purge of 312 expired requests consumed {} CU", cu); + assert!( + cu < 200_000, + "purge must keep wide headroom under the 1.4M CU cap, got {cu} CU" + ); + + // Queue fully drained and cursor reset to the start of the items region. + let queue_account = banks.get_account(queue_address).await.unwrap().unwrap(); + let header = Queue::try_from_bytes(&queue_account.data).unwrap(); + assert_eq!(header.len(), 0); + let items_start = align_up( + core::mem::size_of::(), + core::mem::align_of::(), + ); + assert_eq!(header.cursor as usize, items_start); + + // Reclaimed fees paid to the oracle: 312 * VRF_LAMPORTS_COST. + let expected_fees = 312 * VRF_LAMPORTS_COST; + let oracle_account = banks + .get_account(oracle_keypair.pubkey()) + .await + .unwrap() + .unwrap(); + assert_eq!(oracle_account.lamports, 1_000_000_000 + expected_fees); + assert_eq!(queue_account.lamports, queue_lamports - expected_fees); +} + +/// Live requests must survive a purge untouched: expired items become +/// reusable holes, and trailing expired items shrink the cursor. +#[tokio::test] +async fn purge_mixed_queue_keeps_live_requests() { + let mut program_test = ProgramTest::new( + "solana_vrf_program", + solana_vrf_api::ID, + processor!(solana_vrf_program::process_instruction), + ); + + let oracle_keypair = Keypair::new(); + let (queue_address, _) = oracle_queue_pda(&oracle_keypair.pubkey(), 0); + program_test.add_account( + oracle_keypair.pubkey(), + Account { + lamports: 1_000_000_000, + data: vec![], + owner: solana_sdk_ids::system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let mut context = program_test.start_with_context().await; + let banks = context.banks_client.clone(); + let live_slot = banks + .get_sysvar::() + .await + .unwrap() + .slot + + 1_000_000; // far in the future: never expires during the test + + // 312 items: alternate expired/live for the first 200 (middle holes), + // then 112 expired at the tail (trailing holes). + let mut data = vec![0u8; QUEUE_ACCOUNT_SIZE]; + data[..8].copy_from_slice(&AccountDiscriminator::Queue.to_bytes()); + let (expired_count, live_count) = { + let mut queue = QueueAccount::load(&mut data).unwrap(); + let mut expired = 0u64; + let mut live = 0u64; + for i in 0..312usize { + let is_live = i < 200 && i % 2 == 0; + let item = QueueItem { + slot: if is_live { live_slot } else { 0 }, + id: [i as u8; 32], + ..QueueItem::default() + }; + queue.add_item(&item, &[], &[], &[]).unwrap(); + if is_live { + live += 1; + } else { + expired += 1; + } + } + (expired as usize, live as usize) + }; + assert_eq!((expired_count, live_count), (212, 100)); + context.set_account( + &queue_address, + &Account { + lamports: 10_000_000_000, + data, + owner: solana_vrf_api::ID, + executable: false, + rent_epoch: 0, + } + .into(), + ); + + // Advance past the TTL and purge. + let slot = banks + .get_sysvar::() + .await + .unwrap() + .slot; + context.warp_to_slot(slot + 1_000).unwrap(); + let mut clock = context + .banks_client + .get_sysvar::() + .await + .unwrap(); + // Expiry is wall-clock based; push the Clock timestamp well past the TTL. + clock.unix_timestamp = clock.epoch_start_timestamp + 1_000_000; + context.set_sysvar(&clock); + let purge_ix = purge_expired_requests(oracle_keypair.pubkey(), 0); + let blockhash = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[purge_ix], + Some(&context.payer.pubkey()), + &[&context.payer], + blockhash, + ); + banks.process_transaction(tx).await.expect("purge failed"); + + // All and only the live requests survive, in order and intact. + let queue_account = banks.get_account(queue_address).await.unwrap().unwrap(); + let mut data = queue_account.data.clone(); + let mut queue = QueueAccount::load(&mut data).unwrap(); + let survivors: Vec = queue.iter_items().collect(); + assert_eq!(survivors.len(), live_count); + for (n, item) in survivors.iter().enumerate() { + assert_eq!(item.slot, live_slot); + assert_eq!(item.id, [(2 * n) as u8; 32]); // original even-indexed items + } + + // Cursor trimmed to the end of the last live item (original index 198). + let items_start = align_up( + core::mem::size_of::(), + core::mem::align_of::(), + ); + let item_span = align_up( + core::mem::size_of::(), + core::mem::align_of::(), + ); + assert_eq!(queue.header.cursor as usize, items_start + 199 * item_span); + + // Middle holes are reusable: adding a live item fills the first hole. + let new_item = QueueItem { + slot: live_slot, + id: [255; 32], + ..QueueItem::default() + }; + let logical_index = queue.add_item(&new_item, &[], &[], &[]).unwrap(); + assert_eq!(logical_index, 1); // reused the hole left by expired item 1 + assert_eq!(queue.get_item_by_index(1).unwrap().id, [255; 32]); + assert_eq!(queue.len(), live_count + 1); +} + +/// Worst case under the size cap: a max-size (512 KiB) queue completely full +/// of expired minimal requests must still be purgeable in one transaction. +#[tokio::test] +async fn purge_max_size_queue_within_mainnet_compute_budget() { + let mut program_test = ProgramTest::new( + "solana_vrf_program", + solana_vrf_api::ID, + processor!(solana_vrf_program::process_instruction), + ); + + let oracle_keypair = Keypair::new(); + let (queue_address, _) = oracle_queue_pda(&oracle_keypair.pubkey(), 0); + program_test.add_account( + oracle_keypair.pubkey(), + Account { + lamports: 1_000_000_000, + data: vec![], + owner: solana_sdk_ids::system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let (queue_data, item_count) = full_expired_queue(0, MAX_QUEUE_ACCOUNT_SIZE as usize); + println!("max-size queue holds {item_count} minimal requests"); + let queue_lamports = 20_000_000_000_000; // rent + reclaimable fees at this size + program_test.add_account( + queue_address, + Account { + lamports: queue_lamports, + data: queue_data, + owner: solana_vrf_api::ID, + executable: false, + rent_epoch: 0, + }, + ); + + let mut context = program_test.start_with_context().await; + let banks = context.banks_client.clone(); + let slot = banks + .get_sysvar::() + .await + .unwrap() + .slot; + context.warp_to_slot(slot + 1_000).unwrap(); + let mut clock = context + .banks_client + .get_sysvar::() + .await + .unwrap(); + // Expiry is wall-clock based; push the Clock timestamp well past the TTL. + clock.unix_timestamp = clock.epoch_start_timestamp + 1_000_000; + context.set_sysvar(&clock); + + let budget_ix = ComputeBudgetInstruction::set_compute_unit_limit(MAINNET_CU_LIMIT); + let purge_ix = purge_expired_requests(oracle_keypair.pubkey(), 0); + let blockhash = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[budget_ix, purge_ix], + Some(&context.payer.pubkey()), + &[&context.payer], + blockhash, + ); + let outcome = banks + .process_transaction_with_metadata(tx) + .await + .expect("purge transaction failed to execute"); + let metadata = outcome.metadata.expect("missing transaction metadata"); + assert!( + outcome.result.is_ok(), + "purge of a max-size queue must succeed: {:?}", + outcome.result + ); + let cu = metadata.compute_units_consumed; + println!("purge of {item_count} expired requests consumed {cu} CU"); + assert!( + cu < MAINNET_CU_LIMIT as u64, + "purge of a max-size queue must fit the mainnet CU cap, got {cu} CU" + ); + + let queue_account = banks.get_account(queue_address).await.unwrap().unwrap(); + let header = Queue::try_from_bytes(&queue_account.data).unwrap(); + assert_eq!(header.len(), 0); + let items_start = align_up( + core::mem::size_of::(), + core::mem::align_of::(), + ); + assert_eq!(header.cursor as usize, items_start); + + let expected_fees = item_count as u64 * VRF_LAMPORTS_COST; + let oracle_account = banks + .get_account(oracle_keypair.pubkey()) + .await + .unwrap() + .unwrap(); + assert_eq!(oracle_account.lamports, 1_000_000_000 + expected_fees); +} diff --git a/program/tests/purge_ttl_test.rs b/program/tests/purge_ttl_test.rs new file mode 100644 index 0000000..fb0e169 --- /dev/null +++ b/program/tests/purge_ttl_test.rs @@ -0,0 +1,95 @@ +//! Requests expire by wall-clock time, not a fixed slot count. Drives the real +//! purge instruction with a controlled Clock so the elapsed time is exact. + +use solana_program::clock::Clock; +use solana_program_test::{processor, ProgramTest}; +use solana_sdk::account::Account; +use solana_sdk::{signature::Keypair, signer::Signer, transaction::Transaction}; +use solana_vrf_api::prelude::*; + +/// Program-owned queue at index 0 holding items at the given creation slots. +fn queue_with_items(size: usize, slots: &[u64]) -> Vec { + let mut data = vec![0u8; size]; + data[..8].copy_from_slice(&AccountDiscriminator::Queue.to_bytes()); + { + let mut queue = QueueAccount::load(&mut data).unwrap(); + queue.header.index = 0; + for (i, slot) in slots.iter().enumerate() { + let item = QueueItem { + slot: *slot, + id: [i as u8 + 1; 32], + ..QueueItem::default() + }; + queue.add_item(&item, &[], &[], &[]).unwrap(); + } + } + data +} + +#[tokio::test] +async fn purge_expires_by_wall_clock_time() { + let mut program_test = ProgramTest::new( + "solana_vrf_program", + solana_vrf_api::ID, + processor!(solana_vrf_program::process_instruction), + ); + + let oracle = Keypair::new(); + let queue_addr = oracle_queue_pda(&oracle.pubkey(), 0).0; + program_test.add_account( + oracle.pubkey(), + Account { + lamports: 1_000_000_000, + data: vec![], + owner: solana_sdk_ids::system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + // Item 1 created at slot 0 (old), item 2 at slot 900 (fresh). + program_test.add_account( + queue_addr, + Account { + lamports: 10_000_000_000, + data: queue_with_items(9_500, &[0, 900]), + owner: solana_vrf_api::ID, + executable: false, + rent_epoch: 0, + }, + ); + + let ctx = program_test.start_with_context().await; + let banks = ctx.banks_client.clone(); + + // Controlled clock in epoch 0 (which starts at slot 0): 1000 slots and + // 130 s have elapsed since epoch start => ~0.13 s/slot. + // item 1: age = 1000 * 130 / 1000 = 130 s > 120 => purged + // item 2: age = 100 * 130 / 1000 = 13 s < 120 => kept + let mut clock = banks.get_sysvar::().await.unwrap(); + clock.epoch = 0; + clock.slot = 1000; + clock.epoch_start_timestamp = 0; + clock.unix_timestamp = 130; + ctx.set_sysvar(&clock); + + let purge_ix = purge_expired_requests(oracle.pubkey(), 0); + let bh = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[purge_ix], + Some(&ctx.payer.pubkey()), + &[&ctx.payer], + bh, + ); + banks.process_transaction(tx).await.expect("purge failed"); + + // Only the fresh request (id 2) survives. + let acct = banks.get_account(queue_addr).await.unwrap().unwrap(); + let mut data = acct.data.clone(); + let queue = QueueAccount::load(&mut data).unwrap(); + let ids: Vec = queue.iter_items().map(|it| it.id[0]).collect(); + assert_eq!( + ids, + vec![2], + "the old request must be purged, the fresh one kept" + ); +} diff --git a/program/tests/queue_size_limit_test.rs b/program/tests/queue_size_limit_test.rs new file mode 100644 index 0000000..4a2ddf1 --- /dev/null +++ b/program/tests/queue_size_limit_test.rs @@ -0,0 +1,132 @@ +//! Live test for the 512 KiB queue size cap (MAX_QUEUE_ACCOUNT_SIZE). +//! +//! Runs the real `initialize_oracle_queue` instruction: an over-limit +//! `target_size` must be rejected with `QueueSizeTooLarge`, while a valid +//! size must still initialize the queue correctly. + +use solana_curve25519::ristretto::PodRistrettoPoint; +use solana_program_test::{processor, ProgramTest}; +use solana_sdk::account::Account; +use solana_sdk::{signature::Keypair, signer::Signer, transaction::Transaction}; +use solana_vrf_api::prelude::*; + +fn oracle_data_account( + identity: &solana_program::pubkey::Pubkey, +) -> (solana_program::pubkey::Pubkey, Account) { + let oracle = Oracle { + vrf_pubkey: PodRistrettoPoint([0; 32]), + registration_slot: 0, + open_queues: 0, + }; + let mut data = AccountDiscriminator::Oracle.to_bytes().to_vec(); + data.extend_from_slice(oracle.to_bytes()); + ( + oracle_data_pda(identity).0, + Account { + lamports: 1_000_000_000, + data, + owner: solana_vrf_api::ID, + executable: false, + rent_epoch: 0, + }, + ) +} + +#[tokio::test] +async fn queue_size_is_capped_at_max() { + let mut program_test = ProgramTest::new( + "solana_vrf_program", + solana_vrf_api::ID, + processor!(solana_vrf_program::process_instruction), + ); + + let oracle_keypair = Keypair::new(); + program_test.add_account( + oracle_keypair.pubkey(), + Account { + lamports: 1_000_000_000, + data: vec![], + owner: solana_sdk_ids::system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + let (oracle_data_address, oracle_data) = oracle_data_account(&oracle_keypair.pubkey()); + program_test.add_account(oracle_data_address, oracle_data); + + let mut context = program_test.start_with_context().await; + let banks = context.banks_client.clone(); + + // Oracle must have been registered for at least 200 slots. + context.warp_to_slot(250).unwrap(); + + // Over-limit target size must be rejected with QueueSizeTooLarge. + // (The guard runs before any realloc, so a single ix is enough even + // though the SDK would emit one ix per 10 KiB step.) + let ixs = initialize_oracle_queue( + context.payer.pubkey(), + oracle_keypair.pubkey(), + 0, + Some(MAX_QUEUE_ACCOUNT_SIZE + 1), + ); + let blockhash = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ixs[0].clone()], + Some(&context.payer.pubkey()), + &[&context.payer, &oracle_keypair], + blockhash, + ); + let err = banks + .process_transaction(tx) + .await + .expect_err("over-limit queue size must be rejected"); + assert_eq!( + err.unwrap(), + solana_sdk::transaction::TransactionError::InstructionError( + 0, + solana_program::instruction::InstructionError::Custom( + SolanaVrfError::QueueSizeTooLarge as u32 + ) + ) + ); + + // A valid size still initializes the queue correctly. + let target_size = 9_500u32; + let ixs = initialize_oracle_queue( + context.payer.pubkey(), + oracle_keypair.pubkey(), + 1, + Some(target_size), + ); + let blockhash = banks.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ixs[0].clone()], + Some(&context.payer.pubkey()), + &[&context.payer, &oracle_keypair], + blockhash, + ); + banks + .process_transaction(tx) + .await + .expect("valid queue init failed"); + + let (queue_address, _) = oracle_queue_pda(&oracle_keypair.pubkey(), 1); + let queue_account = banks.get_account(queue_address).await.unwrap().unwrap(); + assert_eq!(queue_account.data.len(), target_size as usize); + let header = Queue::try_from_bytes(&queue_account.data).unwrap(); + assert_eq!(header.index, 1); + assert_eq!(header.len(), 0); + + // The oracle's open queue count was incremented. + let oracle_account = banks + .get_account(oracle_data_address) + .await + .unwrap() + .unwrap(); + let expected = Oracle { + vrf_pubkey: PodRistrettoPoint([0; 32]), + registration_slot: 0, + open_queues: 1, + }; + assert_eq!(&oracle_account.data[8..], expected.to_bytes()); +} diff --git a/program/tests/test.rs b/program/tests/test.rs index 1e722f0..b6b97a4 100644 --- a/program/tests/test.rs +++ b/program/tests/test.rs @@ -2,8 +2,6 @@ mod fixtures; use crate::fixtures::{TEST_AUTHORITY, TEST_CALLBACK_PROGRAM, TEST_ORACLE}; use ephemeral_rollups_sdk::consts::DELEGATION_PROGRAM_ID; -use ephemeral_vrf::vrf::{compute_vrf, generate_vrf_keypair, verify_vrf}; -use ephemeral_vrf_api::prelude::*; use solana_compute_budget_interface::ComputeBudgetInstruction; use solana_curve25519::ristretto::PodRistrettoPoint; use solana_curve25519::scalar::PodScalar; @@ -12,12 +10,14 @@ use solana_program::sysvar::slot_hashes; use solana_program_test::{processor, read_file, 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}; +use solana_vrf_api::prelude::*; async fn setup() -> ProgramTestContext { let mut program_test = ProgramTest::new( - "ephemeral_vrf_program", - ephemeral_vrf_api::ID, - processor!(ephemeral_vrf_program::process_instruction), + "solana_vrf_program", + solana_vrf_api::ID, + processor!(solana_vrf_program::process_instruction), ); // Setup the test authority @@ -44,7 +44,7 @@ async fn setup() -> ProgramTestContext { }, ); - // Setup program to test vrf-macro + // Setup program to test the callback let data = read_file("tests/integration/use-randomness/target/deploy/use_randomness.so"); program_test.add_account( TEST_CALLBACK_PROGRAM, @@ -99,7 +99,7 @@ async fn run_test() { let oracles_address = oracles_pda().0; let oracles_account = banks.get_account(oracles_address).await.unwrap().unwrap(); let oracles = Oracles::try_from_bytes_with_discriminator(&oracles_account.data).unwrap(); - assert_eq!(oracles_account.owner, ephemeral_vrf_api::ID); + assert_eq!(oracles_account.owner, solana_vrf_api::ID); assert_eq!(oracles.oracles.len(), 0); // Submit add oracle transaction. @@ -134,7 +134,7 @@ async fn run_test() { .await .unwrap() .unwrap(); - assert_eq!(oracle_data_info.owner, ephemeral_vrf_api::ID); + assert_eq!(oracle_data_info.owner, solana_vrf_api::ID); let oracle_data = Oracle::try_from_bytes(&oracle_data_info.data).unwrap(); assert!(oracle_data.registration_slot > 0); assert_eq!( @@ -142,10 +142,6 @@ async fn run_test() { oracle_vrf_pk.compress().to_bytes() ); - // Advance to current slot + 200 - let current_slot = banks.get_sysvar::().await.unwrap().slot; - context.warp_to_slot(current_slot + 200).unwrap(); - // Submit init oracle queue transaction. let target_size = 50_000u32; let ixs = initialize_oracle_queue( @@ -172,7 +168,7 @@ async fn run_test() { .unwrap() .unwrap(); let oracle_queue = Queue::try_from_bytes(&oracle_queue_account.data).unwrap(); - assert_eq!(oracle_queue_account.owner, ephemeral_vrf_api::ID); + assert_eq!(oracle_queue_account.owner, solana_vrf_api::ID); assert_eq!(oracle_queue_account.data.len(), target_size as usize); assert_eq!(oracle_queue.index, 0); assert_eq!(oracle_queue.item_count, 0); @@ -197,8 +193,8 @@ async fn run_test() { .unwrap() .unwrap(); let mut qdata = oracle_queue_account.data.clone(); - let queue_acc = QueueAccount::load(&mut qdata[8..]).unwrap(); - assert_eq!(oracle_queue_account.owner, ephemeral_vrf_api::ID); + let queue_acc = QueueAccount::load(&mut qdata[..]).unwrap(); + assert_eq!(oracle_queue_account.owner, solana_vrf_api::ID); assert_eq!(queue_acc.len(), 1); // Verify cost of the vrf was collected in the oracle queue account. @@ -223,7 +219,7 @@ async fn run_test() { .unwrap() .unwrap(); let mut qdata2 = oracle_queue_account.data.clone(); - let queue_acc2 = QueueAccount::load(&mut qdata2[8..]).unwrap(); + let queue_acc2 = QueueAccount::load(&mut qdata2[..]).unwrap(); let vrf_input = queue_acc2.get_item_by_index(0).unwrap().id; let (output, (commitment_base_compressed, commitment_hash_compressed, s)) = compute_vrf(oracle_vrf_sk, &vrf_input); @@ -264,8 +260,8 @@ async fn run_test() { .unwrap() .unwrap(); let mut qdata = oracle_queue_account.data.clone(); - let queue_acc = QueueAccount::load(&mut qdata[8..]).unwrap(); - assert_eq!(oracle_queue_account.owner, ephemeral_vrf_api::ID); + let queue_acc = QueueAccount::load(&mut qdata[..]).unwrap(); + assert_eq!(oracle_queue_account.owner, solana_vrf_api::ID); assert_eq!(queue_acc.len(), 0); assert_eq!( oracle_queue_account.lamports, @@ -297,11 +293,14 @@ async fn run_test() { let oracle_queue = Queue::try_from_bytes(&oracle_queue_account.data).unwrap(); assert_eq!(oracle_queue.len(), 1); - // Advance slots beyond TTL to make the request expired + // Expiry is measured in wall-clock time. Warping slots advances the slot + // age, but the warped bank keeps the parent block time, so also push the + // Clock timestamp well past the TTL. let current_slot = banks.get_sysvar::().await.unwrap().slot; - context - .warp_to_slot(current_slot + QUEUE_TTL_SLOTS + 1) - .unwrap(); + context.warp_to_slot(current_slot + 1_000).unwrap(); + let mut clock = banks.get_sysvar::().await.unwrap(); + clock.unix_timestamp = clock.epoch_start_timestamp + QUEUE_TTL_SECONDS as i64 + 1_000; + context.set_sysvar(&clock); // Purge expired requests let purge_ix = purge_expired_requests(oracle_keypair.pubkey(), 0); @@ -384,7 +383,7 @@ async fn run_test() { .unwrap() .unwrap(); let mut qdata = oracle_queue_account.data.clone(); - let queue_acc = QueueAccount::load(&mut qdata[8..]).unwrap(); + let queue_acc = QueueAccount::load(&mut qdata[..]).unwrap(); assert_eq!(queue_acc.len(), num_requests as usize); // Increase the slot @@ -400,7 +399,7 @@ async fn run_test() { .unwrap() .unwrap(); let mut qdata2 = oracle_queue_account.data.clone(); - let queue_acc2 = QueueAccount::load(&mut qdata2[8..]).unwrap(); + let queue_acc2 = QueueAccount::load(&mut qdata2[..]).unwrap(); let vrf_input = queue_acc2.get_item_by_index(0).unwrap().id; // Compute off-chain VRF @@ -437,7 +436,7 @@ async fn run_test() { .unwrap() .unwrap(); let mut qdata = oracle_queue_account.data.clone(); - let queue_acc = QueueAccount::load(&mut qdata[8..]).unwrap(); + let queue_acc = QueueAccount::load(&mut qdata[..]).unwrap(); assert_eq!(queue_acc.len(), 0); // Close oracle queue. @@ -539,7 +538,7 @@ pub fn request_randomness_to_queue( AccountMeta::new(oracle_queue, false), AccountMeta::new_readonly(system_program::ID, false), AccountMeta::new_readonly(slot_hashes::ID, false), - AccountMeta::new_readonly(ephemeral_vrf_api::ID, false), + AccountMeta::new_readonly(solana_vrf_api::ID, false), ]; // Instruction data: discriminator + client_seed diff --git a/src/Cargo.toml b/src/Cargo.toml deleted file mode 100644 index c9f3fb2..0000000 --- a/src/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "ephemeral-vrf-oracle" -version = {workspace = true} -edition = {workspace = true} - -[[bin]] -name = "ephemeral_vrf" -path = "main.rs" - -[dependencies] -ephemeral-vrf = { workspace = true } -solana-sdk.workspace = true \ No newline at end of file diff --git a/vrf-cli/Cargo.toml b/vrf-cli/Cargo.toml index 7638b18..29a75ae 100644 --- a/vrf-cli/Cargo.toml +++ b/vrf-cli/Cargo.toml @@ -2,14 +2,14 @@ name = "vrf-cli" version = {workspace = true} edition = {workspace = true} -description = "CLI tool for interacting with Ephemeral VRF Solana program" +description = "CLI tool for interacting with SolanaVrf program" authors = {workspace = true} [dependencies] anyhow.workspace = true clap.workspace = true -ephemeral-vrf-api.workspace = true -ephemeral-vrf.workspace = true +solana-vrf-api.workspace = true +solana-vrf.workspace = true solana-client.workspace = true solana-commitment-config.workspace = true solana-compute-budget-interface.workspace = true diff --git a/vrf-cli/src/main.rs b/vrf-cli/src/main.rs index 1d3f67e..605faed 100644 --- a/vrf-cli/src/main.rs +++ b/vrf-cli/src/main.rs @@ -1,7 +1,5 @@ use anyhow::Result; use clap::{Parser, Subcommand}; -use ephemeral_vrf::vrf::generate_vrf_keypair; -use ephemeral_vrf_api::prelude::*; use solana_client::rpc_client::RpcClient; use solana_commitment_config::CommitmentConfig; use solana_compute_budget_interface::ComputeBudgetInstruction; @@ -10,10 +8,12 @@ use solana_sdk::{ signature::{Keypair, Signer}, transaction::Transaction, }; +use solana_vrf::vrf::generate_vrf_keypair; +use solana_vrf_api::prelude::*; use std::process::exit; use std::str::FromStr; -/// VRF CLI - A tool to interact with the Ephemeral VRF program +/// VRF CLI - A tool to interact with the SolanaVrf program #[derive(Parser, Debug)] #[command(author, version, about)] struct Args { @@ -74,13 +74,6 @@ enum Commands { queue: String, }, - /// Undelegate an oracle queue - UndelegateOracleQueue { - /// Queue pubkey - #[arg(short, long)] - queue: String, - }, - /// Close an oracle queue CloseOracleQueue { /// Queue pubkey @@ -160,27 +153,6 @@ async fn main() -> Result<()> { queue_struct.index, )] } - Commands::UndelegateOracleQueue { queue } => { - let queue = Pubkey::from_str(queue)?; - let queue_account = match rpc_client.get_account(&queue) { - Ok(acc) => acc, - Err(e) => { - eprintln!("Error: {e}"); - rpc_client.request_airdrop(&queue, 1)?; - exit(1); - } - }; - let queue_struct = Queue::try_from_bytes(queue_account.data.as_slice())?; - println!( - "Undelegating oracle queue: {} with index: {}", - queue, queue_struct.index - ); - vec![undelegate_oracle_queue( - signer.pubkey(), - queue, - queue_struct.index, - )] - } Commands::CloseOracleQueue { queue } => { let queue = Pubkey::from_str(queue)?; let queue_account = rpc_client.get_account(&queue)?; @@ -215,7 +187,7 @@ async fn main() -> Result<()> { queue, queue_struct.item_count, queue_struct.index, - !account.owner.eq(&ephemeral_vrf_api::ID) + !account.owner.eq(&solana_vrf_api::ID) ); } } diff --git a/vrf-oracle/Cargo.toml b/vrf-oracle/Cargo.toml index b88c352..c14f321 100644 --- a/vrf-oracle/Cargo.toml +++ b/vrf-oracle/Cargo.toml @@ -3,13 +3,13 @@ name = "vrf-oracle" version = { workspace = true } edition = { workspace = true } -description = "Ephemeral VRF Oracle" +description = "SolanaVrf Oracle" authors = { workspace = true } [dependencies] clap.workspace = true -ephemeral-vrf-api = { workspace = true} -ephemeral-vrf = { workspace = true} +solana-vrf-api = { workspace = true} +solana-vrf = { workspace = true} curve25519-dalek.workspace = true solana-curve25519.workspace = true solana-sdk.workspace = true diff --git a/vrf-oracle/src/oracle/client.rs b/vrf-oracle/src/oracle/client.rs index 028e7fb..27ef4de 100644 --- a/vrf-oracle/src/oracle/client.rs +++ b/vrf-oracle/src/oracle/client.rs @@ -10,6 +10,7 @@ use solana_client::{ }; use solana_commitment_config::CommitmentConfig; use solana_sdk::{pubkey::Pubkey, signature::Keypair}; +use solana_vrf_api::prelude::QUEUE_TTL_SECONDS; use std::{ collections::HashMap, sync::{ @@ -38,12 +39,12 @@ use crate::oracle::processor::{fetch_and_process_program_accounts, process_oracl use crate::oracle::sources::{LaserstreamSource, WebSocketSource}; use crate::oracle::utils::queue_memcmp_filter; use curve25519_dalek::{RistrettoPoint, Scalar}; -use ephemeral_vrf::vrf::generate_vrf_keypair; -use ephemeral_vrf_api::prelude::AccountDiscriminator; -use ephemeral_vrf_api::{prelude::Queue, ID as PROGRAM_ID}; use log::{error, info, warn}; use solana_client::nonblocking::rpc_client::RpcClient; use solana_sdk::signer::Signer; +use solana_vrf::vrf::generate_vrf_keypair; +use solana_vrf_api::prelude::AccountDiscriminator; +use solana_vrf_api::{prelude::Queue, ID as PROGRAM_ID}; pub type RequestId = [u8; 32]; pub type QueueKey = String; @@ -67,6 +68,8 @@ const NON_ER_EARLY_SEND_BONUS: Duration = Duration::from_millis(400); const NON_ER_MIN_SLOT_DURATION: Duration = Duration::from_millis(200); const MIN_SLOT_SAMPLE: Duration = Duration::from_millis(1); const MIN_SLOT_SAMPLES: u8 = 3; +/// Assumed slot duration before the tracker has measured one. +const NOMINAL_SLOT_DURATION: Duration = Duration::from_millis(500); #[derive(Clone)] pub struct SlotTracker { @@ -107,6 +110,22 @@ impl SlotTracker { *self.sender.borrow() } + /// Number of slots that currently approximates `QUEUE_TTL_SECONDS` of + /// wall-clock time, using the measured slot duration so it tracks changes + /// to Solana's block time. Falls back to a nominal rate until enough + /// samples have been collected, so a single noisy measurement can't shrink + /// the threshold and cause premature purging. + pub fn ttl_slots(&self) -> u64 { + let timing = self.timing.lock().unwrap(); + let slot_duration = if timing.samples >= MIN_SLOT_SAMPLES { + timing.slot_duration.unwrap_or(NOMINAL_SLOT_DURATION) + } else { + NOMINAL_SLOT_DURATION + }; + let slot_ms = slot_duration.as_millis().max(1); + (QUEUE_TTL_SECONDS as u128 * 1000 / slot_ms) as u64 + } + pub fn update(&self, slot: u64) { self.sender.send_if_modified(|current| { if slot > *current { diff --git a/vrf-oracle/src/oracle/processor.rs b/vrf-oracle/src/oracle/processor.rs index 048b6c9..70a7729 100644 --- a/vrf-oracle/src/oracle/processor.rs +++ b/vrf-oracle/src/oracle/processor.rs @@ -1,15 +1,6 @@ use crate::blockhash_cache::BlockhashCache; use crate::oracle::client::OracleClient; use anyhow::Result; -use ephemeral_vrf::vrf::{compute_vrf, verify_vrf}; -use ephemeral_vrf_api::{ - prelude::{ - provide_randomness_with_identity_mode, purge_expired_requests, EphemeralVrfError, Queue, - QueueAccount, QueueItem, QUEUE_TTL_SLOTS, - }, - state::oracle_queue_pda, - ID as PROGRAM_ID, -}; use futures_util::future::join_all; use futures_util::FutureExt; use log::{error, info, trace, warn}; @@ -31,6 +22,15 @@ use solana_sdk::{ signature::Signer, transaction::{Transaction, TransactionError}, }; +use solana_vrf::vrf::{compute_vrf, verify_vrf}; +use solana_vrf_api::{ + prelude::{ + provide_randomness_with_identity_mode, purge_expired_requests, Queue, QueueAccount, + QueueItem, SolanaVrfError, + }, + state::oracle_queue_pda, + ID as PROGRAM_ID, +}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; @@ -175,8 +175,9 @@ pub async fn process_oracle_queue( let mut current_ids: HashSet<[u8; 32]> = HashSet::new(); let mut current_slots_by_id: HashMap<[u8; 32], u64> = HashMap::new(); - // Construct a read-only view over the queue items using a local mutable copy - let mut acc_bytes = account_bytes[8..].to_vec(); // strip discriminator + // Construct a read-only view over the queue items using a local mutable copy. + // Pass the full account data; load() skips the discriminator. + let mut acc_bytes = account_bytes[..].to_vec(); let queue_account = match QueueAccount::load(&mut acc_bytes[..]) { Ok(q) => q, Err(e) => { @@ -314,7 +315,8 @@ pub async fn process_oracle_queue( let mut use_backoff = false; let transaction = match prepared_transaction.take() { Some(transaction) - if attempt_slot.saturating_sub(item.slot) <= QUEUE_TTL_SLOTS => + if attempt_slot.saturating_sub(item.slot) + <= oracle_client_for_proc.slot_tracker.ttl_slots() => { transaction } @@ -362,17 +364,19 @@ pub async fn process_oracle_queue( .downcast_ref::() .and_then(ClientError::get_transaction_error) { - if code == EphemeralVrfError::RandomnessRequestNotFound as u32 { + if code == SolanaVrfError::RandomnessRequestNotFound as u32 { break; } - if code - == EphemeralVrfError::OracleMustProvideInDifferentSlot as u32 - { + if code == SolanaVrfError::OracleMustProvideInDifferentSlot as u32 { use_backoff = false; } if code == ANCHOR_CONSTRAINT_ADDRESS_ERROR { - let purge_slot = - item.slot.saturating_add(QUEUE_TTL_SLOTS).saturating_add(1); + let purge_slot = item + .slot + .saturating_add( + oracle_client_for_proc.slot_tracker.ttl_slots(), + ) + .saturating_add(1); oracle_client_for_proc .slot_tracker .wait_for_slot(purge_slot) @@ -505,7 +509,7 @@ impl ProcessableItem { // Check whether the request is expired let age = current_slot.saturating_sub(self.0.slot); - let is_purge = age > QUEUE_TTL_SLOTS; + let is_purge = age > oracle_client.slot_tracker.ttl_slots(); let ix = if is_purge { // Build purge instruction for the queue index purge_expired_requests(oracle_client.keypair.pubkey(), queue_meta.index) diff --git a/vrf-oracle/src/oracle/sources.rs b/vrf-oracle/src/oracle/sources.rs index 91ae2c3..17a1d8a 100644 --- a/vrf-oracle/src/oracle/sources.rs +++ b/vrf-oracle/src/oracle/sources.rs @@ -11,12 +11,12 @@ use solana_sdk::pubkey::Pubkey; use crate::blockhash_cache::BlockhashCache; use crate::oracle::client::{QueueUpdateSource, SlotTracker}; -use ephemeral_vrf_api::prelude::Queue; -use ephemeral_vrf_api::ID as PROGRAM_ID; use helius_laserstream::{ grpc::{subscribe_update::UpdateOneof, SlotStatus, SubscribeUpdate}, LaserstreamError, }; +use solana_vrf_api::prelude::Queue; +use solana_vrf_api::ID as PROGRAM_ID; pub struct WebSocketSource { pub subscription: Receiver>, diff --git a/vrf-oracle/src/oracle/utils.rs b/vrf-oracle/src/oracle/utils.rs index 239faff..d292e6f 100644 --- a/vrf-oracle/src/oracle/utils.rs +++ b/vrf-oracle/src/oracle/utils.rs @@ -1,5 +1,5 @@ -use ephemeral_vrf_api::prelude::AccountDiscriminator; use solana_client::rpc_filter::{Memcmp, MemcmpEncodedBytes, RpcFilterType}; +use solana_vrf_api::prelude::AccountDiscriminator; pub fn queue_memcmp_filter() -> Vec { vec![RpcFilterType::Memcmp(Memcmp::new( diff --git a/vrf/Cargo.toml b/vrf/Cargo.toml index a94f410..d41fe2b 100644 --- a/vrf/Cargo.toml +++ b/vrf/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "ephemeral-vrf" -description = "Core Ephemeral Vrf functionality" +name = "solana-vrf" +description = "Core SolanaVrf functionality" version.workspace = true edition.workspace = true license.workspace = true diff --git a/src/main.rs b/vrf/examples/keygen.rs similarity index 91% rename from src/main.rs rename to vrf/examples/keygen.rs index 5150a35..80d5f71 100644 --- a/src/main.rs +++ b/vrf/examples/keygen.rs @@ -1,7 +1,7 @@ -use ephemeral_vrf::vrf::{compute_vrf, generate_vrf_keypair, verify_vrf}; use solana_sdk::hash::hash; use solana_sdk::pubkey::Pubkey; use solana_sdk::signature::Keypair; +use solana_vrf::vrf::{compute_vrf, generate_vrf_keypair, verify_vrf}; fn main() { let keypair = Keypair::new();