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]