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"