diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index a7097f26..725540a1 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -23,6 +23,9 @@ pub const MIN_DISPUTE_WINDOW_OVERRIDE: u64 = 60; /// Maximum allowed per-bounty dispute window override (30 days in seconds). pub const MAX_DISPUTE_WINDOW_OVERRIDE: u64 = 2_592_000; +/// Maximum bounty amount allowed +pub const MAX_BOUNTY_AMOUNT: i128 = i128::MAX / 2; + #[contracttype] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BountyStatus { @@ -47,7 +50,7 @@ pub struct Bounty { pub title: String, pub deadline: u64, pub status: BountyStatus, - pub protocol_fee_bps: u32, // stored per-bounty so the fee is locked in at creation time + pub protocol_fee_bps: u32, pub dispute_raised_at: u64, pub dispute_window_override: Option, } @@ -70,11 +73,74 @@ pub struct FeeStats { pub bounty_count: u64, } +#[contracttype] +pub struct Config { + pub appeal_window: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DisputeDecision { + Release, + Refund, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PendingResolution { + pub decision: DisputeDecision, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ContractError { + NotInitialized, + AlreadyInitialized, + InvalidAmount, + AmountTooSmall, + DeadlineMustBeInTheFuture, + BountyNotFound, + BountyNotOpen, + BountyMustBeReserved, + BountyMustBeSubmitted, + ContributorMismatch, + MaintainerMismatch, + BountyAlreadyFinalized, + BountyNotExpiredYet, + BountyExpired, + MissingContributor, + CannotExtendFinalizedBounty, + DeadlineMustAdvance, + DisputeWindowNotMet, + DisputeWindowOverrideTooSmall, + DisputeWindowOverrideTooLarge, + FeeRecipientNotSet, + ArbiterNotSet, + NotArbiter, + ContractIsPaused, + TokenNotAllowed, + NotAdmin, + NoPendingArbiter, + TimelockNotElapsed, +} + #[contracttype] enum DataKey { NextBountyId, Bounty(u64), - + Admin, + FeeRecipient, + Arbiter, + DisputeWindow, + MinBountyAmount, + Paused, + PendingArbiter, + ArbiterRotationTimelock, + FeeStats, + Config, + PendingResolution(u64), + AllowlistConfig, } #[contracttype] @@ -86,7 +152,7 @@ pub struct BountyCreated { pub amount: i128, pub repo: String, pub issue_number: u32, - pub protocol_fee_bps: u32, // included in event for indexers + pub protocol_fee_bps: u32, } #[contracttype] @@ -116,8 +182,8 @@ pub struct BountySubmitted { pub struct BountyReleased { pub bounty_id: u64, pub contributor: Address, - pub amount: i128, // net payout after fee - pub fee_amount: i128, // how much went to fee recipient + pub amount: i128, + pub fee_amount: i128, } #[contracttype] @@ -130,22 +196,33 @@ pub struct BountyRefunded { #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct Config { - pub appeal_window: u64, +pub struct BountyCanceled { + pub bounty_id: u64, + pub maintainer: Address, + pub amount: i128, } #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] -pub enum DisputeDecision { - Release, - Refund, +pub struct BountyDeadlineExtended { + pub bounty_id: u64, + pub new_deadline: u64, } #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingResolution { - pub decision: DisputeDecision, - pub timestamp: u64, +pub struct BountyDisputed { + pub bounty_id: u64, + pub contributor: Address, + pub arbiter: Address, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BountyResolved { + pub bounty_id: u64, + pub arbiter: Address, + pub release: bool, } #[contracttype] @@ -156,14 +233,18 @@ pub struct DisputeResolutionScheduled { pub resolve_at: u64, } -/// Emitted when the contract admin (arbiter) pauses the circuit-breaker. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DisputeAppealed { + pub bounty_id: u64, +} + #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] pub struct ContractPaused { pub admin: Address, } -/// Emitted when the contract admin (arbiter) unpauses the circuit-breaker. #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] pub struct ContractUnpaused { @@ -171,26 +252,64 @@ pub struct ContractUnpaused { } #[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArbiterRotationProposed { + pub new_arbiter: Address, + pub unlock_time: u64, +} +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArbiterRotationConfirmed { + pub old_arbiter: Address, + pub new_arbiter: Address, } #[contract] pub struct StellarBountyBoardContract; +fn panic_error(err: ContractError) -> ! { + match err { + ContractError::NotInitialized => panic!("not initialized"), + ContractError::AlreadyInitialized => panic!("already initialized"), + ContractError::InvalidAmount => panic!("InvalidAmount"), + ContractError::AmountTooSmall => panic!("AmountTooSmall"), + ContractError::DeadlineMustBeInTheFuture => panic!("DeadlineMustBeInTheFuture"), + ContractError::BountyNotFound => panic!("BountyNotFound"), + ContractError::BountyNotOpen => panic!("BountyNotOpen"), + ContractError::BountyMustBeReserved => panic!("BountyMustBeReserved"), + ContractError::BountyMustBeSubmitted => panic!("BountyMustBeSubmitted"), + ContractError::ContributorMismatch => panic!("ContributorMismatch"), + ContractError::MaintainerMismatch => panic!("MaintainerMismatch"), + ContractError::BountyAlreadyFinalized => panic!("BountyAlreadyFinalized"), + ContractError::BountyNotExpiredYet => panic!("BountyNotExpiredYet"), + ContractError::BountyExpired => panic!("BountyExpired"), + ContractError::MissingContributor => panic!("MissingContributor"), + ContractError::CannotExtendFinalizedBounty => panic!("CannotExtendFinalizedBounty"), + ContractError::DeadlineMustAdvance => panic!("DeadlineMustAdvance"), + ContractError::DisputeWindowNotMet => panic!("DisputeWindowNotMet"), + ContractError::DisputeWindowOverrideTooSmall => panic!("DisputeWindowOverrideTooSmall"), + ContractError::DisputeWindowOverrideTooLarge => panic!("DisputeWindowOverrideTooLarge"), + ContractError::FeeRecipientNotSet => panic!("FeeRecipientNotSet"), + ContractError::ArbiterNotSet => panic!("ArbiterNotSet"), + ContractError::NotArbiter => panic!("NotArbiter"), + ContractError::ContractIsPaused => panic!("ContractIsPaused"), + ContractError::TokenNotAllowed => panic!("TokenNotAllowed"), + ContractError::NotAdmin => panic!("NotAdmin"), + ContractError::NoPendingArbiter => panic!("NoPendingArbiter"), + ContractError::TimelockNotElapsed => panic!("TimelockNotElapsed"), + } +} + #[contractimpl] impl StellarBountyBoardContract { // ─── Version ──────────────────────────────────────────────────────── /// Returns the contract version as a semver string (e.g. "0.1.0"). pub fn get_version(_env: Env) -> String { - // We use _env because String::from_str needs it, but in future - // Soroban SDK versions this may be optional for static strings. String::from_str(&_env, CONTRACT_VERSION) } - pub fn initialize(env: Env, fee_recipient: Address, arbiter: Address, dispute_window: u64) { - pub fn initialize(env: Env, admin: Address, fee_recipient: Address, arbiter: Address, dispute_window: u64) { - // Prevent re-initialization if env.storage().persistent().has(&DataKey::FeeRecipient) { panic!("already initialized"); } @@ -204,7 +323,6 @@ impl StellarBountyBoardContract { env.storage() .persistent() .set(&DataKey::DisputeWindow, &dispute_window); - // Set default minimum bounty amount on initialization env.storage() .persistent() .set(&DataKey::MinBountyAmount, &DEFAULT_MIN_BOUNTY_AMOUNT); @@ -217,6 +335,26 @@ impl StellarBountyBoardContract { .unwrap_or_else(|| panic!("not initialized")) } + /// Returns the currently configured arbiter Address. + /// This is a read-only view function that requires no authentication. + /// Panics if the contract has not been initialized. + pub fn get_arbiter(env: Env) -> Address { + env.storage() + .persistent() + .get(&DataKey::Arbiter) + .unwrap_or_else(|| panic!("not initialized")) + } + + /// Returns the currently configured dispute window duration (in seconds). + /// This is a read-only view function that requires no authentication. + /// Panics if the contract has not been initialized. + pub fn get_dispute_window(env: Env) -> u64 { + env.storage() + .persistent() + .get(&DataKey::DisputeWindow) + .unwrap_or_else(|| panic!("not initialized")) + } + /// Returns the current minimum bounty amount required to create a bounty. /// If the contract has not been initialized, this will panic. pub fn get_min_bounty_amount(env: Env) -> i128 { @@ -249,11 +387,6 @@ impl StellarBountyBoardContract { } // ─── Circuit Breaker ──────────────────────────────────────────────── - /// Pauses the contract, halting new bounty creation (and reservation). - /// Only callable by the configured arbiter, which acts as the contract - /// admin (the same role used by `set_min_bounty_amount`). - /// Existing in-flight bounties can still be released, refunded, or - /// disputed while paused. pub fn pause(env: Env) { let admin: Address = env .storage() @@ -270,8 +403,6 @@ impl StellarBountyBoardContract { ); } - /// Unpauses the contract, resuming new bounty creation (and reservation). - /// Only callable by the configured arbiter (contract admin). pub fn unpause(env: Env) { let admin: Address = env .storage() @@ -288,8 +419,6 @@ impl StellarBountyBoardContract { ); } - /// Returns whether the contract is currently paused. - /// Defaults to `false` (unpaused) if never explicitly set. pub fn get_paused_state(env: Env) -> bool { env.storage() .persistent() @@ -326,7 +455,6 @@ impl StellarBountyBoardContract { if deadline <= env.ledger().timestamp() { panic_error(ContractError::DeadlineMustBeInTheFuture); } - //fee cannot exceed 100% (10000 bps) if protocol_fee_bps > 10_000 { panic!("fee exceeds 100%"); } @@ -337,7 +465,6 @@ impl StellarBountyBoardContract { panic_error(ContractError::TokenNotAllowed); } - // Validate dispute window override if provided if let Some(override_value) = dispute_window_override { if override_value < MIN_DISPUTE_WINDOW_OVERRIDE { panic_error(ContractError::DisputeWindowOverrideTooSmall); @@ -500,12 +627,6 @@ impl StellarBountyBoardContract { let token_client = TokenClient::new(&env, &bounty.token); let contract_address = env.current_contract_address(); - // ── Fee calculation ───────────────────────────────────────────── - // Fee is deducted FROM the payout, never added on top. - // fee_amount = floor(amount * protocol_fee_bps / 10_000) - // net_payout = amount - fee_amount - // - // Using i128 arithmetic to avoid overflow on large amounts. let fee_amount: i128 = if bounty.protocol_fee_bps == 0 { 0 } else { @@ -514,10 +635,8 @@ impl StellarBountyBoardContract { let net_payout = bounty.amount - fee_amount; - // Transfer net payout to contributor token_client.transfer(&contract_address, &contributor, &net_payout); - // Transfer fee to recipient (only when fee is non-zero) if fee_amount > 0 { let fee_recipient: Address = env .storage() @@ -526,9 +645,7 @@ impl StellarBountyBoardContract { .unwrap_or_else(|| panic_error(ContractError::FeeRecipientNotSet)); token_client.transfer(&contract_address, &fee_recipient, &fee_amount); } - // ───────────────────────────────────────────────────────────────── - // Atomically update FeeStats accumulate_fee_stats(&env, fee_amount); bounty.status = BountyStatus::Released; @@ -539,7 +656,7 @@ impl StellarBountyBoardContract { BountyReleased { bounty_id, contributor, - amount: net_payout, // net amount after fee + amount: net_payout, fee_amount, }, ); @@ -564,7 +681,6 @@ impl StellarBountyBoardContract { let token_client = TokenClient::new(&env, &bounty.token); let contract_address = env.current_contract_address(); - // Refund returns the FULL original amount there is no fee on refunds token_client.transfer(&contract_address, &maintainer, &bounty.amount); bounty.status = BountyStatus::Refunded; @@ -697,7 +813,6 @@ impl StellarBountyBoardContract { panic!("bounty not disputed"); } - // Use per-bounty override if set, otherwise fall back to global default let effective_dispute_window: u64 = bounty .dispute_window_override .unwrap_or_else(|| { @@ -739,7 +854,6 @@ impl StellarBountyBoardContract { token_client.transfer(&contract_address, &fee_recipient, &fee_amount); } - // Atomically update FeeStats for the dispute-release path accumulate_fee_stats(&env, fee_amount); bounty.status = BountyStatus::Released; @@ -766,129 +880,6 @@ impl StellarBountyBoardContract { bounty } - // ---------- New Functions ---------- - pub fn init(env: Env, appeal_window: u64) { - // Only allow setting once - if env.storage().persistent().has(&DataKey::Config) { - panic!("config already set"); - } - let cfg = Config { appeal_window }; - env.storage().persistent().set(&DataKey::Config, &cfg); - } - - pub fn resolve_dispute(env: Env, bounty_id: u64, decision_u8: u8) { - // For simplicity, any caller can resolve; in production enforce arbiter auth. - let decision = match decision_u8 { - 0 => DisputeDecision::Release, - 1 => DisputeDecision::Refund, - _ => panic!("invalid decision"), - }; - let timestamp = env.ledger().timestamp(); - let pending = PendingResolution { decision, timestamp }; - env.storage() - .persistent() - .set(&DataKey::PendingResolution(bounty_id), &pending); - env.events().publish( - (symbol_short!("Dispute"), symbol_short!("Scheduled")), - DisputeResolutionScheduled { - bounty_id, - decision, - resolve_at: timestamp, - }, - ); - } - - pub fn finalize_resolution(env: Env, bounty_id: u64) { - // Load pending - let pending_opt: Option = env - .storage() - .persistent() - .get(&DataKey::PendingResolution(bounty_id)); - let pending = pending_opt.expect("no pending resolution"); - // Load config - let cfg: Config = env.storage().persistent().get(&DataKey::Config).expect("config not set"); - let now = env.ledger().timestamp(); - if now < pending.timestamp + cfg.appeal_window { - panic!("appeal window not elapsed"); - } - // Load bounty - let mut bounty = read_bounty(&env, bounty_id); - // Resolve based on decision - match pending.decision { - DisputeDecision::Release => { - // transfer to contributor - let contributor = bounty - .contributor - .clone() - .unwrap_or_else(|| panic!("missing contributor")); - let token_client = TokenClient::new(&env, &bounty.token); - token_client.transfer(&env.current_contract_address(), &contributor, &bounty.amount); - bounty.status = BountyStatus::Released; - write_bounty(&env, bounty_id, &bounty); - env.events().publish( - (symbol_short!("Bounty"), symbol_short!("Releas")), - BountyReleased { - bounty_id, - contributor, - amount: bounty.amount, - }, - ); - } - DisputeDecision::Refund => { - let maintainer = bounty.maintainer.clone(); - let token_client = TokenClient::new(&env, &bounty.token); - token_client.transfer(&env.current_contract_address(), &maintainer, &bounty.amount); - bounty.status = BountyStatus::Refunded; - write_bounty(&env, bounty_id, &bounty); - env.events().publish( - (symbol_short!("Bounty"), symbol_short!("Refund")), - BountyRefunded { - bounty_id, - maintainer, - amount: bounty.amount, - }, - ); - } - } - // Remove pending - env.storage().persistent().remove(&DataKey::PendingResolution(bounty_id)); - } - - pub fn appeal(env: Env, bounty_id: u64) { - let pending_opt: Option = env - .storage() - .persistent() - .get(&DataKey::PendingResolution(bounty_id)); - let pending = pending_opt.expect("no pending resolution"); - let cfg: Config = env.storage().persistent().get(&DataKey::Config).expect("config not set"); - let now = env.ledger().timestamp(); - if now >= pending.timestamp + cfg.appeal_window { - panic!("appeal window elapsed"); - } - // Verify caller is losing party - let bounty = read_bounty(&env, bounty_id); - match pending.decision { - DisputeDecision::Release => { - // loser is maintainer - bounty.maintainer.require_auth(); - } - DisputeDecision::Refund => { - // loser is contributor - if let Some(contrib) = bounty.contributor.clone() { - contrib.require_auth(); - } else { - panic!("no contributor to appeal"); - } - } - } - // Remove pending to block finalization until re-resolved - env.storage().persistent().remove(&DataKey::PendingResolution(bounty_id)); - env.events().publish( - (symbol_short!("Dispute"), symbol_short!("Appealed")), - DisputeAppealed { bounty_id }, - ); - } - pub fn get_next_bounty_id(env: Env) -> u64 { env.storage() .persistent() @@ -896,7 +887,6 @@ impl StellarBountyBoardContract { .unwrap_or(0) } - /// Read-only view function to enumerate bounties on-chain. pub fn get_all_bounties(env: Env, start: u64, limit: u32) -> Vec { let enforced_limit = if limit > 50 { 50 } else { limit }; let mut result = Vec::new(&env); @@ -907,7 +897,6 @@ impl StellarBountyBoardContract { .get(&DataKey::NextBountyId) .unwrap_or(0); - // Return empty Vec immediately if start is out of bounds or invalid if start == 0 || start > next_id || enforced_limit == 0 { return result; } @@ -915,9 +904,7 @@ impl StellarBountyBoardContract { let mut id = start; let mut count = 0u32; - // Loop up to the limit or until we exceed the highest allocated bounty ID while count < enforced_limit && id <= next_id { - // Check if the bounty actually exists in storage before reading to prevent a panic if env.storage().persistent().has(&DataKey::Bounty(id)) { let mut bounty = read_bounty(&env, id); expire_if_needed(&env, &mut bounty); @@ -930,15 +917,6 @@ impl StellarBountyBoardContract { result } - /// Returns all bounties where the contributor field matches the given address, - /// using the same start/limit pagination as [`get_all_bounties`]. - /// - /// Only bounties in `Reserved`, `Submitted`, `Released`, or `Disputed` state - /// are ever returned — `Open` bounties have no contributor and are always - /// excluded. `Expired` and `Refunded` bounties that were previously reserved - /// by this contributor will also appear so callers can see their full history. - /// - /// The `limit` parameter is capped at 50 matching the rest of the API. pub fn get_bounties_by_contributor(env: Env, contributor: Address, start: u64, limit: u32) -> Vec { let enforced_limit = if limit > 50 { 50 } else { limit }; let mut result = Vec::new(&env); @@ -963,7 +941,6 @@ impl StellarBountyBoardContract { if env.storage().persistent().has(&DataKey::Bounty(id)) { let mut bounty = read_bounty(&env, id); expire_if_needed(&env, &mut bounty); - // Include the bounty only if this contributor was assigned to it if bounty.contributor.as_ref() == Some(&contributor) { result.push_back(bounty); } @@ -974,10 +951,6 @@ impl StellarBountyBoardContract { result } - /// Returns the cumulative fee statistics for the contract. - /// - /// Returns a [`FeeStats`] with `total_collected = 0` and `bounty_count = 0` - /// if no bounties have been released yet. pub fn get_fee_stats(env: Env) -> FeeStats { env.storage() .persistent() @@ -987,28 +960,7 @@ impl StellarBountyBoardContract { bounty_count: 0, }) } -} - -fn accumulate_fee_stats(env: &Env, fee_amount: i128) { - if fee_amount > 0 { - let mut stats: FeeStats = env - .storage() - .persistent() - .get(&DataKey::FeeStats) - .unwrap_or(FeeStats { - total_collected: 0, - bounty_count: 0, - }); - stats.total_collected += fee_amount; - stats.bounty_count += 1; - env.storage() - .persistent() - .set(&DataKey::FeeStats, &stats); - } - /// Returns the effective dispute window for a bounty. - /// If the bounty has a per-bounty override, returns that value. - /// Otherwise returns the global DisputeWindow configured at initialization. pub fn get_effective_dispute_window(env: Env, bounty_id: u64) -> u64 { let bounty = read_bounty(&env, bounty_id); bounty.dispute_window_override.unwrap_or_else(|| { @@ -1018,6 +970,7 @@ fn accumulate_fee_stats(env: &Env, fee_amount: i128) { .unwrap_or(0) }) } + pub fn set_arbiter(env: Env, new_arbiter: Address) { let admin: Address = env .storage() @@ -1030,7 +983,7 @@ fn accumulate_fee_stats(env: &Env, fee_amount: i128) { .persistent() .set(&DataKey::PendingArbiter, &new_arbiter); - let timelock = env.ledger().timestamp() + 86400 * 2; // 2 days delay + let timelock = env.ledger().timestamp() + 86400 * 2; env.storage() .persistent() .set(&DataKey::ArbiterRotationTimelock, &timelock); @@ -1087,7 +1040,6 @@ fn accumulate_fee_stats(env: &Env, fee_amount: i128) { ); } - /// Admin: set allowlist enabled state pub fn set_allowlist_enabled(env: Env, admin: Address, enabled: bool) { let stored_admin: Address = env .storage() @@ -1103,7 +1055,6 @@ fn accumulate_fee_stats(env: &Env, fee_amount: i128) { env.storage().instance().set(&DataKey::AllowlistConfig, &config); } - /// Admin: add a token to the allowlist pub fn add_allowed_token(env: Env, admin: Address, token: Address) { let stored_admin: Address = env .storage() @@ -1125,7 +1076,6 @@ fn accumulate_fee_stats(env: &Env, fee_amount: i128) { } } - /// Admin: remove a token from the allowlist pub fn remove_allowed_token(env: Env, admin: Address, token: Address) { let stored_admin: Address = env .storage() @@ -1147,6 +1097,96 @@ fn accumulate_fee_stats(env: &Env, fee_amount: i128) { ); } } + + pub fn init(env: Env, appeal_window: u64) { + if env.storage().persistent().has(&DataKey::Config) { + panic!("config already set"); + } + let cfg = Config { appeal_window }; + env.storage().persistent().set(&DataKey::Config, &cfg); + } + + pub fn finalize_resolution(env: Env, bounty_id: u64) { + let pending_opt: Option = env + .storage() + .persistent() + .get(&DataKey::PendingResolution(bounty_id)); + let pending = pending_opt.expect("no pending resolution"); + let cfg: Config = env.storage().persistent().get(&DataKey::Config).expect("config not set"); + let now = env.ledger().timestamp(); + if now < pending.timestamp + cfg.appeal_window { + panic!("appeal window not elapsed"); + } + let mut bounty = read_bounty(&env, bounty_id); + match pending.decision { + DisputeDecision::Release => { + let contributor = bounty + .contributor + .clone() + .unwrap_or_else(|| panic!("missing contributor")); + let token_client = TokenClient::new(&env, &bounty.token); + token_client.transfer(&env.current_contract_address(), &contributor, &bounty.amount); + bounty.status = BountyStatus::Released; + write_bounty(&env, bounty_id, &bounty); + env.events().publish( + (symbol_short!("Bounty"), symbol_short!("Releas")), + BountyReleased { + bounty_id, + contributor, + amount: bounty.amount, + fee_amount: 0, + }, + ); + } + DisputeDecision::Refund => { + let maintainer = bounty.maintainer.clone(); + let token_client = TokenClient::new(&env, &bounty.token); + token_client.transfer(&env.current_contract_address(), &maintainer, &bounty.amount); + bounty.status = BountyStatus::Refunded; + write_bounty(&env, bounty_id, &bounty); + env.events().publish( + (symbol_short!("Bounty"), symbol_short!("Refund")), + BountyRefunded { + bounty_id, + maintainer, + amount: bounty.amount, + }, + ); + } + } + env.storage().persistent().remove(&DataKey::PendingResolution(bounty_id)); + } + + pub fn appeal(env: Env, bounty_id: u64) { + let pending_opt: Option = env + .storage() + .persistent() + .get(&DataKey::PendingResolution(bounty_id)); + let pending = pending_opt.expect("no pending resolution"); + let cfg: Config = env.storage().persistent().get(&DataKey::Config).expect("config not set"); + let now = env.ledger().timestamp(); + if now >= pending.timestamp + cfg.appeal_window { + panic!("appeal window elapsed"); + } + let bounty = read_bounty(&env, bounty_id); + match pending.decision { + DisputeDecision::Release => { + bounty.maintainer.require_auth(); + } + DisputeDecision::Refund => { + if let Some(contrib) = bounty.contributor.clone() { + contrib.require_auth(); + } else { + panic!("no contributor to appeal"); + } + } + } + env.storage().persistent().remove(&DataKey::PendingResolution(bounty_id)); + env.events().publish( + (symbol_short!("Dispute"), symbol_short!("Appealed")), + DisputeAppealed { bounty_id }, + ); + } } // ─── Helper Functions ──────────────────────────────────────────────────────── @@ -1183,7 +1223,6 @@ fn get_allowlist_config(env: &Env) -> AllowlistConfig { }) } -/// Check if a token is allowed to fund bounties fn is_token_allowed(env: &Env, token: Address) -> bool { let config = get_allowlist_config(env); if !config.enabled { @@ -1192,11 +1231,6 @@ fn is_token_allowed(env: &Env, token: Address) -> bool { config.allowed_tokens.contains(token) } -/// Atomically add `fee_amount` to the cumulative [`FeeStats`] in persistent storage. -/// -/// Called after every payout (normal release and dispute-release). When `fee_amount` -/// is zero the stats are still updated so that `bounty_count` always reflects the -/// total number of released bounties, not just fee-paying ones. fn accumulate_fee_stats(env: &Env, fee_amount: i128) { let mut stats: FeeStats = env .storage() @@ -1211,5 +1245,4 @@ fn accumulate_fee_stats(env: &Env, fee_amount: i128) { stats.bounty_count += 1; env.storage().persistent().set(&DataKey::FeeStats, &stats); - -} \ No newline at end of file +} diff --git a/contracts/src/test.rs b/contracts/src/test.rs index a6ec0d26..6d6dc1cd 100644 --- a/contracts/src/test.rs +++ b/contracts/src/test.rs @@ -160,7 +160,7 @@ macro_rules! invalid_transition_test { #[test] fn test_get_min_bounty_amount_default() { let env = Env::default(); - let (client, _, _, _, _, _) = setup_test(&env); + let (client, _admin, _maintainer, _contributor, _token_id, _fee_recipient, _arbiter) = setup_test(&env); let min = client.get_min_bounty_amount(); assert_eq!(min, DEFAULT_MIN_BOUNTY_AMOUNT); @@ -171,7 +171,7 @@ fn test_set_min_bounty_amount_success() { let env = Env::default(); env.mock_all_auths(); - let (client, _, _, _, _, arbiter) = setup_test(&env); + let (client, _admin, _maintainer, _contributor, _token_id, _fee_recipient, arbiter) = setup_test(&env); let new_min = 1000i128; client.set_min_bounty_amount(&new_min); @@ -186,7 +186,7 @@ fn test_set_min_bounty_amount_zero_fails() { let env = Env::default(); env.mock_all_auths(); - let (client, _, _, _, _, arbiter) = setup_test(&env); + let (client, _admin, _maintainer, _contributor, _token_id, _fee_recipient, _arbiter) = setup_test(&env); client.set_min_bounty_amount(&0); } @@ -196,7 +196,7 @@ fn test_set_min_bounty_amount_above_max_fails() { let env = Env::default(); env.mock_all_auths(); - let (client, _, _, _, _, arbiter) = setup_test(&env); + let (client, _admin, _maintainer, _contributor, _token_id, _fee_recipient, _arbiter) = setup_test(&env); client.set_min_bounty_amount(&(MAX_BOUNTY_AMOUNT + 1)); } @@ -206,7 +206,7 @@ fn test_create_bounty_below_minimum_fails() { let env = Env::default(); env.mock_all_auths(); - let (client, maintainer, _, token_id, _, _) = setup_test(&env); + let (client, _admin, maintainer, _contributor, token_id, _fee_recipient, _arbiter) = setup_test(&env); let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); token_admin.mint(&maintainer, &1000); @@ -229,7 +229,7 @@ fn test_create_bounty_at_minimum_succeeds() { let env = Env::default(); env.mock_all_auths(); - let (client, maintainer, _, token_id, _, _) = setup_test(&env); + let (client, _admin, maintainer, _contributor, token_id, _fee_recipient, _arbiter) = setup_test(&env); let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); token_admin.mint(&maintainer, &1000); @@ -255,7 +255,7 @@ fn test_create_bounty_above_minimum_succeeds() { let env = Env::default(); env.mock_all_auths(); - let (client, maintainer, _, token_id, _, _) = setup_test(&env); + let (client, _admin, maintainer, _contributor, token_id, _fee_recipient, _arbiter) = setup_test(&env); let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); token_admin.mint(&maintainer, &1000); @@ -282,7 +282,7 @@ fn test_create_bounty_after_raising_minimum_fails() { let env = Env::default(); env.mock_all_auths(); - let (client, maintainer, _, token_id, _, arbiter) = setup_test(&env); + let (client, _admin, maintainer, _contributor, token_id, _fee_recipient, _arbiter) = setup_test(&env); let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); token_admin.mint(&maintainer, &1000); @@ -308,7 +308,7 @@ fn test_create_bounty_after_raising_minimum_succeeds() { let env = Env::default(); env.mock_all_auths(); - let (client, maintainer, _, token_id, _, arbiter) = setup_test(&env); + let (client, _admin, maintainer, _contributor, token_id, _fee_recipient, _arbiter) = setup_test(&env); let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); token_admin.mint(&maintainer, &10_000); @@ -459,7 +459,7 @@ fn test_refund_reserved_before_deadline_fails() { let env = Env::default(); env.mock_all_auths(); - let (client, maintainer, contributor, token_id, _, _) = setup_test(&env); + let (client, _admin, maintainer, contributor, token_id, _fee_recipient, _arbiter) = setup_test(&env); let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); token_admin.mint(&maintainer, &1000); @@ -1359,13 +1359,45 @@ fn test_create_bounty_override_above_max_fails() { #[test] #[should_panic(expected = "DisputeWindowNotMet")] fn test_resolve_dispute_custom_window_not_met_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, maintainer, contributor, token_id, _, _) = setup_test(&env); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); + token_admin.mint(&maintainer, &1000); + + let deadline = env.ledger().timestamp() + 1000; + let custom_window = 3600u64; + let bounty_id = client.create_bounty( + &maintainer, + &token_id, + &500, + &String::from_str(&env, "repo"), + &1, + &String::from_str(&env, "title"), + &deadline, + &0u32, + &Some(custom_window), + ); + + client.reserve_bounty(&bounty_id, &contributor); + client.submit_bounty(&bounty_id, &contributor); + client.dispute_bounty(&bounty_id, &client.address()); + + // Advance time but less than the custom window + env.ledger().set_timestamp(env.ledger().timestamp() + 1800); + + // This should panic because the dispute window (3600s) hasn't elapsed yet + client.resolve_dispute(&bounty_id, &true); +} + // ─── get_bounties_by_contributor tests (Issue #750) ──────────────────────── /// No bounties at all — should return an empty vec without panic. #[test] fn test_get_bounties_by_contributor_empty() { let env = Env::default(); - let (client, _, contributor, _, _, _) = setup_test(&env); + let (client, _admin, _maintainer, contributor, _token_id, _fee_recipient, _arbiter) = setup_test(&env); let result = client.get_bounties_by_contributor(&contributor, &1u64, &10u32); assert_eq!(result.len(), 0); @@ -1377,7 +1409,7 @@ fn test_get_bounties_by_contributor_no_match() { let env = Env::default(); env.mock_all_auths(); - let (client, maintainer, contributor, token_id, _, _) = setup_test(&env); + let (client, _admin, maintainer, contributor, token_id, _fee_recipient, _arbiter) = setup_test(&env); let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); token_admin.mint(&maintainer, &1000); @@ -1403,7 +1435,7 @@ fn test_get_bounties_by_contributor_single_reserved() { let env = Env::default(); env.mock_all_auths(); - let (client, maintainer, contributor, token_id, _, _) = setup_test(&env); + let (client, _admin, maintainer, contributor, token_id, _fee_recipient, _arbiter) = setup_test(&env); let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); token_admin.mint(&maintainer, &1000); @@ -1432,7 +1464,7 @@ fn test_get_bounties_by_contributor_multiple_bounties() { let env = Env::default(); env.mock_all_auths(); - let (client, maintainer, contributor, token_id, _, _) = setup_test(&env); + let (client, _admin, maintainer, contributor, token_id, _fee_recipient, _arbiter) = setup_test(&env); let other_contributor = Address::generate(&env); let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); token_admin.mint(&maintainer, &5_000); @@ -1486,7 +1518,30 @@ fn test_get_bounties_by_contributor_includes_released() { let env = Env::default(); env.mock_all_auths(); + let (client, _admin, maintainer, contributor, token_id, _fee_recipient, _arbiter) = setup_test(&env); + let token = TokenClient::new(&env, &token_id); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); + token_admin.mint(&maintainer, &1000); + + let bounty_id = client.create_bounty( + &maintainer, + &token_id, + &500, + &String::from_str(&env, "repo"), + &1, + &String::from_str(&env, "title"), + &(env.ledger().timestamp() + 1000), + &0u32, + &None, + ); + + client.reserve_bounty(&bounty_id, &contributor); + client.submit_bounty(&bounty_id, &contributor); + client.release_bounty(&bounty_id, &maintainer); + let result = client.get_bounties_by_contributor(&contributor, &1u64, &50u32); + assert_eq!(result.len(), 1, "released bounty should appear in contributor history"); + assert_eq!(result.get(0).unwrap().status, BountyStatus::Released); } // ─── Double-refund after cancel_bounty test (#747) ──────────────────────── @@ -1518,3 +1573,109 @@ fn test_double_refund_after_cancel_bounty() { client.refund_bounty(&bounty_id, &maintainer); } +// ─── get_arbiter and get_dispute_window Tests (#758) ────────────────────── + +#[test] +fn test_get_arbiter_returns_initialized_value() { + let env = Env::default(); + let (client, _admin, _, _, _, _fee_recipient, arbiter) = setup_test(&env); + + let result = client.get_arbiter(); + assert_eq!(result, arbiter, "get_arbiter() should return the arbiter address set during initialize()"); +} + +#[test] +fn test_get_arbiter_matches_initialize_parameter() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, StellarBountyBoardContract); + let client = StellarBountyBoardContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + let arbiter = Address::generate(&env); + let dispute_window = 900u64; + + client.initialize(&admin, &fee_recipient, &arbiter, &dispute_window); + + let retrieved_arbiter = client.get_arbiter(); + assert_eq!(retrieved_arbiter, arbiter, "get_arbiter() should exactly match the arbiter passed to initialize()"); +} + +#[test] +#[should_panic(expected = "not initialized")] +fn test_get_arbiter_panics_when_not_initialized() { + let env = Env::default(); + + let contract_id = env.register_contract(None, StellarBountyBoardContract); + let client = StellarBountyBoardContractClient::new(&env, &contract_id); + + // Do not call initialize — arbiter storage should be empty + client.get_arbiter(); +} + +#[test] +fn test_get_dispute_window_returns_initialized_value() { + let env = Env::default(); + let (client, _, _, _, _, _, _) = setup_test(&env); + + let result = client.get_dispute_window(); + assert_eq!(result, 600u64, "get_dispute_window() should return the dispute window set during initialize()"); +} + +#[test] +fn test_get_dispute_window_matches_initialize_parameter() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, StellarBountyBoardContract); + let client = StellarBountyBoardContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + let arbiter = Address::generate(&env); + let dispute_window = 1800u64; + + client.initialize(&admin, &fee_recipient, &arbiter, &dispute_window); + + let retrieved_window = client.get_dispute_window(); + assert_eq!(retrieved_window, dispute_window, "get_dispute_window() should exactly match the dispute_window passed to initialize()"); +} + +#[test] +#[should_panic(expected = "not initialized")] +fn test_get_dispute_window_panics_when_not_initialized() { + let env = Env::default(); + + let contract_id = env.register_contract(None, StellarBountyBoardContract); + let client = StellarBountyBoardContractClient::new(&env, &contract_id); + + // Do not call initialize — dispute window storage should be empty + client.get_dispute_window(); +} + +#[test] +fn test_get_arbiter_and_dispute_window_no_auth_required() { + let env = Env::default(); + + let contract_id = env.register_contract(None, StellarBountyBoardContract); + let client = StellarBountyBoardContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + let arbiter = Address::generate(&env); + let dispute_window = 600u64; + + // Initialize with explicit auth + env.mock_all_auths(); + client.initialize(&admin, &fee_recipient, &arbiter, &dispute_window); + + // These should succeed despite no auth being provided + let retrieved_arbiter = client.get_arbiter(); + assert_eq!(retrieved_arbiter, arbiter); + + let retrieved_window = client.get_dispute_window(); + assert_eq!(retrieved_window, dispute_window); +} +