From 2bce84d2bca5e8183e76bf4c96c2d6ef7b335272 Mon Sep 17 00:00:00 2001 From: AUTONOMIA Bot Date: Tue, 7 Jul 2026 06:14:18 +0000 Subject: [PATCH] feat: add partial milestone release support --- README.md | 8 +- docs/PARTIAL_RELEASE.md | 42 ++++ src/lib.rs | 209 +++++++++++++++++- .../test_create_vault_exact_balance.1.json | 11 + .../test/test_create_vault_success.1.json | 11 + .../test_create_vault_with_verifier.1.json | 11 + ..._cancelled_vault_still_returns_some.1.json | 11 + ...ate_failed_vault_still_returns_some.1.json | 11 + tests/partial_release.rs | 79 +++++++ tests/proptest_timestamps.rs | 1 - 10 files changed, 380 insertions(+), 14 deletions(-) create mode 100644 docs/PARTIAL_RELEASE.md create mode 100644 tests/partial_release.rs diff --git a/README.md b/README.md index f980c63..b8fc6f6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ and the machine-readable interface lives in | --- | --- | --- | | `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. | -| `release_funds` | Yes | Sends funds to `success_destination` and moves the vault to `Completed`. | +| `release_funds` | Yes | Sends all remaining funds to `success_destination` and moves the vault to `Completed`. | +| `release_partial` | Yes | Sends a tranche to `success_destination`; keeps the vault `Active` until `remaining` reaches zero. | | `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`. | | `get_vault_state` | No | Reads a vault record, returning `None` for an unknown id. | @@ -77,3 +78,8 @@ Any attempt to call `validate_milestone`, `release_funds`, `redirect_funds`, or for integrators and tooling. - [`src/doc.md`](src/doc.md) maps these contract semantics to backend API payloads and HTTP error responses. + + +## Partial Releases + +See [docs/PARTIAL_RELEASE.md](docs/PARTIAL_RELEASE.md) for tranche release behavior, examples, and safety rules. diff --git a/docs/PARTIAL_RELEASE.md b/docs/PARTIAL_RELEASE.md new file mode 100644 index 0000000..52c6b15 --- /dev/null +++ b/docs/PARTIAL_RELEASE.md @@ -0,0 +1,42 @@ +# Partial Milestone Release + +`release_partial` lets a vault pay a tranche of its escrowed USDC balance to the +`success_destination` while keeping the vault `Active` for the unreleased +balance. + +## Balance Model + +- `amount`: original escrowed amount. +- `remaining`: unreleased balance still held by the vault. + +A partial release must satisfy: + +```text +0 < release_amount <= remaining +new_remaining = remaining - release_amount +``` + +The contract uses checked subtraction and rejects invalid release amounts with +`Error::InvalidAmount`. + +## Lifecycle + +1. `create_vault` sets `amount` and `remaining` to the initial escrow amount. +2. `release_partial` transfers `release_amount` to `success_destination`. +3. The vault remains `Active` while `remaining > 0`. +4. The final tranche sets `remaining = 0` and moves the vault to `Completed`. +5. `release_funds` preserves legacy behavior by releasing all `remaining` funds. + +## Worked Example + +A creator escrows 10,000 USDC: + +| Step | Action | Paid | Remaining | Status | +| --- | --- | ---: | ---: | --- | +| 1 | create vault | 0 | 10,000 | Active | +| 2 | release_partial(2,500) | 2,500 | 7,500 | Active | +| 3 | release_partial(3,000) | 3,000 | 4,500 | Active | +| 4 | release_funds() | 4,500 | 0 | Completed | + +`cancel_vault` and `redirect_funds` only transfer `remaining`, so cumulative +payouts can never exceed the original `amount`. diff --git a/src/lib.rs b/src/lib.rs index 9893393..ccc4025 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,8 +58,10 @@ pub enum VaultStatus { pub struct ProductivityVault { /// Address that created (and funded) the vault. pub creator: Address, - /// USDC amount locked in the vault (in stroops / smallest unit). + /// Original USDC amount locked in the vault (in stroops / smallest unit). pub amount: i128, + /// Remaining unreleased balance still held by the vault. + pub remaining: i128, /// Ledger timestamp when the commitment period starts. pub start_timestamp: u64, /// Ledger timestamp after which deadline-based release is allowed. @@ -186,6 +188,7 @@ impl DisciplrVault { let vault = ProductivityVault { creator, amount, + remaining: amount, start_timestamp, end_timestamp, milestone_hash, @@ -253,8 +256,42 @@ impl DisciplrVault { // release_funds // ----------------------------------------------------------------------- - /// Release vault funds to `success_destination`. + /// Release all remaining vault funds to `success_destination`. + /// + /// Preserves the legacy all-or-nothing behavior for callers that do not + /// need tranche releases: the full remaining balance is paid and the vault + /// moves to `Completed`. pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result { + let vault_key = DataKey::Vault(vault_id); + let vault: ProductivityVault = env + .storage() + .instance() + .get(&vault_key) + .ok_or(Error::VaultNotFound)?; + + Self::release_success_amount(env, vault_id, usdc_token, vault.remaining, false) + } + + /// Release a partial tranche of vault funds to `success_destination`. + /// + /// The vault remains `Active` while `remaining > 0` and transitions to + /// `Completed` only when the final tranche drains the balance. + pub fn release_partial( + env: Env, + vault_id: u32, + usdc_token: Address, + release_amount: i128, + ) -> Result { + Self::release_success_amount(env, vault_id, usdc_token, release_amount, true) + } + + fn release_success_amount( + env: Env, + vault_id: u32, + usdc_token: Address, + release_amount: i128, + partial_event: bool, + ) -> Result { let vault_key = DataKey::Vault(vault_id); let mut vault: ProductivityVault = env .storage() @@ -265,10 +302,9 @@ impl DisciplrVault { vault.creator.require_auth(); if vault.status != VaultStatus::Active { - return Err(Error::VaultNotActive); // Or InvalidStatus as appropriate + return Err(Error::VaultNotActive); } - // Check release conditions. let now = env.ledger().timestamp(); let deadline_reached = now >= vault.end_timestamp; let validated = vault.milestone_validated; @@ -277,20 +313,39 @@ impl DisciplrVault { return Err(Error::NotAuthorized); } + if release_amount <= 0 || release_amount > vault.remaining { + return Err(Error::InvalidAmount); + } + + let remaining = vault + .remaining + .checked_sub(release_amount) + .ok_or(Error::InvalidAmount)?; + let token_client = token::Client::new(&env, &usdc_token); token_client.transfer( &env.current_contract_address(), &vault.success_destination, - &vault.amount, + &release_amount, ); - vault.status = VaultStatus::Completed; + vault.remaining = remaining; + if vault.remaining == 0 { + vault.status = VaultStatus::Completed; + } env.storage().instance().set(&vault_key, &vault); - env.events().publish( - (Symbol::new(&env, "funds_released"), vault_id), - vault.amount, - ); + if partial_event { + env.events().publish( + (Symbol::new(&env, "funds_released_partial"), vault_id), + (release_amount, vault.remaining), + ); + } else { + env.events().publish( + (Symbol::new(&env, "funds_released"), vault_id), + release_amount, + ); + } Ok(true) } @@ -324,9 +379,10 @@ impl DisciplrVault { token_client.transfer( &env.current_contract_address(), &vault.failure_destination, - &vault.amount, + &vault.remaining, ); + vault.remaining = 0; vault.status = VaultStatus::Failed; env.storage().instance().set(&vault_key, &vault); @@ -360,9 +416,10 @@ impl DisciplrVault { token_client.transfer( &env.current_contract_address(), &vault.creator, - &vault.amount, + &vault.remaining, ); + vault.remaining = 0; vault.status = VaultStatus::Cancelled; env.storage().instance().set(&vault_key, &vault); @@ -529,6 +586,7 @@ mod tests { let vault = vault_state.unwrap(); assert_eq!(vault.creator, setup.creator); assert_eq!(vault.amount, setup.amount); + assert_eq!(vault.remaining, 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()); @@ -850,6 +908,133 @@ mod tests { assert_eq!(vault.status, VaultStatus::Completed); } + #[test] + fn test_release_partial_after_validation_keeps_vault_active() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = setup.create_default_vault(); + client.validate_milestone(&vault_id); + + let usdc = setup.usdc_client(); + let success_before = usdc.balance(&setup.success_dest); + let partial = setup.amount / 2; + + let result = client.release_partial(&vault_id, &setup.usdc_token, &partial); + assert!(result); + + let success_after = usdc.balance(&setup.success_dest); + assert_eq!(success_after - success_before, partial); + + let vault = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.status, VaultStatus::Active); + assert_eq!(vault.amount, setup.amount); + assert_eq!(vault.remaining, setup.amount - partial); + } + + #[test] + fn test_release_partial_final_tranche_completes_vault() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = setup.create_default_vault(); + client.validate_milestone(&vault_id); + + let usdc = setup.usdc_client(); + let success_before = usdc.balance(&setup.success_dest); + let first = setup.amount / 2; + let second = setup.amount - first; + + assert!(client.release_partial(&vault_id, &setup.usdc_token, &first)); + assert!(client.release_partial(&vault_id, &setup.usdc_token, &second)); + + assert_eq!( + usdc.balance(&setup.success_dest) - success_before, + setup.amount + ); + let vault = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.status, VaultStatus::Completed); + assert_eq!(vault.remaining, 0); + } + + #[test] + fn test_release_funds_after_partial_releases_remaining() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = setup.create_default_vault(); + client.validate_milestone(&vault_id); + + let usdc = setup.usdc_client(); + let success_before = usdc.balance(&setup.success_dest); + let partial = setup.amount / 4; + + assert!(client.release_partial(&vault_id, &setup.usdc_token, &partial)); + assert!(client.release_funds(&vault_id, &setup.usdc_token)); + + assert_eq!( + usdc.balance(&setup.success_dest) - success_before, + setup.amount + ); + let vault = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.status, VaultStatus::Completed); + assert_eq!(vault.remaining, 0); + } + + #[test] + #[should_panic(expected = "Error(Contract, #7)")] + fn test_release_partial_zero_amount_rejected() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = setup.create_default_vault(); + client.validate_milestone(&vault_id); + + client.release_partial(&vault_id, &setup.usdc_token, &0); + } + + #[test] + #[should_panic(expected = "Error(Contract, #7)")] + fn test_release_partial_over_remaining_rejected() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = setup.create_default_vault(); + client.validate_milestone(&vault_id); + + client.release_partial(&vault_id, &setup.usdc_token, &(setup.amount + 1)); + } + + #[test] + fn test_cancel_after_partial_returns_only_remaining() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = setup.create_default_vault(); + client.validate_milestone(&vault_id); + + let partial = setup.amount / 2; + assert!(client.release_partial(&vault_id, &setup.usdc_token, &partial)); + + let usdc = setup.usdc_client(); + let creator_before = usdc.balance(&setup.creator); + assert!(client.cancel_vault(&vault_id, &setup.usdc_token)); + assert_eq!( + usdc.balance(&setup.creator) - creator_before, + setup.amount - partial + ); + + let vault = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.status, VaultStatus::Cancelled); + assert_eq!(vault.remaining, 0); + } + #[test] fn test_double_release_rejected() { let setup = TestSetup::new(); 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..98a408a 100644 --- a/test_snapshots/test/test_create_vault_exact_balance.1.json +++ b/test_snapshots/test/test_create_vault_exact_balance.1.json @@ -344,6 +344,17 @@ "bool": false } }, + { + "key": { + "symbol": "remaining" + }, + "val": { + "i128": { + "hi": 0, + "lo": 10000000 + } + } + }, { "key": { "symbol": "start_timestamp" diff --git a/test_snapshots/test/test_create_vault_success.1.json b/test_snapshots/test/test_create_vault_success.1.json index baf60db..14564f9 100644 --- a/test_snapshots/test/test_create_vault_success.1.json +++ b/test_snapshots/test/test_create_vault_success.1.json @@ -344,6 +344,17 @@ "bool": false } }, + { + "key": { + "symbol": "remaining" + }, + "val": { + "i128": { + "hi": 0, + "lo": 10000000 + } + } + }, { "key": { "symbol": "start_timestamp" 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..974b9da 100644 --- a/test_snapshots/test/test_create_vault_with_verifier.1.json +++ b/test_snapshots/test/test_create_vault_with_verifier.1.json @@ -345,6 +345,17 @@ "bool": false } }, + { + "key": { + "symbol": "remaining" + }, + "val": { + "i128": { + "hi": 0, + "lo": 10000000 + } + } + }, { "key": { "symbol": "start_timestamp" 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..83c8cfa 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 @@ -400,6 +400,17 @@ "bool": false } }, + { + "key": { + "symbol": "remaining" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, { "key": { "symbol": "start_timestamp" 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..0fcc99b 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 @@ -346,6 +346,17 @@ "bool": false } }, + { + "key": { + "symbol": "remaining" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, { "key": { "symbol": "start_timestamp" diff --git a/tests/partial_release.rs b/tests/partial_release.rs new file mode 100644 index 0000000..09a8e52 --- /dev/null +++ b/tests/partial_release.rs @@ -0,0 +1,79 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{StellarAssetClient, TokenClient}, + Address, BytesN, Env, +}; + +use disciplr_vault::{DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT}; + +fn setup() -> ( + Env, + DisciplrVaultClient<'static>, + Address, + StellarAssetClient<'static>, + TokenClient<'static>, +) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(DisciplrVault, ()); + let client = DisciplrVaultClient::new(&env, &contract_id); + + let usdc_admin = Address::generate(&env); + let usdc_token = env.register_stellar_asset_contract_v2(usdc_admin.clone()); + let usdc_addr = usdc_token.address(); + let usdc_asset = StellarAssetClient::new(&env, &usdc_addr); + let usdc_token_client = TokenClient::new(&env, &usdc_addr); + + (env, client, usdc_addr, usdc_asset, usdc_token_client) +} + +#[test] +fn partial_release_tracks_remaining_and_final_release_completes() { + let (env, client, usdc, usdc_asset, usdc_token) = setup(); + + let creator = Address::generate(&env); + let verifier = Address::generate(&env); + let success_dest = Address::generate(&env); + let failure_dest = Address::generate(&env); + let now = 1_700_000_000u64; + env.ledger().set_timestamp(now); + + usdc_asset.mint(&creator, &MIN_AMOUNT); + + let milestone = BytesN::from_array(&env, &[7u8; 32]); + let vault_id = client.create_vault( + &usdc, + &creator, + &MIN_AMOUNT, + &now, + &(now + 86_400), + &milestone, + &Some(verifier.clone()), + &success_dest, + &failure_dest, + ); + + env.ledger().set_timestamp(now + 3_600); + client.validate_milestone(&vault_id); + + let first = MIN_AMOUNT / 4; + let second = MIN_AMOUNT / 4; + client.release_partial(&vault_id, &usdc, &first); + client.release_partial(&vault_id, &usdc, &second); + + let active_state = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(active_state.status, VaultStatus::Active); + assert_eq!(active_state.amount, MIN_AMOUNT); + assert_eq!(active_state.remaining, MIN_AMOUNT - first - second); + assert_eq!(usdc_token.balance(&success_dest), first + second); + + client.release_funds(&vault_id, &usdc); + + let final_state = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(final_state.status, VaultStatus::Completed); + assert_eq!(final_state.remaining, 0); + assert_eq!(usdc_token.balance(&success_dest), MIN_AMOUNT); +} diff --git a/tests/proptest_timestamps.rs b/tests/proptest_timestamps.rs index d8e37e8..bd89ed3 100644 --- a/tests/proptest_timestamps.rs +++ b/tests/proptest_timestamps.rs @@ -279,7 +279,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();