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
---