Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion contracts/creator-event-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,10 +451,47 @@ impl CreatorEventManagerContract {
prediction::get_prediction_distribution(&env, match_id)
}

/// Retrieve every prediction submitted for a specific match (#808).
///
/// Returns a `Vec<Prediction>` in submission order. Returns an empty `Vec`
/// when the match has no predictions (or the match id does not exist).
/// Useful for analytics and displaying a match's full prediction
/// distribution.
pub fn get_match_predictions(env: Env, match_id: u64) -> Vec<Prediction> {
prediction::get_match_predictions(&env, match_id)
}

// =========================================================================
// Oracle / Winner Verification (#798–#801)
// Oracle / Winner Verification (#798–#801, #810)
// =========================================================================

/// Submit a match result as the authorized AI oracle agent (#810).
///
/// Resolves the match, records the winning outcome, and grades every
/// prediction for the match (sets each `is_correct`). `winning_team` must be
/// one of the `TEAM_A`, `TEAM_B`, or `DRAW` symbols, and the match must have
/// started (current time >= match_time).
///
/// # Panics
/// * `"contract_paused"` — the contract is paused.
/// * `"unauthorized"` — caller is not the configured AI agent.
/// * `"match_not_found"` — no match exists with the given ID.
/// * `"result_already_submitted"` — a result was already submitted.
/// * `"match_not_started"` — current time is before the match start time.
/// * `"invalid_outcome"` — `winning_team` is not a valid outcome symbol.
pub fn submit_match_result(env: Env, caller: Address, match_id: u64, winning_team: Symbol) {
match oracle::submit_match_result(&env, caller, match_id, winning_team) {
Ok(()) => {}
Err(oracle::OracleError::Paused) => panic!("contract_paused"),
Err(oracle::OracleError::Unauthorized) => panic!("unauthorized"),
Err(oracle::OracleError::MatchNotFound) => panic!("match_not_found"),
Err(oracle::OracleError::ResultAlreadySubmitted) => panic!("result_already_submitted"),
Err(oracle::OracleError::MatchNotStarted) => panic!("match_not_started"),
Err(oracle::OracleError::InvalidOutcome) => panic!("invalid_outcome"),
Err(_) => panic!("unexpected_error"),
}
}

/// Verify and record all perfect scorers for an event.
///
/// After all matches in an event are resolved, calculate which users
Expand Down
128 changes: 127 additions & 1 deletion contracts/creator-event-manager/src/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use soroban_sdk::{Address, Env, Symbol, Vec};

use crate::admin;
use crate::storage::{self, StorageError};
use crate::storage_types::{DataKey, Event, Match, Winner};
use crate::storage_types::{DataKey, Event, Match, MatchResult, Winner};

// ---------------------------------------------------------------------------
// Error type
Expand All @@ -23,6 +23,16 @@ pub enum OracleError {
CreationFeeNotSet = 5,
/// Arithmetic overflow occurred during calculation.
Overflow = 6,
/// Caller is not the authorized AI agent. (#810)
Unauthorized = 7,
/// No match found for the given match_id. (#810)
MatchNotFound = 8,
/// A result has already been submitted for this match. (#810)
ResultAlreadySubmitted = 9,
/// The match has not started yet (current time < match_time). (#810)
MatchNotStarted = 10,
/// The provided outcome is not one of TEAM_A, TEAM_B, or DRAW. (#810)
InvalidOutcome = 11,
}

impl From<StorageError> for OracleError {
Expand All @@ -45,6 +55,122 @@ fn emit_winners_verified(env: &Env, event_id: u64, winner_count: u32) {
);
}

fn emit_match_result_submitted(
env: &Env,
match_id: u64,
winning_team: &Symbol,
submitted_by: &Address,
) {
env.events().publish(
(
Symbol::new(env, "match"),
Symbol::new(env, "result_submitted"),
),
(match_id, winning_team.clone(), submitted_by.clone()),
);
}

// ---------------------------------------------------------------------------
// submit_match_result (#810)
// ---------------------------------------------------------------------------

/// Map an outcome `Symbol` ("TEAM_A" / "TEAM_B" / "DRAW") to a [`MatchResult`].
///
/// Returns `None` for any symbol that is not one of the three valid outcomes.
fn symbol_to_result(env: &Env, outcome: &Symbol) -> Option<MatchResult> {
if *outcome == Symbol::new(env, crate::storage_types::OUTCOME_TEAM_A) {
Some(MatchResult::TeamA)
} else if *outcome == Symbol::new(env, crate::storage_types::OUTCOME_TEAM_B) {
Some(MatchResult::TeamB)
} else if *outcome == Symbol::new(env, crate::storage_types::OUTCOME_DRAW) {
Some(MatchResult::Draw)
} else {
None
}
}

