diff --git a/.gitignore b/.gitignore index d58ebed..7625e09 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,8 @@ engine-bridge/dist/ # Secrets .env +# Database +*.db + # OS .DS_Store diff --git a/engine-core/src/core/access.rs b/engine-core/src/core/access.rs new file mode 100644 index 0000000..c6661c7 --- /dev/null +++ b/engine-core/src/core/access.rs @@ -0,0 +1,201 @@ +use soroban_sdk::{contracterror, panic_with_error, symbol_short, vec, Address, BytesN, Env, Symbol, Vec}; +use crate::event_struct::{MOD_CORE, ACT_INIT}; +use crate::event_utils::publish_event; + +const KEY_INIT: Symbol = symbol_short!("C_INIT"); +const KEY_ADMIN: Symbol = symbol_short!("C_ADMIN"); +const KEY_OPERATRS: Symbol = symbol_short!("C_OPERS"); +const KEY_AUDITORS: Symbol = symbol_short!("C_AUDIT"); + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum AccessError { + Unauthorized = 1, + AlreadyInit = 2, + NotInitialized = 3, + InvalidAdmin = 4, + InvalidOperator = 5, + InvalidAuditor = 6, +} + +pub const ROLE_ADMIN: u32 = 0; +pub const ROLE_OPERATOR: u32 = 1; +pub const ROLE_AUDITOR: u32 = 2; + +pub fn initialize(env: &Env, admin: &Address, operators: Vec
, auditors: Vec
) { + if is_initialized(env) { + panic_with_error!(env, AccessError::AlreadyInit); + } + + admin.require_auth(); + + env.storage().instance().set(&KEY_INIT, &true); + env.storage().instance().set(&KEY_ADMIN, admin); + + let mut op_vec: Vec
= vec![env]; + for o in operators.iter() { + op_vec.push_back(o); + } + env.storage().instance().set(&KEY_OPERATRS, &op_vec); + + let mut au_vec: Vec
= vec![env]; + for a in auditors.iter() { + au_vec.push_back(a); + } + env.storage().instance().set(&KEY_AUDITORS, &au_vec); + + publish_event( + env, + MOD_CORE | ACT_INIT, + 0, + BytesN::from_array(env, &[0u8; 32]), + ); +} + +pub fn is_initialized(env: &Env) -> bool { + env.storage().instance().has(&KEY_INIT) +} + +pub fn require_initialized(env: &Env) { + if !is_initialized(env) { + panic_with_error!(env, AccessError::NotInitialized); + } +} + +pub fn require_role(env: &Env, caller: &Address, role: u32) { + caller.require_auth(); + require_initialized(env); + + let ok = match role { + ROLE_ADMIN => { + let admin: Address = env.storage().instance().get(&KEY_ADMIN) + .unwrap_or_else(|| panic_with_error!(env, AccessError::NotInitialized)); + admin == caller.clone() + } + ROLE_OPERATOR => { + let operators: Vec
= env.storage().instance().get(&KEY_OPERATRS) + .unwrap_or_else(|| vec![env]); + operators.contains(caller) + } + ROLE_AUDITOR => { + let auditors: Vec
= env.storage().instance().get(&KEY_AUDITORS) + .unwrap_or_else(|| vec![env]); + auditors.contains(caller) + } + _ => false, + }; + + if !ok { + panic_with_error!(env, AccessError::Unauthorized); + } +} + +pub fn get_admin(env: &Env) -> Option
{ + env.storage().instance().get(&KEY_ADMIN) +} + +pub fn get_operators(env: &Env) -> Vec
{ + env.storage().instance().get(&KEY_OPERATRS).unwrap_or(vec![env]) +} + +pub fn get_auditors(env: &Env) -> Vec
{ + env.storage().instance().get(&KEY_AUDITORS).unwrap_or(vec![env]) +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, vec, Env}; + + #[soroban_sdk::contract] + pub struct TestContract; + + #[soroban_sdk::contractimpl] + impl TestContract {} + + fn setup(env: &Env) -> (Address, Address, Vec
, Vec
) { + let admin = Address::generate(env); + let op1 = Address::generate(env); + let au1 = Address::generate(env); + let operators = vec![env, op1.clone()]; + let auditors = vec![env, au1.clone()]; + (admin, op1, operators, auditors) + } + + #[test] + fn initialize_sets_admin() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TestContract); + let (admin, _, operators, auditors) = setup(&env); + env.as_contract(&contract_id, || { + initialize(&env, &admin, operators, auditors); + assert!(is_initialized(&env)); + assert_eq!(get_admin(&env).unwrap(), admin); + }); + } + + #[test] + #[should_panic] + fn double_init_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TestContract); + let (admin, _, operators, auditors) = setup(&env); + env.as_contract(&contract_id, || { + initialize(&env, &admin, operators.clone(), auditors.clone()); + initialize(&env, &admin, operators, auditors); + }); + } + + #[test] + fn admin_role_authorized() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TestContract); + let (admin, _, operators, auditors) = setup(&env); + env.as_contract(&contract_id, || { + initialize(&env, &admin, operators, auditors); + require_role(&env, &admin, ROLE_ADMIN); + }); + } + + #[test] + fn operator_role_authorized() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TestContract); + let (admin, op1, operators, auditors) = setup(&env); + env.as_contract(&contract_id, || { + initialize(&env, &admin, operators, auditors); + require_role(&env, &op1, ROLE_OPERATOR); + }); + } + + #[test] + #[should_panic] + fn operator_not_admin() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TestContract); + let (admin, op1, operators, auditors) = setup(&env); + let rogue = Address::generate(&env); + env.as_contract(&contract_id, || { + initialize(&env, &admin, operators, auditors); + require_role(&env, &rogue, ROLE_ADMIN); + }); + } + + #[test] + fn auditor_role_authorized() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TestContract); + let (admin, _, operators, auditors) = setup(&env); + let au1 = auditors.get(0).unwrap(); + env.as_contract(&contract_id, || { + initialize(&env, &admin, operators, auditors); + require_role(&env, &au1, ROLE_AUDITOR); + }); + } +} diff --git a/engine-core/src/core/guards.rs b/engine-core/src/core/guards.rs new file mode 100644 index 0000000..fb1fea6 --- /dev/null +++ b/engine-core/src/core/guards.rs @@ -0,0 +1,103 @@ +use soroban_sdk::{contracterror, panic_with_error, symbol_short, Env, Symbol}; + +const KEY_REENTRY: Symbol = symbol_short!("C_REENTR"); + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum GuardError { + ReentrancyDetected = 1, +} + +pub struct ReentrancyGuard; + +impl ReentrancyGuard { + pub fn new(env: &Env) -> Self { + if env.storage().temporary().has(&KEY_REENTRY) { + panic_with_error!(env, GuardError::ReentrancyDetected); + } + env.storage().temporary().set(&KEY_REENTRY, &true); + Self + } +} + +impl Drop for ReentrancyGuard { + fn drop(&mut self) { + } +} + +pub fn with_guard(env: &Env, f: impl FnOnce() -> T) -> T { + let _guard = ReentrancyGuard::new(env); + let result = f(); + env.storage().temporary().remove(&KEY_REENTRY); + result +} + +pub fn enter_guard(env: &Env) { + if env.storage().temporary().has(&KEY_REENTRY) { + panic_with_error!(env, GuardError::ReentrancyDetected); + } + env.storage().temporary().set(&KEY_REENTRY, &true); +} + +pub fn exit_guard(env: &Env) { + env.storage().temporary().remove(&KEY_REENTRY); +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::Env; + + #[soroban_sdk::contract] + pub struct TestContract; + + #[soroban_sdk::contractimpl] + impl TestContract {} + + #[test] + fn guard_enter_and_exit() { + let env = Env::default(); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + enter_guard(&env); + assert!(env.storage().temporary().has(&KEY_REENTRY)); + exit_guard(&env); + assert!(!env.storage().temporary().has(&KEY_REENTRY)); + }); + } + + #[test] + #[should_panic] + fn double_enter_detected() { + let env = Env::default(); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + enter_guard(&env); + enter_guard(&env); + }); + } + + #[test] + fn with_guard_wraps_call() { + let env = Env::default(); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + let result = with_guard(&env, || 42); + assert_eq!(result, 42); + assert!(!env.storage().temporary().has(&KEY_REENTRY)); + }); + } + + #[test] + fn reentrancy_guard_drop_releases() { + let env = Env::default(); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + { + let _guard = ReentrancyGuard::new(&env); + assert!(env.storage().temporary().has(&KEY_REENTRY)); + } + assert!(!env.storage().temporary().has(&KEY_REENTRY)); + }); + } +} diff --git a/engine-core/src/core/state.rs b/engine-core/src/core/state.rs new file mode 100644 index 0000000..a113fb4 --- /dev/null +++ b/engine-core/src/core/state.rs @@ -0,0 +1,144 @@ +use soroban_sdk::{contracterror, panic_with_error, symbol_short, BytesN, Env, Symbol}; +use crate::event_struct::{MOD_CORE, ACT_TRANSITION}; +use crate::event_utils::publish_event; + +const KEY_STATE: Symbol = symbol_short!("PROTO_ST"); + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum StateError { + InvalidTransition = 1, + AlreadyInState = 2, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum ProtocolState { + Pending = 0, + Active = 1, + Settled = 2, + Closed = 3, +} + +impl ProtocolState { + fn from_u32(v: u32) -> Self { + match v { + 0 => ProtocolState::Pending, + 1 => ProtocolState::Active, + 2 => ProtocolState::Settled, + 3 => ProtocolState::Closed, + _ => ProtocolState::Pending, + } + } +} + +fn allowed_transition(from: ProtocolState, to: ProtocolState) -> bool { + match (from, to) { + (ProtocolState::Pending, ProtocolState::Active) => true, + (ProtocolState::Pending, ProtocolState::Closed) => true, + (ProtocolState::Active, ProtocolState::Settled) => true, + (ProtocolState::Active, ProtocolState::Closed) => true, + (ProtocolState::Settled, ProtocolState::Closed) => true, + _ => false, + } +} + +pub fn init(env: &Env) { + env.storage().instance().set(&KEY_STATE, &ProtocolState::Pending); +} + +pub fn get_state(env: &Env) -> ProtocolState { + let raw: u32 = env.storage().instance().get(&KEY_STATE).unwrap_or(0); + ProtocolState::from_u32(raw) +} + +pub fn transition_to(env: &Env, next: ProtocolState) { + let current = get_state(env); + if current == next { + panic_with_error!(env, StateError::AlreadyInState); + } + if !allowed_transition(current, next) { + panic_with_error!(env, StateError::InvalidTransition); + } + env.storage().instance().set(&KEY_STATE, &next); + publish_event( + env, + MOD_CORE | ACT_TRANSITION, + current as u64, + BytesN::from_array(env, &[0u8; 32]), + ); +} + +pub fn require_state(env: &Env, expected: ProtocolState) { + let current = get_state(env); + if current != expected { + panic_with_error!(env, StateError::InvalidTransition); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::Env; + + #[soroban_sdk::contract] + pub struct TestContract; + + #[soroban_sdk::contractimpl] + impl TestContract {} + + #[test] + fn initial_state_is_pending() { + let env = Env::default(); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + init(&env); + assert_eq!(get_state(&env), ProtocolState::Pending); + }); + } + + #[test] + fn transition_pending_to_active() { + let env = Env::default(); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + init(&env); + transition_to(&env, ProtocolState::Active); + assert_eq!(get_state(&env), ProtocolState::Active); + }); + } + + #[test] + fn full_lifecycle() { + let env = Env::default(); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + init(&env); + transition_to(&env, ProtocolState::Active); + transition_to(&env, ProtocolState::Settled); + transition_to(&env, ProtocolState::Closed); + assert_eq!(get_state(&env), ProtocolState::Closed); + }); + } + + #[test] + #[should_panic] + fn invalid_pending_to_settled_rejected() { + let env = Env::default(); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + init(&env); + transition_to(&env, ProtocolState::Settled); + }); + } + + #[test] + #[should_panic] + fn require_state_panics_on_mismatch() { + let env = Env::default(); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + init(&env); + require_state(&env, ProtocolState::Active); + }); + } +} diff --git a/engine-core/src/event_struct.rs b/engine-core/src/event_struct.rs index eddfb89..0267a33 100644 --- a/engine-core/src/event_struct.rs +++ b/engine-core/src/event_struct.rs @@ -46,6 +46,8 @@ pub const MOD_TREASURY: u32 = 0x03; pub const MOD_CB: u32 = 0x04; pub const MOD_BURN: u32 = 0x05; pub const MOD_RECOVERY: u32 = 0x06; +pub const MOD_FEE: u32 = 0x07; +pub const MOD_CORE: u32 = 0x08; pub const MOD_FEE: u32 = 0x07; @@ -76,6 +78,9 @@ pub struct CompactEvent { pub value: u64, pub const ACT_TRIGGERED: u32 = 0x0A << 8; +pub const ACT_FEE: u32 = 0x0B << 8; +pub const ACT_INIT: u32 = 0x0C << 8; +pub const ACT_TRANSITION: u32 = 0x0D << 8; pub const ACT_FEE: u32 = 0x0B << 8; pub const ACT_UPGRADE: u32 = 0x0C << 8; pub const ACT_UPDATE: u32 = 0x0D << 8; diff --git a/engine-core/src/lib.rs b/engine-core/src/lib.rs index 8ae9884..1b14ef7 100644 --- a/engine-core/src/lib.rs +++ b/engine-core/src/lib.rs @@ -17,6 +17,9 @@ pub mod treasury; pub mod types; pub mod upgrade; pub mod version; +pub mod event_struct; +pub mod event_utils; +pub mod core; #[cfg(test)] mod governance_tests;