diff --git a/contracts/raffle-instance/src/events.rs b/contracts/raffle-instance/src/events.rs index fbc62a14..9b76572d 100644 --- a/contracts/raffle-instance/src/events.rs +++ b/contracts/raffle-instance/src/events.rs @@ -267,3 +267,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 dd0586c8..9b6ded50 100644 --- a/contracts/raffle-instance/src/lib.rs +++ b/contracts/raffle-instance/src/lib.rs @@ -31,8 +31,8 @@ use crate::events::{ OracleAddressUpdated, PrizeClaimed, PrizeDeposited, PrizeRefunded, ProtocolFeeUpdated, RaffleCancelled, RaffleCreated, RaffleFailed, RaffleFinalized, RaffleStatusChanged, RandomnessFallbackTriggered, RandomnessReceived, RandomnessRequested, SwapDeadlineUpdated, - TicketPurchased, TicketRefunded, TicketSalesPaused, TicketSalesResumed, TokensRescued, - WinnerDrawn, + TicketNftMinted, TicketPurchased, TicketRefunded, TicketSalesPaused, TicketSalesResumed, + TokensRescued, WinnerDrawn, }; const ORACLE_TIMEOUT_LEDGERS: u32 = 200; @@ -66,6 +66,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, @@ -208,9 +211,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() @@ -427,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, @@ -513,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(); @@ -545,6 +558,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, @@ -603,7 +617,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 @@ -878,8 +892,8 @@ impl Contract { } TicketPurchased { - buyer, - ticket_ids, + buyer: buyer.clone(), + ticket_ids: ticket_ids.clone(), quantity, ticket_price: raffle.ticket_price, effective_ticket_price: effective_price, @@ -889,6 +903,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) } @@ -1202,7 +1236,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)?; @@ -1210,7 +1244,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, @@ -1361,7 +1395,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); @@ -1423,7 +1457,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, @@ -1434,7 +1468,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); @@ -1785,10 +1819,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); } } @@ -1939,8 +1976,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, ()); @@ -2219,8 +2256,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); @@ -2255,6 +2292,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); @@ -2309,8 +2348,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); @@ -2342,6 +2381,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); @@ -2373,8 +2414,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); @@ -2407,6 +2448,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); @@ -2466,6 +2509,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 ca823334..916066e0 100644 --- a/contracts/raffle-instance/src/test.rs +++ b/contracts/raffle-instance/src/test.rs @@ -37,8 +37,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 { @@ -122,8 +122,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"), @@ -171,8 +171,8 @@ fn non_winner_cannot_claim() { 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 config = RaffleConfig { description: String::from_str(&env, "Fee update"), @@ -547,8 +547,8 @@ fn test_refund_ticket_after_cancel() { 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, "Test"), @@ -650,8 +650,8 @@ fn emergency_withdraw_fails_for_no_deadline_raffle_before_timeout() { 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 end_time = 5_000u64; let config = RaffleConfig { @@ -860,6 +860,8 @@ fn emergency_withdraw_fails_if_prize_not_deposited() { 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); 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 2d569b41..1bf60b13 100644 --- a/contracts/raffle-shared/src/lib.rs +++ b/contracts/raffle-shared/src/lib.rs @@ -1,6 +1,8 @@ #![no_std] #![cfg_attr(not(test), deny(clippy::unwrap_used))] +pub mod constants; + use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; /// Lifecycle state of a raffle instance. @@ -264,3 +266,27 @@ pub trait RandomnessReceiverTrait { /// Delivers a randomness response to the callback contract. 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 40aa4d31..a8102675 100644 --- a/contracts/raffle/src/lib.rs +++ b/contracts/raffle/src/lib.rs @@ -19,10 +19,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]