/// Submit a match result as the authorized AI oracle agent (#810).
///
/// This is the core oracle function that resolves a match and grades every
/// prediction made for it.
///
/// # Flow
/// 1. Require caller authorization.
/// 2. Reject if the contract is paused.
/// 3. Reject if the caller is not the stored AI agent address.
/// 4. Retrieve the match and verify it exists.
/// 5. Verify a result has not already been submitted.
/// 6. Verify the match has started (`now >= match_time`).
/// 7. Validate `winning_team` is one of TEAM_A / TEAM_B / DRAW.
/// 8. Update the match (result_submitted, winning_team, submitted_by/at).
/// 9. Grade every prediction for the match (`is_correct`).
/// 10. Emit a `MatchResultSubmitted` event.
///
/// # Errors
/// * [`OracleError::Paused`] — the contract is paused.
/// * [`OracleError::Unauthorized`] — caller is not the AI agent.
/// * [`OracleError::MatchNotFound`] — no match with the given id.
/// * [`OracleError::ResultAlreadySubmitted`] — result already recorded.
/// * [`OracleError::MatchNotStarted`] — match has not started yet.
/// * [`OracleError::InvalidOutcome`] — `winning_team` is not a valid outcome.
pub fn submit_match_result(
env: &Env,
caller: Address,
match_id: u64,
winning_team: Symbol,
) -> Result<(), OracleError> {
caller.require_auth();

// 1. Contract must not be paused.
if admin::is_paused(env) {
return Err(OracleError::Paused);
}

// 2. Caller must be the authorized AI agent.
let ai_agent = admin::get_ai_agent(env).ok_or(OracleError::Unauthorized)?;
if caller != ai_agent {
return Err(OracleError::Unauthorized);
}

// 3. Match must exist.
let mut match_record: Match =
storage::get_match(env, match_id).map_err(|_| OracleError::MatchNotFound)?;

// 4. Result must not already be submitted.
if match_record.result_submitted {
return Err(OracleError::ResultAlreadySubmitted);
}

// 5. Match must have started.
let now = env.ledger().timestamp();
if now < match_record.match_time {
return Err(OracleError::MatchNotStarted);
}

// 6. Outcome must be valid.
let result = symbol_to_result(env, &winning_team).ok_or(OracleError::InvalidOutcome)?;

// 7. Record the result on the match.
match_record
.submit_result(result, caller.clone(), now)
.map_err(|_| OracleError::ResultAlreadySubmitted)?;
storage::set_match(env, match_id, &match_record);

// 8. Grade every prediction submitted for this match.
let prediction_ids = storage::get_match_predictions(env, match_id);
for prediction_id in prediction_ids.iter() {
if let Ok(mut prediction) = storage::get_prediction(env, prediction_id) {
prediction.grade(&winning_team);
storage::set_prediction(env, prediction_id, &prediction);
}
}

// 9. Emit the result event.
emit_match_result_submitted(env, match_id, &winning_team, &caller);

Ok(())
}

// ---------------------------------------------------------------------------
// verify_event_winners (#798)
// ---------------------------------------------------------------------------
Expand Down
22 changes: 22 additions & 0 deletions contracts/creator-event-manager/src/prediction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,28 @@ pub fn get_user_predictions(env: &Env, user: Address, event_id: u64) -> Vec<Pred
predictions
}

/// Retrieve every prediction submitted for a specific match.
///
/// Reads the `MatchPredictions(match_id)` index of prediction IDs, loads each
/// `Prediction` struct, and returns them as a `Vec<Prediction>` in submission
/// order (the order in which predictions were placed).
///
/// Returns an empty `Vec` when the match has no predictions — including for a
/// `match_id` that does not exist, since no prediction index is stored for it.
/// Useful for analytics and displaying a match's full prediction distribution.
pub fn get_match_predictions(env: &Env, match_id: u64) -> Vec<Prediction> {
let prediction_ids = storage::get_match_predictions(env, match_id);

let mut predictions: Vec<Prediction> = Vec::new(env);
for prediction_id in prediction_ids.iter() {
if let Ok(prediction) = storage::get_prediction(env, prediction_id) {
predictions.push_back(prediction);
}
}

predictions
}

/// Calculate how many users predicted each outcome for a match.
///
/// Returns a tuple `(team_a_count, team_b_count, draw_count)` where each
Expand Down
152 changes: 152 additions & 0 deletions contracts/creator-event-manager/tests/get_match_predictions_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//! #808 — Integration tests for the public `get_match_predictions` view
//! (exercised through the contract client), covering:
//! - Returns all predictions for a match
//! - Empty list for a match with no predictions
//! - Correct count
//! - Unknown match id yields an empty list

use creator_event_manager::storage;
use creator_event_manager::CreatorEventManagerContractClient;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::token::StellarAssetClient;
use soroban_sdk::{Address, Env, String, Symbol};

const FEE: i128 = 1_000_000;

