diff --git a/README.md b/README.md index f980c63..7e31e35 100644 --- a/README.md +++ b/README.md @@ -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` | `ConflictingAddresses` | `create_vault` | A configured verifier is also the success or failure payout destination. See [`docs/ADDRESS_CONSTRAINTS.md`](docs/ADDRESS_CONSTRAINTS.md). | ## Vault Lifecycle @@ -60,7 +61,7 @@ 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` | +| None | `Active` | `create_vault` | Creator authorizes; amount and timestamps are valid; verifier/destination addresses satisfy the address matrix; 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` | diff --git a/contract-interface.json b/contract-interface.json index 6450206..fc3dcdb 100644 --- a/contract-interface.json +++ b/contract-interface.json @@ -38,7 +38,8 @@ "InvalidStatus": 6, "InvalidAmount": 7, "InvalidTimestamps": 8, - "DurationTooLong": 9 + "DurationTooLong": 9, + "ConflictingAddresses": 10 } } }, diff --git a/docs/ADDRESS_CONSTRAINTS.md b/docs/ADDRESS_CONSTRAINTS.md new file mode 100644 index 0000000..54e460e --- /dev/null +++ b/docs/ADDRESS_CONSTRAINTS.md @@ -0,0 +1,25 @@ +# Address Constraints + +`create_vault` accepts four participant addresses: + +- `creator`: funds the vault and may cancel it while active. +- `verifier`: optional milestone validator. +- `success_destination`: receives funds when the vault succeeds. +- `failure_destination`: receives funds when the vault fails. + +The verifier must not also be a payout destination. A verifier who can validate +their own payout creates a conflict of interest, so `create_vault` rejects that +configuration with `Error::ConflictingAddresses`. + +## Matrix + +| Configuration | Result | Rationale | +| --- | --- | --- | +| `verifier == success_destination` | Rejected | Verifier would be able to approve their own success payout. | +| `verifier == failure_destination` | Rejected | Verifier would have a payout interest in the failure path. | +| `verifier == creator` | Allowed | This is an explicit creator-validation mode and is covered by tests. | +| `verifier == None` and `success_destination == failure_destination` | Allowed | No third-party verifier conflict exists; both terminal paths pay the configured destination. | +| Distinct verifier, success, and failure addresses | Allowed | Standard third-party validation setup. | + +These checks run before the USDC escrow transfer, so rejected configurations do +not move funds into the contract. diff --git a/src/lib.rs b/src/lib.rs index 9893393..fb0a230 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,8 @@ pub enum Error { InvalidTimestamps = 8, /// Vault duration (end − start) exceeds MAX_VAULT_DURATION. DurationTooLong = 9, + /// Verifier cannot also be a success or failure payout destination. + ConflictingAddresses = 10, } // --------------------------------------------------------------------------- @@ -129,6 +131,8 @@ impl DisciplrVault { /// # Validation Rules /// - `amount` must be positive; otherwise returns `Error::InvalidAmount`. /// - `start_timestamp` must be strictly less than `end_timestamp`; otherwise returns `Error::InvalidTimestamps`. + /// - `verifier`, when present, must differ from `success_destination` and `failure_destination`; + /// otherwise returns `Error::ConflictingAddresses`. /// /// # Prerequisites /// Creator must have sufficient USDC balance and authorize the transaction. @@ -154,6 +158,12 @@ impl DisciplrVault { return Err(Error::InvalidAmount); } + if let Some(ref verifier_addr) = verifier { + if *verifier_addr == success_destination || *verifier_addr == failure_destination { + return Err(Error::ConflictingAddresses); + } + } + // Validate timestamps let current_time = env.ledger().timestamp(); if start_timestamp < current_time { @@ -646,6 +656,93 @@ mod tests { ); } + #[test] + #[should_panic(expected = "Error(Contract, #10)")] + fn test_create_vault_verifier_equals_success_destination_rejected() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + client.create_vault( + &setup.usdc_token, + &setup.creator, + &setup.amount, + &setup.start_timestamp, + &setup.end_timestamp, + &setup.milestone_hash(), + &Some(setup.success_dest.clone()), + &setup.success_dest, + &setup.failure_dest, + ); + } + + #[test] + #[should_panic(expected = "Error(Contract, #10)")] + fn test_create_vault_verifier_equals_failure_destination_rejected() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + client.create_vault( + &setup.usdc_token, + &setup.creator, + &setup.amount, + &setup.start_timestamp, + &setup.end_timestamp, + &setup.milestone_hash(), + &Some(setup.failure_dest.clone()), + &setup.success_dest, + &setup.failure_dest, + ); + } + + #[test] + fn test_create_vault_verifier_same_as_creator_allowed() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = client.create_vault( + &setup.usdc_token, + &setup.creator, + &setup.amount, + &setup.start_timestamp, + &setup.end_timestamp, + &setup.milestone_hash(), + &Some(setup.creator.clone()), + &setup.success_dest, + &setup.failure_dest, + ); + + let vault = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.verifier, Some(setup.creator)); + assert_eq!(vault.status, VaultStatus::Active); + } + + #[test] + fn test_create_vault_no_verifier_destination_overlap_allowed() { + let setup = TestSetup::new(); + let client = setup.client(); + + setup.env.ledger().set_timestamp(setup.start_timestamp); + let vault_id = client.create_vault( + &setup.usdc_token, + &setup.creator, + &setup.amount, + &setup.start_timestamp, + &setup.end_timestamp, + &setup.milestone_hash(), + &None, + &setup.success_dest, + &setup.success_dest, + ); + + let vault = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.verifier, None); + assert_eq!(vault.success_destination, setup.success_dest); + assert_eq!(vault.failure_destination, setup.success_dest); + } + #[test] fn test_validate_milestone_rejects_after_end() { 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();