diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 72ba285..7f3afef 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -160,6 +160,19 @@ cat test_snapshots/test/test_vault_creation_emits_event.1.json ## Security Testing +### Release Authorization + +```bash +cargo test test_release_funds_after_deadline_without_auth +cargo test test_release_funds_before_validation_without_auth_rejected +cargo test test_release_funds_after_validation_without_release_auth +``` + +These tests cover the `release_funds` authorization matrix documented in +[docs/RELEASE_AUTH.md](./docs/RELEASE_AUTH.md): release is permissionless after a +success condition is true, but an unvalidated pre-deadline vault cannot be +released. + ### Double-Spending Prevention ```bash diff --git a/docs/RELEASE_AUTH.md b/docs/RELEASE_AUTH.md new file mode 100644 index 0000000..3670ee5 --- /dev/null +++ b/docs/RELEASE_AUTH.md @@ -0,0 +1,26 @@ +# Release Funds Authorization + +`release_funds` is a permissionless trigger once the vault has met a success +condition. The caller cannot choose where funds go: every successful release +transfers the escrowed amount to the vault's pre-committed +`success_destination`. + +## Authorization Matrix + +| Vault condition | Who may call `release_funds` | Result | +| --- | --- | --- | +| Active, milestone not validated, before `end_timestamp` | No one | Rejects with `Error::NotAuthorized` | +| Active, milestone validated, before `end_timestamp` | Anyone | Transfers to `success_destination` | +| Active, at or after `end_timestamp` | Anyone | Transfers to `success_destination` | +| Completed, Failed, or Cancelled | No one | Rejects with `Error::VaultNotActive` | + +Validation authorization remains separate: + +- If `verifier` is `Some(address)`, only that verifier can call + `validate_milestone`. +- If `verifier` is `None`, only the creator can call `validate_milestone`. + +This avoids a liveness failure where a creator disappears after funding the +vault. Once the milestone has been validated or the deadline has arrived, any +account can finalize the successful path, but funds still only reach the +configured `success_destination`. diff --git a/src/lib.rs b/src/lib.rs index 9893393..79dd69d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,8 +68,9 @@ pub struct ProductivityVault { pub milestone_hash: BytesN<32>, /// Optional designated verifier. When `Some(addr)`, only that address may call `validate_milestone`. /// When `None`, only the creator may call `validate_milestone` (no third-party validation). - /// `release_funds` is consistent: after deadline, anyone can release; before deadline, only - /// after the designated validator (or creator when verifier is None) has validated. + /// `release_funds` is permissionless once a success condition is true: after the deadline, + /// anyone can trigger release; before the deadline, release is allowed only after the + /// designated validator (or creator when verifier is None) has validated. pub verifier: Option
, /// Funds go here on success. pub success_destination: Address, @@ -254,6 +255,14 @@ impl DisciplrVault { // ----------------------------------------------------------------------- /// Release vault funds to `success_destination`. + /// + /// Authorization matrix: + /// - Before `end_timestamp`: requires prior milestone validation by the configured verifier + /// or creator, but the release call itself is permissionless. + /// - At or after `end_timestamp`: anyone can trigger release. + /// + /// This is safe because the caller cannot choose the recipient; funds always move to the + /// pre-committed `success_destination`. pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result { let vault_key = DataKey::Vault(vault_id); let mut vault: ProductivityVault = env @@ -262,8 +271,6 @@ impl DisciplrVault { .get(&vault_key) .ok_or(Error::VaultNotFound)?; - vault.creator.require_auth(); - if vault.status != VaultStatus::Active { return Err(Error::VaultNotActive); // Or InvalidStatus as appropriate } @@ -850,6 +857,66 @@ mod tests { assert_eq!(vault.status, VaultStatus::Completed); } + #[test] + fn test_release_funds_after_deadline_without_auth() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = setup.create_default_vault(); + setup.env.ledger().set_timestamp(setup.end_timestamp); + setup.env.mock_auths(&[]); + + let result = client.release_funds(&vault_id, &setup.usdc_token); + + assert!(result); + let vault = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.status, VaultStatus::Completed); + assert_eq!( + setup.usdc_client().balance(&setup.success_dest), + setup.amount + ); + } + + #[test] + fn test_release_funds_before_validation_without_auth_rejected() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = setup.create_default_vault(); + setup.env.mock_auths(&[]); + + assert!(client + .try_release_funds(&vault_id, &setup.usdc_token) + .is_err()); + + let vault = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.status, VaultStatus::Active); + assert_eq!(setup.usdc_client().balance(&setup.success_dest), 0); + } + + #[test] + fn test_release_funds_after_validation_without_release_auth() { + 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); + setup.env.mock_auths(&[]); + + let result = client.release_funds(&vault_id, &setup.usdc_token); + + assert!(result); + let vault = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.status, VaultStatus::Completed); + assert_eq!( + setup.usdc_client().balance(&setup.success_dest), + setup.amount + ); + } + #[test] fn test_double_release_rejected() { let setup = TestSetup::new(); 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();