diff --git a/contracts/game_contract/src/lib.rs b/contracts/game_contract/src/lib.rs index dcb5ef0..7bde0a0 100644 --- a/contracts/game_contract/src/lib.rs +++ b/contracts/game_contract/src/lib.rs @@ -244,6 +244,17 @@ pub struct GameContract; #[contractimpl] impl GameContract { + /// Bind the SEP-41 token contract used for all wager escrow and prize transfers. + /// + /// Must be called once before any game or reward function. Calling it a + /// second time panics with `"Contract already initialized"`. + /// + /// # Parameters + /// - `admin` — Address that authorises the call (`require_auth` is enforced). + /// - `token_contract` — Address of the SEP-41 token contract (e.g. XLM or USDC). + /// + /// # Panics + /// - If `TOKEN_CONTRACT` is already set in instance storage. pub fn initialize_token(env: Env, admin: Address, token_contract: Address) { if env.storage().instance().has(&TOKEN_CONTRACT) { panic!("Contract already initialized"); @@ -266,7 +277,31 @@ impl GameContract { } /// Gas-optimized tournament payout — single pass, no redundant map reads. - /// Validates percentages and distributes atomically. + /// + /// Validates that `percentages` sum to exactly 100, then distributes the + /// total prize pool from escrow to each winner in a single token-transfer + /// loop. Any integer-division remainder (dust) is added to the first + /// winner's share. + /// + /// Requires authorisation from `game.player1` (the tournament organiser). + /// + /// # Parameters + /// - `game_id` — ID of a game in the `Completed` state. + /// - `winners` — Ordered list of recipient addresses. + /// - `percentages` — Whole-number percentages (0–100) parallel to `winners`; + /// must sum to exactly 100. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::GameNotInProgress`] — Game is not in `Completed` state. + /// - [`ContractError::MismatchedLengths`] — `winners` and `percentages` differ in length. + /// - [`ContractError::InvalidPercentage`] — Percentages overflow or do not sum to 100. + /// + /// # Events + /// Does not emit events directly; token transfers emit SAC-level transfer events. pub fn payout_tournament_optimized( env: Env, game_id: u64, @@ -341,6 +376,25 @@ impl GameContract { // ── Game lifecycle ──────────────────────────────────────────────────────── + /// Create a new wager game and lock `player1`'s stake into escrow. + /// + /// Generates a sequential game ID, transfers `wager_amount` tokens from + /// `player1` to the contract, and stores the game in `Created` state + /// awaiting a second player. + /// + /// # Parameters + /// - `player1` — Address of the game creator; must authorise the call. + /// - `wager_amount` — Token amount to stake; must be > 0, ≤ `MAX_STAKE`, and + /// `wager_amount * 2` must be ≤ `MAX_PRIZE_POOL`. + /// + /// # Returns + /// `Ok(game_id)` — the unique `u64` identifier for the new game. + /// + /// # Errors + /// - [`ContractError::StakeLimitExceeded`] — `wager_amount > MAX_STAKE`. + /// - [`ContractError::PrizePoolLimitExceeded`] — `wager_amount * 2 > MAX_PRIZE_POOL`. + /// - [`ContractError::InvalidAmount`] — Integer overflow computing the prize pool. + /// - [`ContractError::InsufficientFunds`] — `player1`'s token balance < `wager_amount`. pub fn create_game( env: Env, player1: Address, @@ -411,6 +465,29 @@ impl GameContract { Ok(game_counter) } + /// Join an existing game as the second player and lock matching stake. + /// + /// Transfers `game.wager_amount` tokens from `player2` to the contract, + /// sets `game.player2`, and advances the game to `InProgress` state with + /// `current_turn = 1` (player1 moves first). + /// + /// # Parameters + /// - `game_id` — ID of a game in `Created` state. + /// - `player2` — Address of the joining player; must differ from `player1` + /// and must authorise the call. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::GameAlreadyCompleted`] — Game is not in `Created` state. + /// - [`ContractError::GameFull`] — A second player has already joined. + /// - [`ContractError::AlreadyJoined`] — `player2` is the same as `player1`. + /// - [`ContractError::StakeLimitExceeded`] — Wager exceeds `MAX_STAKE`. + /// - [`ContractError::PrizePoolLimitExceeded`] — Combined pool exceeds `MAX_PRIZE_POOL`. + /// - [`ContractError::InvalidAmount`] — Integer overflow computing the pool. + /// - [`ContractError::InsufficientFunds`] — `player2`'s balance < `wager_amount`. pub fn join_game(env: Env, game_id: u64, player2: Address) -> Result<(), ContractError> { let mut games: Map = env .storage() @@ -478,6 +555,28 @@ impl GameContract { Ok(()) } + /// Record a chess move for the active player and advance the turn. + /// + /// Appends a [`ChessMove`] entry to `game.moves`, flips `current_turn` + /// (1 → 2 → 1 …), and updates `last_move_at` to the current ledger + /// sequence (used by the timeout mechanism). + /// + /// # Parameters + /// - `game_id` — ID of a game in `InProgress` state. + /// - `player` — Address of the moving player; must be `player1` or `player2` + /// and must authorise the call. + /// - `move_data` — Encoded move payload (e.g. from/to squares as `u32`s); + /// must be non-empty. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::GameNotInProgress`] — Game is not in `InProgress` state. + /// - [`ContractError::NotPlayer`] — `player` is not a participant. + /// - [`ContractError::NotYourTurn`] — It is not `player`'s turn. + /// - [`ContractError::InvalidMove`] — `move_data` is empty. pub fn submit_move( env: Env, game_id: u64, @@ -529,6 +628,34 @@ impl GameContract { Ok(()) } + /// Claim a draw result, verified by the backend signing service. + /// + /// Verifies an ED25519 signature over `SHA256(game_id_le8 || b"DRAW")`, + /// transitions the game to `Drawn`, and returns each player's stake in + /// full via [`process_draw_payout`]. + /// + /// The backend signature prevents either player from unilaterally claiming + /// a draw without mutual agreement or arbiter approval. + /// + /// # Signature Payload + /// `SHA256( game_id.to_le_bytes() || b"DRAW" )` + /// + /// # Parameters + /// - `game_id` — ID of a game in `InProgress` state. + /// - `player` — Either participant's address; must authorise the call. + /// - `signature` — 64-byte ED25519 signature from the backend service. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::GameNotInProgress`] — Game is not `InProgress`. + /// - [`ContractError::NotPlayer`] — `player` is not a participant. + /// + /// # Panics + /// - If the ED25519 signature is invalid (Soroban `ed25519_verify` panics on failure). + /// - If the contract has not been initialized (`ADMIN_KEY` absent). pub fn claim_draw( env: Env, game_id: u64, @@ -581,6 +708,32 @@ impl GameContract { Ok(()) } + /// Claim a win result, verified by the backend signing service. + /// + /// Verifies an ED25519 signature over `SHA256(game_id_le8 || winner_address_bytes)`, + /// pays out the net prize (total pool minus protocol fee, if configured) to + /// `winner`, and marks the game `Settled`. + /// + /// # Signature Payload + /// `SHA256( game_id.to_le_bytes() || winner_address_string_bytes )` + /// + /// # Parameters + /// - `game_id` — ID of a game in `InProgress` state. + /// - `winner` — Address of the winning player; must be a participant and + /// must authorise the call. + /// - `signature` — 64-byte ED25519 signature from the backend service. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::GameNotInProgress`] — Game is not `InProgress`. + /// - [`ContractError::NotPlayer`] — `winner` is not a participant. + /// + /// # Panics + /// - If the ED25519 signature is invalid. + /// - If the contract has not been initialized. pub fn claim_win( env: Env, game_id: u64, @@ -639,6 +792,23 @@ impl GameContract { Ok(()) } + /// Cancel a game that has not yet been joined and refund the creator. + /// + /// Only `player1` (the creator) may cancel, and only while the game is in + /// `Created` state (before `player2` joins). The staked wager is returned + /// to `player1` and the game is marked `Completed` to prevent future joins. + /// + /// # Parameters + /// - `game_id` — ID of a game in `Created` state. + /// - `player` — Must equal `game.player1`; must authorise the call. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::GameAlreadyCompleted`] — Game has already progressed past `Created`. + /// - [`ContractError::NotPlayer`] — `player` is not `player1`. pub fn cancel_game(env: Env, game_id: u64, player: Address) -> Result<(), ContractError> { let mut games: Map = env .storage() @@ -680,6 +850,25 @@ impl GameContract { Ok(()) } + /// Forfeit the game, awarding the full prize pool to the opponent. + /// + /// Either player may forfeit an `InProgress` game at any time. The + /// opponent receives the net payout (total pool minus protocol fee) and + /// the game is marked `Settled`. + /// + /// # Parameters + /// - `game_id` — ID of a game in `InProgress` state. + /// - `player` — Forfeiting player's address; must be a participant and + /// must authorise the call. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::GameNotInProgress`] — Game is not `InProgress`. + /// - [`ContractError::NotPlayer`] — `player` is not a participant. + /// - [`ContractError::GameFull`] — No `player2` found (single-player game; should not occur). pub fn forfeit(env: Env, game_id: u64, player: Address) -> Result<(), ContractError> { let mut games: Map = env .storage() @@ -717,6 +906,25 @@ impl GameContract { Ok(()) } + /// Transfer the prize to the pre-recorded winner of a `Completed` game. + /// + /// This entrypoint is used when the game outcome has been recorded + /// off-chain and `game.winner` has been set (e.g. after `claim_win` + /// advanced the state to `Completed`). The winner calls this to pull + /// their funds. + /// + /// # Parameters + /// - `game_id` — ID of a game in `Completed` state with a recorded winner. + /// - `winner` — Must match `game.winner`; must authorise the call. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::AlreadySettled`] — Game is already `Settled`. + /// - [`ContractError::GameNotInProgress`] — Game is not in `Completed` state. + /// - [`ContractError::NotPlayer`] — `winner` does not match `game.winner`. pub fn payout(env: Env, game_id: u64, winner: Address) -> Result<(), ContractError> { let mut games: Map = env .storage() @@ -747,6 +955,31 @@ impl GameContract { Ok(()) } + /// Distribute a completed game's prize pool across multiple tournament winners. + /// + /// Deducts both players' escrow balances, then distributes the total pool + /// proportionally according to `percentages`. Integer-division dust goes to + /// the first winner. Requires `player1`'s authorisation as the tournament + /// organiser. + /// + /// Prefer [`payout_tournament_optimized`] for lower gas cost; this variant + /// does separate escrow reads per winner. + /// + /// # Parameters + /// - `game_id` — ID of a game in `Completed` state. + /// - `winners` — Ordered recipient addresses. + /// - `percentages` — Whole-number percentages parallel to `winners`; + /// must sum to exactly 100. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::GameNotInProgress`] — Game is not `Completed`. + /// - [`ContractError::MismatchedLengths`] — `winners` and `percentages` differ. + /// - [`ContractError::InsufficientFunds`] — Escrow balance is lower than the wager. + /// - [`ContractError::InvalidPercentage`] — Percentages overflow or do not sum to 100. pub fn payout_tournament( env: Env, game_id: u64, @@ -835,6 +1068,16 @@ impl GameContract { Ok(()) } + /// Fetch a single game by ID. + /// + /// # Parameters + /// - `game_id` — Numeric game identifier returned by [`create_game`]. + /// + /// # Returns + /// `Ok(Game)` — full [`Game`] struct including state, players, moves, and wager. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. pub fn get_game(env: Env, game_id: u64) -> Result { let games: Map = env .storage() @@ -845,6 +1088,15 @@ impl GameContract { games.get(game_id).ok_or(ContractError::GameNotFound) } + /// Return every stored game as a map keyed by game ID. + /// + /// Returns an empty map if no games have been created yet. + /// + /// > **Note:** This is an unbounded read. Frontend callers should paginate + /// > using individual [`get_game`] calls once the game count grows large. + /// + /// # Returns + /// `Map` — all games indexed by their numeric ID. pub fn get_all_games(env: Env) -> Map { env.storage() .instance() @@ -952,12 +1204,26 @@ impl GameContract { // ── Administration ──────────────────────────────────────────────────────── /// Initialize puzzle-reward system (#199) and fee configuration (#200). - /// Must be called exactly once. /// - /// * `admin_public_key` – 32-byte ED25519 public key of the backend signing service - /// * `treasury_amount` – Initial token reserve for puzzle payouts - /// * `fee_bips` – Protocol fee in basis-points of 1000 (e.g. 20 = 2 %) - /// * `treasury_address` – Address that receives the protocol fee + /// Must be called exactly once after [`initialize_token`]. Sets the ED25519 + /// backend public key, seeds the treasury, and configures the protocol fee + /// and treasury address. Also sets the default `MAX_STAKE` (1 000) and + /// `MAX_PRIZE_POOL` (2 000). + /// + /// # Parameters + /// - `admin` — Contract administrator; must authorise the call. + /// - `admin_public_key` — 32-byte ED25519 public key of the backend signing + /// service (used to verify puzzle-reward and win/draw claims). + /// - `treasury_amount` — Initial token reserve for puzzle reward payouts; must be ≥ 0. + /// - `fee_bips` — Protocol fee in basis-points of 1 000 (e.g. `20` = 2 %). + /// Capped at 1 000 (100 %). + /// - `treasury_address` — Address that receives the fee portion of each payout. + /// + /// # Panics + /// - If `CONTRACT_ADMIN` is already set (`"Already initialized"`). + /// - If `admin_public_key.len() != 32`. + /// - If `treasury_amount < 0`. + /// - If `fee_bips > 1000`. pub fn initialize_puzzle_rewards( env: Env, admin: Address, @@ -993,6 +1259,20 @@ impl GameContract { env.storage().instance().set(&MAX_PRIZE_POOL, &2_000i128); } + /// Update the per-game maximum wager (admin only). + /// + /// Any subsequent [`create_game`] or [`join_game`] call with a + /// `wager_amount` exceeding this limit will return + /// [`ContractError::StakeLimitExceeded`]. + /// + /// # Parameters + /// - `admin` — Must match `CONTRACT_ADMIN`; must authorise the call. + /// - `new_limit` — New maximum wager; must be > 0. + /// + /// # Panics + /// - If the contract is not initialized. + /// - If `admin` does not match the stored admin. + /// - If `new_limit <= 0`. pub fn set_max_stake(env: Env, admin: Address, new_limit: i128) { let current_admin: Address = env .storage() @@ -1009,6 +1289,20 @@ impl GameContract { env.storage().instance().set(&MAX_STAKE, &new_limit); } + /// Update the maximum combined prize pool across both players (admin only). + /// + /// Guards against very large pools. Any [`create_game`] or [`join_game`] + /// call where `wager_amount * 2 > MAX_PRIZE_POOL` will return + /// [`ContractError::PrizePoolLimitExceeded`]. + /// + /// # Parameters + /// - `admin` — Must match `CONTRACT_ADMIN`; must authorise the call. + /// - `new_limit` — New maximum prize pool; must be > 0. + /// + /// # Panics + /// - If the contract is not initialized. + /// - If `admin` does not match the stored admin. + /// - If `new_limit <= 0`. pub fn set_max_prize_pool(env: Env, admin: Address, new_limit: i128) { let current_admin: Address = env .storage() @@ -1025,6 +1319,20 @@ impl GameContract { env.storage().instance().set(&MAX_PRIZE_POOL, &new_limit); } + /// Update the protocol fee and treasury address (admin only, no multi-sig). + /// + /// For fee changes requiring multi-sig approval, use the + /// [`propose_fee_change`] / [`approve_fee_proposal`] flow instead. + /// + /// # Parameters + /// - `admin` — Must match `CONTRACT_ADMIN`; must authorise the call. + /// - `fee_bips` — New fee in basis-points of 1 000 (0–1 000). + /// - `treasury_address` — New recipient address for the fee portion. + /// + /// # Panics + /// - If the contract is not initialized. + /// - If `admin` does not match the stored admin. + /// - If `fee_bips > 1000`. pub fn configure_fees(env: Env, admin: Address, fee_bips: u32, treasury_address: Address) { let current_admin: Address = env .storage() @@ -1046,6 +1354,18 @@ impl GameContract { .set(&TREASURY_ADDR, &treasury_address); } + /// Set the contract admin when none has been recorded yet (upgrade path). + /// + /// This is a one-time migration helper for contracts initialised before + /// `CONTRACT_ADMIN` storage was introduced. It panics if an admin is + /// already set, or if the contract's `ADMIN_KEY` has not been seeded. + /// + /// # Parameters + /// - `admin` — New administrator address; must authorise the call. + /// + /// # Panics + /// - If `CONTRACT_ADMIN` is already set. + /// - If `ADMIN_KEY` is not set (contract not initialised). pub fn upgrade_admin(env: Env, admin: Address) { if env.storage().instance().has(&CONTRACT_ADMIN) { panic!("Admin already set"); @@ -1069,6 +1389,36 @@ impl GameContract { // • Invalid signature → panics (Soroban's ed25519_verify panics on failure) // • Replayed nonce → Err(ContractError::Unauthorized) // • Valid call → recipient balance incremented, treasury decremented + + /// Claim a single puzzle reward backed by a backend ED25519 signature. + /// + /// Verifies the signature, guards against nonce replay, deducts + /// `reward_amount` from the treasury, and credits the recipient's + /// puzzle-reward balance. Emits a `pzl_rwd` event on success. + /// + /// # Signature Payload + /// `SHA256( recipient_address_string_bytes || reward_amount_i64_le8 || nonce_u64_le8 )` + /// + /// # Parameters + /// - `recipient` — Address to credit; must be the one encoded in the signature. + /// - `reward_amount` — Token amount to award; must be > 0 and ≤ `i64::MAX`. + /// - `nonce` — Unique per-claim `u64` nonce; prevents replay attacks. + /// - `signature` — 64-byte ED25519 signature from the backend service. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::InvalidAmount`] — `reward_amount ≤ 0` or `> i64::MAX`. + /// - [`ContractError::Unauthorized`] — `nonce` has already been used. + /// + /// # Panics + /// - If the ED25519 signature is invalid. + /// - If `ADMIN_KEY` is absent (contract not initialised). + /// - If the treasury has insufficient balance. + /// + /// # Events + /// Emits `("pzl_rwd", recipient) → reward_amount`. pub fn claim_puzzle_reward( env: Env, recipient: Address, @@ -1188,6 +1538,40 @@ impl GameContract { // • Any reused/duplicate nonce → Err(ContractError::Unauthorized) // • All proofs valid → every recipient balance incremented, // treasury decremented by the sum, in one TX + + /// Claim multiple puzzle rewards in a single transaction. + /// + /// More gas-efficient than calling [`claim_puzzle_reward`] repeatedly because + /// the nonce, balance, and treasury storage maps are read and written only + /// once for the whole batch. Each [`Proof`] is validated with the same + /// signature scheme and replay-protection rules as the single-claim variant. + /// + /// **Atomicity:** all proofs are applied to in-memory state first; storage + /// is only committed after every proof passes. A single invalid proof rolls + /// back the entire batch. + /// + /// # Parameters + /// - `proofs` — 1–[`MAX_BATCH_SIZE`] (currently 20) [`Proof`] entries. + /// Each proof carries its own `recipient`, `reward_amount`, + /// `nonce`, and `signature`; see [`claim_puzzle_reward`] for + /// the per-proof validation rules. + /// + /// # Returns + /// `Ok(())` when all proofs are accepted and state is committed. + /// + /// # Errors + /// - [`ContractError::EmptyBatch`] — `proofs` is empty. + /// - [`ContractError::BatchTooLarge`] — `proofs.len() > MAX_BATCH_SIZE`. + /// - [`ContractError::InvalidAmount`] — Any proof has `reward_amount ≤ 0` or `> i64::MAX`. + /// - [`ContractError::Unauthorized`] — Any nonce is already used or duplicated within the batch. + /// + /// # Panics + /// - If any ED25519 signature is invalid. + /// - If the treasury has insufficient balance for any individual proof. + /// + /// # Events + /// Emits `("pzl_rwd", recipient) → reward_amount` per proof, then + /// `("pzlbatch",) → (proof_count, total_claimed)` for the batch. pub fn claim_puzzle_rewards_batch(env: Env, proofs: Vec) -> Result<(), ContractError> { if proofs.is_empty() { return Err(ContractError::EmptyBatch); @@ -1306,9 +1690,24 @@ impl GameContract { // ── Dispute Resolution System ────────────────────────────────────────── - /// Configure dispute resolution system - /// * `arbitrator` - Address of the dispute arbitrator - /// * `dispute_fee` - Fee required to file a dispute (in tokens) + // ── Dispute Resolution System ────────────────────────────────────────── + + /// Configure the dispute resolution system (admin only). + /// + /// Sets the arbitrator address and the token fee required to file a + /// dispute. Must be called before [`file_dispute`] is usable. + /// + /// # Parameters + /// - `admin` — Must match `CONTRACT_ADMIN`; must authorise the call. + /// - `arbitrator` — Address authorised to call [`resolve_dispute`] and + /// [`reject_dispute`]. + /// - `dispute_fee` — Token amount a disputing player must pay upfront; + /// refunded if the dispute is rejected. Must be ≥ 0. + /// + /// # Panics + /// - If the contract is not initialised. + /// - If `admin` does not match the stored admin. + /// - If `dispute_fee < 0`. pub fn configure_dispute_system( env: Env, admin: Address, @@ -1333,7 +1732,21 @@ impl GameContract { env.storage().instance().set(&DISPUTE_FEE, &dispute_fee); } - /// Configure timeout duration (in ledger sequences) + /// Set the inactivity timeout used by [`claim_timeout_win`] (admin only). + /// + /// The timeout is measured in ledger sequences (≈ 5 s each on Stellar + /// mainnet). A player may claim a timeout win once `current_ledger - + /// game.last_move_at ≥ timeout_duration`. + /// + /// # Parameters + /// - `admin` — Must match `CONTRACT_ADMIN`; must authorise the call. + /// - `duration` — Ledger sequences of inactivity before a timeout win is + /// claimable; must be > 0. + /// + /// # Panics + /// - If the contract is not initialised. + /// - If `admin` does not match the stored admin. + /// - If `duration == 0`. pub fn configure_timeout(env: Env, admin: Address, duration: u64) { let current_admin: Address = env .storage() @@ -1352,7 +1765,33 @@ impl GameContract { env.storage().instance().set(&TIMEOUT_DURATION, &duration); } - /// File a dispute against the opponent for a game. + /// File a dispute against an opponent for an in-progress game. + /// + /// Transfers the configured `dispute_fee` (if non-zero) from `filer` to + /// the contract, creates a [`Dispute`] in `Pending` status, and emits a + /// `("dispute", "filed")` event. The fee is held until the dispute is + /// resolved or rejected. + /// + /// # Parameters + /// - `game_id` — ID of a game in `InProgress` state. + /// - `filer` — Player raising the dispute; must be a game participant + /// and must authorise the call. + /// - `against` — The opposing player; must also be a participant and + /// must differ from `filer`. + /// - `reason` — Encoded reason bytes (arbitrary off-chain payload). + /// + /// # Returns + /// `Ok(dispute_id)` — unique `u64` identifier for the new dispute. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::NotDisputable`] — Game is not `InProgress`. + /// - [`ContractError::NotPlayer`] — `filer` or `against` is not a participant. + /// - [`ContractError::InvalidMove`] — `filer` and `against` are the same address. + /// - [`ContractError::InsufficientDisputeFee`] — `filer`'s balance < configured dispute fee. + /// + /// # Events + /// Emits `("dispute", "filed") → (dispute_id, filer)`. pub fn file_dispute( env: Env, game_id: u64, @@ -1429,9 +1868,30 @@ impl GameContract { Ok(dispute_counter) } - /// Claim timeout win when opponent hasn't moved within timeout period - /// The current player can claim victory if the opponent hasn't made a move - /// within the configured timeout duration + /// Claim a win by timeout when the opponent has not moved within the allowed window. + /// + /// Only the *waiting* player (the one who is not required to move) may + /// claim a timeout win. Once `current_ledger - game.last_move_at ≥ + /// timeout_duration`, that player calls this to receive the net payout. + /// + /// # Parameters + /// - `game_id` — ID of a game in `InProgress` state. + /// - `claimant` — The player who is waiting for the opponent's move; + /// must authorise the call. + /// + /// # Returns + /// `Ok(())` on success; game is marked `Settled`. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::GameNotInProgress`] — Game is not `InProgress`. + /// - [`ContractError::NotPlayer`] — `claimant` is not a participant. + /// - [`ContractError::InvalidTimeoutClaimant`] — `claimant` is the player whose turn it is (not the waiting player). + /// - [`ContractError::TimeoutNotConfigured`] — [`configure_timeout`] has not been called. + /// - [`ContractError::TimeoutNotReached`] — Elapsed ledgers < `timeout_duration`. + /// + /// # Events + /// Emits `("timeout", game_id) → (claimant, timeout_duration)`. pub fn claim_timeout_win( env: Env, game_id: u64, @@ -1495,7 +1955,18 @@ impl GameContract { Ok(()) } - /// Query remaining time before timeout (in ledger sequences). + /// Query the ledger sequences remaining before the active player's turn times out. + /// + /// Returns `None` if the game does not exist, is not `InProgress`, or if + /// no timeout has been configured. Returns `Some(0)` when the timeout has + /// already elapsed (a timeout win is claimable). + /// + /// # Parameters + /// - `game_id` — ID of the game to query. + /// + /// # Returns + /// `Some(remaining_sequences)` — sequences until timeout, or `Some(0)` if already elapsed. + /// `None` — game not found, not in progress, or timeout not configured. pub fn get_timeout_remaining(env: Env, game_id: u64) -> Option { let games: Map = env.storage().instance().get(&GAMES)?; let game = games.get(game_id)?; @@ -1515,7 +1986,32 @@ impl GameContract { Some(timeout_duration - elapsed) } - /// Resolve a dispute and settle the game according to the arbitrator's decision. + /// Resolve a pending dispute and settle the underlying game (arbitrator only). + /// + /// Authenticates the arbitrator, processes the payout (`Some(winner)` for a + /// win, `None` for a draw), marks the game `Settled`, and closes the + /// dispute as `Resolved`. Emits a `("dispute", "solved")` event. + /// + /// # Parameters + /// - `dispute_id` — ID of a dispute in `Pending` status. + /// - `arbitrator` — Must match the stored arbitrator; must authorise the call. + /// - `winner` — `Some(address)` to pay out a winner, or `None` to + /// split stakes equally (draw). + /// - `resolution` — Encoded resolution rationale (arbitrary off-chain payload). + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::NotArbitrator`] — `arbitrator` is not the configured arbitrator, + /// or the arbitrator has not been set. + /// - [`ContractError::DisputeNotFound`] — `dispute_id` does not exist. + /// - [`ContractError::GameAlreadyCompleted`] — Dispute is not `Pending`, or game is not `InProgress`. + /// - [`ContractError::GameNotFound`] — Underlying game does not exist. + /// - [`ContractError::NotPlayer`] — Provided winner is not a game participant. + /// + /// # Events + /// Emits `("dispute", "solved") → (dispute_id, winner)`. pub fn resolve_dispute( env: Env, dispute_id: u64, @@ -1593,8 +2089,27 @@ impl GameContract { Ok(()) } - /// Reject a dispute (arbitrator only) - /// Returns the dispute fee to the filer + /// Reject a pending dispute and refund the dispute fee to the filer (arbitrator only). + /// + /// Marks the dispute `Rejected`, stores the arbitrator's reason, and + /// returns the dispute fee (if non-zero) to the original filer. + /// + /// # Parameters + /// - `dispute_id` — ID of a dispute in `Pending` status. + /// - `arbitrator` — Must match the stored arbitrator; must authorise the call. + /// - `reason` — Encoded rejection rationale (arbitrary off-chain payload). + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::NotArbitrator`] — `arbitrator` is not the configured arbitrator, + /// or the arbitrator has not been set. + /// - [`ContractError::DisputeNotFound`] — `dispute_id` does not exist. + /// - [`ContractError::GameAlreadyCompleted`] — Dispute is not in `Pending` status. + /// + /// # Events + /// Emits `("dispute", "reject") → (dispute_id, filer)`. pub fn reject_dispute( env: Env, dispute_id: u64, @@ -1653,7 +2168,17 @@ impl GameContract { Ok(()) } - /// Query a dispute by ID + /// Fetch a dispute by ID. + /// + /// # Parameters + /// - `dispute_id` — Numeric ID returned by [`file_dispute`]. + /// + /// # Returns + /// `Ok(Dispute)` — the full [`Dispute`] struct including status and resolution. + /// + /// # Errors + /// - [`ContractError::DisputeNotFound`] — `dispute_id` does not exist or + /// no disputes have been filed yet. pub fn get_dispute(env: Env, dispute_id: u64) -> Result { let disputes: Map = env .storage() @@ -1677,12 +2202,30 @@ impl GameContract { // The challenge payload is: SHA256( address_bytes || nonce_le8 || expiry_le8 ) // This matches the backend signing convention used in claim_puzzle_reward. - /// Issue a SEP-10 challenge for `account`. + /// Issue a SEP-10 Web Authentication challenge for `account` (admin only). + /// + /// Stores `nonce → expiry` in contract storage. The user must subsequently + /// call [`verify_sep10_challenge`] with a valid ED25519 signature over the + /// challenge payload to become verified. + /// + /// # Parameters + /// - `admin` — Must match `CONTRACT_ADMIN`; must authorise the call. + /// - `account` — Stellar account address the challenge is issued for. + /// - `nonce` — 32-byte random value; must be globally unique (re-using a + /// nonce returns [`ContractError::ChallengeAlreadyUsed`]). + /// - `expiry` — Ledger sequence after which the challenge is considered + /// expired and cannot be verified. /// - /// * `nonce` – 32-byte random value (must be unique per challenge) - /// * `expiry` – ledger sequence after which the challenge is invalid + /// # Returns + /// `Ok(())` on success. /// - /// Only the contract admin may issue challenges (prevents spam). + /// # Errors + /// - [`ContractError::Unauthorized`] — `admin` does not match `CONTRACT_ADMIN`. + /// - [`ContractError::ChallengeExpired`] — `expiry ≤ current_ledger`. + /// - [`ContractError::ChallengeAlreadyUsed`] — `nonce` already exists in storage. + /// + /// # Events + /// Emits `("sep10", "issued") → (account, nonce)`. pub fn issue_sep10_challenge( env: Env, admin: Address, @@ -1828,10 +2371,28 @@ impl GameContract { // 4. Once approvals ≥ threshold the fee change is applied automatically. // 5. Any signer can call `cancel_fee_proposal` to discard the proposal. - /// Configure the multi-sig signer set and approval threshold. + /// Configure the multi-sig signer set and approval threshold (admin only). + /// + /// Replaces any existing signer configuration. Once set, fee changes + /// proposed via [`propose_fee_change`] require at least `threshold` + /// approvals from `signers` before taking effect. /// - /// Can only be called by the contract admin. - /// `threshold` must be ≥ 1 and ≤ `signers.len()`. + /// # Parameters + /// - `admin` — Must match `CONTRACT_ADMIN`; must authorise the call. + /// - `signers` — Exhaustive list of addresses authorised to propose and + /// approve fee changes. + /// - `threshold` — Minimum number of approvals required; must be ≥ 1 and + /// ≤ `signers.len()`. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::Unauthorized`] — `admin` does not match `CONTRACT_ADMIN`. + /// - [`ContractError::InvalidThreshold`] — `threshold == 0` or `threshold > signers.len()`. + /// + /// # Panics + /// - If the contract is not initialised. pub fn configure_multisig( env: Env, admin: Address, @@ -1861,10 +2422,27 @@ impl GameContract { Ok(()) } - /// Propose a new fee configuration. + /// Propose a new fee configuration (any registered signer). + /// + /// Replaces any existing pending proposal and resets the approval map. + /// The proposer's approval is counted automatically, so a single-signer + /// setup with `threshold = 1` will execute immediately. + /// + /// # Parameters + /// - `proposer` — Must be in the configured signer set; must authorise the call. + /// - `new_fee_bips` — Proposed fee in basis-points of 1 000 (0–1 000). + /// - `new_treasury_address` — Proposed recipient address for the fee portion. + /// + /// # Returns + /// `Ok(())` on success. /// - /// Replaces any existing pending proposal. The proposer's approval is - /// counted automatically. + /// # Errors + /// - [`ContractError::InvalidAmount`] — `new_fee_bips > 1000`. + /// - [`ContractError::NotASigner`] — `proposer` is not in the signer set. + /// - [`ContractError::Unauthorized`] — Multi-sig has not been configured yet. + /// + /// # Events + /// Emits `("multisig", "proposed") → proposer`. pub fn propose_fee_change( env: Env, proposer: Address, @@ -1914,9 +2492,28 @@ impl GameContract { Ok(()) } - /// Approve the pending fee proposal. + /// Approve the pending fee proposal (any registered signer). + /// + /// Records the signer's approval. If the running approval count reaches + /// the configured threshold, the fee change is applied immediately and + /// the proposal is cleared. + /// + /// # Parameters + /// - `signer` — Must be in the configured signer set; must authorise the call. + /// + /// # Returns + /// `Ok(true)` — threshold reached, proposal executed and cleared. + /// `Ok(false)` — approval recorded, still awaiting more signers. + /// + /// # Errors + /// - [`ContractError::NotASigner`] — `signer` is not in the signer set. + /// - [`ContractError::Unauthorized`] — Multi-sig has not been configured. + /// - [`ContractError::NoProposal`] — No pending proposal exists. + /// - [`ContractError::AlreadyApproved`] — `signer` has already approved this proposal. /// - /// When approvals reach the threshold the fee change is applied immediately. + /// # Events + /// On partial approval: emits `("multisig", "approved") → signer`. + /// On execution: emits `("multisig", "executed") → new_fee_bips`. pub fn approve_fee_proposal(env: Env, signer: Address) -> Result { let signers: Vec
= env .storage() @@ -1988,7 +2585,24 @@ impl GameContract { Ok(false) // still waiting for more approvals } - /// Cancel the pending fee proposal (any signer may cancel). + /// Cancel the pending fee proposal (any registered signer). + /// + /// Removes the proposal and clears all approvals. Any signer may cancel, + /// not only the original proposer. + /// + /// # Parameters + /// - `signer` — Must be in the configured signer set; must authorise the call. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::NotASigner`] — `signer` is not in the signer set. + /// - [`ContractError::Unauthorized`] — Multi-sig has not been configured. + /// - [`ContractError::NoProposal`] — No pending proposal to cancel. + /// + /// # Events + /// Emits `("multisig", "cancel") → signer`. pub fn cancel_fee_proposal(env: Env, signer: Address) -> Result<(), ContractError> { let signers: Vec
= env .storage() @@ -2015,12 +2629,19 @@ impl GameContract { Ok(()) } - /// Query the pending fee proposal (if any). + /// Return the pending fee proposal, if one exists. + /// + /// # Returns + /// `Some(FeeProposal)` — the proposal with its `new_fee_bips`, + /// `new_treasury_address`, `proposed_at` ledger sequence, and `proposer`. + /// `None` — no proposal is currently pending. pub fn get_fee_proposal(env: Env) -> Option { env.storage().instance().get(&PENDING_FEE_PROPOSAL) } - /// Query how many signers have approved the pending proposal. + /// Return the number of signers who have approved the pending proposal. + /// + /// Returns `0` if no proposal is pending or no approvals have been recorded. pub fn get_approval_count(env: Env) -> u32 { let approvals: Map = env .storage() @@ -2036,7 +2657,23 @@ impl GameContract { // 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. + /// Set the SEP-40 oracle contract address used for clock synchronisation (admin only). + /// + /// Once configured, [`get_oracle_time`] may call the oracle via + /// cross-contract invocation to obtain a trusted timestamp. + /// + /// # Parameters + /// - `admin` — Must match `CONTRACT_ADMIN`; must authorise the call. + /// - `oracle` — Address of the SEP-40-compliant oracle contract. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::Unauthorized`] — `admin` does not match `CONTRACT_ADMIN`. + /// + /// # Panics + /// - If the contract is not initialised. pub fn configure_oracle( env: Env, admin: Address, @@ -2055,7 +2692,13 @@ impl GameContract { Ok(()) } - /// Get the current oracle contract address. + /// Return the configured SEP-40 oracle contract address. + /// + /// # Returns + /// `Ok(Address)` — the oracle contract address. + /// + /// # Errors + /// - [`ContractError::OracleNotConfigured`] — [`configure_oracle`] has not been called. pub fn get_oracle(env: Env) -> Result { env.storage() .instance() @@ -2088,7 +2731,25 @@ impl GameContract { // 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). + /// Configure the time-lock duration for tournament prize escrows (admin only). + /// + /// After a tournament game completes, a prize escrow created via + /// [`create_tournament_escrow`] is locked for `duration` ledger sequences + /// before it can be released. + /// + /// # Parameters + /// - `admin` — Must match `CONTRACT_ADMIN`; must authorise the call. + /// - `duration` — Lock duration in ledger sequences; must be > 0. + /// + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::Unauthorized`] — `admin` does not match `CONTRACT_ADMIN`. + /// + /// # Panics + /// - If the contract is not initialised. + /// - If `duration == 0`. pub fn configure_tournament_timelock( env: Env, admin: Address, @@ -2114,8 +2775,24 @@ impl GameContract { /// Create a time-locked escrow for a completed tournament game. /// - /// Locks the total prize pool until `current_ledger + timelock_duration`. - /// Returns the escrow ID. + /// Calculates the total prize pool from the game's wager amount and locks it + /// until `current_ledger + timelock_duration`. The escrow must then be + /// released via [`release_tournament_escrow`] by the admin after the lock + /// expires. Requires `player1`'s authorisation as the tournament organiser. + /// + /// # Parameters + /// - `game_id` — ID of a game in `Completed` state. + /// + /// # Returns + /// `Ok(escrow_id)` — the unique `u64` identifier for the new escrow. + /// + /// # Errors + /// - [`ContractError::GameNotFound`] — `game_id` does not exist. + /// - [`ContractError::GameNotInProgress`] — Game is not in `Completed` state. + /// - [`ContractError::TimeoutNotConfigured`] — [`configure_tournament_timelock`] has not been called. + /// + /// # Events + /// Emits `("tl_escrow", "created") → (escrow_id, game_id, locked_until)`. pub fn create_tournament_escrow(env: Env, game_id: u64) -> Result { let games: Map = env .storage() @@ -2170,10 +2847,32 @@ impl GameContract { Ok(escrow_id) } - /// Release a time-locked tournament escrow to the specified winners. + /// Release a time-locked tournament escrow to the specified winners (admin only). + /// + /// Distributes the locked prize pool proportionally according to + /// `percentages`. Integer-division dust goes to the first winner. + /// Can only be called after the lock period has expired. + /// + /// # Parameters + /// - `admin` — Must match `CONTRACT_ADMIN`; must authorise the call. + /// - `escrow_id` — ID of a previously created, unreleased escrow. + /// - `winners` — Ordered recipient addresses. + /// - `percentages` — Whole-number percentages parallel to `winners`; + /// must sum to exactly 100. /// - /// Can only be called after the lock period has expired, and only by the - /// contract admin. + /// # Returns + /// `Ok(())` on success. + /// + /// # Errors + /// - [`ContractError::Unauthorized`] — `admin` does not match `CONTRACT_ADMIN`. + /// - [`ContractError::EscrowNotFound`] — `escrow_id` does not exist. + /// - [`ContractError::EscrowAlreadyReleased`] — Escrow has already been released. + /// - [`ContractError::EscrowStillLocked`] — Lock period has not yet elapsed. + /// - [`ContractError::MismatchedLengths`] — `winners` and `percentages` differ. + /// - [`ContractError::InvalidPercentage`] — Percentages overflow or do not sum to 100. + /// + /// # Events + /// Emits `("tl_escrow", "released") → escrow_id`. pub fn release_tournament_escrow( env: Env, admin: Address, @@ -2259,7 +2958,18 @@ impl GameContract { Ok(()) } - /// Query a tournament escrow by ID. + /// Fetch a tournament escrow by ID. + /// + /// # Parameters + /// - `escrow_id` — Numeric ID returned by [`create_tournament_escrow`]. + /// + /// # Returns + /// `Ok(TournamentEscrow)` — the full escrow record including `locked_until` + /// and `released` flag. + /// + /// # Errors + /// - [`ContractError::EscrowNotFound`] — `escrow_id` does not exist or no + /// escrows have been created yet. pub fn get_tournament_escrow( env: Env, escrow_id: u64,