fn setup() -> (
Env,
CreatorEventManagerContractClient<'static>,
Address,
Address,
Address,
Address,
) {
let env = Env::default();
env.mock_all_auths();

let contract_id =
env.register_contract(None, creator_event_manager::CreatorEventManagerContract);
let client = CreatorEventManagerContractClient::new(&env, &contract_id);
let client: CreatorEventManagerContractClient<'static> =
unsafe { core::mem::transmute(client) };

let admin = Address::generate(&env);
let ai_agent = Address::generate(&env);
let treasury = Address::generate(&env);
let token_admin = Address::generate(&env);
let xlm_token = env
.register_stellar_asset_contract_v2(token_admin)
.address();

client.initialize(&admin, &ai_agent, &treasury, &xlm_token, &FEE);
(env, client, contract_id, admin, ai_agent, xlm_token)
}

fn fund(env: &Env, token: &Address, user: &Address, amount: i128) {
StellarAssetClient::new(env, token).mint(user, &amount);
}

fn title(env: &Env) -> String {
String::from_str(env, "Test Event")
}

fn desc(env: &Env) -> String {
String::from_str(env, "Test Description")
}

fn create_event_with_match(
env: &Env,
contract_id: &Address,
client: &CreatorEventManagerContractClient<'static>,
creator: &Address,
xlm_token: &Address,
match_time_offset: u64,
) -> (u64, Symbol, u64) {
fund(env, xlm_token, creator, FEE);
let (event_id, invite_code) = client.create_event(creator, &title(env), &desc(env), &10u32);

let match_id = env.as_contract(contract_id, || {
let match_id = storage::next_match_id(env);
let match_record = creator_event_manager::storage_types::Match::new(
match_id,
event_id,
String::from_str(env, "Team A"),
String::from_str(env, "Team B"),
env.ledger().timestamp() + match_time_offset,
);
storage::set_match(env, match_id, &match_record);
storage::add_event_match(env, event_id, match_id);

let mut event = storage::get_event(env, event_id).expect("event exists");
event.add_match();
storage::set_event(env, event_id, &event);
match_id
});

(event_id, invite_code, match_id)
}

#[test]
fn test_returns_all_predictions_for_match() {
let (env, client, contract_id, _admin, _ai_agent, xlm_token) = setup();
let creator = Address::generate(&env);
let (_event_id, invite, match_id) =
create_event_with_match(&env, &contract_id, &client, &creator, &xlm_token, 10_000);

let u1 = Address::generate(&env);
let u2 = Address::generate(&env);
let u3 = Address::generate(&env);
client.join_event(&u1, &invite);
client.join_event(&u2, &invite);
client.join_event(&u3, &invite);
client.submit_prediction(&u1, &match_id, &Symbol::new(&env, "TEAM_A"));
client.submit_prediction(&u2, &match_id, &Symbol::new(&env, "TEAM_B"));
client.submit_prediction(&u3, &match_id, &Symbol::new(&env, "DRAW"));

let predictions = client.get_match_predictions(&match_id);
assert_eq!(predictions.len(), 3);

let mut matched = 0u32;
for p in predictions.iter() {
assert_eq!(p.match_id, match_id);
if p.predictor == u1 || p.predictor == u2 || p.predictor == u3 {
matched += 1;
}
}
assert_eq!(matched, 3);
}

#[test]
fn test_empty_list_for_match_with_no_predictions() {
let (env, client, contract_id, _admin, _ai_agent, xlm_token) = setup();
let creator = Address::generate(&env);
let (_event_id, _invite, match_id) =
create_event_with_match(&env, &contract_id, &client, &creator, &xlm_token, 10_000);

let predictions = client.get_match_predictions(&match_id);
assert_eq!(predictions.len(), 0);
}

#[test]
fn test_correct_count() {
let (env, client, contract_id, _admin, _ai_agent, xlm_token) = setup();
let creator = Address::generate(&env);
let (_event_id, invite, match_id) =
create_event_with_match(&env, &contract_id, &client, &creator, &xlm_token, 10_000);

let u1 = Address::generate(&env);
let u2 = Address::generate(&env);
client.join_event(&u1, &invite);
client.join_event(&u2, &invite);
client.submit_prediction(&u1, &match_id, &Symbol::new(&env, "TEAM_A"));
client.submit_prediction(&u2, &match_id, &Symbol::new(&env, "TEAM_A"));

assert_eq!(client.get_match_predictions(&match_id).len(), 2);
}

#[test]
fn test_unknown_match_returns_empty() {
let (_env, client, _contract_id, _admin, _ai_agent, _xlm_token) = setup();
let predictions = client.get_match_predictions(&99_999u64);
assert_eq!(predictions.len(), 0);
}
2 changes: 2 additions & 0 deletions contracts/creator-event-manager/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
mod admin_tests;
mod event_tests;
mod get_match_predictions_tests;
mod match_tests;
mod oracle_tests;
mod prediction_tests;
mod storage_types_tests;
mod submit_match_result_contract_tests;
mod verification_tests;
mod views_tests;
Loading
Loading