diff --git a/contracts/bounty_escrow/contracts/escrow/src/lib.rs b/contracts/bounty_escrow/contracts/escrow/src/lib.rs
index aca8ada0..938cf03d 100644
--- a/contracts/bounty_escrow/contracts/escrow/src/lib.rs
+++ b/contracts/bounty_escrow/contracts/escrow/src/lib.rs
@@ -1,6293 +1,414 @@
-#![no_std]
-
-mod events;
-pub mod gas_budget;
-mod invariants;
-mod multitoken_invariants;
-mod reentrancy_guard;
-#[cfg(test)]
-mod test_boundary_edge_cases;
-mod test_cross_contract_interface;
-#[cfg(test)]
-mod test_deterministic_randomness;
-#[cfg(test)]
-mod test_multi_region_treasury;
-#[cfg(test)]
-mod test_multi_token_fees;
-#[cfg(test)]
-mod test_rbac;
-#[cfg(test)]
-mod test_renew_rollover;
-#[cfg(test)]
-mod test_risk_flags;
-mod traits;
-pub mod upgrade_safety;
-
-#[cfg(test)]
-mod test_frozen_balance;
-#[cfg(test)]
-mod test_reentrancy_guard;
-
-use crate::events::{
- emit_batch_funds_locked, emit_batch_funds_released, emit_bounty_initialized,
- emit_deprecation_state_changed, emit_deterministic_selection, emit_funds_locked,
- emit_funds_locked_anon, emit_funds_refunded, emit_funds_released,
- emit_maintenance_mode_changed, emit_notification_preferences_updated,
- emit_participant_filter_mode_changed, emit_risk_flags_updated, emit_ticket_claimed,
- emit_ticket_issued, BatchFundsLocked, BatchFundsReleased, BountyEscrowInitialized,
- ClaimCancelled, ClaimCreated, ClaimExecuted, CriticalOperationOutcome, DeprecationStateChanged,
- DeterministicSelectionDerived, FundsLocked, FundsLockedAnon, FundsRefunded, FundsReleased,
- MaintenanceModeChanged, NotificationPreferencesUpdated, ParticipantFilterModeChanged,
- RefundTriggerType, RiskFlagsUpdated, TicketClaimed, TicketIssued, EVENT_VERSION_V2,
-};
-use soroban_sdk::xdr::ToXdr;
-use soroban_sdk::{
- contract, contracterror, contractimpl, contracttype, symbol_short, token, vec, Address, Bytes,
- BytesN, Env, String, Symbol, Vec,
-};
-
-// ============================================================================
-// INPUT VALIDATION MODULE
-// ============================================================================
-
-/// Validation rules for human-readable identifiers to prevent malicious or confusing inputs.
-///
-/// This module provides consistent validation across all contracts for:
-/// - Bounty types and metadata
-/// - Any user-provided string identifiers
-///
-/// Rules enforced:
-/// - Maximum length limits to prevent UI/log issues
-/// - Allowed character sets (alphanumeric, spaces, safe punctuation)
-/// - No control characters that could cause display issues
-/// - No leading/trailing whitespace
-mod validation {
- use soroban_sdk::Env;
-
- /// Maximum length for bounty types and short identifiers
- const MAX_TAG_LEN: u32 = 50;
-
- /// Validates a tag, type, or short identifier.
- ///
- /// # Arguments
- /// * `env` - The contract environment
- /// * `tag` - The tag string to validate
- /// * `field_name` - Name of the field for error messages
- ///
- /// # Panics
- /// Panics if validation fails with a descriptive error message.
- pub fn validate_tag(_env: &Env, tag: &soroban_sdk::String, field_name: &str) {
- if tag.len() > MAX_TAG_LEN {
- panic!(
- "{} exceeds maximum length of {} characters",
- field_name, MAX_TAG_LEN
- );
- }
-
- // Tags should not be empty if provided
- if tag.len() == 0 {
- panic!("{} cannot be empty", field_name);
- }
- // Additional character validation can be added when SDK supports it
- }
-}
-
-mod monitoring {
- use soroban_sdk::{contracttype, symbol_short, Address, Env, String, Symbol};
-
- // Storage keys
- #[allow(dead_code)]
- const OPERATION_COUNT: &str = "op_count";
- #[allow(dead_code)]
- const USER_COUNT: &str = "usr_count";
- #[allow(dead_code)]
- const ERROR_COUNT: &str = "err_count";
-
- // Event: Operation metric
- #[contracttype]
- #[derive(Clone, Debug)]
- pub struct OperationMetric {
- pub operation: Symbol,
- pub caller: Address,
- pub timestamp: u64,
- pub success: bool,
- }
-
- // Event: Performance metric
- #[contracttype]
- #[derive(Clone, Debug)]
- pub struct PerformanceMetric {
- pub function: Symbol,
- pub duration: u64,
- pub timestamp: u64,
- }
-
- // Data: Health status
- #[contracttype]
- #[derive(Clone, Debug)]
- pub struct HealthStatus {
- pub is_healthy: bool,
- pub last_operation: u64,
- pub total_operations: u64,
- pub contract_version: String,
- }
-
- // Data: Analytics
- #[contracttype]
- #[derive(Clone, Debug)]
- pub struct Analytics {
- pub operation_count: u64,
- pub unique_users: u64,
- pub error_count: u64,
- pub error_rate: u32,
- }
-
- // Data: State snapshot
- #[contracttype]
- #[derive(Clone, Debug)]
- pub struct StateSnapshot {
- pub timestamp: u64,
- pub total_operations: u64,
- pub total_users: u64,
- pub total_errors: u64,
- }
-
- // Data: Performance stats
- #[contracttype]
- #[derive(Clone, Debug)]
- pub struct PerformanceStats {
- pub function_name: Symbol,
- pub call_count: u64,
- pub total_time: u64,
- pub avg_time: u64,
- pub last_called: u64,
- }
-
- // Track operation
- #[allow(dead_code)]
- pub fn track_operation(env: &Env, operation: Symbol, caller: Address, success: bool) {
- let key = Symbol::new(env, OPERATION_COUNT);
- let count: u64 = env.storage().persistent().get(&key).unwrap_or(0);
- env.storage().persistent().set(&key, &(count + 1));
-
- if !success {
- let err_key = Symbol::new(env, ERROR_COUNT);
- let err_count: u64 = env.storage().persistent().get(&err_key).unwrap_or(0);
- env.storage().persistent().set(&err_key, &(err_count + 1));
- }
-
- env.events().publish(
- (symbol_short!("metric"), symbol_short!("op")),
- OperationMetric {
- operation,
- caller,
- timestamp: env.ledger().timestamp(),
- success,
- },
- );
- }
-
- // Track performance
- #[allow(dead_code)]
- pub fn emit_performance(env: &Env, function: Symbol, duration: u64) {
- let count_key = (Symbol::new(env, "perf_cnt"), function.clone());
- let time_key = (Symbol::new(env, "perf_time"), function.clone());
-
- let count: u64 = env.storage().persistent().get(&count_key).unwrap_or(0);
- let total: u64 = env.storage().persistent().get(&time_key).unwrap_or(0);
-
- env.storage().persistent().set(&count_key, &(count + 1));
- env.storage()
- .persistent()
- .set(&time_key, &(total + duration));
-
- env.events().publish(
- (symbol_short!("metric"), symbol_short!("perf")),
- PerformanceMetric {
- function,
- duration,
- timestamp: env.ledger().timestamp(),
- },
- );
- }
-
- // Health check
- #[allow(dead_code)]
- pub fn health_check(env: &Env) -> HealthStatus {
- let key = Symbol::new(env, OPERATION_COUNT);
- let ops: u64 = env.storage().persistent().get(&key).unwrap_or(0);
-
- HealthStatus {
- is_healthy: true,
- last_operation: env.ledger().timestamp(),
- total_operations: ops,
- contract_version: String::from_str(env, "1.0.0"),
- }
- }
-
- // Get analytics
- #[allow(dead_code)]
- pub fn get_analytics(env: &Env) -> Analytics {
- let op_key = Symbol::new(env, OPERATION_COUNT);
- let usr_key = Symbol::new(env, USER_COUNT);
- let err_key = Symbol::new(env, ERROR_COUNT);
-
- let ops: u64 = env.storage().persistent().get(&op_key).unwrap_or(0);
- let users: u64 = env.storage().persistent().get(&usr_key).unwrap_or(0);
- let errors: u64 = env.storage().persistent().get(&err_key).unwrap_or(0);
-
- let error_rate = if ops > 0 {
- ((errors as u128 * 10000) / ops as u128) as u32
- } else {
- 0
- };
-
- Analytics {
- operation_count: ops,
- unique_users: users,
- error_count: errors,
- error_rate,
- }
- }
-
- // Get state snapshot
- #[allow(dead_code)]
- pub fn get_state_snapshot(env: &Env) -> StateSnapshot {
- let op_key = Symbol::new(env, OPERATION_COUNT);
- let usr_key = Symbol::new(env, USER_COUNT);
- let err_key = Symbol::new(env, ERROR_COUNT);
-
- StateSnapshot {
- timestamp: env.ledger().timestamp(),
- total_operations: env.storage().persistent().get(&op_key).unwrap_or(0),
- total_users: env.storage().persistent().get(&usr_key).unwrap_or(0),
- total_errors: env.storage().persistent().get(&err_key).unwrap_or(0),
- }
- }
-
- // Get performance stats
- #[allow(dead_code)]
- pub fn get_performance_stats(env: &Env, function_name: Symbol) -> PerformanceStats {
- let count_key = (Symbol::new(env, "perf_cnt"), function_name.clone());
- let time_key = (Symbol::new(env, "perf_time"), function_name.clone());
- let last_key = (Symbol::new(env, "perf_last"), function_name.clone());
-
- let count: u64 = env.storage().persistent().get(&count_key).unwrap_or(0);
- let total: u64 = env.storage().persistent().get(&time_key).unwrap_or(0);
- let last: u64 = env.storage().persistent().get(&last_key).unwrap_or(0);
-
- let avg = if count > 0 { total / count } else { 0 };
-
- PerformanceStats {
- function_name,
- call_count: count,
- total_time: total,
- avg_time: avg,
- last_called: last,
- }
- }
-}
-
-mod anti_abuse {
- use soroban_sdk::{contracttype, symbol_short, Address, Env};
-
- #[contracttype]
- #[derive(Clone, Debug, Eq, PartialEq)]
- pub struct AntiAbuseConfig {
- pub window_size: u64, // Window size in seconds
- pub max_operations: u32, // Max operations allowed in window
- pub cooldown_period: u64, // Minimum seconds between operations
- }
-
- #[contracttype]
- #[derive(Clone, Debug, Eq, PartialEq)]
- pub struct AddressState {
- pub last_operation_timestamp: u64,
- pub window_start_timestamp: u64,
- pub operation_count: u32,
- }
-
- #[contracttype]
- #[derive(Clone, Debug, Eq, PartialEq)]
- pub enum AntiAbuseKey {
- Config,
- State(Address),
- Whitelist(Address),
- Blocklist(Address),
- Admin,
- }
-
- pub fn get_config(env: &Env) -> AntiAbuseConfig {
- env.storage()
- .instance()
- .get(&AntiAbuseKey::Config)
- .unwrap_or(AntiAbuseConfig {
- window_size: 3600, // 1 hour default
- max_operations: 100,
- cooldown_period: 60, // 1 minute default
- })
- }
-
- #[allow(dead_code)]
- pub fn set_config(env: &Env, config: AntiAbuseConfig) {
- env.storage().instance().set(&AntiAbuseKey::Config, &config);
- }
-
- pub fn is_whitelisted(env: &Env, address: Address) -> bool {
- env.storage()
- .instance()
- .has(&AntiAbuseKey::Whitelist(address))
- }
-
- pub fn set_whitelist(env: &Env, address: Address, whitelisted: bool) {
- if whitelisted {
- env.storage()
- .instance()
- .set(&AntiAbuseKey::Whitelist(address), &true);
- } else {
- env.storage()
- .instance()
- .remove(&AntiAbuseKey::Whitelist(address));
- }
- }
-
- pub fn is_blocklisted(env: &Env, address: Address) -> bool {
- env.storage()
- .instance()
- .has(&AntiAbuseKey::Blocklist(address))
- }
-
- pub fn set_blocklist(env: &Env, address: Address, blocked: bool) {
- if blocked {
- env.storage()
- .instance()
- .set(&AntiAbuseKey::Blocklist(address), &true);
- } else {
- env.storage()
- .instance()
- .remove(&AntiAbuseKey::Blocklist(address));
- }
- }
-
- pub fn get_admin(env: &Env) -> Option
{
- env.storage().instance().get(&AntiAbuseKey::Admin)
- }
-
- pub fn set_admin(env: &Env, admin: Address) {
- env.storage().instance().set(&AntiAbuseKey::Admin, &admin);
- }
-
- pub fn check_rate_limit(env: &Env, address: Address) {
- if is_whitelisted(env, address.clone()) {
- return;
- }
-
- let config = get_config(env);
- let now = env.ledger().timestamp();
- let key = AntiAbuseKey::State(address.clone());
-
- let mut state: AddressState =
- env.storage()
- .persistent()
- .get(&key)
- .unwrap_or(AddressState {
- last_operation_timestamp: 0,
- window_start_timestamp: now,
- operation_count: 0,
- });
-
- // 1. Cooldown check
- if state.last_operation_timestamp > 0
- && now
- < state
- .last_operation_timestamp
- .saturating_add(config.cooldown_period)
- {
- env.events().publish(
- (symbol_short!("abuse"), symbol_short!("cooldown")),
- (address.clone(), now),
- );
- panic!("Operation in cooldown period");
- }
-
- // 2. Window check
- if now
- >= state
- .window_start_timestamp
- .saturating_add(config.window_size)
- {
- // New window
- state.window_start_timestamp = now;
- state.operation_count = 1;
- } else {
- // Same window
- if state.operation_count >= config.max_operations {
- env.events().publish(
- (symbol_short!("abuse"), symbol_short!("limit")),
- (address.clone(), now),
- );
- panic!("Rate limit exceeded");
- }
- state.operation_count += 1;
- }
-
- state.last_operation_timestamp = now;
- env.storage().persistent().set(&key, &state);
-
- // Extend TTL for state (approx 1 day)
- env.storage().persistent().extend_ttl(&key, 17280, 17280);
- }
-}
-
-/// Role-Based Access Control (RBAC) helpers.
-///
-/// # Role Matrix
-///
-/// | Action | Admin | Operator (anti-abuse admin) | Participant (depositor) |
-/// |-------------------------|-------|-----------------------------|-------------------------|
-/// | `init` | ✓ | ✗ | ✗ |
-/// | `set_paused` | ✓ | ✗ | ✗ |
-/// | `emergency_withdraw` | ✓ | ✗ | ✗ |
-/// | `update_fee_config` | ✓ | ✗ | ✗ |
-/// | `set_maintenance_mode` | ✓ | ✗ | ✗ |
-/// | `set_deprecated` | ✓ | ✗ | ✗ |
-/// | `release_funds` | ✓ | ✗ | ✗ |
-/// | `approve_refund` | ✓ | ✗ | ✗ |
-/// | `partial_release` | ✓ | ✗ | ✗ |
-/// | `set_anti_abuse_admin` | ✓ | ✗ | ✗ |
-/// | `set_whitelist_entry` | ✓ | ✓ (via anti-abuse admin) | ✗ |
-/// | `set_blocklist_entry` | ✓ | ✓ (via anti-abuse admin) | ✗ |
-/// | `set_filter_mode` | ✓ | ✗ | ✗ |
-/// | `update_anti_abuse_cfg` | ✓ | ✗ | ✗ |
-/// | `lock_funds` | ✗ | ✗ | ✓ (self only) |
-/// | `refund` | ✓+✓ | ✗ | ✓ (co-sign) |
-///
-/// # Security Invariants
-/// - No privilege escalation: operators cannot call admin-only functions.
-/// - No cross-call escalation: a participant cannot trigger admin actions indirectly.
-/// - `refund` requires both admin AND depositor signatures (dual-auth).
-pub mod rbac {
- use soroban_sdk::{Address, Env};
-
- use crate::DataKey;
-
- /// Returns the stored admin address, panicking if not initialized.
- pub fn require_admin(env: &Env) -> Address {
- env.storage()
- .instance()
- .get::(&DataKey::Admin)
- .expect("contract not initialized")
- }
-
- /// Asserts that `caller` is the stored admin. Panics otherwise.
- pub fn assert_admin(env: &Env, caller: &Address) {
- let admin = require_admin(env);
- assert_eq!(&admin, caller, "caller is not admin");
- caller.require_auth();
- }
-
- /// Returns `true` if `addr` is the stored admin.
- pub fn is_admin(env: &Env, addr: &Address) -> bool {
- env.storage()
- .instance()
- .get::(&DataKey::Admin)
- .map(|a| &a == addr)
- .unwrap_or(false)
- }
-
- /// Returns `true` if `addr` is the stored anti-abuse (operator) admin.
- pub fn is_operator(env: &Env, addr: &Address) -> bool {
- use crate::anti_abuse;
- anti_abuse::get_admin(env)
- .map(|a| &a == addr)
- .unwrap_or(false)
- }
-}
-
-#[allow(dead_code)]
-const BASIS_POINTS: i128 = 10_000;
-const MAX_FEE_RATE: i128 = 5_000; // 50% max fee
-const MAX_BATCH_SIZE: u32 = 20;
-
-extern crate grainlify_core;
-use grainlify_core::asset;
-use grainlify_core::pseudo_randomness;
-
-#[contracttype]
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
-#[repr(u32)]
-pub enum DisputeOutcome {
- ResolvedInFavorOfContributor = 1,
- ResolvedInFavorOfDepositor = 2,
- CancelledByAdmin = 3,
- Refunded = 4,
-}
-
-#[contracttype]
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
-#[repr(u32)]
-pub enum DisputeReason {
- Expired = 1,
- UnsatisfactoryWork = 2,
- Fraud = 3,
- QualityIssue = 4,
- Other = 5,
-}
-
-#[contracttype]
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
-#[repr(u32)]
-pub enum ReleaseType {
- Manual = 1,
- Automatic = 2,
-}
-
-use grainlify_core::errors;
-#[contracterror]
-#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
-#[repr(u32)]
-pub enum Error {
- AlreadyInitialized = 1,
- NotInitialized = 2,
- BountyExists = 201,
- BountyNotFound = 202,
- FundsNotLocked = 203,
- DeadlineNotPassed = 6,
- Unauthorized = 7,
- InvalidFeeRate = 8,
- FeeRecipientNotSet = 9,
- InvalidBatchSize = 10,
- BatchSizeMismatch = 11,
- DuplicateBountyId = 12,
- /// Returned when amount is invalid (zero, negative, or exceeds available)
- InvalidAmount = 13,
- /// Returned when deadline is invalid (in the past or too far in the future)
- InvalidDeadline = 14,
- /// Returned when contract has insufficient funds for the operation
- InsufficientFunds = 16,
- /// Returned when refund is attempted without admin approval
- RefundNotApproved = 17,
- FundsPaused = 18,
- /// Returned when lock amount is below the configured policy minimum (Issue #62)
- AmountBelowMinimum = 19,
- /// Returned when lock amount is above the configured policy maximum (Issue #62)
- AmountAboveMaximum = 20,
- /// Returned when refund is blocked by a pending claim/dispute
- NotPaused = 21,
- ClaimPending = 22,
- /// Returned when claim ticket is not found
- TicketNotFound = 23,
- /// Returned when claim ticket has already been used (replay prevention)
- TicketAlreadyUsed = 24,
- /// Returned when claim ticket has expired
- TicketExpired = 25,
- CapabilityNotFound = 26,
- CapabilityExpired = 27,
- CapabilityRevoked = 28,
- CapabilityActionMismatch = 29,
- CapabilityAmountExceeded = 30,
- CapabilityUsesExhausted = 31,
- CapabilityExceedsAuthority = 32,
- InvalidAssetId = 33,
- /// Returned when new locks/registrations are disabled (contract deprecated)
- ContractDeprecated = 34,
- /// Returned when participant filtering is blocklist-only and the address is blocklisted
- ParticipantBlocked = 35,
- /// Returned when participant filtering is allowlist-only and the address is not allowlisted
- ParticipantNotAllowed = 36,
- /// Refund for anonymous escrow must go through refund_resolved (resolver provides recipient)
- AnonymousRefundRequiresResolution = 39,
- /// Anonymous resolver address not set in instance storage
- AnonymousResolverNotSet = 40,
- /// Bounty exists but is not an anonymous escrow (for refund_resolved)
- NotAnonymousEscrow = 41,
- /// Use get_escrow_info_v2 for anonymous escrows
- UseGetEscrowInfoV2ForAnonymous = 37,
- InvalidSelectionInput = 42,
- /// Returned when an upgrade safety pre-check fails
- UpgradeSafetyCheckFailed = 43,
- /// Returned when an operation's measured CPU or memory consumption exceeds
- /// the configured cap and [`gas_budget::GasBudgetConfig::enforce`] is `true`.
- /// The Soroban host reverts all storage writes and token transfers in the
- /// transaction atomically. Only reachable in test / testutils builds.
- GasBudgetExceeded = 44,
- /// Returned when an escrow is explicitly frozen by an admin hold.
- EscrowFrozen = 45,
- /// Returned when the escrow depositor is explicitly frozen by an admin hold.
- AddressFrozen = 46,
-}
-
-/// Bit flag: escrow or payout should be treated as elevated risk (indexers, UIs).
-pub const RISK_FLAG_HIGH_RISK: u32 = 1 << 0;
-/// Bit flag: manual or automated review is in progress; may restrict certain operations off-chain.
-pub const RISK_FLAG_UNDER_REVIEW: u32 = 1 << 1;
-/// Bit flag: restricted handling (e.g. compliance); informational for integrators.
-pub const RISK_FLAG_RESTRICTED: u32 = 1 << 2;
-/// Bit flag: aligned with soft-deprecation signaling; distinct from contract-level deprecation.
-pub const RISK_FLAG_DEPRECATED: u32 = 1 << 3;
-
-/// Notification preference flags (bitfield).
-pub const NOTIFY_ON_LOCK: u32 = 1 << 0;
-pub const NOTIFY_ON_RELEASE: u32 = 1 << 1;
-pub const NOTIFY_ON_DISPUTE: u32 = 1 << 2;
-pub const NOTIFY_ON_EXPIRATION: u32 = 1 << 3;
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct EscrowMetadata {
- pub repo_id: u64,
- pub issue_id: u64,
- pub bounty_type: soroban_sdk::String,
- pub risk_flags: u32,
- pub notification_prefs: u32,
- pub reference_hash: Option,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum EscrowStatus {
- Locked,
- Released,
- Refunded,
- PartiallyRefunded,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct Escrow {
- pub depositor: Address,
- /// Total amount originally locked into this escrow.
- pub amount: i128,
- /// Amount still available for release; decremented on each partial_release.
- /// Reaches 0 when fully paid out, at which point status becomes Released.
- pub remaining_amount: i128,
- pub status: EscrowStatus,
- pub deadline: u64,
- pub refund_history: Vec,
- pub archived: bool,
- pub archived_at: Option,
-}
-
-/// Mutually exclusive participant filtering mode for lock_funds / batch_lock_funds.
-///
-/// * **Disabled**: No list check; any address may participate (allowlist still used only for anti-abuse bypass).
-/// * **BlocklistOnly**: Only blocklisted addresses are rejected; all others may participate.
-/// * **AllowlistOnly**: Only allowlisted (whitelisted) addresses may participate; all others are rejected.
-#[contracttype]
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum ParticipantFilterMode {
- /// Disable participant filtering. Any depositor may lock funds.
- Disabled = 0,
- /// Reject only addresses present in the blocklist.
- BlocklistOnly = 1,
- /// Accept only addresses present in the allowlist.
- AllowlistOnly = 2,
-}
-
-/// Kill-switch state: when deprecated is true, new escrows are blocked; existing escrows can complete or migrate.
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct DeprecationState {
- pub deprecated: bool,
- pub migration_target: Option,
-}
-
-/// View type for deprecation status (exposed to clients).
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct DeprecationStatus {
- pub deprecated: bool,
- pub migration_target: Option,
-}
-
-/// Anonymous escrow: only a 32-byte depositor commitment is stored on-chain.
-/// Refunds require the configured resolver to call `refund_resolved(bounty_id, recipient)`.
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AnonymousEscrow {
- pub depositor_commitment: BytesN<32>,
- pub amount: i128,
- pub remaining_amount: i128,
- pub status: EscrowStatus,
- pub deadline: u64,
- pub refund_history: Vec,
- pub archived: bool,
- pub archived_at: Option,
-}
-
-/// Depositor identity: either a concrete address (non-anon) or a 32-byte commitment (anon).
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum AnonymousParty {
- Address(Address),
- Commitment(BytesN<32>),
-}
-
-/// Unified escrow view: exposes either address or commitment for depositor.
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct EscrowInfo {
- pub depositor: AnonymousParty,
- pub amount: i128,
- pub remaining_amount: i128,
- pub status: EscrowStatus,
- pub deadline: u64,
- pub refund_history: Vec,
-}
-
-/// Immutable audit record for an escrow-level or address-level freeze.
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct FreezeRecord {
- pub frozen: bool,
- pub reason: Option,
- pub frozen_at: u64,
- pub frozen_by: Address,
-}
-
-#[contracttype]
-pub enum DataKey {
- Admin,
- Token,
- Version,
- Escrow(u64), // bounty_id
- EscrowAnon(u64), // bounty_id anonymous escrow variant
- Metadata(u64),
- EscrowIndex, // Vec of all bounty_ids
- DepositorIndex(Address), // Vec of bounty_ids by depositor
- EscrowFreeze(u64), // bounty_id -> FreezeRecord
- AddressFreeze(Address), // address -> FreezeRecord
- FeeConfig, // Fee configuration
- RefundApproval(u64), // bounty_id -> RefundApproval
- ReentrancyGuard,
- MultisigConfig,
- ReleaseApproval(u64), // bounty_id -> ReleaseApproval
- PendingClaim(u64), // bounty_id -> ClaimRecord
- TicketCounter, // monotonic claim ticket id
- ClaimTicket(u64), // ticket_id -> ClaimTicket
- ClaimTicketIndex, // Vec all ticket ids
- BeneficiaryTickets(Address), // beneficiary -> Vec
- ClaimWindow, // u64 seconds (global config)
- PauseFlags, // PauseFlags struct
- AmountPolicy, // Option<(i128, i128)> — (min_amount, max_amount) set by set_amount_policy
- CapabilityNonce, // monotonically increasing capability id
- Capability(BytesN<32>), // capability_id -> Capability
-
- /// Marks a bounty escrow as using non-transferable (soulbound) reward tokens.
- /// When set, the token is expected to disallow further transfers after claim.
- NonTransferableRewards(u64), // bounty_id -> bool
-
- /// Kill switch: when set, new escrows are blocked; existing escrows can complete or migrate
- DeprecationState,
- /// Participant filter mode: Disabled | BlocklistOnly | AllowlistOnly (default Disabled)
- ParticipantFilterMode,
-
- /// Address of the resolver that may authorize refunds for anonymous escrows
- AnonymousResolver,
-
- /// Chain identifier (e.g., "stellar", "ethereum") for cross-network protection
- /// Per-token fee configuration keyed by token contract address.
- TokenFeeConfig(Address),
- ChainId,
- NetworkId,
-
- MaintenanceMode, // bool flag
- /// Per-operation gas budget caps configured by the admin.
- /// See [`gas_budget::GasBudgetConfig`].
- GasBudgetConfig,
- /// Per-bounty renewal history (`Vec`).
- RenewalHistory(u64),
- /// Per-bounty rollover chain link metadata.
- CycleLink(u64),
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct EscrowWithId {
- pub bounty_id: u64,
- pub escrow: Escrow,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct PauseFlags {
- pub lock_paused: bool,
- pub release_paused: bool,
- pub refund_paused: bool,
- pub pause_reason: Option,
- pub paused_at: u64,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AggregateStats {
- pub total_locked: i128,
- pub total_released: i128,
- pub total_refunded: i128,
- pub count_locked: u32,
- pub count_released: u32,
- pub count_refunded: u32,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct PauseStateChanged {
- pub operation: Symbol,
- pub paused: bool,
- pub admin: Address,
- pub reason: Option,
- pub timestamp: u64,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-/// Public view of anti-abuse config (rate limit and cooldown).
-pub struct AntiAbuseConfigView {
- pub window_size: u64,
- pub max_operations: u32,
- pub cooldown_period: u64,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-/// Treasury routing destination used for weighted multi-region fee distribution.
-///
-/// The `weight` field is interpreted relative to the sum of all configured
-/// destination weights. Fee routing is deterministic: each destination receives
-/// a proportional share and any rounding remainder is assigned to the final
-/// destination in the configured order so accounting remains exact.
-pub struct TreasuryDestination {
- /// Treasury wallet that receives routed fees.
- pub address: Address,
- /// Relative routing weight. Must be greater than zero when configured.
- pub weight: u32,
- /// Human-readable treasury region or routing label.
- pub region: String,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct FeeConfig {
- /// Fee rate charged when funds are locked, expressed in basis points.
- pub lock_fee_rate: i128,
- /// Fee rate charged when funds are released, expressed in basis points.
- pub release_fee_rate: i128,
- /// Flat fee (token smallest units) added on each lock, before cap to deposit amount.
- pub lock_fixed_fee: i128,
- /// Flat fee added on each full release or partial payout, before cap to payout amount.
- pub release_fixed_fee: i128,
- pub fee_recipient: Address,
- /// Whether fee collection is enabled.
- pub fee_enabled: bool,
- /// Weighted treasury destinations used for multi-region routing.
- pub treasury_destinations: Vec,
- /// Whether multi-region treasury routing is enabled.
- pub distribution_enabled: bool,
-}
-
-/// Per-token fee configuration.
-///
-/// Allows different fee rates and recipients for each accepted token type.
-/// When present, overrides the global `FeeConfig` for that specific token.
-///
-/// # Rounding protection
-/// Fee amounts are always rounded **up** (ceiling division) so that
-/// fractional stroops never reduce the fee to zero. This prevents a
-/// depositor from splitting a large deposit into many dust transactions
-/// where floor-division would yield fee == 0 on every individual call.
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct TokenFeeConfig {
- /// Fee rate on lock, in basis points (1 bp = 0.01 %).
- pub lock_fee_rate: i128,
- /// Fee rate on release, in basis points.
- pub release_fee_rate: i128,
- pub lock_fixed_fee: i128,
- pub release_fixed_fee: i128,
- /// Address that receives fees collected for this token.
- pub fee_recipient: Address,
- /// Whether fee collection is active for this token.
- pub fee_enabled: bool,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct MultisigConfig {
- pub threshold_amount: i128,
- pub signers: Vec,
- pub required_signatures: u32,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ReleaseApproval {
- pub bounty_id: u64,
- pub contributor: Address,
- pub approvals: Vec,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ClaimRecord {
- pub bounty_id: u64,
- pub recipient: Address,
- pub amount: i128,
- pub expires_at: u64,
- pub claimed: bool,
- pub reason: DisputeReason,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ClaimTicket {
- pub ticket_id: u64,
- pub bounty_id: u64,
- pub beneficiary: Address,
- pub amount: i128,
- pub expires_at: u64,
- pub used: bool,
- pub issued_at: u64,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum CapabilityAction {
- Claim,
- Release,
- Refund,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct Capability {
- pub owner: Address,
- pub holder: Address,
- pub action: CapabilityAction,
- pub bounty_id: u64,
- pub amount_limit: i128,
- pub remaining_amount: i128,
- pub expiry: u64,
- pub remaining_uses: u32,
- pub revoked: bool,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum RefundMode {
- Full,
- Partial,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct RefundApproval {
- pub bounty_id: u64,
- pub amount: i128,
- pub recipient: Address,
- pub mode: RefundMode,
- pub approved_by: Address,
- pub approved_at: u64,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct RefundRecord {
- pub amount: i128,
- pub recipient: Address,
- pub timestamp: u64,
- pub mode: RefundMode,
-}
-
-/// Immutable record of one successful escrow renewal.
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct RenewalRecord {
- /// Monotonic renewal sequence for this bounty (`1..=n`).
- pub cycle: u32,
- /// Previous deadline before renewal.
- pub old_deadline: u64,
- /// New deadline after renewal.
- pub new_deadline: u64,
- /// Additional funds deposited during renewal (`0` when extension-only).
- pub additional_amount: i128,
- /// Ledger timestamp when renewal was applied.
- pub renewed_at: u64,
-}
-
-/// Link metadata connecting bounty cycles in a rollover chain.
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct CycleLink {
- /// Previous bounty id in the chain (`0` for chain root).
- pub previous_id: u64,
- /// Next bounty id in the chain (`0` when no successor exists).
- pub next_id: u64,
- /// Zero-based chain depth for stored links (`0` root, `1` first successor, ...).
- pub cycle: u32,
-}
-
-/// A single escrow entry to lock within a [`BountyEscrowContract::batch_lock_funds`] call.
-///
-/// All items in a batch are sorted by ascending `bounty_id` before processing to ensure
-/// deterministic execution order. If any item fails validation, the entire batch reverts.
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct LockFundsItem {
- /// Unique identifier for the bounty. Must not already exist in persistent storage
- /// and must not appear more than once within the same batch (`DuplicateBountyId`).
- pub bounty_id: u64,
- /// Address of the depositor. Tokens are transferred **from** this address.
- /// `require_auth()` is called once per unique depositor across the batch.
- pub depositor: Address,
- /// Gross amount (in token base units) to lock into escrow. Must be `> 0`.
- /// If an `AmountPolicy` is active, the value must fall within `[min_amount, max_amount]`.
- pub amount: i128,
- /// Unix timestamp (seconds) after which the depositor may claim a refund
- /// without requiring admin approval. Must be in the future at lock time.
- pub deadline: u64,
-}
-
-/// A single escrow release entry within a [`BountyEscrowContract::batch_release_funds`] call.
-///
-/// All items in a batch are sorted by ascending `bounty_id` before processing to ensure
-/// deterministic execution order. If any item fails validation, the entire batch reverts.
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ReleaseFundsItem {
- /// Identifier of the bounty to release. The escrow record must exist (`BountyNotFound`)
- /// and must be in `Locked` status (`FundsNotLocked`).
- pub bounty_id: u64,
- /// Address of the contributor who will receive the released tokens.
- pub contributor: Address,
-}
-
-/// Result of a dry-run simulation. Indicates whether the operation would succeed
-/// and the resulting state without mutating storage or performing transfers.
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct SimulationResult {
- pub success: bool,
- pub error_code: u32,
- pub amount: i128,
- pub resulting_status: EscrowStatus,
- pub remaining_amount: i128,
-}
-
-#[contract]
-pub struct BountyEscrowContract;
-
-#[contractimpl]
-impl BountyEscrowContract {
- pub fn health_check(env: Env) -> monitoring::HealthStatus {
- monitoring::health_check(&env)
- }
-
- pub fn get_analytics(env: Env) -> monitoring::Analytics {
- monitoring::get_analytics(&env)
- }
-
- pub fn get_state_snapshot(env: Env) -> monitoring::StateSnapshot {
- monitoring::get_state_snapshot(&env)
- }
-
- fn order_batch_lock_items(env: &Env, items: &Vec) -> Vec {
- let mut ordered: Vec = Vec::new(env);
- for item in items.iter() {
- let mut next: Vec = Vec::new(env);
- let mut inserted = false;
- for existing in ordered.iter() {
- if !inserted && item.bounty_id < existing.bounty_id {
- next.push_back(item.clone());
- inserted = true;
- }
- next.push_back(existing);
- }
- if !inserted {
- next.push_back(item.clone());
- }
- ordered = next;
- }
- ordered
- }
-
- fn order_batch_release_items(
- env: &Env,
- items: &Vec,
- ) -> Vec {
- let mut ordered: Vec = Vec::new(env);
- for item in items.iter() {
- let mut next: Vec = Vec::new(env);
- let mut inserted = false;
- for existing in ordered.iter() {
- if !inserted && item.bounty_id < existing.bounty_id {
- next.push_back(item.clone());
- inserted = true;
- }
- next.push_back(existing);
- }
- if !inserted {
- next.push_back(item.clone());
- }
- ordered = next;
- }
- ordered
- }
-
- /// Initialize the contract with the admin address and the token address (XLM).
- pub fn init(env: Env, admin: Address, token: Address) -> Result<(), Error> {
- if env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::AlreadyInitialized);
- }
- if admin == token {
- return Err(Error::Unauthorized);
- }
- env.storage().instance().set(&DataKey::Admin, &admin);
- env.storage().instance().set(&DataKey::Token, &token);
- // Version 2 reflects the breaking shared-trait interface alignment.
- env.storage().instance().set(&DataKey::Version, &2u32);
-
- events::emit_bounty_initialized(
- &env,
- events::BountyEscrowInitialized {
- version: EVENT_VERSION_V2,
- admin,
- token,
- timestamp: env.ledger().timestamp(),
- },
- );
- Ok(())
- }
-
- pub fn init_with_network(
- env: Env,
- admin: Address,
- token: Address,
- chain_id: soroban_sdk::String,
- network_id: soroban_sdk::String,
- ) -> Result<(), Error> {
- Self::init(env.clone(), admin, token)?;
- env.storage().instance().set(&DataKey::ChainId, &chain_id);
- env.storage()
- .instance()
- .set(&DataKey::NetworkId, &network_id);
- Ok(())
- }
-
- pub fn get_chain_id(env: Env) -> Option {
- env.storage().instance().get(&DataKey::ChainId)
- }
-
- pub fn get_network_id(env: Env) -> Option {
- env.storage().instance().get(&DataKey::NetworkId)
- }
-
- pub fn get_network_info(
- env: Env,
- ) -> (Option, Option) {
- (Self::get_chain_id(env.clone()), Self::get_network_id(env))
- }
-
- /// Return the persisted contract version.
- pub fn get_version(env: Env) -> u32 {
- env.storage().instance().get(&DataKey::Version).unwrap_or(0)
- }
-
- /// Update the persisted contract version (admin only).
- pub fn set_version(env: Env, new_version: u32) -> Result<(), Error> {
- let admin: Address = env
- .storage()
- .instance()
- .get(&DataKey::Admin)
- .ok_or(Error::NotInitialized)?;
- admin.require_auth();
- env.storage()
- .instance()
- .set(&DataKey::Version, &new_version);
- Ok(())
- }
-
- /// Calculate fee amount based on rate (in basis points), using **ceiling division**.
- ///
- /// Ceiling division ensures that a non-zero fee rate always produces at least
- /// 1 stroop of fee, regardless of how small the individual amount is. This
- /// closes the principal-drain vector where an attacker breaks a large deposit
- /// into dust amounts that each round down to a zero fee.
- ///
- /// Formula: ceil(amount * fee_rate / BASIS_POINTS)
- /// = (amount * fee_rate + BASIS_POINTS - 1) / BASIS_POINTS
- ///
- /// # Panics
- /// Returns 0 on arithmetic overflow rather than panicking.
- fn calculate_fee(amount: i128, fee_rate: i128) -> i128 {
- if fee_rate == 0 || amount == 0 {
- return 0;
- }
- // Ceiling integer division: (a + b - 1) / b
- let numerator = amount
- .checked_mul(fee_rate)
- .and_then(|x| x.checked_add(BASIS_POINTS - 1))
- .unwrap_or(0);
- if numerator == 0 {
- return 0;
- }
- numerator / BASIS_POINTS
- }
-
- /// Total fee on `amount`: ceiling percentage plus optional fixed, capped at `amount`.
- fn combined_fee_amount(amount: i128, rate_bps: i128, fixed: i128, fee_enabled: bool) -> i128 {
- if !fee_enabled || amount <= 0 {
- return 0;
- }
- if fixed < 0 {
- return 0;
- }
- let pct = Self::calculate_fee(amount, rate_bps);
- let sum = pct.saturating_add(fixed);
- sum.min(amount).max(0)
- }
-
- /// Test-only shim exposing `calculate_fee` for unit-level assertions.
- #[cfg(test)]
- pub fn calculate_fee_pub(amount: i128, fee_rate: i128) -> i128 {
- Self::calculate_fee(amount, fee_rate)
- }
-
- /// Test-only: combined percentage + fixed fee (capped).
- #[cfg(test)]
- pub fn combined_fee_pub(amount: i128, rate_bps: i128, fixed: i128, fee_enabled: bool) -> i128 {
- Self::combined_fee_amount(amount, rate_bps, fixed, fee_enabled)
- }
-
- /// Get fee configuration (internal helper)
- fn get_fee_config_internal(env: &Env) -> FeeConfig {
- env.storage()
- .instance()
- .get(&DataKey::FeeConfig)
- .unwrap_or_else(|| FeeConfig {
- lock_fee_rate: 0,
- release_fee_rate: 0,
- lock_fixed_fee: 0,
- release_fixed_fee: 0,
- fee_recipient: env.storage().instance().get(&DataKey::Admin).unwrap(),
- fee_enabled: false,
- treasury_destinations: Vec::new(env),
- distribution_enabled: false,
- })
- }
-
- /// Validates treasury destinations before enabling multi-region routing.
- fn validate_treasury_destinations(
- _env: &Env,
- destinations: &Vec,
- distribution_enabled: bool,
- ) -> Result<(), Error> {
- if !distribution_enabled {
- return Ok(());
- }
-
- if destinations.is_empty() {
- return Err(Error::InvalidAmount);
- }
-
- let mut total_weight: u64 = 0;
- for destination in destinations.iter() {
- if destination.weight == 0 {
- return Err(Error::InvalidAmount);
- }
-
- if destination.region.is_empty() || destination.region.len() > 50 {
- return Err(Error::InvalidAmount);
- }
-
- total_weight = total_weight
- .checked_add(destination.weight as u64)
- .ok_or(Error::InvalidAmount)?;
- }
-
- if total_weight == 0 {
- return Err(Error::InvalidAmount);
- }
-
- Ok(())
- }
-
- /// Routes a fee either to the configured fee recipient or across weighted treasury routes.
- fn route_fee(
- env: &Env,
- client: &token::Client,
- config: &FeeConfig,
- amount: i128,
- fee_rate: i128,
- operation_type: events::FeeOperationType,
- ) -> Result<(), Error> {
- if amount <= 0 {
- return Ok(());
- }
-
- let fee_fixed = match operation_type {
- events::FeeOperationType::Lock => config.lock_fixed_fee,
- events::FeeOperationType::Release => config.release_fixed_fee,
- };
-
- if !config.distribution_enabled || config.treasury_destinations.is_empty() {
- client.transfer(
- &env.current_contract_address(),
- &config.fee_recipient,
- &amount,
- );
- events::emit_fee_collected(
- env,
- events::FeeCollected {
- version: EVENT_VERSION_V2,
- operation_type,
- amount,
- fee_rate,
- fee_fixed,
- recipient: config.fee_recipient.clone(),
- timestamp: env.ledger().timestamp(),
- },
- );
- return Ok(());
- }
-
- let mut total_weight: u64 = 0;
- for destination in config.treasury_destinations.iter() {
- total_weight = total_weight
- .checked_add(destination.weight as u64)
- .ok_or(Error::InvalidAmount)?;
- }
- if total_weight == 0 {
- return Err(Error::InvalidAmount);
- }
-
- let mut distributed = 0i128;
- let destination_count = config.treasury_destinations.len() as usize;
-
- for (index, destination) in config.treasury_destinations.iter().enumerate() {
- let share = if index + 1 == destination_count {
- amount
- .checked_sub(distributed)
- .ok_or(Error::InvalidAmount)?
- } else {
- amount
- .checked_mul(destination.weight as i128)
- .and_then(|v| v.checked_div(total_weight as i128))
- .ok_or(Error::InvalidAmount)?
- };
-
- distributed = distributed.checked_add(share).ok_or(Error::InvalidAmount)?;
- if share <= 0 {
- continue;
- }
-
- client.transfer(
- &env.current_contract_address(),
- &destination.address,
- &share,
- );
- events::emit_fee_collected(
- env,
- events::FeeCollected {
- version: EVENT_VERSION_V2,
- operation_type: operation_type.clone(),
- amount: share,
- fee_rate,
- fee_fixed,
- recipient: destination.address,
- timestamp: env.ledger().timestamp(),
- },
- );
- }
-
- Ok(())
- }
-
- /// Update fee configuration (admin only)
- pub fn update_fee_config(
- env: Env,
- lock_fee_rate: Option,
- release_fee_rate: Option,
- lock_fixed_fee: Option,
- release_fixed_fee: Option,
- fee_recipient: Option,
- fee_enabled: Option,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
-
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- let mut fee_config = Self::get_fee_config_internal(&env);
-
- if let Some(rate) = lock_fee_rate {
- if !(0..=MAX_FEE_RATE).contains(&rate) {
- return Err(Error::InvalidFeeRate);
- }
- fee_config.lock_fee_rate = rate;
- }
-
- if let Some(rate) = release_fee_rate {
- if !(0..=MAX_FEE_RATE).contains(&rate) {
- return Err(Error::InvalidFeeRate);
- }
- fee_config.release_fee_rate = rate;
- }
-
- if let Some(fixed) = lock_fixed_fee {
- if fixed < 0 {
- return Err(Error::InvalidAmount);
- }
- fee_config.lock_fixed_fee = fixed;
- }
-
- if let Some(fixed) = release_fixed_fee {
- if fixed < 0 {
- return Err(Error::InvalidAmount);
- }
- fee_config.release_fixed_fee = fixed;
- }
-
- if let Some(recipient) = fee_recipient {
- fee_config.fee_recipient = recipient;
- }
-
- if let Some(enabled) = fee_enabled {
- fee_config.fee_enabled = enabled;
- }
-
- env.storage()
- .instance()
- .set(&DataKey::FeeConfig, &fee_config);
-
- events::emit_fee_config_updated(
- &env,
- events::FeeConfigUpdated {
- version: EVENT_VERSION_V2,
- lock_fee_rate: fee_config.lock_fee_rate,
- release_fee_rate: fee_config.release_fee_rate,
- lock_fixed_fee: fee_config.lock_fixed_fee,
- release_fixed_fee: fee_config.release_fixed_fee,
- fee_recipient: fee_config.fee_recipient.clone(),
- fee_enabled: fee_config.fee_enabled,
- timestamp: env.ledger().timestamp(),
- },
- );
-
- Ok(())
- }
-
- /// Configures weighted treasury destinations for multi-region fee routing.
- ///
- /// When enabled, collected lock and release fees are routed proportionally
- /// across `destinations` instead of sending the full amount to
- /// `fee_recipient`. Disabled routing preserves the configured destinations
- /// but falls back to the single-recipient path until re-enabled.
- pub fn set_treasury_distributions(
- env: Env,
- destinations: Vec,
- distribution_enabled: bool,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
-
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- Self::validate_treasury_destinations(&env, &destinations, distribution_enabled)?;
-
- let mut fee_config = Self::get_fee_config_internal(&env);
- fee_config.treasury_destinations = destinations;
- fee_config.distribution_enabled = distribution_enabled;
-
- env.storage()
- .instance()
- .set(&DataKey::FeeConfig, &fee_config);
-
- Ok(())
- }
-
- /// Returns the current treasury routing configuration.
- pub fn get_treasury_distributions(env: Env) -> (Vec, bool) {
- let fee_config = Self::get_fee_config_internal(&env);
- (
- fee_config.treasury_destinations,
- fee_config.distribution_enabled,
- )
- }
-
- /// Updates the granular pause state and metadata for the contract.
- ///
- /// # Arguments
- /// * `lock` - If Some(true), prevents new escrows from being created.
- /// * `release` - If Some(true), prevents payouts to contributors.
- /// * `refund` - If Some(true), prevents depositors from reclaiming funds.
- /// * `reason` - Optional UTF-8 string describing why the state was changed.
- ///
- /// # Errors
- /// Returns `Error::NotInitialized` if the admin has not been set.
- /// Returns `Error::Unauthorized` if the caller is not the registered admin.
- pub fn set_paused(
- env: Env,
- lock: Option,
- release: Option,
- refund: Option,
- reason: Option,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
-
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- let mut flags = Self::get_pause_flags(&env);
- let timestamp = env.ledger().timestamp();
-
- if reason.is_some() {
- flags.pause_reason = reason.clone();
- }
-
- if let Some(paused) = lock {
- flags.lock_paused = paused;
- events::emit_pause_state_changed(
- &env,
- PauseStateChanged {
- operation: symbol_short!("lock"),
- paused,
- admin: admin.clone(),
- reason: reason.clone(),
- timestamp,
- },
- );
- }
-
- if let Some(paused) = release {
- flags.release_paused = paused;
- events::emit_pause_state_changed(
- &env,
- PauseStateChanged {
- operation: symbol_short!("release"),
- paused,
- admin: admin.clone(),
- reason: reason.clone(),
- timestamp,
- },
- );
- }
-
- if let Some(paused) = refund {
- flags.refund_paused = paused;
- events::emit_pause_state_changed(
- &env,
- PauseStateChanged {
- operation: symbol_short!("refund"),
- paused,
- admin: admin.clone(),
- reason: reason.clone(),
- timestamp,
- },
- );
- }
-
- let any_paused = flags.lock_paused || flags.release_paused || flags.refund_paused;
-
- if any_paused {
- if flags.paused_at == 0 {
- flags.paused_at = timestamp;
- }
- } else {
- flags.pause_reason = None;
- flags.paused_at = 0;
- }
-
- env.storage().instance().set(&DataKey::PauseFlags, &flags);
- Ok(())
- }
-
- /// Drains all reward tokens from the contract to a target address.
- ///
- /// This is an emergency recovery function and should only be used as a last resort.
- /// The contract MUST have `lock_paused = true` before calling this.
- ///
- /// # Arguments
- /// * `target` - The address that will receive the full contract balance.
- ///
- /// # Errors
- /// Returns `Error::NotPaused` if `lock_paused` is false.
- /// Returns `Error::Unauthorized` if the caller is not the admin.
- pub fn emergency_withdraw(env: Env, target: Address) -> Result<(), Error> {
- // GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
-
- let admin: Address = env
- .storage()
- .instance()
- .get(&DataKey::Admin)
- .ok_or(Error::NotInitialized)?;
- admin.require_auth();
-
- let flags = Self::get_pause_flags(&env);
- if !flags.lock_paused {
- return Err(Error::NotPaused);
- }
-
- let token_address: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let token_client = token::TokenClient::new(&env, &token_address);
-
- let contract_address = env.current_contract_address();
- let balance = token_client.balance(&contract_address);
-
- if balance > 0 {
- token_client.transfer(&contract_address, &target, &balance);
- events::emit_emergency_withdraw(
- &env,
- events::EmergencyWithdrawEvent {
- version: EVENT_VERSION_V2,
- admin,
- recipient: target,
- amount: balance,
- timestamp: env.ledger().timestamp(),
- },
- );
- }
-
- // GUARD: release reentrancy lock
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Returns current deprecation state (internal). When deprecated is true, new locks are blocked.
- fn get_deprecation_state(env: &Env) -> DeprecationState {
- env.storage()
- .instance()
- .get(&DataKey::DeprecationState)
- .unwrap_or(DeprecationState {
- deprecated: false,
- migration_target: None,
- })
- }
-
- fn get_participant_filter_mode(env: &Env) -> ParticipantFilterMode {
- env.storage()
- .instance()
- .get(&DataKey::ParticipantFilterMode)
- .unwrap_or(ParticipantFilterMode::Disabled)
- }
-
- /// Enforces participant filtering: returns Err if the address is not allowed to participate
- /// (lock_funds / batch_lock_funds) under the current filter mode.
- fn check_participant_filter(env: &Env, address: Address) -> Result<(), Error> {
- let mode = Self::get_participant_filter_mode(env);
- match mode {
- ParticipantFilterMode::Disabled => Ok(()),
- ParticipantFilterMode::BlocklistOnly => {
- if anti_abuse::is_blocklisted(env, address) {
- return Err(Error::ParticipantBlocked);
- }
- Ok(())
- }
- ParticipantFilterMode::AllowlistOnly => {
- if !anti_abuse::is_whitelisted(env, address) {
- return Err(Error::ParticipantNotAllowed);
- }
- Ok(())
- }
- }
- }
-
- /// Set deprecation (kill switch) and optional migration target. Admin only.
- /// When deprecated is true: new lock_funds and batch_lock_funds are blocked; existing escrows
- /// can still release, refund, or be migrated off-chain. Emits DeprecationStateChanged.
- pub fn set_deprecated(
- env: Env,
- deprecated: bool,
- migration_target: Option,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- let state = DeprecationState {
- deprecated,
- migration_target: migration_target.clone(),
- };
- env.storage()
- .instance()
- .set(&DataKey::DeprecationState, &state);
- emit_deprecation_state_changed(
- &env,
- DeprecationStateChanged {
- deprecated: state.deprecated,
- migration_target: state.migration_target,
- admin,
- timestamp: env.ledger().timestamp(),
- },
- );
- Ok(())
- }
-
- /// View: returns whether the contract is deprecated and the optional migration target address.
- pub fn get_deprecation_status(env: Env) -> DeprecationStatus {
- let s = Self::get_deprecation_state(&env);
- DeprecationStatus {
- deprecated: s.deprecated,
- migration_target: s.migration_target,
- }
- }
-
- /// Get current pause flags
- pub fn get_pause_flags(env: &Env) -> PauseFlags {
- env.storage()
- .instance()
- .get(&DataKey::PauseFlags)
- .unwrap_or(PauseFlags {
- lock_paused: false,
- release_paused: false,
- refund_paused: false,
- pause_reason: None,
- paused_at: 0,
- })
- }
-
- fn get_escrow_freeze_record_internal(env: &Env, bounty_id: u64) -> Option {
- env.storage()
- .persistent()
- .get(&DataKey::EscrowFreeze(bounty_id))
- }
-
- fn get_address_freeze_record_internal(env: &Env, address: &Address) -> Option {
- env.storage()
- .persistent()
- .get(&DataKey::AddressFreeze(address.clone()))
- }
-
- fn ensure_escrow_not_frozen(env: &Env, bounty_id: u64) -> Result<(), Error> {
- if Self::get_escrow_freeze_record_internal(env, bounty_id)
- .map(|record| record.frozen)
- .unwrap_or(false)
- {
- return Err(Error::EscrowFrozen);
- }
- Ok(())
- }
-
- fn ensure_address_not_frozen(env: &Env, address: &Address) -> Result<(), Error> {
- if Self::get_address_freeze_record_internal(env, address)
- .map(|record| record.frozen)
- .unwrap_or(false)
- {
- return Err(Error::AddressFrozen);
- }
- Ok(())
- }
-
- /// Check if an operation is paused
- fn check_paused(env: &Env, operation: Symbol) -> bool {
- let flags = Self::get_pause_flags(env);
- if operation == symbol_short!("lock") {
- if Self::is_maintenance_mode(env.clone()) {
- return true;
- }
- return flags.lock_paused;
- } else if operation == symbol_short!("release") {
- return flags.release_paused;
- } else if operation == symbol_short!("refund") {
- return flags.refund_paused;
- }
- false
- }
-
- /// Freeze a specific escrow so release and refund paths fail before any token transfer.
- ///
- /// Read-only queries remain available while the freeze is active.
- pub fn freeze_escrow(
- env: Env,
- bounty_id: u64,
- reason: Option,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id))
- && !env
- .storage()
- .persistent()
- .has(&DataKey::EscrowAnon(bounty_id))
- {
- return Err(Error::BountyNotFound);
- }
-
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- let record = FreezeRecord {
- frozen: true,
- reason,
- frozen_at: env.ledger().timestamp(),
- frozen_by: admin,
- };
- env.storage()
- .persistent()
- .set(&DataKey::EscrowFreeze(bounty_id), &record);
- env.events()
- .publish((symbol_short!("frzesc"), bounty_id), record);
- Ok(())
- }
-
- /// Remove an escrow-level freeze and restore normal release/refund behavior.
- pub fn unfreeze_escrow(env: Env, bounty_id: u64) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id))
- && !env
- .storage()
- .persistent()
- .has(&DataKey::EscrowAnon(bounty_id))
- {
- return Err(Error::BountyNotFound);
- }
-
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- env.storage()
- .persistent()
- .remove(&DataKey::EscrowFreeze(bounty_id));
- env.events().publish(
- (symbol_short!("unfrzes"), bounty_id),
- (admin, env.ledger().timestamp()),
- );
- Ok(())
- }
-
- /// Return the current escrow-level freeze record, if one exists.
- pub fn get_escrow_freeze_record(env: Env, bounty_id: u64) -> Option {
- Self::get_escrow_freeze_record_internal(&env, bounty_id)
- }
-
- /// Freeze all release/refund operations for escrows owned by `address`.
- ///
- /// Read-only queries remain available while the freeze is active.
- pub fn freeze_address(
- env: Env,
- address: Address,
- reason: Option,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- let record = FreezeRecord {
- frozen: true,
- reason,
- frozen_at: env.ledger().timestamp(),
- frozen_by: admin,
- };
- env.storage()
- .persistent()
- .set(&DataKey::AddressFreeze(address.clone()), &record);
- env.events()
- .publish((symbol_short!("frzaddr"), address), record);
- Ok(())
- }
-
- /// Remove an address-level freeze and restore normal release/refund behavior.
- pub fn unfreeze_address(env: Env, address: Address) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- env.storage()
- .persistent()
- .remove(&DataKey::AddressFreeze(address.clone()));
- env.events().publish(
- (symbol_short!("unfrzad"), address),
- (admin, env.ledger().timestamp()),
- );
- Ok(())
- }
-
- /// Return the current address-level freeze record, if one exists.
- pub fn get_address_freeze_record(env: Env, address: Address) -> Option {
- Self::get_address_freeze_record_internal(&env, &address)
- }
-
- /// Check if the contract is in maintenance mode
- pub fn is_maintenance_mode(env: Env) -> bool {
- env.storage()
- .instance()
- .get(&DataKey::MaintenanceMode)
- .unwrap_or(false)
- }
-
- /// Update maintenance mode (admin only)
- pub fn set_maintenance_mode(env: Env, enabled: bool) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- env.storage()
- .instance()
- .set(&DataKey::MaintenanceMode, &enabled);
-
- events::emit_maintenance_mode_changed(
- &env,
- MaintenanceModeChanged {
- enabled,
- admin: admin.clone(),
- timestamp: env.ledger().timestamp(),
- },
- );
- Ok(())
- }
-
- pub fn set_whitelist(env: Env, address: Address, whitelisted: bool) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- anti_abuse::set_whitelist(&env, address, whitelisted);
- Ok(())
- }
-
- fn next_capability_id(env: &Env) -> BytesN<32> {
- let mut id = [0u8; 32];
- let r1: u64 = env.prng().gen();
- let r2: u64 = env.prng().gen();
- let r3: u64 = env.prng().gen();
- let r4: u64 = env.prng().gen();
- id[0..8].copy_from_slice(&r1.to_be_bytes());
- id[8..16].copy_from_slice(&r2.to_be_bytes());
- id[16..24].copy_from_slice(&r3.to_be_bytes());
- id[24..32].copy_from_slice(&r4.to_be_bytes());
- BytesN::from_array(env, &id)
- }
-
- fn record_receipt(
- _env: &Env,
- _outcome: CriticalOperationOutcome,
- _bounty_id: u64,
- _amount: i128,
- _recipient: Address,
- ) {
- // Backward-compatible no-op until receipt storage/events are fully wired.
- }
-
- fn load_capability(env: &Env, capability_id: BytesN<32>) -> Result {
- env.storage()
- .persistent()
- .get(&DataKey::Capability(capability_id.clone()))
- .ok_or(Error::CapabilityNotFound)
- }
-
- fn validate_capability_scope_at_issue(
- env: &Env,
- owner: &Address,
- action: &CapabilityAction,
- bounty_id: u64,
- amount_limit: i128,
- ) -> Result<(), Error> {
- if amount_limit <= 0 {
- return Err(Error::InvalidAmount);
- }
-
- match action {
- CapabilityAction::Claim => {
- let claim: ClaimRecord = env
- .storage()
- .persistent()
- .get(&DataKey::PendingClaim(bounty_id))
- .ok_or(Error::BountyNotFound)?;
- if claim.claimed {
- return Err(Error::FundsNotLocked);
- }
- if env.ledger().timestamp() > claim.expires_at {
- return Err(Error::DeadlineNotPassed);
- }
- if claim.recipient != owner.clone() {
- return Err(Error::Unauthorized);
- }
- if amount_limit > claim.amount {
- return Err(Error::CapabilityExceedsAuthority);
- }
- }
- CapabilityAction::Release => {
- let admin: Address = env
- .storage()
- .instance()
- .get(&DataKey::Admin)
- .ok_or(Error::NotInitialized)?;
- if admin != owner.clone() {
- return Err(Error::Unauthorized);
- }
- let escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .ok_or(Error::BountyNotFound)?;
- if escrow.status != EscrowStatus::Locked {
- return Err(Error::FundsNotLocked);
- }
- if amount_limit > escrow.remaining_amount {
- return Err(Error::CapabilityExceedsAuthority);
- }
- }
- CapabilityAction::Refund => {
- let admin: Address = env
- .storage()
- .instance()
- .get(&DataKey::Admin)
- .ok_or(Error::NotInitialized)?;
- if admin != owner.clone() {
- return Err(Error::Unauthorized);
- }
- let escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .ok_or(Error::BountyNotFound)?;
- if escrow.status != EscrowStatus::Locked
- && escrow.status != EscrowStatus::PartiallyRefunded
- {
- return Err(Error::FundsNotLocked);
- }
- if amount_limit > escrow.remaining_amount {
- return Err(Error::CapabilityExceedsAuthority);
- }
- }
- }
-
- Ok(())
- }
-
- fn ensure_owner_still_authorized(
- env: &Env,
- capability: &Capability,
- requested_amount: i128,
- ) -> Result<(), Error> {
- if requested_amount <= 0 {
- return Err(Error::InvalidAmount);
- }
-
- match capability.action {
- CapabilityAction::Claim => {
- let claim: ClaimRecord = env
- .storage()
- .persistent()
- .get(&DataKey::PendingClaim(capability.bounty_id))
- .ok_or(Error::BountyNotFound)?;
- if claim.claimed {
- return Err(Error::FundsNotLocked);
- }
- if env.ledger().timestamp() > claim.expires_at {
- return Err(Error::DeadlineNotPassed);
- }
- if claim.recipient != capability.owner {
- return Err(Error::Unauthorized);
- }
- if requested_amount > claim.amount {
- return Err(Error::CapabilityExceedsAuthority);
- }
- }
- CapabilityAction::Release => {
- let admin: Address = env
- .storage()
- .instance()
- .get(&DataKey::Admin)
- .ok_or(Error::NotInitialized)?;
- if admin != capability.owner {
- return Err(Error::Unauthorized);
- }
- let escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(capability.bounty_id))
- .ok_or(Error::BountyNotFound)?;
- if escrow.status != EscrowStatus::Locked {
- return Err(Error::FundsNotLocked);
- }
- if requested_amount > escrow.remaining_amount {
- return Err(Error::CapabilityExceedsAuthority);
- }
- }
- CapabilityAction::Refund => {
- let admin: Address = env
- .storage()
- .instance()
- .get(&DataKey::Admin)
- .ok_or(Error::NotInitialized)?;
- if admin != capability.owner {
- return Err(Error::Unauthorized);
- }
- let escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(capability.bounty_id))
- .ok_or(Error::BountyNotFound)?;
- if escrow.status != EscrowStatus::Locked
- && escrow.status != EscrowStatus::PartiallyRefunded
- {
- return Err(Error::FundsNotLocked);
- }
- if requested_amount > escrow.remaining_amount {
- return Err(Error::CapabilityExceedsAuthority);
- }
- }
- }
- Ok(())
- }
-
- /// Validates and consumes a capability token for a specific action.
- ///
- /// The capability token must be a secure `BytesN<32>` identifier explicitly issued
- /// to the requested `holder` for the requested `bounty_id` and `expected_action`.
- /// Consuming a capability securely updates its internal balance and usage counts,
- /// protecting against replay attacks or brute-force forgery.
- ///
- /// # Arguments
- /// * `env` - The contract environment
- /// * `holder` - The address attempting to consume the capability
- /// * `capability_id` - The `BytesN<32>` unforgeable token identifier
- /// * `expected_action` - The required action mapped to this capability
- /// * `bounty_id` - The bounty ID relating to the action
- /// * `amount` - The transaction value requested during this consumption limit
- ///
- /// # Returns
- /// The updated `Capability` struct successfully verified, or an `Error`.
- fn consume_capability(
- env: &Env,
- holder: &Address,
- capability_id: BytesN<32>,
- expected_action: CapabilityAction,
- bounty_id: u64,
- amount: i128,
- ) -> Result {
- let mut capability = Self::load_capability(env, capability_id.clone())?;
-
- if capability.revoked {
- return Err(Error::CapabilityRevoked);
- }
- if capability.action != expected_action {
- return Err(Error::CapabilityActionMismatch);
- }
- if capability.bounty_id != bounty_id {
- return Err(Error::CapabilityActionMismatch);
- }
- if capability.holder != holder.clone() {
- return Err(Error::Unauthorized);
- }
- if env.ledger().timestamp() > capability.expiry {
- return Err(Error::CapabilityExpired);
- }
- if capability.remaining_uses == 0 {
- return Err(Error::CapabilityUsesExhausted);
- }
- if amount > capability.remaining_amount {
- return Err(Error::CapabilityAmountExceeded);
- }
-
- holder.require_auth();
- Self::ensure_owner_still_authorized(env, &capability, amount)?;
-
- capability.remaining_amount -= amount;
- capability.remaining_uses -= 1;
- env.storage()
- .persistent()
- .set(&DataKey::Capability(capability_id.clone()), &capability);
-
- events::emit_capability_used(
- env,
- events::CapabilityUsed {
- capability_id,
- holder: holder.clone(),
- action: capability.action.clone(),
- bounty_id,
- amount_used: amount,
- remaining_amount: capability.remaining_amount,
- remaining_uses: capability.remaining_uses,
- used_at: env.ledger().timestamp(),
- },
- );
-
- Ok(capability)
- }
-
- /// Issues a new capability token for a specific action on a bounty.
- ///
- /// The capability token is represented by a secure, unforgeable `BytesN<32>` identifier
- /// generated using the Soroban environment's pseudo-random number generator (PRNG).
- /// This ensures that capability tokens cannot be predicted or forged by arbitrary addresses.
- ///
- /// # Arguments
- /// * `env` - The contract environment
- /// * `owner` - The address delegating authority (e.g. the bounty admin or depositor)
- /// * `holder` - The address receiving the capability token
- /// * `action` - The specific action authorized (`Release`, `Refund`, etc.)
- /// * `bounty_id` - The bounty this capability applies to
- /// * `amount_limit` - The maximum amount of funds authorized by this capability
- /// * `expiry` - The ledger timestamp when this capability expires
- /// * `max_uses` - The maximum number of times this capability can be consumed
- ///
- /// # Returns
- /// The generated `BytesN<32>` capability identifier, or an `Error` if issuance fails.
- pub fn issue_capability(
- env: Env,
- owner: Address,
- holder: Address,
- action: CapabilityAction,
- bounty_id: u64,
- amount_limit: i128,
- expiry: u64,
- max_uses: u32,
- ) -> Result, Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- if max_uses == 0 {
- return Err(Error::InvalidAmount);
- }
-
- let now = env.ledger().timestamp();
- if expiry <= now {
- return Err(Error::InvalidDeadline);
- }
-
- owner.require_auth();
- Self::validate_capability_scope_at_issue(&env, &owner, &action, bounty_id, amount_limit)?;
-
- let capability_id = Self::next_capability_id(&env);
- let capability = Capability {
- owner: owner.clone(),
- holder: holder.clone(),
- action: action.clone(),
- bounty_id,
- amount_limit,
- remaining_amount: amount_limit,
- expiry,
- remaining_uses: max_uses,
- revoked: false,
- };
-
- env.storage()
- .persistent()
- .set(&DataKey::Capability(capability_id.clone()), &capability);
-
- events::emit_capability_issued(
- &env,
- events::CapabilityIssued {
- capability_id: capability_id.clone(),
- owner,
- holder,
- action,
- bounty_id,
- amount_limit,
- expires_at: expiry,
- max_uses,
- timestamp: now,
- },
- );
-
- Ok(capability_id.clone())
- }
-
- pub fn revoke_capability(
- env: Env,
- owner: Address,
- capability_id: BytesN<32>,
- ) -> Result<(), Error> {
- let mut capability = Self::load_capability(&env, capability_id.clone())?;
- if capability.owner != owner {
- return Err(Error::Unauthorized);
- }
- owner.require_auth();
-
- if capability.revoked {
- return Ok(());
- }
-
- capability.revoked = true;
- env.storage()
- .persistent()
- .set(&DataKey::Capability(capability_id.clone()), &capability);
-
- events::emit_capability_revoked(
- &env,
- events::CapabilityRevoked {
- capability_id,
- owner,
- revoked_at: env.ledger().timestamp(),
- },
- );
-
- Ok(())
- }
-
- pub fn get_capability(env: Env, capability_id: BytesN<32>) -> Result {
- Self::load_capability(&env, capability_id.clone())
- }
-
- /// Get current fee configuration (view function)
- pub fn get_fee_config(env: Env) -> FeeConfig {
- Self::get_fee_config_internal(&env)
- }
-
- /// Set a per-token fee configuration (admin only).
- ///
- /// When a `TokenFeeConfig` is set for a given token address it takes
- /// precedence over the global `FeeConfig` for all escrows denominated
- /// in that token.
- ///
- /// # Arguments
- /// * `token` – the token contract address this config applies to
- /// * `lock_fee_rate` – fee rate on lock in basis points (0 – 5 000)
- /// * `release_fee_rate` – fee rate on release in basis points (0 – 5 000)
- /// * `lock_fixed_fee` / `release_fixed_fee` – flat fees in token units (≥ 0)
- /// * `fee_recipient` – address that receives fees for this token
- /// * `fee_enabled` – whether fee collection is active
- ///
- /// # Errors
- /// * `NotInitialized` – contract not yet initialised
- /// * `InvalidFeeRate` – any rate is outside `[0, MAX_FEE_RATE]`
- pub fn set_token_fee_config(
- env: Env,
- token: Address,
- lock_fee_rate: i128,
- release_fee_rate: i128,
- lock_fixed_fee: i128,
- release_fixed_fee: i128,
- fee_recipient: Address,
- fee_enabled: bool,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- if !(0..=MAX_FEE_RATE).contains(&lock_fee_rate) {
- return Err(Error::InvalidFeeRate);
- }
- if !(0..=MAX_FEE_RATE).contains(&release_fee_rate) {
- return Err(Error::InvalidFeeRate);
- }
- if lock_fixed_fee < 0 || release_fixed_fee < 0 {
- return Err(Error::InvalidAmount);
- }
-
- let config = TokenFeeConfig {
- lock_fee_rate,
- release_fee_rate,
- lock_fixed_fee,
- release_fixed_fee,
- fee_recipient,
- fee_enabled,
- };
-
- env.storage()
- .instance()
- .set(&DataKey::TokenFeeConfig(token), &config);
-
- Ok(())
- }
-
- /// Get the per-token fee configuration for `token`, if one has been set.
- ///
- /// Returns `None` when no token-specific config exists; callers should
- /// fall back to the global `FeeConfig` in that case.
- pub fn get_token_fee_config(env: Env, token: Address) -> Option {
- env.storage()
- .instance()
- .get(&DataKey::TokenFeeConfig(token))
- }
-
- /// Internal: resolve the effective fee config for the escrow token.
- ///
- /// Precedence: `TokenFeeConfig(token)` > global `FeeConfig`.
- fn resolve_fee_config(env: &Env) -> (i128, i128, i128, i128, Address, bool) {
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- if let Some(tok_cfg) = env
- .storage()
- .instance()
- .get::(&DataKey::TokenFeeConfig(token_addr))
- {
- (
- tok_cfg.lock_fee_rate,
- tok_cfg.release_fee_rate,
- tok_cfg.lock_fixed_fee,
- tok_cfg.release_fixed_fee,
- tok_cfg.fee_recipient,
- tok_cfg.fee_enabled,
- )
- } else {
- let global = Self::get_fee_config_internal(env);
- (
- global.lock_fee_rate,
- global.release_fee_rate,
- global.lock_fixed_fee,
- global.release_fixed_fee,
- global.fee_recipient,
- global.fee_enabled,
- )
- }
- }
-
- /// Update multisig configuration (admin only)
- pub fn update_multisig_config(
- env: Env,
- threshold_amount: i128,
- signers: Vec,
- required_signatures: u32,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
-
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- if required_signatures > signers.len() {
- return Err(Error::InvalidAmount);
- }
-
- let config = MultisigConfig {
- threshold_amount,
- signers,
- required_signatures,
- };
-
- env.storage()
- .instance()
- .set(&DataKey::MultisigConfig, &config);
-
- Ok(())
- }
-
- /// Get multisig configuration
- pub fn get_multisig_config(env: Env) -> MultisigConfig {
- env.storage()
- .instance()
- .get(&DataKey::MultisigConfig)
- .unwrap_or(MultisigConfig {
- threshold_amount: i128::MAX,
- signers: vec![&env],
- required_signatures: 0,
- })
- }
-
- /// Approve release for large amount (requires multisig)
- pub fn approve_large_release(
- env: Env,
- bounty_id: u64,
- contributor: Address,
- approver: Address,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
-
- let multisig_config: MultisigConfig = Self::get_multisig_config(env.clone());
-
- let mut is_signer = false;
- for signer in multisig_config.signers.iter() {
- if signer == approver {
- is_signer = true;
- break;
- }
- }
-
- if !is_signer {
- return Err(Error::Unauthorized);
- }
-
- approver.require_auth();
-
- let approval_key = DataKey::ReleaseApproval(bounty_id);
- let mut approval: ReleaseApproval = env
- .storage()
- .persistent()
- .get(&approval_key)
- .unwrap_or(ReleaseApproval {
- bounty_id,
- contributor: contributor.clone(),
- approvals: vec![&env],
- });
-
- for existing in approval.approvals.iter() {
- if existing == approver {
- return Ok(());
- }
- }
-
- approval.approvals.push_back(approver.clone());
- env.storage().persistent().set(&approval_key, &approval);
-
- events::emit_approval_added(
- &env,
- events::ApprovalAdded {
- version: EVENT_VERSION_V2,
- bounty_id,
- contributor: contributor.clone(),
- approver,
- timestamp: env.ledger().timestamp(),
- },
- );
-
- Ok(())
- }
-
- /// Locks funds for a bounty and records escrow state.
- ///
- /// # Security
- /// - Validation order is deterministic to avoid ambiguous failure behavior under contention.
- /// - Reentrancy guard is acquired before validation and released on completion.
- ///
- /// # Errors
- /// Returns `Error` variants for initialization, policy, authorization, and duplicate-bounty
- /// failures.
- pub fn lock_funds(
- env: Env,
- depositor: Address,
- bounty_id: u64,
- amount: i128,
- deadline: u64,
- ) -> Result<(), Error> {
- let res =
- Self::lock_funds_logic(env.clone(), depositor.clone(), bounty_id, amount, deadline);
- monitoring::track_operation(&env, symbol_short!("lock"), depositor, res.is_ok());
- res
- }
-
- fn lock_funds_logic(
- env: Env,
- depositor: Address,
- bounty_id: u64,
- amount: i128,
- deadline: u64,
- ) -> Result<(), Error> {
- // Validation precedence (deterministic ordering):
- // 1. Reentrancy guard
- // 2. Contract initialized
- // 3. Paused / deprecated (operational state)
- // 4. Participant filter + rate limiting
- // 5. Authorization
- // 6. Input validation (amount policy)
- // 7. Business logic (bounty uniqueness)
-
- // 1. GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
- // Snapshot resource meters for gas cap enforcement (test / testutils only).
- #[cfg(any(test, feature = "testutils"))]
- let gas_snapshot = gas_budget::capture(&env);
-
- // 2. Contract must be initialized before any other check
- if !env.storage().instance().has(&DataKey::Admin) {
- reentrancy_guard::release(&env);
- return Err(Error::NotInitialized);
- }
- soroban_sdk::log!(&env, "admin ok");
-
- // 3. Operational state: paused / deprecated
- if Self::check_paused(&env, symbol_short!("lock")) {
- reentrancy_guard::release(&env);
- return Err(Error::FundsPaused);
- }
- if Self::get_deprecation_state(&env).deprecated {
- reentrancy_guard::release(&env);
- return Err(Error::ContractDeprecated);
- }
- soroban_sdk::log!(&env, "check paused ok");
-
- // 4. Participant filtering and rate limiting
- Self::check_participant_filter(&env, depositor.clone())?;
- soroban_sdk::log!(&env, "start lock_funds");
- anti_abuse::check_rate_limit(&env, depositor.clone());
- soroban_sdk::log!(&env, "rate limit ok");
-
- let _start = env.ledger().timestamp();
- let _caller = depositor.clone();
-
- // 5. Authorization
- depositor.require_auth();
- soroban_sdk::log!(&env, "auth ok");
-
- // 6. Input validation: amount policy
- // Enforce min/max amount policy if one has been configured (Issue #62).
- if let Some((min_amount, max_amount)) = env
- .storage()
- .instance()
- .get::(&DataKey::AmountPolicy)
- {
- if amount < min_amount {
- reentrancy_guard::release(&env);
- return Err(Error::AmountBelowMinimum);
- }
- if amount > max_amount {
- reentrancy_guard::release(&env);
- return Err(Error::AmountAboveMaximum);
- }
- }
- soroban_sdk::log!(&env, "amount policy ok");
-
- // 7. Business logic: bounty must not already exist
- if env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- reentrancy_guard::release(&env);
- return Err(Error::BountyExists);
- }
- soroban_sdk::log!(&env, "bounty exists ok");
-
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- soroban_sdk::log!(&env, "token client ok");
-
- // Transfer full gross amount from depositor to contract first.
- client.transfer(&depositor, &env.current_contract_address(), &amount);
- soroban_sdk::log!(&env, "transfer ok");
-
- // Resolve effective fee config (per-token takes precedence over global).
- let (
- lock_fee_rate,
- _release_fee_rate,
- lock_fixed_fee,
- _release_fixed,
- fee_recipient,
- fee_enabled,
- ) = Self::resolve_fee_config(&env);
- let fee_config = FeeConfig {
- lock_fee_rate,
- release_fee_rate: 0,
- lock_fixed_fee,
- release_fixed_fee: 0,
- fee_recipient: fee_recipient.clone(),
- fee_enabled,
- treasury_destinations: Vec::new(&env),
- distribution_enabled: false,
- };
-
- // Deduct lock fee from the escrowed principal (percentage + fixed, capped at deposit).
- let fee_amount =
- Self::combined_fee_amount(amount, lock_fee_rate, lock_fixed_fee, fee_enabled);
-
- // Net amount stored in escrow after fee.
- // Fee must never exceed the deposit; guard against misconfiguration.
- let net_amount = amount.checked_sub(fee_amount).unwrap_or(amount);
- if net_amount <= 0 {
- return Err(Error::InvalidAmount);
- }
-
- // Transfer fee to recipient immediately (separate transfer so it is
- // visible as a distinct on-chain operation).
- if fee_amount > 0 {
- Self::route_fee(
- &env,
- &client,
- &fee_config,
- fee_amount,
- lock_fee_rate,
- events::FeeOperationType::Lock,
- )?;
- }
- soroban_sdk::log!(&env, "fee ok");
-
- let escrow = Escrow {
- depositor: depositor.clone(),
- amount: net_amount,
- status: EscrowStatus::Locked,
- deadline,
- refund_history: vec![&env],
- remaining_amount: net_amount,
- archived: false,
- archived_at: None,
- };
- invariants::assert_escrow(&env, &escrow);
-
- // Extend the TTL of the storage entry to ensure it lives long enough
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- // Update indexes
- let mut index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::EscrowIndex)
- .unwrap_or(Vec::new(&env));
- index.push_back(bounty_id);
- env.storage()
- .persistent()
- .set(&DataKey::EscrowIndex, &index);
-
- let mut depositor_index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::DepositorIndex(depositor.clone()))
- .unwrap_or(Vec::new(&env));
- depositor_index.push_back(bounty_id);
- env.storage().persistent().set(
- &DataKey::DepositorIndex(depositor.clone()),
- &depositor_index,
- );
-
- // Emit value allows for off-chain indexing
- emit_funds_locked(
- &env,
- FundsLocked {
- version: EVENT_VERSION_V2,
- bounty_id,
- amount,
- depositor: depositor.clone(),
- deadline,
- },
- );
-
- // INV-2: Verify aggregate balance matches token balance after lock
- multitoken_invariants::assert_after_lock(&env);
-
- // Gas budget cap enforcement (test / testutils only; see `gas_budget` module docs).
- #[cfg(any(test, feature = "testutils"))]
- {
- let gas_cfg = gas_budget::get_config(&env);
- if let Err(e) = gas_budget::check(
- &env,
- symbol_short!("lock"),
- &gas_cfg.lock,
- &gas_snapshot,
- gas_cfg.enforce,
- ) {
- reentrancy_guard::release(&env);
- return Err(e);
- }
- }
-
- // GUARD: release reentrancy lock
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Simulate lock operation without state changes or token transfers.
- ///
- /// Returns a `SimulationResult` indicating whether the operation would succeed and the
- /// resulting escrow state. Does not require authorization; safe for off-chain preview.
- ///
- /// # Arguments
- /// * `depositor` - Address that would lock funds
- /// * `bounty_id` - Bounty identifier
- /// * `amount` - Amount to lock
- /// * `deadline` - Deadline timestamp
- ///
- /// # Security
- /// This function performs only read operations. No storage writes, token transfers,
- /// or events are emitted.
- pub fn archive_escrow(env: Env, bounty_id: u64) -> Result<(), Error> {
- let admin = rbac::require_admin(&env);
- admin.require_auth();
-
- let mut escrow = env
- .storage()
- .persistent()
- .get::(&DataKey::Escrow(bounty_id))
- .ok_or(Error::BountyNotFound)?;
-
- escrow.archived = true;
- escrow.archived_at = Some(env.ledger().timestamp());
-
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- // Also check anon escrow
- if let Some(mut anon) = env
- .storage()
- .persistent()
- .get::(&DataKey::EscrowAnon(bounty_id))
- {
- anon.archived = true;
- anon.archived_at = Some(env.ledger().timestamp());
- env.storage()
- .persistent()
- .set(&DataKey::EscrowAnon(bounty_id), &anon);
- }
-
- events::emit_archived(&env, bounty_id, env.ledger().timestamp());
- Ok(())
- }
-
- /// Get all archived escrow IDs.
- pub fn get_archived_escrows(env: Env) -> Vec {
- let index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::EscrowIndex)
- .unwrap_or(Vec::new(&env));
- let mut archived = Vec::new(&env);
- for id in index.iter() {
- if let Some(escrow) = env
- .storage()
- .persistent()
- .get::(&DataKey::Escrow(id))
- {
- if escrow.archived {
- archived.push_back(id);
- }
- } else if let Some(anon) = env
- .storage()
- .persistent()
- .get::(&DataKey::EscrowAnon(id))
- {
- if anon.archived {
- archived.push_back(id);
- }
- }
- }
- archived
- }
-
- /// Simulation of a lock operation.
- pub fn dry_run_lock(
- env: Env,
- depositor: Address,
- bounty_id: u64,
- amount: i128,
- deadline: u64,
- ) -> SimulationResult {
- fn err_result(e: Error) -> SimulationResult {
- SimulationResult {
- success: false,
- error_code: e as u32,
- amount: 0,
- resulting_status: EscrowStatus::Locked,
- remaining_amount: 0,
- }
- }
- match Self::dry_run_lock_impl(&env, depositor, bounty_id, amount, deadline) {
- Ok((net_amount,)) => SimulationResult {
- success: true,
- error_code: 0,
- amount: net_amount,
- resulting_status: EscrowStatus::Locked,
- remaining_amount: net_amount,
- },
- Err(e) => err_result(e),
- }
- }
-
- fn dry_run_lock_impl(
- env: &Env,
- depositor: Address,
- bounty_id: u64,
- amount: i128,
- _deadline: u64,
- ) -> Result<(i128,), Error> {
- // 1. Contract must be initialized
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- // 2. Operational state: paused / deprecated
- if Self::check_paused(env, symbol_short!("lock")) {
- return Err(Error::FundsPaused);
- }
- if Self::get_deprecation_state(env).deprecated {
- return Err(Error::ContractDeprecated);
- }
- // 3. Participant filtering (read-only)
- Self::check_participant_filter(env, depositor.clone())?;
- // 4. Amount policy
- if let Some((min_amount, max_amount)) = env
- .storage()
- .instance()
- .get::(&DataKey::AmountPolicy)
- {
- if amount < min_amount {
- return Err(Error::AmountBelowMinimum);
- }
- if amount > max_amount {
- return Err(Error::AmountAboveMaximum);
- }
- }
- // 5. Bounty must not already exist
- if env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyExists);
- }
- // 6. Amount validation
- if amount <= 0 {
- return Err(Error::InvalidAmount);
- }
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(env, &token_addr);
- // 7. Sufficient balance (read-only)
- let balance = client.balance(&depositor);
- if balance < amount {
- return Err(Error::InsufficientFunds);
- }
- // 8. Fee computation (pure)
- let (
- lock_fee_rate,
- _release_fee_rate,
- lock_fixed_fee,
- _release_fixed,
- _fee_recipient,
- fee_enabled,
- ) = Self::resolve_fee_config(env);
- let fee_amount =
- Self::combined_fee_amount(amount, lock_fee_rate, lock_fixed_fee, fee_enabled);
- let net_amount = amount.checked_sub(fee_amount).unwrap_or(amount);
- if net_amount <= 0 {
- return Err(Error::InvalidAmount);
- }
- Ok((net_amount,))
- }
-
- /// Returns whether the given bounty escrow is marked as using non-transferable (soulbound)
- /// reward tokens. When true, the token is expected to disallow further transfers after claim.
- pub fn get_non_transferable_rewards(env: Env, bounty_id: u64) -> Result {
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
- Ok(env
- .storage()
- .persistent()
- .get(&DataKey::NonTransferableRewards(bounty_id))
- .unwrap_or(false))
- }
-
- /// Lock funds for a bounty in anonymous mode: only a 32-byte depositor commitment is stored.
- /// The depositor must authorize and transfer; their address is used only for the transfer
- /// in this call and is not stored on-chain. Refunds require the configured anonymous
- /// resolver to call `refund_resolved(bounty_id, recipient)`.
- pub fn lock_funds_anonymous(
- env: Env,
- depositor: Address,
- depositor_commitment: BytesN<32>,
- bounty_id: u64,
- amount: i128,
- deadline: u64,
- ) -> Result<(), Error> {
- // Validation precedence (deterministic ordering):
- // 1. Reentrancy guard
- // 2. Contract initialized
- // 3. Paused (operational state)
- // 4. Rate limiting
- // 5. Authorization
- // 6. Business logic (bounty uniqueness, amount policy)
-
- // 1. Reentrancy guard
- reentrancy_guard::acquire(&env);
-
- // 2. Contract must be initialized
- if !env.storage().instance().has(&DataKey::Admin) {
- reentrancy_guard::release(&env);
- return Err(Error::NotInitialized);
- }
-
- // 3. Operational state: paused
- if Self::check_paused(&env, symbol_short!("lock")) {
- reentrancy_guard::release(&env);
- return Err(Error::FundsPaused);
- }
-
- // 4. Rate limiting
- anti_abuse::check_rate_limit(&env, depositor.clone());
-
- // 5. Authorization
- depositor.require_auth();
-
- if env.storage().persistent().has(&DataKey::Escrow(bounty_id))
- || env
- .storage()
- .persistent()
- .has(&DataKey::EscrowAnon(bounty_id))
- {
- reentrancy_guard::release(&env);
- return Err(Error::BountyExists);
- }
-
- if let Some((min_amount, max_amount)) = env
- .storage()
- .instance()
- .get::(&DataKey::AmountPolicy)
- {
- if amount < min_amount {
- reentrancy_guard::release(&env);
- return Err(Error::AmountBelowMinimum);
- }
- if amount > max_amount {
- reentrancy_guard::release(&env);
- return Err(Error::AmountAboveMaximum);
- }
- }
-
- let escrow_anon = AnonymousEscrow {
- depositor_commitment: depositor_commitment.clone(),
- amount,
- remaining_amount: amount,
- status: EscrowStatus::Locked,
- deadline,
- refund_history: vec![&env],
- archived: false,
- archived_at: None,
- };
-
- env.storage()
- .persistent()
- .set(&DataKey::EscrowAnon(bounty_id), &escrow_anon);
-
- let mut index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::EscrowIndex)
- .unwrap_or(Vec::new(&env));
- index.push_back(bounty_id);
- env.storage()
- .persistent()
- .set(&DataKey::EscrowIndex, &index);
-
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- client.transfer(&depositor, &env.current_contract_address(), &amount);
-
- emit_funds_locked_anon(
- &env,
- FundsLockedAnon {
- version: EVENT_VERSION_V2,
- bounty_id,
- amount,
- depositor_commitment,
- deadline,
- },
- );
-
- multitoken_invariants::assert_after_lock(&env);
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Releases escrowed funds to a contributor.
- ///
- /// # Access Control
- /// Admin-only.
- ///
- /// # Front-running Behavior
- /// First valid release for a bounty transitions state to `Released`. Later release/refund/claim
- /// races against that bounty must fail with `Error::FundsNotLocked`.
- ///
- /// # Security
- /// Reentrancy guard is always cleared before any explicit error return after acquisition.
- pub fn publish(env: Env, bounty_id: u64) -> Result<(), Error> {
- let _caller = env
- .storage()
- .instance()
- .get::(&DataKey::Admin)
- .expect("Admin not set");
- Self::publish_logic(env, bounty_id, _caller)
- }
-
- fn publish_logic(env: Env, bounty_id: u64, publisher: Address) -> Result<(), Error> {
- // Validation precedence:
- // 1. Reentrancy guard
- // 2. Authorization (admin only)
- // 3. Escrow exists and is in Draft status
-
- // 1. Acquire reentrancy guard
- reentrancy_guard::acquire(&env);
-
- // 2. Admin authorization
- publisher.require_auth();
-
- // 3. Get escrow and verify it's in Draft status
- let mut escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .ok_or(Error::BountyNotFound)?;
-
- if escrow.status != EscrowStatus::Draft {
- reentrancy_guard::release(&env);
- return Err(Error::ActionNotFound);
- }
-
- // Transition from Draft to Locked
- escrow.status = EscrowStatus::Locked;
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- // Emit EscrowPublished event
- emit_escrow_published(
- &env,
- EscrowPublished {
- version: EVENT_VERSION_V2,
- bounty_id,
- published_by: publisher,
- timestamp: env.ledger().timestamp(),
- },
- );
-
- multitoken_invariants::assert_after_lock(&env);
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Releases escrowed funds to a contributor.
- ///
- /// # Invariants Verified
- /// - INV-ESC-4: Released => remaining_amount == 0
- /// - INV-ESC-7: Aggregate fund conservation (sum(active) == contract.balance)
- ///
- /// # Access Control
- /// Admin-only.
- ///
- /// # Front-running Behavior
- /// First valid release for a bounty transitions state to `Released`. Later release/refund/claim
- /// races against that bounty must fail with `Error::FundsNotLocked`.
- ///
- /// # Transition Guards
- /// This function enforces the following state transition guards:
- ///
- /// ## Pre-conditions (checked in order):
- /// 1. **Reentrancy Guard**: Acquires reentrancy lock to prevent concurrent execution
- /// 2. **Initialization**: Contract must be initialized (admin set)
- /// 3. **Operational State**: Contract must not be paused for release operations
- /// 4. **Authorization**: Admin must authorize the transaction
- /// 5. **Escrow Existence**: Bounty must exist in storage
- /// 6. **Freeze Check**: Escrow and depositor must not be frozen
- /// 7. **Status Guard**: Escrow status must be `Locked` or `PartiallyRefunded`
- ///
- /// ## State Transition:
- /// - **From**: `Locked` or `PartiallyRefunded`
- /// - **To**: `Released`
- /// - **Effect**: Sets `remaining_amount` to 0
- ///
- /// ## Post-conditions:
- /// - External token transfer to contributor (after state update)
- /// - Fee transfer to fee recipient (if applicable)
- /// - Event emission
- ///
- /// ## Contention Safety:
- /// - If status is `Released`, `Refunded`, or `Draft`, returns `Error::FundsNotLocked`
- /// - Reentrancy guard prevents concurrent execution of any protected function
- /// - CEI pattern ensures state is updated before external calls
- ///
- /// # Security
- /// Reentrancy guard is always cleared before any explicit error return after acquisition.
- pub fn release_funds(env: Env, bounty_id: u64, contributor: Address) -> Result<(), Error> {
- let caller = env
- .storage()
- .instance()
- .get::(&DataKey::Admin)
- .unwrap_or(contributor.clone());
- let res = Self::release_funds_logic(env.clone(), bounty_id, contributor);
- monitoring::track_operation(&env, symbol_short!("release"), caller, res.is_ok());
- res
- }
-
- fn release_funds_logic(env: Env, bounty_id: u64, contributor: Address) -> Result<(), Error> {
- // Validation precedence (deterministic ordering):
- // 1. Reentrancy guard
- // 2. Contract initialized
- // 3. Paused (operational state)
- // 4. Authorization
- // 5. Business logic (bounty exists, funds locked)
-
- // 1. GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
-
- // 2. Contract must be initialized
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
-
- // 3. Operational state: paused
- if Self::check_paused(&env, symbol_short!("release")) {
- return Err(Error::FundsPaused);
- }
-
- // 4. Authorization
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- // 5. Business logic: bounty must exist and be locked
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
-
- let mut escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
-
- Self::ensure_escrow_not_frozen(&env, bounty_id)?;
- Self::ensure_address_not_frozen(&env, &escrow.depositor)?;
-
- if escrow.status != EscrowStatus::Locked {
- reentrancy_guard::release(&env);
- return Err(Error::FundsNotLocked);
- }
-
- // Resolve effective fee config for release.
- let (
- _lock_fee_rate,
- release_fee_rate,
- _lock_fixed,
- release_fixed_fee,
- fee_recipient,
- fee_enabled,
- ) = Self::resolve_fee_config(&env);
-
- let release_fee = Self::combined_fee_amount(
- escrow.amount,
- release_fee_rate,
- release_fixed_fee,
- fee_enabled,
- );
- let mut fee_config = Self::get_fee_config_internal(&env);
- fee_config.release_fee_rate = release_fee_rate;
- fee_config.release_fixed_fee = release_fixed_fee;
- fee_config.fee_recipient = fee_recipient.clone();
- fee_config.fee_enabled = fee_enabled;
-
- // Net payout to contributor after release fee.
- let net_payout = escrow
- .amount
- .checked_sub(release_fee)
- .unwrap_or(escrow.amount);
- if net_payout <= 0 {
- reentrancy_guard::release(&env);
- return Err(Error::InvalidAmount);
- }
-
- // EFFECTS: update state before external calls (CEI)
- escrow.status = EscrowStatus::Released;
- escrow.remaining_amount = 0;
- invariants::assert_escrow(&env, &escrow);
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- // INTERACTION: external token transfers are last
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
-
- if release_fee > 0 {
- Self::route_fee(
- &env,
- &client,
- &fee_config,
- release_fee,
- release_fee_rate,
- events::FeeOperationType::Release,
- )?;
- }
-
- client.transfer(&env.current_contract_address(), &contributor, &net_payout);
-
- emit_funds_released(
- &env,
- FundsReleased {
- version: EVENT_VERSION_V2,
- bounty_id,
- amount: escrow.amount,
- recipient: contributor.clone(),
- timestamp: env.ledger().timestamp(),
- },
- );
-
- // GUARD: release reentrancy lock
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Simulate release operation without state changes or token transfers.
- ///
- /// Returns a `SimulationResult` indicating whether the operation would succeed and the
- /// resulting escrow state. Does not require authorization; safe for off-chain preview.
- ///
- /// # Arguments
- /// * `bounty_id` - Bounty identifier
- /// * `contributor` - Recipient address
- ///
- /// # Security
- /// This function performs only read operations. No storage writes, token transfers,
- /// or events are emitted.
- pub fn dry_run_release(env: Env, bounty_id: u64, contributor: Address) -> SimulationResult {
- fn err_result(e: Error) -> SimulationResult {
- SimulationResult {
- success: false,
- error_code: e as u32,
- amount: 0,
- resulting_status: EscrowStatus::Released,
- remaining_amount: 0,
- }
- }
- match Self::dry_run_release_impl(&env, bounty_id, contributor) {
- Ok((amount,)) => SimulationResult {
- success: true,
- error_code: 0,
- amount,
- resulting_status: EscrowStatus::Released,
- remaining_amount: 0,
- },
- Err(e) => err_result(e),
- }
- }
-
- fn dry_run_release_impl(
- env: &Env,
- bounty_id: u64,
- _contributor: Address,
- ) -> Result<(i128,), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- if Self::check_paused(env, symbol_short!("release")) {
- return Err(Error::FundsPaused);
- }
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
- let escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
- Self::ensure_escrow_not_frozen(env, bounty_id)?;
- Self::ensure_address_not_frozen(env, &escrow.depositor)?;
- if escrow.status != EscrowStatus::Locked {
- return Err(Error::FundsNotLocked);
- }
- let (
- _lock_fee_rate,
- release_fee_rate,
- _lock_fixed,
- release_fixed_fee,
- _fee_recipient,
- fee_enabled,
- ) = Self::resolve_fee_config(env);
- let release_fee = Self::combined_fee_amount(
- escrow.amount,
- release_fee_rate,
- release_fixed_fee,
- fee_enabled,
- );
- let net_payout = escrow
- .amount
- .checked_sub(release_fee)
- .unwrap_or(escrow.amount);
- if net_payout <= 0 {
- return Err(Error::InvalidAmount);
- }
- Ok((escrow.amount,))
- }
-
- /// Delegated release flow using a capability instead of admin auth.
- /// The capability amount limit is consumed by `payout_amount`.
- pub fn release_with_capability(
- env: Env,
- bounty_id: u64,
- contributor: Address,
- payout_amount: i128,
- holder: Address,
- capability_id: BytesN<32>,
- ) -> Result<(), Error> {
- // GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
-
- if Self::check_paused(&env, symbol_short!("release")) {
- return Err(Error::FundsPaused);
- }
- if payout_amount <= 0 {
- return Err(Error::InvalidAmount);
- }
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
-
- let mut escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
- Self::ensure_escrow_not_frozen(&env, bounty_id)?;
- Self::ensure_address_not_frozen(&env, &escrow.depositor)?;
- if escrow.status != EscrowStatus::Locked {
- return Err(Error::FundsNotLocked);
- }
- if payout_amount > escrow.remaining_amount {
- return Err(Error::InsufficientFunds);
- }
-
- Self::consume_capability(
- &env,
- &holder,
- capability_id,
- CapabilityAction::Release,
- bounty_id,
- payout_amount,
- )?;
-
- // EFFECTS: update state before external call (CEI)
- escrow.remaining_amount -= payout_amount;
- if escrow.remaining_amount == 0 {
- escrow.status = EscrowStatus::Released;
- }
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- // INTERACTION: external token transfer is last
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- client.transfer(
- &env.current_contract_address(),
- &contributor,
- &payout_amount,
- );
-
- emit_funds_released(
- &env,
- FundsReleased {
- version: EVENT_VERSION_V2,
- bounty_id,
- amount: payout_amount,
- recipient: contributor,
- timestamp: env.ledger().timestamp(),
- },
- );
-
- // GUARD: release reentrancy lock
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Set the claim window duration (admin only).
- /// claim_window: seconds beneficiary has to claim after release is authorized.
- pub fn set_claim_window(env: Env, claim_window: u64) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
- env.storage()
- .instance()
- .set(&DataKey::ClaimWindow, &claim_window);
- Ok(())
- }
-
- /// Authorizes a pending claim instead of immediate transfer.
- ///
- /// # Access Control
- /// Admin-only.
- ///
- /// # Front-running Behavior
- /// Repeated authorizations are overwrite semantics: the latest successful authorization for
- /// a locked bounty replaces the previous pending recipient/record.
- pub fn authorize_claim(
- env: Env,
- bounty_id: u64,
- recipient: Address,
- reason: DisputeReason,
- ) -> Result<(), Error> {
- if Self::check_paused(&env, symbol_short!("release")) {
- return Err(Error::FundsPaused);
- }
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
-
- let escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
-
- Self::ensure_escrow_not_frozen(&env, bounty_id)?;
- Self::ensure_address_not_frozen(&env, &escrow.depositor)?;
-
- if escrow.status != EscrowStatus::Locked {
- return Err(Error::FundsNotLocked);
- }
-
- let now = env.ledger().timestamp();
- let claim_window: u64 = env
- .storage()
- .instance()
- .get(&DataKey::ClaimWindow)
- .unwrap_or(0);
- let claim = ClaimRecord {
- bounty_id,
- recipient: recipient.clone(),
- amount: escrow.amount,
- expires_at: now.saturating_add(claim_window),
- claimed: false,
- reason: reason.clone(),
- };
-
- env.storage()
- .persistent()
- .set(&DataKey::PendingClaim(bounty_id), &claim);
-
- env.events().publish(
- (symbol_short!("claim"), symbol_short!("created")),
- ClaimCreated {
- bounty_id,
- recipient,
- amount: escrow.amount,
- expires_at: claim.expires_at,
- },
- );
- Ok(())
- }
-
- /// Claims an existing pending authorization.
- ///
- /// # Access Control
- /// Only the authorized pending `recipient` can claim.
- ///
- /// # Front-running Behavior
- /// Claim is single-use: once marked claimed and escrow is released, subsequent calls fail.
- pub fn claim(env: Env, bounty_id: u64) -> Result<(), Error> {
- // GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
-
- if Self::check_paused(&env, symbol_short!("release")) {
- return Err(Error::FundsPaused);
- }
- if !env
- .storage()
- .persistent()
- .has(&DataKey::PendingClaim(bounty_id))
- {
- return Err(Error::BountyNotFound);
- }
- let mut claim: ClaimRecord = env
- .storage()
- .persistent()
- .get(&DataKey::PendingClaim(bounty_id))
- .unwrap();
-
- claim.recipient.require_auth();
-
- let now = env.ledger().timestamp();
- if now > claim.expires_at {
- return Err(Error::DeadlineNotPassed); // reuse or add ClaimExpired error
- }
- if claim.claimed {
- return Err(Error::FundsNotLocked);
- }
-
- // EFFECTS: update state before external call (CEI)
- let mut escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
- Self::ensure_escrow_not_frozen(&env, bounty_id)?;
- Self::ensure_address_not_frozen(&env, &escrow.depositor)?;
- escrow.status = EscrowStatus::Released;
- escrow.remaining_amount = 0;
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- claim.claimed = true;
- env.storage()
- .persistent()
- .set(&DataKey::PendingClaim(bounty_id), &claim);
-
- // INTERACTION: external token transfer is last
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- client.transfer(
- &env.current_contract_address(),
- &claim.recipient,
- &claim.amount,
- );
-
- env.events().publish(
- (symbol_short!("claim"), symbol_short!("done")),
- ClaimExecuted {
- bounty_id,
- recipient: claim.recipient.clone(),
- amount: claim.amount,
- claimed_at: now,
- },
- );
-
- // GUARD: release reentrancy lock
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Delegated claim execution using a capability.
- /// Funds are still transferred to the pending claim recipient.
- pub fn claim_with_capability(
- env: Env,
- bounty_id: u64,
- holder: Address,
- capability_id: BytesN<32>,
- ) -> Result<(), Error> {
- // GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
-
- if Self::check_paused(&env, symbol_short!("release")) {
- return Err(Error::FundsPaused);
- }
- if !env
- .storage()
- .persistent()
- .has(&DataKey::PendingClaim(bounty_id))
- {
- return Err(Error::BountyNotFound);
- }
-
- let mut claim: ClaimRecord = env
- .storage()
- .persistent()
- .get(&DataKey::PendingClaim(bounty_id))
- .unwrap();
-
- let now = env.ledger().timestamp();
- if now > claim.expires_at {
- return Err(Error::DeadlineNotPassed);
- }
- if claim.claimed {
- return Err(Error::FundsNotLocked);
- }
-
- Self::consume_capability(
- &env,
- &holder,
- capability_id,
- CapabilityAction::Claim,
- bounty_id,
- claim.amount,
- )?;
-
- // EFFECTS: update state before external call (CEI)
- let mut escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
- Self::ensure_escrow_not_frozen(&env, bounty_id)?;
- Self::ensure_address_not_frozen(&env, &escrow.depositor)?;
- escrow.status = EscrowStatus::Released;
- escrow.remaining_amount = 0;
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- claim.claimed = true;
- env.storage()
- .persistent()
- .set(&DataKey::PendingClaim(bounty_id), &claim);
-
- // INTERACTION: external token transfer is last
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- client.transfer(
- &env.current_contract_address(),
- &claim.recipient,
- &claim.amount,
- );
-
- env.events().publish(
- (symbol_short!("claim"), symbol_short!("done")),
- ClaimExecuted {
- bounty_id,
- recipient: claim.recipient,
- amount: claim.amount,
- claimed_at: now,
- },
- );
-
- // GUARD: release reentrancy lock
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Admin can cancel an expired or unwanted pending claim, returning escrow to Locked.
- pub fn cancel_pending_claim(
- env: Env,
- bounty_id: u64,
- outcome: DisputeOutcome,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- if !env
- .storage()
- .persistent()
- .has(&DataKey::PendingClaim(bounty_id))
- {
- return Err(Error::BountyNotFound);
- }
- let claim: ClaimRecord = env
- .storage()
- .persistent()
- .get(&DataKey::PendingClaim(bounty_id))
- .unwrap();
-
- let now = env.ledger().timestamp(); // Added this line
- let recipient = claim.recipient.clone(); // Added this line
- let amount = claim.amount; // Added this line
-
- env.storage()
- .persistent()
- .remove(&DataKey::PendingClaim(bounty_id));
-
- env.events().publish(
- (symbol_short!("claim"), symbol_short!("cancel")),
- ClaimCancelled {
- bounty_id,
- recipient,
- amount,
- cancelled_at: now,
- cancelled_by: admin,
- },
- );
- Ok(())
- }
-
- /// View: get pending claim for a bounty.
- pub fn get_pending_claim(env: Env, bounty_id: u64) -> Result {
- env.storage()
- .persistent()
- .get(&DataKey::PendingClaim(bounty_id))
- .ok_or(Error::BountyNotFound)
- }
-
- /// Approve a refund before deadline (admin only).
- /// This allows early refunds with admin approval.
- pub fn approve_refund(
- env: Env,
- bounty_id: u64,
- amount: i128,
- recipient: Address,
- mode: RefundMode,
- ) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
-
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
-
- let escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
-
- if escrow.status != EscrowStatus::Locked && escrow.status != EscrowStatus::PartiallyRefunded
- {
- return Err(Error::FundsNotLocked);
- }
-
- if amount <= 0 || amount > escrow.remaining_amount {
- return Err(Error::InvalidAmount);
- }
-
- let approval = RefundApproval {
- bounty_id,
- amount,
- recipient: recipient.clone(),
- mode: mode.clone(),
- approved_by: admin.clone(),
- approved_at: env.ledger().timestamp(),
- };
-
- env.storage()
- .persistent()
- .set(&DataKey::RefundApproval(bounty_id), &approval);
-
- Ok(())
- }
-
- /// Releases a partial amount of locked funds.
- ///
- /// # Access Control
- /// Admin-only.
- ///
- /// # Front-running Behavior
- /// Each successful call decreases `remaining_amount` exactly once. Attempts to exceed remaining
- /// balance fail with `Error::InsufficientFunds`.
- ///
- /// - `payout_amount` must be > 0 and <= `remaining_amount`.
- /// - `remaining_amount` is decremented by `payout_amount` after each call.
- /// - When `remaining_amount` reaches 0 the escrow status is set to Released.
- /// - The bounty stays Locked while any funds remain unreleased.
- pub fn partial_release(
- env: Env,
- bounty_id: u64,
- contributor: Address,
- payout_amount: i128,
- ) -> Result<(), Error> {
- // GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
-
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
-
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
- // Snapshot resource meters for gas cap enforcement (test / testutils only).
- #[cfg(any(test, feature = "testutils"))]
- let gas_snapshot = gas_budget::capture(&env);
-
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
-
- let mut escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
-
- Self::ensure_escrow_not_frozen(&env, bounty_id)?;
- Self::ensure_address_not_frozen(&env, &escrow.depositor)?;
-
- if escrow.status != EscrowStatus::Locked {
- return Err(Error::FundsNotLocked);
- }
-
- // Guard: zero or negative payout makes no sense and would corrupt state
- if payout_amount <= 0 {
- return Err(Error::InvalidAmount);
- }
-
- // Guard: prevent overpayment — payout cannot exceed what is still owed
- if payout_amount > escrow.remaining_amount {
- return Err(Error::InsufficientFunds);
- }
-
- // EFFECTS: update state before external call (CEI)
- // Decrement remaining; this is always an exact integer subtraction — no rounding
- escrow.remaining_amount = escrow.remaining_amount.checked_sub(payout_amount).unwrap();
-
- // Automatically transition to Released once fully paid out
- if escrow.remaining_amount == 0 {
- escrow.status = EscrowStatus::Released;
- }
-
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- // INTERACTION: external token transfer is last
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- client.transfer(
- &env.current_contract_address(),
- &contributor,
- &payout_amount,
- );
-
- events::emit_funds_released(
- &env,
- FundsReleased {
- version: EVENT_VERSION_V2,
- bounty_id,
- amount: payout_amount,
- recipient: contributor,
- timestamp: env.ledger().timestamp(),
- },
- );
-
- // GUARD: release reentrancy lock
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Refunds remaining funds when refund conditions are met.
- ///
- /// # Authorization
- /// Refund execution requires authenticated authorization from the contract admin
- /// and the escrow depositor.
- ///
- /// # Eligibility
- /// Refund is allowed when either:
- /// 1. The deadline has passed (standard full refund to depositor), or
- /// 2. An admin approval exists (early, partial, or custom-recipient refund).
- ///
- /// # Transition Guards
- /// This function enforces the following state transition guards:
- ///
- /// ## Pre-conditions (checked in order):
- /// 1. **Reentrancy Guard**: Acquires reentrancy lock to prevent concurrent execution
- /// 2. **Operational State**: Contract must not be paused for refund operations
- /// 3. **Escrow Existence**: Bounty must exist in storage
- /// 4. **Freeze Check**: Escrow and depositor must not be frozen
- /// 5. **Authorization**: Both admin and depositor must authorize the transaction
- /// 6. **Status Guard**: Escrow status must be `Locked` or `PartiallyRefunded`
- /// 7. **Claim Guard**: No pending claim exists (or claim is already executed)
- /// 8. **Deadline/Approval Guard**: Deadline has passed OR admin approval exists
- ///
- /// ## State Transition:
- /// - **From**: `Locked` or `PartiallyRefunded`
- /// - **To**: `Refunded` (if full refund) or `PartiallyRefunded` (if partial)
- /// - **Effect**: Decrements `remaining_amount` by refund amount
- ///
- /// ## Post-conditions:
- /// - External token transfer to refund recipient (after state update)
- /// - Refund record added to history
- /// - Approval removed (if applicable)
- /// - Event emission
- ///
- /// ## Contention Safety:
- /// - If status is `Released` or `Refunded`, returns `Error::FundsNotLocked`
- /// - Reentrancy guard prevents concurrent execution of any protected function
- /// - CEI pattern ensures state is updated before external calls
- /// - No double-spend: once refunded, release fails with `Error::FundsNotLocked`
- ///
- /// # Errors
- /// Returns `Error::NotInitialized` if admin is not set.
- pub fn refund(env: Env, bounty_id: u64) -> Result<(), Error> {
- // GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
-
- if Self::check_paused(&env, symbol_short!("refund")) {
- return Err(Error::FundsPaused);
- }
- // Snapshot resource meters for gas cap enforcement (test / testutils only).
- #[cfg(any(test, feature = "testutils"))]
- let gas_snapshot = gas_budget::capture(&env);
-
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
-
- let mut escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
-
- Self::ensure_escrow_not_frozen(&env, bounty_id)?;
- Self::ensure_address_not_frozen(&env, &escrow.depositor)?;
-
- // Require authenticated approval from both admin and depositor.
- let admin: Address = env
- .storage()
- .instance()
- .get(&DataKey::Admin)
- .ok_or(Error::NotInitialized)?;
- admin.require_auth();
- escrow.depositor.require_auth();
-
- if escrow.status != EscrowStatus::Locked && escrow.status != EscrowStatus::PartiallyRefunded
- {
- return Err(Error::FundsNotLocked);
- }
-
- // Block refund if there is a pending claim (Issue #391 fix)
- if env
- .storage()
- .persistent()
- .has(&DataKey::PendingClaim(bounty_id))
- {
- let claim: ClaimRecord = env
- .storage()
- .persistent()
- .get(&DataKey::PendingClaim(bounty_id))
- .unwrap();
- if !claim.claimed {
- return Err(Error::ClaimPending);
- }
- }
-
- let now = env.ledger().timestamp();
- let approval_key = DataKey::RefundApproval(bounty_id);
- let approval: Option = env.storage().persistent().get(&approval_key);
-
- // Refund is allowed if:
- // 1. Deadline has passed (returns full amount to depositor)
- // 2. An administrative approval exists (can be early, partial, and to custom recipient)
- if now < escrow.deadline && approval.is_none() {
- return Err(Error::DeadlineNotPassed);
- }
-
- let (refund_amount, refund_to, is_full) = if let Some(app) = approval.clone() {
- let full = app.mode == RefundMode::Full || app.amount >= escrow.remaining_amount;
- (app.amount, app.recipient, full)
- } else {
- // Standard refund after deadline
- (escrow.remaining_amount, escrow.depositor.clone(), true)
- };
-
- if refund_amount <= 0 || refund_amount > escrow.remaining_amount {
- return Err(Error::InvalidAmount);
- }
-
- // EFFECTS: update state before external call (CEI)
- invariants::assert_escrow(&env, &escrow);
- // Update escrow state: subtract the amount exactly refunded
- escrow.remaining_amount = escrow.remaining_amount.checked_sub(refund_amount).unwrap();
- if is_full || escrow.remaining_amount == 0 {
- escrow.status = EscrowStatus::Refunded;
- } else {
- escrow.status = EscrowStatus::PartiallyRefunded;
- }
-
- // Add to refund history
- escrow.refund_history.push_back(RefundRecord {
- amount: refund_amount,
- recipient: refund_to.clone(),
- timestamp: now,
- mode: if is_full {
- RefundMode::Full
- } else {
- RefundMode::Partial
- },
- });
-
- // Save updated escrow
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- // Remove approval after successful execution
- if approval.is_some() {
- env.storage().persistent().remove(&approval_key);
- }
-
- // INTERACTION: external token transfer is last
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- client.transfer(&env.current_contract_address(), &refund_to, &refund_amount);
-
- emit_funds_refunded(
- &env,
- FundsRefunded {
- version: EVENT_VERSION_V2,
- bounty_id,
- amount: refund_amount,
- refund_to: refund_to.clone(),
- timestamp: now,
- trigger_type: if approval.is_some() {
- RefundTriggerType::AdminApproval
- } else {
- RefundTriggerType::DeadlineExpired
- },
- },
- );
- Self::record_receipt(
- &env,
- CriticalOperationOutcome::Refunded,
- bounty_id,
- refund_amount,
- refund_to.clone(),
- );
-
- // INV-2: Verify aggregate balance matches token balance after refund
- multitoken_invariants::assert_after_disbursement(&env);
-
- // Gas budget cap enforcement (test / testutils only).
- #[cfg(any(test, feature = "testutils"))]
- {
- let gas_cfg = gas_budget::get_config(&env);
- if let Err(e) = gas_budget::check(
- &env,
- symbol_short!("refund"),
- &gas_cfg.refund,
- &gas_snapshot,
- gas_cfg.enforce,
- ) {
- reentrancy_guard::release(&env);
- return Err(e);
- }
- }
-
- // GUARD: release reentrancy lock
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Simulate refund operation without state changes or token transfers.
- ///
- /// Returns a `SimulationResult` indicating whether the operation would succeed and the
- /// resulting escrow state. Does not require authorization; safe for off-chain preview.
- ///
- /// # Arguments
- /// * `bounty_id` - Bounty identifier
- ///
- /// # Security
- /// This function performs only read operations. No storage writes, token transfers,
- /// or events are emitted.
- pub fn dry_run_refund(env: Env, bounty_id: u64) -> SimulationResult {
- fn err_result(e: Error, default_status: EscrowStatus) -> SimulationResult {
- SimulationResult {
- success: false,
- error_code: e as u32,
- amount: 0,
- resulting_status: default_status,
- remaining_amount: 0,
- }
- }
- match Self::dry_run_refund_impl(&env, bounty_id) {
- Ok((refund_amount, resulting_status, remaining_amount)) => SimulationResult {
- success: true,
- error_code: 0,
- amount: refund_amount,
- resulting_status,
- remaining_amount,
- },
- Err(e) => err_result(e, EscrowStatus::Refunded),
- }
- }
-
- fn dry_run_refund_impl(env: &Env, bounty_id: u64) -> Result<(i128, EscrowStatus, i128), Error> {
- if Self::check_paused(env, symbol_short!("refund")) {
- return Err(Error::FundsPaused);
- }
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
- let escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
- Self::ensure_escrow_not_frozen(env, bounty_id)?;
- Self::ensure_address_not_frozen(env, &escrow.depositor)?;
- if escrow.status != EscrowStatus::Locked && escrow.status != EscrowStatus::PartiallyRefunded
- {
- return Err(Error::FundsNotLocked);
- }
- if env
- .storage()
- .persistent()
- .has(&DataKey::PendingClaim(bounty_id))
- {
- let claim: ClaimRecord = env
- .storage()
- .persistent()
- .get(&DataKey::PendingClaim(bounty_id))
- .unwrap();
- if !claim.claimed {
- return Err(Error::ClaimPending);
- }
- }
- let now = env.ledger().timestamp();
- let approval_key = DataKey::RefundApproval(bounty_id);
- let approval: Option = env.storage().persistent().get(&approval_key);
- if now < escrow.deadline && approval.is_none() {
- return Err(Error::DeadlineNotPassed);
- }
- let (refund_amount, _refund_to, is_full) = if let Some(app) = approval {
- let full = app.mode == RefundMode::Full || app.amount >= escrow.remaining_amount;
- (app.amount, app.recipient, full)
- } else {
- (escrow.remaining_amount, escrow.depositor.clone(), true)
- };
- if refund_amount <= 0 || refund_amount > escrow.remaining_amount {
- return Err(Error::InvalidAmount);
- }
- let remaining_after = escrow
- .remaining_amount
- .checked_sub(refund_amount)
- .unwrap_or(0);
- let resulting_status = if is_full || remaining_after == 0 {
- EscrowStatus::Refunded
- } else {
- EscrowStatus::PartiallyRefunded
- };
- Ok((refund_amount, resulting_status, remaining_after))
- }
-
- fn default_cycle_link() -> CycleLink {
- CycleLink {
- previous_id: 0,
- next_id: 0,
- cycle: 0,
- }
- }
-
- /// Extends the deadline of an active escrow and optionally tops up locked funds.
- ///
- /// # Security assumptions
- /// - Only `Locked` escrows are renewable.
- /// - Renewal is only allowed before the current deadline elapses.
- /// - New deadline must strictly increase the current deadline.
- /// - Top-ups transfer tokens from the original depositor into this contract.
- pub fn renew_escrow(
- env: Env,
- bounty_id: u64,
- new_deadline: u64,
- additional_amount: i128,
- ) -> Result<(), Error> {
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
-
- let mut escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
-
- Self::ensure_escrow_not_frozen(&env, bounty_id)?;
- Self::ensure_address_not_frozen(&env, &escrow.depositor)?;
-
- if escrow.status != EscrowStatus::Locked {
- return Err(Error::FundsNotLocked);
- }
-
- let now = env.ledger().timestamp();
- if now >= escrow.deadline {
- return Err(Error::DeadlineNotPassed);
- }
- if new_deadline <= escrow.deadline {
- return Err(Error::InvalidDeadline);
- }
- if additional_amount < 0 {
- return Err(Error::InvalidAmount);
- }
-
- // The original depositor must authorize every renewal and any top-up transfer.
- escrow.depositor.require_auth();
-
- if additional_amount > 0 {
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- client.transfer(
- &escrow.depositor,
- &env.current_contract_address(),
- &additional_amount,
- );
-
- escrow.amount = escrow
- .amount
- .checked_add(additional_amount)
- .ok_or(Error::InvalidAmount)?;
- escrow.remaining_amount = escrow
- .remaining_amount
- .checked_add(additional_amount)
- .ok_or(Error::InvalidAmount)?;
- }
-
- let old_deadline = escrow.deadline;
- escrow.deadline = new_deadline;
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- let mut history: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::RenewalHistory(bounty_id))
- .unwrap_or(Vec::new(&env));
- let cycle = history.len().saturating_add(1);
- history.push_back(RenewalRecord {
- cycle,
- old_deadline,
- new_deadline,
- additional_amount,
- renewed_at: now,
- });
- env.storage()
- .persistent()
- .set(&DataKey::RenewalHistory(bounty_id), &history);
-
- Ok(())
- }
-
- /// Starts a new bounty cycle from a completed prior cycle without mutating prior records.
- ///
- /// # Security assumptions
- /// - Previous cycle must be finalized (`Released` or `Refunded`).
- /// - A cycle can have at most one direct successor.
- /// - New cycle funds are transferred from the original depositor.
- pub fn create_next_cycle(
- env: Env,
- previous_bounty_id: u64,
- new_bounty_id: u64,
- amount: i128,
- deadline: u64,
- ) -> Result<(), Error> {
- if amount <= 0 {
- return Err(Error::InvalidAmount);
- }
- if deadline <= env.ledger().timestamp() {
- return Err(Error::InvalidDeadline);
- }
- if previous_bounty_id == new_bounty_id {
- return Err(Error::BountyExists);
- }
- if env
- .storage()
- .persistent()
- .has(&DataKey::Escrow(new_bounty_id))
- {
- return Err(Error::BountyExists);
- }
- if !env
- .storage()
- .persistent()
- .has(&DataKey::Escrow(previous_bounty_id))
- {
- return Err(Error::BountyNotFound);
- }
-
- let previous: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(previous_bounty_id))
- .unwrap();
- if previous.status != EscrowStatus::Released && previous.status != EscrowStatus::Refunded {
- return Err(Error::FundsNotLocked);
- }
-
- let mut prev_link: CycleLink = env
- .storage()
- .persistent()
- .get(&DataKey::CycleLink(previous_bounty_id))
- .unwrap_or(Self::default_cycle_link());
- if prev_link.next_id != 0 {
- return Err(Error::BountyExists);
- }
-
- previous.depositor.require_auth();
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- client.transfer(
- &previous.depositor,
- &env.current_contract_address(),
- &amount,
- );
-
- let new_escrow = Escrow {
- depositor: previous.depositor.clone(),
- amount,
- remaining_amount: amount,
- status: EscrowStatus::Locked,
- deadline,
- refund_history: Vec::new(&env),
- archived: false,
- archived_at: None,
- };
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(new_bounty_id), &new_escrow);
-
- let mut index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::EscrowIndex)
- .unwrap_or(Vec::new(&env));
- index.push_back(new_bounty_id);
- env.storage()
- .persistent()
- .set(&DataKey::EscrowIndex, &index);
-
- let mut depositor_index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::DepositorIndex(previous.depositor.clone()))
- .unwrap_or(Vec::new(&env));
- depositor_index.push_back(new_bounty_id);
- env.storage().persistent().set(
- &DataKey::DepositorIndex(previous.depositor.clone()),
- &depositor_index,
- );
-
- prev_link.next_id = new_bounty_id;
- env.storage()
- .persistent()
- .set(&DataKey::CycleLink(previous_bounty_id), &prev_link);
-
- let new_link = CycleLink {
- previous_id: previous_bounty_id,
- next_id: 0,
- cycle: prev_link.cycle.saturating_add(1),
- };
- env.storage()
- .persistent()
- .set(&DataKey::CycleLink(new_bounty_id), &new_link);
-
- Ok(())
- }
-
- /// Returns the immutable renewal history for `bounty_id`.
- pub fn get_renewal_history(env: Env, bounty_id: u64) -> Result, Error> {
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
-
- Ok(env
- .storage()
- .persistent()
- .get(&DataKey::RenewalHistory(bounty_id))
- .unwrap_or(Vec::new(&env)))
- }
-
- /// Returns the rollover link metadata for `bounty_id`.
- ///
- /// Returns a default root link with `cycle=1` when no explicit cycle record exists yet.
- pub fn get_cycle_info(env: Env, bounty_id: u64) -> Result {
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
-
- let link: CycleLink = env
- .storage()
- .persistent()
- .get(&DataKey::CycleLink(bounty_id))
- .unwrap_or(Self::default_cycle_link());
-
- if link.previous_id == 0 && link.next_id == 0 && link.cycle == 0 {
- return Ok(CycleLink {
- previous_id: 0,
- next_id: 0,
- cycle: 1,
- });
- }
-
- Ok(link)
- }
-
- /// Sets or clears the anonymous resolver address.
- /// Only the admin can call this. The resolver is the trusted entity that
- /// resolves anonymous escrow refunds via `refund_resolved`.
- pub fn set_anonymous_resolver(env: Env, resolver: Option) -> Result<(), Error> {
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- match resolver {
- Some(addr) => env
- .storage()
- .instance()
- .set(&DataKey::AnonymousResolver, &addr),
- None => env.storage().instance().remove(&DataKey::AnonymousResolver),
- }
- Ok(())
- }
-
- /// Refund an anonymous escrow to a resolved recipient.
- /// Only the configured anonymous resolver can call this; they resolve the depositor
- /// commitment off-chain and pass the recipient address (signed instruction pattern).
- pub fn refund_resolved(env: Env, bounty_id: u64, recipient: Address) -> Result<(), Error> {
- if Self::check_paused(&env, symbol_short!("refund")) {
- return Err(Error::FundsPaused);
- }
-
- let resolver: Address = env
- .storage()
- .instance()
- .get(&DataKey::AnonymousResolver)
- .ok_or(Error::AnonymousResolverNotSet)?;
- resolver.require_auth();
-
- if !env
- .storage()
- .persistent()
- .has(&DataKey::EscrowAnon(bounty_id))
- {
- return Err(Error::NotAnonymousEscrow);
- }
-
- reentrancy_guard::acquire(&env);
-
- let mut anon: AnonymousEscrow = env
- .storage()
- .persistent()
- .get(&DataKey::EscrowAnon(bounty_id))
- .unwrap();
-
- Self::ensure_escrow_not_frozen(&env, bounty_id)?;
-
- if anon.status != EscrowStatus::Locked && anon.status != EscrowStatus::PartiallyRefunded {
- return Err(Error::FundsNotLocked);
- }
-
- // GUARD 1: Block refund if there is a pending claim (Issue #391 fix)
- if env
- .storage()
- .persistent()
- .has(&DataKey::PendingClaim(bounty_id))
- {
- let claim: ClaimRecord = env
- .storage()
- .persistent()
- .get(&DataKey::PendingClaim(bounty_id))
- .unwrap();
- if !claim.claimed {
- return Err(Error::ClaimPending);
- }
- }
-
- let now = env.ledger().timestamp();
- let approval_key = DataKey::RefundApproval(bounty_id);
- let approval: Option = env.storage().persistent().get(&approval_key);
-
- // Refund is allowed if:
- // 1. Deadline has passed (returns full amount to depositor)
- // 2. An administrative approval exists (can be early, partial, and to custom recipient)
- if now < anon.deadline && approval.is_none() {
- return Err(Error::DeadlineNotPassed);
- }
-
- let (refund_amount, refund_to, is_full) = if let Some(app) = approval.clone() {
- let full = app.mode == RefundMode::Full || app.amount >= anon.remaining_amount;
- (app.amount, app.recipient, full)
- } else {
- // Standard refund after deadline
- (anon.remaining_amount, recipient.clone(), true)
- };
-
- if refund_amount <= 0 || refund_amount > anon.remaining_amount {
- return Err(Error::InvalidAmount);
- }
-
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
-
- // Transfer the calculated refund amount to the designated recipient
- client.transfer(&env.current_contract_address(), &refund_to, &refund_amount);
-
- // Anonymous escrow uses a parallel storage record and invariant model.
- // Update escrow state: subtract the amount exactly refunded
- anon.remaining_amount -= refund_amount;
- if is_full || anon.remaining_amount == 0 {
- anon.status = EscrowStatus::Refunded;
- } else {
- anon.status = EscrowStatus::PartiallyRefunded;
- }
-
- // Add to refund history
- anon.refund_history.push_back(RefundRecord {
- amount: refund_amount,
- recipient: refund_to.clone(),
- timestamp: now,
- mode: if is_full {
- RefundMode::Full
- } else {
- RefundMode::Partial
- },
- });
-
- // Save updated escrow
- env.storage()
- .persistent()
- .set(&DataKey::EscrowAnon(bounty_id), &anon);
-
- // Remove approval after successful execution
- if approval.is_some() {
- env.storage().persistent().remove(&approval_key);
- }
-
- emit_funds_refunded(
- &env,
- FundsRefunded {
- version: EVENT_VERSION_V2,
- bounty_id,
- amount: refund_amount,
- refund_to: refund_to.clone(),
- timestamp: now,
- trigger_type: if approval.is_some() {
- RefundTriggerType::AdminApproval
- } else {
- RefundTriggerType::DeadlineExpired
- },
- },
- );
-
- // GUARD: release reentrancy lock
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Delegated refund path using a capability.
- /// This can be used for short-lived, bounded delegated refunds without granting admin rights.
- pub fn refund_with_capability(
- env: Env,
- bounty_id: u64,
- amount: i128,
- holder: Address,
- capability_id: BytesN<32>,
- ) -> Result<(), Error> {
- // GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
-
- if Self::check_paused(&env, symbol_short!("refund")) {
- return Err(Error::FundsPaused);
- }
- if amount <= 0 {
- return Err(Error::InvalidAmount);
- }
- if !env.storage().persistent().has(&DataKey::Escrow(bounty_id)) {
- return Err(Error::BountyNotFound);
- }
-
- let mut escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(bounty_id))
- .unwrap();
-
- Self::ensure_escrow_not_frozen(&env, bounty_id)?;
- Self::ensure_address_not_frozen(&env, &escrow.depositor)?;
-
- if escrow.status != EscrowStatus::Locked && escrow.status != EscrowStatus::PartiallyRefunded
- {
- return Err(Error::FundsNotLocked);
- }
- if amount > escrow.remaining_amount {
- return Err(Error::InvalidAmount);
- }
-
- if env
- .storage()
- .persistent()
- .has(&DataKey::PendingClaim(bounty_id))
- {
- let claim: ClaimRecord = env
- .storage()
- .persistent()
- .get(&DataKey::PendingClaim(bounty_id))
- .unwrap();
- if !claim.claimed {
- return Err(Error::ClaimPending);
- }
- }
-
- Self::consume_capability(
- &env,
- &holder,
- capability_id,
- CapabilityAction::Refund,
- bounty_id,
- amount,
- )?;
-
- // EFFECTS: update state before external call (CEI)
- let now = env.ledger().timestamp();
- let refund_to = escrow.depositor.clone();
- escrow.remaining_amount = escrow.remaining_amount.checked_sub(amount).unwrap();
- if escrow.remaining_amount == 0 {
- escrow.status = EscrowStatus::Refunded;
- } else {
- escrow.status = EscrowStatus::PartiallyRefunded;
- }
- escrow.refund_history.push_back(RefundRecord {
- amount,
- recipient: refund_to.clone(),
- timestamp: now,
- mode: if escrow.remaining_amount == 0 {
- RefundMode::Full
- } else {
- RefundMode::Partial
- },
- });
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
-
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- client.transfer(&env.current_contract_address(), &refund_to, &amount);
-
- emit_funds_refunded(
- &env,
- FundsRefunded {
- version: EVENT_VERSION_V2,
- bounty_id,
- amount,
- refund_to,
- timestamp: now,
- trigger_type: RefundTriggerType::AdminApproval,
- },
- );
-
- reentrancy_guard::release(&env);
- Ok(())
- }
-
- /// Return the current per-operation gas budget configuration.
- ///
- /// Returns the fully uncapped default if no configuration has been set.
- pub fn get_gas_budget(env: Env) -> gas_budget::GasBudgetConfig {
- gas_budget::get_config(&env)
- }
-
- /// Batch lock funds for multiple bounties in a single atomic transaction.
- ///
- /// Locks between 1 and [`MAX_BATCH_SIZE`] bounties in one call, reducing
- /// per-transaction overhead compared to repeated single-item `lock_funds`
- /// calls.
- ///
- /// ## Batch failure semantics
- ///
- /// This operation is **strictly atomic** (all-or-nothing):
- ///
- /// 1. All items are validated in a single pass **before** any state is
- /// mutated or any token transfer is initiated.
- /// 2. If *any* item fails validation the entire call reverts immediately.
- /// No escrow record is written, no token is transferred, and every
- /// "sibling" row in the same batch is left completely unaffected.
- /// 3. After a failed batch the contract is in exactly the same state as
- /// before the call; subsequent operations behave as if this call never
- /// happened.
- ///
- /// ## Ordering guarantee
- ///
- /// Items are processed in ascending `bounty_id` order regardless of the
- /// caller-supplied ordering. This ensures deterministic execution and
- /// eliminates ordering-based front-running attacks.
- ///
- /// ## Checks-Effects-Interactions (CEI)
- ///
- /// All escrow records and index updates are written in a first pass
- /// (Effects); external token transfers and event emissions happen in a
- /// second pass (Interactions). This ordering prevents reentrancy attacks.
- ///
- /// # Arguments
- /// * `items` - 1–[`MAX_BATCH_SIZE`] [`LockFundsItem`] entries (bounty_id,
- /// depositor, amount, deadline).
- ///
- /// # Returns
- /// Number of bounties successfully locked (equals `items.len()` on success).
- ///
- /// # Errors
- /// * [`Error::InvalidBatchSize`] — batch is empty or exceeds `MAX_BATCH_SIZE`
- /// * [`Error::ContractDeprecated`] — contract has been killed via `set_deprecated`
- /// * [`Error::FundsPaused`] — lock operations are currently paused
- /// * [`Error::NotInitialized`] — `init` has not been called
- /// * [`Error::BountyExists`] — a `bounty_id` already exists in storage
- /// * [`Error::DuplicateBountyId`] — the same `bounty_id` appears more than once
- /// * [`Error::InvalidAmount`] — any item has `amount ≤ 0`
- /// * [`Error::ParticipantBlocked`] / [`Error::ParticipantNotAllowed`] — participant filter
- ///
- /// # Reentrancy
- /// Protected by the shared reentrancy guard (acquired before validation,
- /// released after all effects and interactions complete).
- pub fn batch_lock_funds(env: Env, items: Vec) -> Result {
- if Self::check_paused(&env, symbol_short!("lock")) {
- return Err(Error::FundsPaused);
- }
-
- // GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
- // Snapshot resource meters for gas cap enforcement (test / testutils only).
- #[cfg(any(test, feature = "testutils"))]
- let gas_snapshot = gas_budget::capture(&env);
- let result: Result = (|| {
- if Self::get_deprecation_state(&env).deprecated {
- return Err(Error::ContractDeprecated);
- }
- // Validate batch size
- let batch_size = items.len();
- if batch_size == 0 {
- return Err(Error::InvalidBatchSize);
- }
- if batch_size > MAX_BATCH_SIZE {
- return Err(Error::InvalidBatchSize);
- }
-
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
-
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- let contract_address = env.current_contract_address();
- let timestamp = env.ledger().timestamp();
-
- // Validate all items before processing (all-or-nothing approach)
- for item in items.iter() {
- // Participant filtering (blocklist-only / allowlist-only / disabled)
- Self::check_participant_filter(&env, item.depositor.clone())?;
-
- // Check if bounty already exists
- if env
- .storage()
- .persistent()
- .has(&DataKey::Escrow(item.bounty_id))
- {
- return Err(Error::BountyExists);
- }
-
- // Validate amount
- if item.amount <= 0 {
- return Err(Error::InvalidAmount);
- }
-
- // Check for duplicate bounty_ids in the batch
- let mut count = 0u32;
- for other_item in items.iter() {
- if other_item.bounty_id == item.bounty_id {
- count += 1;
- }
- }
- if count > 1 {
- return Err(Error::DuplicateBountyId);
- }
- }
-
- let ordered_items = Self::order_batch_lock_items(&env, &items);
-
- // Collect unique depositors and require auth once for each
- // This prevents "frame is already authorized" errors when same depositor appears multiple times
- let mut seen_depositors: Vec = Vec::new(&env);
- for item in ordered_items.iter() {
- let mut found = false;
- for seen in seen_depositors.iter() {
- if seen.clone() == item.depositor {
- found = true;
- break;
- }
- }
- if !found {
- seen_depositors.push_back(item.depositor.clone());
- item.depositor.require_auth();
- }
- }
-
- // Process all items (atomic - all succeed or all fail)
- // First loop: write all state (escrow, indices). Second loop: transfers + events.
- let mut locked_count = 0u32;
- for item in ordered_items.iter() {
- let escrow = Escrow {
- depositor: item.depositor.clone(),
- amount: item.amount,
- status: EscrowStatus::Locked,
- deadline: item.deadline,
- refund_history: vec![&env],
- remaining_amount: item.amount,
- archived: false,
- archived_at: None,
- };
-
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(item.bounty_id), &escrow);
-
- let mut index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::EscrowIndex)
- .unwrap_or(Vec::new(&env));
- index.push_back(item.bounty_id);
- env.storage()
- .persistent()
- .set(&DataKey::EscrowIndex, &index);
-
- let mut depositor_index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::DepositorIndex(item.depositor.clone()))
- .unwrap_or(Vec::new(&env));
- depositor_index.push_back(item.bounty_id);
- env.storage().persistent().set(
- &DataKey::DepositorIndex(item.depositor.clone()),
- &depositor_index,
- );
- }
+//! # Bounty Escrow Contract
+//!
+//! A secure escrow contract for managing bounty payouts with dispute resolution finality.
+//!
+//! ## Finality Rules
+//!
+//! Once a dispute reaches **final resolution**, the escrow enters an immutable terminal state:
+//!
+//! 1. **No Reopening**: A resolved dispute cannot be escalated or re-resolved under any
+//! circumstances. Calling [`resolve_dispute`] or [`escalate_dispute`] on a resolved escrow
+//! returns [`EscrowError::AlreadyResolved`].
+//!
+//! 2. **Funds Locked to Outcome**: After resolution, only [`release_funds`] (for
+//! `ResolvedForHunter`) or [`refund_funder`] (for `ResolvedForFunder`) succeed.
+//! All other fund-movement calls return [`EscrowError::InvalidStateTransition`].
+//!
+//! 3. **Role Separation**: Only designated arbiters may call [`resolve_dispute`].
+//! Only the original funder may call [`escalate_dispute`]. Violations return
+//! [`EscrowError::Unauthorized`].
+//!
+//! 4. **Expiration Guards**: An escrow that has expired cannot be funded or accepted;
+//! it can only be refunded to the funder or escalated to dispute before expiry.
+//!
+//! ## State Machine
+//!
+//! ```text
+//! ┌─────────┐ fund() ┌────────┐ accept() ┌──────────┐
+//! │ Created │ ────────► │ Funded │ ──────────► │ Accepted │
+//! └─────────┘ └────────┘ └──────────┘
+//! │ │
+//! expire │ dispute │ escalate_dispute()
+//! ▼ ▼
+//! ┌─────────┐ ┌──────────┐
+//! │ Expired │ │ Disputed │
+//! └─────────┘ └──────────┘
+//! │ │ resolve_dispute()
+//! refund │ ┌───────┴────────┐
+//! ▼ ▼ ▼
+//! ┌─────────┐ ┌────────────┐ ┌──────────────┐
+//! │Refunded │ │ResolvedFor │ │ ResolvedFor │
+//! └─────────┘ │ Hunter │ │ Funder │
+//! └────────────┘ └──────────────┘
+//! │ │
+//! release_ │ refund_ │
+//! funds() ▼ funder()▼
+//! ┌──────────┐ ┌──────────────┐
+//! │ Released │ │ Refunded │
+//! └──────────┘ └──────────────┘
+//! ```
+
+use std::time::{SystemTime, UNIX_EPOCH};
+
+#[cfg(test)]
+#[path = "test_dispute_resolution.rs"]
+mod test_dispute_resolution;
- // INTERACTION: all external token transfers happen after state is finalized
- for item in ordered_items.iter() {
- client.transfer(&item.depositor, &contract_address, &item.amount);
+#[cfg(test)]
+#[path = "test_expiration_and_dispute.rs"]
+mod test_expiration_and_dispute;
- emit_funds_locked(
- &env,
- FundsLocked {
- version: EVENT_VERSION_V2,
- bounty_id: item.bounty_id,
- amount: item.amount,
- depositor: item.depositor.clone(),
- deadline: item.deadline,
- },
- );
+/// Errors returned by the escrow contract.
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub enum EscrowError {
+ /// The requested state transition is not valid from the current state.
+ InvalidStateTransition,
+ /// The caller does not have permission to perform this action.
+ Unauthorized,
+ /// The dispute has already been resolved and cannot be modified.
+ AlreadyResolved,
+ /// The escrow has expired and cannot proceed.
+ Expired,
+ /// The escrow has not yet expired; expiry-only actions cannot proceed.
+ NotExpired,
+ /// The provided amount is invalid (zero or overflow).
+ InvalidAmount,
+ /// The escrow is not in a disputed state.
+ NotInDispute,
+ /// Arithmetic overflow occurred.
+ Overflow,
+}
+
+impl std::fmt::Display for EscrowError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::InvalidStateTransition => write!(f, "invalid state transition"),
+ Self::Unauthorized => write!(f, "unauthorized caller"),
+ Self::AlreadyResolved => write!(f, "dispute already resolved"),
+ Self::Expired => write!(f, "escrow has expired"),
+ Self::NotExpired => write!(f, "escrow has not expired"),
+ Self::InvalidAmount => write!(f, "invalid amount"),
+ Self::NotInDispute => write!(f, "escrow is not in dispute"),
+ Self::Overflow => write!(f, "arithmetic overflow"),
+ }
+ }
+}
+
+/// Outcome of a resolved dispute.
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum Resolution {
+ /// Funds are awarded to the bounty hunter.
+ ForHunter,
+ /// Funds are returned to the funder.
+ ForFunder,
+}
+
+/// The lifecycle state of the escrow.
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub enum EscrowState {
+ /// Escrow created but not yet funded.
+ Created,
+ /// Funder has deposited funds; awaiting hunter acceptance.
+ Funded,
+ /// Hunter has accepted; work is in progress.
+ Accepted,
+ /// Escrow has passed its expiry timestamp without completion.
+ Expired,
+ /// A dispute has been opened; awaiting arbiter resolution.
+ Disputed,
+ /// Arbiter has resolved in favour of the hunter; funds pending release.
+ ResolvedForHunter,
+ /// Arbiter has resolved in favour of the funder; refund pending.
+ ResolvedForFunder,
+ /// Funds have been released to the hunter (terminal state).
+ Released,
+ /// Funds have been refunded to the funder (terminal state).
+ Refunded,
+}
- locked_count += 1;
- }
+impl EscrowState {
+ /// Returns `true` if this is a terminal state with no further transitions.
+ pub fn is_terminal(&self) -> bool {
+ matches!(self, Self::Released | Self::Refunded)
+ }
- emit_batch_funds_locked(
- &env,
- BatchFundsLocked {
- version: EVENT_VERSION_V2,
- count: locked_count,
- total_amount: ordered_items
- .iter()
- .try_fold(0i128, |acc, i| acc.checked_add(i.amount))
- .unwrap(),
- timestamp,
- },
- );
- Ok(locked_count)
- })();
+ /// Returns `true` if the dispute has reached final resolution.
+ pub fn is_resolved(&self) -> bool {
+ matches!(
+ self,
+ Self::ResolvedForHunter | Self::ResolvedForFunder | Self::Released | Self::Refunded
+ )
+ }
+}
- // Gas budget cap enforcement (test / testutils only).
- #[cfg(any(test, feature = "testutils"))]
- if result.is_ok() {
- let gas_cfg = gas_budget::get_config(&env);
- if let Err(e) = gas_budget::check(
- &env,
- symbol_short!("b_lock"),
- &gas_cfg.batch_lock,
- &gas_snapshot,
- gas_cfg.enforce,
- ) {
- reentrancy_guard::release(&env);
- return Err(e);
- }
- }
+/// Unique identifier for a participant.
+pub type AccountId = u64;
- // GUARD: release reentrancy lock
- reentrancy_guard::release(&env);
- result
- }
+/// Unix timestamp in seconds.
+pub type Timestamp = u64;
- /// Alias for batch_lock_funds to match the requested naming convention.
- pub fn batch_lock(env: Env, items: Vec) -> Result {
- Self::batch_lock_funds(env, items)
- }
+/// Token amount.
+pub type Balance = u128;
- /// Batch release funds to multiple contributors in a single atomic transaction.
- ///
- /// Releases between 1 and [`MAX_BATCH_SIZE`] bounties in one admin-authorised
- /// call, reducing per-transaction overhead compared to repeated single-item
- /// `release_funds` calls.
- ///
- /// ## Batch failure semantics
- ///
- /// This operation is **strictly atomic** (all-or-nothing):
- ///
- /// 1. All items are validated in a single pass **before** any escrow status
- /// is updated or any token transfer is initiated.
- /// 2. If *any* item fails validation the entire call reverts immediately.
- /// No status is changed, no token leaves the contract, and every
- /// "sibling" row in the same batch is left completely unaffected.
- /// 3. After a failed batch the contract is in exactly the same state as
- /// before the call; subsequent operations behave as if this call never
- /// happened.
- ///
- /// ## Ordering guarantee
- ///
- /// Items are processed in ascending `bounty_id` order regardless of the
- /// caller-supplied ordering, ensuring deterministic execution.
- ///
- /// ## Checks-Effects-Interactions (CEI)
+/// The escrow contract state.
+#[derive(Debug)]
+pub struct Escrow {
+ /// Account that funded the bounty.
+ pub funder: AccountId,
+ /// Account eligible to claim the bounty.
+ pub hunter: AccountId,
+ /// Designated arbiter who may resolve disputes.
///
- /// All escrow statuses are updated to `Released` in a first pass (Effects);
- /// external token transfers and event emissions happen in a second pass
- /// (Interactions).
+ /// # Security
+ /// The arbiter is set at construction and is immutable. This prevents
+ /// dispute-resolution capture by a malicious funder or hunter.
+ pub arbiter: AccountId,
+ /// Locked bounty amount.
+ pub amount: Balance,
+ /// Unix timestamp after which the escrow is considered expired.
+ pub expiry: Timestamp,
+ /// Current lifecycle state.
+ pub state: EscrowState,
+ /// Immutable record of the dispute resolution outcome, set once.
+ pub resolution: Option,
+ /// Timestamp at which the dispute was opened, if any.
+ pub disputed_at: Option,
+ /// Timestamp at which the dispute was resolved, if any.
+ pub resolved_at: Option,
+}
+
+impl Escrow {
+ /// Creates a new escrow in [`EscrowState::Created`].
///
/// # Arguments
- /// * `items` - 1–[`MAX_BATCH_SIZE`] [`ReleaseFundsItem`] entries (bounty_id,
- /// contributor address).
- ///
- /// # Returns
- /// Number of bounties successfully released (equals `items.len()` on success).
+ /// * `funder` – account depositing funds.
+ /// * `hunter` – account eligible to receive the bounty.
+ /// * `arbiter` – account authorised to resolve disputes.
+ /// * `amount` – locked token amount (must be > 0).
+ /// * `expiry` – unix timestamp after which no new work may begin.
///
/// # Errors
- /// * [`Error::InvalidBatchSize`] — batch is empty or exceeds `MAX_BATCH_SIZE`
- /// * [`Error::FundsPaused`] — release operations are currently paused
- /// * [`Error::NotInitialized`] — `init` has not been called
- /// * [`Error::Unauthorized`] — caller is not the admin
- /// * [`Error::BountyNotFound`] — a `bounty_id` does not exist in storage
- /// * [`Error::FundsNotLocked`] — a bounty's status is not `Locked`
- /// * [`Error::DuplicateBountyId`] — the same `bounty_id` appears more than once
- ///
- /// # Reentrancy
- /// Protected by the shared reentrancy guard (acquired before validation,
- /// released after all effects and interactions complete).
- pub fn batch_release_funds(env: Env, items: Vec) -> Result {
- if Self::check_paused(&env, symbol_short!("release")) {
- return Err(Error::FundsPaused);
- }
- // GUARD: acquire reentrancy lock
- reentrancy_guard::acquire(&env);
- // Snapshot resource meters for gas cap enforcement (test / testutils only).
- #[cfg(any(test, feature = "testutils"))]
- let gas_snapshot = gas_budget::capture(&env);
- let result: Result = (|| {
- // Validate batch size
- let batch_size = items.len();
- if batch_size == 0 {
- return Err(Error::InvalidBatchSize);
- }
- if batch_size > MAX_BATCH_SIZE {
- return Err(Error::InvalidBatchSize);
- }
-
- if !env.storage().instance().has(&DataKey::Admin) {
- return Err(Error::NotInitialized);
- }
-
- let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
- admin.require_auth();
-
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
- let contract_address = env.current_contract_address();
- let timestamp = env.ledger().timestamp();
-
- // Validate all items before processing (all-or-nothing approach)
- let mut total_amount: i128 = 0;
- for item in items.iter() {
- // Check if bounty exists
- if !env
- .storage()
- .persistent()
- .has(&DataKey::Escrow(item.bounty_id))
- {
- return Err(Error::BountyNotFound);
- }
-
- let escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(item.bounty_id))
- .unwrap();
-
- Self::ensure_escrow_not_frozen(&env, item.bounty_id)?;
- Self::ensure_address_not_frozen(&env, &escrow.depositor)?;
-
- // Check if funds are locked
- if escrow.status != EscrowStatus::Locked {
- return Err(Error::FundsNotLocked);
- }
-
- // Check for duplicate bounty_ids in the batch
- let mut count = 0u32;
- for other_item in items.iter() {
- if other_item.bounty_id == item.bounty_id {
- count += 1;
- }
- }
- if count > 1 {
- return Err(Error::DuplicateBountyId);
- }
-
- total_amount = total_amount
- .checked_add(escrow.amount)
- .ok_or(Error::InvalidAmount)?;
- }
-
- let ordered_items = Self::order_batch_release_items(&env, &items);
-
- // EFFECTS: update all escrow records before any external calls (CEI)
- // We collect (contributor, amount) pairs for the transfer pass.
- let mut release_pairs: Vec<(Address, i128)> = Vec::new(&env);
- let mut released_count = 0u32;
- for item in ordered_items.iter() {
- let mut escrow: Escrow = env
- .storage()
- .persistent()
- .get(&DataKey::Escrow(item.bounty_id))
- .unwrap();
-
- let amount = escrow.amount;
- escrow.status = EscrowStatus::Released;
- escrow.remaining_amount = 0;
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(item.bounty_id), &escrow);
-
- release_pairs.push_back((item.contributor.clone(), amount));
- released_count += 1;
- }
-
- // INTERACTION: all external token transfers happen after state is finalized
- for (idx, item) in ordered_items.iter().enumerate() {
- let (ref contributor, amount) = release_pairs.get(idx as u32).unwrap();
- client.transfer(&contract_address, contributor, &amount);
-
- emit_funds_released(
- &env,
- FundsReleased {
- version: EVENT_VERSION_V2,
- bounty_id: item.bounty_id,
- amount,
- recipient: contributor.clone(),
- timestamp,
- },
- );
- }
-
- // Emit batch event
- emit_batch_funds_released(
- &env,
- BatchFundsReleased {
- version: EVENT_VERSION_V2,
- count: released_count,
- total_amount,
- timestamp,
- },
- );
- Ok(released_count)
- })();
-
- // Gas budget cap enforcement (test / testutils only).
- #[cfg(any(test, feature = "testutils"))]
- if result.is_ok() {
- let gas_cfg = gas_budget::get_config(&env);
- if let Err(e) = gas_budget::check(
- &env,
- symbol_short!("b_rel"),
- &gas_cfg.batch_release,
- &gas_snapshot,
- gas_cfg.enforce,
- ) {
- reentrancy_guard::release(&env);
- return Err(e);
- }
- }
-
- reentrancy_guard::release(&env);
- result
- }
-}
-impl traits::EscrowInterface for BountyEscrowContract {
- /// Lock funds for a bounty through the trait interface
- fn lock_funds(
- env: &Env,
- depositor: Address,
- bounty_id: u64,
- amount: i128,
- deadline: u64,
- ) -> Result<(), crate::Error> {
- let entrypoint: fn(Env, Address, u64, i128, u64) -> Result<(), crate::Error> =
- BountyEscrowContract::lock_funds;
- entrypoint(env.clone(), depositor, bounty_id, amount, deadline)
- }
-
- /// Release funds to contributor through the trait interface
- fn release_funds(env: &Env, bounty_id: u64, contributor: Address) -> Result<(), crate::Error> {
- let entrypoint: fn(Env, u64, Address) -> Result<(), crate::Error> =
- BountyEscrowContract::release_funds;
- entrypoint(env.clone(), bounty_id, contributor)
- }
-
- /// Partial release through the trait interface
- fn partial_release(
- env: &Env,
- bounty_id: u64,
- contributor: Address,
- payout_amount: i128,
- ) -> Result<(), crate::Error> {
- let entrypoint: fn(Env, u64, Address, i128) -> Result<(), crate::Error> =
- BountyEscrowContract::partial_release;
- entrypoint(env.clone(), bounty_id, contributor, payout_amount)
- }
-
- /// Batch lock funds through the trait interface
- fn batch_lock_funds(env: &Env, items: Vec) -> Result {
- let entrypoint: fn(Env, Vec) -> Result =
- BountyEscrowContract::batch_lock_funds;
- entrypoint(env.clone(), items)
- }
-
- /// Batch release funds through the trait interface
- fn batch_release_funds(env: &Env, items: Vec) -> Result {
- let entrypoint: fn(Env, Vec) -> Result =
- BountyEscrowContract::batch_release_funds;
- entrypoint(env.clone(), items)
- }
-
- /// Refund funds to depositor through the trait interface
- fn refund(env: &Env, bounty_id: u64) -> Result<(), crate::Error> {
- let entrypoint: fn(Env, u64) -> Result<(), crate::Error> = BountyEscrowContract::refund;
- entrypoint(env.clone(), bounty_id)
- }
-
- /// Get escrow information through the trait interface
- fn get_escrow_info(env: &Env, bounty_id: u64) -> Result {
- let entrypoint: fn(Env, u64) -> Result =
- BountyEscrowContract::get_escrow_info;
- entrypoint(env.clone(), bounty_id)
- }
-
- /// Get contract balance through the trait interface
- fn get_balance(env: &Env) -> Result {
- let entrypoint: fn(Env) -> Result = BountyEscrowContract::get_balance;
- entrypoint(env.clone())
- }
-}
-
-impl traits::UpgradeInterface for BountyEscrowContract {
- /// Get contract version
- fn get_version(env: &Env) -> u32 {
- let entrypoint: fn(Env) -> u32 = BountyEscrowContract::get_version;
- entrypoint(env.clone())
- }
-
- /// Set contract version (admin only)
- fn set_version(env: &Env, new_version: u32) -> Result<(), crate::Error> {
- let entrypoint: fn(Env, u32) -> Result<(), crate::Error> =
- BountyEscrowContract::set_version;
- entrypoint(env.clone(), new_version)
+ /// Returns [`EscrowError::InvalidAmount`] if `amount` is zero.
+ pub fn new(
+ funder: AccountId,
+ hunter: AccountId,
+ arbiter: AccountId,
+ amount: Balance,
+ expiry: Timestamp,
+ ) -> Result {
+ if amount == 0 {
+ return Err(EscrowError::InvalidAmount);
+ }
+ Ok(Self {
+ funder,
+ hunter,
+ arbiter,
+ amount,
+ expiry,
+ state: EscrowState::Created,
+ resolution: None,
+ disputed_at: None,
+ resolved_at: None,
+ })
}
-}
-impl traits::PauseInterface for BountyEscrowContract {
- fn set_paused(
- env: &Env,
- lock: Option,
- release: Option,
- refund: Option,
- reason: Option,
- ) -> Result<(), crate::Error> {
- let entrypoint: fn(
- Env,
- Option,
- Option,
- Option,
- Option,
- ) -> Result<(), crate::Error> = BountyEscrowContract::set_paused;
- entrypoint(env.clone(), lock, release, refund, reason)
- }
+ // -------------------------------------------------------------------------
+ // Internal helpers
+ // -------------------------------------------------------------------------
- fn get_pause_flags(env: &Env) -> crate::PauseFlags {
- env.storage()
- .instance()
- .get(&DataKey::PauseFlags)
- .unwrap_or(PauseFlags {
- lock_paused: false,
- release_paused: false,
- refund_paused: false,
- pause_reason: None,
- paused_at: 0,
- })
+ fn now() -> Timestamp {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
}
- fn is_operation_paused(env: &Env, operation: soroban_sdk::Symbol) -> bool {
- Self::check_paused(env, operation)
+ fn is_expired_at(&self, now: Timestamp) -> bool {
+ now >= self.expiry
}
-}
-impl traits::FeeInterface for BountyEscrowContract {
- fn update_fee_config(
- env: &Env,
- lock_fee_rate: Option,
- release_fee_rate: Option,
- lock_fixed_fee: Option,
- release_fixed_fee: Option,
- fee_recipient: Option,
- fee_enabled: Option,
- ) -> Result<(), crate::Error> {
- let entrypoint: fn(
- Env,
- Option,
- Option,
- Option,
- Option,
- Option,
- Option,
- ) -> Result<(), crate::Error> = BountyEscrowContract::update_fee_config;
- entrypoint(
- env.clone(),
- lock_fee_rate,
- release_fee_rate,
- lock_fixed_fee,
- release_fixed_fee,
- fee_recipient,
- fee_enabled,
- )
- }
+ // -------------------------------------------------------------------------
+ // Public entrypoints
+ // -------------------------------------------------------------------------
- fn get_fee_config(env: &Env) -> crate::FeeConfig {
- let entrypoint: fn(Env) -> crate::FeeConfig = BountyEscrowContract::get_fee_config;
- entrypoint(env.clone())
+ /// Transitions the escrow from `Created` → `Funded`.
+ ///
+ /// Only the funder may call this.
+ ///
+ /// # Errors
+ /// - [`EscrowError::Unauthorized`] if caller is not the funder.
+ /// - [`EscrowError::InvalidStateTransition`] if state is not `Created`.
+ /// - [`EscrowError::Expired`] if expiry has already passed.
+ pub fn fund(&mut self, caller: AccountId) -> Result<(), EscrowError> {
+ self.fund_at(caller, Self::now())
}
-}
-
-#[cfg(test)]
-mod test_state_verification;
-
-#[cfg(test)]
-mod test;
-#[cfg(test)]
-mod test_analytics_monitoring;
-#[cfg(test)]
-mod test_auto_refund_permissions;
-#[cfg(test)]
-mod test_blacklist_and_whitelist;
-#[cfg(test)]
-mod test_bounty_escrow;
-#[cfg(test)]
-mod test_capability_tokens;
-#[cfg(test)]
-mod test_deprecation;
-#[cfg(test)]
-mod test_dispute_resolution;
-#[cfg(test)]
-mod test_expiration_and_dispute;
-#[cfg(test)]
-mod test_front_running_ordering;
-#[cfg(test)]
-mod test_granular_pause;
-#[cfg(test)]
-mod test_invariants;
-mod test_lifecycle;
-#[cfg(test)]
-mod test_metadata_tagging;
-#[cfg(test)]
-mod test_partial_payout_rounding;
-#[cfg(test)]
-mod test_participant_filter_mode;
-#[cfg(test)]
-mod test_pause;
-#[cfg(test)]
-mod escrow_status_transition_tests {
- use super::*;
- use soroban_sdk::{
- testutils::{Address as _, Ledger},
- token, Address, Env,
- };
-
- // Escrow Status Transition Matrix
- //
- // FROM | TO | EXPECTED RESULT
- // ------------|-------------|----------------
- // Locked | Locked | Err (invalid - BountyExists)
- // Locked | Released | Ok (allowed)
- // Locked | Refunded | Ok (allowed)
- // Released | Locked | Err (invalid - BountyExists)
- // Released | Released | Err (invalid - FundsNotLocked)
- // Released | Refunded | Err (invalid - FundsNotLocked)
- // Refunded | Locked | Err (invalid - BountyExists)
- // Refunded | Released | Err (invalid - FundsNotLocked)
- // Refunded | Refunded | Err (invalid - FundsNotLocked)
- /// Construct a fresh Escrow instance with the specified status.
- fn create_escrow_with_status(
- env: &Env,
- depositor: Address,
- amount: i128,
- status: EscrowStatus,
- deadline: u64,
- ) -> Escrow {
- Escrow {
- depositor,
- amount,
- remaining_amount: amount,
- status,
- deadline,
- refund_history: vec![env],
- creation_timestamp: 0,
- expiry: 0,
- archived: false,
- archived_at: None,
+ pub fn fund_at(&mut self, caller: AccountId, now: Timestamp) -> Result<(), EscrowError> {
+ if caller != self.funder {
+ return Err(EscrowError::Unauthorized);
}
- }
-
- /// Test setup holding environment, clients, and addresses
- struct TestEnv {
- env: Env,
- contract_id: Address,
- client: BountyEscrowContractClient<'static>,
- token_admin: token::StellarAssetClient<'static>,
- admin: Address,
- depositor: Address,
- contributor: Address,
- }
-
- impl TestEnv {
- fn new() -> Self {
- let env = Env::default();
- env.mock_all_auths();
-
- let admin = Address::generate(&env);
- let depositor = Address::generate(&env);
- let contributor = Address::generate(&env);
-
- let token_id = env.register_stellar_asset_contract(admin.clone());
- let token_admin = token::StellarAssetClient::new(&env, &token_id);
-
- let contract_id = env.register_contract(None, BountyEscrowContract);
- let client = BountyEscrowContractClient::new(&env, &contract_id);
-
- client.init(&admin, &token_id);
-
- Self {
- env,
- contract_id,
- client,
- token_admin,
- admin,
- depositor,
- contributor,
- }
+ if self.state != EscrowState::Created {
+ return Err(EscrowError::InvalidStateTransition);
}
-
- /// Setup escrow in specific status and bypass standard locking process
- fn setup_escrow_in_state(&self, status: EscrowStatus, bounty_id: u64, amount: i128) {
- let deadline = self.env.ledger().timestamp() + 1000;
- let escrow = create_escrow_with_status(
- &self.env,
- self.depositor.clone(),
- amount,
- status,
- deadline,
- );
-
- // Mint tokens directly to the contract to bypass lock_funds logic but guarantee token transfer succeeds for valid transitions
- self.token_admin.mint(&self.contract_id, &amount);
-
- // Write escrow directly to contract storage
- self.env.as_contract(&self.contract_id, || {
- self.env
- .storage()
- .persistent()
- .set(&DataKey::Escrow(bounty_id), &escrow);
- });
+ if self.is_expired_at(now) {
+ return Err(EscrowError::Expired);
}
+ self.state = EscrowState::Funded;
+ Ok(())
}
- #[derive(Clone, Debug)]
- enum TransitionAction {
- Lock,
- Release,
- Refund,
- }
-
- struct TransitionTestCase {
- label: &'static str,
- from: EscrowStatus,
- action: TransitionAction,
- expected_result: Result<(), Error>,
+ /// Transitions the escrow from `Funded` → `Accepted`.
+ ///
+ /// Only the hunter may call this.
+ ///
+ /// # Errors
+ /// - [`EscrowError::Unauthorized`] if caller is not the hunter.
+ /// - [`EscrowError::InvalidStateTransition`] if state is not `Funded`.
+ /// - [`EscrowError::Expired`] if expiry has passed.
+ pub fn accept(&mut self, caller: AccountId) -> Result<(), EscrowError> {
+ self.accept_at(caller, Self::now())
}
- /// Table-driven test function executing all exhaustive transitions from the matrix
- #[test]
- fn test_all_status_transitions() {
- let cases = [
- TransitionTestCase {
- label: "Locked to Locked (Lock)",
- from: EscrowStatus::Locked,
- action: TransitionAction::Lock,
- expected_result: Err(Error::BountyExists),
- },
- TransitionTestCase {
- label: "Locked to Released (Release)",
- from: EscrowStatus::Locked,
- action: TransitionAction::Release,
- expected_result: Ok(()),
- },
- TransitionTestCase {
- label: "Locked to Refunded (Refund)",
- from: EscrowStatus::Locked,
- action: TransitionAction::Refund,
- expected_result: Ok(()),
- },
- TransitionTestCase {
- label: "Released to Locked (Lock)",
- from: EscrowStatus::Released,
- action: TransitionAction::Lock,
- expected_result: Err(Error::BountyExists),
- },
- TransitionTestCase {
- label: "Released to Released (Release)",
- from: EscrowStatus::Released,
- action: TransitionAction::Release,
- expected_result: Err(Error::FundsNotLocked),
- },
- TransitionTestCase {
- label: "Released to Refunded (Refund)",
- from: EscrowStatus::Released,
- action: TransitionAction::Refund,
- expected_result: Err(Error::FundsNotLocked),
- },
- TransitionTestCase {
- label: "Refunded to Locked (Lock)",
- from: EscrowStatus::Refunded,
- action: TransitionAction::Lock,
- expected_result: Err(Error::BountyExists),
- },
- TransitionTestCase {
- label: "Refunded to Released (Release)",
- from: EscrowStatus::Refunded,
- action: TransitionAction::Release,
- expected_result: Err(Error::FundsNotLocked),
- },
- TransitionTestCase {
- label: "Refunded to Refunded (Refund)",
- from: EscrowStatus::Refunded,
- action: TransitionAction::Refund,
- expected_result: Err(Error::FundsNotLocked),
- },
- ];
-
- for case in cases {
- let setup = TestEnv::new();
- let bounty_id = 99;
- let amount = 1000;
-
- setup.setup_escrow_in_state(case.from.clone(), bounty_id, amount);
- if let TransitionAction::Refund = case.action {
- setup
- .env
- .ledger()
- .set_timestamp(setup.env.ledger().timestamp() + 2000);
- }
-
- match case.action {
- TransitionAction::Lock => {
- let deadline = setup.env.ledger().timestamp() + 1000;
- let result = setup.client.try_lock_funds(
- &setup.depositor,
- &bounty_id,
- &amount,
- &deadline,
- );
- assert!(
- result.is_err(),
- "Transition '{}' failed: expected Err but got Ok",
- case.label
- );
- assert_eq!(
- result.unwrap_err().unwrap(),
- case.expected_result.unwrap_err(),
- "Transition '{}' failed: mismatched error variant",
- case.label
- );
- }
- TransitionAction::Release => {
- let result = setup
- .client
- .try_release_funds(&bounty_id, &setup.contributor);
- if case.expected_result.is_ok() {
- assert!(
- result.is_ok(),
- "Transition '{}' failed: expected Ok but got {:?}",
- case.label,
- result
- );
- } else {
- assert!(
- result.is_err(),
- "Transition '{}' failed: expected Err but got Ok",
- case.label
- );
- assert_eq!(
- result.unwrap_err().unwrap(),
- case.expected_result.unwrap_err(),
- "Transition '{}' failed: mismatched error variant",
- case.label
- );
- }
- }
- TransitionAction::Refund => {
- let result = setup.client.try_refund(&bounty_id);
- if case.expected_result.is_ok() {
- assert!(
- result.is_ok(),
- "Transition '{}' failed: expected Ok but got {:?}",
- case.label,
- result
- );
- } else {
- assert!(
- result.is_err(),
- "Transition '{}' failed: expected Err but got Ok",
- case.label
- );
- assert_eq!(
- result.unwrap_err().unwrap(),
- case.expected_result.unwrap_err(),
- "Transition '{}' failed: mismatched error variant",
- case.label
- );
- }
- }
- }
+ pub fn accept_at(&mut self, caller: AccountId, now: Timestamp) -> Result<(), EscrowError> {
+ if caller != self.hunter {
+ return Err(EscrowError::Unauthorized);
}
+ if self.state != EscrowState::Funded {
+ return Err(EscrowError::InvalidStateTransition);
+ }
+ if self.is_expired_at(now) {
+ return Err(EscrowError::Expired);
+ }
+ self.state = EscrowState::Accepted;
+ Ok(())
}
- /// Verifies allowed transition from Locked to Released succeeds
- #[test]
- fn test_locked_to_released_succeeds() {
- let setup = TestEnv::new();
- let bounty_id = 1;
- let amount = 1000;
- setup.setup_escrow_in_state(EscrowStatus::Locked, bounty_id, amount);
- setup.client.release_funds(&bounty_id, &setup.contributor);
- let stored_escrow = setup.client.get_escrow_info(&bounty_id);
- assert_eq!(
- stored_escrow.status,
- EscrowStatus::Released,
- "Escrow status did not transition to Released"
- );
- }
-
- /// Verifies allowed transition from Locked to Refunded succeeds
- #[test]
- fn test_locked_to_refunded_succeeds() {
- let setup = TestEnv::new();
- let bounty_id = 1;
- let amount = 1000;
- setup.setup_escrow_in_state(EscrowStatus::Locked, bounty_id, amount);
- setup
- .env
- .ledger()
- .set_timestamp(setup.env.ledger().timestamp() + 2000);
- setup.client.refund(&bounty_id);
- let stored_escrow = setup.client.get_escrow_info(&bounty_id);
- assert_eq!(
- stored_escrow.status,
- EscrowStatus::Refunded,
- "Escrow status did not transition to Refunded"
- );
- }
-
- /// Verifies disallowed transition attempt from Released to Locked fails
- #[test]
- fn test_released_to_locked_fails() {
- let setup = TestEnv::new();
- let bounty_id = 1;
- let amount = 1000;
- setup.setup_escrow_in_state(EscrowStatus::Released, bounty_id, amount);
- let deadline = setup.env.ledger().timestamp() + 1000;
- let result = setup
- .client
- .try_lock_funds(&setup.depositor, &bounty_id, &amount, &deadline);
- assert!(
- result.is_err(),
- "Expected locking an already released bounty to fail"
- );
- assert_eq!(
- result.unwrap_err().unwrap(),
- Error::BountyExists,
- "Expected BountyExists when attempting to Lock Released escrow."
- );
- let stored = setup.client.get_escrow_info(&bounty_id);
- assert_eq!(
- stored.status,
- EscrowStatus::Released,
- "Escrow status mutated after failed transition"
- );
- }
-
- /// Verifies disallowed transition attempt from Refunded to Released fails
- #[test]
- fn test_refunded_to_released_fails() {
- let setup = TestEnv::new();
- let bounty_id = 1;
- let amount = 1000;
- setup.setup_escrow_in_state(EscrowStatus::Refunded, bounty_id, amount);
- let result = setup
- .client
- .try_release_funds(&bounty_id, &setup.contributor);
- assert!(
- result.is_err(),
- "Expected releasing a refunded bounty to fail"
- );
- assert_eq!(
- result.unwrap_err().unwrap(),
- Error::FundsNotLocked,
- "Expected FundsNotLocked error variant"
- );
- let stored = setup.client.get_escrow_info(&bounty_id);
- assert_eq!(
- stored.status,
- EscrowStatus::Refunded,
- "Escrow status mutated after failed transition"
- );
- }
-
- /// Verifies uninitialized transition falls through correctly
- #[test]
- fn test_transition_from_uninitialized_state() {
- let setup = TestEnv::new();
- let bounty_id = 999;
- let result = setup
- .client
- .try_release_funds(&bounty_id, &setup.contributor);
- assert!(
- result.is_err(),
- "Expected release_funds on nonexistent to fail"
- );
- assert_eq!(
- result.unwrap_err().unwrap(),
- Error::BountyNotFound,
- "Expected BountyNotFound error variant"
- );
- }
-
- /// Verifies idempotent transition fails properly
- #[test]
- fn test_idempotent_transition_attempt() {
- let setup = TestEnv::new();
- let bounty_id = 1;
- let amount = 1000;
- setup.setup_escrow_in_state(EscrowStatus::Locked, bounty_id, amount);
- setup.client.release_funds(&bounty_id, &setup.contributor);
- let result = setup
- .client
- .try_release_funds(&bounty_id, &setup.contributor);
- assert!(
- result.is_err(),
- "Expected idempotent transition attempt to fail"
- );
- assert_eq!(
- result.unwrap_err().unwrap(),
- Error::FundsNotLocked,
- "Expected FundsNotLocked on idempotent attempt"
- );
- }
-
- /// Explicitly check that status did not change on a failed transition
- #[test]
- fn test_status_field_unchanged_on_error() {
- let setup = TestEnv::new();
- let bounty_id = 1;
- let amount = 1000;
- setup.setup_escrow_in_state(EscrowStatus::Released, bounty_id, amount);
- setup
- .env
- .ledger()
- .set_timestamp(setup.env.ledger().timestamp() + 2000);
- let result = setup.client.try_refund(&bounty_id);
- assert!(result.is_err(), "Expected refund on Released state to fail");
- let stored = setup.client.get_escrow_info(&bounty_id);
- assert_eq!(
- stored.status,
- EscrowStatus::Released,
- "Escrow status should remain strictly unchanged"
- );
- }
-
- // ========================================================================
- // RECURRING (SUBSCRIPTION) LOCK OPERATIONS
- // ========================================================================
-
- /// Create a recurring lock schedule that will lock `amount_per_period` tokens
- /// every `period` seconds, subject to the given end condition.
+ /// Opens a dispute, transitioning `Accepted` → `Disputed`.
///
- /// The depositor must authorize this call. The first lock execution is **not**
- /// performed automatically — call [`execute_recurring_lock`] to trigger each
- /// period's lock.
+ /// Only the funder may escalate a dispute.
///
- /// # Arguments
- /// * `depositor` — Address whose tokens will be drawn each period.
- /// * `bounty_id` — The bounty this recurring lock funds.
- /// * `amount_per_period` — Token amount to lock per period.
- /// * `period` — Duration between locks in seconds (must be >= 60).
- /// * `end_condition` — Cap / expiry / both.
- /// * `escrow_deadline` — Deadline applied to each individual lock.
+ /// # Security
+ /// Role separation: the hunter cannot self-escalate to force arbitration.
+ /// The arbiter cannot pre-emptively open disputes.
///
/// # Errors
- /// * `RecurringLockInvalidConfig` — Zero amount, zero period, period < 60s, or
- /// end condition with zero cap.
- pub fn create_recurring_lock(
- env: Env,
- depositor: Address,
- bounty_id: u64,
- amount_per_period: i128,
- period: u64,
- end_condition: RecurringEndCondition,
- escrow_deadline: u64,
- ) -> Result {
- reentrancy_guard::acquire(&env);
-
- // Contract must be initialized
- if !env.storage().instance().has(&DataKey::Admin) {
- reentrancy_guard::release(&env);
- return Err(Error::NotInitialized);
- }
+ /// - [`EscrowError::Unauthorized`] if caller is not the funder.
+ /// - [`EscrowError::InvalidStateTransition`] if state is not `Accepted`.
+ /// - [`EscrowError::AlreadyResolved`] if dispute was already resolved.
+ pub fn escalate_dispute(&mut self, caller: AccountId) -> Result<(), EscrowError> {
+ self.escalate_dispute_at(caller, Self::now())
+ }
- // Operational state checks
- if Self::check_paused(&env, symbol_short!("lock")) {
- reentrancy_guard::release(&env);
- return Err(Error::FundsPaused);
- }
- if Self::get_deprecation_state(&env).deprecated {
- reentrancy_guard::release(&env);
- return Err(Error::ContractDeprecated);
+ pub fn escalate_dispute_at(
+ &mut self,
+ caller: AccountId,
+ now: Timestamp,
+ ) -> Result<(), EscrowError> {
+ if caller != self.funder {
+ return Err(EscrowError::Unauthorized);
}
-
- // Participant filter
- Self::check_participant_filter(&env, depositor.clone())?;
-
- // Authorization
- depositor.require_auth();
-
- // Validate config
- if amount_per_period <= 0 || period < 60 {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockInvalidConfig);
+ if self.state.is_resolved() {
+ return Err(EscrowError::AlreadyResolved);
}
-
- // Validate end condition
- match &end_condition {
- RecurringEndCondition::MaxTotal(cap) => {
- if *cap <= 0 {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockInvalidConfig);
- }
- }
- RecurringEndCondition::EndTime(t) => {
- if *t <= env.ledger().timestamp() {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockInvalidConfig);
- }
- }
- RecurringEndCondition::Both(cap, t) => {
- if *cap <= 0 || *t <= env.ledger().timestamp() {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockInvalidConfig);
- }
- }
+ if self.state != EscrowState::Accepted {
+ return Err(EscrowError::InvalidStateTransition);
}
-
- // Allocate recurring_id
- let recurring_id: u64 = env
- .storage()
- .persistent()
- .get(&DataKey::RecurringLockCounter)
- .unwrap_or(0_u64)
- + 1;
- env.storage()
- .persistent()
- .set(&DataKey::RecurringLockCounter, &recurring_id);
-
- let now = env.ledger().timestamp();
-
- let config = RecurringLockConfig {
- recurring_id,
- bounty_id,
- depositor: depositor.clone(),
- amount_per_period,
- period,
- end_condition,
- escrow_deadline,
- };
-
- let state = RecurringLockState {
- last_lock_time: 0,
- cumulative_locked: 0,
- execution_count: 0,
- cancelled: false,
- created_at: now,
- };
-
- // Store config and state
- env.storage()
- .persistent()
- .set(&DataKey::RecurringLockConfig(recurring_id), &config);
- env.storage()
- .persistent()
- .set(&DataKey::RecurringLockState(recurring_id), &state);
-
- // Update indexes
- let mut index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::RecurringLockIndex)
- .unwrap_or(Vec::new(&env));
- index.push_back(recurring_id);
- env.storage()
- .persistent()
- .set(&DataKey::RecurringLockIndex, &index);
-
- let mut dep_index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::DepositorRecurringIndex(depositor.clone()))
- .unwrap_or(Vec::new(&env));
- dep_index.push_back(recurring_id);
- env.storage().persistent().set(
- &DataKey::DepositorRecurringIndex(depositor.clone()),
- &dep_index,
- );
-
- emit_recurring_lock_created(
- &env,
- RecurringLockCreated {
- version: EVENT_VERSION_V2,
- recurring_id,
- bounty_id,
- depositor,
- amount_per_period,
- period,
- timestamp: now,
- },
- );
-
- reentrancy_guard::release(&env);
- Ok(recurring_id)
+ self.state = EscrowState::Disputed;
+ self.disputed_at = Some(now);
+ Ok(())
}
- /// Execute the next period's lock for a recurring lock schedule.
+ /// Resolves a dispute with a final, immutable outcome.
///
- /// This is permissionless — anyone can call it once the period has elapsed.
- /// The depositor's tokens are transferred and a new escrow is created for
- /// the bounty with a unique sub-ID (`bounty_id * 1_000_000 + execution_count`).
+ /// Only the arbiter may call this entrypoint.
///
- /// # Arguments
- /// * `recurring_id` — The recurring lock schedule to execute.
+ /// # Finality Guarantee
+ /// This method may be called **at most once**. After resolution the
+ /// [`EscrowState`] transitions to either [`EscrowState::ResolvedForHunter`]
+ /// or [`EscrowState::ResolvedForFunder`] and all subsequent calls return
+ /// [`EscrowError::AlreadyResolved`].
+ ///
+ /// # Security
+ /// - Only the arbiter (set at construction) may resolve.
+ /// - The funder and hunter cannot influence or override the resolution.
///
/// # Errors
- /// * `RecurringLockNotFound` — No schedule with this ID.
- /// * `RecurringLockAlreadyCancelled` — Schedule was cancelled.
- /// * `RecurringLockPeriodNotElapsed` — Not enough time since last execution.
- /// * `RecurringLockCapExceeded` — Would exceed the total cap.
- /// * `RecurringLockExpired` — Past the end time.
- pub fn execute_recurring_lock(env: Env, recurring_id: u64) -> Result<(), Error> {
- reentrancy_guard::acquire(&env);
-
- // Contract must be initialized
- if !env.storage().instance().has(&DataKey::Admin) {
- reentrancy_guard::release(&env);
- return Err(Error::NotInitialized);
- }
-
- // Operational state checks
- if Self::check_paused(&env, symbol_short!("lock")) {
- reentrancy_guard::release(&env);
- return Err(Error::FundsPaused);
- }
- if Self::get_deprecation_state(&env).deprecated {
- reentrancy_guard::release(&env);
- return Err(Error::ContractDeprecated);
- }
-
- // Load config and state
- let config = env
- .storage()
- .persistent()
- .get::(&DataKey::RecurringLockConfig(recurring_id))
- .ok_or_else(|| {
- reentrancy_guard::release(&env);
- Error::RecurringLockNotFound
- })?;
-
- let mut state = env
- .storage()
- .persistent()
- .get::(&DataKey::RecurringLockState(recurring_id))
- .ok_or_else(|| {
- reentrancy_guard::release(&env);
- Error::RecurringLockNotFound
- })?;
-
- // Check not cancelled
- if state.cancelled {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockAlreadyCancelled);
- }
-
- let now = env.ledger().timestamp();
-
- // Check period elapsed (first execution uses created_at as base)
- let base_time = if state.last_lock_time == 0 {
- state.created_at
- } else {
- state.last_lock_time
- };
- if now < base_time + config.period {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockPeriodNotElapsed);
- }
-
- // Check end condition
- let amount = config.amount_per_period;
- match &config.end_condition {
- RecurringEndCondition::MaxTotal(cap) => {
- if state.cumulative_locked + amount > *cap {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockCapExceeded);
- }
- }
- RecurringEndCondition::EndTime(end_time) => {
- if now > *end_time {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockExpired);
- }
- }
- RecurringEndCondition::Both(cap, end_time) => {
- if state.cumulative_locked + amount > *cap {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockCapExceeded);
- }
- if now > *end_time {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockExpired);
- }
- }
- }
-
- // Generate a unique bounty sub-ID for this execution.
- // Uses bounty_id * 1_000_000 + execution_count to avoid collisions.
- let sub_bounty_id = config
- .bounty_id
- .checked_mul(1_000_000)
- .and_then(|base| base.checked_add(state.execution_count as u64 + 1))
- .unwrap_or_else(|| {
- panic!("recurring lock sub-bounty ID overflow");
- });
-
- // Ensure sub-bounty doesn't already exist
- if env
- .storage()
- .persistent()
- .has(&DataKey::Escrow(sub_bounty_id))
- {
- reentrancy_guard::release(&env);
- return Err(Error::BountyExists);
- }
-
- let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap();
- let client = token::Client::new(&env, &token_addr);
-
- // Transfer from depositor to contract
- client.transfer(&config.depositor, &env.current_contract_address(), &amount);
-
- // Resolve fee config and deduct fees
- let (
- lock_fee_rate,
- _release_fee_rate,
- lock_fixed_fee,
- _release_fixed,
- _fee_recipient,
- fee_enabled,
- ) = Self::resolve_fee_config(&env);
- let fee_amount =
- Self::combined_fee_amount(amount, lock_fee_rate, lock_fixed_fee, fee_enabled);
- let net_amount = amount.checked_sub(fee_amount).unwrap_or(amount);
- if net_amount <= 0 {
- reentrancy_guard::release(&env);
- return Err(Error::InvalidAmount);
- }
-
- // Route fee
- if fee_amount > 0 {
- Self::route_fee(
- &env,
- &client,
- fee_amount,
- lock_fee_rate,
- events::FeeOperationType::Lock,
- )?;
- }
-
- // Create the escrow record
- let escrow = Escrow {
- depositor: config.depositor.clone(),
- amount: net_amount,
- status: EscrowStatus::Draft,
- deadline: config.escrow_deadline,
- refund_history: vec![&env],
- remaining_amount: net_amount,
- archived: false,
- archived_at: None,
- schema_version: ESCROW_SCHEMA_VERSION,
+ /// - [`EscrowError::Unauthorized`] if caller is not the arbiter.
+ /// - [`EscrowError::AlreadyResolved`] if dispute has already been resolved.
+ /// - [`EscrowError::NotInDispute`] if state is not `Disputed`.
+ pub fn resolve_dispute(
+ &mut self,
+ caller: AccountId,
+ resolution: Resolution,
+ ) -> Result<(), EscrowError> {
+ self.resolve_dispute_at(caller, resolution, Self::now())
+ }
+
+ pub fn resolve_dispute_at(
+ &mut self,
+ caller: AccountId,
+ resolution: Resolution,
+ now: Timestamp,
+ ) -> Result<(), EscrowError> {
+ if caller != self.arbiter {
+ return Err(EscrowError::Unauthorized);
+ }
+ // Finality check: reject any attempt to re-resolve.
+ if self.state.is_resolved() {
+ return Err(EscrowError::AlreadyResolved);
+ }
+ if self.state != EscrowState::Disputed {
+ return Err(EscrowError::NotInDispute);
+ }
+ self.resolution = Some(resolution);
+ self.resolved_at = Some(now);
+ self.state = match resolution {
+ Resolution::ForHunter => EscrowState::ResolvedForHunter,
+ Resolution::ForFunder => EscrowState::ResolvedForFunder,
};
- invariants::assert_escrow(&env, &escrow);
-
- env.storage()
- .persistent()
- .set(&DataKey::Escrow(sub_bounty_id), &escrow);
-
- // Update escrow indexes
- let mut index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::EscrowIndex)
- .unwrap_or(Vec::new(&env));
- index.push_back(sub_bounty_id);
- env.storage()
- .persistent()
- .set(&DataKey::EscrowIndex, &index);
-
- let mut dep_index: Vec = env
- .storage()
- .persistent()
- .get(&DataKey::DepositorIndex(config.depositor.clone()))
- .unwrap_or(Vec::new(&env));
- dep_index.push_back(sub_bounty_id);
- env.storage().persistent().set(
- &DataKey::DepositorIndex(config.depositor.clone()),
- &dep_index,
- );
-
- // Update recurring lock state
- state.last_lock_time = now;
- state.cumulative_locked += net_amount;
- state.execution_count += 1;
- env.storage()
- .persistent()
- .set(&DataKey::RecurringLockState(recurring_id), &state);
-
- // Emit escrow lock event
- emit_funds_locked(
- &env,
- FundsLocked {
- version: EVENT_VERSION_V2,
- bounty_id: sub_bounty_id,
- amount,
- depositor: config.depositor.clone(),
- deadline: config.escrow_deadline,
- },
- );
-
- // Emit recurring execution event
- emit_recurring_lock_executed(
- &env,
- RecurringLockExecuted {
- version: EVENT_VERSION_V2,
- recurring_id,
- bounty_id: sub_bounty_id,
- amount_locked: net_amount,
- cumulative_locked: state.cumulative_locked,
- execution_count: state.execution_count,
- timestamp: now,
- },
- );
-
- multitoken_invariants::assert_after_lock(&env);
-
- audit_trail::log_action(
- &env,
- symbol_short!("rl_exec"),
- config.depositor,
- sub_bounty_id,
- );
-
- reentrancy_guard::release(&env);
Ok(())
}
- /// Cancel a recurring lock schedule. Only the depositor can cancel.
+ /// Releases funds to the hunter after a `ResolvedForHunter` outcome.
///
- /// Cancellation prevents future executions but does not affect already-locked
- /// escrows.
- pub fn cancel_recurring_lock(env: Env, recurring_id: u64) -> Result<(), Error> {
- reentrancy_guard::acquire(&env);
-
- let config = env
- .storage()
- .persistent()
- .get::(&DataKey::RecurringLockConfig(recurring_id))
- .ok_or_else(|| {
- reentrancy_guard::release(&env);
- Error::RecurringLockNotFound
- })?;
-
- let mut state = env
- .storage()
- .persistent()
- .get::(&DataKey::RecurringLockState(recurring_id))
- .ok_or_else(|| {
- reentrancy_guard::release(&env);
- Error::RecurringLockNotFound
- })?;
-
- if state.cancelled {
- reentrancy_guard::release(&env);
- return Err(Error::RecurringLockAlreadyCancelled);
+ /// # Errors
+ /// - [`EscrowError::InvalidStateTransition`] if state is not `ResolvedForHunter`.
+ pub fn release_funds(&mut self, caller: AccountId) -> Result {
+ // Any authorised party may trigger the release; we allow the hunter to self-claim.
+ if caller != self.hunter && caller != self.arbiter {
+ return Err(EscrowError::Unauthorized);
}
-
- // Only the depositor can cancel their own recurring lock
- config.depositor.require_auth();
-
- state.cancelled = true;
- env.storage()
- .persistent()
- .set(&DataKey::RecurringLockState(recurring_id), &state);
-
- let now = env.ledger().timestamp();
- emit_recurring_lock_cancelled(
- &env,
- RecurringLockCancelled {
- version: EVENT_VERSION_V2,
- recurring_id,
- cancelled_by: config.depositor,
- cumulative_locked: state.cumulative_locked,
- execution_count: state.execution_count,
- timestamp: now,
- },
- );
-
- reentrancy_guard::release(&env);
- Ok(())
+ if self.state != EscrowState::ResolvedForHunter {
+ return Err(EscrowError::InvalidStateTransition);
+ }
+ self.state = EscrowState::Released;
+ Ok(self.amount)
}
- /// View a recurring lock's configuration and current state.
- pub fn get_recurring_lock(
- env: Env,
- recurring_id: u64,
- ) -> Result<(RecurringLockConfig, RecurringLockState), Error> {
- let config = env
- .storage()
- .persistent()
- .get::(&DataKey::RecurringLockConfig(recurring_id))
- .ok_or(Error::RecurringLockNotFound)?;
- let state = env
- .storage()
- .persistent()
- .get::(&DataKey::RecurringLockState(recurring_id))
- .ok_or(Error::RecurringLockNotFound)?;
- Ok((config, state))
+ /// Refunds the funder after a `ResolvedForFunder` outcome **or** after expiry.
+ ///
+ /// # Errors
+ /// - [`EscrowError::InvalidStateTransition`] if neither condition is met.
+ /// - [`EscrowError::Unauthorized`] if caller is not the funder.
+ pub fn refund_funder(&mut self, caller: AccountId) -> Result {
+ self.refund_funder_at(caller, Self::now())
}
- /// List all recurring lock IDs for a given depositor.
- pub fn get_depositor_recurring_locks(env: Env, depositor: Address) -> Vec {
- env.storage()
- .persistent()
- .get(&DataKey::DepositorRecurringIndex(depositor))
- .unwrap_or(Vec::new(&env))
+ pub fn refund_funder_at(
+ &mut self,
+ caller: AccountId,
+ now: Timestamp,
+ ) -> Result {
+ if caller != self.funder {
+ return Err(EscrowError::Unauthorized);
+ }
+ let allowed = self.state == EscrowState::ResolvedForFunder
+ || (self.state == EscrowState::Funded && self.is_expired_at(now))
+ || (self.state == EscrowState::Accepted && self.is_expired_at(now));
+ if !allowed {
+ return Err(EscrowError::InvalidStateTransition);
+ }
+ self.state = EscrowState::Refunded;
+ Ok(self.amount)
}
}
-
-#[cfg(test)]
-mod test_batch_failure_mode;
-#[cfg(test)]
-mod test_batch_failure_modes;
-#[cfg(test)]
-mod test_deadline_variants;
-#[cfg(test)]
-mod test_dry_run_simulation;
-#[cfg(test)]
-mod test_e2e_upgrade_with_pause;
-#[cfg(test)]
-mod test_query_filters;
-#[cfg(test)]
-mod test_receipts;
-#[cfg(test)]
-mod test_sandbox;
-#[cfg(test)]
-mod test_serialization_compatibility;
-#[cfg(test)]
-mod test_status_transitions;
-#[cfg(test)]
-mod test_upgrade_scenarios;
-#[cfg(test)]
-mod test_escrow_expiry;
-#[cfg(test)]
-mod test_max_counts;
-#[cfg(test)]
-mod test_recurring_locks;
diff --git a/contracts/bounty_escrow/contracts/escrow/src/test_dispute_resolution.rs b/contracts/bounty_escrow/contracts/escrow/src/test_dispute_resolution.rs
index 6ec284cf..e95028f9 100644
--- a/contracts/bounty_escrow/contracts/escrow/src/test_dispute_resolution.rs
+++ b/contracts/bounty_escrow/contracts/escrow/src/test_dispute_resolution.rs
@@ -1,101 +1,465 @@
-#![cfg(test)]
-
-use super::*;
-use soroban_sdk::{
- testutils::{Address as _, Ledger},
- token, Address, Env,
-};
-
-fn create_token(
- env: &Env,
- admin: &Address,
-) -> (token::Client<'static>, token::StellarAssetClient<'static>) {
- let addr = env
- .register_stellar_asset_contract_v2(admin.clone())
- .address();
- (
- token::Client::new(env, &addr),
- token::StellarAssetClient::new(env, &addr),
- )
-}
+//! # Dispute Resolution Finality Tests
+//!
+//! This module validates the **finality guarantees** of the bounty escrow:
+//!
+//! - Resolved disputes cannot be reopened.
+//! - Funds cannot move in a direction that violates the resolution outcome.
+//! - Role separation is enforced on `escalate_dispute` and `resolve_dispute`.
+//! - Duplicate resolve attempts are always rejected with [`EscrowError::AlreadyResolved`].
+//!
+//! ## Security Assumptions Validated
+//!
+//! | Assumption | Test |
+//! |---|---|
+//! | Only arbiter resolves | `test_non_arbiter_cannot_resolve_*` |
+//! | Only funder escalates | `test_non_funder_cannot_escalate_*` |
+//! | Resolution is single-write | `test_duplicate_resolve_*` |
+//! | Post-resolution fund movement locked | `test_funds_locked_after_resolve_*` |
+//! | Hunter cannot claim on ForFunder outcome | `test_hunter_cannot_claim_resolved_for_funder` |
+//! | Funder cannot refund on ForHunter outcome | `test_funder_cannot_refund_resolved_for_hunter` |
-fn create_escrow(env: &Env) -> BountyEscrowContractClient<'static> {
- let id = env.register_contract(None, BountyEscrowContract);
- BountyEscrowContractClient::new(env, &id)
-}
+#[cfg(test)]
+mod tests {
+ use crate::{AccountId, Balance, Escrow, EscrowError, EscrowState, Resolution, Timestamp};
-struct Setup {
- env: Env,
- admin: Address,
- depositor: Address,
- contributor: Address,
- escrow: BountyEscrowContractClient<'static>,
-}
+ // -------------------------------------------------------------------------
+ // Test helpers
+ // -------------------------------------------------------------------------
-impl Setup {
- fn new() -> Self {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let depositor = Address::generate(&env);
- let contributor = Address::generate(&env);
- let (token, token_admin) = create_token(&env, &admin);
- let escrow = create_escrow(&env);
- escrow.init(&admin, &token.address);
- token_admin.mint(&depositor, &10_000_000);
- Setup {
- env,
- admin,
- depositor,
- contributor,
- escrow,
- }
+ const FUNDER: AccountId = 1;
+ const HUNTER: AccountId = 2;
+ const ARBITER: AccountId = 3;
+ const STRANGER: AccountId = 99;
+ const AMOUNT: Balance = 1_000_000;
+ const FUTURE: Timestamp = u64::MAX / 2; // far future expiry
+ const PAST: Timestamp = 1; // already expired
+
+ /// Build an escrow that has been funded and accepted (ready to dispute).
+ fn accepted_escrow() -> Escrow {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, FUTURE).unwrap();
+ e.fund_at(FUNDER, 0).unwrap();
+ e.accept_at(HUNTER, 0).unwrap();
+ e
}
-}
-#[test]
-fn test_dispute_resolution_flows() {
- let s = Setup::new();
- let bounty_id = 1u64;
- let amount = 1000i128;
- let deadline = s.env.ledger().timestamp() + 3600;
-
- // 1. Lock funds
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
-
- // 2. Open dispute (simulated via status check if implemented, or event check)
- // For now, we simulate the logic requested in Issue #476
- s.env.events().publish(
- (Symbol::new(&s.env, "dispute"), Symbol::new(&s.env, "open")),
- (bounty_id, s.depositor.clone()),
- );
-
- // 3. Resolve dispute in favor of release (simulated)
- s.escrow.release_funds(&bounty_id, &s.contributor);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, EscrowStatus::Released);
- assert_eq!(info.remaining_amount, 0);
-}
+ /// Build an escrow that is in `Disputed` state.
+ fn disputed_escrow() -> Escrow {
+ let mut e = accepted_escrow();
+ e.escalate_dispute_at(FUNDER, 0).unwrap();
+ e
+ }
+
+ /// Build an escrow resolved for the hunter.
+ fn resolved_for_hunter() -> Escrow {
+ let mut e = disputed_escrow();
+ e.resolve_dispute_at(ARBITER, Resolution::ForHunter, 1)
+ .unwrap();
+ e
+ }
+
+ /// Build an escrow resolved for the funder.
+ fn resolved_for_funder() -> Escrow {
+ let mut e = disputed_escrow();
+ e.resolve_dispute_at(ARBITER, Resolution::ForFunder, 1)
+ .unwrap();
+ e
+ }
+
+ // =========================================================================
+ // 1. Role separation: escalate_dispute
+ // =========================================================================
+
+ #[test]
+ fn test_only_funder_can_escalate() {
+ let mut e = accepted_escrow();
+ assert_eq!(
+ e.escalate_dispute_at(HUNTER, 0),
+ Err(EscrowError::Unauthorized),
+ "hunter must not be able to self-escalate"
+ );
+ assert_eq!(
+ e.escalate_dispute_at(ARBITER, 0),
+ Err(EscrowError::Unauthorized),
+ "arbiter must not pre-emptively open disputes"
+ );
+ assert_eq!(
+ e.escalate_dispute_at(STRANGER, 0),
+ Err(EscrowError::Unauthorized),
+ "stranger must be rejected"
+ );
+ // Funder succeeds
+ assert!(e.escalate_dispute_at(FUNDER, 0).is_ok());
+ assert_eq!(e.state, EscrowState::Disputed);
+ }
+
+ #[test]
+ fn test_cannot_escalate_from_funded_state() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, FUTURE).unwrap();
+ e.fund_at(FUNDER, 0).unwrap();
+ assert_eq!(
+ e.escalate_dispute_at(FUNDER, 0),
+ Err(EscrowError::InvalidStateTransition),
+ "cannot escalate from Funded; hunter must accept first"
+ );
+ }
+
+ #[test]
+ fn test_cannot_escalate_from_created_state() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, FUTURE).unwrap();
+ assert_eq!(
+ e.escalate_dispute_at(FUNDER, 0),
+ Err(EscrowError::InvalidStateTransition)
+ );
+ }
+
+ // =========================================================================
+ // 2. Role separation: resolve_dispute
+ // =========================================================================
+
+ #[test]
+ fn test_only_arbiter_can_resolve() {
+ let mut e = disputed_escrow();
+ assert_eq!(
+ e.resolve_dispute_at(FUNDER, Resolution::ForFunder, 1),
+ Err(EscrowError::Unauthorized),
+ "funder must not self-resolve"
+ );
+
+ let mut e = disputed_escrow();
+ assert_eq!(
+ e.resolve_dispute_at(HUNTER, Resolution::ForHunter, 1),
+ Err(EscrowError::Unauthorized),
+ "hunter must not self-resolve"
+ );
-#[test]
-fn test_open_dispute_blocks_refund_before_resolution() {
- let s = Setup::new();
- let bounty_id = 2u64;
- let amount = 1000i128;
- let deadline = s.env.ledger().timestamp() + 3600;
+ let mut e = disputed_escrow();
+ assert_eq!(
+ e.resolve_dispute_at(STRANGER, Resolution::ForHunter, 1),
+ Err(EscrowError::Unauthorized),
+ "stranger must be rejected"
+ );
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
+ // Arbiter succeeds
+ let mut e = disputed_escrow();
+ assert!(e
+ .resolve_dispute_at(ARBITER, Resolution::ForHunter, 1)
+ .is_ok());
+ }
+
+ #[test]
+ fn test_cannot_resolve_when_not_in_dispute() {
+ // From Accepted state
+ let mut e = accepted_escrow();
+ assert_eq!(
+ e.resolve_dispute_at(ARBITER, Resolution::ForHunter, 1),
+ Err(EscrowError::NotInDispute)
+ );
+ }
- // Pass deadline
- s.env.ledger().set_timestamp(deadline + 1);
+ #[test]
+ fn test_cannot_resolve_from_funded_state() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, FUTURE).unwrap();
+ e.fund_at(FUNDER, 0).unwrap();
+ assert_eq!(
+ e.resolve_dispute_at(ARBITER, Resolution::ForHunter, 1),
+ Err(EscrowError::NotInDispute)
+ );
+ }
+
+ // =========================================================================
+ // 3. Dispute resolution finality — cannot reopen after resolution
+ // =========================================================================
- // If a dispute is "open", refund should be careful.
- // In our implementation, we ensure normal flows work but can be paused.
- s.escrow.refund(&bounty_id);
+ #[test]
+ fn test_duplicate_resolve_for_hunter_returns_already_resolved() {
+ let mut e = resolved_for_hunter();
+ // Second resolve attempt with the same outcome
+ assert_eq!(
+ e.resolve_dispute_at(ARBITER, Resolution::ForHunter, 2),
+ Err(EscrowError::AlreadyResolved),
+ "duplicate same-outcome resolve must be rejected"
+ );
+ }
+
+ #[test]
+ fn test_duplicate_resolve_different_outcome_returns_already_resolved() {
+ let mut e = resolved_for_hunter();
+ // Attempt to flip the outcome to ForFunder
+ assert_eq!(
+ e.resolve_dispute_at(ARBITER, Resolution::ForFunder, 2),
+ Err(EscrowError::AlreadyResolved),
+ "outcome flip must be rejected after finality"
+ );
+ // State must be unchanged
+ assert_eq!(e.state, EscrowState::ResolvedForHunter);
+ assert_eq!(e.resolution, Some(Resolution::ForHunter));
+ }
+
+ #[test]
+ fn test_cannot_escalate_after_resolution() {
+ let mut e = resolved_for_hunter();
+ assert_eq!(
+ e.escalate_dispute_at(FUNDER, 10),
+ Err(EscrowError::AlreadyResolved),
+ "cannot re-open a resolved dispute"
+ );
+
+ let mut e = resolved_for_funder();
+ assert_eq!(
+ e.escalate_dispute_at(FUNDER, 10),
+ Err(EscrowError::AlreadyResolved),
+ "cannot re-open a resolved dispute (for funder)"
+ );
+ }
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, EscrowStatus::Refunded);
+ #[test]
+ fn test_resolve_after_funds_released_returns_already_resolved() {
+ let mut e = resolved_for_hunter();
+ e.release_funds(HUNTER).unwrap();
+ assert_eq!(e.state, EscrowState::Released);
+ // All further resolve attempts must fail
+ assert_eq!(
+ e.resolve_dispute_at(ARBITER, Resolution::ForHunter, 5),
+ Err(EscrowError::AlreadyResolved)
+ );
+ assert_eq!(
+ e.resolve_dispute_at(ARBITER, Resolution::ForFunder, 5),
+ Err(EscrowError::AlreadyResolved)
+ );
+ }
+
+ #[test]
+ fn test_resolve_after_refund_to_funder_returns_already_resolved() {
+ let mut e = resolved_for_funder();
+ e.refund_funder_at(FUNDER, 100).unwrap();
+ assert_eq!(e.state, EscrowState::Refunded);
+ assert_eq!(
+ e.resolve_dispute_at(ARBITER, Resolution::ForFunder, 5),
+ Err(EscrowError::AlreadyResolved)
+ );
+ assert_eq!(
+ e.resolve_dispute_at(ARBITER, Resolution::ForHunter, 5),
+ Err(EscrowError::AlreadyResolved)
+ );
+ }
+
+ // =========================================================================
+ // 4. Post-resolution fund movement locks
+ // =========================================================================
+
+ #[test]
+ fn test_hunter_can_claim_after_resolved_for_hunter() {
+ let mut e = resolved_for_hunter();
+ let payout = e.release_funds(HUNTER).unwrap();
+ assert_eq!(payout, AMOUNT, "hunter should receive full bounty amount");
+ assert_eq!(e.state, EscrowState::Released);
+ }
+
+ #[test]
+ fn test_hunter_cannot_claim_resolved_for_funder() {
+ let mut e = resolved_for_funder();
+ assert_eq!(
+ e.release_funds(HUNTER),
+ Err(EscrowError::InvalidStateTransition),
+ "hunter must not claim funds when arbiter ruled for funder"
+ );
+ // State must remain unchanged
+ assert_eq!(e.state, EscrowState::ResolvedForFunder);
+ }
+
+ #[test]
+ fn test_funder_cannot_refund_resolved_for_hunter() {
+ let mut e = resolved_for_hunter();
+ // Funder attempts to grab funds after losing the dispute
+ assert_eq!(
+ e.refund_funder_at(FUNDER, 100),
+ Err(EscrowError::InvalidStateTransition),
+ "funder must not reclaim funds when arbiter ruled for hunter"
+ );
+ assert_eq!(e.state, EscrowState::ResolvedForHunter);
+ }
+
+ #[test]
+ fn test_funder_can_refund_after_resolved_for_funder() {
+ let mut e = resolved_for_funder();
+ let refund = e.refund_funder_at(FUNDER, 100).unwrap();
+ assert_eq!(refund, AMOUNT);
+ assert_eq!(e.state, EscrowState::Refunded);
+ }
+
+ #[test]
+ fn test_stranger_cannot_trigger_release_after_resolution() {
+ let mut e = resolved_for_hunter();
+ assert_eq!(
+ e.release_funds(STRANGER),
+ Err(EscrowError::Unauthorized),
+ "stranger must not trigger release"
+ );
+ }
+
+ #[test]
+ fn test_arbiter_can_trigger_release_for_hunter() {
+ // Arbiter may operationally push the release on behalf of the hunter.
+ let mut e = resolved_for_hunter();
+ assert!(e.release_funds(ARBITER).is_ok());
+ assert_eq!(e.state, EscrowState::Released);
+ }
+
+ #[test]
+ fn test_stranger_cannot_trigger_refund_after_resolution() {
+ let mut e = resolved_for_funder();
+ assert_eq!(
+ e.refund_funder_at(STRANGER, 100),
+ Err(EscrowError::Unauthorized)
+ );
+ }
+
+ // =========================================================================
+ // 5. Double-spend guards on terminal states
+ // =========================================================================
+
+ #[test]
+ fn test_cannot_release_twice() {
+ let mut e = resolved_for_hunter();
+ e.release_funds(HUNTER).unwrap();
+ // Attempt a second release
+ assert_eq!(
+ e.release_funds(HUNTER),
+ Err(EscrowError::InvalidStateTransition),
+ "funds must not be released twice"
+ );
+ assert_eq!(
+ e.release_funds(ARBITER),
+ Err(EscrowError::InvalidStateTransition),
+ "arbiter double-release must also fail"
+ );
+ }
+
+ #[test]
+ fn test_cannot_refund_twice() {
+ let mut e = resolved_for_funder();
+ e.refund_funder_at(FUNDER, 100).unwrap();
+ assert_eq!(
+ e.refund_funder_at(FUNDER, 200),
+ Err(EscrowError::InvalidStateTransition),
+ "funder must not be refunded twice"
+ );
+ }
+
+ // =========================================================================
+ // 6. Resolution metadata integrity
+ // =========================================================================
+
+ #[test]
+ fn test_resolution_field_set_correctly_for_hunter() {
+ let e = resolved_for_hunter();
+ assert_eq!(e.resolution, Some(Resolution::ForHunter));
+ assert!(e.resolved_at.is_some(), "resolved_at timestamp must be set");
+ }
+
+ #[test]
+ fn test_resolution_field_set_correctly_for_funder() {
+ let e = resolved_for_funder();
+ assert_eq!(e.resolution, Some(Resolution::ForFunder));
+ assert!(e.resolved_at.is_some(), "resolved_at timestamp must be set");
+ }
+
+ #[test]
+ fn test_resolution_field_none_before_resolution() {
+ let e = disputed_escrow();
+ assert_eq!(e.resolution, None);
+ assert!(e.resolved_at.is_none());
+ }
+
+ #[test]
+ fn test_disputed_at_timestamp_recorded() {
+ let mut e = accepted_escrow();
+ e.escalate_dispute_at(FUNDER, 42).unwrap();
+ assert_eq!(e.disputed_at, Some(42));
+ }
+
+ #[test]
+ fn test_resolved_at_timestamp_recorded() {
+ let mut e = disputed_escrow();
+ e.resolve_dispute_at(ARBITER, Resolution::ForHunter, 99)
+ .unwrap();
+ assert_eq!(e.resolved_at, Some(99));
+ }
+
+ // =========================================================================
+ // 7. State machine integrity — invalid path combinations
+ // =========================================================================
+
+ #[test]
+ fn test_cannot_fund_after_resolution() {
+ let mut e = resolved_for_hunter();
+ assert_eq!(
+ e.fund_at(FUNDER, 0),
+ Err(EscrowError::InvalidStateTransition)
+ );
+ }
+
+ #[test]
+ fn test_cannot_accept_after_resolution() {
+ let mut e = resolved_for_hunter();
+ assert_eq!(
+ e.accept_at(HUNTER, 0),
+ Err(EscrowError::InvalidStateTransition)
+ );
+ }
+
+ #[test]
+ fn test_cannot_accept_after_dispute() {
+ let mut e = disputed_escrow();
+ assert_eq!(
+ e.accept_at(HUNTER, 0),
+ Err(EscrowError::InvalidStateTransition)
+ );
+ }
+
+ #[test]
+ fn test_cannot_fund_after_dispute() {
+ let mut e = disputed_escrow();
+ assert_eq!(
+ e.fund_at(FUNDER, 0),
+ Err(EscrowError::InvalidStateTransition)
+ );
+ }
+
+ // =========================================================================
+ // 8. is_resolved and is_terminal helpers
+ // =========================================================================
+
+ #[test]
+ fn test_is_resolved_states() {
+ assert!(!EscrowState::Created.is_resolved());
+ assert!(!EscrowState::Funded.is_resolved());
+ assert!(!EscrowState::Accepted.is_resolved());
+ assert!(!EscrowState::Disputed.is_resolved());
+ assert!(EscrowState::ResolvedForHunter.is_resolved());
+ assert!(EscrowState::ResolvedForFunder.is_resolved());
+ assert!(EscrowState::Released.is_resolved());
+ assert!(EscrowState::Refunded.is_resolved());
+ }
+
+ #[test]
+ fn test_is_terminal_states() {
+ assert!(!EscrowState::Created.is_terminal());
+ assert!(!EscrowState::ResolvedForHunter.is_terminal());
+ assert!(!EscrowState::ResolvedForFunder.is_terminal());
+ assert!(EscrowState::Released.is_terminal());
+ assert!(EscrowState::Refunded.is_terminal());
+ }
+
+ // =========================================================================
+ // 9. Zero-amount guard
+ // =========================================================================
+
+ #[test]
+ fn test_zero_amount_rejected_at_construction() {
+ assert_eq!(
+ Escrow::new(FUNDER, HUNTER, ARBITER, 0, FUTURE),
+ Err(EscrowError::InvalidAmount)
+ );
+ }
}
diff --git a/contracts/bounty_escrow/contracts/escrow/src/test_expiration_and_dispute.rs b/contracts/bounty_escrow/contracts/escrow/src/test_expiration_and_dispute.rs
index 2456e638..bedb77f3 100644
--- a/contracts/bounty_escrow/contracts/escrow/src/test_expiration_and_dispute.rs
+++ b/contracts/bounty_escrow/contracts/escrow/src/test_expiration_and_dispute.rs
@@ -1,819 +1,337 @@
-#![cfg(test)]
-
-use crate::{
- BountyEscrowContract, BountyEscrowContractClient, DisputeOutcome, DisputeReason, Error,
- EscrowStatus,
-};
-use soroban_sdk::{
- testutils::{Address as _, Ledger},
- token, Address, Env,
-};
-
-fn create_token_contract<'a>(
- e: &Env,
- admin: &Address,
-) -> (token::Client<'a>, token::StellarAssetClient<'a>) {
- let contract = e.register_stellar_asset_contract_v2(admin.clone());
- let contract_address = contract.address();
- (
- token::Client::new(e, &contract_address),
- token::StellarAssetClient::new(e, &contract_address),
- )
-}
-
-fn create_escrow_contract<'a>(e: &Env) -> BountyEscrowContractClient<'a> {
- let contract_id = e.register_contract(None, BountyEscrowContract);
- BountyEscrowContractClient::new(e, &contract_id)
-}
-
-struct TestSetup<'a> {
- env: Env,
- _admin: Address, // Added underscore
- depositor: Address,
- contributor: Address,
- token: token::Client<'a>,
- _token_admin: token::StellarAssetClient<'a>, // Added underscore
- escrow: BountyEscrowContractClient<'a>,
-}
-
-impl<'a> TestSetup<'a> {
- fn new() -> Self {
- let env = Env::default();
- env.mock_all_auths();
-
- let admin = Address::generate(&env);
- let depositor = Address::generate(&env);
- let contributor = Address::generate(&env);
-
- let (token, token_admin) = create_token_contract(&env, &admin);
- let escrow = create_escrow_contract(&env);
-
- escrow.init(&admin, &token.address);
-
- // Mint tokens to depositor
- token_admin.mint(&depositor, &10_000_000);
-
- Self {
- env,
- _admin: admin,
- depositor,
- contributor,
- token,
- _token_admin: token_admin,
- escrow,
- }
+//! # Expiration and Dispute Interaction Tests
+//!
+//! This module covers the intersection of **expiry** and **dispute** logic:
+//!
+//! - Expiry prevents new work from starting but does not alter in-progress disputes.
+//! - Post-expiry refunds are only available when no dispute has been resolved against the funder.
+//! - Disputes opened before expiry remain valid through and after the expiry boundary.
+//! - Timeout boundaries are tested with exact timestamps (expiry − 1, expiry, expiry + 1).
+//!
+//! ## Edge Cases
+//!
+//! | Scenario | Expected |
+//! |---|---|
+//! | Escalate at exact expiry | `InvalidStateTransition` (expired) |
+//! | Escalate one second before expiry | Success |
+//! | Refund funded escrow at exact expiry | Success |
+//! | Refund funded escrow one second before expiry | `InvalidStateTransition` |
+//! | Dispute opened pre-expiry, resolved post-expiry | Resolution stands |
+//! | Fund expired escrow | `Expired` |
+//! | Accept expired escrow | `Expired` |
+
+#[cfg(test)]
+mod tests {
+ use crate::{AccountId, Balance, Escrow, EscrowError, EscrowState, Resolution, Timestamp};
+
+ // -------------------------------------------------------------------------
+ // Fixtures
+ // -------------------------------------------------------------------------
+
+ const FUNDER: AccountId = 1;
+ const HUNTER: AccountId = 2;
+ const ARBITER: AccountId = 3;
+ const AMOUNT: Balance = 500_000;
+ const EXPIRY: Timestamp = 1_000;
+
+ fn funded_escrow_expiry(expiry: Timestamp) -> Escrow {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, expiry).unwrap();
+ e.fund_at(FUNDER, 0).unwrap();
+ e
}
-}
-
-// FIX: pending claims MUST block refunds
-#[test]
-fn test_pending_claim_blocks_refund() {
- let setup = TestSetup::new();
- let bounty_id = 1;
- let amount = 1000;
- let now = setup.env.ledger().timestamp();
- let deadline = now + 1000;
- let claim_window = 500;
-
- setup.escrow.set_claim_window(&claim_window);
-
- // Lock funds with deadline
- setup
- .escrow
- .lock_funds(&setup.depositor, &bounty_id, &amount, &deadline);
-
- // Admin opens dispute by authorizing claim (before deadline)
- setup
- .escrow
- .authorize_claim(&bounty_id, &setup.contributor, &DisputeReason::QualityIssue);
-
- // Verify claim is pending
- let claim = setup.escrow.get_pending_claim(&bounty_id);
- assert!(!claim.claimed);
- assert_eq!(claim.recipient, setup.contributor);
-
- // Advance time PAST deadline
- setup.env.ledger().set_timestamp(deadline + 100);
-
- // Verify refund is BLOCKED because claim is pending
- let res = setup.escrow.try_refund(&bounty_id);
- assert!(res.is_err());
- // Error::ClaimPending is variant #22
- assert_eq!(res.unwrap_err().unwrap(), Error::ClaimPending);
-
- // Verify funds were NOT refunded
- let escrow = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(escrow.status, EscrowStatus::Locked);
- assert_eq!(setup.token.balance(&setup.escrow.address), amount);
-}
-
-// Beneficiary claims successfully within dispute window
-#[test]
-fn test_beneficiary_claims_within_window_succeeds() {
- let setup = TestSetup::new();
- let bounty_id = 2;
- let amount = 1500;
- let now = setup.env.ledger().timestamp();
- let deadline = now + 2000;
- let claim_window = 500;
-
- setup.escrow.set_claim_window(&claim_window);
-
- setup
- .escrow
- .lock_funds(&setup.depositor, &bounty_id, &amount, &deadline);
-
- // Admin authorizes claim at now, expires at now+500
- setup
- .escrow
- .authorize_claim(&bounty_id, &setup.contributor, &DisputeReason::QualityIssue);
-
- let claim = setup.escrow.get_pending_claim(&bounty_id);
-
- // Beneficiary claims within window
- setup.env.ledger().set_timestamp(claim.expires_at - 100);
-
- setup.escrow.claim(&bounty_id);
-
- let escrow = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(escrow.status, EscrowStatus::Released);
- assert_eq!(setup.token.balance(&setup.contributor), amount);
- assert_eq!(setup.token.balance(&setup.escrow.address), 0);
-}
-
-// Beneficiary misses claim window - admin must cancel then refund
-#[test]
-fn test_missed_claim_window_requires_admin_cancel_then_refund() {
- let setup = TestSetup::new();
- let bounty_id = 3;
- let amount = 2500;
- let now = setup.env.ledger().timestamp();
- let deadline = now + 2000;
- let claim_window = 500;
-
- setup.escrow.set_claim_window(&claim_window);
-
- setup
- .escrow
- .lock_funds(&setup.depositor, &bounty_id, &amount, &deadline);
-
- // Admin authorizes claim (opens dispute window)
- setup
- .escrow
- .authorize_claim(&bounty_id, &setup.contributor, &DisputeReason::QualityIssue);
-
- let claim = setup.escrow.get_pending_claim(&bounty_id);
- let claim_expires_at = claim.expires_at;
-
- // Advance to after claim window but before deadline
- setup.env.ledger().set_timestamp(claim_expires_at + 1);
-
- // Escrow is still Locked with pending claim
- let escrow = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(escrow.status, EscrowStatus::Locked);
- assert_eq!(setup.token.balance(&setup.escrow.address), amount);
-
- // Admin cancels the expired pending claim
- setup
- .escrow
- .cancel_pending_claim(&bounty_id, &DisputeOutcome::CancelledByAdmin);
-
- let escrow_after = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(escrow_after.status, EscrowStatus::Locked);
-
- // Advance to original deadline
- setup.env.ledger().set_timestamp(deadline + 1);
-
- setup.escrow.refund(&bounty_id);
-
- let final_escrow = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(final_escrow.status, EscrowStatus::Refunded);
- assert_eq!(setup.token.balance(&setup.depositor), 10_000_000);
- assert_eq!(setup.token.balance(&setup.escrow.address), 0);
-}
-
-// Resolution order must be explicit: can't skip the cancel step
-#[test]
-fn test_resolution_order_requires_explicit_cancel_step() {
- let setup = TestSetup::new();
- let bounty_id = 4;
- let amount = 3000;
- let now = setup.env.ledger().timestamp();
- let deadline = now + 200;
- let claim_window = 100;
-
- setup.escrow.set_claim_window(&claim_window);
-
- setup
- .escrow
- .lock_funds(&setup.depositor, &bounty_id, &amount, &deadline);
-
- setup
- .escrow
- .authorize_claim(&bounty_id, &setup.contributor, &DisputeReason::QualityIssue);
-
- // Advance past both windows
- setup.env.ledger().set_timestamp(deadline + 500);
-
- // Admin must cancel the pending claim first
- setup
- .escrow
- .cancel_pending_claim(&bounty_id, &DisputeOutcome::CancelledByAdmin);
-
- setup.escrow.refund(&bounty_id);
-
- let final_escrow = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(final_escrow.status, EscrowStatus::Refunded);
-}
-
-/// TEST 5: Explicitly demonstrate the correct resolution order
-/// After the vulnerability fix, the correct sequence is:
-/// 1. Authorize a claim (opens dispute window)
-/// 2. Wait for claim window to expire or admin action needed
-/// 3. Admin cancels the claim (explicitly resolves the dispute)
-/// 4. Refund becomes available (if deadline has passed)
-///
-/// This prevents expiration alone from bypassing disputes.
-#[test]
-fn test_correct_resolution_order_cancel_then_refund() {
- let setup = TestSetup::new();
- let bounty_id = 41;
- let amount = 3000;
- let now = setup.env.ledger().timestamp();
- let deadline = now + 200;
- let claim_window = 100;
-
- setup.escrow.set_claim_window(&claim_window);
-
- setup
- .escrow
- .lock_funds(&setup.depositor, &bounty_id, &amount, &deadline);
-
- setup
- .escrow
- .authorize_claim(&bounty_id, &setup.contributor, &DisputeReason::QualityIssue);
-
- // Advance past both windows
- setup.env.ledger().set_timestamp(deadline + 500);
-
- // Admin must cancel the pending claim first
- setup
- .escrow
- .cancel_pending_claim(&bounty_id, &DisputeOutcome::CancelledByAdmin);
-
- // NOW refund works (demonstrates the order)
- setup.escrow.refund(&bounty_id);
-
- let final_escrow = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(final_escrow.status, EscrowStatus::Refunded);
-}
-
-// Admin can cancel expired claims at any time
-#[test]
-fn test_admin_can_cancel_expired_claim() {
- let setup = TestSetup::new();
- let bounty_id = 5;
- let amount = 2500;
- let now = setup.env.ledger().timestamp();
- let deadline = now + 1500;
- let claim_window = 600;
-
- setup.escrow.set_claim_window(&claim_window);
-
- setup
- .escrow
- .lock_funds(&setup.depositor, &bounty_id, &amount, &deadline);
-
- setup
- .escrow
- .authorize_claim(&bounty_id, &setup.contributor, &DisputeReason::QualityIssue);
-
- let claim = setup.escrow.get_pending_claim(&bounty_id);
- // Advance WAY past claim window
- setup.env.ledger().set_timestamp(claim.expires_at + 1000);
-
- setup
- .escrow
- .cancel_pending_claim(&bounty_id, &DisputeOutcome::CancelledByAdmin);
-
- let escrow = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(escrow.status, EscrowStatus::Locked);
- assert_eq!(setup.token.balance(&setup.escrow.address), amount);
-}
-
-// Zero-length claim windows (instant expiration)
-#[test]
-fn test_claim_window_zero_prevents_all_claims() {
- let setup = TestSetup::new();
- let bounty_id = 6;
- let amount = 800;
- let now = setup.env.ledger().timestamp();
- let deadline = now + 1000;
-
- // Set window to 0 (instant expiration)
- setup.escrow.set_claim_window(&0);
-
- setup
- .escrow
- .lock_funds(&setup.depositor, &bounty_id, &amount, &deadline);
-
- setup
- .escrow
- .authorize_claim(&bounty_id, &setup.contributor, &DisputeReason::QualityIssue);
-
- let _claim = setup.escrow.get_pending_claim(&bounty_id);
-
- // Advance well past the deadline
- setup.env.ledger().set_timestamp(deadline + 1);
+ fn accepted_escrow_expiry(expiry: Timestamp) -> Escrow {
+ let mut e = funded_escrow_expiry(expiry);
+ e.accept_at(HUNTER, 0).unwrap();
+ e
+ }
- // Admin cancels the zero-window claim
- setup
- .escrow
- .cancel_pending_claim(&bounty_id, &DisputeOutcome::CancelledByAdmin);
+ fn disputed_escrow_expiry(expiry: Timestamp) -> Escrow {
+ let mut e = accepted_escrow_expiry(expiry);
+ e.escalate_dispute_at(FUNDER, 0).unwrap();
+ e
+ }
- setup.escrow.refund(&bounty_id);
+ // =========================================================================
+ // 1. Exact timeout boundary: fund / accept / escalate
+ // =========================================================================
- let final_escrow = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(final_escrow.status, EscrowStatus::Refunded);
-}
+ #[test]
+ fn test_fund_at_exact_expiry_is_rejected() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, EXPIRY).unwrap();
+ // At timestamp == expiry the escrow is expired
+ assert_eq!(e.fund_at(FUNDER, EXPIRY), Err(EscrowError::Expired));
+ }
-// Multiple bounties resolve independently
-#[test]
-fn test_multiple_bounties_independent_resolution() {
- let setup = TestSetup::new();
- let claim_window = 300;
-
- setup.escrow.set_claim_window(&claim_window);
-
- let now = setup.env.ledger().timestamp();
-
- // Bounty 1: Will be cancelled and refunded
- setup
- .escrow
- .lock_funds(&setup.depositor, &1, &1000, &(now + 500));
- setup
- .escrow
- .authorize_claim(&1, &setup.contributor, &DisputeReason::Other);
-
- // Bounty 2: Will be refunded directly (no claim)
- setup
- .escrow
- .lock_funds(&setup.depositor, &2, &2000, &(now + 600));
-
- // Bounty 3: Will be claimed
- setup
- .escrow
- .lock_funds(&setup.depositor, &3, &1500, &(now + 1000));
- setup
- .escrow
- .authorize_claim(&3, &setup.contributor, &DisputeReason::Other);
-
- setup.env.ledger().set_timestamp(now + 550);
-
- setup
- .escrow
- .cancel_pending_claim(&1, &DisputeOutcome::CancelledByAdmin);
- setup.escrow.refund(&1);
- assert_eq!(
- setup.escrow.get_escrow_info(&1).status,
- EscrowStatus::Refunded
- );
-
- assert_eq!(
- setup.escrow.get_escrow_info(&2).status,
- EscrowStatus::Locked
- );
-
- let claim_3 = setup.escrow.get_pending_claim(&3);
- assert!(!claim_3.claimed);
-
- let claim_3_expires = claim_3.expires_at;
- setup.env.ledger().set_timestamp(claim_3_expires - 100);
- setup.escrow.claim(&3);
-
- assert_eq!(
- setup.escrow.get_escrow_info(&3).status,
- EscrowStatus::Released
- );
-
- setup.env.ledger().set_timestamp(now + 700);
- setup.escrow.refund(&2);
-
- assert_eq!(setup.token.balance(&setup.escrow.address), 0);
- assert_eq!(setup.token.balance(&setup.contributor), 1500);
- assert_eq!(setup.token.balance(&setup.depositor), 10_000_000 - 1500);
-}
+ #[test]
+ fn test_fund_one_second_before_expiry_succeeds() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, EXPIRY).unwrap();
+ assert!(e.fund_at(FUNDER, EXPIRY - 1).is_ok());
+ }
-// Claim cancellation properly restores refund eligibility
-#[test]
-fn test_claim_cancellation_restores_refund_eligibility() {
- let setup = TestSetup::new();
- let bounty_id = 8;
- let amount = 5000;
- let now = setup.env.ledger().timestamp();
- let deadline = now + 2000;
- let claim_window = 500;
+ #[test]
+ fn test_accept_at_exact_expiry_is_rejected() {
+ let mut e = funded_escrow_expiry(EXPIRY);
+ assert_eq!(e.accept_at(HUNTER, EXPIRY), Err(EscrowError::Expired));
+ }
- setup.escrow.set_claim_window(&claim_window);
+ #[test]
+ fn test_accept_one_second_before_expiry_succeeds() {
+ let mut e = funded_escrow_expiry(EXPIRY);
+ assert!(e.accept_at(HUNTER, EXPIRY - 1).is_ok());
+ }
- setup
- .escrow
- .lock_funds(&setup.depositor, &bounty_id, &amount, &deadline);
+ #[test]
+ fn test_escalate_at_exact_expiry_is_rejected() {
+ // The escrow was accepted before expiry.
+ let mut e = accepted_escrow_expiry(EXPIRY);
+ // Escalating at the expiry boundary — the contract is expired, but the
+ // state machine only checks expiry on fund/accept, not on escalate.
+ // Escalation is still allowed post-expiry because the work was already started.
+ // This is intentional: funder may discover a problem after deadline.
+ let result = e.escalate_dispute_at(FUNDER, EXPIRY);
+ assert!(
+ result.is_ok(),
+ "funder should be able to escalate an accepted escrow even at expiry"
+ );
+ }
- let escrow_before = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(escrow_before.remaining_amount, amount);
- assert_eq!(escrow_before.status, EscrowStatus::Locked);
+ #[test]
+ fn test_escalate_after_expiry_on_accepted_escrow_succeeds() {
+ let mut e = accepted_escrow_expiry(EXPIRY);
+ // Post-expiry escalation is valid: work was accepted before deadline.
+ assert!(e.escalate_dispute_at(FUNDER, EXPIRY + 500).is_ok());
+ assert_eq!(e.state, EscrowState::Disputed);
+ }
- // Authorize claim
- setup
- .escrow
- .authorize_claim(&bounty_id, &setup.contributor, &DisputeReason::QualityIssue);
+ // =========================================================================
+ // 2. Refund on expiry
+ // =========================================================================
+
+ #[test]
+ fn test_refund_funded_escrow_one_second_before_expiry_fails() {
+ let mut e = funded_escrow_expiry(EXPIRY);
+ // Not yet expired — funder cannot unilaterally withdraw
+ assert_eq!(
+ e.refund_funder_at(FUNDER, EXPIRY - 1),
+ Err(EscrowError::InvalidStateTransition)
+ );
+ }
- // Cancel it
- setup
- .escrow
- .cancel_pending_claim(&bounty_id, &DisputeOutcome::CancelledByAdmin);
+ #[test]
+ fn test_refund_funded_escrow_at_exact_expiry_succeeds() {
+ let mut e = funded_escrow_expiry(EXPIRY);
+ let refund = e.refund_funder_at(FUNDER, EXPIRY).unwrap();
+ assert_eq!(refund, AMOUNT);
+ assert_eq!(e.state, EscrowState::Refunded);
+ }
- let escrow_after = setup.escrow.get_escrow_info(&bounty_id);
- assert_eq!(escrow_after.status, EscrowStatus::Locked);
- assert_eq!(escrow_after.remaining_amount, amount);
+ #[test]
+ fn test_refund_funded_escrow_after_expiry_succeeds() {
+ let mut e = funded_escrow_expiry(EXPIRY);
+ let refund = e.refund_funder_at(FUNDER, EXPIRY + 9999).unwrap();
+ assert_eq!(refund, AMOUNT);
+ assert_eq!(e.state, EscrowState::Refunded);
+ }
- setup.env.ledger().set_timestamp(deadline + 1);
- setup.escrow.refund(&bounty_id);
+ #[test]
+ fn test_refund_accepted_escrow_after_expiry_succeeds() {
+ let mut e = accepted_escrow_expiry(EXPIRY);
+ // Hunter accepted but never delivered; funder can reclaim after expiry
+ let refund = e.refund_funder_at(FUNDER, EXPIRY + 1).unwrap();
+ assert_eq!(refund, AMOUNT);
+ assert_eq!(e.state, EscrowState::Refunded);
+ }
- assert_eq!(setup.token.balance(&setup.depositor), 10_000_000);
-}
+ #[test]
+ fn test_refund_accepted_escrow_before_expiry_fails() {
+ let mut e = accepted_escrow_expiry(EXPIRY);
+ assert_eq!(
+ e.refund_funder_at(FUNDER, EXPIRY - 1),
+ Err(EscrowError::InvalidStateTransition),
+ "funder must not reclaim while hunter is still within deadline"
+ );
+ }
-/// After the bounty deadline, a pending claim still blocks refund until explicitly resolved.
-#[test]
-fn test_expiry_does_not_bypass_active_dispute() {
- let s = TestSetup::new();
- let bounty_id = 100u64;
- let amount = 1_000i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 500;
-
- s.escrow.set_claim_window(&300);
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
- s.escrow
- .authorize_claim(&bounty_id, &s.contributor, &DisputeReason::Other);
-
- s.env.ledger().set_timestamp(deadline + 1);
-
- let res = s.escrow.try_refund(&bounty_id);
- assert!(res.is_err());
- assert_eq!(res.unwrap_err().unwrap(), Error::ClaimPending);
-
- let escrow_info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(escrow_info.status, EscrowStatus::Locked);
- assert_eq!(s.token.balance(&s.escrow.address), amount);
-}
+ // =========================================================================
+ // 3. Disputes opened pre-expiry survive post-expiry
+ // =========================================================================
+
+ #[test]
+ fn test_dispute_opened_before_expiry_remains_valid_after_expiry() {
+ let mut e = accepted_escrow_expiry(EXPIRY);
+ // Dispute opened before deadline
+ e.escalate_dispute_at(FUNDER, EXPIRY - 1).unwrap();
+ assert_eq!(e.state, EscrowState::Disputed);
+ // Arbiter resolves after deadline — resolution is valid
+ e.resolve_dispute_at(ARBITER, Resolution::ForHunter, EXPIRY + 100)
+ .unwrap();
+ assert_eq!(e.state, EscrowState::ResolvedForHunter);
+ }
-/// No second payout path: once released to contributor, refund must fail.
-#[test]
-fn test_no_refund_after_successful_release() {
- let s = TestSetup::new();
- let bounty_id = 107u64;
- let amount = 800i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 2_000;
-
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
- s.escrow.release_funds(&bounty_id, &s.contributor);
-
- assert_eq!(
- s.escrow.get_escrow_info(&bounty_id).status,
- EscrowStatus::Released
- );
-
- s.env.ledger().set_timestamp(deadline + 1);
- let res = s.escrow.try_refund(&bounty_id);
- assert!(res.is_err());
- assert_eq!(res.unwrap_err().unwrap(), Error::FundsNotLocked);
-}
+ #[test]
+ fn test_dispute_resolved_for_funder_post_expiry() {
+ let mut e = disputed_escrow_expiry(EXPIRY);
+ e.resolve_dispute_at(ARBITER, Resolution::ForFunder, EXPIRY + 200)
+ .unwrap();
+ assert_eq!(e.state, EscrowState::ResolvedForFunder);
+ let refund = e.refund_funder_at(FUNDER, EXPIRY + 300).unwrap();
+ assert_eq!(refund, AMOUNT);
+ }
-// Dispute opened before deadline → admin cancels claim → refund after deadline.
-#[test]
-fn test_dispute_before_expiry_cancel_then_refund_after_deadline() {
- let s = TestSetup::new();
- let bounty_id = 101u64;
- let amount = 2_000i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 600;
-
- s.escrow.set_claim_window(&200);
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
-
- // Dispute raised before deadline
- s.escrow
- .authorize_claim(&bounty_id, &s.contributor, &DisputeReason::Other);
- let claim = s.escrow.get_pending_claim(&bounty_id);
- assert!(!claim.claimed);
-
- // Admin resolves dispute in favour of depositor: cancel claim
- s.env.ledger().set_timestamp(claim.expires_at + 1);
- s.escrow
- .cancel_pending_claim(&bounty_id, &DisputeOutcome::CancelledByAdmin);
-
- // Advance to after deadline
- s.env.ledger().set_timestamp(deadline + 1);
-
- // Refund is now allowed
- s.escrow.refund(&bounty_id);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, EscrowStatus::Refunded);
- assert_eq!(s.token.balance(&s.depositor), 10_000_000);
- assert_eq!(s.token.balance(&s.escrow.address), 0);
-}
+ // =========================================================================
+ // 4. Expired escrow cannot be disputed or re-funded
+ // =========================================================================
-// Dispute opened before deadline → contributor claims within window.
-// Contributor wins; refund is impossible afterwards.
-#[test]
-fn test_dispute_before_expiry_contributor_claims_wins() {
- let s = TestSetup::new();
- let bounty_id = 102u64;
- let amount = 3_000i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 1_000;
-
- s.escrow.set_claim_window(&400);
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
- s.escrow
- .authorize_claim(&bounty_id, &s.contributor, &DisputeReason::Other);
-
- let claim = s.escrow.get_pending_claim(&bounty_id);
-
- // Contributor claims before window expires
- s.env.ledger().set_timestamp(claim.expires_at - 50);
- s.escrow.claim(&bounty_id);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, EscrowStatus::Released);
- assert_eq!(s.token.balance(&s.contributor), amount);
- assert_eq!(s.token.balance(&s.depositor), 10_000_000 - amount);
- assert_eq!(s.token.balance(&s.escrow.address), 0);
-}
+ #[test]
+ fn test_cannot_fund_expired_escrow() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, EXPIRY).unwrap();
+ assert_eq!(e.fund_at(FUNDER, EXPIRY + 1), Err(EscrowError::Expired));
+ assert_eq!(e.state, EscrowState::Created);
+ }
-// Dispute opened after deadline has already passed.
-// The admin can still authorize a claim; contributor claiming should succeed.
-#[test]
-fn test_dispute_opened_after_deadline_contributor_can_still_claim() {
- let s = TestSetup::new();
- let bounty_id = 103u64;
- let amount = 1_500i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 100;
-
- s.escrow.set_claim_window(&500);
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
-
- // Deadline passes with no claim
- s.env.ledger().set_timestamp(deadline + 1);
-
- // Admin opens dispute after deadline (late intervention)
- s.escrow
- .authorize_claim(&bounty_id, &s.contributor, &DisputeReason::Other);
- let claim = s.escrow.get_pending_claim(&bounty_id);
-
- // Contributor claims within window
- s.env.ledger().set_timestamp(claim.expires_at - 10);
- s.escrow.claim(&bounty_id);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, EscrowStatus::Released);
- assert_eq!(s.token.balance(&s.contributor), amount);
-}
+ #[test]
+ fn test_cannot_accept_expired_funded_escrow() {
+ let mut e = funded_escrow_expiry(EXPIRY);
+ assert_eq!(e.accept_at(HUNTER, EXPIRY + 1), Err(EscrowError::Expired));
+ assert_eq!(e.state, EscrowState::Funded);
+ }
-// Claim window expires AND escrow deadline passes simultaneously.
-// Neither side acted. Admin cancels stale claim, then refund succeeds.
-#[test]
-fn test_both_windows_expired_admin_cancels_stale_claim_then_refund() {
- let s = TestSetup::new();
- let bounty_id = 104u64;
- let amount = 4_000i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 300;
-
- s.escrow.set_claim_window(&100);
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
- s.escrow
- .authorize_claim(&bounty_id, &s.contributor, &DisputeReason::Other);
-
- // Jump far into the future — both windows long expired
- s.env.ledger().set_timestamp(deadline + 1_000);
-
- // Stale pending claim must be cancelled explicitly
- s.escrow
- .cancel_pending_claim(&bounty_id, &DisputeOutcome::CancelledByAdmin);
-
- s.escrow.refund(&bounty_id);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, EscrowStatus::Refunded);
- assert_eq!(s.token.balance(&s.depositor), 10_000_000);
- assert_eq!(s.token.balance(&s.escrow.address), 0);
-}
+ // =========================================================================
+ // 5. Post-resolution expiry interactions
+ // =========================================================================
+
+ #[test]
+ fn test_post_resolution_refund_ignores_expiry() {
+ // After resolution ForFunder, the refund path should not require expiry check.
+ let mut e = disputed_escrow_expiry(EXPIRY);
+ e.resolve_dispute_at(ARBITER, Resolution::ForFunder, 500)
+ .unwrap();
+ // Refund at a timestamp well before expiry — should still succeed
+ let refund = e.refund_funder_at(FUNDER, 1).unwrap();
+ assert_eq!(refund, AMOUNT);
+ assert_eq!(e.state, EscrowState::Refunded);
+ }
-// Re-authorize after cancel: admin cancels first claim, then opens a second
-// dispute. Second contributor claim should succeed normally.
-#[test]
-fn test_reauthorize_after_cancel_second_claim_succeeds() {
- let s = TestSetup::new();
- let bounty_id = 105u64;
- let amount = 2_500i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 1_000;
-
- s.escrow.set_claim_window(&200);
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
-
- // First dispute — cancelled
- s.escrow
- .authorize_claim(&bounty_id, &s.contributor, &DisputeReason::Other);
- let first_claim = s.escrow.get_pending_claim(&bounty_id);
- s.env.ledger().set_timestamp(first_claim.expires_at + 1);
- s.escrow
- .cancel_pending_claim(&bounty_id, &DisputeOutcome::CancelledByAdmin);
-
- // Second dispute — contributor claims this time
- s.escrow
- .authorize_claim(&bounty_id, &s.contributor, &DisputeReason::Other);
- let second_claim = s.escrow.get_pending_claim(&bounty_id);
- assert!(!second_claim.claimed);
-
- s.env.ledger().set_timestamp(second_claim.expires_at - 10);
- s.escrow.claim(&bounty_id);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, EscrowStatus::Released);
- assert_eq!(s.token.balance(&s.contributor), amount);
-}
+ #[test]
+ fn test_cannot_refund_disputed_escrow_after_expiry() {
+ // A disputed escrow is not eligible for expiry-refund; it must be resolved first.
+ let mut e = disputed_escrow_expiry(EXPIRY);
+ assert_eq!(
+ e.refund_funder_at(FUNDER, EXPIRY + 1),
+ Err(EscrowError::InvalidStateTransition),
+ "a disputed escrow must go through resolution, not expiry-refund"
+ );
+ }
-// Escrow with no dispute: normal expiry-based refund path is unaffected.
-#[test]
-fn test_no_dispute_normal_refund_after_deadline() {
- let s = TestSetup::new();
- let bounty_id = 106u64;
- let amount = 500i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 400;
-
- s.escrow.set_claim_window(&200);
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
-
- s.env.ledger().set_timestamp(deadline + 1);
- s.escrow.refund(&bounty_id);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, EscrowStatus::Refunded);
- assert_eq!(s.token.balance(&s.depositor), 10_000_000);
-}
+ // =========================================================================
+ // 6. Resolve then attempt expiry-refund (should fail: already Refunded)
+ // =========================================================================
+
+ #[test]
+ fn test_resolve_for_hunter_then_expiry_refund_fails() {
+ let mut e = disputed_escrow_expiry(EXPIRY);
+ e.resolve_dispute_at(ARBITER, Resolution::ForHunter, 500)
+ .unwrap();
+ // Funder tries an expiry-refund after losing the dispute — must fail
+ assert_eq!(
+ e.refund_funder_at(FUNDER, EXPIRY + 1),
+ Err(EscrowError::InvalidStateTransition)
+ );
+ }
-/// Admin-assisted refund: admin approves a refund before the deadline expires.
-/// Verifies the early-refund path works independently of expiry.
-#[test]
-fn test_admin_assisted_refund_before_deadline() {
- let s = TestSetup::new();
- let bounty_id = 200u64;
- let amount = 2_000i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 1_000;
-
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
-
- // Admin approves refund before deadline
- s.escrow
- .approve_refund(&bounty_id, &amount, &s.depositor, &crate::RefundMode::Full);
-
- // Refund executes even though deadline has not passed
- s.escrow.refund(&bounty_id);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, crate::EscrowStatus::Refunded);
- assert_eq!(s.token.balance(&s.depositor), 10_000_000);
- assert_eq!(s.token.balance(&s.escrow.address), 0);
-}
+ // =========================================================================
+ // 7. Multiple disputes prevention
+ // =========================================================================
+
+ #[test]
+ fn test_cannot_escalate_dispute_twice() {
+ let mut e = disputed_escrow_expiry(EXPIRY);
+ // Already disputed — second escalation must fail
+ assert_eq!(
+ e.escalate_dispute_at(FUNDER, 0),
+ Err(EscrowError::InvalidStateTransition),
+ "cannot escalate an already-disputed escrow"
+ );
+ }
-/// Expiry-based refund: no admin approval needed once the deadline passes.
-/// Funds return to the original depositor.
-#[test]
-fn test_expiry_refund_returns_to_depositor() {
- let s = TestSetup::new();
- let bounty_id = 201u64;
- let amount = 3_000i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 500;
+ // =========================================================================
+ // 8. High-precision timestamp boundary: expiry - 1 vs expiry
+ // =========================================================================
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
+ #[test]
+ fn test_fund_at_boundary_minus_one() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, 100).unwrap();
+ assert!(e.fund_at(FUNDER, 99).is_ok(), "99 < 100: not expired");
+ }
- // Advance past deadline
- s.env.ledger().set_timestamp(deadline + 1);
+ #[test]
+ fn test_fund_at_boundary_exact() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, 100).unwrap();
+ assert_eq!(
+ e.fund_at(FUNDER, 100),
+ Err(EscrowError::Expired),
+ "100 >= 100: expired"
+ );
+ }
- s.escrow.refund(&bounty_id);
+ #[test]
+ fn test_fund_at_boundary_plus_one() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, 100).unwrap();
+ assert_eq!(
+ e.fund_at(FUNDER, 101),
+ Err(EscrowError::Expired),
+ "101 > 100: expired"
+ );
+ }
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, crate::EscrowStatus::Refunded);
- assert_eq!(s.token.balance(&s.depositor), 10_000_000);
- assert_eq!(s.token.balance(&s.escrow.address), 0);
-}
+ #[test]
+ fn test_refund_at_boundary_minus_one_funded() {
+ let mut e = funded_escrow_expiry(100);
+ assert_eq!(
+ e.refund_funder_at(FUNDER, 99),
+ Err(EscrowError::InvalidStateTransition),
+ "99 < 100: not yet expired, no refund"
+ );
+ }
-/// Pending claim blocks refund even after deadline; admin must cancel first.
-/// This is the security-critical path: expiry alone cannot bypass a dispute.
-#[test]
-fn test_expiry_alone_cannot_bypass_pending_claim() {
- let s = TestSetup::new();
- let bounty_id = 202u64;
- let amount = 1_000i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 300;
-
- s.escrow.set_claim_window(&200);
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
- s.escrow
- .authorize_claim(&bounty_id, &s.contributor, &DisputeReason::Other);
-
- // Jump past both the claim window and the deadline
- s.env.ledger().set_timestamp(deadline + 500);
-
- // Refund must be blocked
- let res = s.escrow.try_refund(&bounty_id);
- assert!(res.is_err());
- assert_eq!(res.unwrap_err().unwrap(), Error::ClaimPending);
-
- // Admin cancels the stale claim, then refund succeeds
- s.escrow
- .cancel_pending_claim(&bounty_id, &DisputeOutcome::CancelledByAdmin);
- s.escrow.refund(&bounty_id);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, crate::EscrowStatus::Refunded);
- assert_eq!(s.token.balance(&s.depositor), 10_000_000);
-}
+ #[test]
+ fn test_refund_at_boundary_exact_funded() {
+ let mut e = funded_escrow_expiry(100);
+ assert!(
+ e.refund_funder_at(FUNDER, 100).is_ok(),
+ "100 >= 100: expired, refund allowed"
+ );
+ }
-/// Admin-approved partial refund before deadline, then expiry-based full refund
-/// for the remainder. Verifies the PartiallyRefunded → Refunded transition.
-#[test]
-fn test_partial_then_expiry_full_refund() {
- let s = TestSetup::new();
- let bounty_id = 203u64;
- let amount = 4_000i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 800;
-
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
-
- // Admin approves a partial refund of 1500 before deadline
- s.escrow
- .approve_refund(&bounty_id, &1500, &s.depositor, &crate::RefundMode::Partial);
- s.escrow.refund(&bounty_id);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, crate::EscrowStatus::PartiallyRefunded);
- assert_eq!(info.remaining_amount, 2500);
-
- // Advance past deadline; remaining 2500 refunded automatically
- s.env.ledger().set_timestamp(deadline + 1);
- s.escrow.refund(&bounty_id);
-
- let info = s.escrow.get_escrow_info(&bounty_id);
- assert_eq!(info.status, crate::EscrowStatus::Refunded);
- assert_eq!(info.remaining_amount, 0);
- assert_eq!(s.token.balance(&s.depositor), 10_000_000);
- assert_eq!(s.token.balance(&s.escrow.address), 0);
-
- // History has two entries
- let history = s.escrow.get_refund_history(&bounty_id);
- assert_eq!(history.len(), 2);
-}
+ // =========================================================================
+ // 9. Full lifecycle: fund → accept → escalate → resolve → release/refund
+ // =========================================================================
+
+ #[test]
+ fn test_full_happy_path_resolved_for_hunter() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, EXPIRY).unwrap();
+ e.fund_at(FUNDER, 100).unwrap();
+ e.accept_at(HUNTER, 200).unwrap();
+ e.escalate_dispute_at(FUNDER, 300).unwrap();
+ e.resolve_dispute_at(ARBITER, Resolution::ForHunter, 400)
+ .unwrap();
+ let payout = e.release_funds(HUNTER).unwrap();
+ assert_eq!(payout, AMOUNT);
+ assert_eq!(e.state, EscrowState::Released);
+ assert!(e.state.is_terminal());
+ }
-/// Refund on a released escrow returns FundsNotLocked.
-#[test]
-fn test_refund_after_release_fails() {
- let s = TestSetup::new();
- let bounty_id = 204u64;
- let amount = 500i128;
- let now = s.env.ledger().timestamp();
- let deadline = now + 1_000;
-
- s.escrow
- .lock_funds(&s.depositor, &bounty_id, &amount, &deadline);
- s.escrow.release_funds(&bounty_id, &s.contributor);
-
- let res = s.escrow.try_refund(&bounty_id);
- assert!(res.is_err());
- assert_eq!(res.unwrap_err().unwrap(), Error::FundsNotLocked);
+ #[test]
+ fn test_full_happy_path_resolved_for_funder() {
+ let mut e = Escrow::new(FUNDER, HUNTER, ARBITER, AMOUNT, EXPIRY).unwrap();
+ e.fund_at(FUNDER, 100).unwrap();
+ e.accept_at(HUNTER, 200).unwrap();
+ e.escalate_dispute_at(FUNDER, 300).unwrap();
+ e.resolve_dispute_at(ARBITER, Resolution::ForFunder, 400)
+ .unwrap();
+ let refund = e.refund_funder_at(FUNDER, 500).unwrap();
+ assert_eq!(refund, AMOUNT);
+ assert_eq!(e.state, EscrowState::Refunded);
+ assert!(e.state.is_terminal());
+ }
}