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
250 changes: 250 additions & 0 deletions contracts/game_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ const MULTISIG_THRESHOLD: Symbol = symbol_short!("MS_THRES"); // u32
const PENDING_FEE_PROPOSAL: Symbol = symbol_short!("MS_PROP"); // Option<FeeProposal>
const FEE_PROPOSAL_APPROVALS: Symbol = symbol_short!("MS_APPR"); // Map<Address, bool>

// SEP-40 Oracle clock sync (#533)
const ORACLE_CONTRACT: Symbol = symbol_short!("ORACLE"); // Address of oracle contract

// Time-lock escrow for tournament prizes (#532)
const TOURNAMENT_TIMELOCK: Symbol = symbol_short!("TL_DUR"); // u64 - lock duration in ledger sequences
const TOURNAMENT_ESCROWS: Symbol = symbol_short!("TL_ESC"); // Map<u64, TournamentEscrow>

// ────────────────────────────────────────────────────────────────────────────
// Multi-sig fee proposal type (#535)
// ────────────────────────────────────────────────────────────────────────────
Expand All @@ -131,6 +138,20 @@ pub struct FeeProposal {
pub proposer: Address,
}

// ────────────────────────────────────────────────────────────────────────────
// Tournament escrow type (#532)
// ────────────────────────────────────────────────────────────────────────────

#[contracttype]
#[derive(Clone, Debug)]
pub struct TournamentEscrow {
pub escrow_id: u64,
pub game_id: u64,
pub total_amount: i128,
pub locked_until: u64, // ledger sequence when funds can be released
pub released: bool,
}
Comment on lines +145 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

The release path can pay the entire escrow to arbitrary addresses.

TournamentEscrow does not bind winners or the split, and release_tournament_escrow accepts both from the caller without auth or cross-checking against canonical game state. After the timelock expires, any caller can route 100% of the pool to attacker-controlled addresses. Persist the payout plan at escrow creation time, or derive it from immutable game data, and release only that stored plan.

Also applies to: 1976-2045

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/game_contract/src/lib.rs` around lines 145 - 153, TournamentEscrow
currently lacks owner/winner/split information and release_tournament_escrow
trusts caller-supplied recipients, so persist a canonical payout plan at escrow
creation and validate it at release: add payout fields to the TournamentEscrow
struct (e.g., winners: Vec<AccountId> and splits: Vec<u8>/Vec<i128> or a single
encoded PayoutPlan), populate that plan in the function that creates the escrow
(where game_id is known), and change release_tournament_escrow to only allow
releasing to the stored payout plan (or to compute the plan from immutable game
state using the game_id) while rejecting arbitrary recipient lists and enforcing
any required auth checks; update all places that construct or read
TournamentEscrow to use the new fields and ensure total splits sum validation
occurs at creation.


// ────────────────────────────────────────────────────────────────────────────
// Errors
// ────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -186,6 +207,14 @@ pub enum ContractError {
AlreadyApproved = 30,
/// Multi-sig: threshold must be ≥ 1 and ≤ number of signers (#535)
InvalidThreshold = 31,
/// Oracle contract not configured (#533)
OracleNotConfigured = 32,
/// Tournament escrow not found (#532)
EscrowNotFound = 33,
/// Tournament escrow is still locked (#532)
EscrowStillLocked = 34,
/// Tournament escrow already released (#532)
EscrowAlreadyReleased = 35,
}

#[contract]
Expand Down Expand Up @@ -1803,6 +1832,227 @@ impl GameContract {
.unwrap_or(Map::new(&env));
approvals.len()
}

// ── SEP-40 Oracle Clock Sync (#533) ───────────────────────────────────────
//
// SEP-40 defines a standard oracle interface on Stellar/Soroban.
// We store the oracle contract address and call its `lastprice` function
// to get a trusted timestamp for game clock synchronization.

/// Configure the SEP-40 oracle contract address for clock sync.
pub fn configure_oracle(env: Env, admin: Address, oracle: Address) -> Result<(), ContractError> {
let current_admin: Address = env
.storage()
.instance()
.get(&CONTRACT_ADMIN)
.expect("Not initialized");
current_admin.require_auth();
if admin != current_admin {
return Err(ContractError::Unauthorized);
}
env.storage().instance().set(&ORACLE_CONTRACT, &oracle);
Ok(())
}

/// Get the current oracle contract address.
pub fn get_oracle(env: Env) -> Result<Address, ContractError> {
env.storage()
.instance()
.get(&ORACLE_CONTRACT)
.ok_or(ContractError::OracleNotConfigured)
}

/// Get the oracle-synced timestamp (ledger sequence from oracle).
/// Falls back to the current ledger sequence if oracle is not configured.
/// The oracle is called via cross-contract invocation using the SEP-40
/// `lastprice` interface which returns a price record with a timestamp.
///
/// Note: This is a minimal implementation. In production, you would invoke
/// the oracle contract's `lastprice` method and extract the timestamp.
pub fn get_oracle_time(env: Env) -> u64 {
let oracle_opt: Option<Address> = env.storage().instance().get(&ORACLE_CONTRACT);
match oracle_opt {
Some(_oracle) => {
// TODO: Implement cross-contract call to oracle.lastprice()
// For now, fall back to ledger sequence
env.ledger().sequence() as u64
}
None => env.ledger().sequence() as u64,
}
}
Comment on lines +1865 to +1882

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

get_oracle_time still ignores the configured oracle.

When ORACLE_CONTRACT is present, this branch still returns env.ledger().sequence(), so configure_oracle has no effect and the advertised fallback never actually differs from the configured case. Wire the cross-contract read here before shipping, or rename the API so it does not promise synchronized oracle time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/game_contract/src/lib.rs` around lines 1865 - 1882, The
get_oracle_time function currently ignores ORACLE_CONTRACT; update
get_oracle_time to perform a cross-contract invocation on the stored oracle
address (ORACLE_CONTRACT) to call the oracle's lastprice method, parse the
returned price record to extract the timestamp, and return that timestamp; on
any failure (missing field, call error, or unexpected return type) fall back to
env.ledger().sequence() as u64. Ensure you reference the stored Address from
ORACLE_CONTRACT, invoke the oracle's lastprice entrypoint, convert/unpack the
returned record to obtain the timestamp field, and handle errors gracefully so
configure_oracle actually affects behavior.


