From f6e2fcb27642ebbd284a664c3a1a4c90fdc60e16 Mon Sep 17 00:00:00 2001 From: mathstickz Date: Tue, 30 Jun 2026 02:30:42 +0100 Subject: [PATCH] feat:implement Contract struct in raffle-instance to RaffleInstance for clarity --- contracts/raffle-instance/src/events.rs | 16 +++ contracts/raffle-instance/src/lib.rs | 152 +++++++++++++++-------- contracts/raffle-instance/src/test.rs | 69 ++++++---- contracts/raffle-shared/src/constants.rs | 69 ++++++++++ contracts/raffle-shared/src/lib.rs | 43 ++++++- contracts/raffle/src/lib.rs | 5 +- 6 files changed, 271 insertions(+), 83 deletions(-) create mode 100644 contracts/raffle-shared/src/constants.rs diff --git a/contracts/raffle-instance/src/events.rs b/contracts/raffle-instance/src/events.rs index aedbd11f..fed1f54f 100644 --- a/contracts/raffle-instance/src/events.rs +++ b/contracts/raffle-instance/src/events.rs @@ -256,3 +256,19 @@ pub struct AdminChanged { pub changed_by: Address, pub timestamp: u64, } + +/// Emitted once per ticket after an NFT receipt is successfully minted +/// by the configured `nft_contract`. +#[derive(Clone)] +#[contractevent] +pub struct TicketNftMinted { + /// The address that received the NFT (the ticket buyer). + pub recipient: Address, + /// The ticket ID within this raffle (1-indexed). + pub ticket_id: u32, + /// The raffle instance contract address (NFT namespace). + pub raffle_id: Address, + /// The NFT contract that performed the mint. + pub nft_contract: Address, + pub timestamp: u64, +} diff --git a/contracts/raffle-instance/src/lib.rs b/contracts/raffle-instance/src/lib.rs index af5d819a..7ad79059 100644 --- a/contracts/raffle-instance/src/lib.rs +++ b/contracts/raffle-instance/src/lib.rs @@ -11,8 +11,14 @@ mod events; mod randomness; use raffle_shared::{ - CancelReason, FailureReason, FairnessData, RaffleConfig, RaffleStatus, RandomnessSource, RandomnessType, - Ticket, + CancelReason, FailureReason, FairnessData, NftTicketClient, RaffleConfig, RaffleStatus, + RandomnessSource, RandomnessType, Ticket, +}; + +use raffle_shared::constants::{ + EMERGENCY_WITHDRAW_DELAY_SECONDS, MAX_CLAIM_LOCKUP_SECONDS, MAX_DESCRIPTION_LENGTH, + MAX_PRIZE_AMOUNT, MAX_PRIZES, MAX_PROTOCOL_FEE_BP, MAX_SWAP_DEADLINE_SECONDS, + MAX_TICKETS_LIMIT, MIN_TICKET_PRICE, ORACLE_TIMEOUT_LEDGERS, }; use self::randomness::{build_vrf_proof_message, OracleSeedWinnerSelection, WinnerSelectionStrategy}; @@ -22,27 +28,12 @@ use crate::events::{ OracleAddressUpdated, PrizeClaimed, PrizeDeposited, PrizeRefunded, ProtocolFeeUpdated, RaffleCancelled, RaffleCreated, RaffleFinalized, RaffleFailed, RaffleStatusChanged, RandomnessFallbackTriggered, RandomnessReceived, RandomnessRequested, SwapDeadlineUpdated, - TicketPurchased, TicketRefunded, TicketSalesPaused, TicketSalesResumed, TokensRescued, - WinnerDrawn, + TicketNftMinted, TicketPurchased, TicketRefunded, TicketSalesPaused, TicketSalesResumed, + TokensRescued, WinnerDrawn, }; -const ORACLE_TIMEOUT_LEDGERS: u32 = 200; -pub const MAX_DESCRIPTION_LENGTH: u32 = 1000; -pub const MAX_TICKETS_LIMIT: u32 = 100_000; -pub const MAX_PRIZES: u32 = 100; -pub const MIN_TICKET_PRICE: i128 = 10_000; -pub const MAX_PRIZE_AMOUNT: i128 = 1_000_000_000_000_000_000_000; // 1e21 -/// Default and bounds for the claim lockup delay (#259). -pub const DEFAULT_CLAIM_LOCKUP_SECONDS: u64 = 3_600; -pub const MAX_CLAIM_LOCKUP_SECONDS: u64 = 604_800; // 7 days -/// Default and bounds for swap deadline (network congestion tolerance). -pub const DEFAULT_SWAP_DEADLINE_SECONDS: u64 = 300; // 5 minutes -pub const MAX_SWAP_DEADLINE_SECONDS: u64 = 3_600; // 1 hour -/// Emergency withdraw delay (seconds). Set to 90 days. -pub const EMERGENCY_WITHDRAW_DELAY_SECONDS: u64 = 90 * 24 * 3600; // 7776000 - #[contract] -pub struct Contract; +pub struct RaffleInstance; #[contracttype] #[derive(Clone)] pub struct Raffle { @@ -56,6 +47,9 @@ pub struct Raffle { pub allow_multiple: bool, pub ticket_price: i128, pub payment_token: Address, + /// The token used for prize deposit and claims. + /// Defaults to `payment_token` when not explicitly set by the creator. + pub prize_token: Address, pub prize_amount: i128, pub prizes: Vec, pub tickets_sold: u32, @@ -77,6 +71,9 @@ pub struct Raffle { pub swap_deadline_seconds: u64, /// When true, ticket purchases are blocked while the raffle remains Active. pub ticket_sales_paused: bool, + /// Optional NFT contract address. When `Some`, the contract mints an NFT + /// receipt for every ticket purchased via `buy_tickets`. + pub nft_contract: Option
, } #[contracttype] @@ -215,9 +212,6 @@ fn require_admin(env: &Env) -> Result { Ok(admin) } -/// Maximum protocol fee in basis points (20%) for per-raffle admin updates. -pub const MAX_PROTOCOL_FEE_BP: u32 = 2_000; - fn get_ticket_owner(env: &Env, ticket_id: u32) -> Option
{ env.storage() .persistent() @@ -433,7 +427,7 @@ fn calculate_tier_prize(raffle: &Raffle, tier_index: u32) -> Result } #[contractimpl] -impl Contract { +impl RaffleInstance { pub fn init( env: Env, factory: Address, @@ -519,6 +513,19 @@ impl Contract { // Validate that the payment_token is a valid token contract validate_token_address(&env, &config.payment_token)?; + // Validate prize_token if it differs from payment_token. + if let Some(ref pt) = config.prize_token { + if *pt != config.payment_token { + validate_token_address(&env, pt)?; + } + } + + // Resolve the prize token: use the explicit override, or fall back to payment_token. + let prize_token = config + .prize_token + .clone() + .unwrap_or_else(|| config.payment_token.clone()); + // Resolve default values for fields that use 0 as "use default" let config = config.resolve_defaults(); @@ -543,6 +550,7 @@ impl Contract { allow_multiple: config.allow_multiple, ticket_price: config.ticket_price, payment_token: config.payment_token.clone(), + prize_token: prize_token.clone(), prize_amount: config.prize_amount, prizes: config.prizes.clone(), tickets_sold: 0, @@ -560,6 +568,7 @@ impl Contract { claim_lockup_seconds: config.claim_lockup_seconds, swap_deadline_seconds: config.swap_deadline_seconds, ticket_sales_paused: false, + nft_contract: config.nft_contract, }; write_raffle(&env, &raffle); env.storage().instance().set(&DataKey::Factory, &factory); @@ -596,7 +605,7 @@ impl Contract { // Move tokens first. If the transfer fails we want the contract state // (prize_deposited flag, raffle.status) to remain untouched. - let token_client = token::Client::new(&env, &raffle.payment_token); + let token_client = token::Client::new(&env, &raffle.prize_token); let contract_address = env.current_contract_address(); let _ = token_client .try_transfer(&raffle.creator, &contract_address, &raffle.prize_amount) @@ -617,7 +626,7 @@ impl Contract { PrizeDeposited { creator: raffle.creator.clone(), amount: raffle.prize_amount, - token: raffle.payment_token.clone(), + token: raffle.prize_token.clone(), timestamp, } .publish(&env); @@ -823,8 +832,8 @@ impl Contract { } TicketPurchased { - buyer, - ticket_ids, + buyer: buyer.clone(), + ticket_ids: ticket_ids.clone(), quantity, ticket_price: raffle.ticket_price, total_paid: total_price, @@ -833,6 +842,26 @@ impl Contract { } .publish(&env); + // NFT minting: issue an on-chain NFT receipt for each ticket purchased. + // This is best-effort — a failing NFT contract panics the whole call, so + // the NFT contract is assumed to be trusted and correctly implemented. + if let Some(ref nft_addr) = raffle.nft_contract { + let nft_client = NftTicketClient::new(&env, nft_addr); + let raffle_id = env.current_contract_address(); + for i in 0..ticket_ids.len() { + let tid = ticket_ids.get(i).unwrap(); + nft_client.mint(&buyer, &tid, &raffle_id); + TicketNftMinted { + recipient: buyer.clone(), + ticket_id: tid, + raffle_id: raffle_id.clone(), + nft_contract: nft_addr.clone(), + timestamp, + } + .publish(&env); + } + } + Ok(raffle.tickets_sold) } @@ -1225,7 +1254,7 @@ impl Contract { } write_raffle(&env, &raffle); - let token_client = token::Client::new(&env, &raffle.payment_token); + let token_client = token::Client::new(&env, &raffle.prize_token); let _ = token_client .try_transfer(&env.current_contract_address(), &winner, &amount) .map_err(|_| Error::TokenTransferFailed)?; @@ -1233,7 +1262,7 @@ impl Contract { PrizeClaimed { winner, tier_index, - payment_token: raffle.payment_token.clone(), + payment_token: raffle.prize_token.clone(), gross_amount: amount, net_amount: amount, platform_fee: 0, @@ -1342,7 +1371,7 @@ impl Contract { raffle.prize_deposited = false; write_raffle(&env, &raffle); - let token_client = token::Client::new(&env, &raffle.payment_token); + let token_client = token::Client::new(&env, &raffle.prize_token); let _ = token_client .try_transfer( &env.current_contract_address(), @@ -1354,7 +1383,7 @@ impl Contract { PrizeRefunded { creator: raffle.creator.clone(), amount: raffle.prize_amount, - token: raffle.payment_token.clone(), + token: raffle.prize_token.clone(), timestamp: env.ledger().timestamp(), } .publish(&env); @@ -1419,7 +1448,7 @@ impl Contract { raffle.status = RaffleStatus::Cancelled; write_raffle(&env, &raffle); - let token_client = token::Client::new(&env, &raffle.payment_token); + let token_client = token::Client::new(&env, &raffle.prize_token); token_client.transfer( &env.current_contract_address(), &raffle.creator, @@ -1430,7 +1459,7 @@ impl Contract { withdrawn_by: caller, to: raffle.creator.clone(), amount: raffle.prize_amount, - token: raffle.payment_token.clone(), + token: raffle.prize_token.clone(), timestamp: env.ledger().timestamp(), } .publish(&env); @@ -1705,10 +1734,13 @@ impl Contract { return Err(Error::InvalidParameters); } - // Protect active escrow: block sweeping the raffle payment token while - // the prize is deposited (i.e. the escrow is live). + // Protect active escrow: block sweeping the prize token while the prize + // is deposited. Also block the payment token if it equals the prize token + // to prevent draining the fee pool via a mis-directed rescue. if let Ok(raffle) = read_raffle(&env) { - if token == raffle.payment_token && raffle.prize_deposited { + if raffle.prize_deposited + && (token == raffle.prize_token || token == raffle.payment_token) + { return Err(Error::InvalidParameters); } } @@ -1954,8 +1986,8 @@ mod test { env.mock_all_auths(); env.ledger().set_timestamp(1_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); // Players let factory = env.register(MockFactory, ()); @@ -1992,6 +2024,8 @@ mod test { metadata_hash: BytesN::from_array(&env, &[1u8; 32]), claim_lockup_seconds: 0, // => DEFAULT_CLAIM_LOCKUP_SECONDS (3600) swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -2021,8 +2055,8 @@ mod test { env.mock_all_auths(); env.ledger().set_timestamp(1_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let factory = env.register(MockFactory, ()); let admin = Address::generate(&env); @@ -2055,6 +2089,8 @@ mod test { metadata_hash: BytesN::from_array(&env, &[5u8; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -2070,15 +2106,15 @@ mod test { fn setup_active_raffle( env: &Env, ) -> ( - ContractClient<'_>, + RaffleInstanceClient<'_>, Address, Address, Address, Address, token::StellarAssetClient<'_>, ) { - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(env, &contract_id); let factory = env.register(MockFactory, ()); let admin = Address::generate(env); @@ -2111,6 +2147,8 @@ mod test { metadata_hash: BytesN::from_array(env, &[7u8; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -2170,8 +2208,8 @@ mod test { env.mock_all_auths(); env.ledger().set_timestamp(1_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let factory = env.register(MockFactory, ()); let admin = Address::generate(&env); @@ -2206,6 +2244,8 @@ mod test { metadata_hash: BytesN::from_array(&env, &[9u8; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -2296,8 +2336,8 @@ mod test { env.mock_all_auths(); env.ledger().set_timestamp(1_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let factory = env.register(MockFactory, ()); let admin = Address::generate(&env); @@ -2329,6 +2369,8 @@ mod test { metadata_hash: BytesN::from_array(&env, &[3u8; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -2361,8 +2403,8 @@ mod test { env.mock_all_auths(); env.ledger().set_timestamp(1_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let factory = env.register(MockFactory, ()); let admin = Address::generate(&env); @@ -2395,6 +2437,8 @@ mod test { metadata_hash: BytesN::from_array(&env, &[4u8; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -2421,14 +2465,14 @@ mod test { env: &Env, ) -> ( Address, - ContractClient<'_>, + RaffleInstanceClient<'_>, Address, Address, Address, u64, ) { - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(env, &contract_id); let factory = env.register(MockFactory, ()); let admin = Address::generate(env); @@ -2460,6 +2504,8 @@ mod test { metadata_hash: BytesN::from_array(env, &[5u8; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); diff --git a/contracts/raffle-instance/src/test.rs b/contracts/raffle-instance/src/test.rs index fd9ee3fc..f8cf98fb 100644 --- a/contracts/raffle-instance/src/test.rs +++ b/contracts/raffle-instance/src/test.rs @@ -25,8 +25,8 @@ fn test_oracle_fallback_with_ledger_delays() { let token_client = StellarAssetClient::new(&env, &payment_token); token_client.mint(&creator, &100_000_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); // 2. Initialize Raffle with External Randomness let config = RaffleConfig { @@ -50,6 +50,8 @@ fn test_oracle_fallback_with_ledger_delays() { metadata_hash: BytesN::from_array(&env, &[1; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -108,8 +110,8 @@ fn test_admin_updates_oracle_address() { let oracle = Address::generate(&env); let new_oracle = Address::generate(&env); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let config = RaffleConfig { description: String::from_str(&env, "Oracle migration"), @@ -134,6 +136,8 @@ fn test_admin_updates_oracle_address() { metadata_hash: BytesN::from_array(&env, &[2; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -158,8 +162,8 @@ fn test_admin_sets_protocol_fee_before_sales() { let admin = Address::generate(&env); let creator = Address::generate(&env); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let config = RaffleConfig { description: String::from_str(&env, "Fee update"), @@ -184,6 +188,8 @@ fn test_admin_sets_protocol_fee_before_sales() { metadata_hash: BytesN::from_array(&env, &[3; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -219,8 +225,8 @@ fn test_admin_withdraws_accumulated_fees() { token_client.mint(&creator, &1_000_000); token_client.mint(&buyer, &1_000_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let config = RaffleConfig { description: String::from_str(&env, "Fee withdraw"), @@ -243,6 +249,8 @@ fn test_admin_withdraws_accumulated_fees() { metadata_hash: BytesN::from_array(&env, &[4; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -290,8 +298,8 @@ fn test_buy_tickets_rejects_quantity_above_per_tx_cap() { token_client.mint(&creator, &1_000_000); token_client.mint(&buyer, &1_000_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let config = RaffleConfig { description: String::from_str(&env, "Per-tx cap"), @@ -314,6 +322,8 @@ fn test_buy_tickets_rejects_quantity_above_per_tx_cap() { metadata_hash: BytesN::from_array(&env, &[5; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -350,14 +360,15 @@ fn test_finalize_raffle_sets_drawing_lock_and_blocks_reentry() { let token_client = StellarAssetClient::new(&env, &payment_token); token_client.mint(&creator, &1_000_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let config = RaffleConfig { description: String::from_str(&env, "Drawing lock test"), end_time: 0, no_deadline: true, max_tickets: 1, + max_tickets_per_tx: 1, min_tickets: 1, allow_multiple: true, ticket_price: MIN_TICKET_PRICE, @@ -373,6 +384,8 @@ fn test_finalize_raffle_sets_drawing_lock_and_blocks_reentry() { metadata_hash: BytesN::from_array(&env, &[7; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -428,14 +441,15 @@ fn test_finalize_rollback_on_randomness_request_failure() { let token_client = StellarAssetClient::new(&env, &payment_token); token_client.mint(&creator, &1_000_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let config = RaffleConfig { description: String::from_str(&env, "Rollback test"), end_time: 0, no_deadline: true, max_tickets: 1, + max_tickets_per_tx: 1, min_tickets: 1, allow_multiple: true, ticket_price: MIN_TICKET_PRICE, @@ -451,6 +465,8 @@ fn test_finalize_rollback_on_randomness_request_failure() { metadata_hash: BytesN::from_array(&env, &[8; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -502,14 +518,15 @@ fn test_allow_multiple_false_single_ticket_per_buyer() { token_client.mint(&buyer_a, &1_000_000); token_client.mint(&buyer_b, &1_000_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let config = RaffleConfig { description: String::from_str(&env, "Test allow_multiple=false"), end_time: 0, no_deadline: true, max_tickets: 10, + max_tickets_per_tx: 10, min_tickets: 1, allow_multiple: false, ticket_price: MIN_TICKET_PRICE, @@ -525,6 +542,8 @@ fn test_allow_multiple_false_single_ticket_per_buyer() { metadata_hash: BytesN::from_array(&env, &[6; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -605,8 +624,8 @@ fn test_refund_ticket_after_cancel() { token_client.mint(&creator, &1_000_000); token_client.mint(&buyer, &1_000_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let config = RaffleConfig { description: String::from_str(&env, "Refund test"), @@ -629,6 +648,8 @@ fn test_refund_ticket_after_cancel() { metadata_hash: BytesN::from_array(&env, &[5; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -671,8 +692,8 @@ fn test_refund_guard_released_after_success() { token_client.mint(&creator, &1_000_000); token_client.mint(&buyer, &1_000_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let config = RaffleConfig { description: String::from_str(&env, "Guard release"), @@ -695,6 +716,8 @@ fn test_refund_guard_released_after_success() { metadata_hash: BytesN::from_array(&env, &[6; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); @@ -738,8 +761,8 @@ fn test_claim_prize_pays_full_gross_with_protocol_fee() { token_client.mint(&creator, &1_000_000); token_client.mint(&buyer, &1_000_000); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + let contract_id = env.register(RaffleInstance, ()); + let client = RaffleInstanceClient::new(&env, &contract_id); let config = RaffleConfig { description: String::from_str(&env, "Claim gross"), @@ -762,6 +785,8 @@ fn test_claim_prize_pays_full_gross_with_protocol_fee() { metadata_hash: BytesN::from_array(&env, &[7; 32]), claim_lockup_seconds: 0, swap_deadline_seconds: 0, + prize_token: None, + nft_contract: None, }; client.init(&factory, &admin, &creator, &config); diff --git a/contracts/raffle-shared/src/constants.rs b/contracts/raffle-shared/src/constants.rs new file mode 100644 index 00000000..90c83d62 --- /dev/null +++ b/contracts/raffle-shared/src/constants.rs @@ -0,0 +1,69 @@ +// ============================================================================ +// Protocol-wide constants +// +// Single source of truth for every magic number used across the raffle +// contracts. Import from `raffle_shared::constants::*` (or individually) in +// any crate that needs them. +// ============================================================================ + +// --- Raffle instance limits ------------------------------------------------- + +/// Maximum number of ledgers the oracle may take to respond before a fallback +/// is permitted (~17 minutes at 5-second ledger close times). +pub const ORACLE_TIMEOUT_LEDGERS: u32 = 200; + +/// Maximum byte-length of a raffle description string. +pub const MAX_DESCRIPTION_LENGTH: u32 = 1_000; + +/// Hard cap on tickets per raffle. +pub const MAX_TICKETS_LIMIT: u32 = 100_000; + +/// Hard cap on the number of prize tiers per raffle. +pub const MAX_PRIZES: u32 = 100; + +/// Minimum ticket price in the payment token's base unit (stroops / smallest +/// denomination). Prevents dust-amount raffles that would be uneconomical. +pub const MIN_TICKET_PRICE: i128 = 10_000; + +/// Maximum allowed prize pool. Prevents i128 overflow in prize calculations. +pub const MAX_PRIZE_AMOUNT: i128 = 1_000_000_000_000_000_000_000; // 1e21 + +// --- Timing constants ------------------------------------------------------- + +/// Default delay (seconds) between raffle finalization and when winners may +/// claim their prize. Equals 1 hour. +pub const DEFAULT_CLAIM_LOCKUP_SECONDS: u64 = 3_600; + +/// Upper bound on the claim lockup delay (7 days). +pub const MAX_CLAIM_LOCKUP_SECONDS: u64 = 604_800; + +/// Default window (seconds) added to the current timestamp when submitting +/// token-swap transactions. Equals 5 minutes. +pub const DEFAULT_SWAP_DEADLINE_SECONDS: u64 = 300; + +/// Upper bound on the swap deadline window (1 hour). +pub const MAX_SWAP_DEADLINE_SECONDS: u64 = 3_600; + +/// Minimum time (seconds) that must elapse after raffle finalization before an +/// emergency withdrawal is permitted. Equals 90 days (7 776 000 s). +pub const EMERGENCY_WITHDRAW_DELAY_SECONDS: u64 = 90 * 24 * 3_600; // 7_776_000 + +// --- Factory constants ------------------------------------------------------ + +/// Timelock delay (seconds) before a proposed admin operation may be executed. +/// Equals 48 hours, giving users time to react to protocol changes. +pub const TIMELOCK_DELAY_SECONDS: u64 = 172_800; + +/// Factory creates a state checkpoint every `CHECKPOINT_INTERVAL` raffles. +pub const CHECKPOINT_INTERVAL: u32 = 1_000; + +/// Maximum protocol fee in basis points (20 %). +pub const MAX_PROTOCOL_FEE_BP: u32 = 2_000; + +// --- Pagination defaults ---------------------------------------------------- + +/// Default number of items returned by paginated queries. +pub const DEFAULT_PAGE_LIMIT: u32 = 100; + +/// Hard cap on items returned by a single paginated query. +pub const MAX_PAGE_LIMIT: u32 = 200; diff --git a/contracts/raffle-shared/src/lib.rs b/contracts/raffle-shared/src/lib.rs index e72a8075..49e855bb 100644 --- a/contracts/raffle-shared/src/lib.rs +++ b/contracts/raffle-shared/src/lib.rs @@ -1,5 +1,7 @@ #![no_std] +pub mod constants; + use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; #[derive(Clone, PartialEq, Eq, Debug)] @@ -79,6 +81,13 @@ pub struct RaffleConfig { /// Swap deadline window in seconds (added to current timestamp for token swaps). /// Defaults to 300 (5 minutes) if zero. Configurable to handle network congestion. pub swap_deadline_seconds: u64, + /// Optional separate token for the prize deposit and claim. + /// When `None` the prize token defaults to `payment_token` (current behaviour). + pub prize_token: Option
, + /// Optional NFT contract address. When set, the contract will call + /// `mint(recipient, ticket_id, raffle_id)` on this contract after each + /// successful ticket purchase, issuing an on-chain NFT receipt per ticket. + pub nft_contract: Option
, } impl RaffleConfig { @@ -143,10 +152,12 @@ pub enum AdminOp { UpdateWasmHash(BytesN<32>), } -pub const DEFAULT_PAGE_LIMIT: u32 = 100; -pub const MAX_PAGE_LIMIT: u32 = 200; -pub const DEFAULT_CLAIM_LOCKUP_SECONDS: u64 = 3_600; -pub const DEFAULT_SWAP_DEADLINE_SECONDS: u64 = 300; +// Pagination and timing constants are now in `constants.rs`. +// Re-exported at crate root for backward compatibility. +pub use constants::{ + DEFAULT_CLAIM_LOCKUP_SECONDS, DEFAULT_PAGE_LIMIT, DEFAULT_SWAP_DEADLINE_SECONDS, + MAX_PAGE_LIMIT, +}; pub fn effective_limit(requested: u32) -> u32 { if requested == 0 { @@ -175,3 +186,27 @@ pub trait RandomnessOracleTrait { pub trait RandomnessReceiverTrait { fn receive_randomness(env: soroban_sdk::Env, request_id: u64, random_seed: u64); } + +/// Cross-contract interface for an NFT ticket contract. +/// +/// The raffle-instance calls `mint` on this contract immediately after a +/// successful ticket purchase. The NFT contract is responsible for its own +/// authorisation model; the raffle-instance supplies the raffle's own address +/// as the `minter` so the NFT contract can restrict minting to known raffle +/// contracts. +/// +/// Parameters +/// ---------- +/// * `recipient` – the address that receives the NFT (the ticket buyer). +/// * `ticket_id` – the unique ticket ID within this raffle (1-indexed, u32). +/// * `raffle_id` – the raffle instance contract address, used as a namespace +/// so a single NFT contract can serve multiple raffles. +#[soroban_sdk::contractclient(name = "NftTicketClient")] +pub trait NftTicketTrait { + fn mint( + env: soroban_sdk::Env, + recipient: Address, + ticket_id: u32, + raffle_id: Address, + ); +} diff --git a/contracts/raffle/src/lib.rs b/contracts/raffle/src/lib.rs index 5e1579a7..7b6a0e7b 100644 --- a/contracts/raffle/src/lib.rs +++ b/contracts/raffle/src/lib.rs @@ -17,10 +17,7 @@ use raffle_shared::{ effective_limit, AdminOp, FairnessData, PageResultRaffles, PaginationParams, RaffleConfig, }; -pub const TIMELOCK_DELAY_SECONDS: u64 = 172800; // 48 hours -pub const CHECKPOINT_INTERVAL: u32 = 1_000; -/// Maximum protocol fee in basis points (20%). -pub const MAX_PROTOCOL_FEE_BP: u32 = 2_000; +use raffle_shared::constants::{CHECKPOINT_INTERVAL, MAX_PROTOCOL_FEE_BP, TIMELOCK_DELAY_SECONDS}; #[derive(Clone)] #[contracttype]