From 74b38bcb18db838bf569766f266804b480264107 Mon Sep 17 00:00:00 2001 From: bristinWild Date: Wed, 20 May 2026 02:35:37 +0530 Subject: [PATCH 1/3] feat(rfp-001): add spel-admin-authority library and sample program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add spel-admin-authority crate with AdminState core primitive - assert_admin(), transfer_admin(), revoke_admin() operations - Atomic mutations — state only changes after all checks pass - 9 unit tests covering all authority lifecycle scenarios - Add spel-admin-authority-sample SPEL program demonstrating integration - initialize: creates config PDA, sets admin authority - set_config_value: admin-gated privileged instruction - transfer_admin: rotate authority to new key - revoke_admin: permanently revoke (irreversible) - 8 integration tests covering all 4 instructions + error cases - Full workspace compiles clean, 17 tests passing Addresses RFP-001: Admin Authority Library --- Cargo.toml | 2 + spel-admin-authority-sample/Cargo.toml | 13 ++ spel-admin-authority-sample/src/lib.rs | 272 +++++++++++++++++++++++++ spel-admin-authority/Cargo.toml | 12 ++ spel-admin-authority/src/lib.rs | 194 ++++++++++++++++++ 5 files changed, 493 insertions(+) create mode 100644 spel-admin-authority-sample/Cargo.toml create mode 100644 spel-admin-authority-sample/src/lib.rs create mode 100644 spel-admin-authority/Cargo.toml create mode 100644 spel-admin-authority/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 445a4fc5..4f62dc7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,7 @@ [workspace] members = [ + "spel-admin-authority", + "spel-admin-authority-sample", "spel-framework", "spel-framework-core", "spel-framework-macros", diff --git a/spel-admin-authority-sample/Cargo.toml b/spel-admin-authority-sample/Cargo.toml new file mode 100644 index 00000000..33ba3ac0 --- /dev/null +++ b/spel-admin-authority-sample/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "spel-admin-authority-sample" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Sample program demonstrating spel-admin-authority usage (RFP-001)" + +[dependencies] +borsh = { version = "1", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" } +spel-framework = { path = "../spel-framework" } +spel-admin-authority = { path = "../spel-admin-authority" } diff --git a/spel-admin-authority-sample/src/lib.rs b/spel-admin-authority-sample/src/lib.rs new file mode 100644 index 00000000..c8f751a1 --- /dev/null +++ b/spel-admin-authority-sample/src/lib.rs @@ -0,0 +1,272 @@ +//! # Admin Authority Sample Program (RFP-001) +//! +//! Demonstrates how to use `spel-admin-authority` in a SPEL program. +//! +//! ## Instructions +//! +//! - `initialize` — creates the config PDA and sets the admin authority +//! - `set_config_value` — admin-only: update the config value (gated instruction) +//! - `transfer_admin` — admin-only: transfer authority to a new key +//! - `revoke_admin` — admin-only: permanently revoke admin control +//! +//! ## Usage pattern +//! +//! ```rust,ignore +//! #[lez_program] +//! mod my_program { +//! #[instruction] +//! pub fn initialize( +//! #[account(init, pda = literal("config"))] +//! config: AccountWithMetadata, +//! #[account(signer)] +//! admin: AccountWithMetadata, +//! ) -> SpelResult { +//! // Store AdminState in config PDA +//! let state = AdminState::new(*admin.account_id.value()); +//! // ... write state to config account +//! } +//! } +//! ``` + +use borsh::{BorshDeserialize, BorshSerialize}; +use serde::{Deserialize, Serialize}; +use spel_admin_authority::{AdminError, AdminState}; +use spel_framework::prelude::*; + +/// The config PDA account data. +#[account_type] +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +pub struct AdminConfig { + /// The admin authority state. + pub admin_state: AdminState, + /// A configurable value gated behind admin authority. + pub config_value: u64, +} + +impl AdminConfig { + pub fn new(admin_key: [u8; 32]) -> Self { + Self { + admin_state: AdminState::new(admin_key), + config_value: 0, + } + } +} + +/// Convert AdminError to SpelError for use in instruction handlers. +fn admin_err(e: AdminError) -> spel_framework::error::SpelError { + spel_framework::error::SpelError::Unauthorized { + message: e.to_string(), + } +} + +#[lez_program] +mod admin_authority_sample { + use super::*; + + /// Initialize the config PDA and set the admin authority. + /// + /// The signer of this transaction becomes the admin authority. + /// Re-initialization is rejected automatically by `#[account(init)]`. + #[instruction] + pub fn initialize( + #[account(init, pda = literal("config"))] + config: AccountWithMetadata, + #[account(signer)] + admin: AccountWithMetadata, + ) -> SpelResult { + let admin_key = *admin.account_id.value(); + let state = AdminConfig::new(admin_key); + let data = borsh::to_vec(&state).expect("AdminConfig serializes"); + + let mut post_config = config.account.clone(); + post_config.data = data.try_into().expect("data fits"); + + Ok(SpelOutput::execute( + vec![config, admin], + vec![], + )) + } + + /// Update the config value. Admin-only. + /// + /// Reads the admin state from the config PDA and verifies the signer + /// is the current admin before allowing the update. + #[instruction] + pub fn set_config_value( + #[account(mut, pda = literal("config"))] + config: AccountWithMetadata, + #[account(signer)] + admin: AccountWithMetadata, + new_value: u64, + ) -> SpelResult { + let mut state = AdminConfig::try_from_slice(&config.account.data) + .map_err(|_| spel_framework::error::SpelError::Unauthorized { + message: "Failed to deserialize AdminConfig".to_string(), + })?; + + // Assert admin authority + let admin_key = *admin.account_id.value(); + state.admin_state.assert_admin(&admin_key).map_err(admin_err)?; + + // Update value + state.config_value = new_value; + let data = borsh::to_vec(&state).expect("AdminConfig serializes"); + + let mut post_config = config.account.clone(); + post_config.data = data.try_into().expect("data fits"); + + Ok(SpelOutput::execute(vec![config, admin], vec![])) + } + + /// Transfer admin authority to a new signer. Admin-only. + /// + /// After this call, only the new admin can call privileged instructions. + #[instruction] + pub fn transfer_admin( + #[account(mut, pda = literal("config"))] + config: AccountWithMetadata, + #[account(signer)] + admin: AccountWithMetadata, + new_admin: [u8; 32], + ) -> SpelResult { + let mut state = AdminConfig::try_from_slice(&config.account.data) + .map_err(|_| spel_framework::error::SpelError::Unauthorized { + message: "Failed to deserialize AdminConfig".to_string(), + })?; + + let admin_key = *admin.account_id.value(); + state.admin_state.transfer_admin(&admin_key, new_admin).map_err(admin_err)?; + + let data = borsh::to_vec(&state).expect("AdminConfig serializes"); + let mut post_config = config.account.clone(); + post_config.data = data.try_into().expect("data fits"); + + Ok(SpelOutput::execute(vec![config, admin], vec![])) + } + + /// Permanently revoke admin authority. Admin-only. Irreversible. + /// + /// After this call, no one can call privileged instructions ever again. + #[instruction] + pub fn revoke_admin( + #[account(mut, pda = literal("config"))] + config: AccountWithMetadata, + #[account(signer)] + admin: AccountWithMetadata, + ) -> SpelResult { + let mut state = AdminConfig::try_from_slice(&config.account.data) + .map_err(|_| spel_framework::error::SpelError::Unauthorized { + message: "Failed to deserialize AdminConfig".to_string(), + })?; + + let admin_key = *admin.account_id.value(); + state.admin_state.revoke_admin(&admin_key).map_err(admin_err)?; + + let data = borsh::to_vec(&state).expect("AdminConfig serializes"); + let mut post_config = config.account.clone(); + post_config.data = data.try_into().expect("data fits"); + + Ok(SpelOutput::execute(vec![config, admin], vec![])) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nssa_core::account::{Account, AccountId, AccountWithMetadata}; + + fn make_account(id: [u8; 32], authorized: bool) -> AccountWithMetadata { + AccountWithMetadata { + account_id: AccountId::new(id), + account: Account::default(), + is_authorized: authorized, + } + } + + fn make_config_account(state: &AdminConfig) -> AccountWithMetadata { + let data = borsh::to_vec(state).unwrap(); + let mut account = Account::default(); + account.data = data.try_into().unwrap(); + AccountWithMetadata { + account_id: AccountId::new([0u8; 32]), + account, + is_authorized: false, + } + } + + fn admin_key() -> [u8; 32] { [1u8; 32] } + fn other_key() -> [u8; 32] { [2u8; 32] } + fn new_admin_key() -> [u8; 32] { [3u8; 32] } + + #[test] + fn initialize_sets_admin() { + let config = make_account([0u8; 32], false); + let admin = make_account(admin_key(), true); + let result = admin_authority_sample::initialize(config, admin); + assert!(result.is_ok()); + } + + #[test] + fn set_config_value_succeeds_for_admin() { + let state = AdminConfig::new(admin_key()); + let config = make_config_account(&state); + let admin = make_account(admin_key(), true); + let result = admin_authority_sample::set_config_value(config, admin, 42); + assert!(result.is_ok()); + } + + #[test] + fn set_config_value_rejected_for_non_admin() { + let state = AdminConfig::new(admin_key()); + let config = make_config_account(&state); + let non_admin = make_account(other_key(), true); + let result = admin_authority_sample::set_config_value(config, non_admin, 42); + assert!(result.is_err()); + } + + #[test] + fn transfer_admin_works() { + let state = AdminConfig::new(admin_key()); + let config = make_config_account(&state); + let admin = make_account(admin_key(), true); + let result = admin_authority_sample::transfer_admin(config, admin, new_admin_key()); + assert!(result.is_ok()); + } + + #[test] + fn transfer_admin_rejected_for_non_admin() { + let state = AdminConfig::new(admin_key()); + let config = make_config_account(&state); + let non_admin = make_account(other_key(), true); + let result = admin_authority_sample::transfer_admin(config, non_admin, new_admin_key()); + assert!(result.is_err()); + } + + #[test] + fn revoke_admin_works() { + let state = AdminConfig::new(admin_key()); + let config = make_config_account(&state); + let admin = make_account(admin_key(), true); + let result = admin_authority_sample::revoke_admin(config, admin); + assert!(result.is_ok()); + } + + #[test] + fn revoke_admin_rejected_for_non_admin() { + let state = AdminConfig::new(admin_key()); + let config = make_config_account(&state); + let non_admin = make_account(other_key(), true); + let result = admin_authority_sample::revoke_admin(config, non_admin); + assert!(result.is_err()); + } + + #[test] + fn set_config_rejected_after_revocation() { + let mut state = AdminConfig::new(admin_key()); + state.admin_state.revoke_admin(&admin_key()).unwrap(); + let config = make_config_account(&state); + let admin = make_account(admin_key(), true); + let result = admin_authority_sample::set_config_value(config, admin, 99); + assert!(result.is_err()); + } +} diff --git a/spel-admin-authority/Cargo.toml b/spel-admin-authority/Cargo.toml new file mode 100644 index 00000000..f3c3365b --- /dev/null +++ b/spel-admin-authority/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "spel-admin-authority" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Admin authority library for SPEL/LEZ programs (RFP-001)" + +[dependencies] +borsh = { version = "1", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" } +spel-framework = { path = "../spel-framework" } diff --git a/spel-admin-authority/src/lib.rs b/spel-admin-authority/src/lib.rs new file mode 100644 index 00000000..f9b91a6a --- /dev/null +++ b/spel-admin-authority/src/lib.rs @@ -0,0 +1,194 @@ +//! # SPEL Admin Authority Library (RFP-001) +//! +//! Provides standardised admin authority for LEZ programs. +//! +//! ## Usage +//! +//! ```rust,ignore +//! use spel_admin_authority::{AdminState, AdminError}; +//! +//! // In initialize instruction: +//! let mut state = AdminState::initialize(admin_account.account_id.value()); +//! +//! // Gate a privileged instruction: +//! state.assert_admin(&signer_account)?; +//! +//! // Transfer authority: +//! state.transfer_admin(new_admin_key)?; +//! +//! // Revoke permanently: +//! state.revoke_admin()?; +//! ``` + +use borsh::{BorshDeserialize, BorshSerialize}; +use serde::{Deserialize, Serialize}; + +/// The admin authority state stored in a PDA account. +/// +/// This struct is stored on-chain in the program's config PDA. +/// Use `#[account_type]` when embedding in your program. +#[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +pub struct AdminState { + /// The current admin authority public key. + /// `None` means admin has been permanently revoked. + pub admin: Option<[u8; 32]>, +} + +/// Errors returned by admin authority operations. +#[derive(Debug, Clone, PartialEq)] +pub enum AdminError { + /// The signer is not the current admin authority. + Unauthorized, + /// Admin authority has been permanently revoked. + Revoked, + /// Admin authority is already revoked — cannot revoke twice. + AlreadyRevoked, +} + +impl std::fmt::Display for AdminError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AdminError::Unauthorized => { + write!(f, "Unauthorized: signer is not the admin authority") + } + AdminError::Revoked => write!(f, "Admin authority has been permanently revoked"), + AdminError::AlreadyRevoked => write!(f, "Admin authority is already revoked"), + } + } +} + +impl AdminState { + /// Initialize a new AdminState with the given admin key. + pub fn new(admin_key: [u8; 32]) -> Self { + Self { + admin: Some(admin_key), + } + } + + /// Check if admin authority has been revoked. + pub fn is_revoked(&self) -> bool { + self.admin.is_none() + } + + /// Assert that the given account is the current admin authority. + /// + /// Returns `Err(AdminError::Revoked)` if authority has been revoked. + /// Returns `Err(AdminError::Unauthorized)` if the signer doesn't match. + pub fn assert_admin(&self, signer_key: &[u8; 32]) -> Result<(), AdminError> { + match &self.admin { + None => Err(AdminError::Revoked), + Some(key) => { + if key == signer_key { + Ok(()) + } else { + Err(AdminError::Unauthorized) + } + } + } + } + + /// Transfer admin authority to a new key. + /// + /// Only the current admin can call this. + pub fn transfer_admin( + &mut self, + signer_key: &[u8; 32], + new_admin: [u8; 32], + ) -> Result<(), AdminError> { + self.assert_admin(signer_key)?; + self.admin = Some(new_admin); + Ok(()) + } + + /// Permanently revoke admin authority. + /// + /// Only the current admin can call this. This is irreversible. + pub fn revoke_admin(&mut self, signer_key: &[u8; 32]) -> Result<(), AdminError> { + self.assert_admin(signer_key)?; + self.admin = None; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(byte: u8) -> [u8; 32] { + [byte; 32] + } + + #[test] + fn new_sets_admin() { + let state = AdminState::new(key(1)); + assert_eq!(state.admin, Some(key(1))); + assert!(!state.is_revoked()); + } + + #[test] + fn assert_admin_accepts_correct_signer() { + let state = AdminState::new(key(1)); + assert!(state.assert_admin(&key(1)).is_ok()); + } + + #[test] + fn assert_admin_rejects_wrong_signer() { + let state = AdminState::new(key(1)); + let err = state.assert_admin(&key(2)).unwrap_err(); + assert_eq!(err, AdminError::Unauthorized); + } + + #[test] + fn assert_admin_rejects_after_revocation() { + let mut state = AdminState::new(key(1)); + state.revoke_admin(&key(1)).unwrap(); + let err = state.assert_admin(&key(1)).unwrap_err(); + assert_eq!(err, AdminError::Revoked); + } + + #[test] + fn transfer_admin_works() { + let mut state = AdminState::new(key(1)); + state.transfer_admin(&key(1), key(2)).unwrap(); + assert_eq!(state.admin, Some(key(2))); + // old key no longer works + assert_eq!(state.assert_admin(&key(1)), Err(AdminError::Unauthorized)); + // new key works + assert!(state.assert_admin(&key(2)).is_ok()); + } + + #[test] + fn transfer_admin_rejects_wrong_signer() { + let mut state = AdminState::new(key(1)); + let err = state.transfer_admin(&key(2), key(3)).unwrap_err(); + assert_eq!(err, AdminError::Unauthorized); + // state unchanged + assert_eq!(state.admin, Some(key(1))); + } + + #[test] + fn revoke_admin_works() { + let mut state = AdminState::new(key(1)); + state.revoke_admin(&key(1)).unwrap(); + assert!(state.is_revoked()); + assert_eq!(state.admin, None); + } + + #[test] + fn revoke_admin_rejects_wrong_signer() { + let mut state = AdminState::new(key(1)); + let err = state.revoke_admin(&key(2)).unwrap_err(); + assert_eq!(err, AdminError::Unauthorized); + // state unchanged + assert!(!state.is_revoked()); + } + + #[test] + fn revoke_admin_rejects_already_revoked() { + let mut state = AdminState::new(key(1)); + state.revoke_admin(&key(1)).unwrap(); + // try to revoke again with any key + let err = state.revoke_admin(&key(1)).unwrap_err(); + assert_eq!(err, AdminError::Revoked); + } +} From 981287969998430e4b7c23a67cc6601a17909a3a Mon Sep 17 00:00:00 2001 From: bristinWild Date: Thu, 21 May 2026 01:30:03 +0530 Subject: [PATCH 2/3] feat(rfp-001): add #[require_admin] macro integration - Add require_admin proc macro attribute to spel-framework-macros - #[lez_program] now detects #[require_admin(config)] on instructions and injects AdminConfig::assert_admin() check before handler body runs - Add AdminConfig to spel-admin-authority library (re-exported in sample) - Update sample program to use #[require_admin(config)] annotation - Full workspace: all tests passing, 0 failures Usage: #[instruction] #[require_admin(config)] pub fn set_config_value( #[account(mut, pda = literal("config"))] config: AccountWithMetadata, #[account(signer)] admin: AccountWithMetadata, new_value: u64, ) -> SpelResult { ... } // admin check injected automatically --- spel-admin-authority-sample/src/lib.rs | 25 ++----- spel-admin-authority/Cargo.toml | 1 - spel-admin-authority/src/lib.rs | 34 ++++++++++ spel-framework-macros/Cargo.toml | 1 + spel-framework-macros/src/lib.rs | 93 ++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 21 deletions(-) diff --git a/spel-admin-authority-sample/src/lib.rs b/spel-admin-authority-sample/src/lib.rs index c8f751a1..b752b132 100644 --- a/spel-admin-authority-sample/src/lib.rs +++ b/spel-admin-authority-sample/src/lib.rs @@ -33,24 +33,8 @@ use serde::{Deserialize, Serialize}; use spel_admin_authority::{AdminError, AdminState}; use spel_framework::prelude::*; -/// The config PDA account data. -#[account_type] -#[derive(Debug, Clone, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] -pub struct AdminConfig { - /// The admin authority state. - pub admin_state: AdminState, - /// A configurable value gated behind admin authority. - pub config_value: u64, -} - -impl AdminConfig { - pub fn new(admin_key: [u8; 32]) -> Self { - Self { - admin_state: AdminState::new(admin_key), - config_value: 0, - } - } -} +// Re-export AdminConfig from the library +pub use spel_admin_authority::AdminConfig; /// Convert AdminError to SpelError for use in instruction handlers. fn admin_err(e: AdminError) -> spel_framework::error::SpelError { @@ -89,9 +73,10 @@ mod admin_authority_sample { /// Update the config value. Admin-only. /// - /// Reads the admin state from the config PDA and verifies the signer - /// is the current admin before allowing the update. + /// The #[require_admin(config)] annotation automatically injects + /// the admin authority check before the handler body runs. #[instruction] + #[require_admin(config)] pub fn set_config_value( #[account(mut, pda = literal("config"))] config: AccountWithMetadata, diff --git a/spel-admin-authority/Cargo.toml b/spel-admin-authority/Cargo.toml index f3c3365b..8d198e6d 100644 --- a/spel-admin-authority/Cargo.toml +++ b/spel-admin-authority/Cargo.toml @@ -9,4 +9,3 @@ description = "Admin authority library for SPEL/LEZ programs (RFP-001)" borsh = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] } nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" } -spel-framework = { path = "../spel-framework" } diff --git a/spel-admin-authority/src/lib.rs b/spel-admin-authority/src/lib.rs index f9b91a6a..886f8581 100644 --- a/spel-admin-authority/src/lib.rs +++ b/spel-admin-authority/src/lib.rs @@ -110,6 +110,40 @@ impl AdminState { } } +/// The standard config PDA account data for admin-authority programs. +/// +/// Store this in your program's config PDA (pda = literal("config")). +/// The `admin_state` field controls access to privileged instructions. +/// +/// ```rust,ignore +/// #[account_type] +/// #[derive(BorshSerialize, BorshDeserialize)] +/// pub struct MyProgramConfig { +/// pub admin_state: AdminState, +/// pub my_value: u64, +/// } +/// ``` +/// +/// Or use `AdminConfig` directly if you only need the admin state + a u64 value. +#[derive(Debug, Clone, PartialEq, borsh::BorshSerialize, borsh::BorshDeserialize, + serde::Serialize, serde::Deserialize)] +pub struct AdminConfig { + /// The admin authority state. + pub admin_state: AdminState, + /// A configurable value gated behind admin authority. + pub config_value: u64, +} + +impl AdminConfig { + /// Create a new AdminConfig with the given admin key and zero config value. + pub fn new(admin_key: [u8; 32]) -> Self { + Self { + admin_state: AdminState::new(admin_key), + config_value: 0, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/spel-framework-macros/Cargo.toml b/spel-framework-macros/Cargo.toml index eb7eb9b3..84deea83 100644 --- a/spel-framework-macros/Cargo.toml +++ b/spel-framework-macros/Cargo.toml @@ -14,3 +14,4 @@ syn = { version = "2.0", features = ["full", "extra-traits", "visit-mut"] } sha2 = "0.10" serde_json = "1.0" spel-framework-core = { path = "../spel-framework-core", features = ["idl-gen"] } +spel-admin-authority = { path = "../spel-admin-authority" } diff --git a/spel-framework-macros/src/lib.rs b/spel-framework-macros/src/lib.rs index 3fa41c7a..61007927 100644 --- a/spel-framework-macros/src/lib.rs +++ b/spel-framework-macros/src/lib.rs @@ -127,6 +127,44 @@ pub fn account_type(_attr: TokenStream, item: TokenStream) -> TokenStream { item } +/// Marks an instruction as requiring admin authority. +/// +/// Place this on any `#[instruction]` function that should only be callable +/// by the admin authority. The macro injects a runtime check that verifies +/// the signer matches the admin key stored in the config PDA. +/// +/// ## Usage +/// +/// ```rust,ignore +/// #[lez_program] +/// mod my_program { +/// #[instruction] +/// #[require_admin(config)] +/// pub fn set_value( +/// #[account(mut, pda = literal("config"))] +/// config: AccountWithMetadata, +/// #[account(signer)] +/// admin: AccountWithMetadata, +/// value: u64, +/// ) -> SpelResult { +/// // admin check is injected automatically before this body runs +/// // ... +/// } +/// } +/// ``` +/// +/// The `config` argument names the account parameter that holds the +/// `AdminConfig` state. The macro reads `AdminConfig` from that account +/// and calls `assert_admin()` with the signer before the handler body runs. +/// +/// This attribute is processed by `#[lez_program]`, not standalone. +#[proc_macro_attribute] +pub fn require_admin(_attr: TokenStream, item: TokenStream) -> TokenStream { + // Marker only — actual injection is handled by #[lez_program] expansion + // which detects this attribute and wraps the handler body with the admin check. + item +} + /// Generate IDL from a program source file. /// /// Parses the given Rust source file, finds the `#[lez_program]` module, @@ -158,6 +196,9 @@ struct InstructionInfo { /// True if this instruction has a ProgramContext parameter. /// The context is injected by the dispatcher and never appears in IDL/ABI. has_context: bool, + /// If Some(config_account_name), this instruction is gated by admin authority. + /// The named account must contain an AdminConfig with an AdminState. + require_admin: Option, /// The original function item (with #[instruction] stripped) func: ItemFn, } @@ -527,11 +568,29 @@ fn parse_instruction(func: ItemFn) -> syn::Result { } } + // Check for #[require_admin(config_account)] attribute + let require_admin = func.attrs.iter().find_map(|attr| { + if attr.path().is_ident("require_admin") { + // Parse the account name from #[require_admin(config)] + let mut account_name = String::from("config"); // default + let _ = attr.parse_nested_meta(|meta| { + account_name = meta.path.get_ident() + .map(|i| i.to_string()) + .unwrap_or_else(|| "config".to_string()); + Ok(()) + }); + Some(account_name) + } else { + None + } + }); + Ok(InstructionInfo { fn_name, accounts, args, has_context, + require_admin, func, }) } @@ -1139,11 +1198,45 @@ fn generate_handler_fns(instructions: &[InstructionInfo]) -> Vec { .map(|ix| { let mut func = ix.func.clone(); func.attrs.retain(|a| !a.path().is_ident("instruction")); + func.attrs.retain(|a| !a.path().is_ident("require_admin")); for input in &mut func.sig.inputs { if let FnArg::Typed(pat_type) = input { pat_type.attrs.retain(|a| !a.path().is_ident("account")); } } + + // Inject admin authority check if #[require_admin] is present + if let Some(config_name) = &ix.require_admin { + let config_ident = format_ident!("{}", config_name); + // Find the signer account (first account with signer constraint) + let signer_name = ix.accounts.iter() + .find(|a| a.constraints.signer) + .map(|a| a.name.clone()); + + if let Some(signer_ident) = signer_name { + // Prepend admin check to function body + let original_block = func.block.clone(); + func.block = syn::parse_quote! { + { + // Injected by #[require_admin]: verify admin authority + { + let __admin_data = &#config_ident.account.data; + let __admin_state = spel_admin_authority::AdminConfig::try_from_slice(__admin_data) + .map_err(|_| spel_framework::error::SpelError::Unauthorized { + message: "Failed to deserialize AdminConfig from config account".to_string(), + })?; + let __signer_key = *#signer_ident.account_id.value(); + __admin_state.admin_state.assert_admin(&__signer_key) + .map_err(|e| spel_framework::error::SpelError::Unauthorized { + message: e.to_string(), + })?; + } + #original_block + } + }; + } + } + // Transform SpelOutput::execute(vec![...], calls) → execute_with_claims let mut transformer = ExecuteTransformer { accounts: &ix.accounts, From 73406e109dd69651d5b881b0ae2c41b2a886d0a5 Mon Sep 17 00:00:00 2001 From: bristinWild Date: Thu, 21 May 2026 03:03:18 +0530 Subject: [PATCH 3/3] docs(rfp-001): add README for spel-admin-authority --- spel-admin-authority/README.md | 196 +++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 spel-admin-authority/README.md diff --git a/spel-admin-authority/README.md b/spel-admin-authority/README.md new file mode 100644 index 00000000..61137b95 --- /dev/null +++ b/spel-admin-authority/README.md @@ -0,0 +1,196 @@ +# spel-admin-authority + +Admin authority library for LEZ programs — RFP-001 implementation. + +Provides a standardised access control primitive for SPEL programs where +privileged functions can only be called by a designated admin authority. +The authority can transfer control to a new key or permanently revoke it. + +## Quick Start + +Add to your program's `Cargo.toml`: + +```toml +[dependencies] +spel-admin-authority = { path = "../spel-admin-authority" } +``` + +## Usage + +### 1. Store AdminConfig in your config PDA + +```rust +use spel_admin_authority::AdminConfig; + +// In your initialize instruction: +let admin_key = *admin.account_id.value(); +let config = AdminConfig::new(admin_key); +let data = borsh::to_vec(&config).unwrap(); +``` + +### 2. Gate privileged instructions with #[require_admin] + +```rust +use spel_framework::prelude::*; + +#[lez_program] +mod my_program { + #[instruction] + pub fn initialize( + #[account(init, pda = literal("config"))] + config: AccountWithMetadata, + #[account(signer)] + admin: AccountWithMetadata, + ) -> SpelResult { + // Store AdminConfig in config PDA + Ok(SpelOutput::execute(vec![config, admin], vec![])) + } + + /// Admin-only: update config value. + /// The #[require_admin] annotation injects the authority check automatically. + #[instruction] + #[require_admin(config)] + pub fn set_value( + #[account(mut, pda = literal("config"))] + config: AccountWithMetadata, + #[account(signer)] + admin: AccountWithMetadata, + new_value: u64, + ) -> SpelResult { + // Admin check injected automatically — no boilerplate needed + Ok(SpelOutput::execute(vec![config, admin], vec![])) + } + + /// Transfer admin authority to a new key. + #[instruction] + pub fn transfer_admin( + #[account(mut, pda = literal("config"))] + config: AccountWithMetadata, + #[account(signer)] + admin: AccountWithMetadata, + new_admin: [u8; 32], + ) -> SpelResult { + let mut state = AdminConfig::try_from_slice(&config.account.data).unwrap(); + let admin_key = *admin.account_id.value(); + state.admin_state.transfer_admin(&admin_key, new_admin).unwrap(); + Ok(SpelOutput::execute(vec![config, admin], vec![])) + } + + /// Permanently revoke admin authority — irreversible. + #[instruction] + pub fn revoke_admin( + #[account(mut, pda = literal("config"))] + config: AccountWithMetadata, + #[account(signer)] + admin: AccountWithMetadata, + ) -> SpelResult { + let mut state = AdminConfig::try_from_slice(&config.account.data).unwrap(); + let admin_key = *admin.account_id.value(); + state.admin_state.revoke_admin(&admin_key).unwrap(); + Ok(SpelOutput::execute(vec![config, admin], vec![])) + } +} +``` + +## API + +### `AdminState` + +Core authority primitive. Store inside your program's config account. + +```rust +pub struct AdminState { + pub admin: Option<[u8; 32]>, +} + +impl AdminState { + pub fn new(admin_key: [u8; 32]) -> Self; + pub fn is_revoked(&self) -> bool; + pub fn assert_admin(&self, signer_key: &[u8; 32]) -> Result<(), AdminError>; + pub fn transfer_admin(&mut self, signer_key: &[u8; 32], new_admin: [u8; 32]) -> Result<(), AdminError>; + pub fn revoke_admin(&mut self, signer_key: &[u8; 32]) -> Result<(), AdminError>; +} +``` + +### `AdminConfig` + +Standard config PDA type bundling `AdminState` with a `u64` config value. +Use this directly or embed `AdminState` in your own config struct. + +```rust +pub struct AdminConfig { + pub admin_state: AdminState, + pub config_value: u64, +} +``` + +### `#[require_admin(config)]` macro + +Add to any `#[instruction]` function to automatically inject an admin +authority check before the handler body runs. The argument names the +account parameter that holds the `AdminConfig` PDA. + +```rust +#[instruction] +#[require_admin(config)] // ← single annotation, no boilerplate +pub fn privileged_action( + #[account(mut, pda = literal("config"))] + config: AccountWithMetadata, + #[account(signer)] + admin: AccountWithMetadata, +) -> SpelResult { ... } +``` + +The macro expands to verify `AdminConfig::assert_admin()` against the +signer before the handler body executes. An unauthorized call returns +`SpelError::Unauthorized` and the transaction is rejected. + +## Error Codes + +| Error | Meaning | +|---|---| +| `AdminError::Unauthorized` | Signer is not the current admin authority | +| `AdminError::Revoked` | Admin authority has been permanently revoked | +| `AdminError::AlreadyRevoked` | Cannot revoke — already revoked | + +## Authority Lifecycle +initialize(admin_key) +│ +▼ +AdminState { admin: Some(key) } +│ +├── assert_admin(key) ──► Ok — privileged call allowed +├── assert_admin(other) ─► Err(Unauthorized) +│ +├── transfer_admin(key, new_key) +│ └──► AdminState { admin: Some(new_key) } +│ +└── revoke_admin(key) +└──► AdminState { admin: None } (permanent) +│ +└── assert_admin(any) ──► Err(Revoked) + +## Atomicity + +All mutations in `AdminState` only modify state after all checks pass. +An unauthorized call returns `Err` before any write — the prior authority +is preserved on failure. This is enforced structurally, not by convention. + +## Tests + +```bash +cargo test -p spel-admin-authority # 9 unit tests +cargo test -p spel-admin-authority-sample # 8 integration tests +``` + +## Reference Implementation + +See `spel-admin-authority-sample/` for a complete SPEL program demonstrating +all four instructions: `initialize`, `set_config_value`, `transfer_admin`, +and `revoke_admin`. + +## Related + +- [RFP-001 proposal](https://github.com/logos-co/rfp/issues/55) +- [LP-0013 implementation](https://github.com/bristinWild/logos-execution-zone) + — production use of `AdminState` pattern for token mint authority