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