diff --git a/README.md b/README.md index c121c8a..94be630 100644 --- a/README.md +++ b/README.md @@ -951,6 +951,11 @@ During `create_vault`, the contract enforces: - `end_timestamp` must be strictly greater than `start_timestamp` - `end_timestamp - start_timestamp` must not exceed `MAX_VAULT_DURATION` - `success_destination` must differ from `failure_destination` (returns `Error::SameDestination`, code `#10`); equal destinations make the success/failure outcome financially indistinguishable, removing the accountability incentive of the vault +<<<<<<< feature/address-validation +- `creator` must differ from `success_destination` and `failure_destination` (returns `Error::InvalidAddress`, code `#11`); a creator that is also a destination could trivially recover funds regardless of milestone outcome +- `verifier` (when `Some`) must differ from `creator` (returns `Error::InvalidAddress`, code `#11`); a verifier equal to the creator provides no independent validation +======= +>>>>>>> main All validations occur before event emission or state mutation, ensuring invalid vaults cannot be created. diff --git a/tests/create_vault.rs b/tests/create_vault.rs index ba0387a..e69de29 100644 --- a/tests/create_vault.rs +++ b/tests/create_vault.rs @@ -1,521 +0,0 @@ -#![cfg(test)] - -extern crate std; - -use soroban_sdk::{ - testutils::{Address as _, Ledger}, - token::StellarAssetClient, - Address, BytesN, Env, -}; - -use disciplr_vault::{ - DisciplrVault, DisciplrVaultClient, VaultStatus, MAX_AMOUNT, MAX_VAULT_DURATION, 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); - - client.initialize(&usdc_addr); - - (env, client, usdc_addr, usdc_asset) -} - -#[test] -fn test_create_vault_valid_boundary_values() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - - usdc_asset.mint(&creator, &MIN_AMOUNT); - - let success = Address::generate(&env); - let failure = Address::generate(&env); - let milestone = BytesN::from_array(&env, &[0u8; 32]); - - let vault_id = client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &now, - &(now + MAX_VAULT_DURATION), - &milestone, - &None, - &success, - &failure, - ); - - assert_eq!(vault_id, 0u32); -} - -#[test] -fn test_create_vault_start_exactly_now() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - - usdc_asset.mint(&creator, &MIN_AMOUNT); - - // start_timestamp == now should be valid - let vault_id = client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &now, - &(now + 86_400), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - - assert_eq!(vault_id, 0u32); -} - -#[test] -fn test_create_vault_start_now_plus_one() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - - usdc_asset.mint(&creator, &MIN_AMOUNT); - - // start_timestamp == now + 1 should be valid - let vault_id = client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &(now + 1), - &(now + 86_400), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - - assert_eq!(vault_id, 0u32); -} - -#[test] -#[should_panic] // This will panic with the token contract error -fn test_create_vault_insufficient_balance() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - - // Mint less than required amount - usdc_asset.mint(&creator, &(MIN_AMOUNT - 1)); - - // This should panic because token_client.transfer will fail - client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &now, - &(now + 86_400), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); -} - -#[test] -#[should_panic(expected = "Error(Contract, #7)")] -fn test_amount_below_minimum() { - let (env, client, usdc, _usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = env.ledger().timestamp(); - - client.create_vault( - &usdc, - &creator, - &(MIN_AMOUNT - 1), - &now, - &(now + 86_400), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); -} - -#[test] -#[should_panic(expected = "Error(Contract, #7)")] -fn test_amount_above_maximum() { - let (env, client, usdc, _usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = env.ledger().timestamp(); - - client.create_vault( - &usdc, - &creator, - &(MAX_AMOUNT + 1), - &now, - &(now + 86_400), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); -} - -#[test] -#[should_panic(expected = "Error(Contract, #9)")] -fn test_duration_exceeds_max() { - let (env, client, usdc, _usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = env.ledger().timestamp(); - - client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &now, - &(now + MAX_VAULT_DURATION + 1), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); -} - -#[test] -fn test_duration_checked_sub_handles_u64_max_end_timestamp() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let start = u64::MAX - MAX_VAULT_DURATION; - let end = u64::MAX; - - usdc_asset.mint(&creator, &MIN_AMOUNT); - - let vault_id = client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &start, - &end, - &BytesN::from_array(&env, &[2u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - - assert_eq!(vault_id, 0u32); -} - -#[test] -#[should_panic(expected = "Error(Contract, #9)")] -fn test_duration_checked_sub_rejects_u64_max_end_timestamp_over_limit() { - let (env, client, usdc, _usdc_asset) = setup(); - - let creator = Address::generate(&env); - - client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &(u64::MAX - MAX_VAULT_DURATION - 1), - &u64::MAX, - &BytesN::from_array(&env, &[3u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); -} - -#[test] -#[should_panic(expected = "Error(Contract, #7)")] -fn test_amount_i128_max_rejected_explicitly() { - let (env, client, usdc, _usdc_asset) = setup(); - - let creator = Address::generate(&env); - - client.create_vault( - &usdc, - &creator, - &i128::MAX, - &0u64, - &1u64, - &BytesN::from_array(&env, &[4u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); -} - -#[test] -#[should_panic(expected = "Error(Contract, #4)")] -fn test_start_timestamp_in_past() { - let (env, client, usdc, _usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - - client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &(now - 3_600), - &(now + 86_400), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_end_before_or_equal_start() { - let (env, client, usdc, _usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - - client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &(now + 200), - &(now + 100), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); -} - -#[test] -fn test_amount_exactly_max_allowed() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - - usdc_asset.mint(&creator, &MAX_AMOUNT); - - let vault_id = client.create_vault( - &usdc, - &creator, - &MAX_AMOUNT, - &now, - &(now + 86_400), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - - assert_eq!(vault_id, 0u32); -} - -#[test] -#[should_panic(expected = "Error(Contract, #7)")] -fn test_amount_zero() { - let (env, client, usdc, _usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = env.ledger().timestamp(); - - client.create_vault( - &usdc, - &creator, - &0_i128, - &now, - &(now + 86_400), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); -} - -#[test] -fn test_minimum_valid_duration() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - - usdc_asset.mint(&creator, &MIN_AMOUNT); - - let vault_id = client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &now, - &(now + 1), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - - assert_eq!(vault_id, 0u32); -} - -#[test] -fn test_valid_zero_verifier_and_normal_duration() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = env.ledger().timestamp(); - - usdc_asset.mint(&creator, &5_000_000_000_i128); - - client.create_vault( - &usdc, - &creator, - &5_000_000_000_i128, - &now, - &(now + 7 * 24 * 60 * 60), - &BytesN::from_array(&env, &[1u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); -} - -#[test] -fn test_get_vault_state_never_created_id_returns_none() { - let (_env, client, _usdc, _usdc_asset) = setup(); - - assert_eq!(client.vault_count(), 0u32); - assert!(client.get_vault_state(&0u32).is_none()); - assert!(client.get_vault_state(&42u32).is_none()); -} - -// --------------------------------------------------------------------------- -// Issue #124: success_destination vs failure_destination equality -// --------------------------------------------------------------------------- - -/// Verifica que create_vault rechaza cuando success_destination == failure_destination. -/// Implicación UX: si ambas direcciones fueran iguales, el resultado de éxito y fracaso -/// sería indistinguible para el creador, eliminando el incentivo de cumplir el milestone. -#[test] -#[should_panic(expected = "Error(Contract, #10)")] -fn test_same_destination_rejected() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - - usdc_asset.mint(&creator, &MIN_AMOUNT); - - // Misma dirección para success y failure - let same_dest = Address::generate(&env); - - client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &now, - &(now + 86_400), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &same_dest, - &same_dest, - ); -} - -/// Verifica que create_vault acepta cuando success_destination != failure_destination. -/// Caso explícito del constraint introducido en el issue #124. -#[test] -fn test_different_destinations_accepted() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - - usdc_asset.mint(&creator, &MIN_AMOUNT); - - let success = Address::generate(&env); - let failure = Address::generate(&env); - - // Las dos direcciones son distintas: debe crearse sin error - let vault_id = client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &now, - &(now + 86_400), - &BytesN::from_array(&env, &[0u8; 32]), - &None, - &success, - &failure, - ); - - assert_eq!(vault_id, 0u32); - - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_ne!(vault.success_destination, vault.failure_destination); -} - -#[test] -fn test_get_vault_state_cancelled_vault_remains_readable() { - let (env, client, usdc, usdc_asset) = setup(); - - let creator = Address::generate(&env); - let now = 1_725_000_000u64; - env.ledger().set_timestamp(now); - usdc_asset.mint(&creator, &(MIN_AMOUNT * 2)); - - let vault_id = client.create_vault( - &usdc, - &creator, - &MIN_AMOUNT, - &now, - &(now + 86_400), - &BytesN::from_array(&env, &[9u8; 32]), - &None, - &Address::generate(&env), - &Address::generate(&env), - ); - - assert_eq!(client.vault_count(), 1u32); - client.cancel_vault(&vault_id, &usdc); - - let vault = client.get_vault_state(&vault_id).unwrap(); - assert_eq!(vault.status, VaultStatus::Cancelled); - assert!(client.get_vault_state(&1u32).is_none()); -} - -#[test] -fn test_contract_version_discovery() { - let (_env, client, _usdc, _usdc_asset) = setup(); - let version = client.version(); - // Verify it matches the version in Cargo.toml (0.1.0) - assert_eq!(version, soroban_sdk::Symbol::new(&_env, "0.1.0")); -} diff --git a/vesting.md b/vesting.md index e69de29..bd25800 100644 --- a/vesting.md +++ b/vesting.md @@ -0,0 +1,508 @@ +<<<<<<< feature/address-validation +# Disciplr Vault Contract Documentation + +## Overview + +The Disciplr Vault is a Soroban smart contract deployed on the Stellar blockchain that enables **programmable time-locked USDC vaults** for productivity-based milestone funding. It allows creators to lock USDC tokens with specific milestones and conditions, ensuring funds are only released upon verified completion or redirected to a failure destination if milestones are not met. + +### Use Cases + +- **Vesting schedules**: Lock tokens that vest over time based on milestone completion +- **Grant funding**: Enable grant providers to fund projects with accountability +- **Team incentives**: Align team compensation with deliverable completion +- **Bug bounties**: Create time-bound bounty programs with predefined payout conditions + +--- + +## Data Model + +### VaultStatus Enum + +Represents the current state of a vault: + +```rust +#[contracttype] +pub enum VaultStatus { + Active = 0, // Vault created and funds locked + Completed = 1, // Milestone validated, funds released to success destination + Failed = 2, // Milestone not completed by deadline, funds redirected + Cancelled = 3, // Vault cancelled by creator, funds returned +} +``` + +| Status | Description | +|--------|-------------| +| `Active` | Vault is live, waiting for milestone validation or deadline | +| `Completed` | Milestone verified, funds released to success destination | +| `Failed` | Deadline passed without validation, funds redirected | +| `Cancelled` | Creator cancelled vault, funds returned | + +### ProductivityVault Struct + +The main data structure representing a vault: + +```rust +#[contracttype] +pub struct ProductivityVault { + pub creator: Address, // Address that created the vault + pub amount: i128, // Amount of USDC locked (in stroops) + pub start_timestamp: u64, // Unix timestamp when vault becomes active + pub end_timestamp: u64, // Unix deadline for milestone validation + pub milestone_hash: BytesN<32>, // SHA-256 hash of milestone requirements + pub verifier: Option
, // Optional trusted verifier address + pub success_destination: Address, // Address for fund release on success + pub failure_destination: Address, // Address for fund redirect on failure + pub status: VaultStatus, // Current vault status +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `creator` | `Address` | Wallet address that created the vault | +| `amount` | `i128` | Total USDC amount locked (in stroops, 1 USDC = 10^7 stroops) | +| `start_timestamp` | `u64` | Unix timestamp (seconds) when vault becomes active | +| `end_timestamp` | `u64` | Unix timestamp (seconds) deadline for milestone validation | +| `milestone_hash` | `BytesN<32>` | SHA-256 hash documenting milestone requirements | +| `verifier` | `Option
` | Optional trusted party who can validate milestones | +| `success_destination` | `Address` | Recipient address on successful milestone completion | +| `failure_destination` | `Address` | Recipient address when milestone is not completed | +| `status` | `VaultStatus` | Current lifecycle state of the vault | + +--- + +## Contract Methods + +### `create_vault` + +Creates a new productivity vault and locks USDC funds. + +```rust +pub fn create_vault( + env: Env, + creator: Address, + amount: i128, + start_timestamp: u64, + end_timestamp: u64, + milestone_hash: BytesN<32>, + verifier: Option
, + success_destination: Address, + failure_destination: Address, +) -> u32 +``` + +**Parameters:** +- `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 = creator validates) +- `success_destination`: Address to receive funds on success +- `failure_destination`: Address to receive funds on failure + +**Returns:** `u32` - Unique vault identifier + +**Requirements:** +- Caller must authorize the transaction (`creator.require_auth()`) +- `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` (error code `#10`). Equal destinations make the success/failure outcome financially indistinguishable, removing the accountability incentive of the vault. +- `creator` must differ from `success_destination` and `failure_destination`; otherwise returns `Error::InvalidAddress` (error code `#11`). A creator that is also a destination could trivially recover funds regardless of milestone outcome, defeating the vault's accountability mechanism. +- `verifier` (when `Some`) must differ from `creator`; otherwise returns `Error::InvalidAddress` (error code `#11`). A verifier equal to the creator provides no independent validation. +- USDC transfer must be approved by creator before calling + +**Emits:** [`vault_created`](#vault_created) event + +--- + +### `validate_milestone` + +Allows the verifier (or authorized party) to validate milestone completion and release funds. + +```rust +pub fn validate_milestone(env: Env, vault_id: u32) -> bool +``` + +**Parameters:** +- `vault_id`: ID of the vault to validate + +**Returns:** `bool` - True if validation successful + +**Requirements:** +- Vault must exist and be in `Active` status +- Caller must be the designated verifier (if set), or creator (if verifier is None) +- Current timestamp must be before `end_timestamp` + +**Emits:** [`milestone_validated`](#milestone_validated) event + +--- + +### `release_funds` + +Releases locked funds to the success destination (after validation or deadline). + +```rust +pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> bool +``` + +**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 + +**Requirements:** +- Vault status must be `Active` +- Milestone must be validated OR current time must be past `end_timestamp` +- Transfers USDC to `success_destination` +- Sets status to `Completed` + +--- + +### `redirect_funds` + +Redirects funds to the failure destination when milestone is not completed by deadline. + +```rust +pub fn redirect_funds(env: Env, vault_id: u32, usdc_token: Address) -> bool +``` + +**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 + +**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` + +--- + +### `cancel_vault` + +Allows the creator to cancel the vault and retrieve locked funds. + +```rust +pub fn cancel_vault(env: Env, vault_id: u32, usdc_token: Address) -> bool +``` + +**Parameters:** +- `vault_id`: ID of the vault to cancel +- `usdc_token`: Address of the USDC token contract + +**Returns:** `bool` - True if cancellation successful + +**Requirements:** +- Caller must be the vault creator +- Vault status must be `Active` +- Returns USDC to creator +- Sets status to `Cancelled` + +--- + +### `get_vault_state` + +Retrieves the current state of a vault. + +```rust +pub fn get_vault_state(env: Env, vault_id: u32) -> Option +``` + +**Parameters:** +- `vault_id`: ID of the vault to query + +**Returns:** `Option` - Stored vault data when a record exists for that ID. + +**Behavior:** Created vault records are not deleted during normal contract execution. Completed, failed, and cancelled vaults still return `Some(ProductivityVault)` with their terminal status. `None` therefore means the ID was never assigned (`vault_id >= vault_count()`) or storage was cleared outside the contract's normal lifecycle. + +--- + +## Events + +### `vault_created` + +Emitted when a new vault is created. + +**Topic:** +``` +("vault_created", vault_id) +``` + +**Data:** +```rust +ProductivityVault { + creator: Address, + amount: i128, + start_timestamp: u64, + end_timestamp: u64, + milestone_hash: BytesN<32>, + verifier: Option
, + success_destination: Address, + failure_destination: Address, + status: VaultStatus::Active, +} +``` + +--- + +### `milestone_validated` + +Emitted when a milestone is successfully validated. + +**Topic:** +``` +("milestone_validated", vault_id) +``` + +**Data:** `()` (empty tuple) + +--- + +## Lifecycle + +``` + ┌──────────────┐ + │ CREATED │ + │ │ + │ create_vault │ + └──────┬───────┘ + │ + ▼ + ┌──────────────┐ + ┌─────────│ ACTIVE │─────────┐ + │ │ │ │ + │ └──────────────┘ │ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ validate_ │ │ redirect_funds │ +│ milestone() │ │ (deadline passed) │ +└────────┬────────┘ └──────────┬──────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ COMPLETED │ │ FAILED │ +│ │ │ │ +└─────────────────┘ └─────────────────────┘ + + │ + ▼ +┌─────────────────┐ +│ cancel_vault() │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ CANCELLED │ +│ │ +└─────────────────┘ +``` + +--- + +## Security and Trust Model + +This section outlines the security properties, trust assumptions, and known limitations of the Disciplr Vault contract to assist auditors and users. + +### Trust Model + +1. **Verifier Trust (Critical)**: When a `verifier` is designated (via `Some(Address)`), that address has **absolute power** to validate the milestone and cause funds to be released to the `success_destination` before the deadline. If the verifier is compromised or malicious, they can release funds prematurely or to a non-compliant recipient. +2. **Creator Authority**: The `creator` is the only address authorized to `create_vault` or `cancel_vault`. They must authorize the initial USDC funding. If no `verifier` is set (`None`), only the `creator` can validate the milestone. +3. **No Administrative Overrides**: There is no "admin" or "owner" role with the power to sweep funds or override the vault logic. Funds can only flow to the predefined `success_destination`, `failure_destination`, or back to the `creator` on cancellation. +4. **Immutable Destinations**: Once a vault is created, the `success_destination` and `failure_destination` are immutable. This prevents redirection of funds after the vault is funded. + +### Security Assumptions + +1. **Stellar Ledger Integrity**: We assume the underlying Stellar blockchain and Soroban runtime correctly enforce authorization (`require_auth`) and maintain state integrity. +2. **Ledger Timestamp**: The contract relies on `env.ledger().timestamp()` for all time-based logic (start/end windows). We assume ledger timestamps are reasonably accurate and monotonic as per Stellar network consensus. +3. **Token Contract Behavior**: The contract interacts with a USDC token contract (standard Soroban token interface). We assume the token contract is honest and follows the expected transfer behavior. + +### Known Limitations & Security Notes + +1. **Per-Call Token Address**: The `usdc_token` address is passed as an argument to release/redirect functions rather than being pinned to the vault data at creation. This introduces a risk where a malicious caller could potentially pass a different token address (though they would still need the contract to hold a balance of that token). +2. **Checks-Effects-Interactions (CEI)**: In `release_funds`, `redirect_funds`, and `cancel_vault`, the USDC transfer is initiated *before* the internal status is updated to `Completed`, `Failed`, or `Cancelled`. While Soroban's atomicity safeguards against most reentrancy/partial-success risks, this is a deviation from the strict CEI pattern. +3. **Lack of Emergency Stops**: There is currently no circuit breaker or emergency pause mechanism. +4. **Precision**: All amounts are handled as `i128` in stroops (7 decimals for USDC); users must ensure they provide correct decimal-adjusted amounts. +5. **Equal Destinations Rejected (Issue #124)**: `success_destination` and `failure_destination` must be different addresses. If they were equal, the outcome of the vault (success vs. failure) would be financially indistinguishable for the creator: funds would arrive at the same address regardless of whether the milestone was completed. This eliminates the accountability incentive that is the core purpose of the vault, and could be used to disguise a vault with no real consequence for non-completion. The contract enforces this at creation time with `Error::SameDestination` (code `#10`). +6. **Invalid Address Roles Rejected (Issue #125)**: The contract rejects configurations where address roles overlap in ways that defeat the vault's accountability mechanism. Specifically: (a) `creator == success_destination` — the creator would trivially recover funds on success regardless of milestone completion; (b) `creator == failure_destination` — the creator would recover funds on failure with no consequence for non-completion; (c) `verifier == creator` — the creator would be validating their own milestone, providing no independent oversight. All three cases return `Error::InvalidAddress` (code `#11`). Note: the Soroban SDK does not expose a way to detect the Stellar zero-address (`GAAA...WHF`) at the contract level in `no_std` environments; these role-overlap checks are the detectable "obviously invalid placeholder" validations available at contract level. + +### Recommendations for Production + +1. **Use Soroban Token Interface**: Implement standard token operations for USDC +2. **Add Access Control**: Implement `Ownable` pattern for admin functions +3. **Circuit Breaker**: Add emergency pause functionality +4. **Upgradeability**: Consider proxy pattern for contract upgrades +5. **Comprehensive Tests**: Achieve 95%+ test coverage +6. **External Audits**: Have security experts review before mainnet deployment +7. **Multisig Verifiers**: For high-value vaults, use a multisig address as the `verifier` + +--- + +## Usage Examples + +### Example 1: Create a Milestone-Based Funding Vault + +A project owner wants to lock 1000 USDC for a bug bounty program with a 30-day deadline. + +```rust +// Parameters +let creator: Address = Address::from_string("GA7..."); // Creator wallet +let amount: i128 = 1000 * 10_000_000; // 1000 USDC in stroops +let start_timestamp: u64 = 1704067200; // Jan 1, 2024 00:00:00 UTC +let end_timestamp: u64 = 1706640000; // Jan 30, 2024 00:00:00 UTC (30 days) + +// Hash of milestone requirements (off-chain document) +let milestone_hash: BytesN<32> = BytesN::from_array(&env, &[ + 0x4d, 0x69, 0x6c, 0x65, 0x73, 0x74, 0x6f, 0x6e, + 0x65, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x68, + 0x61, 0x73, 0x68, 0x5f, 0x65, 0x78, 0x61, 0x6d +]); + +let verifier: Option
= Some(Address::from_string("GB7...")); +let success_destination: Address = Address::from_string("GC7..."); // Project wallet +let failure_destination: Address = Address::from_string("GD7..."); // Funder wallet + +// Create vault +let vault_id = DisciplrVaultClient::new(&env, &contract_address) + .create_vault( + &creator, + &amount, + &start_timestamp, + &end_timestamp, + &milestone_hash, + &verifier, + &success_destination, + &failure_destination, + ); +// vault_id = 0 +``` + +### Example 2: Validate Milestone and Release Funds + +The verifier validates that milestone requirements were met and releases funds. + +```rust +let verifier: Address = Address::from_string("GB7..."); // Designated verifier + +let result = DisciplrVaultClient::new(&env, &contract_address) + .with_source_account(&verifier) + .validate_milestone(&vault_id); +// result = true + +// Funds now transferred to success_destination +// Vault status changed to Completed +``` + +### Example 3: Handle Deadline Without Validation + +After the deadline passes without milestone validation, funds are redirected. + +```rust +// Assume end_timestamp has passed and no validation occurred + +let result = DisciplrVaultClient::new(&env, &contract_address) + .redirect_funds(&vault_id); +// result = true + +// Funds transferred to failure_destination +// Vault status changed to Failed +``` + +### Example 4: Cancel Vault Before Deadline + +Creator decides to cancel the vault before the deadline. + +```rust +let creator: Address = Address::from_string("GA7..."); // Original creator + +let result = DisciplrVaultClient::new(&env, &contract_address) + .with_source_account(&creator) + .cancel_vault(&vault_id); +// result = true + +// Funds returned to creator +// Vault status changed to Cancelled +``` + +### Example 5: Query Vault State + +Check the current state of a vault. + +```rust +let vault_state = DisciplrVaultClient::new(&env, &contract_address) + .get_vault_state(&vault_id); + +// Returns Some(ProductivityVault) or None +match vault_state { + Some(vault) => { + // Access vault fields + let current_status = vault.status; + let amount_locked = vault.amount; + } + None => { + // Vault not found or not initialized + } +} +``` + +--- + +## Testing + +Run the test suite to verify contract functionality: + +```bash +cargo test +``` + +Expected output should include tests for: +- Vault creation with valid parameters +- Vault creation authorization +- Event emission on vault creation +- Milestone validation logic +- Fund release and redirect logic +- Vault cancellation +- State retrieval +- Equal destination rejection (Issue #124) +- Invalid address role rejection (Issue #125) + +--- + +## File Structure + +``` +disciplr-contracts/ +├── src/ +│ └── lib.rs # DisciplrVault contract implementation +├── tests/ +│ └── create_vault.rs # Integration tests for create_vault +├── Cargo.toml # Project dependencies +├── README.md # Project overview +└── vesting.md # This documentation +``` + +--- + +## Related Documentation + +- [Soroban SDK Documentation](https://developers.stellar.org/docs/smart-contracts) +- [Stellar Smart Contracts Guide](https://developers.stellar.org/docs/smart-contracts/getting-started) +- [Token Interface (Soroban)](https://developers.stellar.org/docs/tokens) + +--- + +## Changelog + +| Version | Changes | +|---------|---------| +| 0.1.0 | Initial release with basic vault structure, stubbed implementations | +| 0.2.0 | Issue #124: reject equal success/failure destinations (`SameDestination` error) | +| 0.3.0 | Issue #125: reject invalid address role overlaps (`InvalidAddress` error) | +======= +>>>>>>> main