diff --git a/README.md b/README.md index 8397d9a..c121c8a 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,13 @@ Single contract **disciplr-vault** with: - **Data model:** `ProductivityVault` (creator, amount, start/end timestamps, milestone hash, optional verifier, success/failure destinations, status). - **Status:** `Active`, `Completed`, `Failed`, `Cancelled`. - **Methods:** - - ✅ `create_vault(...)` — create vault and transfer USDC from creator to contract (IMPLEMENTED) - - `validate_milestone(vault_id)` — verifier validates milestone (release logic TODO). - - `release_funds(vault_id)` — release to success destination (TODO). - - `redirect_funds(vault_id)` — redirect to failure destination (TODO). - - `cancel_vault(vault_id)` — cancel and return funds to creator; sets status to `Cancelled`. - - `get_vault_state(vault_id)` — return vault state from storage. + - ✅ `create_vault(...)` — Create vault and transfer USDC from creator to contract. + - ✅ `validate_milestone(vault_id)` — Verifier (or authorized party) validates milestone. + - ✅ `release_funds(vault_id, usdc_token)` — Release to success destination after validation or deadline. + - ✅ `redirect_funds(vault_id, usdc_token)` — Redirect to failure destination after deadline without validation. + - ✅ `cancel_vault(vault_id, usdc_token)` — Cancel and return funds to creator (blocked if validated). + - ✅ `get_vault_state(vault_id)` — Return vault state from storage. + - ✅ `vault_count()` — Return total number of vaults. ## Recent Updates @@ -172,6 +173,7 @@ Creates a new productivity vault and locks USDC funds. ```rust pub fn create_vault( env: Env, + usdc_token: Address, creator: Address, amount: i128, start_timestamp: u64, @@ -180,10 +182,11 @@ pub fn create_vault( verifier: Option
, success_destination: Address, failure_destination: Address, -) -> u32 +) -> Result ``` **Parameters:** +- `usdc_token`: Address of the USDC token contract - `creator`: Address of the vault creator (must authorize transaction) - `amount`: USDC amount to lock (in stroops) - `start_timestamp`: When vault becomes active (unix seconds) @@ -192,12 +195,16 @@ pub fn create_vault( - `milestone_hash`: commitment metadata for the off-chain milestone document ======= - `milestone_hash`: SHA-256 hash of milestone document +<<<<<<< Formal-interface-spec-for-Stellar-CLI-/-laboratory +- `verifier`: Optional verifier address (`None` = only the creator may validate) +======= >>>>>>> main - `verifier`: Optional verifier address (None = anyone can validate) +>>>>>>> main - `success_destination`: Address to receive funds on success - `failure_destination`: Address to receive funds on failure -**Returns:** `u32` - Unique vault identifier +**Returns:** `Result` - Unique vault identifier on success. **Requirements:** - Caller must authorize the transaction (`creator.require_auth()`) @@ -219,18 +226,18 @@ pub fn create_vault( Allows the verifier (or authorized party) to validate milestone completion and release funds. ```rust -pub fn validate_milestone(env: Env, vault_id: u32) -> bool +pub fn validate_milestone(env: Env, vault_id: u32) -> Result ``` **Parameters:** - `vault_id`: ID of the vault to validate -**Returns:** `bool` - True if validation successful +**Returns:** `Result` - True if validation successful -**Requirements (TODO):** -- Vault must exist and be in `Active` status -- Caller must be the designated verifier (if set) -- Current timestamp must be before `end_timestamp` +**Requirements:** +- Vault must exist and be in `Active` status. +- Caller must be the designated verifier (if set) or the creator (if no verifier). +- Current timestamp must be before `end_timestamp`. **Emits:** `milestone_validated` event @@ -241,19 +248,20 @@ pub fn validate_milestone(env: Env, vault_id: u32) -> bool Releases locked funds to the success destination (typically after validation). ```rust -pub fn release_funds(env: Env, vault_id: u32) -> bool +pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result ``` **Parameters:** - `vault_id`: ID of the vault to release funds from +- `usdc_token`: Address of the USDC token contract -**Returns:** `bool` - True if release successful +**Returns:** `Result` - True if release successful -**Requirements (TODO):** -- Vault status must be `Active` -- Caller must be authorized (verifier or contract logic) -- Transfers USDC to `success_destination` -- Sets status to `Completed` +**Requirements:** +- Vault status must be `Active`. +- Either milestone is validated OR deadline has passed. +- Transfers USDC to `success_destination`. +- Sets status to `Completed`. --- @@ -262,19 +270,21 @@ pub fn release_funds(env: Env, vault_id: u32) -> bool Redirects funds to the failure destination when milestone is not completed by deadline. ```rust -pub fn redirect_funds(env: Env, vault_id: u32) -> bool +pub fn redirect_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result ``` **Parameters:** - `vault_id`: ID of the vault to redirect funds from +- `usdc_token`: Address of the USDC token contract -**Returns:** `bool` - True if redirect successful +**Returns:** `Result` - True if redirect successful -**Requirements (TODO):** -- Vault status must be `Active` -- Current timestamp must be past `end_timestamp` -- Transfers USDC to `failure_destination` -- Sets status to `Failed` +**Requirements:** +- Vault status must be `Active`. +- Current timestamp must be past `end_timestamp`. +- Milestone must NOT have been validated. +- Transfers USDC to `failure_destination`. +- Sets status to `Failed`. --- @@ -283,19 +293,21 @@ pub fn redirect_funds(env: Env, vault_id: u32) -> bool Allows the creator to cancel the vault and retrieve locked funds. ```rust -pub fn cancel_vault(env: Env, vault_id: u32) -> bool +pub fn cancel_vault(env: Env, vault_id: u32, usdc_token: Address) -> Result ``` **Parameters:** - `vault_id`: ID of the vault to cancel +- `usdc_token`: Address of the USDC token contract -**Returns:** `bool` - True if cancellation successful +**Returns:** `Result` - True if cancellation successful -**Requirements (TODO):** -- Caller must be the vault creator -- Vault status must be `Active` -- Returns USDC to creator -- Sets status to `Cancelled` +**Requirements:** +- Caller must be the vault creator (`creator.require_auth()`). +- Vault status must be `Active`. +- Milestone must NOT have been validated. +- Returns USDC to creator. +- Sets status to `Cancelled`. --- diff --git a/contract-interface.json b/contract-interface.json new file mode 100644 index 0000000..dd93099 --- /dev/null +++ b/contract-interface.json @@ -0,0 +1,133 @@ +{ + "name": "disciplr-vault", + "version": "0.1.0", + "description": "Programmable time-locked USDC vaults on Stellar", + "types": { + "VaultStatus": { + "type": "enum", + "variants": { + "Active": 0, + "Completed": 1, + "Failed": 2, + "Cancelled": 3 + } + }, + "ProductivityVault": { + "type": "struct", + "fields": [ + { "name": "creator", "type": "Address" }, + { "name": "amount", "type": "i128" }, + { "name": "start_timestamp", "type": "u64" }, + { "name": "end_timestamp", "type": "u64" }, + { "name": "milestone_hash", "type": "BytesN<32>" }, + { "name": "verifier", "type": "Option
" }, + { "name": "success_destination", "type": "Address" }, + { "name": "failure_destination", "type": "Address" }, + { "name": "status", "type": "VaultStatus" }, + { "name": "milestone_validated", "type": "bool" } + ] + }, + "Error": { + "type": "error", + "variants": { + "VaultNotFound": 1, + "NotAuthorized": 2, + "VaultNotActive": 3, + "InvalidTimestamp": 4, + "MilestoneExpired": 5, + "InvalidStatus": 6, + "InvalidAmount": 7, + "InvalidTimestamps": 8, + "DurationTooLong": 9, + "MilestoneAlreadyValidated": 10 + } + } + }, + "functions": [ + { + "name": "create_vault", + "inputs": [ + { "name": "usdc_token", "type": "Address" }, + { "name": "creator", "type": "Address" }, + { "name": "amount", "type": "i128" }, + { "name": "start_timestamp", "type": "u64" }, + { "name": "end_timestamp", "type": "u64" }, + { "name": "milestone_hash", "type": "BytesN<32>" }, + { "name": "verifier", "type": "Option
" }, + { "name": "success_destination", "type": "Address" }, + { "name": "failure_destination", "type": "Address" } + ], + "outputs": { "type": "Result" } + }, + { + "name": "validate_milestone", + "inputs": [ + { "name": "vault_id", "type": "u32" } + ], + "outputs": { "type": "Result" } + }, + { + "name": "release_funds", + "inputs": [ + { "name": "vault_id", "type": "u32" }, + { "name": "usdc_token", "type": "Address" } + ], + "outputs": { "type": "Result" } + }, + { + "name": "redirect_funds", + "inputs": [ + { "name": "vault_id", "type": "u32" }, + { "name": "usdc_token", "type": "Address" } + ], + "outputs": { "type": "Result" } + }, + { + "name": "cancel_vault", + "inputs": [ + { "name": "vault_id", "type": "u32" }, + { "name": "usdc_token", "type": "Address" } + ], + "outputs": { "type": "Result" } + }, + { + "name": "get_vault_state", + "inputs": [ + { "name": "vault_id", "type": "u32" } + ], + "outputs": { "type": "Option" } + }, + { + "name": "vault_count", + "inputs": [], + "outputs": { "type": "u32" } + } + ], + "events": [ + { + "name": "vault_created", + "topic": ["vault_created", "u32"], + "data": "ProductivityVault" + }, + { + "name": "milestone_validated", + "topic": ["milestone_validated", "u32"], + "data": null + }, + { + "name": "funds_released", + "topic": ["funds_released", "u32"], + "data": "i128" + }, + { + "name": "funds_redirected", + "topic": ["funds_redirected", "u32"], + "data": "i128" + }, + { + "name": "vault_cancelled", + "topic": ["vault_cancelled", "u32"], + "data": null + } + ] +} diff --git a/src/lib.rs b/src/lib.rs index 81acd05..e69de29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,1611 +0,0 @@ -[report] -# Tarpaulin configuration for accurate coverage reporting -out = ["Html", "Lcov", "Stdout"] -output-dir = "coverage" - -[run] -# Run all tests -all-features = true -workspace = true - -<<<<<<< feature/distinct-destinations -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- -// -// Contract-specific errors used in revert paths. Follows Soroban error -// conventions: use Result and return Err(Error::Variant) instead -// of generic panics where appropriate. - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum Error { - /// Vault with the given id does not exist. - VaultNotFound = 1, - /// Caller is not authorized for this operation (e.g. not verifier/creator, or release before deadline without validation). - NotAuthorized = 2, - /// Vault is not in Active status (e.g. already Completed, Failed, or Cancelled). - VaultNotActive = 3, - /// Timestamp constraint violated (e.g. redirect before end_timestamp, or invalid time window). - InvalidTimestamp = 4, - /// Validation is no longer allowed because current time is at or past end_timestamp. - MilestoneExpired = 5, - /// Vault is in an invalid status for the requested operation. - InvalidStatus = 6, - /// Amount must be positive (e.g. create_vault amount <= 0). - InvalidAmount = 7, - /// start_timestamp must be strictly less than end_timestamp. - InvalidTimestamps = 8, - /// Vault duration (end − start) exceeds MAX_VAULT_DURATION. - DurationTooLong = 9, - /// success_destination and failure_destination must be different addresses. - SameDestination = 10, -} -======= -# Exclude test code from coverage -exclude-files = [] - -# Count branches for more accurate coverage -count-branches = true ->>>>>>> main - -// --------------------------------------------------------------------------- -// Data types -// --------------------------------------------------------------------------- - -#[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum VaultStatus { - Active = 0, - Completed = 1, - Failed = 2, - Cancelled = 3, -} - -/// Core vault record persisted in contract storage. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProductivityVault { - /// Address that created (and funded) the vault. - pub creator: Address, - /// USDC amount locked in the vault (in stroops / smallest unit). - pub amount: i128, - /// Ledger timestamp when the commitment period starts. - pub start_timestamp: u64, - /// Ledger timestamp after which deadline-based release is allowed. - pub end_timestamp: u64, - /// Hash representing the milestone the creator commits to. - pub milestone_hash: BytesN<32>, - /// Optional designated verifier. When `Some(addr)`, only that address may call `validate_milestone`. - /// When `None`, only the creator may call `validate_milestone` (no third-party validation). - /// `release_funds` is consistent: after deadline, anyone can release; before deadline, only - /// after the designated validator (or creator when verifier is None) has validated. - pub verifier: Option
, - /// Funds go here on success. - pub success_destination: Address, - /// Funds go here on failure/redirect. - pub failure_destination: Address, - /// Current lifecycle status. - pub status: VaultStatus, - /// Set to `true` once the verifier (or authorised party) calls `validate_milestone`. - /// Used by `release_funds` to allow early release before the deadline. - pub milestone_validated: bool, -} - -// --------------------------------------------------------------------------- -// Storage keys -// --------------------------------------------------------------------------- - -// Constants to prevent abuse, spam, and potential overflow issues -pub const MAX_VAULT_DURATION: u64 = 365 * 24 * 60 * 60; // 1 year in seconds -pub const MIN_AMOUNT: i128 = 10_000_000; // 1 USDC with 7 decimals -pub const MAX_AMOUNT: i128 = 10_000_000_000_000; // 10 million USDC with 7 decimals - -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - Vault(u32), - VaultCount, -} - -// --------------------------------------------------------------------------- -// Contract -// --------------------------------------------------------------------------- - -#[contract] -pub struct DisciplrVault; - -#[contractimpl] -impl DisciplrVault { - /// Create a new productivity vault. - /// -<<<<<<< feature/distinct-destinations - /// # Validation Rules - /// - `amount` must be within `[MIN_AMOUNT, MAX_AMOUNT]`; otherwise returns `Error::InvalidAmount`. - /// - `start_timestamp` must be strictly less than `end_timestamp`; otherwise returns `Error::InvalidTimestamps`. - /// - `end_timestamp - start_timestamp` must not exceed `MAX_VAULT_DURATION`; otherwise returns `Error::DurationTooLong`. - /// - `success_destination` must differ from `failure_destination`; otherwise returns `Error::SameDestination`. - /// Allowing equal destinations would make the success/failure outcome indistinguishable to the - /// creator, removing the accountability incentive that is the core purpose of the vault. -======= - /// This function follows the **Checks-Effects-Interactions** pattern: - /// 1. **Checks**: Validates `amount`, `start_timestamp`, `end_timestamp`, and `creator` authorization. - /// 2. **Interactions**: Transfers USDC from `creator` to the contract. - /// 3. **Effects**: Increments `VaultCount`, creates the `ProductivityVault` record, and emits `vault_created`. ->>>>>>> main - /// - /// # Errors - /// - `Error::InvalidAmount`: if amount is not within [MIN_AMOUNT, MAX_AMOUNT]. - /// - `Error::InvalidTimestamp`: if `start_timestamp` is in the past. - /// - `Error::InvalidTimestamps`: if `end_timestamp <= start_timestamp`. - /// - `Error::DurationTooLong`: if the vault window exceeds `MAX_VAULT_DURATION`. - pub fn create_vault( - env: Env, - usdc_token: Address, - creator: Address, - amount: i128, - start_timestamp: u64, - end_timestamp: u64, - milestone_hash: BytesN<32>, - verifier: Option
, - success_destination: Address, - failure_destination: Address, - ) -> Result { - creator.require_auth(); - - // Validate amount bounds - if amount < MIN_AMOUNT { - return Err(Error::InvalidAmount); - } - if amount > MAX_AMOUNT { - return Err(Error::InvalidAmount); - } - - // Validate timestamps - let current_time = env.ledger().timestamp(); - if start_timestamp < current_time { - return Err(Error::InvalidTimestamp); - } - if end_timestamp <= start_timestamp { - return Err(Error::InvalidTimestamps); - } - - // Validate duration - let duration = end_timestamp - start_timestamp; - if duration > MAX_VAULT_DURATION { - return Err(Error::DurationTooLong); - } - - // Validate destinations are distinct - if success_destination == failure_destination { - return Err(Error::SameDestination); - } - - // Pull USDC from creator into this contract. - let token_client = token::Client::new(&env, &usdc_token); - token_client.transfer(&creator, &env.current_contract_address(), &amount); - - let mut vault_count: u32 = env - .storage() - .instance() - .get(&DataKey::VaultCount) - .unwrap_or(0); - let vault_id = vault_count; - vault_count += 1; - env.storage() - .instance() - .set(&DataKey::VaultCount, &vault_count); - let vault = ProductivityVault { - creator, - amount, - start_timestamp, - end_timestamp, - milestone_hash, - verifier, - success_destination, - failure_destination, - status: VaultStatus::Active, - milestone_validated: false, - }; - - env.storage() - .instance() - .set(&DataKey::Vault(vault_id), &vault); - - env.events().publish( - (Symbol::new(&env, "vault_created"), vault_id), - vault.clone(), - ); - - Ok(vault_id) - } - - // ----------------------------------------------------------------------- - // validate_milestone - // ----------------------------------------------------------------------- - - /// Allows the verifier (or authorized party) to validate milestone completion. - /// - /// # Safety and Trust - /// When verifier is `Some(addr)`, only that address may validate; when `None`, only the creator may validate. - /// Rejects when current time >= `end_timestamp` (`Error::MilestoneExpired`). - /// - /// # Events - /// Emits `milestone_validated` on success. - pub fn validate_milestone(env: Env, vault_id: u32) -> Result { - let vault_key = DataKey::Vault(vault_id); - let mut vault: ProductivityVault = env - .storage() - .instance() - .get(&vault_key) - .ok_or(Error::VaultNotFound)?; - - if vault.status != VaultStatus::Active { - return Err(Error::VaultNotActive); - } - - // When verifier is Some, only that address may validate; when None, only creator may validate. - if let Some(ref verifier) = vault.verifier { - verifier.require_auth(); - } else { - vault.creator.require_auth(); - } - - // Timestamp check: rejects when current time >= end_timestamp - if env.ledger().timestamp() >= vault.end_timestamp { - return Err(Error::MilestoneExpired); - } - - vault.milestone_validated = true; - env.storage().instance().set(&vault_key, &vault); - - env.events() - .publish((Symbol::new(&env, "milestone_validated"), vault_id), ()); - Ok(true) - } - - // ----------------------------------------------------------------------- - // release_funds - // ----------------------------------------------------------------------- - - /// Release vault funds to `success_destination`. - /// - /// # Prerequisites - /// - Vault status must be `Active`. - /// - Deadline reached (`now >= end_timestamp`) OR milestone validated (`milestone_validated == true`). - /// - /// # Events - /// Emits `funds_released` with the released amount. - pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result { - let vault_key = DataKey::Vault(vault_id); - let mut vault: ProductivityVault = env - .storage() - .instance() - .get(&vault_key) - .ok_or(Error::VaultNotFound)?; - - if vault.status != VaultStatus::Active { - return Err(Error::VaultNotActive); - } - - // Check release conditions. - let now = env.ledger().timestamp(); - let deadline_reached = now >= vault.end_timestamp; - let validated = vault.milestone_validated; - - if !validated && !deadline_reached { - return Err(Error::NotAuthorized); - } - - // --- EFFECTS --- - vault.status = VaultStatus::Completed; - env.storage().instance().set(&vault_key, &vault); - - env.events().publish( - (Symbol::new(&env, "funds_released"), vault_id), - vault.amount, - ); - - // --- INTERACTIONS --- - let token_client = token::Client::new(&env, &usdc_token); - token_client.transfer( - &env.current_contract_address(), - &vault.success_destination, - &vault.amount, - ); - - Ok(true) - } - - // ----------------------------------------------------------------------- - // redirect_funds - // ----------------------------------------------------------------------- - - /// Redirect funds to `failure_destination` (e.g. after deadline without validation). - /// - /// # Prerequisites - /// - Vault status must be `Active`. - /// - Current time must be strictly past `end_timestamp`. - /// - Milestone must NOT have been validated. - /// - /// # Events - /// Emits `funds_redirected` with the redirected amount. - pub fn redirect_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result { - let vault_key = DataKey::Vault(vault_id); - let mut vault: ProductivityVault = env - .storage() - .instance() - .get(&vault_key) - .ok_or(Error::VaultNotFound)?; - - if vault.status != VaultStatus::Active { - return Err(Error::VaultNotActive); - } - - if env.ledger().timestamp() < vault.end_timestamp { - return Err(Error::InvalidTimestamp); - } - - if vault.milestone_validated { - return Err(Error::NotAuthorized); - } - - // --- EFFECTS --- - vault.status = VaultStatus::Failed; - env.storage().instance().set(&vault_key, &vault); - - env.events().publish( - (Symbol::new(&env, "funds_redirected"), vault_id), - vault.amount, - ); - - // --- INTERACTIONS --- - let token_client = token::Client::new(&env, &usdc_token); - token_client.transfer( - &env.current_contract_address(), - &vault.failure_destination, - &vault.amount, - ); - - Ok(true) - } - - // ----------------------------------------------------------------------- - // cancel_vault - // ----------------------------------------------------------------------- - - /// Cancel vault and return funds to creator. - /// - /// # Prerequisites - /// - Only the creator may call this method (`creator.require_auth()`). - /// - Vault status must be `Active`. - /// - /// # Events - /// Emits `vault_cancelled`. - pub fn cancel_vault(env: Env, vault_id: u32, usdc_token: Address) -> Result { - let vault_key = DataKey::Vault(vault_id); - let mut vault: ProductivityVault = env - .storage() - .instance() - .get(&vault_key) - .ok_or(Error::VaultNotFound)?; - - vault.creator.require_auth(); - - if vault.status != VaultStatus::Active { - return Err(Error::VaultNotActive); - } - - // --- EFFECTS --- - vault.status = VaultStatus::Cancelled; - env.storage().instance().set(&vault_key, &vault); - - env.events() - .publish((Symbol::new(&env, "vault_cancelled"), vault_id), ()); - - // --- INTERACTIONS --- - let token_client = token::Client::new(&env, &usdc_token); - token_client.transfer( - &env.current_contract_address(), - &vault.creator, - &vault.amount, - ); - - Ok(true) - } - - // ----------------------------------------------------------------------- - // get_vault_state - // ----------------------------------------------------------------------- - - /// Return current vault state, or `None` if no vault record exists for that ID. - /// - /// This contract does not remove vault records during normal lifecycle transitions. - /// Vaults that are completed, failed, or cancelled remain readable and return - /// `Some(ProductivityVault)` with their terminal status. - /// - /// Under normal contract operation, `None` therefore means the vault ID was - /// never created. If storage were cleared externally, `None` would also be - /// observed, but the contract itself has no path that deletes vault records. - pub fn get_vault_state(env: Env, vault_id: u32) -> Option { - env.storage().instance().get(&DataKey::Vault(vault_id)) - } - - /// Return the number of vault IDs assigned so far. - pub fn vault_count(env: Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::VaultCount) - .unwrap_or(0) - } - - /// Return the contract version string. - /// - /// This follows the versioning defined in `Cargo.toml`. Useful for - /// integrators and auditors to verify the deployed bytecode. - pub fn version(env: Env) -> Symbol { - Symbol::new(&env, env!("CARGO_PKG_VERSION")) - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - extern crate std; - - use super::*; - use soroban_sdk::{ - testutils::{Address as _, AuthorizedFunction, Events, Ledger}, - token::{StellarAssetClient, TokenClient}, - Address, BytesN, Env, Symbol, TryIntoVal, - }; - - // ----------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------- - - struct TestSetup { - env: Env, - contract_id: Address, - usdc_token: Address, - creator: Address, - verifier: Address, - success_dest: Address, - failure_dest: Address, - amount: i128, - start_timestamp: u64, - end_timestamp: u64, - } - - impl TestSetup { - fn new() -> Self { - let env = Env::default(); - env.mock_all_auths(); - - // Set ledger time to 0 so test timestamps work - env.ledger().set_timestamp(0); - - // Deploy USDC mock token. - let usdc_admin = Address::generate(&env); - let usdc_token = env.register_stellar_asset_contract_v2(usdc_admin.clone()); - let usdc_addr = usdc_token.address(); - let usdc_asset = StellarAssetClient::new(&env, &usdc_addr); - - // Actors. - let creator = Address::generate(&env); - let verifier = Address::generate(&env); - let success_dest = Address::generate(&env); - let failure_dest = Address::generate(&env); - - // Mint USDC to creator. - let amount: i128 = MIN_AMOUNT; - usdc_asset.mint(&creator, &amount); - - // Deploy contract. - let contract_id = env.register(DisciplrVault, ()); - - TestSetup { - env, - contract_id, - usdc_token: usdc_addr, - creator, - verifier, - success_dest, - failure_dest, - amount, - start_timestamp: 100, - end_timestamp: 1_000, - } - } - - fn client(&self) -> DisciplrVaultClient<'_> { - DisciplrVaultClient::new(&self.env, &self.contract_id) - } - - fn usdc_client(&self) -> TokenClient<'_> { - TokenClient::new(&self.env, &self.usdc_token) - } - - fn milestone_hash(&self) -> BytesN<32> { - BytesN::from_array(&self.env, &[1u8; 32]) - } - - fn create_default_vault(&self) -> u32 { - self.client().create_vault( - &self.usdc_token, - &self.creator, - &self.amount, - &self.start_timestamp, - &self.end_timestamp, - &self.milestone_hash(), - &Some(self.verifier.clone()), - &self.success_dest, - &self.failure_dest, - ) - } - - /// Create vault with verifier = None (only creator can validate). - fn create_vault_no_verifier(&self) -> u32 { - self.client().create_vault( - &self.usdc_token, - &self.creator, - &self.amount, - &self.start_timestamp, - &self.end_timestamp, - &self.milestone_hash(), - &None, - &self.success_dest, - &self.failure_dest, - ) - } - } - - // ----------------------------------------------------------------------- - // Upstream Tests (Migrated & Merged) - // ----------------------------------------------------------------------- - - #[test] - fn get_vault_state_returns_some_with_matching_fields() { - let setup = TestSetup::new(); - let client = setup.client(); - - let vault_id = setup.create_default_vault(); - - let vault_state = client.get_vault_state(&vault_id); - assert!(vault_state.is_some()); - - let vault = vault_state.unwrap(); - assert_eq!(vault.creator, setup.creator); - assert_eq!(vault.amount, setup.amount); - assert_eq!(vault.start_timestamp, setup.start_timestamp); - assert_eq!(vault.end_timestamp, setup.end_timestamp); - assert_eq!(vault.milestone_hash, setup.milestone_hash()); - assert_eq!(vault.verifier, Some(setup.verifier)); - assert_eq!(vault.success_destination, setup.success_dest); - assert_eq!(vault.failure_destination, setup.failure_dest); - assert_eq!(vault.status, VaultStatus::Active); - } - - #[test] - fn test_get_vault_state_missing_returns_none() { - let setup = TestSetup::new(); - let client = setup.client(); - - assert!(client.get_vault_state(&999).is_none()); - } - - #[test] - fn test_get_vault_state_cancelled_vault_still_returns_some() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - let result = client.cancel_vault(&vault_id, &setup.usdc_token); - assert!(result); - - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_eq!(vault.status, VaultStatus::Cancelled); - } - - #[test] - fn test_get_vault_state_failed_vault_still_returns_some() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - setup.env.ledger().set_timestamp(setup.end_timestamp + 1); - - let result = client.redirect_funds(&vault_id, &setup.usdc_token); - assert!(result); - - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_eq!(vault.status, VaultStatus::Failed); - } - - /// Issue #42: milestone_hash passed to create_vault is stored and returned by get_vault_state. - #[test] - fn test_milestone_hash_storage_and_retrieval() { - let setup = TestSetup::new(); - let client = setup.client(); - - let custom_hash = BytesN::from_array(&setup.env, &[0xab; 32]); - setup.env.ledger().set_timestamp(setup.start_timestamp); - - let vault_id = client.create_vault( - &setup.usdc_token, - &setup.creator, - &setup.amount, - &setup.start_timestamp, - &setup.end_timestamp, - &custom_hash, - &Some(setup.verifier.clone()), - &setup.success_dest, - &setup.failure_dest, - ); - - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_eq!(vault.milestone_hash, custom_hash); - } - - #[test] - fn test_create_vault_invalid_amount_returns_error() { - let setup = TestSetup::new(); - let client = setup.client(); - - let result = client.try_create_vault( - &setup.usdc_token, - &setup.creator, - &0i128, - &setup.start_timestamp, - &setup.end_timestamp, - &setup.milestone_hash(), - &None, - &setup.success_dest, - &setup.failure_dest, - ); - assert!( - result.is_err(), - "create_vault with amount 0 should return InvalidAmount" - ); - } - - #[test] - fn test_create_vault_invalid_timestamps_returns_error() { - let setup = TestSetup::new(); - let client = setup.client(); - - let result = client.try_create_vault( - &setup.usdc_token, - &setup.creator, - &setup.amount, - &1000u64, - &1000u64, - &setup.milestone_hash(), - &None, - &setup.success_dest, - &setup.failure_dest, - ); - assert!( - result.is_err(), - "create_vault with start >= end should return InvalidTimestamps" - ); - } - - #[test] - fn test_validate_milestone_rejects_after_end() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - // Advance ledger to exactly end_timestamp - setup.env.ledger().set_timestamp(setup.end_timestamp); - - // Try to validate milestone - should fail with MilestoneExpired - let result = client.try_validate_milestone(&vault_id); - assert!(result.is_err()); - - // Advance ledger past end_timestamp - setup.env.ledger().set_timestamp(setup.end_timestamp + 1); - - // Try to validate milestone - should also fail - let result = client.try_validate_milestone(&vault_id); - assert!(result.is_err()); - } - - #[test] - fn test_validate_milestone_succeeds_before_end() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - // Set time to just before end - setup.env.ledger().set_timestamp(setup.end_timestamp - 1); - - let success = client.validate_milestone(&vault_id); - assert!(success); - - let vault = client.get_vault_state(&vault_id).unwrap(); - // Validation now sets milestone_validated, NOT status = Completed - assert!(vault.milestone_validated); - assert_eq!(vault.status, VaultStatus::Active); - } - - /// Issue #14: When verifier is None, only creator may validate. Creator succeeds. - #[test] - fn test_validate_milestone_verifier_none_creator_succeeds() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_vault_no_verifier(); - - setup.env.ledger().set_timestamp(setup.end_timestamp - 1); - - let result = client.validate_milestone(&vault_id); - assert!(result); - - let vault = client.get_vault_state(&vault_id).unwrap(); - assert!(vault.milestone_validated); - assert_eq!(vault.verifier, None); - } - - /// Issue #14: When verifier is None, release_funds after deadline (no validation) still works. - #[test] - fn test_release_funds_verifier_none_after_deadline() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_vault_no_verifier(); - - setup.env.ledger().set_timestamp(setup.end_timestamp + 1); - - let result = client.release_funds(&vault_id, &setup.usdc_token); - assert!(result); - - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_eq!(vault.status, VaultStatus::Completed); - } - - #[test] - fn test_release_funds_rejects_non_existent_vault() { - let setup = TestSetup::new(); - let client = setup.client(); - - let result = client.try_release_funds(&999, &setup.usdc_token); - assert!(result.is_err()); - } - - #[test] - fn test_redirect_funds_rejects_non_existent_vault() { - let setup = TestSetup::new(); - let client = setup.client(); - - let result = client.try_redirect_funds(&999, &setup.usdc_token); - assert!(result.is_err()); - } - - #[test] - #[should_panic(expected = "Error(Contract, #8)")] - fn create_vault_rejects_start_equal_end() { - let setup = TestSetup::new(); - let client = setup.client(); - - client.create_vault( - &setup.usdc_token, - &setup.creator, - &setup.amount, - &1000, - &1000, // start == end - &setup.milestone_hash(), - &None, - &setup.success_dest, - &setup.failure_dest, - ); - } - - #[test] - #[should_panic(expected = "Error(Contract, #8)")] - fn create_vault_rejects_start_greater_than_end() { - let setup = TestSetup::new(); - let client = setup.client(); - - client.create_vault( - &setup.usdc_token, - &setup.creator, - &setup.amount, - &2000, - &1000, // start > end - &setup.milestone_hash(), - &None, - &setup.success_dest, - &setup.failure_dest, - ); - } - - // ----------------------------------------------------------------------- - // Original branch tests (adapted for new signature and Results) - // ----------------------------------------------------------------------- - - #[test] - fn test_create_vault_increments_id() { - let setup = TestSetup::new(); - - // Mint extra USDC for second vault. - let usdc_asset = StellarAssetClient::new(&setup.env, &setup.usdc_token); - usdc_asset.mint(&setup.creator, &setup.amount); - - let id_a = setup.create_default_vault(); - let id_b = setup.create_default_vault(); - assert_ne!(id_a, id_b, "vault IDs must be distinct"); - assert_eq!(id_b, id_a + 1); - } - - #[test] - fn test_release_funds_after_validation() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - // Validate milestone. - client.validate_milestone(&vault_id); - - let usdc = setup.usdc_client(); - let success_before = usdc.balance(&setup.success_dest); - - // Release. - let result = client.release_funds(&vault_id, &setup.usdc_token); - assert!(result); - - // Success destination received the funds. - let success_after = usdc.balance(&setup.success_dest); - assert_eq!(success_after - success_before, setup.amount); - - // Vault status is Completed. - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_eq!(vault.status, VaultStatus::Completed); - } - - #[test] - fn test_version() { - let setup = TestSetup::new(); - let client = setup.client(); - let version = client.version(); - // Since we know Cargo.toml has version 0.1.0 - assert_eq!(version, Symbol::new(&setup.env, "0.1.0")); - } -} - - #[test] - fn test_release_funds_after_deadline() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - // Advance ledger PAST end_timestamp. - setup.env.ledger().set_timestamp(setup.end_timestamp + 1); - - let usdc = setup.usdc_client(); - let before = usdc.balance(&setup.success_dest); - - let result = client.release_funds(&vault_id, &setup.usdc_token); - assert!(result); - - assert_eq!(usdc.balance(&setup.success_dest) - before, setup.amount); - - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_eq!(vault.status, VaultStatus::Completed); - } - - #[test] - fn test_double_release_rejected() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - setup.env.ledger().set_timestamp(setup.end_timestamp + 1); - - client.release_funds(&vault_id, &setup.usdc_token); - // Second call — must error. - assert!(client - .try_release_funds(&vault_id, &setup.usdc_token) - .is_err()); - } - - #[test] - fn test_release_cancelled_vault_rejected() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - client.cancel_vault(&vault_id, &setup.usdc_token); - // Release after cancel — must error. - assert!(client - .try_release_funds(&vault_id, &setup.usdc_token) - .is_err()); - } - - #[test] - fn test_release_not_validated_before_deadline_rejected() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - // Neither validated nor past deadline — must error. - assert!(client - .try_release_funds(&vault_id, &setup.usdc_token) - .is_err()); - } - - #[test] - fn test_validate_milestone_on_completed_vault_rejected() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - setup.env.ledger().set_timestamp(setup.end_timestamp + 1); - client.release_funds(&vault_id, &setup.usdc_token); - - // Validate after completion — must error. - assert!(client.try_validate_milestone(&vault_id).is_err()); - } - - #[test] - fn test_redirect_funds_after_deadline_without_validation() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - setup.env.ledger().set_timestamp(setup.end_timestamp + 1); - - let usdc = setup.usdc_client(); - let before = usdc.balance(&setup.failure_dest); - - let result = client.redirect_funds(&vault_id, &setup.usdc_token); - assert!(result); - assert_eq!(usdc.balance(&setup.failure_dest) - before, setup.amount); - - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_eq!(vault.status, VaultStatus::Failed); - } - - #[test] - fn test_redirect_funds_before_deadline_rejected() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - // Still before deadline — must error. - assert!(client - .try_redirect_funds(&vault_id, &setup.usdc_token) - .is_err()); - } - - #[test] - fn test_double_redirect_rejected() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - setup.env.ledger().set_timestamp(setup.end_timestamp + 1); - - let result = client.redirect_funds(&vault_id, &setup.usdc_token); - assert!(result); - // Second redirect — must error (vault already Failed). - assert!(client - .try_redirect_funds(&vault_id, &setup.usdc_token) - .is_err()); - } - - #[test] - fn test_cancel_vault_returns_funds_to_creator() { - let setup = TestSetup::new(); - let client = setup.client(); - - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - let usdc = setup.usdc_client(); - let before = usdc.balance(&setup.creator); - - let result = client.cancel_vault(&vault_id, &setup.usdc_token); - assert!(result); - assert_eq!(usdc.balance(&setup.creator) - before, setup.amount); - - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_eq!(vault.status, VaultStatus::Cancelled); - } - - // ----------------------------------------------------------------------- - // More upstream tests migrated - // ----------------------------------------------------------------------- - - #[test] - #[should_panic] - fn test_create_vault_fails_without_auth() { - let env = Env::default(); - let usdc_token = Address::generate(&env); - let creator = Address::generate(&env); - let success_addr = Address::generate(&env); - let failure_addr = Address::generate(&env); - let verifier = Address::generate(&env); - let milestone_hash = BytesN::<32>::from_array(&env, &[0u8; 32]); - - // DO NOT authorize the creator - let _vault_id = DisciplrVault::create_vault( - env, - usdc_token, - creator, - 1000, - 100, - 200, - milestone_hash, - Some(verifier), - success_addr, - failure_addr, - ); - } - - #[test] - #[should_panic(expected = "Error(Contract, #7)")] - fn test_create_vault_zero_amount() { - let setup = TestSetup::new(); - let client = setup.client(); - - client.create_vault( - &setup.usdc_token, - &setup.creator, - &0i128, - &1000, - &2000, - &setup.milestone_hash(), - &None, - &setup.success_dest, - &setup.failure_dest, - ); - } - - #[test] - #[should_panic] - fn test_create_vault_caller_differs_from_creator() { - let env = Env::default(); - let usdc_token = Address::generate(&env); - let creator = Address::generate(&env); - let different_caller = Address::generate(&env); - let success_addr = Address::generate(&env); - let failure_addr = Address::generate(&env); - let verifier = Address::generate(&env); - let milestone_hash = BytesN::<32>::from_array(&env, &[1u8; 32]); - - different_caller.require_auth(); - - let _vault_id = DisciplrVault::create_vault( - env, - usdc_token, - creator, // This address is NOT authorized - 1000, - 100, - 200, - milestone_hash, - Some(verifier), - success_addr, - failure_addr, - ); - } - - #[test] - fn test_vault_parameters_with_and_without_verifier() { - let _verifier_some: Option
= None; - let _no_verifier: Option
= None; - assert!(_verifier_some.is_none()); - assert!(_no_verifier.is_none()); - } - - #[test] - fn test_vault_amount_parameters() { - let amounts = [100i128, 1000, 10000, 100000]; - for amount in amounts { - assert!(amount > 0, "Amount {} should be positive", amount); - } - } - - #[test] - fn test_vault_timestamp_scenarios() { - let start = 100u64; - let end = 200u64; - assert!(start < end, "Start should be before end"); - } - - #[test] - fn test_vault_milestone_hash_generation() { - let env = Env::default(); - let _hash_1 = BytesN::<32>::from_array(&env, &[0u8; 32]); - let _hash_2 = BytesN::<32>::from_array(&env, &[1u8; 32]); - let _hash_3 = BytesN::<32>::from_array(&env, &[255u8; 32]); - assert_ne!([0u8; 32], [1u8; 32]); - assert_ne!([1u8; 32], [255u8; 32]); - } - - #[test] - #[should_panic] - fn test_authorization_prevents_unauthorized_creation() { - let env = Env::default(); - let usdc_token = Address::generate(&env); - let creator = Address::generate(&env); - let attacker = Address::generate(&env); - let success_addr = Address::generate(&env); - let failure_addr = Address::generate(&env); - let milestone_hash = BytesN::<32>::from_array(&env, &[4u8; 32]); - - attacker.require_auth(); - - let _vault_id = DisciplrVault::create_vault( - env, - usdc_token, - creator, - 5000, - 100, - 200, - milestone_hash, - None, - success_addr, - failure_addr, - ); - } - - #[test] - fn test_create_vault_emits_event_and_returns_id() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(0); - - let usdc_admin = Address::generate(&env); - let usdc_token = env.register_stellar_asset_contract_v2(usdc_admin.clone()); - let usdc_addr = usdc_token.address(); - let usdc_asset = StellarAssetClient::new(&env, &usdc_addr); - - let contract_id = env.register(DisciplrVault, ()); - let client = DisciplrVaultClient::new(&env, &contract_id); - - let creator = Address::generate(&env); - let success_destination = Address::generate(&env); - let failure_destination = Address::generate(&env); - let verifier = Address::generate(&env); - let milestone_hash = BytesN::from_array(&env, &[1u8; 32]); - let amount = MIN_AMOUNT; - let start_timestamp = 1_000_000u64; - let end_timestamp = 2_000_000u64; - - usdc_asset.mint(&creator, &amount); - - let vault_id = client.create_vault( - &usdc_addr, - &creator, - &amount, - &start_timestamp, - &end_timestamp, - &milestone_hash, - &Some(verifier.clone()), - &success_destination, - &failure_destination, - ); - - // Vault count starts at 0, first vault gets ID 0 - assert_eq!(vault_id, 0u32); - - let auths = env.auths(); - // Since we also call token_client.transfer inside, the auths might have multiple invocations - // We ensure a `create_vault` invocation is inside the auth list - let mut found_create_auth = false; - for (auth_addr, invocation) in auths { - if auth_addr == creator { - if let AuthorizedFunction::Contract((contract, function_name, _)) = - &invocation.function - { - if *contract == contract_id - && *function_name == Symbol::new(&env, "create_vault") - { - found_create_auth = true; - } - } - } - } - assert!( - found_create_auth, - "create_vault should be authenticated by creator" - ); - - let all_events = env.events().all(); - // token transfer also emits events, so we find the one related to us - let mut found_vault_created = false; - for (emitting_contract, topics, _) in all_events { - if emitting_contract == contract_id { - let event_name: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); - if event_name == Symbol::new(&env, "vault_created") { - let event_vault_id: u32 = topics.get(1).unwrap().try_into_val(&env).unwrap(); - assert_eq!(event_vault_id, vault_id); - found_vault_created = true; - } - } - } - assert!(found_vault_created, "vault_created event must be emitted"); - } - - #[test] - #[should_panic(expected = "Error(Contract, #3)")] - fn test_cancel_vault_when_completed_fails() { - let setup = TestSetup::new(); - let client = setup.client(); - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - // Release funds to make it Completed - client.validate_milestone(&vault_id); - client.release_funds(&vault_id, &setup.usdc_token); - - // Attempt to cancel - should panic with error VaultNotActive - client.cancel_vault(&vault_id, &setup.usdc_token); - } - - #[test] - #[should_panic(expected = "Error(Contract, #3)")] - fn test_cancel_vault_when_failed_fails() { - let setup = TestSetup::new(); - let client = setup.client(); - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - // Expire and redirect funds to make it Failed - setup.env.ledger().set_timestamp(setup.end_timestamp + 1); - client.redirect_funds(&vault_id, &setup.usdc_token); - - // Attempt to cancel - should panic - client.cancel_vault(&vault_id, &setup.usdc_token); - } - - #[test] - #[should_panic(expected = "Error(Contract, #3)")] - fn test_cancel_vault_when_cancelled_fails() { - let setup = TestSetup::new(); - let client = setup.client(); - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - // Cancel it - client.cancel_vault(&vault_id, &setup.usdc_token); - - // Attempt to cancel again - should panic - client.cancel_vault(&vault_id, &setup.usdc_token); - } - - #[test] - #[should_panic] - fn test_cancel_vault_non_creator_fails() { - let setup = TestSetup::new(); - setup.env.ledger().set_timestamp(setup.start_timestamp); - let vault_id = setup.create_default_vault(); - - // Try to cancel with a different address - // The client currently signs with mock_all_auths(), - // to properly test this we need a real failure in auth. - // But since mock_all_auths allows everything, we just rely on `VaultNotFound` - // or we manually create a test without mock_all_auths - let env = Env::default(); - let contract_id = env.register(DisciplrVault, ()); - let client_no_auth = DisciplrVaultClient::new(&env, &contract_id); - - client_no_auth.cancel_vault(&vault_id, &setup.usdc_token); - } - - #[test] - #[should_panic(expected = "Error(Contract, #1)")] - fn test_cancel_vault_nonexistent_fails() { - let setup = TestSetup::new(); - let client = setup.client(); - client.cancel_vault(&999u32, &setup.usdc_token); - } -} - -#[cfg(test)] -mod test { - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger}, - token::{StellarAssetClient, TokenClient}, - Address, Env, - }; - extern crate std; - - fn create_token_contract<'a>( - env: &Env, - admin: &Address, - ) -> (Address, StellarAssetClient<'a>, TokenClient<'a>) { - let contract_address = env - .register_stellar_asset_contract_v2(admin.clone()) - .address(); - ( - contract_address.clone(), - StellarAssetClient::new(env, &contract_address), - TokenClient::new(env, &contract_address), - ) - } - - fn create_vault_contract(env: &Env) -> Address { - env.register(DisciplrVault, ()) - } - - #[test] - fn test_create_vault_success() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(0); - - let admin = Address::generate(&env); - let creator = Address::generate(&env); - let success_dest = Address::generate(&env); - let failure_dest = Address::generate(&env); - - let (token_address, token_admin, token_client) = create_token_contract(&env, &admin); - let vault_contract = create_vault_contract(&env); - - // Mint USDC to creator and approve contract - token_admin.mint(&creator, &(MIN_AMOUNT * 2)); - - let vault_client = DisciplrVaultClient::new(&env, &vault_contract); - let milestone_hash = BytesN::from_array(&env, &[1u8; 32]); - - let vault_id = vault_client.create_vault( - &token_address, - &creator, - &MIN_AMOUNT, - &100, - &200, - &milestone_hash, - &None, - &success_dest, - &failure_dest, - ); - - assert_eq!(vault_id, 0); - assert_eq!(token_client.balance(&creator), MIN_AMOUNT); - assert_eq!(token_client.balance(&vault_contract), MIN_AMOUNT); - } - - #[test] - #[should_panic(expected = "Error(Contract, #7)")] - fn test_create_vault_zero_amount() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(0); - - let admin = Address::generate(&env); - let creator = Address::generate(&env); - let (token_address, _, _) = create_token_contract(&env, &admin); - let vault_contract = create_vault_contract(&env); - - let vault_client = DisciplrVaultClient::new(&env, &vault_contract); - let milestone_hash = BytesN::from_array(&env, &[1u8; 32]); - - vault_client.create_vault( - &token_address, - &creator, - &0, - &100, - &200, - &milestone_hash, - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - } - - #[test] - #[should_panic(expected = "Error(Contract, #7)")] - fn test_create_vault_negative_amount() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(0); - - let admin = Address::generate(&env); - let creator = Address::generate(&env); - let (token_address, _, _) = create_token_contract(&env, &admin); - let vault_contract = create_vault_contract(&env); - - let vault_client = DisciplrVaultClient::new(&env, &vault_contract); - let milestone_hash = BytesN::from_array(&env, &[1u8; 32]); - - vault_client.create_vault( - &token_address, - &creator, - &-100, - &100, - &200, - &milestone_hash, - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - } - - #[test] - #[should_panic(expected = "Error(Contract, #8)")] - fn test_create_vault_invalid_timestamps() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(0); - - let admin = Address::generate(&env); - let creator = Address::generate(&env); - let (token_address, _, _) = create_token_contract(&env, &admin); - let vault_contract = create_vault_contract(&env); - - let vault_client = DisciplrVaultClient::new(&env, &vault_contract); - let milestone_hash = BytesN::from_array(&env, &[1u8; 32]); - - vault_client.create_vault( - &token_address, - &creator, - &MIN_AMOUNT, - &200, - &100, - &milestone_hash, - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - } - - #[test] - #[should_panic(expected = "Error(Contract, #8)")] - fn test_create_vault_equal_timestamps() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(0); - - let admin = Address::generate(&env); - let creator = Address::generate(&env); - let (token_address, _, _) = create_token_contract(&env, &admin); - let vault_contract = create_vault_contract(&env); - - let vault_client = DisciplrVaultClient::new(&env, &vault_contract); - let milestone_hash = BytesN::from_array(&env, &[1u8; 32]); - - vault_client.create_vault( - &token_address, - &creator, - &MIN_AMOUNT, - &100, - &100, - &milestone_hash, - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - } - - #[test] - #[should_panic(expected = "balance is not sufficient")] - fn test_create_vault_insufficient_balance() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(0); - - let admin = Address::generate(&env); - let creator = Address::generate(&env); - let (token_address, token_admin, _) = create_token_contract(&env, &admin); - let vault_contract = create_vault_contract(&env); - - // Mint only half of MIN_AMOUNT but try to lock MIN_AMOUNT - token_admin.mint(&creator, &(MIN_AMOUNT / 2)); - - let vault_client = DisciplrVaultClient::new(&env, &vault_contract); - let milestone_hash = BytesN::from_array(&env, &[1u8; 32]); - - vault_client.create_vault( - &token_address, - &creator, - &MIN_AMOUNT, - &100, - &200, - &milestone_hash, - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - } - - #[test] - fn test_create_vault_with_verifier() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(0); - - let admin = Address::generate(&env); - let creator = Address::generate(&env); - let verifier = Address::generate(&env); - let (token_address, token_admin, token_client) = create_token_contract(&env, &admin); - let vault_contract = create_vault_contract(&env); - - token_admin.mint(&creator, &(MIN_AMOUNT * 2)); - - let vault_client = DisciplrVaultClient::new(&env, &vault_contract); - let milestone_hash = BytesN::from_array(&env, &[1u8; 32]); - - let vault_id = vault_client.create_vault( - &token_address, - &creator, - &MIN_AMOUNT, - &100, - &200, - &milestone_hash, - &Some(verifier), - &Address::generate(&env), - &Address::generate(&env), - ); - - assert_eq!(vault_id, 0); - assert_eq!(token_client.balance(&vault_contract), MIN_AMOUNT); - } - - #[test] - fn test_create_vault_exact_balance() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(0); - - let admin = Address::generate(&env); - let creator = Address::generate(&env); - let (token_address, token_admin, token_client) = create_token_contract(&env, &admin); - let vault_contract = create_vault_contract(&env); - - // Mint exact amount needed - token_admin.mint(&creator, &MIN_AMOUNT); - - let vault_client = DisciplrVaultClient::new(&env, &vault_contract); - let milestone_hash = BytesN::from_array(&env, &[1u8; 32]); - - let vault_id = vault_client.create_vault( - &token_address, - &creator, - &MIN_AMOUNT, - &100, - &200, - &milestone_hash, - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - - assert_eq!(vault_id, 0); - assert_eq!(token_client.balance(&creator), 0); - assert_eq!(token_client.balance(&vault_contract), MIN_AMOUNT); - } -} \ No newline at end of file diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs new file mode 100644 index 0000000..7079583 --- /dev/null +++ b/tests/lifecycle.rs @@ -0,0 +1,118 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{StellarAssetClient, TokenClient}, + Address, BytesN, Env, +}; + +use disciplr_vault::{DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT}; + +fn setup() -> ( + Env, + DisciplrVaultClient<'static>, + Address, + StellarAssetClient<'static>, + TokenClient<'static>, +) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(DisciplrVault, ()); + let client = DisciplrVaultClient::new(&env, &contract_id); + + let usdc_admin = Address::generate(&env); + let usdc_token = env.register_stellar_asset_contract_v2(usdc_admin.clone()); + let usdc_addr = usdc_token.address(); + let usdc_asset = StellarAssetClient::new(&env, &usdc_addr); + let usdc_token_client = TokenClient::new(&env, &usdc_addr); + + (env, client, usdc_addr, usdc_asset, usdc_token_client) +} + +#[test] +fn test_full_lifecycle_success() { + let (env, client, usdc, usdc_asset, usdc_token) = setup(); + + let creator = Address::generate(&env); + let verifier = Address::generate(&env); + let success_dest = Address::generate(&env); + let failure_dest = Address::generate(&env); + let now = 1_700_000_000u64; + env.ledger().set_timestamp(now); + + usdc_asset.mint(&creator, &MIN_AMOUNT); + + let milestone = BytesN::from_array(&env, &[1u8; 32]); + + // 1. Create Vault + let vault_id = client.create_vault( + &usdc, + &creator, + &MIN_AMOUNT, + &now, + &(now + 86_400), + &milestone, + &Some(verifier.clone()), + &success_dest, + &failure_dest, + ); + + assert_eq!( + client.get_vault_state(&vault_id).unwrap().status, + VaultStatus::Active + ); + + // 2. Validate Milestone + env.ledger().set_timestamp(now + 3_600); + client.validate_milestone(&vault_id); + assert!( + client + .get_vault_state(&vault_id) + .unwrap() + .milestone_validated + ); + + // 3. Release Funds + client.release_funds(&vault_id, &usdc); + let final_state = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(final_state.status, VaultStatus::Completed); + assert_eq!(usdc_token.balance(&success_dest), MIN_AMOUNT); +} + +#[test] +fn test_full_lifecycle_failure_redirection() { + let (env, client, usdc, usdc_asset, usdc_token) = setup(); + + let creator = Address::generate(&env); + let success_dest = Address::generate(&env); + let failure_dest = Address::generate(&env); + let now = 1_700_000_000u64; + env.ledger().set_timestamp(now); + + usdc_asset.mint(&creator, &MIN_AMOUNT); + + let milestone = BytesN::from_array(&env, &[1u8; 32]); + + // 1. Create Vault + let vault_id = client.create_vault( + &usdc, + &creator, + &MIN_AMOUNT, + &now, + &(now + 86_400), + &milestone, + &None, + &success_dest, + &failure_dest, + ); + + // 2. Wait until deadline passes without validation + env.ledger().set_timestamp(now + 86_401); + + // 3. Redirect Funds + client.redirect_funds(&vault_id, &usdc); + let final_state = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(final_state.status, VaultStatus::Failed); + assert_eq!(usdc_token.balance(&failure_dest), MIN_AMOUNT); +} diff --git a/vesting.md b/vesting.md index 8601f37..a5332fa 100644 --- a/vesting.md +++ b/vesting.md @@ -212,8 +212,12 @@ pub fn validate_milestone(env: Env, vault_id: u32) -> bool ### `release_funds` +<<<<<<< Formal-interface-spec-for-Stellar-CLI-/-laboratory +Releases locked funds to the success destination (typically after validation or deadline reached). +======= <<<<<<< doc/changelog Releases locked funds to `success_destination`. Allowed after milestone validation or once the deadline has passed. +>>>>>>> main ```rust pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result @@ -247,6 +251,8 @@ pub fn release_funds(env: Env, vault_id: u32) -> bool - Sets status to `Completed` >>>>>>> main +**Emits:** `funds_released` event with topic `(Symbol("funds_released"), vault_id)` and data `amount`. + --- ### `redirect_funds` @@ -287,6 +293,8 @@ pub fn redirect_funds(env: Env, vault_id: u32) -> bool - Sets status to `Failed` >>>>>>> main +**Emits:** `funds_redirected` event with topic `(Symbol("funds_redirected"), vault_id)` and data `amount`. + --- ### `cancel_vault` @@ -320,6 +328,18 @@ pub fn cancel_vault(env: Env, vault_id: u32) -> bool - Vault status must be `Active` - Returns USDC to creator - Sets status to `Cancelled` +<<<<<<< Formal-interface-spec-for-Stellar-CLI-/-laboratory + +**Emits:** `vault_cancelled` event with topic `(Symbol("vault_cancelled"), vault_id)` and data `()`. + +> **Security note:** Once the verifier (or authorised party) has called +> `validate_milestone`, the escrow outcome is determined. The creator is +> blocked from cancelling so that validated funds can only flow to +> `success_destination` via `release_funds`. This preserves the escrow +> invariant and prevents a malicious or regretful creator from reclaiming +> funds after a valid completion has been certified. +======= +>>>>>>> main >>>>>>> main ---