From d44165c394023fc38e15b9c78246b42afb0f6c56 Mon Sep 17 00:00:00 2001 From: andreschucks101 Date: Fri, 29 May 2026 16:46:58 +0100 Subject: [PATCH] feat: CEI ordering, guardian pause, M-of-N verifiers, rustfmt/clippy config --- contracts/Cargo.toml | 3 + contracts/README.md | 209 ++++--- contracts/accountability_vault/Cargo.toml | 3 + contracts/accountability_vault/src/lib.rs | 336 +++++++---- contracts/accountability_vault/src/test.rs | 633 +++++++++++++++++++-- contracts/rustfmt.toml | 6 + 6 files changed, 971 insertions(+), 219 deletions(-) create mode 100644 contracts/rustfmt.toml diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 7a9a804..8e793ab 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -7,6 +7,9 @@ members = [ [workspace.dependencies] soroban-sdk = "23" +[workspace.lints.clippy] +all = "warn" + [profile.release] opt-level = "z" overflow-checks = true diff --git a/contracts/README.md b/contracts/README.md index 69cdd3a..8ba1e5a 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -11,90 +11,116 @@ The `accountability_vault` contract implements time-locked capital vaults on Ste The accountability vault allows users to: - Lock funds in a vault with a total amount - Define milestones with individual amounts that must sum to the total -- Specify a verifier authorized to validate milestone completion +- Specify a set of verifiers (M-of-N threshold) authorized to validate milestone completion +- Set a guardian address that can pause/unpause the vault in emergencies - Set success and failure destinations for fund release - - Allow reclaiming residual (dust) token balances to the creator after settlement +- Allow reclaiming residual (dust) token balances to the creator after settlement -### Arithmetic Safety +### Security Invariants -**Critical Security Feature: Overflow-Safe Amount Summation** +#### Checks-Effects-Interactions (CEI) Pattern -The `create_vault` function implements overflow-safe arithmetic for milestone amount summation to prevent integer overflow attacks and unexpected panics. +`slash_on_miss`, `claim`, and `withdraw` (active-vault path) all update and persist vault +state — setting `status` to the terminal value and zeroing `staked` — **before** executing +the external `token::Client::transfer` call. This ensures the vault reaches a terminal state +even if the downstream token call panics or re-enters the contract. -#### Implementation Details +```rust +// CEI: capture transfer values, update and persist state, then call external token. +let slashed = vault.staked; +let failure_destination = vault.failure_destination.clone(); +vault.status = VaultStatus::Failed; +vault.staked = 0; +env.storage().instance().set(&DataKey::Vault, &vault); // ← state committed + +token::Client::new(&env, &token_addr).transfer( // ← external call last + &env.current_contract_address(), + &failure_destination, + &slashed, +); +``` -- **Location**: `accountability_vault/src/lib.rs` in the `create_vault` function -- **Method**: Uses `checked_add` instead of `+=` for all i128 arithmetic operations -- **Error Handling**: Returns `Error::Overflow` on overflow instead of panicking -- **Invariant**: Maintains `sum == amount` invariant after successful validation +#### Emergency Pause (Guardian Role) -#### Code Example +A `guardian` address is set at `create_vault` time. The guardian may call: -```rust -// Sum milestone amounts using checked_add to prevent overflow -let mut sum: i128 = 0; -for milestone in milestones.iter() { - // Use checked_add to detect overflow and return typed error instead of panicking - sum = match sum.checked_add(milestone.amount) { - Some(result) => result, - None => { - // Overflow occurred - return typed error instead of panicking - return Err(Error::Overflow); - } - }; -} -``` +- `emergency_pause(guardian)` — blocks `slash_on_miss`, `claim`, and active-vault + `withdraw` while a dispute or incident is investigated. +- `emergency_unpause(guardian)` — re-enables normal operations. -#### Why This Matters +Only the address stored as `vault.guardian` may call these functions; any other address is +rejected with `Error::Unauthorized`. Draft-vault cancellation via `withdraw` is not affected +by the pause flag, as it involves no token transfer. -1. **Security**: Prevents integer overflow attacks that could bypass amount validation -2. **Reliability**: Returns typed errors instead of panicking, allowing graceful error handling -3. **Predictability**: Ensures the contract behaves consistently even with extreme input values -4. **Auditability**: Clear error types make security reviews easier +#### M-of-N Verifier Approvals -#### Test Coverage +`check_in` supports a configurable set of verifiers and an `approval_threshold` (M-of-N). +A milestone is flipped to `verified` only once at least `approval_threshold` distinct +addresses from the verifier set (or the optional oracle) have approved it. -The contract includes comprehensive tests for overflow scenarios: -- `test_create_vault_overflow_extreme_amounts`: Tests overflow with multiple large milestones -- `test_create_vault_overflow_single_large_milestone`: Tests overflow with two large milestones -- `test_create_vault_large_valid_amounts`: Verifies large but valid amounts work correctly +- Double-approval by the same address returns `Error::AlreadyApproved`. +- Approvals are tracked per-milestone in `DataKey::MilestoneApprovals(index)`. +- The threshold must be ≥ 1 and ≤ `verifiers.len()`; otherwise `create_vault` returns + `Error::InvalidThreshold`. -All tests ensure that: -- Overflow returns `Error::Overflow` instead of panicking -- Valid large amounts are processed correctly -- The `sum == amount` invariant is maintained +### Arithmetic Safety -### Error Types +The `create_vault` function validates that milestone amounts are positive and sum exactly to +the declared `amount`, rejecting mismatches with `Error::AmountMismatch`. -The contract defines the following error types: +### Error Types -- `InvalidAmount`: Negative or zero amounts provided -- `AmountMismatch`: Milestone amounts don't sum to total vault amount -- `Overflow`: Integer overflow occurred during amount summation +| Code | Name | Meaning | +|------|------|---------| +| 1 | `AlreadyInitialized` | Vault storage already set | +| 2 | `NotInitialized` | Vault not yet created | +| 3 | `InvalidAmount` | Zero or negative amount | +| 4 | `InvalidDeadline` | Deadline in the past or milestone exceeds vault end | +| 5 | `NoMilestones` | Empty milestone list | +| 6 | `NotDraft` | Expected Draft state | +| 7 | `NotActive` | Expected Active state | +| 8 | `Unauthorized` | Caller not permitted | +| 9 | `AlreadyStaked` | Vault already funded | +| 10 | `MilestoneIndexOutOfRange` | Index beyond milestone list | +| 11 | `MilestoneAlreadyVerified` | Milestone already at threshold | +| 12 | `DeadlinePassed` | Operation rejected after deadline | +| 13 | `DeadlineNotReached` | Slash attempted before deadline | +| 14 | `MilestonesIncomplete` | Not all milestones verified | +| 15 | `NothingToWithdraw` | Staked balance is zero | +| 16 | `AmountMismatch` | Received amount less than declared | +| 17 | `InsufficientAllowance` | Spender allowance below vault amount | +| 18 | `Paused` | Operation blocked by guardian pause | +| 19 | `AlreadyApproved` | Address has already approved this milestone | +| 20 | `NoVerifiers` | Empty verifier list | +| 21 | `InvalidThreshold` | Threshold is 0 or exceeds verifier count | +| 22 | `StakedRemaining` | Reclaim attempted while stake is non-zero | ### Performance & Gas Benchmarks -To ensure predictable scaling and prevent out-of-gas exploits or transaction failures, the contract has built-in performance bounds. +To ensure predictable scaling and prevent out-of-gas exploits or transaction failures, the +contract has built-in performance bounds. #### Storage Reads & Complexity Analysis -- **Milestone Iteration**: Functions like `claim` and `slash_on_miss` iterate over the `milestones` vector to sum release amounts and check status. CPU and Memory usage scale linearly ($O(N)$) with the milestone count $N$. -- **Flat Storage Access**: The storage layout guarantees flat ($O(1)$) read footprint. There are no redundant storage reads or nested lookups within loops. -- **Gas Bounded Growth**: The CPU and Memory bounds are actively asserted in test suites to catch regressions before deployment. + +- **Milestone Iteration**: Functions like `claim` and `slash_on_miss` iterate over the + `milestones` vector. CPU and Memory usage scale linearly (O(N)) with the milestone count N. +- **Flat Storage Access**: The storage layout guarantees flat (O(1)) read footprint. There + are no redundant storage reads or nested lookups within loops. +- **Gas Bounded Growth**: CPU and Memory bounds are actively asserted in test suites to + catch regressions before deployment. #### Documented Footprint Thresholds (10 Milestones Baseline) -Using Soroban's native budget tracking (`Env::budget()`), the performance metrics for a representative 10-milestone vault are capped as follows: -| Function | CPU Cost Threshold (Instructions) | Memory Cost Threshold (Bytes) | Storage Read Footprint | -|----------|----------------------------------|-------------------------------|------------------------| -| `create_vault` | < 600,000 | < 200,000 | $O(1)$ Flat | -| `stake` | < 700,000 | < 200,000 | $O(1)$ Flat | -| `check_in` | < 300,000 | < 100,000 | $O(1)$ Flat | -| `claim` | < 900,000 | < 250,000 | $O(1)$ Flat | -| `slash_on_miss`| < 900,000 | < 250,000 | $O(1)$ Flat | +| Function | CPU Cost Threshold (Instructions) | Memory Cost Threshold (Bytes) | +|----------|----------------------------------|-------------------------------| +| `create_vault` | < 600,000 | < 200,000 | +| `stake` | < 700,000 | < 200,000 | +| `check_in` | < 300,000 | < 100,000 | +| `claim` | < 900,000 | < 250,000 | +| `slash_on_miss` | < 900,000 | < 250,000 | ### Building and Testing - #### Prerequisites - Rust 1.70+ with `wasm32-unknown-unknown` target @@ -114,14 +140,40 @@ cd contracts/accountability_vault cargo test ``` +#### Formatting + +The workspace ships a `contracts/rustfmt.toml` config. Format all contract sources with: + +```bash +cd contracts +cargo fmt +``` + +#### Lint + +The workspace enables `clippy::all` warnings via `[workspace.lints.clippy]` in +`contracts/Cargo.toml`. Run clippy with warnings treated as errors: + +```bash +cd contracts +cargo clippy -- -D warnings +``` + +To suppress known false-positives in generated Soroban SDK code, add +`#[allow(clippy::...)]` at the item level rather than disabling workspace-wide. + #### Test Coverage -The contract maintains >95% test coverage including: -- Normal vault creation -- Invalid amount validation -- Amount mismatch detection -- Overflow scenarios with extreme values -- Edge cases (empty milestones, zero amounts, negative amounts) +The contract maintains comprehensive test coverage including: + +- Normal vault lifecycle (create, stake, check-in, claim, slash, withdraw) +- CEI ordering invariants: terminal state committed before token transfer +- Emergency pause/unpause: guardian blocks and re-enables settlement paths +- M-of-N verifier approvals: partial approvals, full threshold, double-approval rejection +- Allowance-based staking (`stake_from`) +- Oracle-driven milestone verification +- Joint deadline extension (`extend_deadline`) +- Gas benchmarks with hard CPU/memory bounds ### Deployment @@ -136,24 +188,31 @@ soroban contract deploy \ ### Security Considerations -1. **Overflow Protection**: All arithmetic operations use checked arithmetic -2. **Input Validation**: All amounts are validated for positivity -3. **Invariant Enforcement**: Milestone amounts must exactly sum to total vault amount -4. **Error Handling**: Typed errors prevent information leakage through panics +1. **CEI Pattern**: All token transfers occur after state is persisted to storage. +2. **Emergency Pause**: Guardian can halt settlement paths during disputes. +3. **M-of-N Verification**: No single verifier can unilaterally release funds when + `approval_threshold > 1`. +4. **Overflow Protection**: Milestone amount summation uses safe integer arithmetic. +5. **Input Validation**: All amounts validated for positivity; milestone amounts must sum + exactly to the vault amount. +6. **Authorized Operations**: Creator, verifier set, guardian, and oracle roles are + enforced via `Address::require_auth()`. ### Residual Sweep (reclaim_after_settlement) -The contract exposes `reclaim_after_settlement` to sweep any residual token -balance (dust or rounding remainders) held by the contract back to the vault -creator. Requirements: +The contract exposes `reclaim_after_settlement(token_address)` to sweep any residual token +balance (dust or rounding remainders) held by the contract back to the vault creator. + +Requirements: -- Caller must be the vault `creator` (authorization enforced via `Address::require_auth`). -- The vault must have no staked funds remaining (`amount == 0`). +- Caller must be the vault `creator` (authorization enforced via `require_auth`). +- The vault must have no staked funds remaining (`staked == 0`); otherwise + `Error::StakedRemaining` is returned. -The function queries the contract's token balance via `TokenClient::balance` -and performs a `TokenClient::transfer` of the full balance to the creator. +The function queries the contract's token balance via `token::Client::balance` and performs +a `token::Client::transfer` of the full balance to the creator. -Location: `accountability_vault/src/lib.rs` — `Contract::reclaim_after_settlement` +Location: `accountability_vault/src/lib.rs` — `AccountabilityVault::reclaim_after_settlement` ### License diff --git a/contracts/accountability_vault/Cargo.toml b/contracts/accountability_vault/Cargo.toml index 0bb1c27..0fdafdd 100644 --- a/contracts/accountability_vault/Cargo.toml +++ b/contracts/accountability_vault/Cargo.toml @@ -12,6 +12,9 @@ soroban-sdk = "21.0.0" [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils"] } +[lints] +workspace = true + [profile.release] opt-level = "z" overflow-checks = true diff --git a/contracts/accountability_vault/src/lib.rs b/contracts/accountability_vault/src/lib.rs index b6bbfdc..2b6b1da 100644 --- a/contracts/accountability_vault/src/lib.rs +++ b/contracts/accountability_vault/src/lib.rs @@ -3,26 +3,38 @@ //! //! A Soroban smart contract implementing programmable time-locked capital vaults //! for accountability staking. A creator stakes funds toward a goal with one or -//! more milestones. A designated verifier confirms check-ins / milestone -//! completion. On success the staked capital is released to the -//! `success_destination`; on a missed deadline the capital is slashed to the -//! `failure_destination` (e.g. a charity or forfeit address). +//! more milestones. A designated verifier set confirms check-ins / milestone +//! completion via M-of-N threshold approval. On success the staked capital is +//! released to the `success_destination`; on a missed deadline the capital is +//! slashed to the `failure_destination` (e.g. a charity or forfeit address). //! //! Lifecycle: create_vault -> stake | stake_from -> (check_in)* -> claim | slash_on_miss //! Funds movement is modeled via the SEP-41 token client (`stake`, `stake_from`, //! `claim`, `slash_on_miss`, `withdraw`). The contract enforces the state machine, //! authorization, and deadline rules on-chain. //! +//! Security invariants: +//! - Checks-Effects-Interactions: vault state (status, staked) is persisted to +//! storage BEFORE any external token::Client call in `slash_on_miss`, `claim`, +//! and `withdraw`. This ensures the vault reaches a terminal state even if the +//! downstream token call panics or re-enters. +//! - Emergency pause: a guardian address set at `create_vault` time may call +//! `emergency_pause` to block `slash_on_miss`, `claim`, and `withdraw` during +//! disputes or incidents. The same guardian may call `emergency_unpause`. +//! - M-of-N verifier approvals: `check_in` requires `approval_threshold` distinct +//! verifier (or oracle) approvals before flipping a milestone to verified. +//! Double-approval by the same address is rejected with `Error::AlreadyApproved`. +//! //! Extended features: //! - `stake_from`: allowance-based staking via SEP-41 `transfer_from`, enabling //! backend-driven flows without requiring the creator to call the contract directly. //! The staked amount is measured as the actual contract balance delta to guard //! against fee-on-transfer tokens. -//! - `extend_deadline`: joint creator+verifier extension of `end_timestamp` while -//! the vault is `Active` and before the original deadline passes. +//! - `extend_deadline`: joint creator + all-verifiers extension of `end_timestamp` +//! while the vault is `Active` and before the original deadline passes. //! - oracle support in `check_in`: an optional authorized oracle address may -//! confirm milestones in addition to the designated verifier; the source -//! (oracle vs verifier) is included in the emitted event for backend parsing. +//! confirm milestones in addition to the designated verifier set; the source +//! (`"oracle"` vs `"verifier"`) is included in the emitted event for backend parsing. use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Vec, @@ -34,8 +46,10 @@ use soroban_sdk::{ pub enum DataKey { /// The vault configuration and current state. Vault, - /// Per-milestone check-in record, keyed by milestone index. + /// Per-milestone check-in timestamp (set when the milestone reaches the approval threshold). CheckIn(u32), + /// Per-milestone list of addresses that have approved, used for M-of-N tracking. + MilestoneApprovals(u32), } /// Lifecycle state of the vault, mirroring the backend `PersistedVault.status`. @@ -54,6 +68,19 @@ pub enum VaultStatus { Cancelled = 4, } +/// Verifier configuration for M-of-N milestone approval. +/// +/// Grouping these two fields into a single parameter keeps `create_vault` +/// within Soroban's 10-parameter limit while preserving readable call sites. +#[contracttype] +#[derive(Clone)] +pub struct VerifierSet { + /// Set of addresses authorized to approve milestones via `check_in`. + pub verifiers: Vec
, + /// Minimum number of distinct approvals required to verify a milestone. + pub threshold: u32, +} + /// A single accountability milestone within a vault. #[contracttype] #[derive(Clone)] @@ -63,7 +90,7 @@ pub struct Milestone { pub amount: i128, /// UNIX timestamp (seconds) by which the milestone must be checked in. pub due_date: u64, - /// Whether the verifier or oracle has confirmed this milestone. + /// Whether enough distinct verifiers / oracle have approved this milestone. pub verified: bool, } @@ -72,9 +99,13 @@ pub struct Milestone { #[derive(Clone)] pub struct Vault { pub creator: Address, - /// The party authorized to confirm check-ins / milestones. - pub verifier: Address, - /// Optional oracle address that may confirm milestones alongside the verifier. + /// Set of addresses authorized to approve milestones via `check_in`. + /// A milestone is verified once at least `approval_threshold` distinct members + /// (or the oracle) have approved it. + pub verifiers: Vec
, + /// Minimum number of distinct approvals required to verify a milestone (M of N). + pub approval_threshold: u32, + /// Optional oracle address that may confirm milestones alongside the verifier set. /// Enables automated milestone verification driven by the backend oracle job. pub oracle: Option
, /// SEP-41 token used for staking. @@ -92,6 +123,10 @@ pub struct Vault { pub end_timestamp: u64, pub status: VaultStatus, pub milestones: Vec, + /// Address authorized to pause and unpause this vault in emergencies. + pub guardian: Address, + /// When true, `slash_on_miss`, `claim`, and active `withdraw` are blocked. + pub paused: bool, } /// Errors surfaced to callers. Numbered for stable client mapping. @@ -118,6 +153,16 @@ pub enum Error { /// `stake_from` was called but the spender's token allowance from `from` /// is less than the vault's staking amount. InsufficientAllowance = 17, + /// Operation blocked because the vault is currently paused by the guardian. + Paused = 18, + /// The caller has already approved this milestone and may not approve again. + AlreadyApproved = 19, + /// The `verifiers` list provided to `create_vault` is empty. + NoVerifiers = 20, + /// `approval_threshold` is zero or exceeds the number of verifiers. + InvalidThreshold = 21, + /// `reclaim_after_settlement` was called while `staked` is non-zero. + StakedRemaining = 22, } #[contract] @@ -127,16 +172,17 @@ pub struct AccountabilityVault; impl AccountabilityVault { /// Creates a new accountability vault in `Draft` state. /// - /// Validates that the staked amount is positive, the deadline is in the - /// future, milestone amounts sum to `amount`, and that there is at least one - /// milestone. The creator must authorize the call. + /// `verifiers` is the set of addresses authorized to confirm milestones via + /// `check_in`. `approval_threshold` is the minimum distinct approvals needed + /// to mark a milestone verified (M-of-N; must be >= 1 and <= verifiers.len()). + /// `guardian` is the address that may pause/unpause the vault in emergencies. /// - /// `oracle` is an optional address that may confirm milestones via `check_in` - /// in addition to the designated verifier. Pass `None` for human-only verification. + /// `oracle` is an optional address that may confirm milestones in addition to + /// the verifier set. Pass `None` for human-only verification. pub fn create_vault( env: Env, creator: Address, - verifier: Address, + verifier_set: VerifierSet, oracle: Option
, token: Address, amount: i128, @@ -144,12 +190,21 @@ impl AccountabilityVault { failure_destination: Address, end_timestamp: u64, milestones: Vec, + guardian: Address, ) -> Result<(), Error> { creator.require_auth(); if env.storage().instance().has(&DataKey::Vault) { return Err(Error::AlreadyInitialized); } + if verifier_set.verifiers.is_empty() { + return Err(Error::NoVerifiers); + } + if verifier_set.threshold == 0 || verifier_set.threshold > verifier_set.verifiers.len() { + return Err(Error::InvalidThreshold); + } + let verifiers = verifier_set.verifiers; + let approval_threshold = verifier_set.threshold; if amount <= 0 { return Err(Error::InvalidAmount); } @@ -176,7 +231,8 @@ impl AccountabilityVault { let vault = Vault { creator: creator.clone(), - verifier, + verifiers, + approval_threshold, oracle, token, amount, @@ -186,6 +242,8 @@ impl AccountabilityVault { end_timestamp, status: VaultStatus::Draft, milestones, + guardian, + paused: false, }; env.storage().instance().set(&DataKey::Vault, &vault); env.events() @@ -282,12 +340,14 @@ impl AccountabilityVault { Ok(()) } - /// Records a check-in confirming a milestone before its due date. + /// Records an approval for a milestone from a verifier or oracle, flipping + /// `Milestone.verified` once `approval_threshold` distinct approvals are + /// accumulated. /// - /// Authorized callers are the vault's designated `verifier` or, if configured, - /// the optional `oracle` address. The emitted event includes a `source` topic - /// (`"verifier"` or `"oracle"`) so the backend event parser can distinguish - /// automated oracle confirmations from human verifier sign-offs. + /// Double-approval by the same address is rejected with `Error::AlreadyApproved`. + /// The emitted event includes a `source` topic (`"verifier"` or `"oracle"`) so + /// the backend event parser can distinguish automated oracle confirmations from + /// human verifier sign-offs. pub fn check_in(env: Env, caller: Address, milestone_index: u32) -> Result<(), Error> { caller.require_auth(); let mut vault: Vault = Self::load(&env)?; @@ -296,7 +356,7 @@ impl AccountabilityVault { return Err(Error::NotActive); } - let is_verifier = caller == vault.verifier; + let is_verifier = vault.verifiers.iter().any(|v| v == caller); let is_oracle = vault .oracle .as_ref() @@ -318,12 +378,31 @@ impl AccountabilityVault { return Err(Error::DeadlinePassed); } - milestone.verified = true; - vault.milestones.set(milestone_index, milestone); - env.storage() + // M-of-N approval tracking: load or initialize the per-milestone approval list. + let approvals_key = DataKey::MilestoneApprovals(milestone_index); + let mut approvals: Vec
= env + .storage() .instance() - .set(&DataKey::CheckIn(milestone_index), &env.ledger().timestamp()); - env.storage().instance().set(&DataKey::Vault, &vault); + .get(&approvals_key) + .unwrap_or_else(|| Vec::new(&env)); + + // Prevent double-approval by the same address. + if approvals.iter().any(|a| a == caller) { + return Err(Error::AlreadyApproved); + } + + approvals.push_back(caller.clone()); + env.storage().instance().set(&approvals_key, &approvals); + + // Flip the milestone verified and record the timestamp once the threshold is reached. + if approvals.len() >= vault.approval_threshold { + milestone.verified = true; + vault.milestones.set(milestone_index, milestone); + env.storage() + .instance() + .set(&DataKey::CheckIn(milestone_index), &env.ledger().timestamp()); + env.storage().instance().set(&DataKey::Vault, &vault); + } let source = if is_oracle { String::from_str(&env, "oracle") @@ -343,32 +422,30 @@ impl AccountabilityVault { /// Extends the vault's `end_timestamp` to a later point in time. /// - /// Requires authorization from both the vault's `creator` and `verifier`, - /// ensuring neither party can unilaterally push out the deadline. + /// Requires authorization from the vault's `creator` and all `verifiers`, + /// ensuring no single party can unilaterally push out the deadline. /// /// Constraints: /// - Vault must be `Active`. - /// - The current ledger time must be before the existing `end_timestamp` - /// (extensions after the deadline has already passed are not allowed). + /// - The current ledger time must be before the existing `end_timestamp`. /// - `new_end_timestamp` must be strictly greater than the current `end_timestamp`. - /// - All existing milestone `due_date` values must be `<= new_end_timestamp` - /// (the milestones-within-deadline invariant is preserved). + /// - All existing milestone `due_date` values must be `<= new_end_timestamp`. pub fn extend_deadline( env: Env, creator: Address, - verifier: Address, new_end_timestamp: u64, ) -> Result<(), Error> { creator.require_auth(); - verifier.require_auth(); let mut vault: Vault = Self::load(&env)?; if creator != vault.creator { return Err(Error::Unauthorized); } - if verifier != vault.verifier { - return Err(Error::Unauthorized); + // All verifiers must co-sign the extension; no single party can push out the deadline. + for v in vault.verifiers.iter() { + v.require_auth(); } + if vault.status != VaultStatus::Active { return Err(Error::NotActive); } @@ -398,12 +475,19 @@ impl AccountabilityVault { /// Slashes the staked capital to the `failure_destination` once the vault /// deadline has passed and not all milestones were verified. Permissionless: /// anyone may trigger the slash after the deadline (e.g. a backend keeper). + /// + /// Checks-Effects-Interactions: vault status is set to `Failed` and `staked` + /// is zeroed in storage BEFORE the external token transfer is executed, + /// ensuring the terminal state is committed even if the transfer call panics. pub fn slash_on_miss(env: Env) -> Result<(), Error> { let mut vault: Vault = Self::load(&env)?; if vault.status != VaultStatus::Active { return Err(Error::NotActive); } + if vault.paused { + return Err(Error::Paused); + } if env.ledger().timestamp() <= vault.end_timestamp { return Err(Error::DeadlineNotReached); } @@ -411,21 +495,24 @@ impl AccountabilityVault { return Err(Error::MilestonesIncomplete); } - let client = token::Client::new(&env, &vault.token); - client.transfer( - &env.current_contract_address(), - &vault.failure_destination, - &vault.staked, - ); - - vault.status = VaultStatus::Failed; + // CEI: capture transfer values, update and persist state, then call external token. let slashed = vault.staked; + let failure_destination = vault.failure_destination.clone(); + let token_addr = vault.token.clone(); + vault.status = VaultStatus::Failed; vault.staked = 0; env.storage().instance().set(&DataKey::Vault, &vault); + + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &failure_destination, + &slashed, + ); + env.events().publish( ( String::from_str(&env, "vault_slashed"), - vault.failure_destination.clone(), + failure_destination, ), slashed, ); @@ -433,7 +520,12 @@ impl AccountabilityVault { } /// Releases the staked capital to the `success_destination` once every - /// milestone has been verified. Callable by the creator or verifier. + /// milestone has been verified. Callable by the creator or any member of + /// the verifier set. + /// + /// Checks-Effects-Interactions: vault status is set to `Completed` and + /// `staked` is zeroed in storage BEFORE the external token transfer, + /// ensuring the terminal state is committed even if the transfer call panics. pub fn claim(env: Env, caller: Address) -> Result<(), Error> { caller.require_auth(); let mut vault: Vault = Self::load(&env)?; @@ -441,28 +533,36 @@ impl AccountabilityVault { if vault.status != VaultStatus::Active { return Err(Error::NotActive); } - if caller != vault.creator && caller != vault.verifier { + if vault.paused { + return Err(Error::Paused); + } + let is_authorized = + caller == vault.creator || vault.verifiers.iter().any(|v| v == caller); + if !is_authorized { return Err(Error::Unauthorized); } if !Self::all_verified(&vault) { return Err(Error::MilestonesIncomplete); } - let client = token::Client::new(&env, &vault.token); - client.transfer( - &env.current_contract_address(), - &vault.success_destination, - &vault.staked, - ); - - vault.status = VaultStatus::Completed; + // CEI: capture transfer values, update and persist state, then call external token. let released = vault.staked; + let success_destination = vault.success_destination.clone(); + let token_addr = vault.token.clone(); + vault.status = VaultStatus::Completed; vault.staked = 0; env.storage().instance().set(&DataKey::Vault, &vault); + + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &success_destination, + &released, + ); + env.events().publish( ( String::from_str(&env, "vault_completed"), - vault.success_destination.clone(), + success_destination, ), released, ); @@ -471,7 +571,10 @@ impl AccountabilityVault { /// Cancels an unfunded (`Draft`) vault, or refunds the creator if the vault /// was funded but never activated against any milestone. Only the creator - /// may withdraw; activated vaults with verified check-ins cannot be unwound. + /// may withdraw; vaults with any verified check-ins cannot be unwound. + /// + /// Checks-Effects-Interactions: vault state is updated and persisted BEFORE + /// the external token transfer for the active-vault refund path. pub fn withdraw(env: Env, creator: Address) -> Result<(), Error> { creator.require_auth(); let mut vault: Vault = Self::load(&env)?; @@ -490,20 +593,29 @@ impl AccountabilityVault { if vault.status != VaultStatus::Active { return Err(Error::NotActive); } + if vault.paused { + return Err(Error::Paused); + } if Self::any_verified(&vault) { return Err(Error::Unauthorized); } - if vault.staked <= 0 { + if vault.staked == 0 { return Err(Error::NothingToWithdraw); } - let client = token::Client::new(&env, &vault.token); - client.transfer(&env.current_contract_address(), &creator, &vault.staked); - + // CEI: capture transfer values, update and persist state, then call external token. let refunded = vault.staked; + let token_addr = vault.token.clone(); vault.staked = 0; vault.status = VaultStatus::Cancelled; env.storage().instance().set(&DataKey::Vault, &vault); + + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &creator, + &refunded, + ); + env.events().publish( (String::from_str(&env, "vault_withdrawn"), creator), refunded, @@ -511,11 +623,67 @@ impl AccountabilityVault { Ok(()) } + /// Pauses the vault, blocking `slash_on_miss`, `claim`, and active `withdraw`. + /// + /// Only the `guardian` address set at vault creation may call this function. + /// Use to halt settlement during disputes or detected incidents. + pub fn emergency_pause(env: Env, guardian: Address) -> Result<(), Error> { + guardian.require_auth(); + let mut vault: Vault = Self::load(&env)?; + + if guardian != vault.guardian { + return Err(Error::Unauthorized); + } + vault.paused = true; + env.storage().instance().set(&DataKey::Vault, &vault); + env.events() + .publish((String::from_str(&env, "vault_paused"), guardian), true); + Ok(()) + } + + /// Unpauses the vault, re-enabling `slash_on_miss`, `claim`, and `withdraw`. + /// + /// Only the `guardian` address set at vault creation may call this function. + pub fn emergency_unpause(env: Env, guardian: Address) -> Result<(), Error> { + guardian.require_auth(); + let mut vault: Vault = Self::load(&env)?; + + if guardian != vault.guardian { + return Err(Error::Unauthorized); + } + vault.paused = false; + env.storage().instance().set(&DataKey::Vault, &vault); + env.events() + .publish((String::from_str(&env, "vault_unpaused"), guardian), false); + Ok(()) + } + /// Read-only accessor returning the current vault record. pub fn get_vault(env: Env) -> Result { Self::load(&env) } + /// Sweeps any residual token balance held by the contract to the vault creator + /// after a terminal settlement. Only the creator may call this, and only once + /// `staked` has been zeroed by `claim`, `slash_on_miss`, or `withdraw`. + pub fn reclaim_after_settlement(env: Env, token_address: Address) -> Result<(), Error> { + let vault: Vault = Self::load(&env)?; + vault.creator.require_auth(); + + // Only sweep after the vault has no outstanding stake. + if vault.staked != 0 { + return Err(Error::StakedRemaining); + } + + let contract_addr = env.current_contract_address(); + let client = token::Client::new(&env, &token_address); + let bal = client.balance(&contract_addr); + if bal > 0 { + client.transfer(&contract_addr, &vault.creator, &bal); + } + Ok(()) + } + // ── internal helpers ──────────────────────────────────────────────── fn load(env: &Env) -> Result { @@ -542,42 +710,6 @@ impl AccountabilityVault { } false } - - /// Reclaim any residual token balance left in the contract after a vault - /// has reached a terminal settlement. This transfers the contract's token - /// balance to the vault creator. - /// - /// Requirements: - /// - Caller must be the `creator` (authorization enforced) - /// - Vault must be settled (no staked amount remaining) - pub fn reclaim_after_settlement( - env: Env, - vault: Vault, - token_address: Address, - ) -> Result<(), Error> { - // Ensure the caller is the creator - let creator_addr = Address::from_string(&vault.creator); - creator_addr.require_auth(); - - // Conservatively require the tracked staked amount to be zero before - // sweeping any residuals. This keeps semantics clear: reclaiming is - // only allowed once the vault has no outstanding stake. - if vault.amount != 0 { - return Err(Error::StakedRemaining); - } - - // Use the on-chain contract address as the token holder to sweep from - let contract_addr = env.current_contract_address(); - let token = TokenClient::new(&env, &token_address); - - // Query contract's token balance and transfer any leftover to creator - let bal: i128 = token.balance(&contract_addr); - if bal > 0 { - token.transfer(&contract_addr, &creator_addr, &bal); - } - - Ok(()) - } } mod test; diff --git a/contracts/accountability_vault/src/test.rs b/contracts/accountability_vault/src/test.rs index a857faf..abdab4e 100644 --- a/contracts/accountability_vault/src/test.rs +++ b/contracts/accountability_vault/src/test.rs @@ -21,9 +21,11 @@ struct Setup { env: Env, contract: AccountabilityVaultClient<'static>, token: Address, + #[allow(dead_code)] token_admin_client: token::StellarAssetClient<'static>, creator: Address, verifier: Address, + guardian: Address, success: Address, failure: Address, } @@ -43,6 +45,7 @@ fn setup_with_oracle( let creator = Address::generate(&env); let verifier = Address::generate(&env); + let guardian = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); let token_admin = Address::generate(&env); @@ -65,9 +68,13 @@ fn setup_with_oracle( } let end = 1_000 + milestone_due_offsets.iter().max().copied().unwrap_or(0); + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; contract.create_vault( &creator, - &verifier, + &verifier_set, &oracle, &token, &total, @@ -75,6 +82,7 @@ fn setup_with_oracle( &failure, &end, &milestones, + &guardian, ); Setup { @@ -84,6 +92,7 @@ fn setup_with_oracle( token_admin_client, creator, verifier, + guardian, success, failure, } @@ -203,7 +212,8 @@ fn test_stake_from_with_sufficient_allowance() { let creator = Address::generate(&env); let verifier = Address::generate(&env); - let spender = Address::generate(&env); // backend / authorized account + let guardian = Address::generate(&env); + let spender = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); let token_admin = Address::generate(&env); @@ -214,6 +224,10 @@ fn test_stake_from_with_sufficient_allowance() { let contract_id = env.register_contract(None, AccountabilityVault); let contract = AccountabilityVaultClient::new(&env, &contract_id); + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; let milestones = vec![ &env, Milestone { @@ -224,7 +238,8 @@ fn test_stake_from_with_sufficient_allowance() { }, ]; contract.create_vault( - &creator, &verifier, &None, &token, &1_000, &success, &failure, &1_200, &milestones, + &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, + &milestones, &guardian, ); // Creator approves spender to spend 1_000 tokens on their behalf. @@ -248,6 +263,7 @@ fn test_stake_from_insufficient_allowance_fails() { let creator = Address::generate(&env); let verifier = Address::generate(&env); + let guardian = Address::generate(&env); let spender = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); @@ -259,6 +275,10 @@ fn test_stake_from_insufficient_allowance_fails() { let contract_id = env.register_contract(None, AccountabilityVault); let contract = AccountabilityVaultClient::new(&env, &contract_id); + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; let milestones = vec![ &env, Milestone { @@ -269,7 +289,8 @@ fn test_stake_from_insufficient_allowance_fails() { }, ]; contract.create_vault( - &creator, &verifier, &None, &token, &1_000, &success, &failure, &1_200, &milestones, + &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, + &milestones, &guardian, ); // Approve only 500 — less than the 1_000 vault amount. @@ -289,8 +310,9 @@ fn test_stake_from_non_creator_from_fails() { let creator = Address::generate(&env); let non_creator = Address::generate(&env); - let spender = Address::generate(&env); let verifier = Address::generate(&env); + let guardian = Address::generate(&env); + let spender = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); let token_admin = Address::generate(&env); @@ -301,6 +323,10 @@ fn test_stake_from_non_creator_from_fails() { let contract_id = env.register_contract(None, AccountabilityVault); let contract = AccountabilityVaultClient::new(&env, &contract_id); + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; let milestones = vec![ &env, Milestone { @@ -311,7 +337,8 @@ fn test_stake_from_non_creator_from_fails() { }, ]; contract.create_vault( - &creator, &verifier, &None, &token, &1_000, &success, &failure, &1_200, &milestones, + &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, + &milestones, &guardian, ); // `from` is not the creator — must be rejected with Unauthorized. @@ -329,8 +356,7 @@ fn test_extend_deadline_success() { let old_end = vault_before.end_timestamp; let new_end = old_end + 500; - s.contract - .extend_deadline(&s.creator, &s.verifier, &new_end); + s.contract.extend_deadline(&s.creator, &new_end); let vault_after = s.contract.get_vault(); assert_eq!(vault_after.end_timestamp, new_end); @@ -342,8 +368,7 @@ fn test_extend_deadline_success() { fn test_extend_deadline_on_draft_fails() { let s = setup(&[100], &[500]); // Vault is Draft — extend_deadline must reject with NotActive. - s.contract - .extend_deadline(&s.creator, &s.verifier, &2_000); + s.contract.extend_deadline(&s.creator, &2_000); } #[test] @@ -354,8 +379,7 @@ fn test_extend_deadline_after_deadline_passed_fails() { // Advance past the end_timestamp. s.env.ledger().set_timestamp(2_000); - s.contract - .extend_deadline(&s.creator, &s.verifier, &3_000); + s.contract.extend_deadline(&s.creator, &3_000); } #[test] @@ -366,8 +390,7 @@ fn test_extend_deadline_not_greater_than_current_fails() { let vault = s.contract.get_vault(); // Pass the same end_timestamp — must fail with InvalidDeadline. - s.contract - .extend_deadline(&s.creator, &s.verifier, &vault.end_timestamp); + s.contract.extend_deadline(&s.creator, &vault.end_timestamp); } #[test] @@ -378,8 +401,7 @@ fn test_extend_deadline_milestone_exceeds_new_end_fails() { s.contract.stake(&s.creator); // Try to extend to 1_050 — milestone due_date (1_100) > new_end (1_050). - s.contract - .extend_deadline(&s.creator, &s.verifier, &1_050); + s.contract.extend_deadline(&s.creator, &1_050); } #[test] @@ -389,19 +411,87 @@ fn test_extend_deadline_wrong_creator_fails() { s.contract.stake(&s.creator); let impostor = Address::generate(&s.env); - s.contract - .extend_deadline(&impostor, &s.verifier, &2_000); + s.contract.extend_deadline(&impostor, &2_000); } +// ── issue #364: verifier threshold validation in create_vault ──────────────── + #[test] #[should_panic] -fn test_extend_deadline_wrong_verifier_fails() { - let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); +fn test_create_vault_invalid_threshold_exceeds_verifiers_fails() { + // threshold=2 with only 1 verifier must fail with InvalidThreshold. + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); - let impostor = Address::generate(&s.env); - s.contract - .extend_deadline(&s.creator, &impostor, &2_000); + let creator = Address::generate(&env); + let verifier = Address::generate(&env); + let guardian = Address::generate(&env); + let success = Address::generate(&env); + let failure = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, _) = create_token(&env, &token_admin); + + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); + + // threshold=2 but only 1 verifier — must fail with InvalidThreshold. + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 2u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m"), + amount: 500, + due_date: 1_200, + verified: false, + }, + ]; + contract.create_vault( + &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, + &milestones, &guardian, + ); +} + +#[test] +#[should_panic] +fn test_create_vault_zero_threshold_fails() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let creator = Address::generate(&env); + let verifier = Address::generate(&env); + let guardian = Address::generate(&env); + let success = Address::generate(&env); + let failure = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, _) = create_token(&env, &token_admin); + + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); + + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 0u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m"), + amount: 500, + due_date: 1_200, + verified: false, + }, + ]; + contract.create_vault( + &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, + &milestones, &guardian, + ); } // ── issue #363: oracle-driven check_in path ────────────────────────────────── @@ -476,6 +566,7 @@ fn test_vault_has_oracle_field_when_set() { let creator = Address::generate(&env); let verifier = Address::generate(&env); let oracle = Address::generate(&env); + let guardian = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); let token_admin = Address::generate(&env); @@ -486,6 +577,10 @@ fn test_vault_has_oracle_field_when_set() { let contract_id = env.register_contract(None, AccountabilityVault); let contract = AccountabilityVaultClient::new(&env, &contract_id); + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; let milestones = vec![ &env, Milestone { @@ -497,7 +592,7 @@ fn test_vault_has_oracle_field_when_set() { ]; contract.create_vault( &creator, - &verifier, + &verifier_set, &Some(oracle.clone()), &token, &500, @@ -505,13 +600,13 @@ fn test_vault_has_oracle_field_when_set() { &failure, &1_200, &milestones, + &guardian, ); let vault = contract.get_vault(); assert_eq!(vault.oracle, Some(oracle)); } - #[test] fn test_vault_oracle_field_is_none_when_not_set() { let s = setup(&[100], &[500]); @@ -530,6 +625,7 @@ fn test_stake_from_oracle_checkin_claim_full_flow() { let creator = Address::generate(&env); let verifier = Address::generate(&env); let oracle = Address::generate(&env); + let guardian = Address::generate(&env); let spender = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); @@ -541,6 +637,10 @@ fn test_stake_from_oracle_checkin_claim_full_flow() { let contract_id = env.register_contract(None, AccountabilityVault); let contract = AccountabilityVaultClient::new(&env, &contract_id); + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; let milestones = vec![ &env, Milestone { @@ -552,7 +652,7 @@ fn test_stake_from_oracle_checkin_claim_full_flow() { ]; contract.create_vault( &creator, - &verifier, + &verifier_set, &Some(oracle.clone()), &token, &500, @@ -560,6 +660,7 @@ fn test_stake_from_oracle_checkin_claim_full_flow() { &failure, &1_200, &milestones, + &guardian, ); let token_client = token::Client::new(&env, &token); @@ -579,6 +680,449 @@ fn test_stake_from_oracle_checkin_claim_full_flow() { assert_eq!(token_client.balance(&success), 500); } +// ── issue #352: checks-effects-interactions ordering tests ─────────────────── + +#[test] +fn test_cei_slash_on_miss_state_is_terminal_before_transfer() { + // After slash_on_miss the vault must be in Failed terminal state with + // staked == 0 (CEI: state persisted before the external token transfer). + let s = setup(&[100], &[500]); + s.contract.stake(&s.creator); + + s.env.ledger().set_timestamp(2_000); + s.contract.slash_on_miss(); + + let vault = s.contract.get_vault(); + assert_eq!(vault.status, VaultStatus::Failed); + assert_eq!(vault.staked, 0); + + let token_client = token::Client::new(&s.env, &s.token); + assert_eq!(token_client.balance(&s.failure), 500); +} + +#[test] +fn test_cei_claim_state_is_terminal_before_transfer() { + // After claim the vault must be in Completed terminal state with staked == 0 + // (CEI: state persisted before the external token transfer). + let s = setup(&[100], &[500]); + s.contract.stake(&s.creator); + s.contract.check_in(&s.verifier, &0); + s.contract.claim(&s.creator); + + let vault = s.contract.get_vault(); + assert_eq!(vault.status, VaultStatus::Completed); + assert_eq!(vault.staked, 0); + + let token_client = token::Client::new(&s.env, &s.token); + assert_eq!(token_client.balance(&s.success), 500); +} + +#[test] +fn test_cei_slash_cannot_be_triggered_twice() { + // After a successful slash_on_miss the vault is Failed; a second call must + // fail with NotActive — the CEI state update prevents double-slash. + let s = setup(&[100], &[500]); + s.contract.stake(&s.creator); + + s.env.ledger().set_timestamp(2_000); + s.contract.slash_on_miss(); + + let result = s.contract.try_slash_on_miss(); + assert!(result.is_err()); +} + +#[test] +fn test_cei_claim_cannot_be_triggered_twice() { + // After a successful claim the vault is Completed; a second call must fail + // with NotActive — the CEI state update prevents double-claim. + let s = setup(&[100], &[500]); + s.contract.stake(&s.creator); + s.contract.check_in(&s.verifier, &0); + s.contract.claim(&s.creator); + + let result = s.contract.try_claim(&s.creator); + assert!(result.is_err()); +} + +// ── issue #357: emergency pause / unpause tests ────────────────────────────── + +#[test] +#[should_panic] +fn test_pause_blocks_slash_on_miss() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.creator); + s.contract.emergency_pause(&s.guardian); + + s.env.ledger().set_timestamp(2_000); + // Must fail with Paused. + s.contract.slash_on_miss(); +} + +#[test] +#[should_panic] +fn test_pause_blocks_claim() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.creator); + s.contract.check_in(&s.verifier, &0); + s.contract.emergency_pause(&s.guardian); + + // Must fail with Paused. + s.contract.claim(&s.creator); +} + +#[test] +#[should_panic] +fn test_pause_blocks_withdraw_active() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.creator); + s.contract.emergency_pause(&s.guardian); + + // Must fail with Paused. + s.contract.withdraw(&s.creator); +} + +#[test] +fn test_unpause_allows_slash_on_miss() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.creator); + s.contract.emergency_pause(&s.guardian); + s.contract.emergency_unpause(&s.guardian); + + s.env.ledger().set_timestamp(2_000); + s.contract.slash_on_miss(); + + let vault = s.contract.get_vault(); + assert_eq!(vault.status, VaultStatus::Failed); +} + +#[test] +fn test_unpause_allows_claim() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.creator); + s.contract.check_in(&s.verifier, &0); + s.contract.emergency_pause(&s.guardian); + s.contract.emergency_unpause(&s.guardian); + + s.contract.claim(&s.creator); + + let vault = s.contract.get_vault(); + assert_eq!(vault.status, VaultStatus::Completed); +} + +#[test] +#[should_panic] +fn test_non_guardian_cannot_pause() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.creator); + + let impostor = Address::generate(&s.env); + // impostor is not the vault guardian — must fail with Unauthorized. + s.contract.emergency_pause(&impostor); +} + +#[test] +fn test_pause_does_not_block_draft_withdraw() { + // Cancelling a Draft vault does not transfer tokens; the pause only + // blocks the active-vault settlement paths. + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let creator = Address::generate(&env); + let verifier = Address::generate(&env); + let guardian = Address::generate(&env); + let success = Address::generate(&env); + let failure = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token(&env, &token_admin); + token_admin_client.mint(&creator, &500); + + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); + + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m"), + amount: 500, + due_date: 1_200, + verified: false, + }, + ]; + contract.create_vault( + &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, + &milestones, &guardian, + ); + + // Pause before staking (vault is still Draft). + contract.emergency_pause(&guardian); + + // Draft-path withdraw (cancel) must still succeed. + contract.withdraw(&creator); + let vault = contract.get_vault(); + assert_eq!(vault.status, VaultStatus::Cancelled); +} + +// ── issue #364: M-of-N multi-verifier check_in tests ───────────────────────── + +#[test] +fn test_multi_verifier_single_approval_insufficient_for_threshold_two() { + // With 2 verifiers and threshold=2, a single approval does not verify the milestone. + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let creator = Address::generate(&env); + let verifier1 = Address::generate(&env); + let verifier2 = Address::generate(&env); + let guardian = Address::generate(&env); + let success = Address::generate(&env); + let failure = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token(&env, &token_admin); + token_admin_client.mint(&creator, &500); + + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); + + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier1.clone(), verifier2.clone()], + threshold: 2u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m"), + amount: 500, + due_date: 1_200, + verified: false, + }, + ]; + contract.create_vault( + &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, + &milestones, &guardian, + ); + contract.stake(&creator); + + // Only verifier1 approves — threshold not yet reached. + contract.check_in(&verifier1, &0); + let vault = contract.get_vault(); + assert!(!vault.milestones.get(0).unwrap().verified); +} + +#[test] +fn test_multi_verifier_both_approve_verifies_milestone() { + // With 2 verifiers and threshold=2, both approving flips the milestone to verified. + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let creator = Address::generate(&env); + let verifier1 = Address::generate(&env); + let verifier2 = Address::generate(&env); + let guardian = Address::generate(&env); + let success = Address::generate(&env); + let failure = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token(&env, &token_admin); + token_admin_client.mint(&creator, &500); + + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); + + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier1.clone(), verifier2.clone()], + threshold: 2u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m"), + amount: 500, + due_date: 1_200, + verified: false, + }, + ]; + contract.create_vault( + &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, + &milestones, &guardian, + ); + contract.stake(&creator); + + // Both verifiers approve — threshold reached. + contract.check_in(&verifier1, &0); + contract.check_in(&verifier2, &0); + + let vault = contract.get_vault(); + assert!(vault.milestones.get(0).unwrap().verified); +} + +#[test] +#[should_panic] +fn test_multi_verifier_double_approval_by_same_verifier_fails() { + // The same verifier may not approve the same milestone twice. + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let creator = Address::generate(&env); + let verifier1 = Address::generate(&env); + let verifier2 = Address::generate(&env); + let guardian = Address::generate(&env); + let success = Address::generate(&env); + let failure = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token(&env, &token_admin); + token_admin_client.mint(&creator, &500); + + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); + + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier1.clone(), verifier2.clone()], + threshold: 2u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m"), + amount: 500, + due_date: 1_200, + verified: false, + }, + ]; + contract.create_vault( + &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, + &milestones, &guardian, + ); + contract.stake(&creator); + + contract.check_in(&verifier1, &0); + // Same verifier approves again — must fail with AlreadyApproved. + contract.check_in(&verifier1, &0); +} + +#[test] +fn test_multi_verifier_threshold_one_of_two_single_approval_sufficient() { + // With 2 verifiers and threshold=1, a single approval verifies the milestone. + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let creator = Address::generate(&env); + let verifier1 = Address::generate(&env); + let verifier2 = Address::generate(&env); + let guardian = Address::generate(&env); + let success = Address::generate(&env); + let failure = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token(&env, &token_admin); + token_admin_client.mint(&creator, &500); + + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); + + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier1.clone(), verifier2.clone()], + threshold: 1u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m"), + amount: 500, + due_date: 1_200, + verified: false, + }, + ]; + contract.create_vault( + &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, + &milestones, &guardian, + ); + contract.stake(&creator); + + // Only verifier1 approves — sufficient for threshold=1. + contract.check_in(&verifier1, &0); + let vault = contract.get_vault(); + assert!(vault.milestones.get(0).unwrap().verified); +} + +#[test] +fn test_multi_verifier_2of2_full_claim_flow() { + // Two verifiers, threshold=2: both must approve each milestone before claim. + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let creator = Address::generate(&env); + let verifier1 = Address::generate(&env); + let verifier2 = Address::generate(&env); + let guardian = Address::generate(&env); + let success = Address::generate(&env); + let failure = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token(&env, &token_admin); + token_admin_client.mint(&creator, &1_000); + + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); + + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier1.clone(), verifier2.clone()], + threshold: 2u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m1"), + amount: 400, + due_date: 1_100, + verified: false, + }, + Milestone { + title: String::from_str(&env, "m2"), + amount: 600, + due_date: 1_200, + verified: false, + }, + ]; + contract.create_vault( + &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, + &milestones, &guardian, + ); + contract.stake(&creator); + + // Milestone 0: both verifiers must approve. + contract.check_in(&verifier1, &0); + assert!(!contract.get_vault().milestones.get(0).unwrap().verified); + contract.check_in(&verifier2, &0); + assert!(contract.get_vault().milestones.get(0).unwrap().verified); + + // Milestone 1: both verifiers must approve. + contract.check_in(&verifier1, &1); + contract.check_in(&verifier2, &1); + assert!(contract.get_vault().milestones.get(1).unwrap().verified); + + // All milestones verified — claim succeeds. + contract.claim(&creator); + assert_eq!(contract.get_vault().status, VaultStatus::Completed); + + let token_client = token::Client::new(&env, &token); + assert_eq!(token_client.balance(&success), 1_000); +} + +// ── gas benchmarks ─────────────────────────────────────────────────────────── + #[test] fn test_gas_benchmarks_10_milestones() { let env = Env::default(); @@ -587,13 +1131,13 @@ fn test_gas_benchmarks_10_milestones() { let creator = Address::generate(&env); let verifier = Address::generate(&env); + let guardian = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); let token_admin = Address::generate(&env); let (token, token_admin_client) = create_token(&env, &token_admin); - - // Setup 10 milestones + let milestone_count = 10; let milestone_amount = 100i128; let total_amount = milestone_amount * (milestone_count as i128); @@ -613,12 +1157,16 @@ fn test_gas_benchmarks_10_milestones() { } let end_timestamp = 1_000 + (milestone_count as u64) * 100; - + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; + // 1. Measure create_vault env.budget().reset_default(); contract.create_vault( &creator, - &verifier, + &verifier_set, &None, &token, &total_amount, @@ -626,10 +1174,11 @@ fn test_gas_benchmarks_10_milestones() { &failure, &end_timestamp, &milestones, + &guardian, ); let create_cpu = env.budget().cpu_instruction_cost(); let create_mem = env.budget().memory_bytes_cost(); - + // 2. Measure stake env.budget().reset_default(); contract.stake(&creator); @@ -653,14 +1202,12 @@ fn test_gas_benchmarks_10_milestones() { let claim_cpu = env.budget().cpu_instruction_cost(); let claim_mem = env.budget().memory_bytes_cost(); - // Print values for baseline establishment std::println!("=== Gas Benchmarks (10 Milestones) ==="); std::println!("create_vault: CPU = {}, Memory = {}", create_cpu, create_mem); std::println!("stake: CPU = {}, Memory = {}", stake_cpu, stake_mem); std::println!("check_in: CPU = {}, Memory = {}", check_in_cpu, check_in_mem); std::println!("claim: CPU = {}, Memory = {}", claim_cpu, claim_mem); - // Hard bounds assertions for 10 milestones to prevent unbounded growth/regressions assert!(create_cpu < 600_000); assert!(create_mem < 200_000); @@ -682,13 +1229,13 @@ fn test_gas_benchmarks_slash_on_miss_10_milestones() { let creator = Address::generate(&env); let verifier = Address::generate(&env); + let guardian = Address::generate(&env); let success = Address::generate(&env); let failure = Address::generate(&env); let token_admin = Address::generate(&env); let (token, token_admin_client) = create_token(&env, &token_admin); - - // Setup 10 milestones + let milestone_count = 10; let milestone_amount = 100i128; let total_amount = milestone_amount * (milestone_count as i128); @@ -708,10 +1255,14 @@ fn test_gas_benchmarks_slash_on_miss_10_milestones() { } let end_timestamp = 1_000 + (milestone_count as u64) * 100; - + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; + contract.create_vault( &creator, - &verifier, + &verifier_set, &None, &token, &total_amount, @@ -719,8 +1270,9 @@ fn test_gas_benchmarks_slash_on_miss_10_milestones() { &failure, &end_timestamp, &milestones, + &guardian, ); - + contract.stake(&creator); // Advance past the overall deadline to allow slash @@ -738,6 +1290,3 @@ fn test_gas_benchmarks_slash_on_miss_10_milestones() { assert!(slash_cpu < 900_000); assert!(slash_mem < 250_000); } - - - diff --git a/contracts/rustfmt.toml b/contracts/rustfmt.toml new file mode 100644 index 0000000..ab17e9e --- /dev/null +++ b/contracts/rustfmt.toml @@ -0,0 +1,6 @@ +edition = "2021" +max_width = 100 +tab_spaces = 4 +use_small_heuristics = "Default" +imports_granularity = "Crate" +group_imports = "StdExternalCrate"