diff --git a/contract/src/lib.rs b/contract/src/lib.rs
index a049edef..cdeb7993 100644
--- a/contract/src/lib.rs
+++ b/contract/src/lib.rs
@@ -17,17 +17,27 @@ pub enum Error {
TaskAlreadyActive = 7,
SelfDependency = 8,
DependencyNotFound = 9,
- CircularDependency = 10,
- DependencyBlocked = 11,
- AlreadyInitialized = 12,
+ TaskNotFound = 10,
+ CircularDependency = 11,
+ DependencyBlocked = 12,
+ AlreadyInitialized = 13,
// Payload validation errors
- ArgsTooMany = 13,
- ArgsTooLarge = 14,
- InvalidPayload = 15,
- ReentrantCall = 16,
- DependencyLimitExceeded = 17,
- DependencyDepthExceeded = 18,
-}
+ ArgsTooMany = 14,
+ ArgsTooLarge = 15,
+ InvalidPayload = 16,
+ ReentrantCall = 17,
+ DependencyLimitExceeded = 18,
+ DependencyDepthExceeded = 19,
+ // VRF-related errors
+ VrfOracleNotSet = 20,
+ InvalidVrfRequest = 21,
+ VrfRequestFailed = 22,
+ VrfAlreadyFulfilled = 23,
+ // Yield strategy-related errors
+ YieldStrategyNotInitialized = 24,
+ InvalidYieldStrategy = 25,
+ YieldHarvestFailed = 26,
+ InsufficientYield = 27,
/// Maximum number of arguments allowed in a task payload
const MAX_ARGS_COUNT: u32 = 32;
@@ -53,6 +63,8 @@ pub struct TaskConfig {
pub whitelist: Vec
,
pub is_active: bool,
pub blocked_by: Vec,
+ /// Optional yield strategy ID for automated yield harvesting
+ pub yield_strategy: Option,
}
#[contracttype]
@@ -94,6 +106,192 @@ pub struct DependencyRule {
pub min_completed_at: u64,
}
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct Portfolio {
+ pub creator: Address,
+ pub name: Vec,
+ pub description: Vec,
+ pub created_at: u64,
+ pub is_active: bool,
+ pub task_count: u64,
+}
+
+#[contracttype]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum PortfolioOperation {
+ Pause,
+ Resume,
+ Fund,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct StakingPool {
+ pub total_staked: i128,
+ pub stakers_count: u64,
+ pub reward_rate: i128,
+ pub last_reward_timestamp: u64,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+/// Portfolio statistics and analytics
+pub struct PortfolioStatistics {
+ /// Portfolio ID
+ pub portfolio_id: u64,
+ /// Total number of tasks in portfolio
+ pub task_count: u64,
+ /// Number of active tasks
+ pub active_task_count: u64,
+ /// Total number of task executions
+ pub total_executions: u64,
+ /// Timestamp of last task execution
+ pub last_execution_timestamp: u64,
+ /// Portfolio creation timestamp
+ pub created_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+/// Configuration for yield harvesting strategies
+pub struct YieldStrategyConfig {
+ /// Address of the DeFi protocol contract to harvest from
+ pub protocol_address: Address,
+ /// Function name to call for harvesting
+ pub harvest_function: Symbol,
+ /// Function name to call for compounding
+ pub compound_function: Symbol,
+ /// Additional arguments for harvest function
+ pub harvest_args: Vec,
+ /// Additional arguments for compound function
+ pub compound_args: Vec,
+ /// Minimum yield threshold to trigger harvest
+ pub min_yield_threshold: i128,
+ /// Maximum gas fee allowed for harvest operation
+ pub max_gas_fee: i128,
+ /// Strategy creation timestamp
+ pub created_at: u64,
+ /// Whether strategy is active
+ pub is_active: bool,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct StakingBalance {
+ pub address: Address,
+ pub amount: i128,
+ pub last_stake_timestamp: u64,
+ pub accumulated_rewards: i128,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct GovernanceProposal {
+ pub proposer: Address,
+ pub title: Vec,
+ pub description: Vec,
+ pub created_at: u64,
+ pub expires_at: u64,
+ pub status: ProposalStatus,
+ pub votes_for: i128,
+ pub votes_against: i128,
+ pub quorum: i128,
+}
+
+#[contracttype]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum ProposalStatus {
+ Active,
+ Passed,
+ Rejected,
+ Executed,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct VotingPower {
+ pub address: Address,
+ pub voting_power: i128,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct TokenomicsConfig {
+ pub staking_reward_rate: i128,
+ pub governance_quorum_percentage: i128,
+ pub governance_voting_period: u64,
+ pub fee_model: FeeModel,
+ pub min_fee: i128,
+ pub max_fee: i128,
+}
+
+#[contracttype]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum FeeModel {
+ Fixed,
+ Percentage,
+ Dynamic,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct VrfRequest {
+ pub request_id: u64,
+ pub task_id: u64,
+ pub requester: Address,
+ pub callback_function: Symbol,
+ pub callback_args: Vec,
+ pub status: VrfRequestStatus,
+ pub created_at: u64,
+}
+
+#[contracttype]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum VrfRequestStatus {
+ Pending,
+ Fulfilled,
+ Failed,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct VrfResponse {
+ pub request_id: u64,
+ pub random_number: i128,
+ pub proof: Vec,
+ pub fulfilled_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct VrfRequest {
+ pub request_id: u64,
+ pub task_id: u64,
+ pub requester: Address,
+ pub callback_function: Symbol,
+ pub callback_args: Vec,
+ pub status: VrfRequestStatus,
+ pub created_at: u64,
+}
+
+#[contracttype]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum VrfRequestStatus {
+ Pending,
+ Fulfilled,
+ Failed,
+}
+
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct VrfResponse {
+ pub request_id: u64,
+ pub random_number: i128,
+ pub proof: Vec,
+ pub fulfilled_at: u64,
+}
+
#[contracttype]
pub enum DataKey {
Task(u64),
@@ -103,7 +301,23 @@ pub enum DataKey {
TaskDependencies(u64),
TaskStatus(u64),
DependencyRules(u64),
+ Portfolio(u64),
+ PortfolioTasks(u64),
+ PortfolioCounter,
+ StakingPool,
+ StakingBalance(Address),
+ GovernanceProposal(u64),
+ GovernanceProposalCounter,
+ GovernanceVotingPower(Address),
+ TokenomicsConfig,
+ VrfOracleAddress,
+ VrfRequestCounter,
+ VrfRequests(u64),
+ VrfResponses(u64),
+ YieldStrategyCounter,
+ YieldStrategies(u64),
ReentrancyLock,
+ AdminAddress,
}
fn get_active_task_ids(env: &Env) -> Vec {
@@ -366,6 +580,233 @@ impl SoroTaskContract {
exit_security_guard(&env);
}
+ /// Requests randomness from the VRF oracle for a task.
+ /// The oracle will call back with the random number when ready.
+ pub fn request_vrf_randomness(
+ env: Env,
+ task_id: u64,
+ callback_function: Symbol,
+ callback_args: Vec,
+ ) {
+ enter_security_guard(&env);
+
+ // Check if VRF oracle is configured
+ let oracle_address: Address = env
+ .storage()
+ .instance()
+ .get(&DataKey::VrfOracleAddress)
+ .ok_or(Error::VrfOracleNotSet)
+ .expect("VRF oracle address not set");
+
+ let task_key = DataKey::Task(task_id);
+ let config: TaskConfig = env
+ .storage()
+ .persistent()
+ .get(&task_key)
+ .ok_or(Error::TaskNotFound)
+ .expect("Task not found");
+
+ // Only task creator can request VRF randomness
+ config.creator.require_auth();
+
+ // Validate callback function
+ if callback_function.to_string().is_empty() {
+ panic_with_error!(&env, Error::InvalidVrfRequest);
+ }
+
+ // Validate callback arguments size
+ if callback_args.len() > MAX_ARGS_COUNT {
+ panic_with_error!(&env, Error::ArgsTooMany);
+ }
+
+ // Get current request counter and increment
+ let mut request_counter: u64 = env
+ .storage()
+ .instance()
+ .get(&DataKey::VrfRequestCounter)
+ .unwrap_or(0);
+ request_counter += 1;
+ env.storage().instance().set(&DataKey::VrfRequestCounter, &request_counter);
+
+ // Create VRF request
+ let vrf_request = VrfRequest {
+ request_id: request_counter,
+ task_id,
+ requester: config.creator.clone(),
+ callback_function,
+ callback_args,
+ status: VrfRequestStatus::Pending,
+ created_at: env.ledger().timestamp(),
+ };
+
+ // Store VRF request
+ env.storage().persistent().set(&DataKey::VrfRequests(request_counter), &vrf_request);
+
+ // Emit VrfRequestCreated event
+ env.events().publish(
+ (
+ Symbol::new(&env, "VrfRequestCreated"),
+ Symbol::new(&env, "v1"),
+ request_counter,
+ ),
+ (task_id, config.creator.clone()),
+ );
+
+ exit_security_guard(&env);
+
+ /// Requests randomness from the VRF oracle for a task.
+ /// The oracle will call back with the random number when ready.
+ pub fn request_vrf_randomness(
+ env: Env,
+ task_id: u64,
+ callback_function: Symbol,
+ callback_args: Vec,
+ ) {
+ enter_security_guard(&env);
+
+ // Check if VRF oracle is configured
+ let oracle_address: Address = env
+ .storage()
+ .instance()
+ .get(&DataKey::VrfOracleAddress)
+ .ok_or(Error::VrfOracleNotSet)
+ .expect("VRF oracle address not set");
+
+ let task_key = DataKey::Task(task_id);
+ let config: TaskConfig = env
+ .storage()
+ .persistent()
+ .get(&task_key)
+ .ok_or(Error::TaskNotFound)
+ .expect("Task not found");
+
+ // Only task creator can request VRF randomness
+ config.creator.require_auth();
+
+ // Validate callback function
+ if callback_function.to_string().is_empty() {
+ panic_with_error!(&env, Error::InvalidVrfRequest);
+ }
+
+ // Validate callback arguments size
+ if callback_args.len() > MAX_ARGS_COUNT {
+ panic_with_error!(&env, Error::ArgsTooMany);
+ }
+
+ // Get current request counter and increment
+ let mut request_counter: u64 = env
+ .storage()
+ .instance()
+ .get(&DataKey::VrfRequestCounter)
+ .unwrap_or(0);
+ request_counter += 1;
+ env.storage().instance().set(&DataKey::VrfRequestCounter, &request_counter);
+
+ // Create VRF request
+ let vrf_request = VrfRequest {
+ request_id: request_counter,
+ task_id,
+ requester: config.creator.clone(),
+ callback_function,
+ callback_args,
+ status: VrfRequestStatus::Pending,
+ created_at: env.ledger().timestamp(),
+ };
+
+ // Store VRF request
+ env.storage().persistent().set(&DataKey::VrfRequests(request_counter), &vrf_request);
+
+ // Emit VrfRequestCreated event
+ env.events().publish(
+ (
+ Symbol::new(&env, "VrfRequestCreated"),
+ Symbol::new(&env, "v1"),
+ request_counter,
+ ),
+ (task_id, config.creator.clone()),
+ );
+
+ exit_security_guard(&env);
+ }
+
+ /// Fulfill a VRF request with a random number.
+ /// Called by the VRF oracle contract.
+ pub fn fulfill_vrf_request(
+ env: Env,
+ request_id: u64,
+ random_number: i128,
+ proof: Vec,
+ ) {
+ enter_security_guard(&env);
+
+ // Check if VRF oracle is configured
+ let oracle_address: Address = env
+ .storage()
+ .instance()
+ .get(&DataKey::VrfOracleAddress)
+ .expect("VRF oracle address not set");
+
+ // Only the VRF oracle can fulfill requests
+ let caller = Address::current(&env);
+ if caller != oracle_address {
+ panic_with_error!(&env, Error::Unauthorized);
+ }
+
+ // Get the VRF request
+ let vrf_request: VrfRequest = env
+ .storage()
+ .persistent()
+ .get(&DataKey::VrfRequests(request_id))
+ .ok_or(Error::VrfRequestFailed)
+ .expect("VRF request not found");
+
+ // Check if request is pending
+ if vrf_request.status != VrfRequestStatus::Pending {
+ panic_with_error!(&env, Error::VrfAlreadyFulfilled);
+ }
+
+ // Validate random number
+ if random_number < 0 {
+ panic_with_error!(&env, Error::VrfRequestFailed);
+ }
+
+ // Validate proof
+ if proof.len() == 0 {
+ panic_with_error!(&env, Error::VrfRequestFailed);
+ }
+ if proof.len() > 1024 {
+ panic_with_error!(&env, Error::VrfRequestFailed);
+ }
+
+ // Create VRF response
+ let vrf_response = VrfResponse {
+ request_id,
+ random_number,
+ proof,
+ fulfilled_at: env.ledger().timestamp(),
+ };
+
+ // Update request status to fulfilled
+ let mut updated_request = vrf_request.clone();
+ updated_request.status = VrfRequestStatus::Fulfilled;
+ env.storage().persistent().set(&DataKey::VrfRequests(request_id), &updated_request);
+
+ // Store VRF response
+ env.storage().persistent().set(&DataKey::VrfResponses(request_id), &vrf_response);
+
+ // Emit VrfRequestFulfilled event
+ env.events().publish(
+ (
+ Symbol::new(&env, "VrfRequestFulfilled"),
+ Symbol::new(&env, "v1"),
+ request_id,
+ ),
+ (vrf_request.task_id, random_number),
+ );
+
+ exit_security_guard(&env);
+ }
+
pub fn resume_task(env: Env, task_id: u64) {
enter_security_guard(&env);
let task_key = DataKey::Task(task_id);
@@ -397,105 +838,394 @@ impl SoroTaskContract {
exit_security_guard(&env);
}
- pub fn monitor_paginated(env: Env, start_id: u64, limit: u64) -> Vec {
- let now = env.ledger().timestamp();
- let counter: u64 = env
+ /// Creates a new portfolio.
+ /// Returns the unique sequential ID of the created portfolio.
+ pub fn create_portfolio(env: Env, name: Vec, description: Vec) -> u64 {
+ enter_security_guard(&env);
+ let creator = Address::current(&env);
+
+ // Generate a unique sequential ID
+ let mut counter: u64 = env
.storage()
.persistent()
- .get(&DataKey::Counter)
+ .get(&DataKey::PortfolioCounter)
.unwrap_or(0);
+ counter += 1;
+ env.storage().persistent().set(&DataKey::PortfolioCounter, &counter);
- // Clamp start to valid range
- if start_id == 0 || start_id > counter {
- return Vec::new(&env);
- }
-
- let mut executable = Vec::new(&env);
- if start_id == 0 || limit == 0 {
- return executable;
- }
-
- let end_id = start_id.saturating_add(limit.saturating_sub(1));
- let active_task_ids = get_active_task_ids(&env);
- let len = active_task_ids.len();
- let mut i = 0;
+ let portfolio = Portfolio {
+ creator: creator.clone(),
+ name,
+ description,
+ created_at: env.ledger().timestamp(),
+ is_active: true,
+ task_count: 0,
+ };
- while i < len {
- let task_id = active_task_ids
- .get(i)
- .expect("active task index out of bounds")
- .clone();
+ // Store the portfolio configuration
+ env.storage()
+ .persistent()
+ .set(&DataKey::Portfolio(counter), &portfolio);
- if task_id < start_id {
- i += 1;
- continue;
- }
+ // Emit PortfolioCreated event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioCreated"),
+ Symbol::new(&env, "v1"),
+ counter,
+ ),
+ creator.clone(),
+ );
- if task_id > end_id {
- break;
- }
+ exit_security_guard(&env);
+ counter
+ }
- 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,
- target: config.target,
- function: config.function,
- args: config.args,
- });
- }
- }
+ /// Adds a task to a portfolio.
+ pub fn add_task_to_portfolio(env: Env, portfolio_id: u64, task_id: u64) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
- i += 1;
- }
+ portfolio.creator.require_auth();
- executable
- }
- /// Executes a registered task identified by `task_id`.
- ///
- /// # Flow
- /// 1. Load the [`TaskConfig`] from persistent storage (panics if absent).
- /// 2. If a `resolver` address is set, call `check_condition(args) -> bool`
- /// on it via [`try_invoke_contract`] so that a faulty resolver never
- /// permanently blocks execution — a failed call is treated as `false`.
- /// 3. When the condition is met (or there is no resolver), fire the
- /// cross-contract call to `target::function(args)` using
- /// [`invoke_contract`].
- /// 4. Only on a **successful** invocation persist the updated `last_run`
- /// timestamp.
- ///
- /// # Safety & Atomicity
- /// Soroban transactions are fully atomic. If the target contract panics the
- /// entire transaction reverts, so `SoroTask` state is never left in an
- /// inconsistent half-updated form. `last_run` is written **after** the
- /// 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();
+ // Validate task exists
let task_key = DataKey::Task(task_id);
- let mut config: TaskConfig = env
+ let _task: TaskConfig = env
.storage()
.persistent()
.get(&task_key)
.expect("Task not found");
- if !config.is_active {
- panic_with_error!(&env, Error::TaskPaused);
+ // Get current portfolio tasks
+ let mut portfolio_tasks = env
+ .storage()
+ .persistent()
+ .get::>(&DataKey::PortfolioTasks(portfolio_id))
+ .unwrap_or_else(|| Vec::new(&env));
+
+ // Check if task is already in portfolio
+ let mut already_exists = false;
+ for i in 0..portfolio_tasks.len() {
+ if portfolio_tasks.get(i).unwrap() == task_id {
+ already_exists = true;
+ break;
+ }
}
- if !config.whitelist.is_empty() && !config.whitelist.contains(&keeper) {
- panic_with_error!(&env, Error::Unauthorized);
+ if !already_exists {
+ portfolio_tasks.push_back(task_id);
+ portfolio.task_count += 1;
+ env.storage().persistent().set(&DataKey::PortfolioTasks(portfolio_id), &portfolio_tasks);
+ env.storage().persistent().set(&portfolio_key, &portfolio);
}
- if env.ledger().timestamp() < config.last_run + config.interval {
- exit_security_guard(&env);
- return;
- }
+ // Emit PortfolioTaskAdded event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioTaskAdded"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ (task_id, portfolio.creator.clone()),
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Removes a task from a portfolio.
+ pub fn remove_task_from_portfolio(env: Env, portfolio_id: u64, task_id: u64) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
+
+ portfolio.creator.require_auth();
+
+ // Get current portfolio tasks
+ let portfolio_tasks = env
+ .storage()
+ .persistent()
+ .get::>(&DataKey::PortfolioTasks(portfolio_id))
+ .unwrap_or_else(|| Vec::new(&env));
+
+ // Remove task from portfolio
+ let mut new_portfolio_tasks = Vec::new(&env);
+ for i in 0..portfolio_tasks.len() {
+ let task_in_portfolio = portfolio_tasks.get(i).unwrap();
+ if task_in_portfolio != task_id {
+ new_portfolio_tasks.push_back(task_in_portfolio);
+ }
+ }
+
+ if new_portfolio_tasks.len() < portfolio_tasks.len() {
+ portfolio.task_count -= 1;
+ env.storage().persistent().set(&DataKey::PortfolioTasks(portfolio_id), &new_portfolio_tasks);
+ env.storage().persistent().set(&portfolio_key, &portfolio);
+ }
+
+ // Emit PortfolioTaskRemoved event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioTaskRemoved"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ (task_id, portfolio.creator.clone()),
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Gets all tasks in a portfolio.
+ pub fn get_portfolio_tasks(env: Env, portfolio_id: u64) -> Vec {
+ env.storage()
+ .persistent()
+ .get::>(&DataKey::PortfolioTasks(portfolio_id))
+ .unwrap_or_else(|| Vec::new(&env))
+ }
+
+ /// Gets portfolio information.
+ pub fn get_portfolio(env: Env, portfolio_id: u64) -> Option {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Portfolio(portfolio_id))
+ }
+
+ /// Pauses all tasks in a portfolio.
+ pub fn pause_portfolio(env: Env, portfolio_id: u64) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
+
+ portfolio.creator.require_auth();
+
+ let portfolio_tasks = Self::get_portfolio_tasks(env.clone(), portfolio_id);
+
+ for i in 0..portfolio_tasks.len() {
+ let task_id = portfolio_tasks.get(i).unwrap();
+ Self::pause_task(env.clone(), task_id);
+ }
+
+ // Emit PortfolioPaused event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioPaused"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ portfolio.creator.clone(),
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Resumes all tasks in a portfolio.
+ pub fn resume_portfolio(env: Env, portfolio_id: u64) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
+
+ portfolio.creator.require_auth();
+
+ let portfolio_tasks = Self::get_portfolio_tasks(env.clone(), portfolio_id);
+
+ for i in 0..portfolio_tasks.len() {
+ let task_id = portfolio_tasks.get(i).unwrap();
+ Self::resume_task(env.clone(), task_id);
+ }
+
+ // Emit PortfolioResumed event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioResumed"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ portfolio.creator.clone(),
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Funds all tasks in a portfolio with gas tokens.
+ pub fn fund_portfolio(env: Env, portfolio_id: u64, amount: i128) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
+
+ portfolio.creator.require_auth();
+
+ let portfolio_tasks = Self::get_portfolio_tasks(env.clone(), portfolio_id);
+
+ for i in 0..portfolio_tasks.len() {
+ let task_id = portfolio_tasks.get(i).unwrap();
+ Self::deposit_gas(env.clone(), task_id, portfolio.creator.clone(), amount);
+ }
+
+ // Emit PortfolioFunded event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioFunded"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ (amount, portfolio.creator.clone()),
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Executes all tasks in a portfolio.
+ /// Only portfolio creator can execute portfolio tasks.
+ pub fn execute_portfolio_tasks(env: Env, portfolio_id: u64) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
+
+ portfolio.creator.require_auth();
+
+ let portfolio_tasks = Self::get_portfolio_tasks(env.clone(), portfolio_id);
+
+ for i in 0..portfolio_tasks.len() {
+ let task_id = portfolio_tasks.get(i).unwrap();
+ // Execute each task in the portfolio
+ // Note: This will use the keeper's address as the executor
+ // In production, this would be configurable
+ let keeper_address = portfolio.creator.clone();
+ Self::execute(env.clone(), keeper_address, task_id);
+ }
+
+ // Emit PortfolioTasksExecuted event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioTasksExecuted"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ (portfolio_tasks.len(), portfolio.creator.clone()),
+ );
+
+ exit_security_guard(&env);
+ }
+
+ pub fn monitor_paginated(env: Env, start_id: u64, limit: u64) -> Vec {
+ let now = env.ledger().timestamp();
+ let counter: u64 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Counter)
+ .unwrap_or(0);
+
+ // Clamp start to valid range
+ if start_id == 0 || start_id > counter {
+ return Vec::new(&env);
+ }
+
+ let mut executable = Vec::new(&env);
+ if start_id == 0 || limit == 0 {
+ return executable;
+ }
+
+ let end_id = start_id.saturating_add(limit.saturating_sub(1));
+ let active_task_ids = get_active_task_ids(&env);
+ let len = active_task_ids.len();
+ let mut i = 0;
+
+ while i < len {
+ let task_id = active_task_ids
+ .get(i)
+ .expect("active task index out of bounds")
+ .clone();
+
+ if task_id < start_id {
+ i += 1;
+ continue;
+ }
+
+ if task_id > end_id {
+ break;
+ }
+
+ 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,
+ target: config.target,
+ function: config.function,
+ args: config.args,
+ });
+ }
+ }
+
+ i += 1;
+ }
+
+ executable
+ }
+ /// Executes a registered task identified by `task_id`.
+ ///
+ /// # Flow
+ /// 1. Load the [`TaskConfig`] from persistent storage (panics if absent).
+ /// 2. If a `resolver` address is set, call `check_condition(args) -> bool`
+ /// on it via [`try_invoke_contract`] so that a faulty resolver never
+ /// permanently blocks execution — a failed call is treated as `false`.
+ /// 3. When the condition is met (or there is no resolver), fire the
+ /// cross-contract call to `target::function(args)` using
+ /// [`invoke_contract`].
+ /// 4. Only on a **successful** invocation persist the updated `last_run`
+ /// timestamp.
+ ///
+ /// # Safety & Atomicity
+ /// Soroban transactions are fully atomic. If the target contract panics the
+ /// entire transaction reverts, so `SoroTask` state is never left in an
+ /// inconsistent half-updated form. `last_run` is written **after** the
+ /// 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
+ .storage()
+ .persistent()
+ .get(&task_key)
+ .expect("Task not found");
+
+ if !config.is_active {
+ panic_with_error!(&env, Error::TaskPaused);
+ }
+
+ if !config.whitelist.is_empty() && !config.whitelist.contains(&keeper) {
+ panic_with_error!(&env, Error::Unauthorized);
+ }
+
+ if env.ledger().timestamp() < config.last_run + config.interval {
+ exit_security_guard(&env);
+ return;
+ }
// Check if task is blocked by dependencies
if Self::is_task_blocked(env.clone(), task_id) {
@@ -528,17 +1258,72 @@ impl SoroTaskContract {
None => true,
};
- if should_execute {
- // ── 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 = FIXED_EXECUTION_FEE;
+ // ── VRF condition gate ────────────────────────────────────────────────────
+ // When VRF responses are present for this task, we check if the random number
+ // meets the required condition before executing.
+ // The VRF response interface is: check_vrf_condition(random_number: i128) -> bool
+ let should_execute_vrf = {
+ // Check if there are any pending VRF requests for this task
+ let mut vrf_request_found = false;
+ let mut vrf_response_found = false;
+ let mut vrf_response: Option = None;
+
+ // Look for VRF requests for this task
+ // We'll use a simple approach: check request counter and iterate through requests
+ // In production, this would be optimized with proper indexing
+ if env.storage().instance().has(&DataKey::VrfRequestCounter) {
+ let request_counter: u64 = env.storage().instance().get(&DataKey::VrfRequestCounter).unwrap();
+ for i in 1..=request_counter {
+ if let Ok(vrf_request) = env.storage().persistent().get::(&DataKey::VrfRequests(i)) {
+ if vrf_request.task_id == task_id && vrf_request.status == VrfRequestStatus::Fulfilled {
+ vrf_request_found = true;
+ // Check if response exists
+ if let Ok(response) = env.storage().persistent().get::(&DataKey::VrfResponses(i)) {
+ vrf_response_found = true;
+ vrf_response = Some(response);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if vrf_response_found {
+ // Call VRF condition checker if configured
+ // For now, we'll use a simple default: always execute if VRF response exists
+ // In production, this would be configurable per task
+ true
+ } else {
+ // If no VRF response, use resolver result
+ should_execute
+ }
+ };
+
+ if should_execute_vrf {
+ // ── Fee validation & calculation ──────────────────────────────
+ // Calculate fee based on task complexity and configuration
+ let fee: i128 = Self::calculate_execution_fee(&env, &config);
+
+ // Validate sufficient balance
if config.gas_balance < fee {
panic_with_error!(&env, Error::InsufficientBalance);
}
+ // ── Yield strategy execution ──────────────────────────────────────
+ // If task is configured with a yield strategy, execute it instead of cross-contract call
+ let executed_yield_strategy = if let Some(ref yield_strategy_id) = config.yield_strategy {
+ // Execute yield strategy
+ Self::execute_yield_strategy(env.clone(), *yield_strategy_id, task_id)
+ .expect("Yield strategy execution failed");
+ true
+ } else {
+ false
+ };
+
// ── Cross-contract call ──────────────────────────────────────
- env.invoke_contract::(&config.target, &config.function, config.args.clone());
+ if !executed_yield_strategy {
+ env.invoke_contract::(&config.target, &config.function, config.args.clone());
+ }
// ── Payment to keeper & balance deduction ────────────────────
// Decrease the stored gas_balance regardless, and if a token has
@@ -965,115 +1750,1062 @@ impl SoroTaskContract {
}
}
- rules
- }
+ 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
+ }
+
+ /// 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, 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, 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));
+
+ 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, depth + 1) {
+ return true;
+ }
+ }
+ }
+
+ false
+ }
+
+ /// Calculates execution fee based on task configuration and complexity.
+ /// Supports multiple fee models: fixed, percentage-based, and dynamic.
+ fn calculate_execution_fee(env: &Env, config: &TaskConfig) -> i128 {
+ // Get fee model configuration from storage (if available)
+ // Default to fixed fee model if not configured
+ let mut fee = FIXED_EXECUTION_FEE;
+
+ // Check if token is initialized for native token fee payments
+ if env.storage().instance().has(&DataKey::Token) {
+ // Get tokenomics configuration
+ let config: TokenomicsConfig = env
+ .storage()
+ .instance()
+ .get(&DataKey::TokenomicsConfig)
+ .unwrap_or_else(|| TokenomicsConfig {
+ staking_reward_rate: 500,
+ governance_quorum_percentage: 1000,
+ governance_voting_period: 3_600_000,
+ fee_model: FeeModel::Dynamic,
+ min_fee: 50,
+ max_fee: 10000,
+ });
+
+ // For native token, use more sophisticated fee calculation
+ // Base fee + complexity-based multiplier
+ let base_fee = 50; // Base fee in native token units
+
+ // Calculate complexity multiplier based on args size
+ let args_size = config.args.len() as i128 * 10; // 10 units per argument
+
+ // Add complexity bonus for target contract interaction
+ let target_complexity_bonus = 20; // Fixed bonus for cross-contract calls
+
+ fee = base_fee + args_size + target_complexity_bonus;
+
+ // Apply fee model specific logic
+ match config.fee_model {
+ FeeModel::Fixed => {
+ fee = config.min_fee;
+ },
+ FeeModel::Percentage => {
+ // Calculate percentage-based fee
+ let percentage = 10; // 1% fee
+ fee = (base_fee + args_size + target_complexity_bonus) * percentage / 100;
+ },
+ FeeModel::Dynamic => {
+ // Dynamic fee based on network conditions
+ fee = base_fee + args_size + target_complexity_bonus;
+ },
+ }
+
+ // Apply minimum and maximum fee thresholds
+ if fee < config.min_fee {
+ fee = config.min_fee;
+ }
+ if fee > config.max_fee {
+ fee = config.max_fee;
+ }
+ }
+
+ fee
+ }
+
+ /// Initializes the tokenomics configuration.
+ pub fn init_tokenomics_config(env: Env, config: TokenomicsConfig) {
+ enter_security_guard(&env);
+ if env.storage().instance().has(&DataKey::TokenomicsConfig) {
+ panic_with_error!(&env, Error::AlreadyInitialized);
+ }
+
+ env.storage().instance().set(&DataKey::TokenomicsConfig, &config);
+
+ // Emit TokenomicsConfigInitialized event
+ env.events().publish(
+ (
+ Symbol::new(&env, "TokenomicsConfigInitialized"),
+ Symbol::new(&env, "v1"),
+ ),
+ config.staking_reward_rate,
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Updates the tokenomics configuration.
+ pub fn update_tokenomics_config(env: Env, config: TokenomicsConfig) {
+ enter_security_guard(&env);
+ let admin = Address::current(&env);
+
+ // Only admin can update tokenomics config
+ // In production, this would be a multisig or governance-controlled address
+ if admin != Address::generate(&env) {
+ panic_with_error!(&env, Error::Unauthorized);
+ }
+
+ env.storage().instance().set(&DataKey::TokenomicsConfig, &config);
+
+ // Emit TokenomicsConfigUpdated event
+ env.events().publish(
+ (
+ Symbol::new(&env, "TokenomicsConfigUpdated"),
+ Symbol::new(&env, "v1"),
+ ),
+ config.staking_reward_rate,
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Sets the VRF oracle contract address.
+ /// Only admin can set the VRF oracle address.
+ pub fn set_vrf_oracle_address(env: Env, oracle_address: Address) {
+ enter_security_guard(&env);
+ // Get the stored admin address
+ let admin_address: Option = env
+ .storage()
+ .instance()
+ .get(&DataKey::AdminAddress);
+
+ // Only admin can set VRF oracle address
+ match admin_address {
+ Some(admin) => {
+ let caller = Address::current(&env);
+ if caller != admin {
+ panic_with_error!(&env, Error::Unauthorized);
+ }
+ }
+ None => {
+ // No admin set yet - only allow initialization by contract deployer
+ // This is a fallback for initial setup
+ panic_with_error!(&env, Error::NotInitialized);
+ }
+ }
+
+ env.storage().instance().set(&DataKey::VrfOracleAddress, &oracle_address);
+
+ // Emit VrfOracleAddressSet event
+ env.events().publish(
+ (
+ Symbol::new(&env, "VrfOracleAddressSet"),
+ Symbol::new(&env, "v1"),
+ ),
+ oracle_address,
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Sets the admin contract address.
+ /// Only the current admin can set a new admin address, or anyone can set the initial admin.
+ pub fn set_admin_address(env: Env, admin_address: Address) {
+ enter_security_guard(&env);
+
+ // Check if admin address is already set
+ let current_admin: Option = env
+ .storage()
+ .instance()
+ .get(&DataKey::AdminAddress);
+
+ if let Some(existing_admin) = current_admin {
+ // If admin is already set, only the current admin can change it
+ let caller = Address::current(&env);
+ if caller != existing_admin {
+ panic_with_error!(&env, Error::Unauthorized);
+ }
+ }
+
+ // Store the new admin address
+ env.storage().instance().set(&DataKey::AdminAddress, &admin_address);
+
+ // Emit AdminAddressSet event
+ env.events().publish(
+ (
+ Symbol::new(&env, "AdminAddressSet"),
+ Symbol::new(&env, "v1"),
+ ),
+ admin_address,
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Initializes a yield harvesting strategy.
+ /// Only admin can initialize yield strategies.
+ pub fn init_yield_strategy(
+ env: Env,
+ protocol_address: Address,
+ harvest_function: Symbol,
+ compound_function: Symbol,
+ harvest_args: Vec,
+ compound_args: Vec,
+ min_yield_threshold: i128,
+ max_gas_fee: i128,
+ ) {
+ enter_security_guard(&env);
+ let admin = Address::current(&env);
+
+ // Only admin can initialize yield strategies
+ // In production, this would be a multisig or governance-controlled address
+ if admin != Address::generate(&env) {
+ panic_with_error!(&env, Error::Unauthorized);
+ }
+
+ // Generate a unique sequential ID
+ let mut counter: u64 = env
+ .storage()
+ .instance()
+ .get(&DataKey::YieldStrategyCounter)
+ .unwrap_or(0);
+ counter += 1;
+ env.storage().instance().set(&DataKey::YieldStrategyCounter, &counter);
+
+ // Create yield strategy config
+ let strategy_config = YieldStrategyConfig {
+ protocol_address,
+ harvest_function,
+ compound_function,
+ harvest_args,
+ compound_args,
+ min_yield_threshold,
+ max_gas_fee,
+ created_at: env.ledger().timestamp(),
+ is_active: true,
+ };
+
+ // Store yield strategy
+ env.storage().persistent().set(&DataKey::YieldStrategies(counter), &strategy_config);
+
+ // Emit YieldStrategyInitialized event
+ env.events().publish(
+ (
+ Symbol::new(&env, "YieldStrategyInitialized"),
+ Symbol::new(&env, "v1"),
+ counter,
+ ),
+ (protocol_address, harvest_function),
+ );
+
+ exit_security_guard(&env);
+ }
+
+ /// Executes a yield harvesting strategy.
+ /// Called by tasks configured to use yield harvesting.
+ pub fn execute_yield_strategy(
+ env: Env,
+ strategy_id: u64,
+ task_id: u64,
+ ) -> Result<(), Error> {
+ enter_security_guard(&env);
+
+ // Get the yield strategy
+ let strategy: YieldStrategyConfig = env
+ .storage()
+ .persistent()
+ .get(&DataKey::YieldStrategies(strategy_id))
+ .expect("Yield strategy not found");
+
+ if !strategy.is_active {
+ panic_with_error!(&env, Error::YieldStrategyNotInitialized);
+ }
+
+ // Check if we need to harvest (simplified logic)
+ // In production, this would check actual yield balance from protocol
+ let should_harvest = true; // Placeholder - would be real logic in production
+
+ if should_harvest {
+ // Execute harvest function
+ env.invoke_contract::(
+ &strategy.protocol_address,
+ &strategy.harvest_function,
+ strategy.harvest_args.clone(),
+ );
+
+ // Execute compound function
+ env.invoke_contract::(
+ &strategy.protocol_address,
+ &strategy.compound_function,
+ strategy.compound_args.clone(),
+ );
+
+ // Emit YieldHarvested event
+ env.events().publish(
+ (
+ Symbol::new(&env, "YieldHarvested"),
+ Symbol::new(&env, "v1"),
+ strategy_id,
+ ),
+ (task_id, strategy_id),
+ );
+ }
+
+ exit_security_guard(&env);
+ Ok(())
+ }
+
+ /// Gets the current tokenomics configuration.
+ pub fn get_tokenomics_config(env: Env) -> TokenomicsConfig {
+ env.storage()
+ .instance()
+ .get(&DataKey::TokenomicsConfig)
+ .unwrap_or_else(|| TokenomicsConfig {
+ staking_reward_rate: 500,
+ governance_quorum_percentage: 1000,
+ governance_voting_period: 3_600_000,
+ fee_model: FeeModel::Dynamic,
+ min_fee: 50,
+ max_fee: 10000,
+ })
+ }
+
+ /// Creates a new portfolio.
+ /// Returns the unique sequential ID of the created portfolio.
+ pub fn create_portfolio(env: Env, name: Vec, description: Vec) -> u64 {
+ enter_security_guard(&env);
+ let creator = Address::current(&env);
+
+ // Generate a unique sequential ID
+ let mut counter: u64 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::PortfolioCounter)
+ .unwrap_or(0);
+ counter += 1;
+ env.storage().persistent().set(&DataKey::PortfolioCounter, &counter);
+
+ let portfolio = Portfolio {
+ creator: creator.clone(),
+ name,
+ description,
+ created_at: env.ledger().timestamp(),
+ is_active: true,
+ task_count: 0,
+ };
+
+ // Store the portfolio configuration
+ env.storage()
+ .persistent()
+ .set(&DataKey::Portfolio(counter), &portfolio);
+
+ // Emit PortfolioCreated event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioCreated"),
+ Symbol::new(&env, "v1"),
+ counter,
+ ),
+ creator.clone(),
+ );
+
+ exit_security_guard(&env);
+ counter
+ }
+
+ /// Adds a task to a portfolio.
+ pub fn add_task_to_portfolio(env: Env, portfolio_id: u64, task_id: u64) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
+
+ portfolio.creator.require_auth();
+
+ // Validate task exists
+ let task_key = DataKey::Task(task_id);
+ let _task: TaskConfig = env
+ .storage()
+ .persistent()
+ .get(&task_key)
+ .expect("Task not found");
+
+ // Get current portfolio tasks
+ let mut portfolio_tasks = env
+ .storage()
+ .persistent()
+ .get::>(&DataKey::PortfolioTasks(portfolio_id))
+ .unwrap_or_else(|| Vec::new(&env));
+
+ // Check if task is already in portfolio
+ let mut already_exists = false;
+ for i in 0..portfolio_tasks.len() {
+ if portfolio_tasks.get(i).unwrap() == task_id {
+ already_exists = true;
+ break;
+ }
+ }
+
+ if !already_exists {
+ portfolio_tasks.push_back(task_id);
+ portfolio.task_count += 1;
+ env.storage().persistent().set(&DataKey::PortfolioTasks(portfolio_id), &portfolio_tasks);
+ env.storage().persistent().set(&portfolio_key, &portfolio);
+ }
+
+ // Emit PortfolioTaskAdded event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioTaskAdded"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ (task_id, portfolio.creator.clone()),
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Removes a task from a portfolio.
+ pub fn remove_task_from_portfolio(env: Env, portfolio_id: u64, task_id: u64) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
+
+ portfolio.creator.require_auth();
+
+ // Get current portfolio tasks
+ let portfolio_tasks = env
+ .storage()
+ .persistent()
+ .get::>(&DataKey::PortfolioTasks(portfolio_id))
+ .unwrap_or_else(|| Vec::new(&env));
+
+ // Remove task from portfolio
+ let mut new_portfolio_tasks = Vec::new(&env);
+ for i in 0..portfolio_tasks.len() {
+ let task_in_portfolio = portfolio_tasks.get(i).unwrap();
+ if task_in_portfolio != task_id {
+ new_portfolio_tasks.push_back(task_in_portfolio);
+ }
+ }
+
+ if new_portfolio_tasks.len() < portfolio_tasks.len() {
+ portfolio.task_count -= 1;
+ env.storage().persistent().set(&DataKey::PortfolioTasks(portfolio_id), &new_portfolio_tasks);
+ env.storage().persistent().set(&portfolio_key, &portfolio);
+ }
+
+ // Emit PortfolioTaskRemoved event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioTaskRemoved"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ (task_id, portfolio.creator.clone()),
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Gets all tasks in a portfolio.
+ pub fn get_portfolio_tasks(env: Env, portfolio_id: u64) -> Vec {
+ env.storage()
+ .persistent()
+ .get::>(&DataKey::PortfolioTasks(portfolio_id))
+ .unwrap_or_else(|| Vec::new(&env))
+ }
+
+ /// Gets portfolio information.
+ pub fn get_portfolio(env: Env, portfolio_id: u64) -> Option {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Portfolio(portfolio_id))
+ }
+
+ /// Pauses all tasks in a portfolio.
+ pub fn pause_portfolio(env: Env, portfolio_id: u64) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
+
+ portfolio.creator.require_auth();
+
+ let portfolio_tasks = Self::get_portfolio_tasks(env.clone(), portfolio_id);
+
+ for i in 0..portfolio_tasks.len() {
+ let task_id = portfolio_tasks.get(i).unwrap();
+ Self::pause_task(env.clone(), task_id);
+ }
+
+ // Emit PortfolioPaused event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioPaused"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ portfolio.creator.clone(),
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Resumes all tasks in a portfolio.
+ pub fn resume_portfolio(env: Env, portfolio_id: u64) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
+
+ portfolio.creator.require_auth();
+
+ let portfolio_tasks = Self::get_portfolio_tasks(env.clone(), portfolio_id);
+
+ for i in 0..portfolio_tasks.len() {
+ let task_id = portfolio_tasks.get(i).unwrap();
+ Self::resume_task(env.clone(), task_id);
+ }
+
+ // Emit PortfolioResumed event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioResumed"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ portfolio.creator.clone(),
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Funds all tasks in a portfolio with gas tokens.
+ pub fn fund_portfolio(env: Env, portfolio_id: u64, amount: i128) {
+ enter_security_guard(&env);
+ let portfolio_key = DataKey::Portfolio(portfolio_id);
+ let mut portfolio: Portfolio = env
+ .storage()
+ .persistent()
+ .get(&portfolio_key)
+ .expect("Portfolio not found");
+
+ portfolio.creator.require_auth();
+
+ let portfolio_tasks = Self::get_portfolio_tasks(env.clone(), portfolio_id);
+
+ for i in 0..portfolio_tasks.len() {
+ let task_id = portfolio_tasks.get(i).unwrap();
+ Self::deposit_gas(env.clone(), task_id, portfolio.creator.clone(), amount);
+ }
+
+ // Emit PortfolioFunded event
+ env.events().publish(
+ (
+ Symbol::new(&env, "PortfolioFunded"),
+ Symbol::new(&env, "v1"),
+ portfolio_id,
+ ),
+ (amount, portfolio.creator.clone()),
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Initializes the staking pool.
+ pub fn init_staking_pool(env: Env, reward_rate: i128) {
+ enter_security_guard(&env);
+ if env.storage().instance().has(&DataKey::StakingPool) {
+ panic_with_error!(&env, Error::AlreadyInitialized);
+ }
+
+ let pool = StakingPool {
+ total_staked: 0,
+ stakers_count: 0,
+ reward_rate,
+ last_reward_timestamp: env.ledger().timestamp(),
+ };
+
+ env.storage().instance().set(&DataKey::StakingPool, &pool);
+
+ // Emit StakingPoolInitialized event
+ env.events().publish(
+ (
+ Symbol::new(&env, "StakingPoolInitialized"),
+ Symbol::new(&env, "v1"),
+ ),
+ reward_rate,
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Stakes tokens into the staking pool.
+ pub fn stake_tokens(env: Env, amount: i128) {
+ enter_security_guard(&env);
+ let staker = Address::current(&env);
+
+ // Validate staking pool is initialized
+ let pool: StakingPool = env
+ .storage()
+ .instance()
+ .get(&DataKey::StakingPool)
+ .expect("Staking pool not initialized");
+
+ // Get token address
+ let token_address: Address = env
+ .storage()
+ .instance()
+ .get(&DataKey::Token)
+ .expect("Token not initialized");
+
+ // Transfer tokens from staker to contract
+ let token_client = soroban_sdk::token::Client::new(&env, &token_address);
+ token_client.transfer(&staker, &env.current_contract_address(), &amount);
+
+ // Update staking balance
+ let mut staking_balance = env
+ .storage()
+ .persistent()
+ .get::(&DataKey::StakingBalance(staker.clone()))
+ .unwrap_or_else(|| StakingBalance {
+ address: staker.clone(),
+ amount: 0,
+ last_stake_timestamp: 0,
+ accumulated_rewards: 0,
+ });
+
+ staking_balance.amount += amount;
+ staking_balance.last_stake_timestamp = env.ledger().timestamp();
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::StakingBalance(staker.clone()), &staking_balance);
+
+ // Update staking pool
+ let mut updated_pool = pool.clone();
+ updated_pool.total_staked += amount;
+ updated_pool.stakers_count += 1;
+
+ env.storage()
+ .instance()
+ .set(&DataKey::StakingPool, &updated_pool);
+
+ // Emit Staked event
+ env.events().publish(
+ (
+ Symbol::new(&env, "TokensStaked"),
+ Symbol::new(&env, "v1"),
+ staker.clone(),
+ ),
+ amount,
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Unstakes tokens from the staking pool.
+ pub fn unstake_tokens(env: Env, amount: i128) {
+ enter_security_guard(&env);
+ let staker = Address::current(&env);
+
+ // Validate staking pool is initialized
+ let pool: StakingPool = env
+ .storage()
+ .instance()
+ .get(&DataKey::StakingPool)
+ .expect("Staking pool not initialized");
+
+ // Get staking balance
+ let mut staking_balance: StakingBalance = env
+ .storage()
+ .persistent()
+ .get::(&DataKey::StakingBalance(staker.clone()))
+ .expect("No staking balance found");
+
+ if staking_balance.amount < amount {
+ panic_with_error!(&env, Error::InsufficientBalance);
+ }
+
+ // Get token address
+ let token_address: Address = env
+ .storage()
+ .instance()
+ .get(&DataKey::Token)
+ .expect("Token not initialized");
+
+ // Transfer tokens from contract to staker
+ let token_client = soroban_sdk::token::Client::new(&env, &token_address);
+ token_client.transfer(&env.current_contract_address(), &staker, &amount);
+
+ // Update staking balance
+ staking_balance.amount -= amount;
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::StakingBalance(staker.clone()), &staking_balance);
+
+ // Update staking pool
+ let mut updated_pool = pool.clone();
+ updated_pool.total_staked -= amount;
+ if staking_balance.amount == 0 {
+ updated_pool.stakers_count -= 1;
+ }
+
+ env.storage()
+ .instance()
+ .set(&DataKey::StakingPool, &updated_pool);
+
+ // Emit Unstaked event
+ env.events().publish(
+ (
+ Symbol::new(&env, "TokensUnstaked"),
+ Symbol::new(&env, "v1"),
+ staker.clone(),
+ ),
+ amount,
+ );
+ exit_security_guard(&env);
+ }
+
+ /// Claims accumulated rewards.
+ pub fn claim_rewards(env: Env) {
+ enter_security_guard(&env);
+ let staker = Address::current(&env);
+
+ // Validate staking pool is initialized
+ let pool: StakingPool = env
+ .storage()
+ .instance()
+ .get(&DataKey::StakingPool)
+ .expect("Staking pool not initialized");
+
+ // Get staking balance
+ let mut staking_balance: StakingBalance = env
+ .storage()
+ .persistent()
+ .get::(&DataKey::StakingBalance(staker.clone()))
+ .expect("No staking balance found");
+
+ // Calculate rewards
+ let now = env.ledger().timestamp();
+ let time_elapsed = now.saturating_sub(pool.last_reward_timestamp);
+ let reward_amount = (staking_balance.amount * pool.reward_rate * time_elapsed) / 1_000_000;
+
+ if reward_amount > 0 {
+ // Get token address
+ let token_address: Address = env
+ .storage()
+ .instance()
+ .get(&DataKey::Token)
+ .expect("Token not initialized");
+
+ // Transfer rewards to staker
+ let token_client = soroban_sdk::token::Client::new(&env, &token_address);
+ token_client.transfer(&env.current_contract_address(), &staker, &reward_amount);
+
+ // Update staking balance
+ staking_balance.accumulated_rewards += reward_amount;
+ staking_balance.last_stake_timestamp = now;
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::StakingBalance(staker.clone()), &staking_balance);
+
+ // Update staking pool last reward timestamp
+ let mut updated_pool = pool.clone();
+ updated_pool.last_reward_timestamp = now;
+
+ env.storage()
+ .instance()
+ .set(&DataKey::StakingPool, &updated_pool);
+
+ // Emit RewardsClaimed event
+ env.events().publish(
+ (
+ Symbol::new(&env, "RewardsClaimed"),
+ Symbol::new(&env, "v1"),
+ staker.clone(),
+ ),
+ reward_amount,
+ );
+ }
+ exit_security_guard(&env);
+ }
+
+ /// Creates a new governance proposal.
+ pub fn create_proposal(env: Env, title: Vec, description: Vec, expires_at: u64) -> u64 {
+ enter_security_guard(&env);
+ let proposer = Address::current(&env);
+
+ // Generate a unique sequential ID
+ let mut counter: u64 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::GovernanceProposalCounter)
+ .unwrap_or(0);
+ counter += 1;
+ env.storage().persistent().set(&DataKey::GovernanceProposalCounter, &counter);
+
+ // Calculate quorum (1% of total staked)
+ let pool: StakingPool = env
+ .storage()
+ .instance()
+ .get(&DataKey::StakingPool)
+ .expect("Staking pool not initialized");
+ let quorum = pool.total_staked / 100;
+
+ let proposal = GovernanceProposal {
+ proposer: proposer.clone(),
+ title,
+ description,
+ created_at: env.ledger().timestamp(),
+ expires_at,
+ status: ProposalStatus::Active,
+ votes_for: 0,
+ votes_against: 0,
+ quorum,
+ };
- fn dependency_rule_satisfied(env: &Env, rule: &DependencyRule) -> bool {
- if !env.storage().persistent().has(&DataKey::Task(rule.task_id)) {
- return false;
- }
+ // Store the proposal
+ env.storage()
+ .persistent()
+ .set(&DataKey::GovernanceProposal(counter), &proposal);
- let status = Self::task_status(env, rule.task_id);
- if status.completed_at < rule.min_completed_at {
- return false;
- }
+ // Emit ProposalCreated event
+ env.events().publish(
+ (
+ Symbol::new(&env, "ProposalCreated"),
+ Symbol::new(&env, "v1"),
+ counter,
+ ),
+ proposer.clone(),
+ );
- match rule.required_outcome {
- DependencyOutcome::AnyCompletion => status.outcome != ExecutionOutcome::NeverRun,
- DependencyOutcome::Success => status.outcome == ExecutionOutcome::Success,
- DependencyOutcome::Skipped => status.outcome == ExecutionOutcome::Skipped,
- }
+ exit_security_guard(&env);
+ counter
}
- /// 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
- }
+ /// Votes on a governance proposal.
+ pub fn vote_on_proposal(env: Env, proposal_id: u64, vote_for: bool, voting_power: i128) {
+ enter_security_guard(&env);
+ let voter = Address::current(&env);
- 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
- }
+ // Validate proposal exists
+ let mut proposal: GovernanceProposal = env
+ .storage()
+ .persistent()
+ .get::(&DataKey::GovernanceProposal(proposal_id))
+ .expect("Proposal not found");
- 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);
+ if proposal.status != ProposalStatus::Active {
+ panic_with_error!(&env, Error::InvalidInterval); // Reuse error code for simplicity
}
- }
- fn exceeds_dependency_depth(
- env: &Env,
- task_id: u64,
- depth: u32,
- visited: &mut Vec,
- ) -> bool {
- if depth > MAX_DEPENDENCY_DEPTH {
- return true;
+ // Get voter's voting power
+ let voting_power_data = env
+ .storage()
+ .persistent()
+ .get::(&DataKey::GovernanceVotingPower(voter.clone()))
+ .unwrap_or_else(|| VotingPower {
+ address: voter.clone(),
+ voting_power: 0,
+ });
+
+ // Ensure voter has sufficient voting power
+ if voting_power_data.voting_power < voting_power {
+ panic_with_error!(&env, Error::InsufficientBalance);
}
- if visited.contains(&task_id) {
- return false;
+ // Update proposal votes
+ if vote_for {
+ proposal.votes_for += voting_power;
+ } else {
+ proposal.votes_against += voting_power;
}
- 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;
- }
+ // Update proposal status if quorum is reached
+ if proposal.votes_for >= proposal.quorum && proposal.votes_for > proposal.votes_against {
+ proposal.status = ProposalStatus::Passed;
+ } else if proposal.votes_against >= proposal.quorum && proposal.votes_against > proposal.votes_for {
+ proposal.status = ProposalStatus::Rejected;
}
- false
- }
- /// 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, 0)
+ env.storage()
+ .persistent()
+ .set(&DataKey::GovernanceProposal(proposal_id), &proposal);
+
+ // Emit VoteCast event
+ env.events().publish(
+ (
+ Symbol::new(&env, "VoteCast"),
+ Symbol::new(&env, "v1"),
+ proposal_id,
+ ),
+ (voter.clone(), vote_for, voting_power),
+ );
+ exit_security_guard(&env);
}
- /// 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, depth: u32) -> bool {
- if from == to {
- return true;
- }
+ /// Executes a passed governance proposal.
+ pub fn execute_proposal(env: Env, proposal_id: u64) {
+ enter_security_guard(&env);
+ let executor = Address::current(&env);
- if depth > MAX_DEPENDENCY_DEPTH {
- panic_with_error!(env, Error::DependencyDepthExceeded);
- }
+ // Validate proposal exists
+ let mut proposal: GovernanceProposal = env
+ .storage()
+ .persistent()
+ .get::(&DataKey::GovernanceProposal(proposal_id))
+ .expect("Proposal not found");
- if visited.contains(&from) {
- return false;
+ if proposal.status != ProposalStatus::Passed {
+ panic_with_error!(&env, Error::InvalidInterval); // Reuse error code for simplicity
}
- visited.push_back(from);
+ // Mark proposal as executed
+ proposal.status = ProposalStatus::Executed;
+ env.storage()
+ .persistent()
+ .set(&DataKey::GovernanceProposal(proposal_id), &proposal);
- let task: Option = env.storage().persistent().get(&DataKey::Task(from));
+ // Emit ProposalExecuted event
+ env.events().publish(
+ (
+ Symbol::new(&env, "ProposalExecuted"),
+ Symbol::new(&env, "v1"),
+ proposal_id,
+ ),
+ executor.clone(),
+ );
+ exit_security_guard(&env);
+ }
- 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, depth + 1) {
- return true;
- }
- }
- }
+ /// Gets staking pool information.
+ pub fn get_staking_pool(env: Env) -> StakingPool {
+ env.storage()
+ .instance()
+ .get(&DataKey::StakingPool)
+ .expect("Staking pool not initialized")
+ }
- false
+ /// Gets staking balance for an address.
+ pub fn get_staking_balance(env: Env, address: Address) -> Option {
+ env.storage()
+ .persistent()
+ .get::(&DataKey::StakingBalance(address))
+ }
+
+ /// Gets governance proposal information.
+ pub fn get_governance_proposal(env: Env, proposal_id: u64) -> Option {
+ env.storage()
+ .persistent()
+ .get::(&DataKey::GovernanceProposal(proposal_id))
}
}
@@ -1964,6 +3696,263 @@ mod tests {
);
}
+ /// Test portfolio creation and basic functionality.
+ #[test]
+ fn test_create_portfolio() {
+ let (env, id) = setup();
+ let client = SoroTaskContractClient::new(&env, &id);
+
+ let name = vec![&env, b"My Portfolio".to_vec()];
+ let description = vec![&env, b"Test portfolio for grouping tasks".to_vec()];
+
+ let portfolio_id = client.create_portfolio(&name, &description);
+ assert_eq!(portfolio_id, 1);
+
+ let portfolio = client.get_portfolio(&portfolio_id).expect("Portfolio should exist");
+ assert_eq!(portfolio.name, name);
+ assert_eq!(portfolio.description, description);
+ assert_eq!(portfolio.task_count, 0);
+ assert!(portfolio.is_active);
+ }
+
+ /// Test adding tasks to a portfolio.
+ #[test]
+ fn test_add_task_to_portfolio() {
+ let (env, id) = setup();
+ let client = SoroTaskContractClient::new(&env, &id);
+
+ let name = vec![&env, b"Test Portfolio".to_vec()];
+ let portfolio_id = client.create_portfolio(&name, &vec![&env]);
+
+ let target = env.register_contract(None, MockTarget);
+ let task1_id = client.register(&base_config(&env, target.clone()));
+ let task2_id = client.register(&base_config(&env, target.clone()));
+
+ // Add tasks to portfolio
+ client.add_task_to_portfolio(&portfolio_id, &task1_id);
+ client.add_task_to_portfolio(&portfolio_id, &task2_id);
+
+ let portfolio_tasks = client.get_portfolio_tasks(&portfolio_id);
+ assert_eq!(portfolio_tasks.len(), 2);
+ assert_eq!(portfolio_tasks.get(0).unwrap(), task1_id);
+ assert_eq!(portfolio_tasks.get(1).unwrap(), task2_id);
+
+ let portfolio = client.get_portfolio(&portfolio_id).unwrap();
+ assert_eq!(portfolio.task_count, 2);
+ }
+
+ /// Test removing tasks from a portfolio.
+ #[test]
+ fn test_remove_task_from_portfolio() {
+ let (env, id) = setup();
+ let client = SoroTaskContractClient::new(&env, &id);
+
+ let name = vec![&env, b"Test Portfolio".to_vec()];
+ let portfolio_id = client.create_portfolio(&name, &vec![&env]);
+
+ let target = env.register_contract(None, MockTarget);
+ let task1_id = client.register(&base_config(&env, target.clone()));
+ let task2_id = client.register(&base_config(&env, target.clone()));
+
+ // Add tasks to portfolio
+ client.add_task_to_portfolio(&portfolio_id, &task1_id);
+ client.add_task_to_portfolio(&portfolio_id, &task2_id);
+
+ // Remove one task
+ client.remove_task_from_portfolio(&portfolio_id, &task1_id);
+
+ let portfolio_tasks = client.get_portfolio_tasks(&portfolio_id);
+ assert_eq!(portfolio_tasks.len(), 1);
+ assert_eq!(portfolio_tasks.get(0).unwrap(), task2_id);
+
+ let portfolio = client.get_portfolio(&portfolio_id).unwrap();
+ assert_eq!(portfolio.task_count, 1);
+ }
+
+ /// Test portfolio batch pause/resume operations.
+ #[test]
+ fn test_portfolio_batch_operations() {
+ let (env, id) = setup();
+ let client = SoroTaskContractClient::new(&env, &id);
+
+ let name = vec![&env, b"Test Portfolio".to_vec()];
+ let portfolio_id = client.create_portfolio(&name, &vec![&env]);
+
+ let target = env.register_contract(None, MockTarget);
+ let task1_id = client.register(&base_config(&env, target.clone()));
+ let task2_id = client.register(&base_config(&env, target.clone()));
+
+ // Add tasks to portfolio
+ client.add_task_to_portfolio(&portfolio_id, &task1_id);
+ client.add_task_to_portfolio(&portfolio_id, &task2_id);
+
+ // Verify tasks are active initially
+ let task1 = client.get_task(&task1_id).unwrap();
+ let task2 = client.get_task(&task2_id).unwrap();
+ assert!(task1.is_active);
+ assert!(task2.is_active);
+
+ // Pause portfolio
+ client.pause_portfolio(&portfolio_id);
+
+ // Verify tasks are paused
+ let task1 = client.get_task(&task1_id).unwrap();
+ let task2 = client.get_task(&task2_id).unwrap();
+ assert!(!task1.is_active);
+ assert!(!task2.is_active);
+
+ // Resume portfolio
+ client.resume_portfolio(&portfolio_id);
+
+ // Verify tasks are resumed
+ let task1 = client.get_task(&task1_id).unwrap();
+ let task2 = client.get_task(&task2_id).unwrap();
+ assert!(task1.is_active);
+ assert!(task2.is_active);
+ }
+
+ /// Test portfolio funding operation.
+ #[test]
+ fn test_portfolio_funding() {
+ let (env, id) = setup();
+ let client = SoroTaskContractClient::new(&env, &id);
+
+ let token_admin = Address::generate(&env);
+ let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
+ let token_address = token_id.address();
+ let token_client = soroban_sdk::token::Client::new(&env, &token_address);
+ let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);
+
+ client.init(&token_address);
+
+ let name = vec![&env, b"Test Portfolio".to_vec()];
+ let portfolio_id = client.create_portfolio(&name, &vec![&env]);
+
+ let target = env.register_contract(None, MockTarget);
+ let task1_id = client.register(&base_config(&env, target.clone()));
+ let task2_id = client.register(&base_config(&env, target.clone()));
+
+ // Add tasks to portfolio
+ client.add_task_to_portfolio(&portfolio_id, &task1_id);
+ client.add_task_to_portfolio(&portfolio_id, &task2_id);
+
+ // Fund portfolio with gas tokens
+ client.fund_portfolio(&portfolio_id, &1000);
+
+ // Verify tasks have received gas
+ let task1 = client.get_task(&task1_id).unwrap();
+ let task2 = client.get_task(&task2_id).unwrap();
+ assert_eq!(task1.gas_balance, 1000);
+ assert_eq!(task2.gas_balance, 1000);
+ }
+
+ /// Test tokenomics configuration initialization.
+ #[test]
+ fn test_init_tokenomics_config() {
+ let (env, id) = setup();
+ let client = SoroTaskContractClient::new(&env, &id);
+
+ let token_admin = Address::generate(&env);
+ let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
+ let token_address = token_id.address();
+ client.init(&token_address);
+
+ let config = TokenomicsConfig {
+ staking_reward_rate: 500,
+ governance_quorum_percentage: 1000,
+ governance_voting_period: 3_600_000,
+ fee_model: FeeModel::Dynamic,
+ min_fee: 50,
+ max_fee: 10000,
+ };
+
+ client.init_tokenomics_config(&config);
+
+ let retrieved_config = client.get_tokenomics_config();
+ assert_eq!(retrieved_config.staking_reward_rate, 500);
+ assert_eq!(retrieved_config.governance_quorum_percentage, 1000);
+ assert_eq!(retrieved_config.governance_voting_period, 3_600_000);
+ assert_eq!(retrieved_config.fee_model, FeeModel::Dynamic);
+ assert_eq!(retrieved_config.min_fee, 50);
+ assert_eq!(retrieved_config.max_fee, 10000);
+ }
+
+ /// Test staking functionality.
+ #[test]
+ fn test_staking_functionality() {
+ let (env, id) = setup();
+ let client = SoroTaskContractClient::new(&env, &id);
+
+ let token_admin = Address::generate(&env);
+ let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
+ let token_address = token_id.address();
+ let token_client = soroban_sdk::token::Client::new(&env, &token_address);
+ let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);
+
+ client.init(&token_address);
+
+ // Initialize staking pool
+ client.init_staking_pool(&500);
+
+ // Mint tokens to staker
+ let staker = Address::generate(&env);
+ token_admin_client.mint(&staker, &1000);
+
+ // Stake tokens
+ client.stake_tokens(&100);
+
+ // Verify staking balance
+ let staking_balance = client.get_staking_balance(&staker).unwrap();
+ assert_eq!(staking_balance.amount, 100);
+
+ // Verify staking pool
+ let pool = client.get_staking_pool();
+ assert_eq!(pool.total_staked, 100);
+ assert_eq!(pool.stakers_count, 1);
+ }
+
+ /// Test governance proposal creation and voting.
+ #[test]
+ fn test_governance_proposal() {
+ let (env, id) = setup();
+ let client = SoroTaskContractClient::new(&env, &id);
+
+ let token_admin = Address::generate(&env);
+ let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
+ let token_address = token_id.address();
+ let token_client = soroban_sdk::token::Client::new(&env, &token_address);
+ let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);
+
+ client.init(&token_address);
+
+ // Initialize staking pool
+ client.init_staking_pool(&500);
+
+ // Mint tokens to proposer
+ let proposer = Address::generate(&env);
+ token_admin_client.mint(&proposer, &1000);
+
+ // Stake tokens
+ client.stake_tokens(&100);
+
+ // Create proposal
+ let title = vec![&env, b"Test Proposal".to_vec()];
+ let description = vec![&env, b"This is a test proposal".to_vec()];
+ let proposal_id = client.create_proposal(&title, &description, &10000);
+
+ // Verify proposal was created
+ let proposal = client.get_governance_proposal(&proposal_id).unwrap();
+ assert_eq!(proposal.title, title);
+ assert_eq!(proposal.status, ProposalStatus::Active);
+
+ // Vote on proposal
+ client.vote_on_proposal(&proposal_id, &true, &50);
+
+ // Verify votes were recorded
+ let updated_proposal = client.get_governance_proposal(&proposal_id).unwrap();
+ assert_eq!(updated_proposal.votes_for, 50);
+ }
+
#[test]
fn test_dependency_rule_can_require_skipped_outcome() {
let (env, id) = setup();
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 67902b70..5df1bc5f 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -10,7 +10,11 @@ export default function Home() {
interval: '',
gasBalance: '',
dueDate: '',
- parsedDueDate: undefined as Date | undefined
+ parsedDueDate: undefined as Date | undefined,
+ // VRF-related fields
+ useVrf: false,
+ vrfCallbackFunction: '',
+ vrfCallbackArgs: ''
});
const handleDateChange = (value: string, parsedDate?: Date) => {
@@ -147,6 +151,41 @@ export default function Home() {
className="w-full bg-neutral-900 border border-neutral-700/50 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all font-mono text-sm"
/>
+
+ setTaskData(prev => ({ ...prev, useVrf: e.target.checked }))}
+ className="h-4 w-4 text-blue-600 border-neutral-600 rounded focus:ring-blue-500"
+ />
+
+
+ {taskData.useVrf && (
+
+
+
+ setTaskData(prev => ({ ...prev, vrfCallbackFunction: e.target.value }))}
+ className="w-full bg-neutral-900 border border-neutral-700/50 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all font-mono text-sm"
+ />
+
+
+
+
+
+ )}