diff --git a/contracts/bounty_escrow/contracts/escrow/src/events.rs b/contracts/bounty_escrow/contracts/escrow/src/events.rs
index 977681fc..e16046c8 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 5699f8d9..a068cdf8 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 00000000..64e17af5
--- /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 e265d342..c74e6ee2 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 00000000..dee8b11b
--- /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(),
+ })
+}