Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docs/RELEASE_AUTH.md
Original file line number Diff line number Diff line change
@@ -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`.
75 changes: 71 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>,
/// Funds go here on success.
pub success_destination: Address,
Expand Down Expand Up @@ -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<bool, Error> {
let vault_key = DataKey::Vault(vault_id);
let mut vault: ProductivityVault = env
Expand All @@ -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
}
Expand Down Expand Up @@ -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();
Expand Down
1 change: 0 additions & 1 deletion tests/proptest_timestamps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading