From 4e001c1f154a4e0fd8432a030a7afff99f2db1f7 Mon Sep 17 00:00:00 2001 From: githoboman Date: Mon, 30 Mar 2026 13:32:56 +0200 Subject: [PATCH 1/3] feat: implement full vault lifecycle logic and add integration tests --- README.md | 80 +++++++------ USDC_INTEGRATION.md | 17 +-- contract-interface.json | 133 +++++++++++++++++++++ src/lib.rs | 251 +++++++++++++++++++++++++++++++--------- tests/lifecycle.rs | 110 ++++++++++++++++++ vesting.md | 8 +- 6 files changed, 502 insertions(+), 97 deletions(-) create mode 100644 contract-interface.json create mode 100644 tests/lifecycle.rs diff --git a/README.md b/README.md index 9f41a25..1621154 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,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 @@ -121,6 +122,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, @@ -129,20 +131,21 @@ 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) - `end_timestamp`: Deadline for milestone validation (unix seconds) - `milestone_hash`: SHA-256 hash of milestone document -- `verifier`: Optional verifier address (None = anyone can validate) +- `verifier`: Optional verifier address (`None` = only the creator may validate) - `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()`) @@ -158,18 +161,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 @@ -180,19 +183,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`. --- @@ -201,19 +205,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`. --- @@ -222,19 +228,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/USDC_INTEGRATION.md b/USDC_INTEGRATION.md index 98c0142..3e95d54 100644 --- a/USDC_INTEGRATION.md +++ b/USDC_INTEGRATION.md @@ -20,7 +20,7 @@ pub fn create_vault( verifier: Option
, success_destination: Address, failure_destination: Address, -) -> u32 +) -> Result ``` ### Token Transfer Mechanism @@ -51,11 +51,14 @@ The function validates inputs before attempting the transfer: The function will panic (revert) in the following cases: -| Condition | Error Message | +| Condition | Error Variant | |-----------|---------------| -| Amount ≤ 0 | "amount must be positive" | -| end_timestamp ≤ start_timestamp | "end_timestamp must be after start_timestamp" | -| Insufficient balance | "balance is not sufficient to spend" (from token contract) | +| Amount < MIN_AMOUNT | `Error::InvalidAmount` | +| Amount > MAX_AMOUNT | `Error::InvalidAmount` | +| end_timestamp ≤ start_timestamp | `Error::InvalidTimestamps` | +| duration > MAX_VAULT_DURATION | `Error::DurationTooLong` | +| start_timestamp < current_time | `Error::InvalidTimestamp` | +| Insufficient balance | (from token contract) | | Missing authorization | Authorization error from Soroban SDK | ## Security Considerations @@ -75,9 +78,9 @@ The function will panic (revert) in the following cases: - Implementing a whitelist of approved token contracts - Adding admin functions to manage approved tokens -2. **No Refund Logic Yet**: Once funds are transferred, they remain in the contract until release/redirect/cancel functions are implemented +2. **Full Lifecycle Implemented**: Funds can be released, redirected, or cancelled using the respective functions, all of which are fully implemented and tested. -3. **Vault ID Management**: Currently returns placeholder ID (0). Production implementation needs proper ID allocation and storage. +3. **Vault ID Management**: Uses an incremental counter stored in instance storage (`DataKey::VaultCount`) to assign unique IDs. ## Testing 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 80d61f5..4bb01e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ use soroban_sdk::{ // conventions: use Result and return Err(Error::Variant) instead // of generic panics where appropriate. +/// Potential contract error codes. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] @@ -21,22 +22,21 @@ pub enum Error { 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). + /// 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). + /// 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. + /// 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). + /// Amount must be between `MIN_AMOUNT` and `MAX_AMOUNT`. InvalidAmount = 7, - /// start_timestamp must be strictly less than end_timestamp. + /// `start_timestamp` must be strictly less than `end_timestamp`. InvalidTimestamps = 8, - /// Vault duration (end − start) exceeds MAX_VAULT_DURATION. + /// Vault duration (`end − start`) exceeds `MAX_VAULT_DURATION`. DurationTooLong = 9, - /// Cancellation is not allowed once the milestone has been validated; funds must be - /// released via `release_funds` to honour the verified commitment. + /// Cancellation is blocked once the milestone is validated; use `release_funds`. MilestoneAlreadyValidated = 10, } @@ -44,12 +44,17 @@ pub enum Error { // Data types // --------------------------------------------------------------------------- +/// Represents the current lifecycle status of a productivity vault. #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum VaultStatus { + /// Vault is live, waiting for milestone validation or deadline. Active = 0, + /// Milestone verified, funds released to `success_destination`. Completed = 1, + /// Deadline passed without validation, funds redirected to `failure_destination`. Failed = 2, + /// Creator cancelled vault, funds returned to `creator`. Cancelled = 3, } @@ -59,27 +64,23 @@ pub enum VaultStatus { pub struct ProductivityVault { /// Address that created (and funded) the vault. pub creator: Address, - /// USDC amount locked in the vault (in stroops / smallest unit). + /// USDC amount locked in the vault (in stroops, 1 USDC = 10^7). 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. + /// SHA-256 hash representing the milestone requirements. 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. + /// Optional trusted verifier. If `None`, only the creator may validate. pub verifier: Option
, - /// Funds go here on success. + /// Destination address for funds on successful completion. pub success_destination: Address, - /// Funds go here on failure/redirect. + /// Destination address for funds on failure or redirection. 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. + /// `true` once an authorized party calls `validate_milestone`. pub milestone_validated: bool, } @@ -110,12 +111,30 @@ pub struct DisciplrVault; impl DisciplrVault { /// Create a new productivity vault. Transfers USDC from creator to contract. /// - /// # Validation Rules - /// - `amount` must be positive; otherwise returns `Error::InvalidAmount`. - /// - `start_timestamp` must be strictly less than `end_timestamp`; otherwise returns `Error::InvalidTimestamps`. + /// # Parameters + /// - `env`: Current contract environment. + /// - `usdc_token`: Address of the USDC token contract. + /// - `creator`: Wallet address that creates and funds the vault. + /// - `amount`: Total USDC amount to lock (in stroops). + /// - `start_timestamp`: Unix timestamp when the commitment starts. + /// - `end_timestamp`: Unix timestamp deadline for validation. + /// - `milestone_hash`: SHA-256 hash documentation for milestone deliverables. + /// - `verifier`: Optional address authorized to validate the milestone. + /// - `success_destination`: Recipient on milestone success. + /// - `failure_destination`: Recipient on milestone failure or deadline timeout. /// - /// # Prerequisites - /// Creator must have sufficient USDC balance and authorize the transaction. + /// # Returns + /// - `Ok(u32)`: The newly assigned unique vault identifier. + /// - `Err(Error)`: If validation rules (amount, timestamps, duration) are violated. + /// + /// # Errors + /// - `Error::InvalidAmount`: amount < `MIN_AMOUNT` or > `MAX_AMOUNT`. + /// - `Error::InvalidTimestamp`: `start_timestamp` < current ledger time. + /// - `Error::InvalidTimestamps`: `end_timestamp` <= `start_timestamp`. + /// - `Error::DurationTooLong`: duration exceeds `MAX_VAULT_DURATION` (1 year). + /// + /// # Events + /// - Emits `vault_created` with topic `(Symbol("vault_created"), vault_id)` and data `ProductivityVault`. pub fn create_vault( env: Env, usdc_token: Address, @@ -198,9 +217,22 @@ impl DisciplrVault { /// Verifier (or authorized party) validates milestone completion. /// - /// **Optional verifier behavior:** If `verifier` is `Some(addr)`, only that address may call - /// this function. If `verifier` is `None`, only the creator may call it (no validation by - /// other parties). Rejects when current time >= end_timestamp (MilestoneExpired). + /// # Parameters + /// - `env`: Current contract environment. + /// - `vault_id`: Unique identifier of the vault to validate. + /// + /// # Returns + /// - `Ok(true)`: If the milestone flag was successfully set. + /// - `Err(Error)`: If authorization fails or the vault is in an invalid state. + /// + /// # Errors + /// - `Error::VaultNotFound`: No record for the provided `vault_id`. + /// - `Error::VaultNotActive`: Vault state is not `Active`. + /// - `Error::NotAuthorized`: Caller is not the designated verifier (if set) or the creator (if no verifier). + /// - `Error::MilestoneExpired`: Current ledger time is at or past `end_timestamp`. + /// + /// # Events + /// - Emits `milestone_validated` with topic `(Symbol("milestone_validated"), vault_id)` and data `()`. pub fn validate_milestone(env: Env, vault_id: u32) -> Result { let vault_key = DataKey::Vault(vault_id); let mut vault: ProductivityVault = env @@ -238,7 +270,24 @@ impl DisciplrVault { // release_funds // ----------------------------------------------------------------------- - /// Release vault funds to `success_destination`. + /// Release vault funds to the success destination. + /// + /// # Parameters + /// - `env`: Current contract environment. + /// - `vault_id`: Unique identifier of the vault to release. + /// - `usdc_token`: Address of the USDC token contract. + /// + /// # Returns + /// - `Ok(true)`: If funds were successfully transferred and state updated. + /// - `Err(Error)`: If release conditions are not met. + /// + /// # Errors + /// - `Error::VaultNotFound`: No record for the provided `vault_id`. + /// - `Error::VaultNotActive`: Vault state is not `Active`. + /// - `Error::NotAuthorized`: Milestone is not validated AND the deadline has not been reached. + /// + /// # Events + /// - Emits `funds_released` with topic `(Symbol("funds_released"), vault_id)` and data `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 @@ -285,7 +334,27 @@ impl DisciplrVault { // redirect_funds // ----------------------------------------------------------------------- - /// Redirect funds to `failure_destination` (e.g. after deadline without validation). + /// Redirect vault funds to the failure destination. + /// + /// Typically called when a milestone is not completed by the deadline. + /// + /// # Parameters + /// - `env`: Current contract environment. + /// - `vault_id`: Unique identifier of the vault to redirect. + /// - `usdc_token`: Address of the USDC token contract. + /// + /// # Returns + /// - `Ok(true)`: If funds were successfully redirected and state updated. + /// - `Err(Error)`: If redirection criteria (time, validation status) are not met. + /// + /// # Errors + /// - `Error::VaultNotFound`: No record for the provided `vault_id`. + /// - `Error::VaultNotActive`: Vault state is not `Active`. + /// - `Error::InvalidTimestamp`: Current ledger time is before `end_timestamp`. + /// - `Error::NotAuthorized`: Redirect called on a milestone that was already validated. + /// + /// # Events + /// - Emits `funds_redirected` with topic `(Symbol("funds_redirected"), vault_id)` and data `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 @@ -330,21 +399,30 @@ impl DisciplrVault { // cancel_vault // ----------------------------------------------------------------------- - /// Cancel vault and return funds to creator. + /// Cancel vault and return funds to the creator. + /// + /// # Parameters + /// - `env`: Current contract environment. + /// - `vault_id`: Unique identifier of the vault to cancel. + /// - `usdc_token`: Address of the USDC token contract. + /// + /// # Returns + /// - `Ok(true)`: If cancellation was successful and funds returned. + /// - `Err(Error)`: If the vault was already validated or was already closed. + /// + /// # Errors + /// - `Error::VaultNotFound`: No record for the provided `vault_id`. + /// - `Error::VaultNotActive`: Vault state is not `Active`. + /// - `Error::MilestoneAlreadyValidated`: Cancellation blocked after milestone validation. + /// + /// # Events + /// - Emits `vault_cancelled` with topic `(Symbol("vault_cancelled"), vault_id)` and data `()`. /// /// # Security /// Cancellation is **blocked** once the milestone has been validated /// (`milestone_validated == true`). At that point the verifier has /// certified completion and the escrow semantics require that funds travel /// exclusively through [`release_funds`] to `success_destination`. - /// Allowing the creator to cancel after validation would let them - /// unilaterally reclaim committed funds, breaking the escrow guarantee. - /// - /// # Errors - /// - [`Error::VaultNotFound`] — vault does not exist. - /// - [`Error::VaultNotActive`] — vault is not in `Active` status. - /// - [`Error::MilestoneAlreadyValidated`] — milestone has been validated; - /// use `release_funds` instead. 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 @@ -388,20 +466,19 @@ impl DisciplrVault { // get_vault_state // ----------------------------------------------------------------------- - /// Return current vault state, or `None` if no vault record exists for that ID. + /// Return current vault state. /// - /// 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. + /// # Parameters + /// - `vault_id`: Unique identifier to query. /// - /// 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. + /// # Returns + /// - `Some(ProductivityVault)`: If the record exists. + /// - `None`: If the ID was never assigned or storage was cleared. 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. + /// Return the total number of vault IDs assigned so far. pub fn vault_count(env: Env) -> u32 { env.storage() .instance() @@ -1359,22 +1436,90 @@ mod tests { /// Cancellation without prior validation must still succeed (happy path /// unchanged — guard only fires when milestone_validated is true). #[test] - fn test_cancel_without_validation_still_allowed() { + fn test_lifecycle_events() { let setup = TestSetup::new(); let client = setup.client(); + let env = &setup.env; 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); + // 1. validate_milestone event + setup.env.ledger().set_timestamp(setup.start_timestamp + 10); + client.validate_milestone(&vault_id); - let result = client.cancel_vault(&vault_id, &setup.usdc_token); - assert!(result); - assert_eq!(usdc.balance(&setup.creator) - before, setup.amount); + let events = env.events().all(); + let last_event = events.last().unwrap(); + assert_eq!(last_event.0, setup.contract_id); + assert_eq!( + last_event.1.get(0).unwrap().try_into_val(env), + Ok(Symbol::new(env, "milestone_validated")) + ); + assert_eq!( + last_event.1.get(1).unwrap().try_into_val(env), + Ok(vault_id) + ); - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_eq!(vault.status, VaultStatus::Cancelled); + // 2. release_funds event + client.release_funds(&vault_id, &setup.usdc_token); + let events = env.events().all(); + let last_event = events.last().unwrap(); + assert_eq!(last_event.0, setup.contract_id); + assert_eq!( + last_event.1.get(0).unwrap().try_into_val(env), + Ok(Symbol::new(env, "funds_released")) + ); + assert_eq!( + last_event.1.get(1).unwrap().try_into_val(env), + Ok(vault_id) + ); + assert_eq!( + last_event.2.try_into_val(env), + Ok(setup.amount) + ); + } + + #[test] + fn test_redirect_event() { + let setup = TestSetup::new(); + let client = setup.client(); + let env = &setup.env; + + 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.redirect_funds(&vault_id, &setup.usdc_token); + + let events = env.events().all(); + let last_event = events.last().unwrap(); + assert_eq!( + last_event.1.get(0).unwrap().try_into_val(env), + Ok(Symbol::new(env, "funds_redirected")) + ); + assert_eq!( + last_event.2.try_into_val(env), + Ok(setup.amount) + ); + } + + #[test] + fn test_cancel_event() { + let setup = TestSetup::new(); + let client = setup.client(); + let env = &setup.env; + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = setup.create_default_vault(); + + client.cancel_vault(&vault_id, &setup.usdc_token); + + let events = env.events().all(); + let last_event = events.last().unwrap(); + assert_eq!( + last_event.1.get(0).unwrap().try_into_val(env), + Ok(Symbol::new(env, "vault_cancelled")) + ); } } diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs new file mode 100644 index 0000000..8ac9950 --- /dev/null +++ b/tests/lifecycle.rs @@ -0,0 +1,110 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, BytesN, Env, +}; + +use disciplr_vault::{ + DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT, +}; + +fn setup() -> ( + Env, + DisciplrVaultClient<'static>, + Address, + StellarAssetClient<'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); + + (env, client, usdc_addr, usdc_asset) +} + +#[test] +fn test_full_lifecycle_success() { + let (env, client, usdc, usdc_asset) = 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_asset.balance(&success_dest), MIN_AMOUNT); +} + +#[test] +fn test_full_lifecycle_failure_redirection() { + let (env, client, usdc, usdc_asset) = 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_asset.balance(&failure_dest), MIN_AMOUNT); +} diff --git a/vesting.md b/vesting.md index 5d5a537..b3ce486 100644 --- a/vesting.md +++ b/vesting.md @@ -140,7 +140,7 @@ pub fn validate_milestone(env: Env, vault_id: u32) -> Result ### `release_funds` -Releases locked funds to the success destination (typically after validation). +Releases locked funds to the success destination (typically after validation or deadline reached). ```rust pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result @@ -159,6 +159,8 @@ pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result Result Result **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 From a2760e92d8b3e0e5b604a1566c4c6b01cd0b1022 Mon Sep 17 00:00:00 2001 From: githoboman Date: Mon, 30 Mar 2026 15:59:53 +0200 Subject: [PATCH 2/3] test: add integration tests for vault lifecycle success and failure scenarios --- tests/lifecycle.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index 8ac9950..3e16d12 100644 --- a/tests/lifecycle.rs +++ b/tests/lifecycle.rs @@ -2,7 +2,7 @@ use soroban_sdk::{ testutils::{Address as _, Ledger}, - token::StellarAssetClient, + token::{StellarAssetClient, TokenClient}, Address, BytesN, Env, }; @@ -15,6 +15,7 @@ fn setup() -> ( DisciplrVaultClient<'static>, Address, StellarAssetClient<'static>, + TokenClient<'static>, ) { let env = Env::default(); env.mock_all_auths(); @@ -26,13 +27,14 @@ fn setup() -> ( 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) + (env, client, usdc_addr, usdc_asset, usdc_token_client) } #[test] fn test_full_lifecycle_success() { - let (env, client, usdc, usdc_asset) = setup(); + let (env, client, usdc, usdc_asset, usdc_token) = setup(); let creator = Address::generate(&env); let verifier = Address::generate(&env); @@ -69,12 +71,12 @@ fn test_full_lifecycle_success() { 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_asset.balance(&success_dest), MIN_AMOUNT); + assert_eq!(usdc_token.balance(&success_dest), MIN_AMOUNT); } #[test] fn test_full_lifecycle_failure_redirection() { - let (env, client, usdc, usdc_asset) = setup(); + let (env, client, usdc, usdc_asset, usdc_token) = setup(); let creator = Address::generate(&env); let success_dest = Address::generate(&env); @@ -106,5 +108,5 @@ fn test_full_lifecycle_failure_redirection() { 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_asset.balance(&failure_dest), MIN_AMOUNT); + assert_eq!(usdc_token.balance(&failure_dest), MIN_AMOUNT); } From 781b3168cddc968220b495dde12078d239a05734 Mon Sep 17 00:00:00 2001 From: githoboman Date: Mon, 30 Mar 2026 16:09:38 +0200 Subject: [PATCH 3/3] fix: use TokenClient for balance checks and apply cargo fmt StellarAssetClient lacks a balance() method; switch lifecycle tests to TokenClient for balance assertions. Also apply cargo fmt to all files. Co-Authored-By: Claude Sonnet 4.6 --- src/lib.rs | 20 ++++---------------- tests/lifecycle.rs | 16 +++++++++++----- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4bb01e9..6a34c5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1455,10 +1455,7 @@ mod tests { last_event.1.get(0).unwrap().try_into_val(env), Ok(Symbol::new(env, "milestone_validated")) ); - assert_eq!( - last_event.1.get(1).unwrap().try_into_val(env), - Ok(vault_id) - ); + assert_eq!(last_event.1.get(1).unwrap().try_into_val(env), Ok(vault_id)); // 2. release_funds event client.release_funds(&vault_id, &setup.usdc_token); @@ -1469,14 +1466,8 @@ mod tests { last_event.1.get(0).unwrap().try_into_val(env), Ok(Symbol::new(env, "funds_released")) ); - assert_eq!( - last_event.1.get(1).unwrap().try_into_val(env), - Ok(vault_id) - ); - assert_eq!( - last_event.2.try_into_val(env), - Ok(setup.amount) - ); + assert_eq!(last_event.1.get(1).unwrap().try_into_val(env), Ok(vault_id)); + assert_eq!(last_event.2.try_into_val(env), Ok(setup.amount)); } #[test] @@ -1497,10 +1488,7 @@ mod tests { last_event.1.get(0).unwrap().try_into_val(env), Ok(Symbol::new(env, "funds_redirected")) ); - assert_eq!( - last_event.2.try_into_val(env), - Ok(setup.amount) - ); + assert_eq!(last_event.2.try_into_val(env), Ok(setup.amount)); } #[test] diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index 3e16d12..7079583 100644 --- a/tests/lifecycle.rs +++ b/tests/lifecycle.rs @@ -6,9 +6,7 @@ use soroban_sdk::{ Address, BytesN, Env, }; -use disciplr_vault::{ - DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT, -}; +use disciplr_vault::{DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT}; fn setup() -> ( Env, @@ -60,12 +58,20 @@ fn test_full_lifecycle_success() { &failure_dest, ); - assert_eq!(client.get_vault_state(&vault_id).unwrap().status, VaultStatus::Active); + 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); + assert!( + client + .get_vault_state(&vault_id) + .unwrap() + .milestone_validated + ); // 3. Release Funds client.release_funds(&vault_id, &usdc);