From 084dd87fadf4bc66daeb9b5aff4592b8ab913509 Mon Sep 17 00:00:00 2001 From: D'Angelo Rodriguez <70290504+dangelo352@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:49:46 -0400 Subject: [PATCH] feat: support multi-milestone vaults --- README.md | 16 +- contract-interface.json | 14 +- docs/MULTI_MILESTONE.md | 29 +++ src/doc.md | 57 +++--- src/lib.rs | 157 ++++++++++++----- .../test_create_vault_exact_balance.1.json | 22 ++- .../test/test_create_vault_success.1.json | 22 ++- .../test_create_vault_with_verifier.1.json | 22 ++- ..._cancelled_vault_still_returns_some.1.json | 22 ++- ...ate_failed_vault_still_returns_some.1.json | 22 ++- tests/lifecycle.rs | 27 +-- tests/multi_milestone.rs | 165 ++++++++++++++++++ tests/proptest_amounts.rs | 18 +- tests/proptest_timestamps.rs | 27 +-- 14 files changed, 484 insertions(+), 136 deletions(-) create mode 100644 docs/MULTI_MILESTONE.md create mode 100644 tests/multi_milestone.rs diff --git a/README.md b/README.md index f980c63..32e72bd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ and the machine-readable interface lives in | Entrypoint | Mutates state | Purpose | | --- | --- | --- | | `create_vault` | Yes | Creates and funds a new vault, assigns a sequential vault id, and starts it in `Active`. | -| `validate_milestone` | Yes | Marks an `Active` vault milestone as validated before the deadline. | +| `validate_milestone` | Yes | Marks one indexed milestone on an `Active` vault as validated before the deadline. | | `release_funds` | Yes | Sends funds to `success_destination` and moves the vault to `Completed`. | | `redirect_funds` | Yes | Sends funds to `failure_destination` and moves the vault to `Failed`. | | `cancel_vault` | Yes | Returns funds to the creator and moves the vault to `Cancelled`. | @@ -40,6 +40,7 @@ and the backend mapping in [`src/doc.md`](src/doc.md#error-handling). | `7` | `InvalidAmount` | `create_vault` | `amount` is below `MIN_AMOUNT` or above `MAX_AMOUNT`. This covers zero, negative, and over-maximum amounts. | | `8` | `InvalidTimestamps` | `create_vault` | `end_timestamp` is less than or equal to `start_timestamp`. | | `9` | `DurationTooLong` | `create_vault` | `end_timestamp - start_timestamp` exceeds `MAX_VAULT_DURATION` (365 days). | +| `10` | `MilestoneIndexOutOfRange` | `create_vault`, `validate_milestone` | `create_vault` receives an empty milestone vector, or `validate_milestone` receives an index outside the vault's milestone list. | ## Vault Lifecycle @@ -49,8 +50,8 @@ Vault records are never deleted by normal contract operation. A vault starts in ```mermaid stateDiagram-v2 [*] --> Active: create_vault - Active --> Active: validate_milestone / milestone_validated = true - Active --> Completed: release_funds / success_destination receives funds + Active --> Active: validate_milestone(index) + Active --> Completed: release_funds / all milestones validated or deadline reached Active --> Failed: redirect_funds / failure_destination receives funds Active --> Cancelled: cancel_vault / creator receives refund Completed --> [*] @@ -61,14 +62,17 @@ stateDiagram-v2 | From | To | Entrypoint | Preconditions | Resulting event | | --- | --- | --- | --- | --- | | None | `Active` | `create_vault` | Creator authorizes; amount and timestamps are valid; duration is within the maximum; token transfer into the contract succeeds. | `vault_created` | -| `Active` | `Active` | `validate_milestone` | Vault exists, is active, caller is the configured verifier or creator fallback, and ledger time is before `end_timestamp`. | `milestone_validated` | -| `Active` | `Completed` | `release_funds` | Creator authorizes; vault is active; milestone is validated or the deadline has been reached. | `funds_released` | -| `Active` | `Failed` | `redirect_funds` | Vault is active; ledger time is strictly greater than `end_timestamp`; milestone is not validated. | `funds_redirected` | +| `Active` | `Active` | `validate_milestone` | Vault exists, is active, caller is the configured verifier or creator fallback, ledger time is before `end_timestamp`, and the milestone index is in range and not already validated. | `milestone_validated` | +| `Active` | `Completed` | `release_funds` | Creator authorizes; vault is active; every milestone is validated or the deadline has been reached. | `funds_released` | +| `Active` | `Failed` | `redirect_funds` | Vault is active; ledger time is strictly greater than `end_timestamp`; not every milestone is validated. | `funds_redirected` | | `Active` | `Cancelled` | `cancel_vault` | Creator authorizes and vault is active. | `vault_cancelled` | Any attempt to call `validate_milestone`, `release_funds`, `redirect_funds`, or `cancel_vault` after a terminal transition returns `VaultNotActive` (`#3`). +See [`docs/MULTI_MILESTONE.md`](docs/MULTI_MILESTONE.md) for the milestone +vector model and indexed validation examples. + ## Source Of Truth - [`src/lib.rs`](src/lib.rs) defines the enum values, transition guards, events, diff --git a/contract-interface.json b/contract-interface.json index 6450206..7e88ff8 100644 --- a/contract-interface.json +++ b/contract-interface.json @@ -19,12 +19,12 @@ { "name": "amount", "type": "i128" }, { "name": "start_timestamp", "type": "u64" }, { "name": "end_timestamp", "type": "u64" }, - { "name": "milestone_hash", "type": "BytesN<32>" }, + { "name": "milestones", "type": "Vec>" }, { "name": "verifier", "type": "Option
" }, { "name": "success_destination", "type": "Address" }, { "name": "failure_destination", "type": "Address" }, { "name": "status", "type": "VaultStatus" }, - { "name": "milestone_validated", "type": "bool" } + { "name": "milestone_validations", "type": "Vec" } ] }, "Error": { @@ -38,7 +38,8 @@ "InvalidStatus": 6, "InvalidAmount": 7, "InvalidTimestamps": 8, - "DurationTooLong": 9 + "DurationTooLong": 9, + "MilestoneIndexOutOfRange": 10 } } }, @@ -51,7 +52,7 @@ { "name": "amount", "type": "i128" }, { "name": "start_timestamp", "type": "u64" }, { "name": "end_timestamp", "type": "u64" }, - { "name": "milestone_hash", "type": "BytesN<32>" }, + { "name": "milestones", "type": "Vec>" }, { "name": "verifier", "type": "Option
" }, { "name": "success_destination", "type": "Address" }, { "name": "failure_destination", "type": "Address" } @@ -61,7 +62,8 @@ { "name": "validate_milestone", "inputs": [ - { "name": "vault_id", "type": "u32" } + { "name": "vault_id", "type": "u32" }, + { "name": "milestone_index", "type": "u32" } ], "outputs": { "type": "Result" } }, @@ -111,7 +113,7 @@ { "name": "milestone_validated", "topic": ["milestone_validated", "u32"], - "data": null + "data": "u32" }, { "name": "funds_released", diff --git a/docs/MULTI_MILESTONE.md b/docs/MULTI_MILESTONE.md new file mode 100644 index 0000000..fe5d3cc --- /dev/null +++ b/docs/MULTI_MILESTONE.md @@ -0,0 +1,29 @@ +# Multi-Milestone Vaults + +Vaults store an ordered `milestones: Vec>` plus a parallel +`milestone_validations: Vec`. Both vectors have the same length and index +`0` preserves the previous single-milestone behavior when callers submit a +one-item vector. + +## Validation + +`validate_milestone(vault_id, milestone_index)` marks exactly one milestone as +validated. The verifier authorization model is unchanged: a configured verifier +must authorize validation, and creator authorization is used when no verifier is +set. + +The contract rejects: + +- empty milestone vectors at creation +- out-of-range milestone indexes +- duplicate validation of the same milestone index +- validation at or after `end_timestamp` + +## Release And Redirect + +Before the deadline, `release_funds` requires every milestone validation flag to +be `true`. After the deadline, the existing deadline release path still applies. + +`redirect_funds` remains available only after the deadline and only while not +every milestone has been validated, so a partially completed vault can still be +routed to `failure_destination` after the window closes. diff --git a/src/doc.md b/src/doc.md index 4fb8ad2..d807c10 100644 --- a/src/doc.md +++ b/src/doc.md @@ -47,7 +47,9 @@ This guide provides comprehensive documentation for backend developers integrati "amount": "1000000000", "start_timestamp": 1704067200, "end_timestamp": 1706640000, - "milestone_hash": "4d696c6573746f6e655f726571756972656d656e74735f68617368", + "milestones": [ + "4d696c6573746f6e655f726571756972656d656e74735f68617368" + ], "verifier": "GB7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "success_destination": "GC7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "failure_destination": "GD7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" @@ -63,7 +65,7 @@ This guide provides comprehensive documentation for backend developers integrati | `amount` | string | Yes | Amount in stroops (7 decimals: 1 USDC = 10,000,000 stroops) | | `start_timestamp` | integer | Yes | Unix timestamp when vault becomes active | | `end_timestamp` | integer | Yes | Unix timestamp deadline for milestone validation | -| `milestone_hash` | string | Yes | Hex-encoded SHA-256 hash of milestone document | +| `milestones` | array | Yes | Ordered milestone document hashes; submit one item to preserve the legacy single-milestone flow | | `verifier` | string | Optional | Designated verifier address (null for creator-only validation) | | `success_destination` | string | Yes | Address to receive funds on successful milestone | | `failure_destination` | string | Yes | Address to receive funds on failure | @@ -104,18 +106,20 @@ This guide provides comprehensive documentation for backend developers integrati **Request Payload:** ```json -{ - "vault_id": 42, - "verifier_signature": "signature_data_here" -} +{ + "vault_id": 42, + "milestone_index": 0, + "verifier_signature": "signature_data_here" +} ``` **Field Descriptions:** | Field | Type | Required | Description | -|-------|------|----------|-------------| -| `vault_id` | integer | Yes | ID of vault to validate | -| `verifier_signature` | string | Yes | Signed transaction from authorized verifier | +|-------|------|----------|-------------| +| `vault_id` | integer | Yes | ID of vault to validate | +| `milestone_index` | integer | Yes | Zero-based milestone index to validate | +| `verifier_signature` | string | Yes | Signed transaction from authorized verifier | **Constraints:** - Vault must exist and be in `Active` status @@ -127,7 +131,8 @@ This guide provides comprehensive documentation for backend developers integrati ```json { "vault_id": 42, - "milestone_validated": true, + "milestone_index": 0, + "milestone_validated": true, "transaction_hash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", "ledger_sequence": 12345680, "validated_at": "2024-01-15T12:30:00Z" @@ -168,7 +173,7 @@ This guide provides comprehensive documentation for backend developers integrati **Constraints:** - Vault must exist and be in `Active` status -- Either `milestone_validated` is true OR current time >= `end_timestamp` +- Every milestone validation flag is true OR current time >= `end_timestamp` **Response (200 OK):** ```json @@ -217,7 +222,7 @@ This guide provides comprehensive documentation for backend developers integrati **Constraints:** - Vault must exist and be in `Active` status - Current timestamp must be strictly greater than or equal to `end_timestamp` -- `milestone_validated` must be false +- At least one milestone validation flag must be false **Response (200 OK):** ```json @@ -305,12 +310,14 @@ This guide provides comprehensive documentation for backend developers integrati "amount": "1000000000", "start_timestamp": 1704067200, "end_timestamp": 1706640000, - "milestone_hash": "4d696c6573746f6e655f726571756972656d656e74735f68617368", + "milestones": [ + "4d696c6573746f6e655f726571756972656d656e74735f68617368" + ], "verifier": "GB7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "success_destination": "GC7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "failure_destination": "GD7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "status": "Active", - "milestone_validated": false + "milestone_validations": [false] } } ``` @@ -353,7 +360,7 @@ interface CreateVaultRequest { amount: string; start_timestamp: number; end_timestamp: number; - milestone_hash: string; + milestones: string[]; verifier?: string; success_destination: string; failure_destination: string; @@ -414,10 +421,11 @@ class VaultService { ### Pattern 2: Milestone Validation Flow ```typescript -interface ValidateMilestoneRequest { - vault_id: number; - verifier_signature: string; -} +interface ValidateMilestoneRequest { + vault_id: number; + milestone_index: number; + verifier_signature: string; +} class ValidationService { async validateMilestone(request: ValidateMilestoneRequest): Promise { @@ -446,7 +454,8 @@ class ValidationService { return { vault_id: request.vault_id, - milestone_validated: true, + milestone_index: request.milestone_index, + milestone_validated: true, transaction_hash: result.txHash, ledger_sequence: result.ledger, validated_at: new Date().toISOString() @@ -481,7 +490,7 @@ class ReleaseService { // 2. Check release conditions const now = Math.floor(Date.now() / 1000); const deadlineReached = now >= vault.end_timestamp; - const validated = vault.milestone_validated; + const validated = vault.milestone_validations.every(Boolean); if (!validated && !deadlineReached) { throw new AuthorizationError('NotAuthorized', @@ -588,8 +597,8 @@ All transactions benefit from Stellar's built-in replay protection via sequence |-----------|-------------------|-------| | `create_vault` | Creator | Must sign and authorize USDC transfer | | `validate_milestone` | Verifier (if set) or Creator | Must be before deadline | -| `release_funds` | Anyone | Conditions: validated OR past deadline | -| `redirect_funds` | Anyone | Conditions: not validated AND past deadline | +| `release_funds` | Anyone | Conditions: all milestones validated OR past deadline | +| `redirect_funds` | Anyone | Conditions: not all milestones validated AND past deadline | | `cancel_vault` | Creator only | Vault must be Active | --- @@ -601,7 +610,7 @@ All transactions benefit from Stellar's built-in replay protection via sequence | Event | Topic | Data | Trigger | |-------|-------|------|---------| | `vault_created` | `("vault_created", vault_id)` | `ProductivityVault` | Vault creation | -| `milestone_validated` | `("milestone_validated", vault_id)` | `()` | Successful validation | +| `milestone_validated` | `("milestone_validated", vault_id)` | `u32` | Successful validation index | | `funds_released` | `("funds_released", vault_id)` | `amount: i128` | Fund release | | `funds_redirected` | `("funds_redirected", vault_id)` | `amount: i128` | Fund redirect | | `vault_cancelled` | `("vault_cancelled", vault_id)` | `()` | Vault cancellation | diff --git a/src/lib.rs b/src/lib.rs index 9893393..5e2415f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,7 @@ #![allow(clippy::too_many_arguments)] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, + contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, Vec, }; // --------------------------------------------------------------------------- @@ -37,6 +37,8 @@ pub enum Error { InvalidTimestamps = 8, /// Vault duration (end − start) exceeds MAX_VAULT_DURATION. DurationTooLong = 9, + /// Milestone index is outside the vault's milestone list, or the list is empty at creation. + MilestoneIndexOutOfRange = 10, } // --------------------------------------------------------------------------- @@ -64,8 +66,8 @@ pub struct ProductivityVault { 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>, + /// Ordered milestone hashes the creator commits to. + pub milestones: Vec>, /// 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 @@ -77,9 +79,8 @@ pub struct ProductivityVault { 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, + /// Per-index validation flags matching `milestones`. + pub milestone_validations: Vec, } // --------------------------------------------------------------------------- @@ -115,6 +116,27 @@ pub enum DataKey { VaultCount, } +fn all_milestones_validated(validations: &Vec) -> bool { + let mut index = 0; + while index < validations.len() { + if !validations.get_unchecked(index) { + return false; + } + index += 1; + } + true +} + +fn build_validation_flags(env: &Env, len: u32) -> Vec { + let mut validations = Vec::new(env); + let mut index = 0; + while index < len { + validations.push_back(false); + index += 1; + } + validations +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -139,7 +161,7 @@ impl DisciplrVault { amount: i128, start_timestamp: u64, end_timestamp: u64, - milestone_hash: BytesN<32>, + milestones: Vec>, verifier: Option
, success_destination: Address, failure_destination: Address, @@ -168,6 +190,9 @@ impl DisciplrVault { if duration > MAX_VAULT_DURATION { return Err(Error::DurationTooLong); } + if milestones.is_empty() { + return Err(Error::MilestoneIndexOutOfRange); + } // Pull USDC from creator into this contract. let token_client = token::Client::new(&env, &usdc_token); @@ -188,12 +213,12 @@ impl DisciplrVault { amount, start_timestamp, end_timestamp, - milestone_hash, + milestone_validations: build_validation_flags(&env, milestones.len()), + milestones, verifier, success_destination, failure_destination, status: VaultStatus::Active, - milestone_validated: false, }; env.storage() @@ -212,12 +237,16 @@ impl DisciplrVault { // validate_milestone // ----------------------------------------------------------------------- - /// Verifier (or authorized party) validates milestone completion. + /// Verifier (or authorized party) validates one milestone by index. /// /// **Optional verifier behavior:** If `verifier` is `Some(addr)`, only that address may call /// this function. If `verifier` is `None`, only the creator may call it (no validation by /// other parties). Rejects when current time >= end_timestamp (MilestoneExpired). - pub fn validate_milestone(env: Env, vault_id: u32) -> Result { + pub fn validate_milestone( + env: Env, + vault_id: u32, + milestone_index: u32, + ) -> Result { let vault_key = DataKey::Vault(vault_id); let mut vault: ProductivityVault = env .storage() @@ -240,12 +269,20 @@ impl DisciplrVault { if env.ledger().timestamp() >= vault.end_timestamp { return Err(Error::MilestoneExpired); } + if milestone_index >= vault.milestone_validations.len() { + return Err(Error::MilestoneIndexOutOfRange); + } + if vault.milestone_validations.get_unchecked(milestone_index) { + return Err(Error::InvalidStatus); + } - vault.milestone_validated = true; + vault.milestone_validations.set(milestone_index, true); env.storage().instance().set(&vault_key, &vault); - env.events() - .publish((Symbol::new(&env, "milestone_validated"), vault_id), ()); + env.events().publish( + (Symbol::new(&env, "milestone_validated"), vault_id), + milestone_index, + ); Ok(true) } @@ -271,7 +308,7 @@ impl DisciplrVault { // Check release conditions. let now = env.ledger().timestamp(); let deadline_reached = now >= vault.end_timestamp; - let validated = vault.milestone_validated; + let validated = all_milestones_validated(&vault.milestone_validations); if !validated && !deadline_reached { return Err(Error::NotAuthorized); @@ -315,8 +352,8 @@ impl DisciplrVault { return Err(Error::InvalidTimestamp); // Too early to redirect } - // If milestone was validated the funds should go to success, not failure. - if vault.milestone_validated { + // If all milestones were validated the funds should go to success, not failure. + if all_milestones_validated(&vault.milestone_validations) { return Err(Error::NotAuthorized); } @@ -416,6 +453,12 @@ mod tests { // Helpers // ----------------------------------------------------------------------- + fn single_milestone(env: &Env, hash: BytesN<32>) -> Vec> { + let mut milestones = Vec::new(env); + milestones.push_back(hash); + milestones + } + struct TestSetup { env: Env, contract_id: Address, @@ -482,6 +525,12 @@ mod tests { BytesN::from_array(&self.env, &[1u8; 32]) } + fn milestones(&self) -> Vec> { + let mut milestones = Vec::new(&self.env); + milestones.push_back(self.milestone_hash()); + milestones + } + fn create_default_vault(&self) -> u32 { self.client().create_vault( &self.usdc_token, @@ -489,7 +538,7 @@ mod tests { &self.amount, &self.start_timestamp, &self.end_timestamp, - &self.milestone_hash(), + &self.milestones(), &Some(self.verifier.clone()), &self.success_dest, &self.failure_dest, @@ -504,7 +553,7 @@ mod tests { &self.amount, &self.start_timestamp, &self.end_timestamp, - &self.milestone_hash(), + &self.milestones(), &None, &self.success_dest, &self.failure_dest, @@ -531,7 +580,8 @@ mod tests { 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.milestones.get_unchecked(0), setup.milestone_hash()); + assert!(!vault.milestone_validations.get_unchecked(0)); assert_eq!(vault.verifier, Some(setup.verifier)); assert_eq!(vault.success_destination, setup.success_dest); assert_eq!(vault.failure_destination, setup.failure_dest); @@ -584,6 +634,8 @@ mod tests { let client = setup.client(); let custom_hash = BytesN::from_array(&setup.env, &[0xab; 32]); + let mut custom_milestones = Vec::new(&setup.env); + custom_milestones.push_back(custom_hash.clone()); setup.env.ledger().set_timestamp(setup.start_timestamp); let vault_id = client.create_vault( @@ -592,14 +644,14 @@ mod tests { &setup.amount, &setup.start_timestamp, &setup.end_timestamp, - &custom_hash, + &custom_milestones, &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); + assert_eq!(vault.milestones.get_unchecked(0), custom_hash); } #[test] @@ -613,7 +665,7 @@ mod tests { &0i128, &setup.start_timestamp, &setup.end_timestamp, - &setup.milestone_hash(), + &setup.milestones(), &None, &setup.success_dest, &setup.failure_dest, @@ -635,7 +687,7 @@ mod tests { &setup.amount, &1000u64, &1000u64, - &setup.milestone_hash(), + &setup.milestones(), &None, &setup.success_dest, &setup.failure_dest, @@ -658,14 +710,14 @@ mod tests { setup.env.ledger().set_timestamp(setup.end_timestamp); // Try to validate milestone - should fail with MilestoneExpired - let result = client.try_validate_milestone(&vault_id); + let result = client.try_validate_milestone(&vault_id, &0); 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); + let result = client.try_validate_milestone(&vault_id, &0); assert!(result.is_err()); } @@ -680,12 +732,12 @@ mod tests { // Set time to just before end setup.env.ledger().set_timestamp(setup.end_timestamp - 1); - let success = client.validate_milestone(&vault_id); + let success = client.validate_milestone(&vault_id, &0); assert!(success); let vault = client.get_vault_state(&vault_id).unwrap(); // Validation now sets milestone_validated, NOT status = Completed - assert!(vault.milestone_validated); + assert!(vault.milestone_validations.get_unchecked(0)); assert_eq!(vault.status, VaultStatus::Active); } @@ -700,11 +752,11 @@ mod tests { setup.env.ledger().set_timestamp(setup.end_timestamp - 1); - let result = client.validate_milestone(&vault_id); + let result = client.validate_milestone(&vault_id, &0); assert!(result); let vault = client.get_vault_state(&vault_id).unwrap(); - assert!(vault.milestone_validated); + assert!(vault.milestone_validations.get_unchecked(0)); assert_eq!(vault.verifier, None); } @@ -756,7 +808,7 @@ mod tests { &setup.amount, &1000, &1000, // start == end - &setup.milestone_hash(), + &setup.milestones(), &None, &setup.success_dest, &setup.failure_dest, @@ -775,7 +827,7 @@ mod tests { &setup.amount, &2000, &1000, // start > end - &setup.milestone_hash(), + &setup.milestones(), &None, &setup.success_dest, &setup.failure_dest, @@ -809,7 +861,7 @@ mod tests { let vault_id = setup.create_default_vault(); // Validate milestone. - client.validate_milestone(&vault_id); + client.validate_milestone(&vault_id, &0); let usdc = setup.usdc_client(); let success_before = usdc.balance(&setup.success_dest); @@ -908,7 +960,7 @@ mod tests { client.release_funds(&vault_id, &setup.usdc_token); // Validate after completion — must error. - assert!(client.try_validate_milestone(&vault_id).is_err()); + assert!(client.try_validate_milestone(&vault_id, &0).is_err()); } #[test] @@ -996,6 +1048,7 @@ mod tests { let failure_addr = Address::generate(&env); let verifier = Address::generate(&env); let milestone_hash = BytesN::<32>::from_array(&env, &[0u8; 32]); + let milestones = single_milestone(&env, milestone_hash); // DO NOT authorize the creator let _vault_id = DisciplrVault::create_vault( @@ -1005,7 +1058,7 @@ mod tests { 1000, 100, 200, - milestone_hash, + milestones, Some(verifier), success_addr, failure_addr, @@ -1024,7 +1077,7 @@ mod tests { &0i128, &1000, &2000, - &setup.milestone_hash(), + &setup.milestones(), &None, &setup.success_dest, &setup.failure_dest, @@ -1042,6 +1095,7 @@ mod tests { let failure_addr = Address::generate(&env); let verifier = Address::generate(&env); let milestone_hash = BytesN::<32>::from_array(&env, &[1u8; 32]); + let milestones = single_milestone(&env, milestone_hash); different_caller.require_auth(); @@ -1052,7 +1106,7 @@ mod tests { 1000, 100, 200, - milestone_hash, + milestones, Some(verifier), success_addr, failure_addr, @@ -1102,6 +1156,7 @@ mod tests { let success_addr = Address::generate(&env); let failure_addr = Address::generate(&env); let milestone_hash = BytesN::<32>::from_array(&env, &[4u8; 32]); + let milestones = single_milestone(&env, milestone_hash); attacker.require_auth(); @@ -1112,7 +1167,7 @@ mod tests { 5000, 100, 200, - milestone_hash, + milestones, None, success_addr, failure_addr, @@ -1150,7 +1205,7 @@ mod tests { &amount, &start_timestamp, &end_timestamp, - &milestone_hash, + &single_milestone(&env, milestone_hash.clone()), &Some(verifier.clone()), &success_destination, &failure_destination, @@ -1206,7 +1261,7 @@ mod tests { let vault_id = setup.create_default_vault(); // Release funds to make it Completed - client.validate_milestone(&vault_id); + client.validate_milestone(&vault_id, &0); client.release_funds(&vault_id, &setup.usdc_token); // Attempt to cancel - should panic with error VaultNotActive @@ -1309,6 +1364,12 @@ mod test { }; extern crate std; + fn single_milestone(env: &Env, hash: BytesN<32>) -> Vec> { + let mut milestones = Vec::new(env); + milestones.push_back(hash); + milestones + } + fn create_token_contract<'a>( env: &Env, admin: &Address, @@ -1353,7 +1414,7 @@ mod test { &MIN_AMOUNT, &100, &200, - &milestone_hash, + &single_milestone(&env, milestone_hash.clone()), &None, &success_dest, &failure_dest, @@ -1385,7 +1446,7 @@ mod test { &0, &100, &200, - &milestone_hash, + &single_milestone(&env, milestone_hash.clone()), &None, &Address::generate(&env), &Address::generate(&env), @@ -1413,7 +1474,7 @@ mod test { &-100, &100, &200, - &milestone_hash, + &single_milestone(&env, milestone_hash.clone()), &None, &Address::generate(&env), &Address::generate(&env), @@ -1441,7 +1502,7 @@ mod test { &MIN_AMOUNT, &200, &100, - &milestone_hash, + &single_milestone(&env, milestone_hash.clone()), &None, &Address::generate(&env), &Address::generate(&env), @@ -1469,7 +1530,7 @@ mod test { &MIN_AMOUNT, &100, &100, - &milestone_hash, + &single_milestone(&env, milestone_hash.clone()), &None, &Address::generate(&env), &Address::generate(&env), @@ -1500,7 +1561,7 @@ mod test { &MIN_AMOUNT, &100, &200, - &milestone_hash, + &single_milestone(&env, milestone_hash.clone()), &None, &Address::generate(&env), &Address::generate(&env), @@ -1530,7 +1591,7 @@ mod test { &MIN_AMOUNT, &100, &200, - &milestone_hash, + &single_milestone(&env, milestone_hash.clone()), &Some(verifier), &Address::generate(&env), &Address::generate(&env), @@ -1563,7 +1624,7 @@ mod test { &MIN_AMOUNT, &100, &200, - &milestone_hash, + &single_milestone(&env, milestone_hash.clone()), &None, &Address::generate(&env), &Address::generate(&env), diff --git a/test_snapshots/test/test_create_vault_exact_balance.1.json b/test_snapshots/test/test_create_vault_exact_balance.1.json index 6ecff57..1cb3ccf 100644 --- a/test_snapshots/test/test_create_vault_exact_balance.1.json +++ b/test_snapshots/test/test_create_vault_exact_balance.1.json @@ -77,7 +77,11 @@ "u64": 200 }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "vec": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] }, "void", { @@ -330,18 +334,26 @@ }, { "key": { - "symbol": "milestone_hash" + "symbol": "milestone_validations" }, "val": { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "vec": [ + { + "bool": false + } + ] } }, { "key": { - "symbol": "milestone_validated" + "symbol": "milestones" }, "val": { - "bool": false + "vec": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] } }, { diff --git a/test_snapshots/test/test_create_vault_success.1.json b/test_snapshots/test/test_create_vault_success.1.json index baf60db..b83a27b 100644 --- a/test_snapshots/test/test_create_vault_success.1.json +++ b/test_snapshots/test/test_create_vault_success.1.json @@ -77,7 +77,11 @@ "u64": 200 }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "vec": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] }, "void", { @@ -330,18 +334,26 @@ }, { "key": { - "symbol": "milestone_hash" + "symbol": "milestone_validations" }, "val": { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "vec": [ + { + "bool": false + } + ] } }, { "key": { - "symbol": "milestone_validated" + "symbol": "milestones" }, "val": { - "bool": false + "vec": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] } }, { diff --git a/test_snapshots/test/test_create_vault_with_verifier.1.json b/test_snapshots/test/test_create_vault_with_verifier.1.json index b54b3aa..f0ec28b 100644 --- a/test_snapshots/test/test_create_vault_with_verifier.1.json +++ b/test_snapshots/test/test_create_vault_with_verifier.1.json @@ -77,7 +77,11 @@ "u64": 200 }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "vec": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" @@ -331,18 +335,26 @@ }, { "key": { - "symbol": "milestone_hash" + "symbol": "milestone_validations" }, "val": { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "vec": [ + { + "bool": false + } + ] } }, { "key": { - "symbol": "milestone_validated" + "symbol": "milestones" }, "val": { - "bool": false + "vec": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] } }, { diff --git a/test_snapshots/tests/test_get_vault_state_cancelled_vault_still_returns_some.1.json b/test_snapshots/tests/test_get_vault_state_cancelled_vault_still_returns_some.1.json index bef38d9..3f4a471 100644 --- a/test_snapshots/tests/test_get_vault_state_cancelled_vault_still_returns_some.1.json +++ b/test_snapshots/tests/test_get_vault_state_cancelled_vault_still_returns_some.1.json @@ -77,7 +77,11 @@ "u64": 1000 }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "vec": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" @@ -386,18 +390,26 @@ }, { "key": { - "symbol": "milestone_hash" + "symbol": "milestone_validations" }, "val": { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "vec": [ + { + "bool": false + } + ] } }, { "key": { - "symbol": "milestone_validated" + "symbol": "milestones" }, "val": { - "bool": false + "vec": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] } }, { diff --git a/test_snapshots/tests/test_get_vault_state_failed_vault_still_returns_some.1.json b/test_snapshots/tests/test_get_vault_state_failed_vault_still_returns_some.1.json index 56f4a68..f5ce39a 100644 --- a/test_snapshots/tests/test_get_vault_state_failed_vault_still_returns_some.1.json +++ b/test_snapshots/tests/test_get_vault_state_failed_vault_still_returns_some.1.json @@ -77,7 +77,11 @@ "u64": 1000 }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "vec": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" @@ -332,18 +336,26 @@ }, { "key": { - "symbol": "milestone_hash" + "symbol": "milestone_validations" }, "val": { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "vec": [ + { + "bool": false + } + ] } }, { "key": { - "symbol": "milestone_validated" + "symbol": "milestones" }, "val": { - "bool": false + "vec": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] } }, { diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index 7079583..e88df40 100644 --- a/tests/lifecycle.rs +++ b/tests/lifecycle.rs @@ -3,11 +3,17 @@ use soroban_sdk::{ testutils::{Address as _, Ledger}, token::{StellarAssetClient, TokenClient}, - Address, BytesN, Env, + Address, BytesN, Env, Vec, }; use disciplr_vault::{DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT}; +fn single_milestone(env: &Env, hash: BytesN<32>) -> Vec> { + let mut milestones = Vec::new(env); + milestones.push_back(hash); + milestones +} + fn setup() -> ( Env, DisciplrVaultClient<'static>, @@ -44,6 +50,7 @@ fn test_full_lifecycle_success() { usdc_asset.mint(&creator, &MIN_AMOUNT); let milestone = BytesN::from_array(&env, &[1u8; 32]); + let milestones = single_milestone(&env, milestone); // 1. Create Vault let vault_id = client.create_vault( @@ -52,7 +59,7 @@ fn test_full_lifecycle_success() { &MIN_AMOUNT, &now, &(now + 86_400), - &milestone, + &milestones, &Some(verifier.clone()), &success_dest, &failure_dest, @@ -65,13 +72,12 @@ fn test_full_lifecycle_success() { // 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 - ); + client.validate_milestone(&vault_id, &0); + assert!(client + .get_vault_state(&vault_id) + .unwrap() + .milestone_validations + .get_unchecked(0)); // 3. Release Funds client.release_funds(&vault_id, &usdc); @@ -93,6 +99,7 @@ fn test_full_lifecycle_failure_redirection() { usdc_asset.mint(&creator, &MIN_AMOUNT); let milestone = BytesN::from_array(&env, &[1u8; 32]); + let milestones = single_milestone(&env, milestone); // 1. Create Vault let vault_id = client.create_vault( @@ -101,7 +108,7 @@ fn test_full_lifecycle_failure_redirection() { &MIN_AMOUNT, &now, &(now + 86_400), - &milestone, + &milestones, &None, &success_dest, &failure_dest, diff --git a/tests/multi_milestone.rs b/tests/multi_milestone.rs new file mode 100644 index 0000000..50799d7 --- /dev/null +++ b/tests/multi_milestone.rs @@ -0,0 +1,165 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, BytesN, Env, Vec, +}; + +use disciplr_vault::{DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT}; + +struct Setup { + env: Env, + client: DisciplrVaultClient<'static>, + usdc: Address, + asset: StellarAssetClient<'static>, + creator: Address, + verifier: Address, + success: Address, + failure: Address, + now: u64, +} + +impl Setup { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + let now = 1_700_200_000u64; + env.ledger().set_timestamp(now); + + let contract_id = env.register(DisciplrVault, ()); + let client = DisciplrVaultClient::new(&env, &contract_id); + let usdc_admin = Address::generate(&env); + let usdc_contract = env.register_stellar_asset_contract_v2(usdc_admin); + let usdc = usdc_contract.address(); + let asset = StellarAssetClient::new(&env, &usdc); + + Self { + creator: Address::generate(&env), + verifier: Address::generate(&env), + success: Address::generate(&env), + failure: Address::generate(&env), + env, + client, + usdc, + asset, + now, + } + } + + fn milestones(&self, count: u8) -> Vec> { + let mut milestones = Vec::new(&self.env); + let mut index = 0; + while index < count { + milestones.push_back(BytesN::from_array(&self.env, &[index + 1; 32])); + index += 1; + } + milestones + } + + fn create_vault(&self, count: u8) -> u32 { + self.asset.mint(&self.creator, &MIN_AMOUNT); + self.client.create_vault( + &self.usdc, + &self.creator, + &MIN_AMOUNT, + &self.now, + &(self.now + 86_400), + &self.milestones(count), + &Some(self.verifier.clone()), + &self.success, + &self.failure, + ) + } +} + +#[test] +fn create_vault_stores_ordered_milestones_with_false_validation_flags() { + let setup = Setup::new(); + let vault_id = setup.create_vault(3); + + let vault = setup.client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.milestones.len(), 3); + assert_eq!( + vault.milestones.get_unchecked(1), + BytesN::from_array(&setup.env, &[2u8; 32]) + ); + assert_eq!(vault.milestone_validations.len(), 3); + assert!(!vault.milestone_validations.get_unchecked(0)); + assert!(!vault.milestone_validations.get_unchecked(1)); + assert!(!vault.milestone_validations.get_unchecked(2)); +} + +#[test] +fn validate_milestone_marks_only_requested_index() { + let setup = Setup::new(); + let vault_id = setup.create_vault(3); + + setup.env.ledger().set_timestamp(setup.now + 10); + setup.client.validate_milestone(&vault_id, &1); + + let vault = setup.client.get_vault_state(&vault_id).unwrap(); + assert!(!vault.milestone_validations.get_unchecked(0)); + assert!(vault.milestone_validations.get_unchecked(1)); + assert!(!vault.milestone_validations.get_unchecked(2)); + assert_eq!(vault.status, VaultStatus::Active); +} + +#[test] +fn release_before_deadline_requires_all_milestones_validated() { + let setup = Setup::new(); + let vault_id = setup.create_vault(2); + + setup.env.ledger().set_timestamp(setup.now + 10); + setup.client.validate_milestone(&vault_id, &0); + assert!(setup + .client + .try_release_funds(&vault_id, &setup.usdc) + .is_err()); + + setup.client.validate_milestone(&vault_id, &1); + setup.client.release_funds(&vault_id, &setup.usdc); + + let vault = setup.client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.status, VaultStatus::Completed); +} + +#[test] +fn out_of_range_milestone_index_is_rejected() { + let setup = Setup::new(); + let vault_id = setup.create_vault(2); + + setup.env.ledger().set_timestamp(setup.now + 10); + assert!(setup.client.try_validate_milestone(&vault_id, &2).is_err()); +} + +#[test] +fn duplicate_milestone_validation_is_rejected() { + let setup = Setup::new(); + let vault_id = setup.create_vault(2); + + setup.env.ledger().set_timestamp(setup.now + 10); + setup.client.validate_milestone(&vault_id, &0); + assert!(setup.client.try_validate_milestone(&vault_id, &0).is_err()); +} + +#[test] +fn create_vault_rejects_empty_milestone_vector() { + let setup = Setup::new(); + setup.asset.mint(&setup.creator, &MIN_AMOUNT); + let empty = Vec::>::new(&setup.env); + + let result = setup.client.try_create_vault( + &setup.usdc, + &setup.creator, + &MIN_AMOUNT, + &setup.now, + &(setup.now + 86_400), + &empty, + &Some(setup.verifier.clone()), + &setup.success, + &setup.failure, + ); + + assert!(result.is_err()); +} diff --git a/tests/proptest_amounts.rs b/tests/proptest_amounts.rs index fa0f5d0..a0adb3e 100644 --- a/tests/proptest_amounts.rs +++ b/tests/proptest_amounts.rs @@ -7,7 +7,7 @@ use proptest::prelude::*; use soroban_sdk::{ testutils::{Address as _, Ledger}, token::StellarAssetClient, - Address, BytesN, Env, + Address, BytesN, Env, Vec, }; fn setup() -> ( @@ -30,6 +30,12 @@ fn setup() -> ( (env, client, usdc_addr, usdc_asset) } +fn single_milestone(env: &Env, byte: u8) -> Vec> { + let mut milestones = Vec::new(env); + milestones.push_back(BytesN::from_array(env, &[byte; 32])); + milestones +} + proptest! { #![proptest_config(ProptestConfig::with_cases(128))] @@ -52,7 +58,7 @@ proptest! { &amount, &now, &(now + 86_400), - &BytesN::from_array(&env, &[7u8; 32]), + &single_milestone(&env, 7), &None, &success, &failure, @@ -82,7 +88,7 @@ proptest! { &amount, &now, &(now + 86_400), - &BytesN::from_array(&env, &[8u8; 32]), + &single_milestone(&env, 8), &None, &success, &failure, @@ -106,7 +112,7 @@ fn edge_amount_min_succeeds() { &MIN_AMOUNT, &1_725_000_000u64, &(1_725_000_000u64 + 86_400), - &BytesN::from_array(&env, &[9u8; 32]), + &single_milestone(&env, 9), &None, &Address::generate(&env), &Address::generate(&env), @@ -130,7 +136,7 @@ fn edge_amount_max_succeeds() { &MAX_AMOUNT, &1_725_000_000u64, &(1_725_000_000u64 + 86_400), - &BytesN::from_array(&env, &[10u8; 32]), + &single_milestone(&env, 10), &None, &Address::generate(&env), &Address::generate(&env), @@ -154,7 +160,7 @@ fn edge_amount_max_underfunded_errors() { &MAX_AMOUNT, &1_725_000_000u64, &(1_725_000_000u64 + 86_400), - &BytesN::from_array(&env, &[11u8; 32]), + &single_milestone(&env, 11), &None, &Address::generate(&env), &Address::generate(&env), diff --git a/tests/proptest_timestamps.rs b/tests/proptest_timestamps.rs index d8e37e8..15f0227 100644 --- a/tests/proptest_timestamps.rs +++ b/tests/proptest_timestamps.rs @@ -9,7 +9,7 @@ use proptest::prelude::*; use soroban_sdk::{ testutils::{Address as _, Ledger}, token::StellarAssetClient, - Address, BytesN, Env, + Address, BytesN, Env, Vec, }; fn setup() -> ( @@ -32,6 +32,12 @@ fn setup() -> ( (env, client, usdc_addr, usdc_asset) } +fn single_milestone(env: &Env, byte: u8) -> Vec> { + let mut milestones = Vec::new(env); + milestones.push_back(BytesN::from_array(env, &[byte; 32])); + milestones +} + fn assert_contract_error( result: Result>, expected: Error, @@ -57,7 +63,7 @@ proptest! { let creator = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); - let milestone = BytesN::from_array(&env, &[0u8; 32]); + let milestone = single_milestone(&env, 0); env.ledger().set_timestamp(now); @@ -112,7 +118,7 @@ proptest! { &amount, &start, &end, - &BytesN::from_array(&env, &[1u8; 32]), + &single_milestone(&env, 1), &None, &success, &failure, @@ -147,7 +153,7 @@ proptest! { &amount, &start, &end, - &BytesN::from_array(&env, &[2u8; 32]), + &single_milestone(&env, 2), &None, &success, &failure, @@ -167,7 +173,7 @@ proptest! { let creator = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); - let milestone = BytesN::from_array(&env, &[3u8; 32]); + let milestone = single_milestone(&env, 3); env.ledger().set_timestamp(now); @@ -225,7 +231,7 @@ proptest! { let creator = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); - let milestone = BytesN::from_array(&env, &[4u8; 32]); + let milestone = single_milestone(&env, 4); env.ledger().set_timestamp(now); @@ -268,7 +274,7 @@ fn edge_start_eq_now_succeeds() { &MIN_AMOUNT, &start, &end, - &BytesN::from_array(&env, &[6u8; 32]), + &single_milestone(&env, 6), &None, &Address::generate(&env), &Address::generate(&env), @@ -279,7 +285,6 @@ fn edge_start_eq_now_succeeds() { assert_eq!(vault.end_timestamp, end); } - #[test] fn edge_start_eq_end_rejected() { let (env, client, usdc, usdc_asset) = setup(); @@ -295,7 +300,7 @@ fn edge_start_eq_end_rejected() { &MIN_AMOUNT, &now, &now, - &BytesN::from_array(&env, &[3u8; 32]), + &single_milestone(&env, 3), &None, &Address::generate(&env), &Address::generate(&env), @@ -318,7 +323,7 @@ fn edge_zero_start_with_current_zero_succeeds() { &MIN_AMOUNT, &0, &1, - &BytesN::from_array(&env, &[4u8; 32]), + &single_milestone(&env, 4), &None, &Address::generate(&env), &Address::generate(&env), @@ -347,7 +352,7 @@ fn edge_max_duration_boundary_succeeds() { &MIN_AMOUNT, &start, &end, - &BytesN::from_array(&env, &[5u8; 32]), + &single_milestone(&env, 5), &None, &Address::generate(&env), &Address::generate(&env),