From 8d133557d137e73f9e77b7f6c64beb32a96215c7 Mon Sep 17 00:00:00 2001 From: Anioke Sebastian Date: Thu, 26 Feb 2026 02:19:03 +0100 Subject: [PATCH 1/2] feat: add deterministic pseudo-randomness abstraction for selection flows --- PR_558_deterministic_pseudorandomness.md | 54 ++++ .../contracts/escrow/src/events.rs | 18 ++ .../bounty_escrow/contracts/escrow/src/lib.rs | 264 +++++++++++++++++- .../src/test_deterministic_randomness.rs | 128 +++++++++ contracts/grainlify-core/src/lib.rs | 1 + .../grainlify-core/src/pseudo_randomness.rs | 113 ++++++++ 6 files changed, 576 insertions(+), 2 deletions(-) create mode 100644 PR_558_deterministic_pseudorandomness.md create mode 100644 contracts/bounty_escrow/contracts/escrow/src/test_deterministic_randomness.rs create mode 100644 contracts/grainlify-core/src/pseudo_randomness.rs diff --git a/PR_558_deterministic_pseudorandomness.md b/PR_558_deterministic_pseudorandomness.md new file mode 100644 index 000000000..19abca42b --- /dev/null +++ b/PR_558_deterministic_pseudorandomness.md @@ -0,0 +1,54 @@ +Closes #558 + +## Changes + +- **`contracts/grainlify-core/src/pseudo_randomness.rs`** + - Added a deterministic pseudo-randomness helper module for verifiable on-chain selection. + - Implemented seed derivation from: + - domain tag + - context bytes + - external seed (`BytesN<32>`) + - Implemented candidate selection by per-candidate hash scoring (instead of simple modulo), reducing index/order bias. + - Documented security trade-offs and adversarial examples (seed grinding, timing bias, candidate stuffing). + +- **`contracts/grainlify-core/src/lib.rs`** + - Exported shared helper via `pub mod pseudo_randomness`. + +- **`contracts/bounty_escrow/contracts/escrow/src/lib.rs`** + - Integrated deterministic selection into claim-ticket flow with: + - `derive_claim_ticket_winner_index(...)` + - `derive_claim_ticket_winner(...)` + - `issue_claim_ticket_deterministic(...)` + - Added deterministic selection context construction using contract/bounty/amount/expiry/timestamp/ticket-counter material. + - Added new error variant for invalid selection input. + +- **`contracts/bounty_escrow/contracts/escrow/src/events.rs`** + - Added `DeterministicSelectionDerived` event for verifiable auditability of: + - selected index + - candidate count + - selected beneficiary + - seed hash + - winner score + +- **`contracts/bounty_escrow/contracts/escrow/src/test_deterministic_randomness.rs`** + - Added deterministic randomness tests: + - same inputs => same winner + - order-independent winner selection for the same candidate set + - deterministic ticket issuance matches derived winner + +## Testing + +- `contracts/grainlify-core` + - `cargo test --lib` ✅ + +- `contracts/bounty_escrow/contracts/escrow` + - `cargo fmt --check --all` ✅ + - `cargo test test_deterministic_randomness -- --nocapture` ✅ + - `cargo test --lib` ✅ + - `cargo test --lib invariant_checker_ci` ✅ + +## Notes + +- This is deterministic pseudo-randomness, not true randomness. +- Verifiability is prioritized: any observer can recompute the selected winner from the published inputs. +- Manipulation resistance depends on seed/context discipline; consumers should prefer commit-reveal style external seeds and bounded submission windows where applicable. diff --git a/contracts/bounty_escrow/contracts/escrow/src/events.rs b/contracts/bounty_escrow/contracts/escrow/src/events.rs index 977681fc7..e16046c86 100644 --- a/contracts/bounty_escrow/contracts/escrow/src/events.rs +++ b/contracts/bounty_escrow/contracts/escrow/src/events.rs @@ -167,6 +167,24 @@ pub struct ClaimCancelled { pub cancelled_by: Address, } +/// Event emitted when deterministic pseudo-random winner selection is derived. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeterministicSelectionDerived { + pub bounty_id: u64, + pub selected_index: u32, + pub candidate_count: u32, + pub selected_beneficiary: Address, + pub seed_hash: BytesN<32>, + pub winner_score: BytesN<32>, + pub timestamp: u64, +} + +pub fn emit_deterministic_selection(env: &Env, event: DeterministicSelectionDerived) { + let topics = (symbol_short!("prng_sel"), event.bounty_id); + env.events().publish(topics, event); +} + pub fn emit_pause_state_changed(env: &Env, event: crate::PauseStateChanged) { let topics = (symbol_short!("pause"), event.operation.clone()); env.events().publish(topics, event); diff --git a/contracts/bounty_escrow/contracts/escrow/src/lib.rs b/contracts/bounty_escrow/contracts/escrow/src/lib.rs index 5699f8d9c..a068cdf88 100644 --- a/contracts/bounty_escrow/contracts/escrow/src/lib.rs +++ b/contracts/bounty_escrow/contracts/escrow/src/lib.rs @@ -7,6 +7,8 @@ mod test_metadata; mod test_cross_contract_interface; #[cfg(test)] +mod test_deterministic_randomness; +#[cfg(test)] mod test_multi_token_fees; #[cfg(test)] mod test_rbac; @@ -19,19 +21,22 @@ mod test_maintenance_mode; use events::{ emit_batch_funds_locked, emit_batch_funds_released, emit_bounty_initialized, + emit_deterministic_selection, emit_deprecation_state_changed, emit_funds_locked, emit_funds_locked_anon, emit_funds_refunded, emit_funds_released, emit_maintenance_mode_changed, emit_participant_filter_mode_changed, emit_risk_flags_updated, emit_ticket_claimed, emit_ticket_issued, BatchFundsLocked, BatchFundsReleased, BountyEscrowInitialized, ClaimCancelled, ClaimCreated, ClaimExecuted, DeprecationStateChanged, + DeterministicSelectionDerived, FundsLocked, FundsLockedAnon, FundsRefunded, FundsReleased, MaintenanceModeChanged, ParticipantFilterModeChanged, RiskFlagsUpdated, TicketClaimed, TicketIssued, EVENT_VERSION_V2, }; +use soroban_sdk::xdr::ToXdr; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, vec, Address, Env, - Symbol, Vec, + Bytes, BytesN, String, Symbol, Vec, }; // ============================================================================ @@ -429,6 +434,10 @@ const BASIS_POINTS: i128 = 10_000; const MAX_FEE_RATE: i128 = 5_000; // 50% max fee const MAX_BATCH_SIZE: u32 = 20; +extern crate grainlify_core; +use grainlify_core::asset; +use grainlify_core::pseudo_randomness; + #[contracttype] #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] @@ -518,7 +527,7 @@ pub enum Error { NotAnonymousEscrow = 36, /// Use get_escrow_info_v2 for anonymous escrows UseGetEscrowInfoV2ForAnonymous = 37, - + InvalidSelectionInput = 38, } pub const RISK_FLAG_HIGH_RISK: u32 = 1 << 0; @@ -3836,6 +3845,257 @@ impl BountyEscrowContract { .ok_or(Error::BountyNotFound) } + fn build_claim_selection_context( + env: &Env, + bounty_id: u64, + amount: i128, + expires_at: u64, + ) -> Bytes { + let mut context = Bytes::new(env); + context.append(&env.current_contract_address().to_xdr(env)); + context.append(&Bytes::from_array(env, &bounty_id.to_be_bytes())); + context.append(&Bytes::from_array(env, &amount.to_be_bytes())); + context.append(&Bytes::from_array(env, &expires_at.to_be_bytes())); + context.append(&Bytes::from_array( + env, + &env.ledger().timestamp().to_be_bytes(), + )); + let ticket_counter: u64 = env + .storage() + .persistent() + .get(&DataKey::TicketCounter) + .unwrap_or(0); + context.append(&Bytes::from_array(env, &ticket_counter.to_be_bytes())); + context + } + + /// Deterministically derive the winner index for claim ticket issuance. + /// + /// This is a pure/view helper that lets clients verify expected results + /// before issuing a ticket. + pub fn derive_claim_ticket_winner_index( + env: Env, + bounty_id: u64, + candidates: Vec
, + amount: i128, + expires_at: u64, + external_seed: BytesN<32>, + ) -> Result { + if candidates.is_empty() { + return Err(Error::InvalidSelectionInput); + } + let context = Self::build_claim_selection_context(&env, bounty_id, amount, expires_at); + let domain = Symbol::new(&env, "claim_prng_v1"); + let selection = pseudo_randomness::derive_selection( + &env, + &domain, + &context, + &external_seed, + &candidates, + ) + .ok_or(Error::InvalidSelectionInput)?; + Ok(selection.index) + } + + /// Deterministically derive the winner address for claim ticket issuance. + pub fn derive_claim_ticket_winner( + env: Env, + bounty_id: u64, + candidates: Vec
, + amount: i128, + expires_at: u64, + external_seed: BytesN<32>, + ) -> Result { + let index = Self::derive_claim_ticket_winner_index( + env.clone(), + bounty_id, + candidates.clone(), + amount, + expires_at, + external_seed, + )?; + candidates.get(index).ok_or(Error::InvalidSelectionInput) + } + + /// Deterministically select a winner from `candidates` and issue claim ticket. + /// + /// Security notes: + /// - Deterministic and verifiable from published inputs. + /// - Not unbiased randomness; callers can still influence context/seed choices. + pub fn issue_claim_ticket_deterministic( + env: Env, + bounty_id: u64, + candidates: Vec
, + amount: i128, + expires_at: u64, + external_seed: BytesN<32>, + ) -> Result { + if candidates.is_empty() { + return Err(Error::InvalidSelectionInput); + } + + let context = Self::build_claim_selection_context(&env, bounty_id, amount, expires_at); + let domain = Symbol::new(&env, "claim_prng_v1"); + let selection = pseudo_randomness::derive_selection( + &env, + &domain, + &context, + &external_seed, + &candidates, + ) + .ok_or(Error::InvalidSelectionInput)?; + + let selected = candidates + .get(selection.index) + .ok_or(Error::InvalidSelectionInput)?; + + emit_deterministic_selection( + &env, + DeterministicSelectionDerived { + bounty_id, + selected_index: selection.index, + candidate_count: candidates.len(), + selected_beneficiary: selected.clone(), + seed_hash: selection.seed_hash, + winner_score: selection.winner_score, + timestamp: env.ledger().timestamp(), + }, + ); + + Self::issue_claim_ticket(env, bounty_id, selected, amount, expires_at) + } + + /// Issue a single-use claim ticket to a bounty winner (admin only) + /// + /// This creates a ticket that the beneficiary can use to claim their reward exactly once. + /// Tickets are bound to a specific address, amount, and expiry time. + /// + /// # Arguments + /// * `env` - Contract environment + /// * `bounty_id` - ID of the bounty being claimed + /// * `beneficiary` - Address of the winner who will claim the reward + /// * `amount` - Amount to be claimed (in token units) + /// * `expires_at` - Unix timestamp when the ticket expires + /// + /// # Returns + /// * `Ok(ticket_id)` - The unique ticket ID for this claim + /// * `Err(Error::NotInitialized)` - Contract not initialized + /// * `Err(Error::Unauthorized)` - Caller is not admin + /// * `Err(Error::BountyNotFound)` - Bounty doesn't exist + /// * `Err(Error::InvalidDeadline)` - Expiry time is in the past + /// * `Err(Error::InvalidAmount)` - Amount is invalid or exceeds escrow amount + pub fn issue_claim_ticket( + env: Env, + bounty_id: u64, + beneficiary: Address, + amount: i128, + expires_at: u64, + ) -> Result { + if !env.storage().instance().has(&DataKey::Admin) { + return Err(Error::NotInitialized); + } + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + let escrow_amount: i128; + let escrow_status: EscrowStatus; + if env.storage().persistent().has(&DataKey::Escrow(bounty_id)) { + let escrow: Escrow = env + .storage() + .persistent() + .get(&DataKey::Escrow(bounty_id)) + .unwrap(); + escrow_amount = escrow.amount; + escrow_status = escrow.status; + } else if env + .storage() + .persistent() + .has(&DataKey::EscrowAnon(bounty_id)) + { + let anon: AnonymousEscrow = env + .storage() + .persistent() + .get(&DataKey::EscrowAnon(bounty_id)) + .unwrap(); + escrow_amount = anon.amount; + escrow_status = anon.status; + } else { + return Err(Error::BountyNotFound); + } + + if escrow_status != EscrowStatus::Locked { + return Err(Error::FundsNotLocked); + } + if amount <= 0 || amount > escrow_amount { + return Err(Error::InvalidAmount); + } + + let now = env.ledger().timestamp(); + if expires_at <= now { + return Err(Error::InvalidDeadline); + } + + let ticket_counter_key = DataKey::TicketCounter; + let mut ticket_id: u64 = env + .storage() + .persistent() + .get(&ticket_counter_key) + .unwrap_or(0); + ticket_id += 1; + env.storage() + .persistent() + .set(&ticket_counter_key, &ticket_id); + + let ticket = ClaimTicket { + ticket_id, + bounty_id, + beneficiary: beneficiary.clone(), + amount, + expires_at, + used: false, + issued_at: now, + }; + + env.storage() + .persistent() + .set(&DataKey::ClaimTicket(ticket_id), &ticket); + + let mut ticket_index: Vec = env + .storage() + .persistent() + .get(&DataKey::ClaimTicketIndex) + .unwrap_or(Vec::new(&env)); + ticket_index.push_back(ticket_id); + env.storage() + .persistent() + .set(&DataKey::ClaimTicketIndex, &ticket_index); + + let mut beneficiary_tickets: Vec = env + .storage() + .persistent() + .get(&DataKey::BeneficiaryTickets(beneficiary.clone())) + .unwrap_or(Vec::new(&env)); + beneficiary_tickets.push_back(ticket_id); + env.storage().persistent().set( + &DataKey::BeneficiaryTickets(beneficiary.clone()), + &beneficiary_tickets, + ); + + emit_ticket_issued( + &env, + TicketIssued { + ticket_id, + bounty_id, + beneficiary, + amount, + expires_at, + issued_at: now, + }, + ); + + Ok(ticket_id) + } + pub fn set_escrow_risk_flags( env: Env, bounty_id: u64, diff --git a/contracts/bounty_escrow/contracts/escrow/src/test_deterministic_randomness.rs b/contracts/bounty_escrow/contracts/escrow/src/test_deterministic_randomness.rs new file mode 100644 index 000000000..64e17af5e --- /dev/null +++ b/contracts/bounty_escrow/contracts/escrow/src/test_deterministic_randomness.rs @@ -0,0 +1,128 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, token, Address, BytesN, Env, Vec as SdkVec}; + +struct Setup<'a> { + env: Env, + client: BountyEscrowContractClient<'a>, + admin: Address, + depositor: Address, + token_id: Address, +} + +impl<'a> Setup<'a> { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, BountyEscrowContract); + let client = BountyEscrowContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let depositor = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + client.init(&admin, &token_id); + + Self { + env, + client, + admin, + depositor, + token_id, + } + } +} + +#[test] +fn test_deterministic_winner_is_stable_for_same_inputs() { + let s = Setup::new(); + let _ = &s.admin; + let mut candidates = SdkVec::new(&s.env); + candidates.push_back(Address::generate(&s.env)); + candidates.push_back(Address::generate(&s.env)); + candidates.push_back(Address::generate(&s.env)); + let seed = BytesN::from_array(&s.env, &[7u8; 32]); + let expires_at = s.env.ledger().timestamp() + 500; + + let w1 = s + .client + .derive_claim_ticket_winner(&42, &candidates, &1000, &expires_at, &seed); + let w2 = s + .client + .derive_claim_ticket_winner(&42, &candidates, &1000, &expires_at, &seed); + + assert_eq!(w1, w2); +} + +#[test] +fn test_deterministic_winner_is_order_independent() { + let s = Setup::new(); + let a = Address::generate(&s.env); + let b = Address::generate(&s.env); + let c = Address::generate(&s.env); + let seed = BytesN::from_array(&s.env, &[9u8; 32]); + let expires_at = s.env.ledger().timestamp() + 600; + + let mut candidates_1 = SdkVec::new(&s.env); + candidates_1.push_back(a.clone()); + candidates_1.push_back(b.clone()); + candidates_1.push_back(c.clone()); + let mut candidates_2 = SdkVec::new(&s.env); + candidates_2.push_back(c); + candidates_2.push_back(a); + candidates_2.push_back(b); + + let w1 = s + .client + .derive_claim_ticket_winner(&77, &candidates_1, &2500, &expires_at, &seed); + let w2 = s + .client + .derive_claim_ticket_winner(&77, &candidates_2, &2500, &expires_at, &seed); + + assert_eq!(w1, w2); +} + +#[test] +fn test_issue_claim_ticket_deterministic_issues_for_derived_winner() { + let s = Setup::new(); + let token_admin_client = token::StellarAssetClient::new(&s.env, &s.token_id); + + let bounty_id = 1u64; + let lock_amount = 50_000i128; + let deadline = s.env.ledger().timestamp() + 1_000; + token_admin_client.mint(&s.depositor, &lock_amount); + s.client + .lock_funds(&s.depositor, &bounty_id, &lock_amount, &deadline); + + let mut candidates = SdkVec::new(&s.env); + candidates.push_back(Address::generate(&s.env)); + candidates.push_back(Address::generate(&s.env)); + candidates.push_back(Address::generate(&s.env)); + let seed = BytesN::from_array(&s.env, &[3u8; 32]); + let expires_at = s.env.ledger().timestamp() + 500; + let claim_amount = 10_000i128; + + let derived_winner = s.client.derive_claim_ticket_winner( + &bounty_id, + &candidates, + &claim_amount, + &expires_at, + &seed, + ); + let ticket_id = s.client.issue_claim_ticket_deterministic( + &bounty_id, + &candidates, + &claim_amount, + &expires_at, + &seed, + ); + let ticket = s.client.get_claim_ticket(&ticket_id); + + assert_eq!(ticket.beneficiary, derived_winner); + assert_eq!(ticket.amount, claim_amount); + assert_eq!(ticket.bounty_id, bounty_id); +} diff --git a/contracts/grainlify-core/src/lib.rs b/contracts/grainlify-core/src/lib.rs index e265d3426..c74e6ee20 100644 --- a/contracts/grainlify-core/src/lib.rs +++ b/contracts/grainlify-core/src/lib.rs @@ -160,6 +160,7 @@ use soroban_sdk::{ pub mod asset; mod governance; pub mod nonce; +pub mod pseudo_randomness; pub use governance::{ Error as GovError, GovernanceConfig, Proposal, ProposalStatus, Vote, VoteType, VotingScheme, diff --git a/contracts/grainlify-core/src/pseudo_randomness.rs b/contracts/grainlify-core/src/pseudo_randomness.rs new file mode 100644 index 000000000..dee8b11b0 --- /dev/null +++ b/contracts/grainlify-core/src/pseudo_randomness.rs @@ -0,0 +1,113 @@ +//! Deterministic pseudo-randomness helpers for on-chain selection flows. +//! +//! # Design +//! - Fully deterministic and replayable from public inputs. +//! - Seeded from domain/context bytes + caller-provided external seed. +//! - Candidate selection uses per-candidate score hashing to avoid index/order bias. +//! +//! # Security Trade-offs +//! - This is **not** true randomness; validators/submitters can still influence +//! timing-dependent context. +//! - A caller controlling both context and external seed can brute-force outcomes. +//! - To reduce bias, consumers should include hard-to-predict values (e.g. commit +//! reveals, prior state roots, delayed reveal windows) and publish seed sources. +//! +//! # Adversarial Examples +//! - **Seed grinding**: attacker tries many external seeds off-chain until a +//! preferred winner appears. +//! - **Timing bias**: attacker submits only when ledger metadata favors outcome. +//! - **Candidate stuffing**: attacker adds sybil addresses to increase odds. +//! +//! This helper mitigates order-manipulation by scoring each candidate directly +//! instead of selecting by `hash % n`. + +use core::cmp::Ordering; +use soroban_sdk::xdr::ToXdr; +use soroban_sdk::{Address, Bytes, BytesN, Env, Symbol, Vec}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeterministicSelection { + pub index: u32, + pub seed_hash: BytesN<32>, + pub winner_score: BytesN<32>, +} + +fn cmp_hash(env: &Env, a: &BytesN<32>, b: &BytesN<32>) -> Ordering { + let ax = a.clone().to_xdr(env); + let bx = b.clone().to_xdr(env); + let mut i: u32 = 0; + while i < ax.len() && i < bx.len() { + let av = ax.get(i).unwrap(); + let bv = bx.get(i).unwrap(); + if av < bv { + return Ordering::Less; + } + if av > bv { + return Ordering::Greater; + } + i += 1; + } + ax.len().cmp(&bx.len()) +} + +fn build_seed_hash( + env: &Env, + domain: &Symbol, + context: &Bytes, + external_seed: &BytesN<32>, +) -> BytesN<32> { + let mut seed_material = Bytes::new(env); + seed_material.append(&domain.to_xdr(env)); + seed_material.append(context); + seed_material.append(&external_seed.clone().to_xdr(env)); + env.crypto().sha256(&seed_material).into() +} + +/// Derive a deterministic winner index from candidates + seed material. +/// +/// Returns `None` when `candidates` is empty. +pub fn derive_selection( + env: &Env, + domain: &Symbol, + context: &Bytes, + external_seed: &BytesN<32>, + candidates: &Vec
, +) -> Option { + if candidates.is_empty() { + return None; + } + + let seed_hash = build_seed_hash(env, domain, context, external_seed); + + let mut best_idx: u32 = 0; + let mut best_score: Option> = None; + let mut i: u32 = 0; + + while i < candidates.len() { + let candidate = candidates.get(i).unwrap(); + let mut score_material = Bytes::new(env); + score_material.append(&seed_hash.clone().to_xdr(env)); + score_material.append(&candidate.to_xdr(env)); + let score: BytesN<32> = env.crypto().sha256(&score_material).into(); + + match &best_score { + None => { + best_score = Some(score); + best_idx = i; + } + Some(current_best) => { + if cmp_hash(env, &score, current_best) == Ordering::Greater { + best_score = Some(score); + best_idx = i; + } + } + } + i += 1; + } + + Some(DeterministicSelection { + index: best_idx, + seed_hash, + winner_score: best_score.unwrap(), + }) +} From d54f07f7ef10d1ad4242a76033d2d7e3cdf7f9be Mon Sep 17 00:00:00 2001 From: Anioke Sebastian Date: Thu, 26 Feb 2026 02:23:05 +0100 Subject: [PATCH 2/2] chore: remove PR description markdown from repo --- PR_558_deterministic_pseudorandomness.md | 54 ------------------------ 1 file changed, 54 deletions(-) delete mode 100644 PR_558_deterministic_pseudorandomness.md diff --git a/PR_558_deterministic_pseudorandomness.md b/PR_558_deterministic_pseudorandomness.md deleted file mode 100644 index 19abca42b..000000000 --- a/PR_558_deterministic_pseudorandomness.md +++ /dev/null @@ -1,54 +0,0 @@ -Closes #558 - -## Changes - -- **`contracts/grainlify-core/src/pseudo_randomness.rs`** - - Added a deterministic pseudo-randomness helper module for verifiable on-chain selection. - - Implemented seed derivation from: - - domain tag - - context bytes - - external seed (`BytesN<32>`) - - Implemented candidate selection by per-candidate hash scoring (instead of simple modulo), reducing index/order bias. - - Documented security trade-offs and adversarial examples (seed grinding, timing bias, candidate stuffing). - -- **`contracts/grainlify-core/src/lib.rs`** - - Exported shared helper via `pub mod pseudo_randomness`. - -- **`contracts/bounty_escrow/contracts/escrow/src/lib.rs`** - - Integrated deterministic selection into claim-ticket flow with: - - `derive_claim_ticket_winner_index(...)` - - `derive_claim_ticket_winner(...)` - - `issue_claim_ticket_deterministic(...)` - - Added deterministic selection context construction using contract/bounty/amount/expiry/timestamp/ticket-counter material. - - Added new error variant for invalid selection input. - -- **`contracts/bounty_escrow/contracts/escrow/src/events.rs`** - - Added `DeterministicSelectionDerived` event for verifiable auditability of: - - selected index - - candidate count - - selected beneficiary - - seed hash - - winner score - -- **`contracts/bounty_escrow/contracts/escrow/src/test_deterministic_randomness.rs`** - - Added deterministic randomness tests: - - same inputs => same winner - - order-independent winner selection for the same candidate set - - deterministic ticket issuance matches derived winner - -## Testing - -- `contracts/grainlify-core` - - `cargo test --lib` ✅ - -- `contracts/bounty_escrow/contracts/escrow` - - `cargo fmt --check --all` ✅ - - `cargo test test_deterministic_randomness -- --nocapture` ✅ - - `cargo test --lib` ✅ - - `cargo test --lib invariant_checker_ci` ✅ - -## Notes - -- This is deterministic pseudo-randomness, not true randomness. -- Verifiability is prioritized: any observer can recompute the selected winner from the published inputs. -- Manipulation resistance depends on seed/context discipline; consumers should prefer commit-reveal style external seeds and bounded submission windows where applicable.