diff --git a/contract/ticket_contract/src/lib.rs b/contract/ticket_contract/src/lib.rs index acc203f6..31b6b74a 100644 --- a/contract/ticket_contract/src/lib.rs +++ b/contract/ticket_contract/src/lib.rs @@ -18,7 +18,7 @@ use stellar_tokens::non_fungible::{Base, NonFungibleToken}; mod storage_types; use storage_types::{ AllocationConfig, AllocationStrategyType, AntiSnipingConfig, DataKey, EventInfo, PricingConfig, - PricingStrategy, Ticket, Tier, VRFState, + PricingStrategy, PurchaseCommitment, FrontRunMonitor, Ticket, Tier, VRFState, }; mod oracle; @@ -70,6 +70,15 @@ const DEFAULT_RATE_LIMIT_WINDOW: u64 = 3_600; /// Default randomization delay for anti-sniping protection, in ledgers. const ANTI_SNIPING_RANDOMIZATION_DELAY: u32 = 3; +/// Default minimum ledger delay between commit and reveal (5 ledgers ≈ 25 seconds) +const DEFAULT_MIN_REVEAL_DELAY: u32 = 5; +/// Monitoring window size in ledgers (approximately 5 minutes) +const MONITOR_WINDOW_LEDGERS: u32 = 60; +/// Maximum commits allowed per monitoring window before flagging +const MAX_COMMITS_PER_WINDOW: u32 = 10; +/// Maximum failed reveals before temporarily blocking an address +const MAX_FAILED_REVEALS: u32 = 5; + #[contract] pub struct SoulboundTicketContract; @@ -986,6 +995,278 @@ impl SoulboundTicketContract { e.storage().instance().get(&DataKey::Version).unwrap_or(1) } + // ==================== FRONT-RUNNING MITIGATION ==================== + + /// Set the minimum ledger delay between commit and reveal (admin only). + /// A higher value increases security but also increases the wait time for buyers. + pub fn set_min_reveal_delay(e: &Env, delay_ledgers: u32) { + let admin: Address = e.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + if delay_ledgers == 0 { + panic!("delay must be > 0"); + } + e.storage().instance().set(&DataKey::MinRevealDelay, &delay_ledgers); + } + + /// Phase 1 of front-running-resistant purchase: commit. + /// + /// The buyer submits a hash of their purchase intent without revealing + /// the actual parameters. This prevents observers from front-running + /// because they cannot see which tier or price the buyer is targeting. + /// + /// `commitment_hash` = SHA-256(buyer || tier_symbol || max_price || nonce) + /// + /// The buyer must call `reveal_purchase` after a minimum delay to + /// complete the purchase. + pub fn commit_purchase( + e: &Env, + buyer: Address, + commitment_hash: BytesN<32>, + tier_symbol: Symbol, + ) { + buyer.require_auth(); + + // Check if the buyer is temporarily blocked by the monitor + Self::check_front_run_monitor(e, &buyer); + + // Prevent overwriting an existing unrevealed commitment + let commit_key = DataKey::PurchaseCommitment(buyer.clone()); + if let Some(existing) = e.storage().persistent().get::<_, PurchaseCommitment>(&commit_key) { + if !existing.revealed { + panic!("active commitment already exists; reveal or wait for expiry"); + } + } + + // Verify the tier exists and is active + let tier_key = DataKey::Tier(tier_symbol.clone()); + let tier: Tier = e + .storage() + .persistent() + .get(&tier_key) + .unwrap_or_else(|| panic!("Tier not found")); + if !tier.active { + panic!("Tier is not active"); + } + if tier.minted >= tier.max_supply { + panic!("Tier sold out"); + } + + // Store the commitment + let commitment = PurchaseCommitment { + commitment_hash, + commit_ledger: e.ledger().sequence(), + tier_symbol: tier_symbol.clone(), + revealed: false, + }; + e.storage().persistent().set(&commit_key, &commitment); + + // Update the monitoring counters + Self::update_front_run_monitor(e, &buyer, false); + + e.events().publish( + (Symbol::new(e, "purchase_committed"),), + (buyer, tier_symbol), + ); + } + + /// Phase 2 of front-running-resistant purchase: reveal and execute. + /// + /// The buyer reveals the original parameters that produce the commitment + /// hash. The contract verifies the hash matches, enforces a minimum + /// ledger delay, and checks that the current dynamic price does not + /// exceed `max_price` (slippage protection). + pub fn reveal_purchase( + e: &Env, + buyer: Address, + payment_token: Address, + tier_symbol: Symbol, + max_price: i128, + nonce: u32, + ) { + buyer.require_auth(); + + // Check if the buyer is temporarily blocked + Self::check_front_run_monitor(e, &buyer); + + // Load the commitment + let commit_key = DataKey::PurchaseCommitment(buyer.clone()); + let mut commitment: PurchaseCommitment = e + .storage() + .persistent() + .get(&commit_key) + .unwrap_or_else(|| panic!("No commitment found; call commit_purchase first")); + + if commitment.revealed { + panic!("Commitment already revealed"); + } + + // Enforce minimum delay between commit and reveal + let min_delay: u32 = e + .storage() + .instance() + .get(&DataKey::MinRevealDelay) + .unwrap_or(DEFAULT_MIN_REVEAL_DELAY); + let current_ledger = e.ledger().sequence(); + if current_ledger < commitment.commit_ledger + min_delay { + // Record as failed reveal attempt for monitoring + Self::update_front_run_monitor(e, &buyer, true); + panic!("Reveal too early; minimum delay not met"); + } + + // Verify the commitment hash matches the revealed parameters + // Reconstruct: SHA-256(buyer || tier_symbol || max_price || nonce) + let mut preimage = soroban_sdk::Vec::new(e); + preimage.push_back(buyer.to_val()); + preimage.push_back(tier_symbol.to_val()); + preimage.push_back(soroban_sdk::IntoVal::into_val(&max_price, e)); + preimage.push_back(soroban_sdk::IntoVal::into_val(&nonce, e)); + let computed_hash = e.crypto().sha256(&preimage.to_bytes()); + if computed_hash != commitment.commitment_hash { + // Record as failed reveal for monitoring + Self::update_front_run_monitor(e, &buyer, true); + panic!("Invalid commitment: hash mismatch"); + } + + // Verify tier matches the commitment + if tier_symbol != commitment.tier_symbol { + Self::update_front_run_monitor(e, &buyer, true); + panic!("Tier mismatch with commitment"); + } + + // Get current dynamic price and enforce slippage protection + let current_price = Self::get_ticket_price(e, tier_symbol.clone()); + if current_price > max_price { + Self::update_front_run_monitor(e, &buyer, true); + panic!("Price slippage exceeded: current price is higher than max_price"); + } + + // Mark commitment as revealed + commitment.revealed = true; + e.storage().persistent().set(&commit_key, &commitment); + + // Execute the actual purchase logic (same as the original purchase) + let key = DataKey::Tier(tier_symbol.clone()); + let mut tier: Tier = e + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic!("Tier not found")); + + if !tier.active { + panic!("Tier is not active"); + } + if tier.minted >= tier.max_supply { + panic!("Tier sold out"); + } + + // Process payment at the current price (which is <= max_price) + let admin: Address = e.storage().instance().get(&DataKey::Admin).unwrap(); + let token_client = token::Client::new(e, &payment_token); + token_client.transfer(&buyer, &admin, ¤t_price); + + // Mint Token + let mut counter: u32 = e + .storage() + .instance() + .get(&DataKey::TokenIdCounter) + .unwrap(); + counter = counter.checked_add(1).expect("Counter overflow"); + let token_id = counter; + e.storage() + .instance() + .set(&DataKey::TokenIdCounter, &counter); + + Base::sequential_mint(e, &buyer); + + let ticket = Ticket { + tier_symbol: tier_symbol.clone(), + purchase_time: e.ledger().timestamp(), + price_paid: current_price, + is_valid: true, + }; + e.storage() + .persistent() + .set(&DataKey::Ticket(token_id), &ticket); + + tier.minted = tier.minted.checked_add(1).expect("Supply overflow"); + tier.current_price = current_price; + e.storage().persistent().set(&key, &tier); + + // Update pricing config last update time + let mut config: PricingConfig = + e.storage().instance().get(&DataKey::PricingConfig).unwrap(); + config.last_update_time = e.ledger().timestamp(); + e.storage().instance().set(&DataKey::PricingConfig, &config); + + e.events().publish( + (Symbol::new(e, "purchase_revealed"),), + (buyer, tier_symbol, current_price), + ); + } + + /// Check if an address is temporarily blocked by the front-running monitor. + fn check_front_run_monitor(e: &Env, address: &Address) { + let monitor_key = DataKey::FrontRunMonitor(address.clone()); + if let Some(monitor) = e.storage().persistent().get::<_, FrontRunMonitor>(&monitor_key) { + if monitor.is_blocked { + // Check if the block window has expired + let current_ledger = e.ledger().sequence(); + if current_ledger < monitor.window_start_ledger + MONITOR_WINDOW_LEDGERS * 2 { + panic!("Address temporarily blocked due to suspicious activity"); + } + // Block expired, reset + let reset = FrontRunMonitor { + commit_count: 0, + failed_reveals: 0, + window_start_ledger: current_ledger, + is_blocked: false, + }; + e.storage().persistent().set(&monitor_key, &reset); + } + } + } + + /// Update the front-running monitor for an address. + /// `is_failed_reveal` indicates whether this was a failed reveal attempt. + fn update_front_run_monitor(e: &Env, address: &Address, is_failed_reveal: bool) { + let monitor_key = DataKey::FrontRunMonitor(address.clone()); + let current_ledger = e.ledger().sequence(); + let mut monitor: FrontRunMonitor = e + .storage() + .persistent() + .get(&monitor_key) + .unwrap_or(FrontRunMonitor { + commit_count: 0, + failed_reveals: 0, + window_start_ledger: current_ledger, + is_blocked: false, + }); + + // Reset window if it has expired + if current_ledger > monitor.window_start_ledger + MONITOR_WINDOW_LEDGERS { + monitor.commit_count = 0; + monitor.failed_reveals = 0; + monitor.window_start_ledger = current_ledger; + } + + if is_failed_reveal { + monitor.failed_reveals += 1; + } else { + monitor.commit_count += 1; + } + + // Block address if thresholds exceeded + if monitor.failed_reveals >= MAX_FAILED_REVEALS + || monitor.commit_count >= MAX_COMMITS_PER_WINDOW + { + monitor.is_blocked = true; + e.events().publish( + (Symbol::new(e, "suspicious_activity"),), + address.clone(), + ); + } + + e.storage().persistent().set(&monitor_key, &monitor); // --- ROLE MANAGEMENT --- pub fn grant_role(e: &Env, admin: Address, role: Symbol, address: Address) { admin.require_auth(); diff --git a/contract/ticket_contract/src/storage_types.rs b/contract/ticket_contract/src/storage_types.rs index 87e1572a..94f928bc 100644 --- a/contract/ticket_contract/src/storage_types.rs +++ b/contract/ticket_contract/src/storage_types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, Bytes, String, Symbol}; +use soroban_sdk::{contracttype, Address, Bytes, BytesN, String, Symbol}; use gathera_common::types::{ Timestamp, TokenAmount, BasisPoints, DurationSeconds, LedgerSequence, DurationLedgers, }; @@ -31,6 +31,11 @@ pub enum DataKey { EntropyProviders(Symbol), // List of providers for a tier VRFPublicKey, // Public key for verifying off-chain VRF proofs VRFProof(Symbol), // Latest verified VRF proof for a tier + // Front-running mitigation keys + PurchaseCommitment(Address), // Stores a buyer's purchase commitment + FrontRunMonitor(Address), // Tracks suspicious activity per address + MinRevealDelay, // Global minimum delay (ledgers) between commit and reveal + Role(Symbol, Address), ContractConfig, // Proxy contract configuration TokenName, TokenSymbol, @@ -135,6 +140,37 @@ pub struct VRFState { pub batch_nonce: u32, pub finalization_ledger: LedgerSequence, } +/// Stores a buyer's purchase commitment for the commit-reveal scheme. +/// The buyer first commits a hash of (buyer, tier_symbol, max_price, nonce), +/// then reveals those values after a minimum delay to complete the purchase. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PurchaseCommitment { + /// SHA-256 hash of (buyer, tier_symbol, max_price, nonce) + pub commitment_hash: BytesN<32>, + /// Ledger sequence number when the commitment was made + pub commit_ledger: u32, + /// Tier the buyer intends to purchase + pub tier_symbol: Symbol, + /// Whether this commitment has already been revealed / consumed + pub revealed: bool, +} + +/// Tracks per-address activity to detect suspicious transaction patterns +/// such as repeated failed reveals or abnormally high commit frequency. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FrontRunMonitor { + /// Number of commits in the current monitoring window + pub commit_count: u32, + /// Number of failed or expired reveals + pub failed_reveals: u32, + /// Ledger sequence at which the current monitoring window started + pub window_start_ledger: u32, + /// Whether the address is temporarily blocked + pub is_blocked: bool, +} + #[soroban_sdk::contracterror] #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] #[repr(u32)] @@ -155,6 +191,14 @@ pub enum TicketError { TimelockNotExpired = 14, InvalidVersion = 15, ArithmeticError = 16, + // Front-running mitigation errors + CommitmentNotFound = 17, + CommitmentAlreadyRevealed = 18, + RevealTooEarly = 19, + InvalidCommitment = 20, + PriceSlippageExceeded = 21, + AddressBlocked = 22, + CommitmentAlreadyExists = 23, } #[contracttype] diff --git a/contract/ticket_contract/src/test.rs b/contract/ticket_contract/src/test.rs index 282814a4..7f7ce393 100644 --- a/contract/ticket_contract/src/test.rs +++ b/contract/ticket_contract/src/test.rs @@ -738,3 +738,162 @@ fn test_enhanced_vrf_and_entropy() { } } +// ============================================================================ +// FRONT-RUNNING MITIGATION TESTS +// ============================================================================ + +#[test] +fn test_commit_reveal_purchase_flow() { + let e = Env::default(); + e.mock_all_auths(); + + let admin = Address::generate(&e); + let buyer = Address::generate(&e); + let payment_token = e.register_stellar_asset_contract(admin.clone()); + let client = create_contract(&e, &admin); + + let tier_sym = Symbol::new(&e, "VIP"); + let base_price = 1000_i128; + client.add_tier( + &tier_sym, + &String::from_str(&e, "VIP Ticket"), + &base_price, + &10, + &PricingStrategy::Standard, + ); + + // 1. Prepare commitment + let max_price = 1100_i128; // 10% slippage tolerance + let nonce = 12345u32; + + // Hash: SHA-256(buyer || tier_symbol || max_price || nonce) + let mut preimage = soroban_sdk::Vec::new(&e); + preimage.push_back(buyer.to_val()); + preimage.push_back(tier_sym.to_val()); + preimage.push_back(soroban_sdk::IntoVal::into_val(&max_price, &e)); + preimage.push_back(soroban_sdk::IntoVal::into_val(&nonce, &e)); + let commitment_hash = e.crypto().sha256(&preimage.to_bytes()); + + // 2. Commit Purchase + client.commit_purchase(&buyer, &commitment_hash, &tier_sym); + + // 3. Try to reveal immediately (should fail due to delay) + // Default min delay is 5 ledgers. + let reveal_res = client.try_reveal_purchase(&buyer, &payment_token, &tier_sym, &max_price, &nonce); + assert!(reveal_res.is_err(), "Reveal should fail before delay"); + + // 4. Advance Ledger Sequence + let mut ledger = e.ledger().get(); + ledger.sequence += 6; + e.ledger().set(ledger); + + // 5. Reveal and Finish Purchase + // Give buyer some tokens first + let token_client = token::StellarAssetClient::new(&e, &payment_token); + token_client.mint(&buyer, &2000_i128); + + client.reveal_purchase(&buyer, &payment_token, &tier_sym, &max_price, &nonce); + + // 6. Verify Results + assert_eq!(client.balance(&buyer), 1); + let ticket = client.get_ticket(&1); + assert_eq!(ticket.price_paid, base_price); +} + +#[test] +#[should_panic(expected = "Invalid commitment: hash mismatch")] +fn test_reveal_with_wrong_price() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let buyer = Address::generate(&e); + let client = create_contract(&e, &admin); + let tier_sym = Symbol::new(&e, "VIP"); + client.add_tier(&tier_sym, &String::from_str(&e, "VIP"), &1000, &10, &PricingStrategy::Standard); + + let max_price = 1100_i128; + let nonce = 12345u32; + + let mut preimage = soroban_sdk::Vec::new(&e); + preimage.push_back(buyer.to_val()); + preimage.push_back(tier_sym.to_val()); + preimage.push_back(soroban_sdk::IntoVal::into_val(&max_price, &e)); + preimage.push_back(soroban_sdk::IntoVal::into_val(&nonce, &e)); + let commitment_hash = e.crypto().sha256(&preimage.to_bytes()); + + client.commit_purchase(&buyer, &commitment_hash, &tier_sym); + + // Advance ledger + let mut ledger = e.ledger().get(); + ledger.sequence += 6; + e.ledger().set(ledger); + + // Try to reveal with DIFFERENT price (1101 instead of 1100) + client.reveal_purchase(&buyer, &Address::generate(&e), &tier_sym, &1101_i128, &nonce); +} + +#[test] +#[should_panic(expected = "Price slippage exceeded")] +fn test_price_slippage_protection() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let buyer = Address::generate(&e); + let payment_token = e.register_stellar_asset_contract(admin.clone()); + let client = create_contract(&e, &admin); + let tier_sym = Symbol::new(&e, "VIP"); + + // Low supply to trigger rapid price increase + client.add_tier(&tier_sym, &String::from_str(&e, "VIP"), &1000, &5, &PricingStrategy::Standard); + + let max_price = 1010_i128; // Strict slippage + let nonce = 12345u32; + + let mut preimage = soroban_sdk::Vec::new(&e); + preimage.push_back(buyer.to_val()); + preimage.push_back(tier_sym.to_val()); + preimage.push_back(soroban_sdk::IntoVal::into_val(&max_price, &e)); + preimage.push_back(soroban_sdk::IntoVal::into_val(&nonce, &e)); + let commitment_hash = e.crypto().sha256(&preimage.to_bytes()); + + client.commit_purchase(&buyer, &commitment_hash, &tier_sym); + + // Now, some other user buys 4 tickets to push the price up + // 5 total tickets, threshold is 5/5 = 1. + // 4 mints = 4 thresholds = 20% increase. New price: 1200. + client.batch_mint(&Address::generate(&e), &tier_sym, &4); + assert_eq!(client.get_ticket_price(&tier_sym), 1200); + + // Advance ledger + let mut ledger = e.ledger().get(); + ledger.sequence += 6; + e.ledger().set(ledger); + + // Reveal should fail because current price (1200) > max_price (1010) + client.reveal_purchase(&buyer, &payment_token, &tier_sym, &max_price, &nonce); +} + +#[test] +#[should_panic(expected = "Address temporarily blocked")] +fn test_front_run_monitor_blocking() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let buyer = Address::generate(&e); + let client = create_contract(&e, &admin); + let tier_sym = Symbol::new(&e, "VIP"); + client.add_tier(&tier_sym, &String::from_str(&e, "VIP"), &1000, &10, &PricingStrategy::Standard); + + // Commit once + let hash = BytesN::from_array(&e, &[0u8; 32]); + client.commit_purchase(&buyer, &hash, &tier_sym); + + // Fail reveal 5 times (max failed reveals is 5) + for _ in 0..5 { + let _ = client.try_reveal_purchase(&buyer, &Address::generate(&e), &tier_sym, &1000, &0); + } + + // Next commit/reveal should fail because blocked + client.commit_purchase(&buyer, &hash, &tier_sym); +} +