diff --git a/contracts/README.md b/contracts/README.md index 0a26041..1cb4111 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -24,11 +24,7 @@ Implements the vault lifecycle that the backend models off-chain in | Function | Purpose | |---|---| -| `init` | Initialize the deployment admin that manages instance-level policy. Must be called once before allowlist administration. | -| `set_admin` | Rotate the deployment admin. Only the current admin may call this. | -| `set_allowed_token` | Add or remove a SEP-41 token contract from the instance-level allowlist used for new vaults. | -| `is_allowed_token` | Read whether a token contract is currently allowed for new vault creation. | -| `create_vault` | Create a `Draft` vault with milestones, verifier, token, and success/failure destinations. Validates amount, deadline, milestone sums, and that the requested token is allowlisted. | +| `create_vault` | Create a `Draft` vault with milestones, verifier, and success/failure destinations. Validates amount, deadline, that milestone amounts sum to the total, and rejects obviously-degenerate salt/vault_id values (all-zero or all-ones `BytesN<32>`). | | `stake` | Creator transfers the SEP-41 token into the contract; `Draft` -> `Active`. | | `check_in` | Designated verifier confirms a milestone before its `due_date`. | | `slash_on_miss` | After the deadline with unverified milestones, slash funds to `failure_destination`; `Active` -> `Failed`. | diff --git a/contracts/accountability_vault/src/lib.rs b/contracts/accountability_vault/src/lib.rs index 4fc2c9f..996e489 100644 --- a/contracts/accountability_vault/src/lib.rs +++ b/contracts/accountability_vault/src/lib.rs @@ -8,19 +8,39 @@ //! 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 -> (check_in)* -> claim | slash_on_miss -//! Funds movement is modeled via the SEP-41 token client (`stake`, `claim`, -//! `slash_on_miss`, `withdraw`). The contract enforces the state machine, +//! Lifecycle: create_vault -> stake | stake_from -> (check_in)* -> claim | claim_milestone | 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 + 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 set; the source +//! (`"oracle"` vs `"verifier"`) is included in the emitted event for backend parsing. use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env, String, Symbol, Vec, }; -/// Maximum allowed horizon between vault creation and its deadline. -const MAX_DEADLINE_HORIZON: u64 = 5 * 365 * 24 * 60 * 60; - /// Storage keys for the contract. #[contracttype] #[derive(Clone)] @@ -28,9 +48,12 @@ pub enum DataKey { /// Address allowed to manage deployment-wide settings. Admin, /// The vault configuration and current state. - Vault(String), - /// Whether a SEP-41 token contract may be selected by `create_vault`. - AllowedToken(Address), + Vault(BytesN<32>), + /// 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), + DisputeWindow, } /// Lifecycle state of a vault. @@ -53,13 +76,12 @@ pub enum VaultStatus { #[contracttype] #[derive(Clone)] pub struct Milestone { - /// Human-readable title describing the milestone goal. pub title: String, /// Portion of the staked amount tied to this milestone. pub amount: i128, /// UNIX timestamp (seconds) by which the milestone must be checked in. pub due_date: u64, - /// Whether the verifier has confirmed this milestone. + /// Whether enough distinct verifiers / oracle have approved this milestone. pub verified: bool, /// Whether this milestone's funds have already been released. pub released: bool, @@ -69,15 +91,22 @@ pub struct Milestone { #[contracttype] #[derive(Clone)] pub struct Vault { - /// Address that created the vault and owns the staked funds. pub creator: Address, - /// Address authorized to verify milestones. - pub verifier: Address, + /// 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. pub token: Address, /// Total staked amount. pub amount: i128, - /// Amount actually transferred into the contract via `stake`. + /// Actual amount received by the contract via `stake` or `stake_from`, + /// measured as the balance delta to handle fee-on-transfer tokens correctly. pub staked: i128, /// Destination for released funds on success. pub success_destination: Address, @@ -85,9 +114,7 @@ pub struct Vault { pub failure_destination: Address, /// Overall vault deadline. pub end_timestamp: u64, - /// Current lifecycle state of the vault. pub status: VaultStatus, - /// Ordered list of milestones. pub milestones: Vec, /// Address authorized to pause in future emergency flows. pub guardian: Address, @@ -100,45 +127,46 @@ pub struct Vault { #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u32)] pub enum Error { - /// Vault storage already exists for the given `vault_id`. AlreadyInitialized = 1, - /// No vault found for the given `vault_id`. NotInitialized = 2, - /// Creator and verifier are the same address; role separation is required. - CreatorIsVerifier = 26, - /// Amount is zero or negative. InvalidAmount = 3, - /// Deadline is in the past, exceeds vault end, or beyond the 5-year horizon. InvalidDeadline = 4, - /// Milestone list is empty. NoMilestones = 5, - /// Operation requires the vault to be in `Draft` state. NotDraft = 6, - /// Operation requires the vault to be in `Active` state. NotActive = 7, - /// Caller is not permitted for this operation. - Unauthorized = 8, - /// Vault has already been funded; cannot stake again. + Unauthorized = 8, // backward compatibility + NotCreator = 23, + NotVerifier = 24, + NotCreatorOrVerifier = 25, AlreadyStaked = 9, - /// Milestone index is outside the valid range. MilestoneIndexOutOfRange = 10, - /// Milestone has already been verified. MilestoneAlreadyVerified = 11, - /// Current time is past the milestone or vault deadline. DeadlinePassed = 12, - /// Current time has not yet reached the vault deadline. DeadlineNotReached = 13, - /// Not all milestones have been verified. MilestonesIncomplete = 14, - /// Vault staked balance is zero; nothing to withdraw. NothingToWithdraw = 15, - /// Received amount does not match the declared vault amount. AmountMismatch = 16, - /// Deadline is in the past. - DeadlineInPast = 28, + /// `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, + /// Operation rejected because the vault is in `Disputed` state. + VaultDisputed = 23, + /// Milestone has already been released via claim_milestone + MilestoneAlreadyReleased = 26, + /// Some milestones already released, bulk claim not allowed + PartiallyReleased = 27, } -/// Accountability vault contract entry point. #[contract] pub struct AccountabilityVault; @@ -193,11 +221,20 @@ impl AccountabilityVault { } /// Creates a new accountability vault in `Draft` state. + /// + /// `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 in addition to + /// the verifier set. Pass `None` for human-only verification. pub fn create_vault( env: Env, - vault_id: String, + vault_id: BytesN<32>, creator: Address, - verifier: Address, + verifier_set: VerifierSet, + oracle: Option
, token: Address, amount: i128, success_destination: Address, @@ -208,7 +245,21 @@ impl AccountabilityVault { ) -> Result<(), Error> { creator.require_auth(); - let key = DataKey::Vault(vault_id); + let mut is_zero = true; + let mut is_ones = true; + for byte in vault_id.to_array() { + if byte != 0 { + is_zero = false; + } + if byte != 0xff { + is_ones = false; + } + } + if is_zero || is_ones { + return Err(Error::InvalidSalt); + } + + let key = DataKey::Vault(vault_id.clone()); if env.storage().persistent().has(&key) { return Err(Error::AlreadyInitialized); } @@ -221,20 +272,14 @@ impl AccountabilityVault { if end_timestamp <= env.ledger().timestamp() { return Err(Error::DeadlineInPast); } - if end_timestamp > env.ledger().timestamp() + MAX_DEADLINE_HORIZON { - return Err(Error::InvalidDeadline); - } if milestones.is_empty() { return Err(Error::NoMilestones); } - if milestones.len() > MAX_MILESTONES { - return Err(Error::TooManyMilestones); - } - let mut sum = 0i128; - let mut initialized = Vec::new(&env); - for milestone in milestones.iter() { - if milestone.amount <= 0 { + let mut sum: i128 = 0; + let mut prev_due_date: Option = None; + for m in milestones.iter() { + if m.amount <= 0 { return Err(Error::InvalidAmount); } if milestone.due_date > end_timestamp || milestone.due_date <= now { @@ -255,7 +300,9 @@ impl AccountabilityVault { let vault = Vault { creator: creator.clone(), - verifier, + verifiers, + approval_threshold, + oracle, token, amount, staked: 0, @@ -273,8 +320,18 @@ impl AccountabilityVault { Ok(()) } - /// Funds the vault by transferring the configured token from the creator. - pub fn stake(env: Env, vault_id: String, from: Address) -> Result<(), Error> { + /// Funds the vault by transferring `amount` of the staking token from the + /// creator into the contract, moving the vault from `Draft` to `Active`. + /// + /// The actual received amount is measured as the contract balance delta to + /// correctly account for fee-on-transfer tokens. If the received amount is + /// less than the declared `vault.amount`, the call is rejected with + /// `Error::AmountMismatch`. + pub fn stake( + env: Env, + vault_id: BytesN<32>, + from: Address, + ) -> Result<(), Error> { from.require_auth(); let key = DataKey::Vault(vault_id); let mut vault: Vault = env @@ -293,51 +350,123 @@ impl AccountabilityVault { return Err(Error::AlreadyStaked); } - token::Client::new(&env, &vault.token).transfer( - &from, - &env.current_contract_address(), - &vault.amount, - ); + let client = token::Client::new(&env, &vault.token); + let contract_addr = env.current_contract_address(); + let balance_before = client.balance(&contract_addr); + client.transfer(&from, &contract_addr, &vault.amount); + let received = client.balance(&contract_addr) - balance_before; + if received < vault.amount { + return Err(Error::AmountMismatch); + } - vault.staked = vault.amount; + vault.staked = received; vault.status = VaultStatus::Active; env.storage().persistent().set(&key, &vault); Self::extend_ttl(&env, &key); // Legacy event: preserved for backward-compatible listeners. env.events() - .publish((Symbol::new(&env, "vault_staked"), from.clone()), vault.staked); + .publish((Symbol::new(&env, "vault_staked"), from), vault.staked); + Ok(()) + } - // Rich funding event: carries token address so eventParser.ts can - // reconcile the SEP-41 contract address without a separate Horizon query. - env.events().publish( - (Symbol::new(&env, "vault_funded"), vault.token.clone(), from), - vault.staked, - ); + /// Allowance-based staking variant using SEP-41 `transfer_from`. + /// + /// Enables a backend or authorized spender account to drive the staking flow + /// without requiring the creator to call the contract directly. The creator + /// must first call `token.approve(spender, amount)` to grant the allowance. + /// + /// - `from`: the creator / token holder whose balance is pulled. + /// - `spender`: the account that holds the allowance and must authorize this call. + /// + /// Like `stake`, the received amount is measured via balance delta to handle + /// fee-on-transfer tokens. Returns `Error::InsufficientAllowance` when the + /// spender's allowance from `from` is below the vault's staking amount. + pub fn stake_from( + env: Env, + vault_id: BytesN<32>, + from: Address, + spender: Address, + ) -> Result<(), Error> { + spender.require_auth(); + let mut vault: Vault = Self::load(&env, &vault_id)?; + + if vault.status != VaultStatus::Draft { + return Err(Error::NotDraft); + } + if from != vault.creator { + return Err(Error::Unauthorized); + } + if vault.staked != 0 { + return Err(Error::AlreadyStaked); + } + + let client = token::Client::new(&env, &vault.token); + + // Validate the spender's allowance covers the required stake before + // attempting the transfer, to surface a clear error on under-approval. + let allowance = client.allowance(&from, &spender); + if allowance < vault.amount { + return Err(Error::InsufficientAllowance); + } + + let contract_addr = env.current_contract_address(); + let balance_before = client.balance(&contract_addr); + client.transfer_from(&spender, &from, &contract_addr, &vault.amount); + let received = client.balance(&contract_addr) - balance_before; + if received < vault.amount { + return Err(Error::AmountMismatch); + } + + vault.staked = received; + vault.status = VaultStatus::Active; + let key = DataKey::Vault(vault_id); + env.storage().persistent().set(&key, &vault); + Self::extend_ttl(&env, &key); + + env.events() + .publish((Symbol::new(&env, "vault_staked"), from), vault.staked); Ok(()) } - /// Records the verifier's approval for 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. + /// + /// `evidence_hash` is a 32-byte SHA-256 (or equivalent) digest of the + /// off-chain evidence artifact (e.g. IPFS CID hash, document hash). It is + /// stored alongside the check-in timestamp and emitted in the + /// `milestone_checked_in` event so that on-chain records are + /// cryptographically bound to off-chain evidence. + /// + /// 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, - vault_id: String, - verifier: Address, + vault_id: BytesN<32>, + caller: Address, milestone_index: u32, + evidence_hash: BytesN<32>, ) -> Result<(), Error> { - verifier.require_auth(); - let key = DataKey::Vault(vault_id); - let mut vault: Vault = env - .storage() - .persistent() - .get(&key) - .ok_or(Error::NotInitialized)?; + caller.require_auth(); + let mut vault: Vault = Self::load(&env, &vault_id)?; if vault.status != VaultStatus::Active { return Err(Error::NotActive); } - if verifier != vault.verifier { + + let is_verifier = vault.verifiers.iter().any(|v| v == caller); + let is_oracle = vault + .oracle + .as_ref() + .map(|o| o == &caller) + .unwrap_or(false); + if !is_verifier && !is_oracle { return Err(Error::Unauthorized); } + if milestone_index >= vault.milestones.len() { return Err(Error::MilestoneIndexOutOfRange); } @@ -355,12 +484,101 @@ impl AccountabilityVault { return Err(Error::InvalidDeadline); } - milestone.verified = true; - vault.milestones.set(milestone_index, milestone); + // 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() + .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(), evidence_hash.clone()), + ); + env.storage().instance().set(&DataKey::Vault, &vault); + } + + let source = if is_oracle { + symbol_short!("oracle") + } else { + symbol_short!("verifier") + }; + env.events().publish( + ( + Symbol::new(&env, "milestone_checked_in"), + caller, + source, + ), + (milestone_index, evidence_hash), + ); + Ok(()) + } + + /// Extends the vault's `end_timestamp` to a later point in time. + /// + /// 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`. + /// - `new_end_timestamp` must be strictly greater than the current `end_timestamp`. + /// - All existing milestone `due_date` values must be `<= new_end_timestamp`. + pub fn extend_deadline( + env: Env, + vault_id: BytesN<32>, + creator: Address, + new_end_timestamp: u64, + ) -> Result<(), Error> { + creator.require_auth(); + let mut vault: Vault = Self::load(&env, &vault_id)?; + + if creator != vault.creator { + 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); + } + if env.ledger().timestamp() >= vault.end_timestamp { + return Err(Error::DeadlinePassed); + } + if new_end_timestamp <= vault.end_timestamp { + return Err(Error::InvalidDeadline); + } + // Preserve the invariant: every milestone due_date <= end_timestamp. + for m in vault.milestones.iter() { + if m.due_date > new_end_timestamp { + return Err(Error::InvalidDeadline); + } + } + + let old_end = vault.end_timestamp; + vault.end_timestamp = new_end_timestamp; + let key = DataKey::Vault(vault_id); env.storage().persistent().set(&key, &vault); + Self::extend_ttl(&env, &key); + env.events().publish( - (Symbol::new(&env, "milestone_checked_in"), verifier), - milestone_index, + (Symbol::new(&env, "deadline_extended"), creator), + (old_end, new_end_timestamp), ); Ok(()) } @@ -372,7 +590,7 @@ impl AccountabilityVault { /// 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, vault_id: String) -> Result<(), Error> { + pub fn slash_on_miss(env: Env, vault_id: BytesN<32>) -> Result<(), Error> { let mut vault: Vault = Self::load(&env, &vault_id)?; // Check Disputed before NotActive so callers get the specific error code. @@ -421,7 +639,7 @@ impl AccountabilityVault { /// 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, vault_id: String, caller: Address) -> Result<(), Error> { + pub fn claim(env: Env, vault_id: BytesN<32>, caller: Address) -> Result<(), Error> { caller.require_auth(); let key = DataKey::Vault(vault_id); let mut vault: Vault = env @@ -469,8 +687,104 @@ impl AccountabilityVault { Ok(()) } - /// Slashes funds to the failure destination after the deadline if milestones remain incomplete. - pub fn slash_on_miss(env: Env, vault_id: String) -> Result<(), Error> { + /// Releases a single verified milestone's amount to the `success_destination`. + /// + /// Callable by the creator or verifier once the milestone at `index` is + /// verified (via `check_in`). Tracks released milestones on the `Milestone` + /// struct's `released` flag to prevent double-claiming. + /// + /// When the last milestone is claimed, the vault automatically transitions + /// to `Completed`. + pub fn claim_milestone( + env: Env, + vault_id: BytesN<32>, + caller: Address, + index: u32, + ) -> Result<(), Error> { + caller.require_auth(); + let mut vault: Vault = Self::load(&env, &vault_id)?; + + if vault.status != VaultStatus::Active { + return Err(Error::NotActive); + } + let is_authorized = caller == vault.creator || vault.verifiers.iter().any(|v| v == caller); + if !is_authorized { + return Err(Error::Unauthorized); + } + if index >= vault.milestones.len() { + return Err(Error::MilestoneIndexOutOfRange); + } + + let mut milestone = vault + .milestones + .get(index) + .ok_or(Error::MilestoneIndexOutOfRange)?; + if !milestone.verified { + return Err(Error::MilestonesIncomplete); + } + if milestone.released { + return Err(Error::MilestoneAlreadyReleased); + } + + let payout = milestone.amount; + + // Mark the milestone as released and update vault. + milestone.released = true; + vault.milestones.set(index, milestone); + vault.staked -= payout; + + let client = token::Client::new(&env, &vault.token); + client.transfer( + &env.current_contract_address(), + &vault.success_destination, + &payout, + ); + + env.events().publish( + ( + Symbol::new(&env, "milestone_claimed"), + vault.success_destination.clone(), + ), + (index, payout), + ); + + // Transition to Completed if every milestone has now been released. + if Self::all_released(&vault) { + vault.status = VaultStatus::Completed; + env.events().publish( + ( + Symbol::new(&env, "vault_completed"), + vault.success_destination.clone(), + ), + vault.amount, + ); + } + + env.storage().instance().set(&DataKey::Vault(vault_id), &vault); + Ok(()) + } + + /// Cancels an unfunded (`Draft`) vault. Only the creator may cancel a + /// draft; this path does not transfer tokens and emits `vault_cancelled`. + pub fn cancel_vault(env: Env, vault_id: BytesN<32>, creator: Address) -> Result<(), Error> { + creator.require_auth(); + let mut vault: Vault = Self::load(&env, &vault_id)?; + + if creator != vault.creator { + return Err(Error::Unauthorized); + } + if vault.status == VaultStatus::Draft { + vault.status = VaultStatus::Cancelled; + let key = DataKey::Vault(vault_id); + env.storage().persistent().set(&key, &vault); + Self::extend_ttl(&env, &key); + + env.events() + .publish((Symbol::new(&env, "vault_cancelled"), creator), 0i128); + return Ok(()); + } + + vault.status = VaultStatus::Cancelled; let key = DataKey::Vault(vault_id); env.storage().persistent().set(&key, &vault); Self::extend_ttl(&env, &key); @@ -483,7 +797,7 @@ impl AccountabilityVault { /// Refunds the creator for an `Active` vault that was never checked-in. /// This function is restricted to `Active` refund cases; callers that wish /// to cancel a Draft should call `cancel_vault` instead. - pub fn withdraw(env: Env, vault_id: String, creator: Address) -> Result<(), Error> { + pub fn withdraw(env: Env, vault_id: BytesN<32>, creator: Address) -> Result<(), Error> { creator.require_auth(); let mut vault: Vault = Self::load(&env, &vault_id)?; @@ -525,7 +839,7 @@ impl AccountabilityVault { /// `claim` until an admin resolves the dispute. /// /// Only the `guardian` address may call this. The vault must be `Active`. - pub fn admin_dispute(env: Env, vault_id: String, admin: Address) -> Result<(), Error> { + pub fn admin_dispute(env: Env, vault_id: BytesN<32>, admin: Address) -> Result<(), Error> { admin.require_auth(); let mut vault: Vault = Self::load(&env, &vault_id)?; @@ -557,7 +871,7 @@ impl AccountabilityVault { /// back in the appropriate resolved state. pub fn admin_resolve( env: Env, - vault_id: String, + vault_id: BytesN<32>, admin: Address, target: VaultStatus, ) -> Result<(), Error> { @@ -592,7 +906,7 @@ impl AccountabilityVault { /// Use to halt settlement during disputes or detected incidents. pub fn emergency_pause( env: Env, - vault_id: String, + vault_id: BytesN<32>, guardian: Address, ) -> Result<(), Error> { guardian.require_auth(); @@ -613,7 +927,7 @@ impl AccountabilityVault { /// Only the `guardian` address set at vault creation may call this function. pub fn emergency_unpause( env: Env, - vault_id: String, + vault_id: BytesN<32>, guardian: Address, ) -> Result<(), Error> { guardian.require_auth(); @@ -630,7 +944,7 @@ impl AccountabilityVault { } /// Read-only accessor returning the current vault record. - pub fn get_vault(env: Env, vault_id: String) -> Result { + pub fn get_vault(env: Env, vault_id: BytesN<32>) -> Result { Self::load(&env, &vault_id) } @@ -658,7 +972,7 @@ impl AccountabilityVault { /// 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, vault_id: String, token_address: Address) -> Result<(), Error> { + pub fn reclaim_after_settlement(env: Env, vault_id: BytesN<32>, token_address: Address) -> Result<(), Error> { let vault: Vault = Self::load(&env, &vault_id)?; vault.creator.require_auth(); @@ -683,7 +997,7 @@ impl AccountabilityVault { env.storage().instance().set(&DataKey::DisputeWindow, &window); } - pub fn dispute_milestone(env: Env, vault_id: String, creator: Address, index: u32) -> Result<(), Error> { + pub fn dispute_milestone(env: Env, vault_id: BytesN<32>, creator: Address, index: u32) -> Result<(), Error> { creator.require_auth(); let mut vault: Vault = Self::load(&env, &vault_id)?; @@ -717,15 +1031,7 @@ impl AccountabilityVault { Ok(()) } - /// Asserts that the vault is in the Active state. - fn assert_active(vault: &Vault) -> Result<(), Error> { - if vault.status != VaultStatus::Active { - return Err(Error::NotActive); - } - Ok(()) - } - - fn load(env: &Env, vault_id: &String) -> Result { + fn load(env: &Env, vault_id: &BytesN<32>) -> Result { let key = DataKey::Vault(vault_id.clone()); let vault = env .storage() @@ -765,18 +1071,11 @@ impl AccountabilityVault { Ok(()) } - /// Cancels an unfunded draft vault. - pub fn cancel_vault(env: Env, vault_id: String, creator: Address) -> Result<(), Error> { - creator.require_auth(); - let key = DataKey::Vault(vault_id); - let mut vault: Vault = env - .storage() - .persistent() - .get(&key) - .ok_or(Error::NotInitialized)?; - - if creator != vault.creator { - return Err(Error::Unauthorized); + fn any_verified(vault: &Vault) -> bool { + for m in vault.milestones.iter() { + if m.verified { + return true; + } } if vault.status != VaultStatus::Draft { return Err(Error::NotDraft); @@ -850,9 +1149,26 @@ impl AccountabilityVault { } Ok(()) } + + fn all_released(vault: &Vault) -> bool { + for m in vault.milestones.iter() { + if !m.released { + return false; + } + } + true + } + + fn any_released(vault: &Vault) -> bool { + for m in vault.milestones.iter() { + if m.released { + return true; + } + } + false + } } -#[cfg(test)] mod test; //! Accountability Vault Smart Contract diff --git a/contracts/accountability_vault/src/test.rs b/contracts/accountability_vault/src/test.rs index 0619328..856b2e5 100644 --- a/contracts/accountability_vault/src/test.rs +++ b/contracts/accountability_vault/src/test.rs @@ -3,7 +3,10 @@ extern crate std; use super::*; -use soroban_sdk::{testutils::Address as _, token, vec, Address, Env, String}; +use soroban_sdk::{ + testutils::{Address as _, Events, Ledger}, + token, vec, Address, Env, String, Symbol, +}; struct Setup { env: Env, @@ -28,7 +31,29 @@ fn create_token(env: &Env, admin: &Address) -> (Address, token::StellarAssetClie ) } -fn setup() -> Setup { +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, + vault_id: BytesN<32>, +} + +fn setup(milestone_due_offsets: &[u64], amounts: &[i128]) -> Setup { + setup_with_oracle(milestone_due_offsets, amounts, None) +} + +fn setup_with_oracle( + milestone_due_offsets: &[u64], + amounts: &[i128], + oracle: Option
, +) -> Setup { let env = Env::default(); env.mock_all_auths(); env.ledger().set_timestamp(1_000); @@ -46,16 +71,48 @@ fn setup() -> Setup { token_admin_client.mint(&creator, &500); let (disallowed_token, _) = create_token(&env, &other_token_admin); - let contract_id = env.register(AccountabilityVault, ()); + let contract_id = env.register_contract(None, AccountabilityVault); let contract = AccountabilityVaultClient::new(&env, &contract_id); contract.init(&admin); + let vault_id = BytesN::from_array(&env, &[1; 32]); + + let mut milestones = vec![&env]; + for (i, due) in milestone_due_offsets.iter().enumerate() { + milestones.push_back(Milestone { + title: String::from_str(&env, "m"), + amount: amounts[i], + due_date: 1_000 + due, + verified: false, + released: false, + }); + } + + 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( + &vault_id, + &creator, + &verifier_set, + &oracle, + &token, + &total, + &success, + &failure, + &end, + &milestones, + &guardian, + ); + Setup { env, contract, admin, token, - disallowed_token, + token_admin_client, creator, verifier, guardian, @@ -65,18 +122,13 @@ fn setup() -> Setup { } } -fn milestones(env: &Env) -> Vec { - vec![ - env, - Milestone { - title: String::from_str(env, "m1"), - amount: 500, - due_date: 1_100, - verified: false, - released: false, - }, - ] -} +// ── existing lifecycle tests ───────────────────────────────────────────────── + +#[test] +fn test_create_and_stake() { + let s = setup(&[100], &[500]); + let vault = s.contract.get_vault(&s.vault_id); + assert_eq!(vault.status, VaultStatus::Draft); fn create_vault_with_token(s: &Setup, token: &Address, vault_id: &str) -> Result<(), Error> { s.contract @@ -205,329 +257,1405 @@ fn removing_token_blocks_new_vaults_but_preserves_existing_vault() { } #[test] -fn create_vault_rejects_more_than_max_milestones() { - let s = setup(); - s.contract.set_allowed_token(&s.admin, &s.token, &true); - let mut milestones = Vec::new(&s.env); - for i in 0..(MAX_MILESTONES + 1) { - milestones.push_back(Milestone { - title: String::from_str(&s.env, "m"), - amount: 1, - due_date: 1_100 + u64::from(i), - verified: false, - released: false, - }); - } +fn test_check_in_and_claim_success() { + let s = setup(&[100, 200], &[300, 700]); + s.contract.stake(&s.vault_id, &s.creator); - let result = s - .contract - .try_create_vault( - &String::from_str(&s.env, "v1"), - &s.creator, - &s.verifier, - &s.token, - &i128::from(MAX_MILESTONES + 1), - &s.success, - &s.failure, - &2_000, - &milestones, - &s.guardian, - ) - .unwrap(); + s.contract.check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 1)); + s.contract.check_in(&s.vault_id, &s.verifier, &1, &evidence_hash(&s.env, 1)); - assert_eq!(result, Err(Error::TooManyMilestones)); -} + s.contract.claim(&s.vault_id, &s.creator); + let vault = s.contract.get_vault(&s.vault_id); + assert_eq!(vault.status, VaultStatus::Completed); -// ── pre-init paths ─────────────────────────────────────────────────────────── + let token_client = token::Client::new(&s.env, &s.token); + assert_eq!(token_client.balance(&s.success), 1000); +} #[test] -fn test_get_vault_not_initialized() { - let env = Env::default(); - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - let vault_id = String::from_str(&env, "v1"); +fn test_check_in_out_of_range_returns_typed_error() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.vault_id, &s.creator); + + let result = s.contract.try_check_in(&s.verifier, &1); - let result = contract.try_get_vault(&vault_id); - assert_eq!(result, Err(Ok(Error::NotInitialized))); + assert!(matches!(result, Err(Ok(Error::MilestoneIndexOutOfRange)))); } #[test] -fn test_stake_not_initialized() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - let vault_id = String::from_str(&env, "v1"); - let creator = Address::generate(&env); +fn test_slash_on_miss() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.vault_id, &s.creator); - let result = contract.try_stake(&vault_id, &creator); - assert_eq!(result, Err(Ok(Error::NotInitialized))); -} + // Advance past the deadline without any check-in. + s.env.ledger().set_timestamp(2_000); + s.contract.slash_on_miss(&s.vault_id); -#[test] -fn test_check_in_not_initialized() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - let vault_id = String::from_str(&env, "v1"); - let verifier = Address::generate(&env); + let vault = s.contract.get_vault(&s.vault_id); + assert_eq!(vault.status, VaultStatus::Failed); - let result = contract.try_check_in(&vault_id, &verifier, &0); - assert_eq!(result, Err(Ok(Error::NotInitialized))); + let token_client = token::Client::new(&s.env, &s.token); + assert_eq!(token_client.balance(&s.failure), 500); } #[test] -fn test_claim_not_initialized() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - let vault_id = String::from_str(&env, "v1"); - let creator = Address::generate(&env); - - let result = contract.try_claim(&vault_id, &creator); - assert_eq!(result, Err(Ok(Error::NotInitialized))); +fn test_withdraw_draft_cancels() { + let s = setup(&[100], &[500]); + s.contract.cancel_vault(&s.vault_id, &s.creator); + let vault = s.contract.get_vault(&s.vault_id); + assert_eq!(vault.status, VaultStatus::Cancelled); } -#[cfg(test)] -mod scval_i128_parity_tests { - use super::*; - use soroban_sdk::{Env, IntoVal, TryFromVal, Val}; +#[test] +fn test_withdraw_active_refunds_creator() { + let s = setup(&[100], &[500]); + // Fund the vault and then call withdraw without any check-ins. + s.contract.stake(&s.vault_id, &s.creator); - fn assert_scval_i128_roundtrip(env: &Env, amount: i128) { - // Encodes the native i128 value into a Soroban Val type - let encoded_val: Val = amount.into_val(env); - - // Decodes it back out to guarantee the format matches backend expectations - let decoded_amount: i128 = i128::try_from_val(env, &encoded_val) - .expect("Parity Gap: Unable to decode structural i128 asset value."); - - assert_eq!(amount, decoded_amount, "Parity Gap: Value changed during roundtrip conversion."); - } + s.contract.withdraw(&s.vault_id, &s.creator); + let vault = s.contract.get_vault(&s.vault_id); + assert_eq!(vault.status, VaultStatus::Cancelled); - #[test] - fn test_lifecycle_events_i128_parity() { - let env = Env::default(); - - // Test zero, standard token scales, and large balance amounts - assert_scval_i128_roundtrip(&env, 0); // Base case - assert_scval_i128_roundtrip(&env, 100_000_000); // Stake / Slash amount - assert_scval_i128_roundtrip(&env, 5_000_000_000); // Claim amount - assert_scval_i128_roundtrip(&env, 123_456_789_012_345_678_901_i128); // High volume cap bounds - } + let token_client = token::Client::new(&s.env, &s.token); + assert_eq!(token_client.balance(&s.creator), 500); } #[test] -fn test_check_in_and_claim_success() { +#[should_panic] +fn test_claim_before_all_verified_fails() { let s = setup(&[100, 200], &[300, 700]); s.contract.stake(&s.vault_id, &s.creator); - s.contract.check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 1)); - s.contract.check_in(&s.vault_id, &s.verifier, &1, &evidence_hash(&s.env, 1)); - + // Second milestone not yet verified -> claim must fail. s.contract.claim(&s.vault_id, &s.creator); - let vault = s.contract.get_vault(&s.vault_id); - assert_eq!(vault.status, VaultStatus::Completed); - - let token_client = token::Client::new(&s.env, &s.token); - assert_eq!(token_client.balance(&s.success), 1000); } #[test] -fn test_check_in_out_of_range_returns_typed_error() { +#[should_panic] +fn test_slash_before_deadline_fails() { let s = setup(&[100], &[500]); s.contract.stake(&s.vault_id, &s.creator); + s.contract.slash_on_miss(&s.vault_id); +} - let result = s.contract.try_check_in(&s.verifier, &1); +// ── issue #368: balance delta assertion in stake ───────────────────────────── - assert!(matches!(result, Err(Ok(Error::MilestoneIndexOutOfRange)))); +#[test] +fn test_stake_records_balance_delta_as_staked() { + // For a standard token (no fee on transfer) the delta equals vault.amount. + let s = setup(&[100], &[800]); + s.contract.stake(&s.vault_id, &s.creator); + let vault = s.contract.get_vault(&s.vault_id); + assert_eq!(vault.staked, 800); + assert_eq!(vault.status, VaultStatus::Active); } #[test] -fn test_slash_on_miss() { +#[should_panic] +fn test_stake_unauthorized_non_creator_fails() { + let s = setup(&[100], &[500]); + let other = Address::generate(&s.env); + s.contract.stake(&s.vault_id, &other); +} + +#[test] +#[should_panic] +fn test_stake_double_stake_fails() { let s = setup(&[100], &[500]); s.contract.stake(&s.vault_id, &s.creator); + // Second stake on an Active vault must fail with AlreadyStaked / NotDraft. + s.contract.stake(&s.vault_id, &s.creator); +} - // Advance past the deadline without any check-in. - s.env.ledger().set_timestamp(2_000); - s.contract.slash_on_miss(&s.vault_id); +// ── issue #370: stake_from allowance-based variant ─────────────────────────── - let vault = s.contract.get_vault(&s.vault_id); - assert_eq!(vault.status, VaultStatus::Failed); +#[test] +fn test_stake_from_with_sufficient_allowance() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); - let token_client = token::Client::new(&s.env, &s.token); - assert_eq!(token_client.balance(&s.failure), 500); + 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); + 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, verifier.clone()], + threshold: 1u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m1"), + amount: 1_000, + due_date: 1_200, + verified: false, + released: false, + }, + ]; + let vault_id = BytesN::from_array(&env, &[1; 32]); + contract.create_vault( + &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, + &milestones, &guardian, + ); + + // Creator approves spender to spend 1_000 tokens on their behalf. + let token_client = token::Client::new(&env, &token); + token_client.approve(&creator, &spender, &1_000, &200); + + contract.stake_from(&vault_id, &creator, &spender); + + let vault = contract.get_vault(&vault_id); + assert_eq!(vault.status, VaultStatus::Active); + assert_eq!(vault.staked, 1_000); + assert_eq!(token_client.balance(&creator), 0); } #[test] -fn test_token_admin_balance_invariant_success_lifecycle() { - let s = setup(&[100, 200], &[300, 700]); - let token_client = token::Client::new(&s.env, &s.token); - let admin_balance_before = token_client.balance(&s.token_admin); +#[should_panic] +fn test_stake_from_insufficient_allowance_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 spender = 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, verifier.clone()], + threshold: 1u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m1"), + amount: 1_000, + due_date: 1_200, + verified: false, + released: false, + }, + ]; + let vault_id = BytesN::from_array(&env, &[1; 32]); + contract.create_vault( + &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, + &milestones, &guardian, + ); + + // Approve only 500 — less than the 1_000 vault amount. + let token_client = token::Client::new(&env, &token); + token_client.approve(&creator, &spender, &500, &200); + + // Must fail with InsufficientAllowance. + contract.stake_from(&vault_id, &creator, &spender); +} + +#[test] +#[should_panic] +fn test_stake_from_non_creator_from_fails() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let creator = Address::generate(&env); + let non_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); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token(&env, &token_admin); + token_admin_client.mint(&non_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, verifier.clone()], + threshold: 1u32, + }; + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m1"), + amount: 1_000, + due_date: 1_200, + verified: false, + released: false, + }, + ]; + let vault_id = BytesN::from_array(&env, &[1; 32]); + contract.create_vault( + &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, + &milestones, &guardian, + ); + + // `from` is not the creator — must be rejected with Unauthorized. + contract.stake_from(&vault_id, &non_creator, &spender); +} + +// ── issue #372: extend_deadline with dual auth ─────────────────────────────── + +#[test] +fn test_extend_deadline_success() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.vault_id, &s.creator); + + let vault_before = s.contract.get_vault(&s.vault_id); + let old_end = vault_before.end_timestamp; + + let new_end = old_end + 500; + s.contract.extend_deadline(&s.creator, &new_end); + + let vault_after = s.contract.get_vault(&s.vault_id); + assert_eq!(vault_after.end_timestamp, new_end); + assert_eq!(vault_after.status, VaultStatus::Active); +} + +#[test] +#[should_panic] +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, &2_000); +} + +#[test] +#[should_panic] +fn test_extend_deadline_after_deadline_passed_fails() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.vault_id, &s.creator); + + // Advance past the end_timestamp. + s.env.ledger().set_timestamp(2_000); + s.contract.extend_deadline(&s.creator, &3_000); +} + +#[test] +#[should_panic] +fn test_extend_deadline_not_greater_than_current_fails() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.vault_id, &s.creator); + + let vault = s.contract.get_vault(&s.vault_id); + // Pass the same end_timestamp — must fail with InvalidDeadline. + s.contract.extend_deadline(&s.creator, &vault.end_timestamp); +} + +#[test] +#[should_panic] +fn test_extend_deadline_milestone_exceeds_new_end_fails() { + // milestone due_date = 1_100, vault end = 1_100. + let s = setup(&[100], &[500]); + s.contract.stake(&s.vault_id, &s.creator); + + // Try to extend to 1_050 — milestone due_date (1_100) > new_end (1_050). + s.contract.extend_deadline(&s.creator, &1_050); +} + +#[test] +#[should_panic] +fn test_extend_deadline_wrong_creator_fails() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.vault_id, &s.creator); + + let impostor = Address::generate(&s.env); + s.contract.extend_deadline(&impostor, &2_000); +} + +// ── issue #364: verifier threshold validation in create_vault ──────────────── + +#[test] +#[should_panic] +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 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, + }, + ]; + let vault_id = BytesN::from_array(&env, &[1; 32]); + contract.create_vault( + &vault_id, &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 ────────────────────────────────── + +#[test] +fn test_oracle_check_in_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let oracle = Address::generate(&env); + let s = setup_with_oracle(&[100, 200], &[400, 600], Some(oracle.clone())); + s.contract.stake(&s.vault_id, &s.creator); + + // Oracle confirms both milestones. + s.contract.check_in(&s.vault_id, &oracle, &0, &evidence_hash(&s.env, 1)); + s.contract.check_in(&s.vault_id, &oracle, &1, &evidence_hash(&s.env, 1)); + + s.contract.claim(&s.vault_id, &s.creator); + let vault = s.contract.get_vault(&s.vault_id); + assert_eq!(vault.status, VaultStatus::Completed); + + let token_client = token::Client::new(&s.env, &s.token); + assert_eq!(token_client.balance(&s.success), 1_000); +} + +#[test] +fn test_verifier_check_in_still_works_with_oracle_configured() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let oracle = Address::generate(&env); + let s = setup_with_oracle(&[100], &[500], Some(oracle.clone())); + s.contract.stake(&s.vault_id, &s.creator); + + // The human verifier can still check in even when an oracle is set. + s.contract.check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 1)); + + let vault = s.contract.get_vault(&s.vault_id); + assert!(vault.milestones.get(0).unwrap().verified); +} + +#[test] +#[should_panic] +fn test_unauthorized_caller_check_in_fails() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.vault_id, &s.creator); + + let random = Address::generate(&s.env); + // Neither verifier nor oracle — must fail with Unauthorized. + s.contract.check_in(&s.vault_id, &random, &0, &evidence_hash(&s.env, 1)); +} + +#[test] +#[should_panic] +fn test_oracle_not_set_random_caller_check_in_fails() { + // No oracle configured; only the verifier is authorized. + let s = setup_with_oracle(&[100], &[500], None); + s.contract.stake(&s.vault_id, &s.creator); + + let fake_oracle = Address::generate(&s.env); + s.contract.check_in(&s.vault_id, &fake_oracle, &0, &evidence_hash(&s.env, 1)); +} + +#[test] +fn test_vault_has_oracle_field_when_set() { + 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 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); + + 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, "goal"), + amount: 500, + due_date: 1_200, + verified: false, + released: false, + }, + ]; + let vault_id = BytesN::from_array(&env, &[1; 32]); + contract.create_vault( + &vault_id, + &creator, + &verifier_set, + &Some(oracle.clone()), + &token, + &500, + &success, + &failure, + &1_200, + &milestones, + &guardian, + ); + + let vault = contract.get_vault(&vault_id); + assert_eq!(vault.oracle, Some(oracle)); +} + +#[test] +fn test_vault_oracle_field_is_none_when_not_set() { + let s = setup(&[100], &[500]); + let vault = s.contract.get_vault(&s.vault_id); + assert_eq!(vault.oracle, None); +} + +// ── cross-feature: stake_from then oracle check_in then claim ──────────────── + +#[test] +fn test_stake_from_oracle_checkin_claim_full_flow() { + 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 oracle = 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); + + 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, "goal"), + amount: 500, + due_date: 1_200, + verified: false, + released: false, + }, + ]; + let vault_id = BytesN::from_array(&env, &[1; 32]); + contract.create_vault( + &vault_id, + &creator, + &verifier_set, + &Some(oracle.clone()), + &token, + &500, + &success, + &failure, + &1_200, + &milestones, + &guardian, + ); + + let token_client = token::Client::new(&env, &token); + token_client.approve(&creator, &spender, &500, &200); + + // Backend drives staking via allowance. + contract.stake_from(&vault_id, &creator, &spender); + assert_eq!(contract.get_vault(&vault_id).status, VaultStatus::Active); + + // Oracle confirms the milestone. + contract.check_in(&vault_id, &oracle, &0, &evidence_hash(&env, 1)); + assert!(contract.get_vault(&vault_id).milestones.get(0).unwrap().verified); + + // Claim releases funds. + contract.claim(&vault_id, &creator); + assert_eq!(contract.get_vault(&vault_id).status, VaultStatus::Completed); + 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, &evidence_hash(&s.env, 1)); + 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, &evidence_hash(&s.env, 1)); + 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, &evidence_hash(&s.env, 1)); + 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.vault_id, &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, &evidence_hash(&s.env, 1)); + 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 cancel must still succeed. + contract.cancel_vault(&vault_id, &creator); + let vault = contract.get_vault(&vault_id); + 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, &evidence_hash(&env, 1)); + 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, &evidence_hash(&env, 1)); + contract.check_in(&verifier2, &0, &evidence_hash(&env, 1)); + + 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, &evidence_hash(&env, 1)); + // Same verifier approves again — must fail with AlreadyApproved. + contract.check_in(&verifier1, &0, &evidence_hash(&env, 1)); +} + +#[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, &evidence_hash(&env, 1)); + 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, &evidence_hash(&env, 1)); + assert!(!contract.get_vault().milestones.get(0).unwrap().verified); + contract.check_in(&verifier2, &0, &evidence_hash(&env, 1)); + assert!(contract.get_vault().milestones.get(0).unwrap().verified); + + // Milestone 1: both verifiers must approve. + contract.check_in(&verifier1, &1, &evidence_hash(&env, 1)); + contract.check_in(&verifier2, &1, &evidence_hash(&env, 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(); + 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); + + let milestone_count = 10; + let milestone_amount = 100i128; + let total_amount = milestone_amount * (milestone_count as i128); + token_admin_client.mint(&creator, &total_amount); + + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); + + let mut milestones = vec![&env]; + for i in 0..milestone_count { + milestones.push_back(Milestone { + title: String::from_str(&env, "milestone"), + amount: milestone_amount, + due_date: 1_000 + (i as u64 + 1) * 100, + verified: false, + }); + } + + 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( + &vault_id, + &creator, + &verifier_set, + &None, + &token, + &total_amount, + &success, + &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(&vault_id, &creator); + let stake_cpu = env.budget().cpu_instruction_cost(); + let stake_mem = env.budget().memory_bytes_cost(); + + // 3. Measure check_in + env.budget().reset_default(); + contract.check_in(&vault_id, &verifier, &0, &evidence_hash(&env, 1)); + let check_in_cpu = env.budget().cpu_instruction_cost(); + let check_in_mem = env.budget().memory_bytes_cost(); + + // Verify all remaining milestones so we can claim + for i in 1..milestone_count { + contract.check_in(&vault_id, &verifier, &i, &evidence_hash(&env, 1)); + } + + // 4. Measure claim + env.budget().reset_default(); + contract.claim(&vault_id, &creator); + let claim_cpu = env.budget().cpu_instruction_cost(); + let claim_mem = env.budget().memory_bytes_cost(); + + 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); + + assert!(create_cpu < 600_000); + assert!(create_mem < 200_000); + + assert!(stake_cpu < 700_000); + assert!(stake_mem < 200_000); + + assert!(check_in_cpu < 300_000); + assert!(check_in_mem < 100_000); + + assert!(claim_cpu < 900_000); + assert!(claim_mem < 250_000); +} + +// ── monotonic milestone due date tests ─────────────────────────────────────── +#[test] +#[should_panic(expected = "Error::InvalidDeadline")] +fn test_create_vault_duplicate_due_dates_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: 1u32, + }; + + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m1"), + amount: 500, + due_date: 1_200, + verified: false, + released: false, + }, + Milestone { + title: String::from_str(&env, "m2"), + amount: 500, + due_date: 1_200, // Same as previous milestone + verified: false, + released: false, + }, + ]; + + let vault_id = BytesN::from_array(&env, &[1; 32]); + contract.create_vault( + &vault_id, + &creator, + &verifier_set, + &None, + &token, + &1000, + &success, + &failure, + &1_200, + &milestones, + &guardian, + ); +} + +#[test] +#[should_panic(expected = "Error::InvalidDeadline")] +fn test_create_vault_non_monotonic_due_dates_fails() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); - // Stake should only move creator -> vault. - s.contract.stake(&s.vault_id, &s.creator); - assert_token_admin_balance_unchanged(&token_client, &s.token_admin, admin_balance_before); + 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); - // Check-ins should not move any tokens. - s.contract - .check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 7)); - assert_token_admin_balance_unchanged(&token_client, &s.token_admin, admin_balance_before); - s.contract - .check_in(&s.vault_id, &s.verifier, &1, &evidence_hash(&s.env, 9)); - assert_token_admin_balance_unchanged(&token_client, &s.token_admin, admin_balance_before); + let (token, _) = create_token(&env, &token_admin); - // Claim should move vault -> success destination only. - s.contract.claim(&s.vault_id, &s.creator); - assert_token_admin_balance_unchanged(&token_client, &s.token_admin, admin_balance_before); -} + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); -#[test] -fn test_token_admin_balance_invariant_slash_lifecycle() { - let s = setup(&[100], &[500]); - let token_client = token::Client::new(&s.env, &s.token); - let admin_balance_before = token_client.balance(&s.token_admin); + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; - // Stake should only move creator -> vault. - s.contract.stake(&s.vault_id, &s.creator); - assert_token_admin_balance_unchanged(&token_client, &s.token_admin, admin_balance_before); + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m1"), + amount: 500, + due_date: 1_300, + verified: false, + released: false, + }, + Milestone { + title: String::from_str(&env, "m2"), + amount: 500, + due_date: 1_200, // Earlier than previous milestone + verified: false, + released: false, + }, + ]; - // Slash should move vault -> failure destination only. - s.env.ledger().set_timestamp(2_000); - s.contract.slash_on_miss(&s.vault_id); - assert_token_admin_balance_unchanged(&token_client, &s.token_admin, admin_balance_before); + let vault_id = BytesN::from_array(&env, &[1; 32]); + contract.create_vault( + &vault_id, + &creator, + &verifier_set, + &None, + &token, + &1000, + &success, + &failure, + &1_300, + &milestones, + &guardian, + ); } #[test] -fn test_withdraw_draft_cancels() { - let s = setup(&[100], &[500]); - s.contract.cancel_vault(&s.vault_id, &s.creator); - let vault = s.contract.get_vault(&s.vault_id); - assert_eq!(vault.status, VaultStatus::Cancelled); -} +fn test_create_vault_monotonic_due_dates_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); -// ── #489: get_unverified_milestone_indices accessor ─────────────────────────── + 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); -#[test] -fn test_unverified_indices_all_unverified_on_stake() { - let s = setup(&[100, 200, 300], &[200, 300, 500]); - s.contract.stake(&s.vault_id, &s.creator); + let (token, _) = create_token(&env, &token_admin); - let indices = s.contract.get_unverified_milestone_indices(&s.vault_id).unwrap(); - assert_eq!(indices.len(), 3); - assert_eq!(indices.get(0).unwrap(), 0u32); - assert_eq!(indices.get(1).unwrap(), 1u32); - assert_eq!(indices.get(2).unwrap(), 2u32); -} + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); -#[test] -fn test_unverified_indices_partial_verification() { - let s = setup(&[100, 200, 300], &[200, 300, 500]); - s.contract.stake(&s.vault_id, &s.creator); - // Verify milestone 1 only. - s.contract.check_in(&s.vault_id, &s.verifier, &1, &evidence_hash(&s.env, 2)); + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; - let indices = s.contract.get_unverified_milestone_indices(&s.vault_id).unwrap(); - assert_eq!(indices.len(), 2); - assert_eq!(indices.get(0).unwrap(), 0u32); - assert_eq!(indices.get(1).unwrap(), 2u32); -} + let milestones = vec![ + &env, + Milestone { + title: String::from_str(&env, "m1"), + amount: 300, + due_date: 1_100, + verified: false, + released: false, + }, + Milestone { + title: String::from_str(&env, "m2"), + amount: 300, + due_date: 1_150, + verified: false, + released: false, + }, + Milestone { + title: String::from_str(&env, "m3"), + amount: 400, + due_date: 1_200, + verified: false, + released: false, + }, + ]; -#[test] -fn test_unverified_indices_empty_when_all_verified() { - let s = setup(&[100, 200], &[300, 700]); - s.contract.stake(&s.vault_id, &s.creator); - s.contract.check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 1)); - s.contract.check_in(&s.vault_id, &s.verifier, &1, &evidence_hash(&s.env, 2)); + let vault_id = BytesN::from_array(&env, &[1; 32]); + contract.create_vault( + &vault_id, + &creator, + &verifier_set, + &None, + &token, + &1000, + &success, + &failure, + &1_200, + &milestones, + &guardian, + ); - let indices = s.contract.get_unverified_milestone_indices(&s.vault_id).unwrap(); - assert_eq!(indices.len(), 0); + let vault = contract.get_vault(&vault_id); + assert_eq!(vault.status, VaultStatus::Draft); } #[test] -fn test_unverified_indices_not_initialized_returns_error() { +fn test_gas_benchmarks_slash_on_miss_10_milestones() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(AccountabilityVault, ()); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - - let result = contract.try_get_unverified_milestone_indices(&String::from_str(&env, "missing")); - assert!(matches!(result, Err(Ok(Error::NotInitialized)))); -} - -#[test] -fn test_unverified_indices_order_preserved() { - // Verify milestones 0 and 2; expect remaining [1, 3] in ascending order. - let s = setup(&[50, 100, 150, 200], &[100, 200, 300, 400]); - s.contract.stake(&s.vault_id, &s.creator); - s.contract.check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 0)); - s.contract.check_in(&s.vault_id, &s.verifier, &2, &evidence_hash(&s.env, 2)); + env.ledger().set_timestamp(1_000); - let indices = s.contract.get_unverified_milestone_indices(&s.vault_id).unwrap(); - assert_eq!(indices.len(), 2); - assert_eq!(indices.get(0).unwrap(), 1u32); - assert_eq!(indices.get(1).unwrap(), 3u32); -} + 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); -// ── cross-feature: stake_from then oracle check_in then claim ──────────────── + let (token, token_admin_client) = create_token(&env, &token_admin); -#[test] -fn test_cancel_vault_then_stake_rejected_with_not_draft() { - let s = setup(&[100], &[500]); - s.contract.cancel_vault(&s.vault_id, &s.creator); - let vault = s.contract.get_vault(&s.vault_id); - assert_eq!(vault.status, VaultStatus::Cancelled); + let milestone_count = 10; + let milestone_amount = 100i128; + let total_amount = milestone_amount * (milestone_count as i128); + token_admin_client.mint(&creator, &total_amount); - // Terminal-state regression guard: Cancelled vault must never accept stake. - assert_stake_rejected_with_not_draft(&s); -} + let contract_id = env.register_contract(None, AccountabilityVault); + let contract = AccountabilityVaultClient::new(&env, &contract_id); -#[test] -fn test_withdraw_active_refunds_creator() { - let s = setup(&[100], &[500]); - // Fund the vault and then call withdraw without any check-ins. - s.contract.stake(&s.vault_id, &s.creator); + let mut milestones = vec![&env]; + for i in 0..milestone_count { + milestones.push_back(Milestone { + title: String::from_str(&env, "milestone"), + amount: milestone_amount, + due_date: 1_000 + (i as u64 + 1) * 100, + verified: false, + }); + } - s.contract.withdraw(&s.vault_id, &s.creator); - let vault = s.contract.get_vault(&s.vault_id); - assert_eq!(vault.status, VaultStatus::Cancelled); + let end_timestamp = 1_000 + (milestone_count as u64) * 100; + let verifier_set = VerifierSet { + verifiers: vec![&env, verifier.clone()], + threshold: 1u32, + }; - let token_client = token::Client::new(&s.env, &s.token); - assert_eq!(token_client.balance(&s.creator), 500); -} + contract.create_vault( + &vault_id, + &creator, + &verifier_set, + &None, + &token, + &total_amount, + &success, + &failure, + &end_timestamp, + &milestones, + &guardian, + ); -#[test] -fn test_withdraw_cancelled_then_stake_rejected_with_not_draft() { - let s = setup(&[100], &[500]); - s.contract.stake(&s.vault_id, &s.creator); - s.contract.withdraw(&s.vault_id, &s.creator); + contract.stake(&creator); - let vault = s.contract.get_vault(&s.vault_id); - assert_eq!(vault.status, VaultStatus::Cancelled); + // Advance past the overall deadline to allow slash + env.ledger().set_timestamp(end_timestamp + 1); - // Terminal-state regression guard: once cancelled via withdraw, staking is blocked. - assert_stake_rejected_with_not_draft(&s); -} + // Measure slash_on_miss + env.budget().reset_default(); + contract.slash_on_miss(&vault_id); + let slash_cpu = env.budget().cpu_instruction_cost(); + let slash_mem = env.budget().memory_bytes_cost(); -#[test] -#[should_panic] -fn test_claim_before_all_verified_fails() { - let s = setup(&[100, 200], &[300, 700]); - s.contract.stake(&s.vault_id, &s.creator); - s.contract.check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 1)); - // Second milestone not yet verified -> claim must fail. - s.contract.claim(&s.vault_id, &s.creator); -} + std::println!("=== Gas Benchmarks Slash (10 Milestones) ==="); + std::println!("slash_on_miss: CPU = {}, Memory = {}", slash_cpu, slash_mem); -#[test] -#[should_panic] -fn test_slash_before_deadline_fails() { - let s = setup(&[100], &[500]); - s.contract.stake(&s.vault_id, &s.creator); - s.contract.slash_on_miss(&s.vault_id); + assert!(slash_cpu < 900_000); + assert!(slash_mem < 250_000); } +// ── issue #488: symbol-typed event topics ──────────────────────────────────── /// Extracts the first XDR-decoded topic from an emitted event tuple. /// Soroban testutils return events as `(contract_id, topics_vec, data)`. @@ -686,7 +1814,7 @@ fn test_oracle_check_in_source_topic_is_oracle_symbol() { released: false, }, ]; - let vault_id = String::from_str(&env, "v1"); + let vault_id = BytesN::from_array(&env, &[1; 32]); contract.create_vault( &vault_id, &creator, @@ -819,12 +1947,8 @@ fn test_vault_unpaused_emits_symbol_topic() { assert_eq!(actual, Symbol::new(&s.env, "vault_unpaused")); } -// ── security: cap deadline horizon to MAX_DEADLINE_HORIZON (5 years) ────────── - #[test] -#[should_panic] -fn test_create_vault_deadline_exceeds_max_horizon_fails() { - // end_timestamp more than 5 years in the future must be rejected. +fn test_create_vault_rejects_all_zero_salt() { let env = Env::default(); env.mock_all_auths(); env.ledger().set_timestamp(1_000); @@ -850,68 +1974,15 @@ fn test_create_vault_deadline_exceeds_max_horizon_fails() { Milestone { title: String::from_str(&env, "m"), amount: 500, - due_date: 1_000 + MAX_DEADLINE_HORIZON + 1, + due_date: 1_200, verified: false, released: false, }, ]; - let vault_id = String::from_str(&env, "v1"); - // 5 years + 1 second — must fail with InvalidDeadline. - let end_timestamp = 1_000 + MAX_DEADLINE_HORIZON + 1; - contract.create_vault( - &vault_id, - &creator, - &verifier_set, - &None, - &token, - &500, - &success, - &failure, - &end_timestamp, - &milestones, - &guardian, - ); -} - -#[test] -fn test_create_vault_deadline_at_max_horizon_succeeds() { - // end_timestamp exactly at the 5-year boundary should succeed. - 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_000 + MAX_DEADLINE_HORIZON, - verified: false, - released: false, - }, - ]; - let vault_id = String::from_str(&env, "v1"); - // Exactly 5 years — should succeed. - let end_timestamp = 1_000 + MAX_DEADLINE_HORIZON; - contract.create_vault( - &vault_id, + let degenerate_salt = BytesN::from_array(&env, &[0; 32]); + let result = contract.try_create_vault( + °enerate_salt, &creator, &verifier_set, &None, @@ -919,23 +1990,16 @@ fn test_create_vault_deadline_at_max_horizon_succeeds() { &500, &success, &failure, - &end_timestamp, + &1_200, &milestones, &guardian, ); - let vault = contract.get_vault(&vault_id); - assert_eq!(vault.end_timestamp, end_timestamp); - assert_eq!(vault.status, VaultStatus::Draft); + assert!(matches!(result, Err(Ok(Error::InvalidSalt)))); } -// ── security: reject failure_destination equal to creator (slash-to-self) ───── - #[test] -#[should_panic] -fn test_create_vault_failure_destination_equals_creator_fails() { - // Setting failure_destination == creator would nullify accountability: - // a missed deadline simply returns funds to the creator. +fn test_create_vault_rejects_all_ones_salt() { let env = Env::default(); env.mock_all_auths(); env.ledger().set_timestamp(1_000); @@ -944,6 +2008,7 @@ fn test_create_vault_failure_destination_equals_creator_fails() { 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); @@ -965,21 +2030,23 @@ fn test_create_vault_failure_destination_equals_creator_fails() { released: false, }, ]; - let vault_id = String::from_str(&env, "v1"); - // failure_destination is the same as creator — must fail with InvalidFailureDestination. - contract.create_vault( - &vault_id, + + let degenerate_salt = BytesN::from_array(&env, &[0xff; 32]); + let result = contract.try_create_vault( + °enerate_salt, &creator, &verifier_set, &None, &token, &500, &success, - &creator, + &failure, &1_200, &milestones, &guardian, ); + + assert!(matches!(result, Err(Ok(Error::InvalidSalt)))); } #[test]