From d8de6770196fe6324667b59b7dd28359f01ba177 Mon Sep 17 00:00:00 2001 From: Dopezapha Date: Wed, 27 May 2026 19:05:10 +0100 Subject: [PATCH] Implement contract security guards and advanced dependencies --- contract/src/lib.rs | 549 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 484 insertions(+), 65 deletions(-) diff --git a/contract/src/lib.rs b/contract/src/lib.rs index e6227ff5..a049edef 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -24,6 +24,9 @@ pub enum Error { ArgsTooMany = 13, ArgsTooLarge = 14, InvalidPayload = 15, + ReentrantCall = 16, + DependencyLimitExceeded = 17, + DependencyDepthExceeded = 18, } /// Maximum number of arguments allowed in a task payload @@ -32,6 +35,10 @@ const MAX_ARGS_COUNT: u32 = 32; /// Maximum serialized size of arguments in bytes (approx 4KB limit for Soroban) const MAX_ARGS_SIZE_BYTES: u32 = 4096; +const FIXED_EXECUTION_FEE: i128 = 100; +const MAX_DEPENDENCIES_PER_TASK: u32 = 16; +const MAX_DEPENDENCY_DEPTH: u32 = 16; + #[contracttype] #[derive(Clone, Debug)] pub struct TaskConfig { @@ -55,6 +62,38 @@ pub struct TaskDependency { pub depends_on: u64, } +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ExecutionOutcome { + NeverRun, + Success, + Skipped, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaskExecutionStatus { + pub outcome: ExecutionOutcome, + pub completed_at: u64, + pub run_count: u64, +} + +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DependencyOutcome { + AnyCompletion, + Success, + Skipped, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DependencyRule { + pub task_id: u64, + pub required_outcome: DependencyOutcome, + pub min_completed_at: u64, +} + #[contracttype] pub enum DataKey { Task(u64), @@ -62,6 +101,9 @@ pub enum DataKey { ActiveTasks, Token, TaskDependencies(u64), + TaskStatus(u64), + DependencyRules(u64), + ReentrancyLock, } fn get_active_task_ids(env: &Env) -> Vec { @@ -83,11 +125,7 @@ fn add_active_task_id(env: &Env, task_id: u64) { let mut i = 0; while i < len { - if active - .get(i) - .expect("active task index out of bounds") - == task_id - { + if active.get(i).expect("active task index out of bounds") == task_id { return; } i += 1; @@ -117,6 +155,25 @@ fn remove_active_task_id(env: &Env, task_id: u64) { set_active_task_ids(env, &filtered); } +fn enter_security_guard(env: &Env) { + if env + .storage() + .instance() + .get(&DataKey::ReentrancyLock) + .unwrap_or(false) + { + panic_with_error!(env, Error::ReentrantCall); + } + + env.storage() + .instance() + .set(&DataKey::ReentrancyLock, &true); +} + +fn exit_security_guard(env: &Env) { + env.storage().instance().remove(&DataKey::ReentrancyLock); +} + #[contracttype] #[derive(Clone, Debug)] pub struct ExecutableTask { @@ -139,19 +196,19 @@ impl SoroTaskContract { /// Returns Ok(()) if valid, or an error code if validation fails. fn validate_args(args: &Vec) -> Result<(), Error> { let args_count = args.len(); - + // Validate argument count if args_count > MAX_ARGS_COUNT { return Err(Error::ArgsTooMany); } - + // Estimate serialized size (each Val is at least 8 bytes + overhead) // This is a conservative estimate since Val representation varies let estimated_size = args_count * 64; // 64 bytes per Val as upper bound if estimated_size > MAX_ARGS_SIZE_BYTES { return Err(Error::ArgsTooLarge); } - + Ok(()) } @@ -185,6 +242,8 @@ impl SoroTaskContract { // * That IDs are stable across contract re-deployments — a fresh // deployment resets DataKey::Counter to 0. pub fn register(env: Env, mut config: TaskConfig) -> u64 { + enter_security_guard(&env); + // Ensure the creator has authorized the registration config.creator.require_auth(); @@ -212,16 +271,29 @@ impl SoroTaskContract { env.storage() .persistent() .set(&DataKey::Task(counter), &config); + env.storage().persistent().set( + &DataKey::TaskStatus(counter), + &TaskExecutionStatus { + outcome: ExecutionOutcome::NeverRun, + completed_at: 0, + run_count: 0, + }, + ); // Add to the active task index for efficient monitoring. add_active_task_id(&env, counter); // Emit TaskRegistered event env.events().publish( - (Symbol::new(&env, "TaskRegistered"), Symbol::new(&env, "v1"), counter), + ( + Symbol::new(&env, "TaskRegistered"), + Symbol::new(&env, "v1"), + counter, + ), config.creator.clone(), ); + exit_security_guard(&env); counter } @@ -243,7 +315,11 @@ impl SoroTaskContract { .get(i) .expect("active task index out of bounds") .clone(); - if let Some(config) = env.storage().persistent().get::(&DataKey::Task(task_id)) { + if let Some(config) = env + .storage() + .persistent() + .get::(&DataKey::Task(task_id)) + { if config.is_active && now >= config.last_run + config.interval { executable.push_back(ExecutableTask { task_id, @@ -260,6 +336,7 @@ impl SoroTaskContract { } pub fn pause_task(env: Env, task_id: u64) { + enter_security_guard(&env); let task_key = DataKey::Task(task_id); let mut config: TaskConfig = env .storage() @@ -279,12 +356,18 @@ impl SoroTaskContract { remove_active_task_id(&env, task_id); env.events().publish( - (Symbol::new(&env, "TaskPaused"), Symbol::new(&env, "v1"), task_id), + ( + Symbol::new(&env, "TaskPaused"), + Symbol::new(&env, "v1"), + task_id, + ), config.creator.clone(), ); + exit_security_guard(&env); } pub fn resume_task(env: Env, task_id: u64) { + enter_security_guard(&env); let task_key = DataKey::Task(task_id); let mut config: TaskConfig = env .storage() @@ -304,9 +387,14 @@ impl SoroTaskContract { add_active_task_id(&env, task_id); env.events().publish( - (Symbol::new(&env, "TaskResumed"), Symbol::new(&env, "v1"), task_id), + ( + Symbol::new(&env, "TaskResumed"), + Symbol::new(&env, "v1"), + task_id, + ), config.creator.clone(), ); + exit_security_guard(&env); } pub fn monitor_paginated(env: Env, start_id: u64, limit: u64) -> Vec { @@ -347,7 +435,11 @@ impl SoroTaskContract { break; } - if let Some(config) = env.storage().persistent().get::(&DataKey::Task(task_id)) { + if let Some(config) = env + .storage() + .persistent() + .get::(&DataKey::Task(task_id)) + { if config.is_active && now >= config.last_run + config.interval { executable.push_back(ExecutableTask { task_id, @@ -383,6 +475,7 @@ impl SoroTaskContract { /// cross-contract call returns, guaranteeing it only reflects completed /// executions. pub fn execute(env: Env, keeper: Address, task_id: u64) { + enter_security_guard(&env); keeper.require_auth(); let task_key = DataKey::Task(task_id); let mut config: TaskConfig = env @@ -400,6 +493,7 @@ impl SoroTaskContract { } if env.ledger().timestamp() < config.last_run + config.interval { + exit_security_guard(&env); return; } @@ -438,7 +532,7 @@ impl SoroTaskContract { // ── Fee validation & calculation (MVP: fixed fee) ────────────── // For MVP use a fixed fee per execution. Ensure the task has // sufficient gas_balance before attempting execution. - let fee: i128 = 100; // fixed fee units (token smallest unit) + let fee: i128 = FIXED_EXECUTION_FEE; if config.gas_balance < fee { panic_with_error!(&env, Error::InsufficientBalance); } @@ -468,15 +562,26 @@ impl SoroTaskContract { // ── State update ──────────────────────────────────────────── config.last_run = env.ledger().timestamp(); env.storage().persistent().set(&task_key, &config); + Self::set_task_status(&env, task_id, ExecutionOutcome::Success); // Emit keeper paid event - env.events() - .publish((Symbol::new(&env, "KeeperPaid"), Symbol::new(&env, "v1"), task_id), (keeper, fee)); + env.events().publish( + ( + Symbol::new(&env, "KeeperPaid"), + Symbol::new(&env, "v1"), + task_id, + ), + (keeper, fee), + ); + } else { + Self::set_task_status(&env, task_id, ExecutionOutcome::Skipped); } + exit_security_guard(&env); } /// Initializes the contract with a gas token. pub fn init(env: Env, token: Address) { + enter_security_guard(&env); if env.storage().instance().has(&DataKey::Token) { panic_with_error!(&env, Error::AlreadyInitialized); } @@ -484,13 +589,18 @@ impl SoroTaskContract { // Emit initialized event env.events().publish( - (Symbol::new(&env, "ContractInitialized"), Symbol::new(&env, "v1")), + ( + Symbol::new(&env, "ContractInitialized"), + Symbol::new(&env, "v1"), + ), token, ); + exit_security_guard(&env); } /// Deposits gas tokens to a task's balance. pub fn deposit_gas(env: Env, task_id: u64, from: Address, amount: i128) { + enter_security_guard(&env); from.require_auth(); let task_key = DataKey::Task(task_id); @@ -515,13 +625,21 @@ impl SoroTaskContract { env.storage().persistent().set(&task_key, &config); // Emit event - env.events() - .publish((Symbol::new(&env, "GasDeposited"), Symbol::new(&env, "v1"), task_id), (from, amount)); + env.events().publish( + ( + Symbol::new(&env, "GasDeposited"), + Symbol::new(&env, "v1"), + task_id, + ), + (from, amount), + ); + exit_security_guard(&env); } /// Withdraws gas tokens from a task's balance. /// Only the task creator can withdraw. pub fn withdraw_gas(env: Env, task_id: u64, amount: i128) { + enter_security_guard(&env); let task_key = DataKey::Task(task_id); let mut config: TaskConfig = env .storage() @@ -552,13 +670,19 @@ impl SoroTaskContract { // Emit event env.events().publish( - (Symbol::new(&env, "GasWithdrawn"), Symbol::new(&env, "v1"), task_id), + ( + Symbol::new(&env, "GasWithdrawn"), + Symbol::new(&env, "v1"), + task_id, + ), (config.creator.clone(), amount), ); + exit_security_guard(&env); } /// Cancels a task, refunds remaining gas, and removes it from storage. pub fn cancel_task(env: Env, task_id: u64) { + enter_security_guard(&env); let task_key = DataKey::Task(task_id); let config: TaskConfig = env .storage() @@ -572,13 +696,13 @@ impl SoroTaskContract { // Refund: Automatically withdraw all remaining gas_balance to the creator if config.gas_balance > 0 { if env.storage().instance().has(&DataKey::Token) { - let token_address: Address = env - .storage() - .instance() - .get(&DataKey::Token) - .unwrap(); + let token_address: Address = env.storage().instance().get(&DataKey::Token).unwrap(); let token_client = soroban_sdk::token::Client::new(&env, &token_address); - token_client.transfer(&env.current_contract_address(), &config.creator, &config.gas_balance); + token_client.transfer( + &env.current_contract_address(), + &config.creator, + &config.gas_balance, + ); } } @@ -587,13 +711,24 @@ impl SoroTaskContract { // Cleanup: Remove the task from storage env.storage().persistent().remove(&task_key); + env.storage() + .persistent() + .remove(&DataKey::TaskStatus(task_id)); + env.storage() + .persistent() + .remove(&DataKey::DependencyRules(task_id)); let refund_amount = config.gas_balance; // Events: TaskCancelled(u64, i128) with data: (creator, amount_refunded) env.events().publish( - (Symbol::new(&env, "TaskCancelled"), Symbol::new(&env, "v1"), task_id), + ( + Symbol::new(&env, "TaskCancelled"), + Symbol::new(&env, "v1"), + task_id, + ), (config.creator.clone(), refund_amount), ); + exit_security_guard(&env); } /// Returns the global gas token address. @@ -604,21 +739,47 @@ impl SoroTaskContract { .expect("Not initialized") } + pub fn get_task_status(env: Env, task_id: u64) -> TaskExecutionStatus { + Self::task_status(&env, task_id) + } + + pub fn get_dependency_rules(env: Env, task_id: u64) -> Vec { + Self::dependency_rules(&env, task_id) + } + /// Adds a dependency relationship between tasks. /// task_id will be blocked by depends_on_task_id. pub fn add_dependency(env: Env, task_id: u64, depends_on_task_id: u64) { + Self::add_dependency_with_rule( + env, + task_id, + depends_on_task_id, + DependencyOutcome::Success, + 0, + ); + } + + /// Adds a dependency with an explicit required outcome and minimum completion timestamp. + pub fn add_dependency_with_rule( + env: Env, + task_id: u64, + depends_on_task_id: u64, + required_outcome: DependencyOutcome, + min_completed_at: u64, + ) { + enter_security_guard(&env); // Validate both tasks exist let task: TaskConfig = env .storage() .persistent() .get(&DataKey::Task(task_id)) .expect("Task not found"); - + let depends_on_task: Option = env .storage() .persistent() .get(&DataKey::Task(depends_on_task_id)); - + if depends_on_task.is_none() { panic_with_error!(&env, Error::DependencyNotFound); } @@ -639,21 +800,61 @@ impl SoroTaskContract { // Get current blocked_by list let mut updated_task = task.clone(); if !updated_task.blocked_by.contains(&depends_on_task_id) { + if updated_task.blocked_by.len() >= MAX_DEPENDENCIES_PER_TASK { + panic_with_error!(&env, Error::DependencyLimitExceeded); + } + updated_task.blocked_by.push_back(depends_on_task_id); env.storage() .persistent() .set(&DataKey::Task(task_id), &updated_task); + } + let mut rules = Self::dependency_rules(&env, task_id); + let rule = DependencyRule { + task_id: depends_on_task_id, + required_outcome, + min_completed_at, + }; + let mut replaced = false; + for i in 0..rules.len() { + if rules + .get(i) + .expect("dependency rule index out of bounds") + .task_id + == depends_on_task_id + { + rules.set(i, rule.clone()); + replaced = true; + break; + } + } + + if !replaced { + rules.push_back(rule); + } + env.storage() + .persistent() + .set(&DataKey::DependencyRules(task_id), &rules); + Self::validate_dependency_depth(&env, task_id); + + if !task.blocked_by.contains(&depends_on_task_id) { // Emit event env.events().publish( - (Symbol::new(&env, "DependencyAdded"), Symbol::new(&env, "v1"), task_id), + ( + Symbol::new(&env, "DependencyAdded"), + Symbol::new(&env, "v1"), + task_id, + ), depends_on_task_id, ); } + exit_security_guard(&env); } /// Removes a dependency relationship between tasks. pub fn remove_dependency(env: Env, task_id: u64, depends_on_task_id: u64) { + enter_security_guard(&env); let task: TaskConfig = env .storage() .persistent() @@ -665,7 +866,7 @@ impl SoroTaskContract { let mut updated_task = task.clone(); let mut new_blocked_by = Vec::new(&env); - + for i in 0..updated_task.blocked_by.len() { let dep = updated_task.blocked_by.get(i).unwrap(); if dep != depends_on_task_id { @@ -678,45 +879,162 @@ impl SoroTaskContract { .persistent() .set(&DataKey::Task(task_id), &updated_task); + let existing_rules = Self::dependency_rules(&env, task_id); + let mut updated_rules = Vec::new(&env); + for i in 0..existing_rules.len() { + let rule = existing_rules + .get(i) + .expect("dependency rule index out of bounds"); + if rule.task_id != depends_on_task_id { + updated_rules.push_back(rule); + } + } + env.storage() + .persistent() + .set(&DataKey::DependencyRules(task_id), &updated_rules); + // Emit event env.events().publish( - (Symbol::new(&env, "DependencyRemoved"), Symbol::new(&env, "v1"), task_id), + ( + Symbol::new(&env, "DependencyRemoved"), + Symbol::new(&env, "v1"), + task_id, + ), depends_on_task_id, ); + exit_security_guard(&env); } /// Gets all dependencies for a task (tasks that block this task). pub fn get_dependencies(env: Env, task_id: u64) -> Vec { - let task: Option = env - .storage() - .persistent() - .get(&DataKey::Task(task_id)); - + let task: Option = env.storage().persistent().get(&DataKey::Task(task_id)); + match task { Some(t) => t.blocked_by, None => Vec::new(&env), } } - /// Checks if a task is blocked by any incomplete dependencies. - pub fn is_task_blocked(env: Env, task_id: u64) -> bool { - let task: Option = env + fn task_status(env: &Env, task_id: u64) -> TaskExecutionStatus { + env.storage() + .persistent() + .get(&DataKey::TaskStatus(task_id)) + .unwrap_or(TaskExecutionStatus { + outcome: ExecutionOutcome::NeverRun, + completed_at: 0, + run_count: 0, + }) + } + + fn set_task_status(env: &Env, task_id: u64, outcome: ExecutionOutcome) { + let previous = Self::task_status(env, task_id); + env.storage().persistent().set( + &DataKey::TaskStatus(task_id), + &TaskExecutionStatus { + outcome, + completed_at: env.ledger().timestamp(), + run_count: previous.run_count.saturating_add(1), + }, + ); + } + + fn dependency_rules(env: &Env, task_id: u64) -> Vec { + if let Some(rules) = env .storage() .persistent() - .get(&DataKey::Task(task_id)); - - if let Some(t) = task { - for i in 0..t.blocked_by.len() { - let dep_id = t.blocked_by.get(i).unwrap(); - let dep_task: Option = env - .storage() - .persistent() - .get(&DataKey::Task(dep_id)); - - // If dependency doesn't exist or hasn't run yet, task is blocked - if dep_task.is_none() || dep_task.unwrap().last_run == 0 { - return true; - } + .get::>(&DataKey::DependencyRules(task_id)) + { + return rules; + } + + let mut rules = Vec::new(env); + if let Some(task) = env + .storage() + .persistent() + .get::(&DataKey::Task(task_id)) + { + for i in 0..task.blocked_by.len() { + rules.push_back(DependencyRule { + task_id: task + .blocked_by + .get(i) + .expect("dependency index out of bounds"), + required_outcome: DependencyOutcome::Success, + min_completed_at: 0, + }); + } + } + + rules + } + + fn dependency_rule_satisfied(env: &Env, rule: &DependencyRule) -> bool { + if !env.storage().persistent().has(&DataKey::Task(rule.task_id)) { + return false; + } + + let status = Self::task_status(env, rule.task_id); + if status.completed_at < rule.min_completed_at { + return false; + } + + match rule.required_outcome { + DependencyOutcome::AnyCompletion => status.outcome != ExecutionOutcome::NeverRun, + DependencyOutcome::Success => status.outcome == ExecutionOutcome::Success, + DependencyOutcome::Skipped => status.outcome == ExecutionOutcome::Skipped, + } + } + + /// Checks if a task is blocked by any incomplete dependencies. + pub fn is_task_blocked(env: Env, task_id: u64) -> bool { + let rules = Self::dependency_rules(&env, task_id); + for i in 0..rules.len() { + let rule = rules.get(i).expect("dependency rule index out of bounds"); + if !Self::dependency_rule_satisfied(&env, &rule) { + return true; + } + } + false + } + + pub fn is_dependency_satisfied(env: Env, task_id: u64, depends_on_task_id: u64) -> bool { + let rules = Self::dependency_rules(&env, task_id); + for i in 0..rules.len() { + let rule = rules.get(i).expect("dependency rule index out of bounds"); + if rule.task_id == depends_on_task_id { + return Self::dependency_rule_satisfied(&env, &rule); + } + } + false + } + + fn validate_dependency_depth(env: &Env, task_id: u64) { + let mut visited = Vec::new(env); + if Self::exceeds_dependency_depth(env, task_id, 0, &mut visited) { + panic_with_error!(env, Error::DependencyDepthExceeded); + } + } + + fn exceeds_dependency_depth( + env: &Env, + task_id: u64, + depth: u32, + visited: &mut Vec, + ) -> bool { + if depth > MAX_DEPENDENCY_DEPTH { + return true; + } + + if visited.contains(&task_id) { + return false; + } + visited.push_back(task_id); + + let rules = Self::dependency_rules(env, task_id); + for i in 0..rules.len() { + let rule = rules.get(i).expect("dependency rule index out of bounds"); + if Self::exceeds_dependency_depth(env, rule.task_id, depth + 1, visited) { + return true; } } false @@ -725,30 +1043,31 @@ impl SoroTaskContract { /// Helper to detect circular dependencies using DFS. fn would_create_cycle(env: &Env, task_id: u64, new_dependency: u64) -> bool { let mut visited = Vec::new(env); - Self::has_path_to(env, new_dependency, task_id, &mut visited) + Self::has_path_to(env, new_dependency, task_id, &mut visited, 0) } /// DFS helper to check if there's a path from 'from' to 'to'. - fn has_path_to(env: &Env, from: u64, to: u64, visited: &mut Vec) -> bool { + fn has_path_to(env: &Env, from: u64, to: u64, visited: &mut Vec, depth: u32) -> bool { if from == to { return true; } + if depth > MAX_DEPENDENCY_DEPTH { + panic_with_error!(env, Error::DependencyDepthExceeded); + } + if visited.contains(&from) { return false; } visited.push_back(from); - let task: Option = env - .storage() - .persistent() - .get(&DataKey::Task(from)); + let task: Option = env.storage().persistent().get(&DataKey::Task(from)); if let Some(t) = task { for i in 0..t.blocked_by.len() { let dep = t.blocked_by.get(i).unwrap(); - if Self::has_path_to(env, dep, to, visited) { + if Self::has_path_to(env, dep, to, visited, depth + 1) { return true; } } @@ -799,6 +1118,11 @@ mod tests { pub fn add(_env: Env, a: i64, b: i64) -> i64 { a + b } + + pub fn reenter_pause(env: Env, contract_id: Address, task_id: u64) { + let client = SoroTaskContractClient::new(&env, &contract_id); + client.pause_task(&task_id); + } } // ── Resolver contracts (separate sub-modules) ─────────────────────── @@ -891,7 +1215,11 @@ mod tests { env.storage().persistent().set(&task_key, &updated); env.events().publish( - (Symbol::new(&env, "TaskUpdated"), Symbol::new(&env, "v1"), task_id), + ( + Symbol::new(&env, "TaskUpdated"), + Symbol::new(&env, "v1"), + task_id, + ), updated.creator.clone(), ); } @@ -1627,7 +1955,7 @@ mod tests { let keeper = Address::generate(&env); set_timestamp(&env, 3600); let result = client.try_execute(&keeper, &task2_id); - + assert_eq!( result, Err(Ok(soroban_sdk::Error::from_contract_error( @@ -1636,6 +1964,90 @@ mod tests { ); } + #[test] + fn test_dependency_rule_can_require_skipped_outcome() { + let (env, id) = setup(); + let client = SoroTaskContractClient::new(&env, &id); + + let target = env.register_contract(None, MockTarget); + let resolver = env.register_contract(None, resolver_false::MockResolverFalse); + + let dependency_cfg = TaskConfig { + resolver: Some(resolver), + ..base_config(&env, target.clone()) + }; + let dependency_id = client.register(&dependency_cfg); + let dependent_id = client.register(&base_config(&env, target)); + + client.add_dependency_with_rule( + &dependent_id, + &dependency_id, + &DependencyOutcome::Skipped, + &0, + ); + assert!(client.is_task_blocked(&dependent_id)); + + let keeper = Address::generate(&env); + set_timestamp(&env, 3_600); + client.execute(&keeper, &dependency_id); + + let status = client.get_task_status(&dependency_id); + assert_eq!(status.outcome, ExecutionOutcome::Skipped); + assert!(client.is_dependency_satisfied(&dependent_id, &dependency_id)); + assert!(!client.is_task_blocked(&dependent_id)); + } + + #[test] + fn test_dependency_rule_honors_min_completion_timestamp() { + let (env, id) = setup(); + let client = SoroTaskContractClient::new(&env, &id); + + let target = env.register_contract(None, MockTarget); + let dependency_id = client.register(&base_config(&env, target.clone())); + let dependent_id = client.register(&base_config(&env, target)); + let keeper = Address::generate(&env); + + set_timestamp(&env, 3_600); + client.execute(&keeper, &dependency_id); + client.add_dependency_with_rule( + &dependent_id, + &dependency_id, + &DependencyOutcome::Success, + &3_601, + ); + + assert!(client.is_task_blocked(&dependent_id)); + assert!(!client.is_dependency_satisfied(&dependent_id, &dependency_id)); + } + + #[test] + fn test_reentrant_state_mutation_is_rejected() { + let (env, id) = setup(); + let client = SoroTaskContractClient::new(&env, &id); + + let target = env.register_contract(None, MockTarget); + let victim_id = client.register(&base_config(&env, target.clone())); + + let mut args: Vec = Vec::new(&env); + args.push_back(id.clone().into_val(&env)); + args.push_back(victim_id.into_val(&env)); + + let malicious_cfg = TaskConfig { + function: Symbol::new(&env, "reenter_pause"), + args, + ..base_config(&env, target) + }; + let malicious_id = client.register(&malicious_cfg); + + let keeper = Address::generate(&env); + set_timestamp(&env, 3_600); + let result = client.try_execute(&keeper, &malicious_id); + + assert!(result.is_err(), "reentrant pause must abort execution"); + assert!(client.get_task(&victim_id).unwrap().is_active); + assert_eq!(client.get_task(&malicious_id).unwrap().last_run, 0); + } + #[test] fn test_dependency_not_found() { let (env, id) = setup(); @@ -1734,7 +2146,10 @@ mod tests { let id_a = client.register(&base_config(&env, target.clone())); let id_b = client.register(&base_config(&env, target)); - assert_ne!(id_a, id_b, "concurrent-style registrations must not share an ID"); + assert_ne!( + id_a, id_b, + "concurrent-style registrations must not share an ID" + ); assert_eq!(id_b, id_a + 1, "IDs must be strictly sequential"); } @@ -1789,7 +2204,11 @@ mod tests { registered_ids.push_back(task_id); } - assert_eq!(registered_ids.len(), n, "must have registered exactly {n} tasks"); + assert_eq!( + registered_ids.len(), + n, + "must have registered exactly {n} tasks" + ); for i in 0..registered_ids.len() { let task_id = registered_ids.get(i).unwrap();