// ── Time-lock Escrow for Tournament Grand Prizes (#532) ───────────────────
//
// Tournament grand prizes are locked in escrow for a configurable duration
// before they can be released to winners. This prevents immediate withdrawal
// and allows time for dispute resolution.

/// Configure the tournament time-lock duration (in ledger sequences).
pub fn configure_tournament_timelock(
env: Env,
admin: Address,
duration: u64,
) -> Result<(), ContractError> {
let current_admin: Address = env
.storage()
.instance()
.get(&CONTRACT_ADMIN)
.expect("Not initialized");
current_admin.require_auth();
if admin != current_admin {
return Err(ContractError::Unauthorized);
}
if duration == 0 {
panic!("Timelock duration must be greater than 0");
}
env.storage().instance().set(&TOURNAMENT_TIMELOCK, &duration);
Ok(())
}

/// Create a time-locked escrow for a completed tournament game.
///
/// Locks the total prize pool until `current_ledger + timelock_duration`.
/// Returns the escrow ID.
pub fn create_tournament_escrow(
env: Env,
game_id: u64,
) -> Result<u64, ContractError> {
let games: Map<u64, Game> = env
.storage()
.instance()
.get(&GAMES)
.ok_or(ContractError::GameNotFound)?;

let game = games.get(game_id).ok_or(ContractError::GameNotFound)?;

if game.state != GameState::Completed {
return Err(ContractError::GameNotInProgress);
}

game.player1.require_auth();

let duration: u64 = env
.storage()
.instance()
.get(&TOURNAMENT_TIMELOCK)
.ok_or(ContractError::TimeoutNotConfigured)?;

let total_amount = match &game.player2 {
Some(_) => game.wager_amount * 2,
None => game.wager_amount,
};

let locked_until = env.ledger().sequence() as u64 + duration;

let mut escrows: Map<u64, TournamentEscrow> = env
.storage()
.instance()
.get(&TOURNAMENT_ESCROWS)
.unwrap_or(Map::new(&env));

let escrow_id = escrows.len() as u64 + 1;
let escrow = TournamentEscrow {
escrow_id,
game_id,
total_amount,
locked_until,
released: false,
};

escrows.set(escrow_id, escrow);
env.storage().instance().set(&TOURNAMENT_ESCROWS, &escrows);

env.events().publish(
(symbol_short!("tl_escrow"), symbol_short!("created")),
(escrow_id, game_id, locked_until),
);

Ok(escrow_id)
Comment on lines +1916 to +1970

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Prevent the same game from being escrowed multiple times.

create_tournament_escrow only appends a new record. It does not mark the game as escrowed/settled, and it does not store any game_id -> escrow_id guard. The same completed game can therefore mint multiple escrows and still remain eligible for the existing tournament payout paths, creating multiple claims against one prize pool.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/game_contract/src/lib.rs` around lines 1916 - 1970, The function
create_tournament_escrow currently allows creating multiple escrows for the same
game; before creating a new TournamentEscrow, check for an existing escrow tied
to game_id (e.g. by looking up TOURNAMENT_ESCROWS for any entry with that
game_id or by maintaining a new map TOURNAMENT_GAME_TO_ESCROW mapping game_id ->
escrow_id) and return an error if one exists; then, when creating the escrow,
persist the guard by either updating the Game (add/set an escrowed flag on the
Game stored in GAMES) or by inserting the new mapping (set
TOURNAMENT_GAME_TO_ESCROW[game_id] = escrow_id) so subsequent calls to
create_tournament_escrow will fail. Ensure you update the saved GAMES or
TOURNAMENT_ESCROWS/TOURNAMENT_GAME_TO_ESCROW storage consistently and publish
the event only after the guard is stored.

}

/// Release a time-locked tournament escrow to the specified winners.
///
/// Can only be called after the lock period has expired.
pub fn release_tournament_escrow(
env: Env,
escrow_id: u64,
winners: Vec<Address>,
percentages: Vec<u32>,
) -> Result<(), ContractError> {
let mut escrows: Map<u64, TournamentEscrow> = env
.storage()
.instance()
.get(&TOURNAMENT_ESCROWS)
.ok_or(ContractError::EscrowNotFound)?;

let escrow = escrows.get(escrow_id).ok_or(ContractError::EscrowNotFound)?;

if escrow.released {
return Err(ContractError::EscrowAlreadyReleased);
}

let current_ledger = env.ledger().sequence() as u64;
if current_ledger < escrow.locked_until {
return Err(ContractError::EscrowStillLocked);
}

if winners.len() != percentages.len() {
return Err(ContractError::MismatchedLengths);
}

let mut total_pct: u32 = 0;
for i in 0..percentages.len() {
total_pct += percentages.get(i).unwrap();
if total_pct > 100 {
return Err(ContractError::InvalidPercentage);
}
}
if total_pct != 100 {
return Err(ContractError::InvalidPercentage);
}

let token_client = Self::token_client(&env);
let contract_address = env.current_contract_address();
let total = escrow.total_amount;
let mut distributed: i128 = 0;

for i in 0..winners.len() {
let winner = winners.get(i).unwrap();
let pct = percentages.get(i).unwrap();
let amount = (total * pct as i128) / 100;
distributed += amount;
token_client.transfer(&contract_address, &winner, &amount);
}

// Dust goes to first winner
let remainder = total - distributed;
if remainder > 0 && !winners.is_empty() {
let first_winner = winners.get(0).unwrap();
token_client.transfer(&contract_address, &first_winner, &remainder);
}

let mut released_escrow = escrow;
released_escrow.released = true;
escrows.set(escrow_id, released_escrow);
env.storage().instance().set(&TOURNAMENT_ESCROWS, &escrows);

env.events().publish(
(symbol_short!("tl_escrow"), symbol_short!("released")),
escrow_id,
);

Ok(())
}

/// Query a tournament escrow by ID.
pub fn get_tournament_escrow(env: Env, escrow_id: u64) -> Result<TournamentEscrow, ContractError> {
let escrows: Map<u64, TournamentEscrow> = env
.storage()
.instance()
.get(&TOURNAMENT_ESCROWS)
.ok_or(ContractError::EscrowNotFound)?;
escrows.get(escrow_id).ok_or(ContractError::EscrowNotFound)
}
}

// ────────────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading