diff --git a/contracts/soroban/src/lib.rs b/contracts/soroban/src/lib.rs index 1236019..645c66a 100644 --- a/contracts/soroban/src/lib.rs +++ b/contracts/soroban/src/lib.rs @@ -54,6 +54,9 @@ pub const MAX_OVERDRAFT_PERCENTAGE: u32 = 20; /// Challenge period between recovery initiation and execution. pub const RECOVERY_TIMELOCK_SECONDS: u64 = 14 * 24 * 60 * 60; +/// Minimum ledgers (~25 seconds) that must pass between commit and reveal (#212) +pub const BOUNTY_CHALLENGE_DELAY_LEDGERS: u32 = 5; + /// Calculates (threshold, extend_to) in ledgers for vault persistent storage /// and contract instance storage based on interval_seconds. pub fn calculate_ttl_params(interval_seconds: u64) -> (u32, u32) { @@ -188,6 +191,30 @@ pub struct MerklePayrollData { pub expiration_timestamp: u64, } +/// Competitive payroll bounty data (#212): escrows reward tokens for tasks solved on-chain. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BountyData { + pub id: u64, + pub creator: Address, + pub token: Address, + pub amount: i128, + pub solution_hash: BytesN<32>, + pub expiration_timestamp: u64, + pub claimed: bool, + pub claimant: Option
, +} + +/// Cryptographic claim commitment (#212): binds claimant address, solution proof, and secret salt +/// to prevent mempool frontrunning bots from copying solutions. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BountyCommitment { + pub claimant: Address, + pub commitment_hash: BytesN<32>, + pub commit_ledger: u32, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { @@ -204,6 +231,12 @@ pub enum DataKey { MerkleClaimed(u64, BytesN<32>), /// Router a merchant has pre-approved for cross-asset subscriptions (#191) TrustedSwapRouter(Address, Address), + /// Id the next competitive bounty will receive (#212) + NextBountyId, + /// Competitive payroll bounty data: bounty_id -> BountyData (#212) + Bounty(u64), + /// Bounty commitment: (bounty_id, claimant) -> BountyCommitment (#212) + BountyCommitment(u64, Address), } /// Storage key for a metered (usage-based) vault (#46). An enum on purpose: @@ -3146,7 +3179,10 @@ impl OrbitContract { liquidity_buffer: i128, ) { admin.require_auth(); - assert!(liquidity_buffer >= 0, "Liquidity buffer must be non-negative"); + assert!( + liquidity_buffer >= 0, + "Liquidity buffer must be non-negative" + ); let key = FeatureKey::YieldStrategy; let config = YieldStrategyConfig { @@ -3263,13 +3299,12 @@ impl OrbitContract { .as_ref() .expect("No yield pool configured"); let contract_addr = env.current_contract_address(); - let pool_client = LendingPoolClient::new(&env, &config.token); + let pool_client = LendingPoolClient::new(&env, pool_address); // Query the contract's balance in the lending pool let pool_balance = pool_client.balance_of(&config.token, &contract_addr); if pool_balance > 0 { - let pool = LendingPoolClient::new(&env, pool_address); - let recovered = pool.withdraw(&config.token, &pool_balance, &contract_addr); + let recovered = pool_client.withdraw(&config.token, &pool_balance, &contract_addr); config.total_deployed = 0; env.storage().persistent().set(&key, &config); @@ -3283,9 +3318,224 @@ impl OrbitContract { /// Read the current yield strategy configuration. No authorization required. pub fn get_yield_strategy(env: Env) -> Option { + env.storage().persistent().get(&FeatureKey::YieldStrategy) + } + + // -------------------------------------------------------------------- + // COMPETITIVE PAYROLL BOUNTIES & ANTI-FRONTRUNNING COMMIT-REVEAL (#212) + // -------------------------------------------------------------------- + + /// Escrow `amount` of `token` for a competitive bounty requiring proof whose + /// SHA256 hash equals `solution_hash`. Returns the unique `bounty_id`. + pub fn create_bounty( + env: Env, + creator: Address, + token: Address, + amount: i128, + solution_hash: BytesN<32>, + duration_seconds: u64, + ) -> u64 { + creator.require_auth(); + assert!(amount > 0, "amount must be positive"); + assert!(duration_seconds > 0, "duration must be positive"); + + let expiration_timestamp = env + .ledger() + .timestamp() + .checked_add(duration_seconds) + .expect("expiration overflow"); + + let counter_key = DataKey::NextBountyId; + let id: u64 = env.storage().instance().get(&counter_key).unwrap_or(0); + env.storage().instance().set( + &counter_key, + &id.checked_add(1).expect("Bounty ID exhausted"), + ); + + token::Client::new(&env, &token).transfer_from( + &env.current_contract_address(), + &creator, + &env.current_contract_address(), + &amount, + ); + + let bounty = BountyData { + id, + creator: creator.clone(), + token: token.clone(), + amount, + solution_hash: solution_hash.clone(), + expiration_timestamp, + claimed: false, + claimant: None, + }; + save_bounty(&env, &bounty); + + env.events().publish( + (Symbol::new(&env, "bounty_created"), creator, id), + (token, solution_hash, amount, expiration_timestamp), + ); + id + } + + /// Commit Phase: Contributor submits commitment hash: + /// `commitment_hash = SHA256(claimant || proof || secret_salt)`. + /// Records the commitment with current ledger sequence to enforce the + /// challenge delay window before reveal. + pub fn commit_bounty_claim( + env: Env, + claimant: Address, + bounty_id: u64, + commitment_hash: BytesN<32>, + ) { + claimant.require_auth(); + + let bounty = load_bounty(&env, bounty_id); + assert!(!bounty.claimed, "Bounty already claimed"); + assert!( + env.ledger().timestamp() <= bounty.expiration_timestamp, + "Bounty has expired" + ); + + let commit_ledger = env.ledger().sequence(); + let commitment = BountyCommitment { + claimant: claimant.clone(), + commitment_hash: commitment_hash.clone(), + commit_ledger, + }; + save_bounty_commitment(&env, bounty_id, &claimant, &commitment); + + env.events().publish( + (Symbol::new(&env, "bounty_committed"), claimant, bounty_id), + (commitment_hash, commit_ledger), + ); + } + + /// Reveal Phase: Contributor submits proof and secret salt. + /// Contract verifies: + /// 1. A commitment was recorded for `claimant` + /// 2. Challenge delay window (5 ledgers) has elapsed + /// 3. SHA256(claimant || proof || secret_salt) matches commitment_hash + /// 4. SHA256(proof) matches bounty solution_hash + /// Once verified, dispatches the token payout directly to `claimant`. + pub fn reveal_bounty_claim( + env: Env, + claimant: Address, + bounty_id: u64, + proof: Bytes, + secret_salt: BytesN<32>, + ) { + claimant.require_auth(); + + let mut bounty = load_bounty(&env, bounty_id); + assert!(!bounty.claimed, "Bounty already claimed"); + assert!( + env.ledger().timestamp() <= bounty.expiration_timestamp, + "Bounty has expired" + ); + + let commitment_key = DataKey::BountyCommitment(bounty_id, claimant.clone()); + let commitment: BountyCommitment = env + .storage() + .persistent() + .get(&commitment_key) + .expect("No commitment found for claimant"); + + assert!( + commitment.claimant == claimant, + "Mismatched claimant address" + ); + + let current_ledger = env.ledger().sequence(); + assert!( + current_ledger + >= commitment + .commit_ledger + .saturating_add(BOUNTY_CHALLENGE_DELAY_LEDGERS), + "Challenge delay window not met: must wait at least 5 ledgers" + ); + + let computed_commitment = compute_bounty_commitment(&env, &claimant, &proof, &secret_salt); + assert!( + computed_commitment == commitment.commitment_hash, + "Invalid preimage: commitment hash mismatch" + ); + + let solution_hash = env.crypto().sha256(&proof).to_bytes(); + assert!( + solution_hash == bounty.solution_hash, + "Invalid solution: does not match bounty target" + ); + + bounty.claimed = true; + bounty.claimant = Some(claimant.clone()); + save_bounty(&env, &bounty); + + token::Client::new(&env, &bounty.token).transfer( + &env.current_contract_address(), + &claimant, + &bounty.amount, + ); + + env.events().publish( + (Symbol::new(&env, "bounty_claimed"), claimant, bounty_id), + (bounty.token, bounty.amount), + ); + } + + /// Reclaim an unclaimed bounty after its expiration timestamp has elapsed. + /// Only callable by the original bounty creator. + pub fn reclaim_bounty(env: Env, creator: Address, bounty_id: u64) -> i128 { + creator.require_auth(); + let mut bounty = load_bounty(&env, bounty_id); + assert!(bounty.creator == creator, "Not the bounty creator"); + assert!(!bounty.claimed, "Bounty already claimed"); + assert!( + env.ledger().timestamp() > bounty.expiration_timestamp, + "Bounty has not expired yet" + ); + + bounty.claimed = true; + save_bounty(&env, &bounty); + + let amount = bounty.amount; + token::Client::new(&env, &bounty.token).transfer( + &env.current_contract_address(), + &creator, + &amount, + ); + + env.events().publish( + (Symbol::new(&env, "bounty_reclaimed"), creator, bounty_id), + (bounty.token, amount), + ); + amount + } + + /// Read bounty details. Requires no authorization. + pub fn get_bounty(env: Env, bounty_id: u64) -> Option { + env.storage().persistent().get(&DataKey::Bounty(bounty_id)) + } + + /// Read a contributor's commitment for a bounty. Requires no authorization. + pub fn get_bounty_commitment( + env: Env, + bounty_id: u64, + claimant: Address, + ) -> Option { env.storage() .persistent() - .get(&FeatureKey::YieldStrategy) + .get(&DataKey::BountyCommitment(bounty_id, claimant)) + } + + /// Pure helper to compute commitment hash: `SHA256(claimant || proof || secret_salt)`. + pub fn compute_bounty_commitment_hash( + env: Env, + claimant: Address, + proof: Bytes, + secret_salt: BytesN<32>, + ) -> BytesN<32> { + compute_bounty_commitment(&env, &claimant, &proof, &secret_salt) } } @@ -3415,6 +3665,52 @@ fn save_proposal(env: &Env, proposal: &PayrollProposal) { .extend_ttl(TTL_THRESHOLD_LEDGERS, MIN_TTL_LEDGERS); } +fn load_bounty(env: &Env, bounty_id: u64) -> BountyData { + env.storage() + .persistent() + .get(&DataKey::Bounty(bounty_id)) + .expect("Bounty does not exist") +} + +fn save_bounty(env: &Env, bounty: &BountyData) { + let key = DataKey::Bounty(bounty.id); + env.storage().persistent().set(&key, bounty); + let remaining = bounty + .expiration_timestamp + .saturating_sub(env.ledger().timestamp()); + let (threshold, extend_to) = calculate_ttl_params(remaining); + env.storage() + .persistent() + .extend_ttl(&key, threshold, extend_to); +} + +fn save_bounty_commitment( + env: &Env, + bounty_id: u64, + claimant: &Address, + commitment: &BountyCommitment, +) { + let key = DataKey::BountyCommitment(bounty_id, claimant.clone()); + env.storage().persistent().set(&key, commitment); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD_LEDGERS, MIN_TTL_LEDGERS); +} + +/// Computes `SHA256(claimant || proof || secret_salt)` preimage commitment (#212). +pub fn compute_bounty_commitment( + env: &Env, + claimant: &Address, + proof: &Bytes, + secret_salt: &BytesN<32>, +) -> BytesN<32> { + use soroban_sdk::xdr::ToXdr; + let mut preimage = claimant.clone().to_xdr(env); + preimage.append(proof); + preimage.extend_from_array(&secret_salt.to_array()); + env.crypto().sha256(&preimage).to_bytes() +} + #[cfg(test)] mod test; diff --git a/contracts/soroban/src/test.rs b/contracts/soroban/src/test.rs index 94c0c94..d4e7859 100644 --- a/contracts/soroban/src/test.rs +++ b/contracts/soroban/src/test.rs @@ -1579,204 +1579,6 @@ fn test_payroll_proposal_and_approval_events_published() { ); } -#[test] -fn test_failed_pull_enters_grace_and_settled_pull_restores_active() { - let env = Env::default(); - env.mock_all_auths(); - let user = Address::generate(&env); - let merchant = Address::generate(&env); - let token_admin = Address::generate(&env); - let token = env.register_stellar_asset_contract_v2(token_admin).address(); - let token_client = TokenClient::new(&env, &token); - StellarAssetClient::new(&env, &token).mint(&user, &100); - let contract_id = env.register(OrbitContract, ()); - let client = OrbitContractClient::new(&env, &contract_id); - token_client.approve(&user, &client.address, &100, &2000); - client.create_vault_with_grace_period(&user, &merchant, &token, &10, &100, &50); - - env.ledger().set_timestamp(101); - client.record_failed_pull(&merchant, &user); - assert_eq!(client.get_vault(&user, &merchant).unwrap().payment_status, PAYMENT_GRACE_PERIOD); - - client.pull_funds(&user, &merchant); - assert_eq!(client.get_vault(&user, &merchant).unwrap().payment_status, PAYMENT_ACTIVE); - assert_eq!(token_client.balance(&merchant), 10); -} - -#[test] -fn test_failed_pull_defaults_and_defaulted_pull_is_blocked() { - let env = Env::default(); - env.mock_all_auths(); - let user = Address::generate(&env); - let merchant = Address::generate(&env); - let token = Address::generate(&env); - let contract_id = env.register(OrbitContract, ()); - let client = OrbitContractClient::new(&env, &contract_id); - client.create_vault_with_grace_period(&user, &merchant, &token, &10, &100, &50); - env.ledger().set_timestamp(150); - client.record_failed_pull(&merchant, &user); - assert_eq!(client.get_vault(&user, &merchant).unwrap().payment_status, PAYMENT_DEFAULTED); - assert!(client.try_pull_funds(&user, &merchant).is_err()); - client.merchant_cancel_vault(&merchant, &user); - assert_eq!(client.get_vault(&user, &merchant), None); -} - -#[test] -fn test_merchant_cancel_and_refund() { - let env = Env::default(); - env.mock_all_auths(); - let user = Address::generate(&env); - let merchant = Address::generate(&env); - let token_admin = Address::generate(&env); - let token = env.register_stellar_asset_contract_v2(token_admin).address(); - let token_client = TokenClient::new(&env, &token); - StellarAssetClient::new(&env, &token).mint(&merchant, &100); - let contract_id = env.register(OrbitContract, ()); - let client = OrbitContractClient::new(&env, &contract_id); - client.create_vault(&user, &merchant, &token, &10, &100); - client.merchant_cancel_vault(&merchant, &user); - assert_eq!(client.get_vault(&user, &merchant), None); - token_client.approve(&merchant, &client.address, &25, &2000); - client.refund_subscriber(&merchant, &user, &token, &25); - assert_eq!(token_client.balance(&user), 25); -} - -#[test] -fn test_update_vault_terms_preserves_last_pull_and_uses_new_amount() { - let env = Env::default(); - env.mock_all_auths(); - let user = Address::generate(&env); - let merchant = Address::generate(&env); - let token_admin = Address::generate(&env); - let token = env.register_stellar_asset_contract_v2(token_admin).address(); - let token_client = TokenClient::new(&env, &token); - StellarAssetClient::new(&env, &token).mint(&user, &100); - let contract_id = env.register(OrbitContract, ()); - let client = OrbitContractClient::new(&env, &contract_id); - token_client.approve(&user, &client.address, &100, &2000); - client.create_vault(&user, &merchant, &token, &10, &100); - env.ledger().set_timestamp(10); - client.pull_funds(&user, &merchant); - client.update_vault_terms(&user, &merchant, &25, &200); - let vault = client.get_vault(&user, &merchant).unwrap(); - assert_eq!(vault.last_pull_timestamp, 10); - assert_eq!(vault.amount_per_interval, 25); - env.ledger().set_timestamp(210); - client.pull_funds(&user, &merchant); - assert_eq!(token_client.balance(&merchant), 35); -} - -#[test] -fn test_stream_withdraws_linearly_and_at_completion() { - let env = Env::default(); - env.mock_all_auths(); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token_admin = Address::generate(&env); - let token = env.register_stellar_asset_contract_v2(token_admin).address(); - let token_client = TokenClient::new(&env, &token); - StellarAssetClient::new(&env, &token).mint(&sender, &100); - let contract_id = env.register(OrbitContract, ()); - let client = OrbitContractClient::new(&env, &contract_id); - token_client.approve(&sender, &client.address, &100, &2000); - let stream_id = client.create_stream(&sender, &recipient, &token, &100, &100, &200); - assert_eq!(token_client.balance(&sender), 0); - env.ledger().set_timestamp(150); - client.withdraw_from_stream(&stream_id, &25); - assert_eq!(token_client.balance(&recipient), 25); - env.ledger().set_timestamp(200); - client.withdraw_from_stream(&stream_id, &75); - assert_eq!(token_client.balance(&recipient), 100); -} - -#[test] -fn test_stream_early_cancel_splits_vested_and_unvested_amounts() { - let env = Env::default(); - env.mock_all_auths(); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token_admin = Address::generate(&env); - let token = env.register_stellar_asset_contract_v2(token_admin).address(); - let token_client = TokenClient::new(&env, &token); - StellarAssetClient::new(&env, &token).mint(&sender, &100); - let contract_id = env.register(OrbitContract, ()); - let client = OrbitContractClient::new(&env, &contract_id); - token_client.approve(&sender, &client.address, &100, &2000); - let stream_id = client.create_stream(&sender, &recipient, &token, &100, &100, &200); - env.ledger().set_timestamp(120); - client.cancel_stream(&stream_id); - assert_eq!(token_client.balance(&recipient), 20); - assert_eq!(token_client.balance(&sender), 80); -} - -// ---------------------------------------------------------------------------- -// ISSUE #93: SUBSCRIPTION PAUSE & RESUME -// ---------------------------------------------------------------------------- - -#[test] -fn test_pause_blocks_pull_and_resume_restores_billing() { - let env = Env::default(); - env.mock_all_auths(); - - let user = Address::generate(&env); - let merchant = Address::generate(&env); - let token_admin = Address::generate(&env); - - let token_address = env - .register_stellar_asset_contract_v2(token_admin.clone()) - .address(); - let token_client = TokenClient::new(&env, &token_address); - let token_admin_client = StellarAssetClient::new(&env, &token_address); - token_admin_client.mint(&user, &100_0000000); - - let contract_id = env.register(OrbitContract, ()); - let orbit_client = OrbitContractClient::new(&env, &contract_id); - - let amount = 29_0000000; - let interval = 86400; - - token_client.approve(&user, &orbit_client.address, &100_0000000, &2000000); - orbit_client.create_vault(&user, &merchant, &token_address, &amount, &interval); - - // First settlement at t = 1000 - env.ledger().set_timestamp(1000); - orbit_client.pull_funds(&user, &merchant); - assert_eq!(token_client.balance(&merchant), amount); - - // The subscriber freezes the vault - orbit_client.pause_vault(&user, &merchant); - let paused = orbit_client - .get_vault(&user, &merchant) - .expect("vault should exist"); - assert!(paused.is_paused); - - // A full interval later the merchant still cannot pull while paused - env.ledger().set_timestamp(1000 + interval); - let res = orbit_client.try_pull_funds(&user, &merchant); - assert!(res.is_err()); - assert_eq!(token_client.balance(&merchant), amount); - assert_eq!(token_client.balance(&user), 100_0000000 - amount); - - // Resume restarts the billing clock at "now" so the interval that elapsed - // during the pause is not back-billed - orbit_client.resume_vault(&user, &merchant); - let resumed = orbit_client - .get_vault(&user, &merchant) - .expect("vault should exist"); - assert!(!resumed.is_paused); - assert_eq!(resumed.last_pull_timestamp, 1000 + interval); - - // Pulling immediately after the resume is rejected (no retro-active billing) - let res = orbit_client.try_pull_funds(&user, &merchant); - assert!(res.is_err()); - - // A full interval after the resume the merchant can bill again - env.ledger().set_timestamp(1000 + 2 * interval); - orbit_client.pull_funds(&user, &merchant); - assert_eq!(token_client.balance(&merchant), amount * 2); - assert_eq!(token_client.balance(&user), 100_0000000 - amount * 2); -} - #[test] fn test_pause_vault_authorized_by_merchant_fails() { let env = Env::default(); @@ -3269,6 +3071,9 @@ fn test_revenue_pool_multiple_deposits_claims_and_rounding_carry() { assert_eq!(state.pool.undistributed_balance, 0); assert_eq!(token_client.balance(&shareholder_a), 5); assert_eq!(token_client.balance(&shareholder_b), 10); +} + +// ---------------------------------------------------------------------------- // MERKLE PAYROLL DISTRIBUTION (#156) // ---------------------------------------------------------------------------- @@ -3686,6 +3491,9 @@ fn test_gas_subsidy_caps_and_nonce_prevent_reserve_drain() { assert_eq!(pool.balance, 90); assert_eq!(pool.next_nonce, 1); assert_eq!(native_client.balance(&sponsor), 10); +} + +#[test] fn test_merkle_payroll_emits_created_and_claimed_events() { let env = Env::default(); let (sender, _, token, contract_id) = payroll_setup(&env); @@ -3925,7 +3733,7 @@ fn test_deploy_idle_capital() { let token_address = env .register_stellar_asset_contract_v2(token_admin.clone()) .address(); - let token_client = TokenClient::new(&env, &token_address); + let _token_client = TokenClient::new(&env, &token_address); let token_admin_client = StellarAssetClient::new(&env, &token_address); let pool_id = env.register(MockLendingPool, ()); @@ -4022,3 +3830,346 @@ fn test_deploy_fails_when_no_excess() { orbit_client.deploy_idle_capital(&token_address); } + +// ---------------------------------------------------------------------------- +// COMPETITIVE PAYROLL BOUNTIES & ANTI-FRONTRUNNING COMMIT-REVEAL TESTS (#212) +// ---------------------------------------------------------------------------- + +#[test] +fn test_commit_reveal_bounty_happy_path() { + let env = Env::default(); + let (creator, claimant, token, contract_id) = payroll_setup(&env); + let orbit = OrbitContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token); + + let proof = Bytes::from_slice(&env, b"solution_code_or_work_artifact_xyz"); + let solution_hash = env.crypto().sha256(&proof).to_bytes(); + let bounty_amount: i128 = 50_0000000; + let duration_seconds: u64 = 3_600; + + let bounty_id = orbit.create_bounty( + &creator, + &token, + &bounty_amount, + &solution_hash, + &duration_seconds, + ); + assert!(has_event(&env, &contract_id, "bounty_created")); + assert_eq!(token_client.balance(&contract_id), bounty_amount); + + let bounty = orbit.get_bounty(&bounty_id).expect("bounty should exist"); + assert_eq!(bounty.amount, bounty_amount); + assert!(!bounty.claimed); + assert_eq!(bounty.claimant, None); + assert_eq!(bounty.solution_hash, solution_hash); + + // Contributor prepares commitment with secret salt + let secret_salt = BytesN::from_array(&env, &[99u8; 32]); + let commitment_hash = orbit.compute_bounty_commitment_hash(&claimant, &proof, &secret_salt); + + // Step 1: Commit Phase + let commit_seq = env.ledger().sequence(); + orbit.commit_bounty_claim(&claimant, &bounty_id, &commitment_hash); + assert!(has_event(&env, &contract_id, "bounty_committed")); + + let commitment = orbit + .get_bounty_commitment(&bounty_id, &claimant) + .expect("commitment should exist"); + assert_eq!(commitment.claimant, claimant); + assert_eq!(commitment.commitment_hash, commitment_hash); + assert_eq!(commitment.commit_ledger, commit_seq); + + // Step 2: Challenge Delay Window (advance 5 ledgers) + env.ledger().set_sequence_number(commit_seq + 5); + + // Step 3: Reveal Phase + let claimant_balance_before = token_client.balance(&claimant); + orbit.reveal_bounty_claim(&claimant, &bounty_id, &proof, &secret_salt); + assert!(has_event(&env, &contract_id, "bounty_claimed")); + + // Verify payout dispatched to claimant + let claimant_balance_after = token_client.balance(&claimant); + assert_eq!( + claimant_balance_after - claimant_balance_before, + bounty_amount + ); + + // Verify bounty marked claimed + let updated_bounty = orbit.get_bounty(&bounty_id).unwrap(); + assert!(updated_bounty.claimed); + assert_eq!(updated_bounty.claimant, Some(claimant)); +} + +#[test] +fn test_reveal_bounty_claim_before_challenge_delay_fails() { + let env = Env::default(); + let (creator, claimant, token, contract_id) = payroll_setup(&env); + let orbit = OrbitContractClient::new(&env, &contract_id); + + let proof = Bytes::from_slice(&env, b"solution_xyz"); + let solution_hash = env.crypto().sha256(&proof).to_bytes(); + let bounty_id = orbit.create_bounty(&creator, &token, &25_0000000, &solution_hash, &3_600); + + let secret_salt = BytesN::from_array(&env, &[12u8; 32]); + let commitment_hash = orbit.compute_bounty_commitment_hash(&claimant, &proof, &secret_salt); + + let commit_seq = env.ledger().sequence(); + orbit.commit_bounty_claim(&claimant, &bounty_id, &commitment_hash); + + // Try reveal at same ledger (seq + 0) -> fails + assert!(orbit + .try_reveal_bounty_claim(&claimant, &bounty_id, &proof, &secret_salt) + .is_err()); + + // Try reveal at seq + 4 (less than 5 ledgers delay) -> fails + env.ledger().set_sequence_number(commit_seq + 4); + assert!(orbit + .try_reveal_bounty_claim(&claimant, &bounty_id, &proof, &secret_salt) + .is_err()); + + // Reveal at exactly seq + 5 (minimum challenge delay window satisfied) -> succeeds + env.ledger().set_sequence_number(commit_seq + 5); + orbit.reveal_bounty_claim(&claimant, &bounty_id, &proof, &secret_salt); + + let bounty = orbit.get_bounty(&bounty_id).unwrap(); + assert!(bounty.claimed); +} + +#[test] +fn test_frontrunning_bot_mempool_copy_fails() { + let env = Env::default(); + let (creator, alice, token, contract_id) = payroll_setup(&env); + let bob = Address::generate(&env); // Frontrunning bot + let orbit = OrbitContractClient::new(&env, &contract_id); + + let proof = Bytes::from_slice(&env, b"alice_proprietary_solution"); + let solution_hash = env.crypto().sha256(&proof).to_bytes(); + let bounty_id = orbit.create_bounty(&creator, &token, &40_0000000, &solution_hash, &3_600); + + let secret_salt = BytesN::from_array(&env, &[77u8; 32]); + let alice_commitment_hash = orbit.compute_bounty_commitment_hash(&alice, &proof, &secret_salt); + + let commit_seq = env.ledger().sequence(); + orbit.commit_bounty_claim(&alice, &bounty_id, &alice_commitment_hash); + + // Attack 1: Frontrunning bot Bob sees Alice's commitment hash and copies it directly under Bob's address + orbit.commit_bounty_claim(&bob, &bounty_id, &alice_commitment_hash); + + // Advance 5 ledgers to reveal window + env.ledger().set_sequence_number(commit_seq + 5); + + // Alice broadcasts reveal with proof and salt in mempool. + // Attack 2: Bob intercepts Alice's tx in mempool, extracts proof and salt, and attempts to submit + // reveal_bounty_claim under Bob's address with higher fee before Alice's tx confirms. + let bot_reveal_result = orbit.try_reveal_bounty_claim(&bob, &bounty_id, &proof, &secret_salt); + // Bob's reveal MUST fail because Bob's commitment preimage SHA256(Bob || proof || salt) != alice_commitment_hash! + assert!(bot_reveal_result.is_err()); + + // Alice's legitimate reveal succeeds! + orbit.reveal_bounty_claim(&alice, &bounty_id, &proof, &secret_salt); + let bounty = orbit.get_bounty(&bounty_id).unwrap(); + assert!(bounty.claimed); + assert_eq!(bounty.claimant, Some(alice)); + + // Attack 3: If Bob now tries to generate a new legitimate commitment for Bob, Bob must wait 5 ledgers. + // Even if Bob waits, the bounty is already claimed and rejects any further claims. + let bob_salt = BytesN::from_array(&env, &[88u8; 32]); + let bob_commitment_hash = orbit.compute_bounty_commitment_hash(&bob, &proof, &bob_salt); + // Committing to an already claimed bounty fails + assert!(orbit + .try_commit_bounty_claim(&bob, &bounty_id, &bob_commitment_hash) + .is_err()); +} + +#[test] +fn test_tampered_proof_or_salt_panics() { + let env = Env::default(); + let (creator, claimant, token, contract_id) = payroll_setup(&env); + let orbit = OrbitContractClient::new(&env, &contract_id); + + let proof = Bytes::from_slice(&env, b"legitimate_proof"); + let solution_hash = env.crypto().sha256(&proof).to_bytes(); + let bounty_id = orbit.create_bounty(&creator, &token, &30_0000000, &solution_hash, &3_600); + + let secret_salt = BytesN::from_array(&env, &[1u8; 32]); + let commitment_hash = orbit.compute_bounty_commitment_hash(&claimant, &proof, &secret_salt); + + let commit_seq = env.ledger().sequence(); + orbit.commit_bounty_claim(&claimant, &bounty_id, &commitment_hash); + env.ledger().set_sequence_number(commit_seq + 5); + + // Tampered proof: different solution data fails preimage check + let tampered_proof = Bytes::from_slice(&env, b"tampered_proof"); + assert!(orbit + .try_reveal_bounty_claim(&claimant, &bounty_id, &tampered_proof, &secret_salt) + .is_err()); + + // Tampered salt: wrong salt fails preimage check + let wrong_salt = BytesN::from_array(&env, &[2u8; 32]); + assert!(orbit + .try_reveal_bounty_claim(&claimant, &bounty_id, &proof, &wrong_salt) + .is_err()); + + // Valid reveal succeeds + orbit.reveal_bounty_claim(&claimant, &bounty_id, &proof, &secret_salt); + assert!(orbit.get_bounty(&bounty_id).unwrap().claimed); +} + +#[test] +fn test_invalid_solution_hash_fails() { + let env = Env::default(); + let (creator, claimant, token, contract_id) = payroll_setup(&env); + let orbit = OrbitContractClient::new(&env, &contract_id); + + let expected_proof = Bytes::from_slice(&env, b"expected_target_solution"); + let target_solution_hash = env.crypto().sha256(&expected_proof).to_bytes(); + let bounty_id = + orbit.create_bounty(&creator, &token, &10_0000000, &target_solution_hash, &3_600); + + // Claimant commits a valid preimage for an incorrect solution + let wrong_solution_proof = Bytes::from_slice(&env, b"wrong_solution"); + let salt = BytesN::from_array(&env, &[3u8; 32]); + let commitment_hash = + orbit.compute_bounty_commitment_hash(&claimant, &wrong_solution_proof, &salt); + + let commit_seq = env.ledger().sequence(); + orbit.commit_bounty_claim(&claimant, &bounty_id, &commitment_hash); + env.ledger().set_sequence_number(commit_seq + 5); + + // Reveal: Preimage matches commitment, but solution does not match bounty target! + assert!(orbit + .try_reveal_bounty_claim(&claimant, &bounty_id, &wrong_solution_proof, &salt) + .is_err()); + + // Bounty remains unclaimed + let bounty = orbit.get_bounty(&bounty_id).unwrap(); + assert!(!bounty.claimed); +} + +#[test] +fn test_bounty_cannot_be_claimed_twice() { + let env = Env::default(); + let (creator, claimant, token, contract_id) = payroll_setup(&env); + let orbit = OrbitContractClient::new(&env, &contract_id); + + let proof = Bytes::from_slice(&env, b"double_claim_proof"); + let solution_hash = env.crypto().sha256(&proof).to_bytes(); + let bounty_id = orbit.create_bounty(&creator, &token, &15_0000000, &solution_hash, &3_600); + + let salt = BytesN::from_array(&env, &[4u8; 32]); + let commitment_hash = orbit.compute_bounty_commitment_hash(&claimant, &proof, &salt); + + let commit_seq = env.ledger().sequence(); + orbit.commit_bounty_claim(&claimant, &bounty_id, &commitment_hash); + env.ledger().set_sequence_number(commit_seq + 5); + + orbit.reveal_bounty_claim(&claimant, &bounty_id, &proof, &salt); + assert!(orbit.get_bounty(&bounty_id).unwrap().claimed); + + // Attempting to reveal again fails + assert!(orbit + .try_reveal_bounty_claim(&claimant, &bounty_id, &proof, &salt) + .is_err()); +} + +#[test] +fn test_bounty_reclaim_after_expiration() { + let env = Env::default(); + let (creator, claimant, token, contract_id) = payroll_setup(&env); + let orbit = OrbitContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token); + + let proof = Bytes::from_slice(&env, b"unsolved_bounty"); + let solution_hash = env.crypto().sha256(&proof).to_bytes(); + let bounty_amount: i128 = 20_0000000; + let duration: u64 = 1_000; + let bounty_id = + orbit.create_bounty(&creator, &token, &bounty_amount, &solution_hash, &duration); + + let creator_balance_before = token_client.balance(&creator); + + // Cannot reclaim before expiration + assert!(orbit.try_reclaim_bounty(&creator, &bounty_id).is_err()); + + // Non-creator cannot reclaim + let attacker = Address::generate(&env); + assert!(orbit.try_reclaim_bounty(&attacker, &bounty_id).is_err()); + + // Advance time past expiration (1000 + 1000 + 1) + env.ledger().set_timestamp(2_001); + + // Creator reclaims bounty + let reclaimed = orbit.reclaim_bounty(&creator, &bounty_id); + assert!(has_event(&env, &contract_id, "bounty_reclaimed")); + assert_eq!(reclaimed, bounty_amount); + assert_eq!( + token_client.balance(&creator), + creator_balance_before + bounty_amount + ); + + // Claims after reclaim or expiration are rejected + let salt = BytesN::from_array(&env, &[5u8; 32]); + let commitment_hash = orbit.compute_bounty_commitment_hash(&claimant, &proof, &salt); + assert!(orbit + .try_commit_bounty_claim(&claimant, &bounty_id, &commitment_hash) + .is_err()); +} + +#[test] +#[should_panic(expected = "Challenge delay window not met: must wait at least 5 ledgers")] +fn test_bounty_reveal_challenge_delay_panics_with_message() { + let env = Env::default(); + let (creator, claimant, token, contract_id) = payroll_setup(&env); + let orbit = OrbitContractClient::new(&env, &contract_id); + + let proof = Bytes::from_slice(&env, b"early_reveal"); + let solution_hash = env.crypto().sha256(&proof).to_bytes(); + let bounty_id = orbit.create_bounty(&creator, &token, &10_0000000, &solution_hash, &3_600); + + let salt = BytesN::from_array(&env, &[6u8; 32]); + let commitment_hash = orbit.compute_bounty_commitment_hash(&claimant, &proof, &salt); + + let commit_seq = env.ledger().sequence(); + orbit.commit_bounty_claim(&claimant, &bounty_id, &commitment_hash); + + // Only 2 ledgers elapsed + env.ledger().set_sequence_number(commit_seq + 2); + orbit.reveal_bounty_claim(&claimant, &bounty_id, &proof, &salt); +} + +#[test] +#[should_panic(expected = "Invalid preimage: commitment hash mismatch")] +fn test_bounty_reveal_invalid_preimage_panics_with_message() { + let env = Env::default(); + let (creator, claimant, token, contract_id) = payroll_setup(&env); + let orbit = OrbitContractClient::new(&env, &contract_id); + + let proof = Bytes::from_slice(&env, b"solution_abc"); + let solution_hash = env.crypto().sha256(&proof).to_bytes(); + let bounty_id = orbit.create_bounty(&creator, &token, &10_0000000, &solution_hash, &3_600); + + let salt = BytesN::from_array(&env, &[7u8; 32]); + let commitment_hash = orbit.compute_bounty_commitment_hash(&claimant, &proof, &salt); + + let commit_seq = env.ledger().sequence(); + orbit.commit_bounty_claim(&claimant, &bounty_id, &commitment_hash); + env.ledger().set_sequence_number(commit_seq + 5); + + let fake_proof = Bytes::from_slice(&env, b"fake_preimage"); + orbit.reveal_bounty_claim(&claimant, &bounty_id, &fake_proof, &salt); +} + +#[test] +#[should_panic(expected = "No commitment found for claimant")] +fn test_bounty_reveal_without_commitment_panics() { + let env = Env::default(); + let (creator, claimant, token, contract_id) = payroll_setup(&env); + let orbit = OrbitContractClient::new(&env, &contract_id); + + let proof = Bytes::from_slice(&env, b"no_commit"); + let solution_hash = env.crypto().sha256(&proof).to_bytes(); + let bounty_id = orbit.create_bounty(&creator, &token, &10_0000000, &solution_hash, &3_600); + + let salt = BytesN::from_array(&env, &[8u8; 32]); + orbit.reveal_bounty_claim(&claimant, &bounty_id, &proof, &salt); +}