From e80ed88a848afcc8417ffe77c58faa48c9035c9e Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:00:46 +0700 Subject: [PATCH 1/2] fix(dice-duel): add session-bound commit-reveal --- Cargo.lock | 3 + contracts/dice-duel/Cargo.toml | 3 + contracts/dice-duel/README.md | 194 ++-- contracts/dice-duel/src/lib.rs | 612 +++++++----- contracts/dice-duel/src/test.rs | 889 ++++++++++++++---- .../dice-duel/tests/commitment_vector.rs | 123 +++ contracts/dice-duel/tests/wasm_resource.rs | 132 +++ contracts/mock-game-hub/Cargo.toml | 3 +- contracts/mock-game-hub/src/lib.rs | 349 +++++-- 9 files changed, 1751 insertions(+), 557 deletions(-) create mode 100644 contracts/dice-duel/tests/commitment_vector.rs create mode 100644 contracts/dice-duel/tests/wasm_resource.rs diff --git a/Cargo.lock b/Cargo.lock index a2ce2e7b..05d5f59a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,7 +495,10 @@ dependencies = [ name = "dice-duel" version = "0.1.0" dependencies = [ + "mock-game-hub", + "sha2", "soroban-sdk", + "stellar-xdr", ] [[package]] diff --git a/contracts/dice-duel/Cargo.toml b/contracts/dice-duel/Cargo.toml index d17023dd..42a41b03 100644 --- a/contracts/dice-duel/Cargo.toml +++ b/contracts/dice-duel/Cargo.toml @@ -13,3 +13,6 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +mock-game-hub = { path = "../mock-game-hub", features = ["testutils"] } +sha2 = "0.10.9" +stellar-xdr = "27.0.0" diff --git a/contracts/dice-duel/README.md b/contracts/dice-duel/README.md index 76b59187..44dbaa12 100644 --- a/contracts/dice-duel/README.md +++ b/contracts/dice-duel/README.md @@ -1,98 +1,154 @@ -# Dice Duel Game - -A two-player dice game smart contract built on Stellar's Soroban platform. - -## Overview - -Each player commits to a roll. After both players have rolled, the contract -reveals two dice for each player and the highest total wins (ties go to Player 1). - -## Features - -- **Contract-Generated Dice**: Uses Soroban PRNG to generate dice values -- **Two-Player Games**: Each game involves exactly two players -- **Simple Rules**: Roll two dice each, highest total wins -- **Multiple Concurrent Games**: Support for multiple independent games -- **Game Hub Integration**: Uses `start_game` and `end_game` for points locking and results - -## Contract Methods +# Dice Duel + +`dice-duel` is a two-player Soroban game that uses commit/reveal so neither a +caller nor the contract can choose a winning public input after seeing the +other player's contribution. + +## Protocol + +Each player generates a uniformly random `BytesN<32>` secret off chain. A +commitment is SHA-256 over a canonical Soroban `SCV_MAP` XDR value containing +the protocol version and domain, this Dice Duel contract address, the +`dice-duel` game tag, session ID, player role and address, both player +addresses, both exact stakes, and the secret. A separate salt is unnecessary: +a uniformly random 256-bit secret already supplies the required entropy. + +Both commitments, both complete player authorizations, and the reveal deadline +are supplied to `start_game` before the Hub locks either stake. Each player then +reveals with `roll`. Reveals are accepted through the deadline inclusively. The +four dice are derived in fixed player-role order from both verified secrets; +they do not depend on reveal order, ledger time, or `session_id`. + +After both reveals, anyone may call `reveal_winner`. Ties go to player 1. After +the deadline, anyone may call `resolve_timeout`: exactly one revealer wins by +forfeit, while zero reveals cause a neutral Hub cancellation and exact refund +of each player's asymmetric stake. Terminal Hub interaction is guarded by the +`Finalizing` phase and Soroban transaction rollback, so a failed Hub call can +be retried without leaving a partially finalized game. + +Game records use persistent storage with a 30-day live TTL. An unattended game +is therefore archived rather than deleted irreversibly. If it has archived, +restore the Dice game entry and required contract/Hub footprint before calling +`reveal_winner`, `resolve_timeout`, or `get_game`. + +## ABI + +### `commitment` + +Computes the commitment for a player. Production clients should reproduce this +canonical encoding and hash locally, using the golden vector in the test suite, +and retain the secret securely until reveal. **Never sign or submit a +`commitment` invocation as a transaction, and never send the secret to an +untrusted RPC.** Trusted local simulation is suitable only as a development +cross-check. + +```text +commitment( + session_id: u32, + role: u32, // 1 = player 1, 2 = player 2 + revealing_player: Address, + player1: Address, + player2: Address, + player1_points: i128, + player2_points: i128, + secret: BytesN<32>, +) -> BytesN<32> +``` ### `start_game` -Start a new game between two players. - -**Parameters:** -- `session_id: u32` -- `player1: Address` -- `player2: Address` -- `player1_points: i128` -- `player2_points: i128` -**Returns:** `Result<(), Error>` +```text +start_game( + session_id: u32, + player1: Address, + player2: Address, + player1_points: i128, + player2_points: i128, + player1_commitment: BytesN<32>, + player2_commitment: BytesN<32>, + reveal_deadline: u32, +) -> Result<(), Error> +``` -**Auth:** Requires authentication from both players +Both players authorize the complete argument set. The deadline must be in the +future and fit within the persistent entry's 30-day live TTL while preserving +the timeout-resolution grace window. ### `roll` -Commit a roll for the current game. - -**Parameters:** -- `session_id: u32` -- `player: Address` -**Returns:** `Result<(), Error>` +```text +roll(session_id: u32, player: Address, secret: BytesN<32>) + -> Result<(), Error> +``` -**Auth:** Requires authentication from the rolling player +Requires the revealing player's authorization. It rejects a wrong secret, a +duplicate reveal, and a reveal after the deadline. ### `reveal_winner` -Reveal the winner after both players have rolled. -**Parameters:** -- `session_id: u32` +```text +reveal_winner(session_id: u32) -> Result +``` -**Returns:** `Result` - Address of the winning player +Permissionless. It settles a game only after both valid reveals. -**Note:** Can only be called after both players have rolled. If totals are equal, -Player 1 wins the tie. +### `resolve_timeout` -### `get_game` -Get the current state of a game. +```text +resolve_timeout(session_id: u32) -> Result, Error> +``` -**Parameters:** -- `session_id: u32` +Permissionless and available only after the deadline. Returns the sole +revealer for a forfeit or `None` for neutral cancellation. -**Returns:** `Result` - The game state +### Queries and administration -## Game Flow +- `get_game(session_id)` returns the complete stored game state. +- `get_admin()` and `get_hub()` return current configuration. +- `set_admin(new_admin)`, `set_hub(new_hub)`, and `upgrade(new_wasm_hash)` + require administrator authorization. -1. Two players call `start_game` to create a new game -2. Each player calls `roll` to commit their roll -3. Once both players have rolled, anyone can call `reveal_winner` -4. The contract generates two dice for each player -5. The game is marked as ended and the winner is recorded +Each game snapshots its Hub address at creation. Changing the configured Hub +therefore affects only later games. -## Error Codes +## Errors -- `GameNotFound` (1): The specified session does not exist -- `NotPlayer` (2): Caller is not a player in this game -- `AlreadyRolled` (3): Player already committed their roll -- `BothPlayersNotRolled` (4): Cannot reveal winner until both players roll -- `GameAlreadyEnded` (5): Game already ended +Codes 1 through 5 are the legacy Dice Duel ABI and remain stable: -## Building +1. `GameNotFound` +2. `NotPlayer` +3. `AlreadyRolled` +4. `BothPlayersNotRolled` +5. `GameAlreadyEnded` -```bash -stellar contract build -``` +New errors use codes above the preserved range: -Output: `target/wasm32v1-none/release/dice_duel.wasm` +6. `SamePlayer` +7. `GameAlreadyExists` +8. `InvalidDeadline` +9. `RevealDeadlinePassed` +10. `RevealDeadlineNotReached` +11. `WrongSecret` +12. `FinalizationInProgress` -## Testing +## Build and test ```bash -cargo test +cargo test --workspace --locked -j 2 +cargo build --locked --release --target wasm32v1-none -p dice-duel -p mock-game-hub +cargo test -p dice-duel --test wasm_resource --locked -- --ignored --nocapture ``` -## Technical Details +The ignored resource test loads the optimized Wasm files at runtime and checks +normal settlement, one-sided forfeit, and zero-reveal cancellation under the +Soroban SDK's mainnet invocation limits. + +## Deployment gates -- **Deterministic PRNG**: Uses a deterministic seed so results are stable between - simulation and submission. -- **Storage**: Uses temporary storage with a 30-day TTL. +The repository mock demonstrates the proposed neutral `cancel_game(session_id)` +behavior; it is not evidence of production Hub settlement economics. Before +deployment, the production Hub must confirm that ABI and exact refund +semantics. Because the game state and public ABI changed, deploy a new Dice +contract ID (or prove all legacy sessions are drained), then regenerate Studio +bindings and add secure client-side secret retention and timeout UI. This +change does not update the Studio contract ID or generated TypeScript bindings. diff --git a/contracts/dice-duel/src/lib.rs b/contracts/dice-duel/src/lib.rs index b803e190..9a679b17 100644 --- a/contracts/dice-duel/src/lib.rs +++ b/contracts/dice-duel/src/lib.rs @@ -1,21 +1,17 @@ #![no_std] -//! # Dice Duel +//! A two-player dice game using session-bound commit/reveal randomness. //! -//! A two-player dice game where each player rolls two dice. -//! The player with the highest total wins (ties go to player 1). -//! -//! **Game Hub Integration:** -//! This game is Game Hub-aware and enforces all games to be played through the -//! Game Hub contract. Games cannot be started or completed without points involvement. +//! Both commitments and the exact game terms are authorized before the Game +//! Hub locks either stake. Reveals are accepted through the inclusive ledger +//! deadline. The dice are derived from the two secrets in player-role order, +//! so neither public session inputs nor reveal order influence the result. use soroban_sdk::{ - Address, Bytes, BytesN, Env, IntoVal, contract, contractclient, contracterror, contractimpl, - contracttype, vec + contract, contractclient, contracterror, contractimpl, contracttype, xdr::ToXdr, Address, + Bytes, BytesN, Env, }; -// Import GameHub contract interface -// This allows us to call into the GameHub contract #[contractclient(name = "GameHubClient")] pub trait GameHub { fn start_game( @@ -28,31 +24,81 @@ pub trait GameHub { player2_points: i128, ); - fn end_game( - env: Env, - session_id: u32, - player1_won: bool - ); -} + fn end_game(env: Env, session_id: u32, player1_won: bool); -// ============================================================================ -// Errors -// ============================================================================ + fn cancel_game(env: Env, session_id: u32); +} #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] pub enum Error { + // Codes 1-5 are the legacy Dice Duel ABI and must not be renumbered. GameNotFound = 1, NotPlayer = 2, AlreadyRolled = 3, BothPlayersNotRolled = 4, GameAlreadyEnded = 5, + SamePlayer = 6, + GameAlreadyExists = 7, + InvalidDeadline = 8, + RevealDeadlinePassed = 9, + RevealDeadlineNotReached = 10, + WrongSecret = 11, + FinalizationInProgress = 12, +} + +pub const PLAYER1_ROLE: u32 = 1; +pub const PLAYER2_ROLE: u32 = 2; +pub const COMMITMENT_DOMAIN: &[u8] = b"stellar-game-studio:commitment:v1"; +pub const OUTCOME_DOMAIN: &[u8] = b"stellar-game-studio:dice-outcome:v1"; +pub const GAME_TAG: &[u8] = b"dice-duel"; + +/// Persistent game entries remain live for 30 days at approximately five +/// seconds per ledger. If left untouched longer, they can be restored from +/// archival before settlement instead of being deleted irreversibly. +pub const GAME_TTL_LEDGERS: u32 = 518_400; +pub const TIMEOUT_RESOLUTION_GRACE_LEDGERS: u32 = 17_280; +pub const MAX_REVEAL_WINDOW_LEDGERS: u32 = GAME_TTL_LEDGERS - TIMEOUT_RESOLUTION_GRACE_LEDGERS; + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Phase { + Revealing, + Ready, + Finalizing, + Settled, + Forfeited, + Cancelled, } -// ============================================================================ -// Data Types -// ============================================================================ +/// A named Soroban value is used so `to_xdr` produces one canonical SCV_MAP. +/// Every field is load-bearing replay context. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommitmentPreimage { + pub domain: Bytes, + pub contract: Address, + pub game: Bytes, + pub session_id: u32, + pub role: u32, + pub revealing_player: Address, + pub player1: Address, + pub player2: Address, + pub player1_points: i128, + pub player2_points: i128, + pub secret: BytesN<32>, +} + +/// Outcome entropy intentionally contains no session or other public input. +/// Secrets always appear in player1/player2 order, never reveal order. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutcomePreimage { + pub domain: Bytes, + pub player1_secret: BytesN<32>, + pub player2_secret: BytesN<32>, +} #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -61,8 +107,16 @@ pub struct Game { pub player2: Address, pub player1_points: i128, pub player2_points: i128, + pub game_hub: Address, + pub player1_commitment: BytesN<32>, + pub player2_commitment: BytesN<32>, + pub reveal_deadline: u32, + pub phase: Phase, pub player1_rolled: bool, pub player2_rolled: bool, + /// Only the first valid reveal must be retained. It is cleared as soon as + /// the second reveal deterministically prepares the dice. + pub first_secret: Option>, pub player1_die1: Option, pub player1_die2: Option, pub player2_die1: Option, @@ -78,60 +132,127 @@ pub enum DataKey { Admin, } -// ============================================================================ -// Storage TTL Management -// ============================================================================ -// TTL (Time To Live) ensures game data doesn't expire unexpectedly -// Games are stored in temporary storage with a minimum 30-day retention +#[allow(clippy::too_many_arguments)] +fn commitment_digest( + env: &Env, + session_id: u32, + role: u32, + revealing_player: &Address, + player1: &Address, + player2: &Address, + player1_points: i128, + player2_points: i128, + secret: &BytesN<32>, +) -> BytesN<32> { + let preimage = CommitmentPreimage { + domain: Bytes::from_slice(env, COMMITMENT_DOMAIN), + contract: env.current_contract_address(), + game: Bytes::from_slice(env, GAME_TAG), + session_id, + role, + revealing_player: revealing_player.clone(), + player1: player1.clone(), + player2: player2.clone(), + player1_points, + player2_points, + secret: secret.clone(), + }; + env.crypto().sha256(&preimage.to_xdr(env)).to_bytes() +} -/// TTL for game storage (30 days in ledgers, ~5 seconds per ledger) -/// 30 days = 30 * 24 * 60 * 60 / 5 = 518,400 ledgers -const GAME_TTL_LEDGERS: u32 = 518_400; +fn prepare_dice( + env: &Env, + player1_secret: &BytesN<32>, + player2_secret: &BytesN<32>, +) -> (u32, u32, u32, u32) { + let preimage = OutcomePreimage { + domain: Bytes::from_slice(env, OUTCOME_DOMAIN), + player1_secret: player1_secret.clone(), + player2_secret: player2_secret.clone(), + }; + let seed = env.crypto().sha256(&preimage.to_xdr(env)); + env.prng().seed(seed.into()); + ( + env.prng().gen_range::(1..=6) as u32, + env.prng().gen_range::(1..=6) as u32, + env.prng().gen_range::(1..=6) as u32, + env.prng().gen_range::(1..=6) as u32, + ) +} -// ============================================================================ -// Helper Functions -// ============================================================================ +fn put_game(env: &Env, session_id: u32, game: &Game) { + let key = DataKey::Game(session_id); + env.storage().persistent().set(&key, game); + // Every game-state write refreshes retention. If a Hub invocation fails, + // Soroban rolls this write and the nested invocation back atomically. + env.storage() + .persistent() + .extend_ttl(&key, GAME_TTL_LEDGERS, GAME_TTL_LEDGERS); + // Refresh the contract instance/code together with game state. Otherwise + // a game opened late in the previous instance lifetime could outlive the + // contract that must settle it. + env.storage() + .instance() + .extend_ttl(GAME_TTL_LEDGERS, GAME_TTL_LEDGERS); +} -/// Roll a single die (1-6) using deterministic PRNG -fn roll_die(env: &Env, seed: BytesN<32>) -> u32 { - env.prng().seed(seed.into()); - env.prng().gen_range::(1..=6) as u32 +fn is_terminal(phase: Phase) -> bool { + matches!(phase, Phase::Settled | Phase::Forfeited | Phase::Cancelled) } -// ============================================================================ -// Contract Definition -// ============================================================================ +fn end_hub_game(env: &Env, hub_address: &Address, session_id: u32, player1_won: bool) { + GameHubClient::new(env, hub_address).end_game(&session_id, &player1_won); +} + +/// Kept isolated because production Hub cancellation compatibility remains a +/// deployment gate independent of the Dice Duel lifecycle implementation. +fn cancel_hub_game(env: &Env, hub_address: &Address, session_id: u32) { + GameHubClient::new(env, hub_address).cancel_game(&session_id); +} #[contract] pub struct DiceDuelContract; #[contractimpl] impl DiceDuelContract { - /// Initialize the contract with GameHub address and admin - /// - /// # Arguments - /// * `admin` - Admin address (can upgrade contract) - /// * `game_hub` - Address of the GameHub contract pub fn __constructor(env: Env, admin: Address, game_hub: Address) { - // Store admin and GameHub address env.storage().instance().set(&DataKey::Admin, &admin); env.storage() .instance() .set(&DataKey::GameHubAddress, &game_hub); + env.storage() + .instance() + .extend_ttl(GAME_TTL_LEDGERS, GAME_TTL_LEDGERS); + } + + /// Return the commitment expected by `roll` for this contract instance. + #[allow(clippy::too_many_arguments)] + pub fn commitment( + env: Env, + session_id: u32, + role: u32, + revealing_player: Address, + player1: Address, + player2: Address, + player1_points: i128, + player2_points: i128, + secret: BytesN<32>, + ) -> BytesN<32> { + commitment_digest( + &env, + session_id, + role, + &revealing_player, + &player1, + &player2, + player1_points, + player2_points, + &secret, + ) } - /// Start a new game between two players with points. - /// This creates a session in the Game Hub and locks points before starting the game. - /// - /// **CRITICAL:** This method requires authorization from THIS contract (not players). - /// The Game Hub will call `game_id.require_auth()` which checks this contract's address. - /// - /// # Arguments - /// * `session_id` - Unique session identifier (u32) - /// * `player1` - Address of first player - /// * `player2` - Address of second player - /// * `player1_points` - Points amount committed by player 1 - /// * `player2_points` - Points amount committed by player 2 + /// Both signers authorize the complete game intent before the Hub lock. + #[allow(clippy::too_many_arguments)] pub fn start_game( env: Env, session_id: u32, @@ -139,45 +260,50 @@ impl DiceDuelContract { player2: Address, player1_points: i128, player2_points: i128, + player1_commitment: BytesN<32>, + player2_commitment: BytesN<32>, + reveal_deadline: u32, ) -> Result<(), Error> { - // Prevent self-play: Player 1 and Player 2 must be different if player1 == player2 { - panic!("Cannot play against yourself: Player 1 and Player 2 must be different addresses"); + return Err(Error::SamePlayer); } - // Require authentication from both players (they consent to committing points) - player1.require_auth_for_args(vec![&env, session_id.into_val(&env), player1_points.into_val(&env)]); - player2.require_auth_for_args(vec![&env, session_id.into_val(&env), player2_points.into_val(&env)]); + let current_ledger = env.ledger().sequence(); + let reveal_window = reveal_deadline + .checked_sub(current_ledger) + .ok_or(Error::InvalidDeadline)?; + if reveal_window == 0 || reveal_window > MAX_REVEAL_WINDOW_LEDGERS { + return Err(Error::InvalidDeadline); + } + + let key = DataKey::Game(session_id); + if env.storage().persistent().has(&key) { + return Err(Error::GameAlreadyExists); + } + + // This binds each signer to every argument of the current invocation. + player1.require_auth(); + player2.require_auth(); - // Get GameHub address - let game_hub_addr: Address = env + let game_hub: Address = env .storage() .instance() .get(&DataKey::GameHubAddress) - .expect("GameHub address not set"); - - // Create GameHub client - let game_hub = GameHubClient::new(&env, &game_hub_addr); - - // Call the Game Hub to start the session and lock points - // This requires THIS contract's authorization (env.current_contract_address()) - game_hub.start_game( - &env.current_contract_address(), - &session_id, - &player1, - &player2, - &player1_points, - &player2_points, - ); + .ok_or(Error::GameNotFound)?; - // Create game (dice not rolled yet - will be generated in reveal_winner) let game = Game { player1: player1.clone(), player2: player2.clone(), player1_points, player2_points, + game_hub: game_hub.clone(), + player1_commitment, + player2_commitment, + reveal_deadline, + phase: Phase::Revealing, player1_rolled: false, player2_rolled: false, + first_secret: None, player1_die1: None, player1_die2: None, player2_die1: None, @@ -185,185 +311,206 @@ impl DiceDuelContract { winner: None, }; - // Store game in temporary storage with 30-day TTL - let game_key = DataKey::Game(session_id); - env.storage().temporary().set(&game_key, &game); - - // Set TTL to ensure game is retained for at least 30 days - env.storage() - .temporary() - .extend_ttl(&game_key, GAME_TTL_LEDGERS, GAME_TTL_LEDGERS); - - // Event emitted by GameHub contract (GameStarted) - + // The entire invocation is atomic: a rejected Hub call rolls back this + // pre-lock state write. + put_game(&env, session_id, &game); + GameHubClient::new(&env, &game_hub).start_game( + &env.current_contract_address(), + &session_id, + &player1, + &player2, + &player1_points, + &player2_points, + ); Ok(()) } - /// Commit a roll for the current game. - /// Both players must roll before the winner can be revealed. - /// - /// # Arguments - /// * `session_id` - The session ID of the game - /// * `player` - Address of the player rolling the dice - pub fn roll(env: Env, session_id: u32, player: Address) -> Result<(), Error> { - player.require_auth(); - - // Get game from temporary storage - let key = DataKey::Game(session_id); + /// Verify one reveal. The second valid reveal prepares but does not settle. + pub fn roll( + env: Env, + session_id: u32, + player: Address, + secret: BytesN<32>, + ) -> Result<(), Error> { let mut game: Game = env .storage() - .temporary() - .get(&key) + .persistent() + .get(&DataKey::Game(session_id)) .ok_or(Error::GameNotFound)?; - // Check game is still active (no winner yet) - if game.winner.is_some() { + if game.phase == Phase::Finalizing { + return Err(Error::FinalizationInProgress); + } + if is_terminal(game.phase) { return Err(Error::GameAlreadyEnded); } - // Update roll commitment for the appropriate player - if player == game.player1 { - if game.player1_rolled { - return Err(Error::AlreadyRolled); - } - game.player1_rolled = true; + let (role, already_revealed, expected_commitment) = if player == game.player1 { + ( + PLAYER1_ROLE, + game.player1_rolled, + game.player1_commitment.clone(), + ) } else if player == game.player2 { - if game.player2_rolled { - return Err(Error::AlreadyRolled); - } - game.player2_rolled = true; + ( + PLAYER2_ROLE, + game.player2_rolled, + game.player2_commitment.clone(), + ) } else { return Err(Error::NotPlayer); + }; + + if already_revealed { + return Err(Error::AlreadyRolled); + } + if game.phase != Phase::Revealing { + return Err(Error::GameAlreadyEnded); + } + if env.ledger().sequence() > game.reveal_deadline { + return Err(Error::RevealDeadlinePassed); } - // Store updated game in temporary storage - env.storage().temporary().set(&key, &game); + player.require_auth(); + let actual_commitment = commitment_digest( + &env, + session_id, + role, + &player, + &game.player1, + &game.player2, + game.player1_points, + game.player2_points, + &secret, + ); + if actual_commitment != expected_commitment { + return Err(Error::WrongSecret); + } + let was_player1_revealed = game.player1_rolled; + let was_player2_revealed = game.player2_rolled; + if role == PLAYER1_ROLE { + game.player1_rolled = true; + } else { + game.player2_rolled = true; + } + + if !was_player1_revealed && !was_player2_revealed { + game.first_secret = Some(secret); + put_game(&env, session_id, &game); + return Ok(()); + } + + let first_secret = game.first_secret.clone().ok_or(Error::GameNotFound)?; + let (player1_secret, player2_secret) = if was_player1_revealed { + (first_secret, secret) + } else { + (secret, first_secret) + }; + let (player1_die1, player1_die2, player2_die1, player2_die2) = + prepare_dice(&env, &player1_secret, &player2_secret); + + game.first_secret = None; + game.player1_die1 = Some(player1_die1); + game.player1_die2 = Some(player1_die2); + game.player2_die1 = Some(player2_die1); + game.player2_die2 = Some(player2_die2); + game.winner = Some( + if player1_die1 + player1_die2 >= player2_die1 + player2_die2 { + game.player1.clone() + } else { + game.player2.clone() + }, + ); + game.phase = Phase::Ready; + put_game(&env, session_id, &game); Ok(()) } - /// Reveal the winner of the game and submit outcome to GameHub. - /// Can only be called after both players have rolled. - /// This generates dice rolls for both players, determines the winner, and ends the session. - /// - /// # Arguments - /// * `session_id` - The session ID of the game - /// - /// # Returns - /// * `Address` - Address of the winning player + /// Permissionlessly settle a Ready game through its snapshotted Hub. pub fn reveal_winner(env: Env, session_id: u32) -> Result { - // Get game from temporary storage - let key = DataKey::Game(session_id); let mut game: Game = env .storage() - .temporary() - .get(&key) + .persistent() + .get(&DataKey::Game(session_id)) .ok_or(Error::GameNotFound)?; - // Check if game already ended (has a winner) - if let Some(winner) = &game.winner { - return Ok(winner.clone()); + if game.phase == Phase::Finalizing { + return Err(Error::FinalizationInProgress); } - - // Check both players have rolled - if !game.player1_rolled || !game.player2_rolled { + if is_terminal(game.phase) { + return Err(Error::GameAlreadyEnded); + } + if game.phase != Phase::Ready { return Err(Error::BothPlayersNotRolled); } - // Generate deterministic dice rolls (1-6) - // Seed components (all deterministic and identical between sim/submit): - // 1. Session ID - unique per game - // 2. Player addresses - both players contribute - // - // Note: We do NOT include ledger sequence or timestamp because those differ - // between simulation and submission, which would cause different winners. - let mut seed_bytes = Bytes::new(&env); - seed_bytes.append(&Bytes::from_array(&env, &session_id.to_be_bytes())); - seed_bytes.append(&game.player1.to_string().to_bytes()); - seed_bytes.append(&game.player2.to_string().to_bytes()); - let base_seed = env.crypto().keccak256(&seed_bytes); - - // Roll dice for both players using unique seeds - let mut roll_seed_bytes = Bytes::new(&env); - roll_seed_bytes.append(&Bytes::from(base_seed.clone())); - roll_seed_bytes.append(&Bytes::from_array(&env, &[1, 1])); - let player1_die1 = roll_die(&env, env.crypto().keccak256(&roll_seed_bytes).into()); - - let mut roll_seed_bytes = Bytes::new(&env); - roll_seed_bytes.append(&Bytes::from(base_seed.clone())); - roll_seed_bytes.append(&Bytes::from_array(&env, &[1, 2])); - let player1_die2 = roll_die(&env, env.crypto().keccak256(&roll_seed_bytes).into()); - - let mut roll_seed_bytes = Bytes::new(&env); - roll_seed_bytes.append(&Bytes::from(base_seed.clone())); - roll_seed_bytes.append(&Bytes::from_array(&env, &[2, 1])); - let player2_die1 = roll_die(&env, env.crypto().keccak256(&roll_seed_bytes).into()); - - let mut roll_seed_bytes = Bytes::new(&env); - roll_seed_bytes.append(&Bytes::from(base_seed.clone())); - roll_seed_bytes.append(&Bytes::from_array(&env, &[2, 2])); - let player2_die2 = roll_die(&env, env.crypto().keccak256(&roll_seed_bytes).into()); + let winner = game.winner.clone().ok_or(Error::BothPlayersNotRolled)?; + let player1_won = winner == game.player1; - game.player1_die1 = Some(player1_die1); - game.player1_die2 = Some(player1_die2); - game.player2_die1 = Some(player2_die1); - game.player2_die2 = Some(player2_die2); + game.phase = Phase::Finalizing; + put_game(&env, session_id, &game); + end_hub_game(&env, &game.game_hub, session_id, player1_won); + game.phase = Phase::Settled; + put_game(&env, session_id, &game); + Ok(winner) + } - // Determine winner (if tie, player1 wins) - let player1_total = player1_die1 + player1_die2; - let player2_total = player2_die1 + player2_die2; - let winner = if player1_total >= player2_total { - game.player1.clone() - } else { - game.player2.clone() - }; + /// Strictly after the inclusive deadline, one reveal wins by forfeit and + /// zero reveals cause neutral cancellation. + pub fn resolve_timeout(env: Env, session_id: u32) -> Result, Error> { + let mut game: Game = env + .storage() + .persistent() + .get(&DataKey::Game(session_id)) + .ok_or(Error::GameNotFound)?; - // Update game with winner (this marks the game as ended) - game.winner = Some(winner.clone()); - env.storage().temporary().set(&key, &game); + if game.phase == Phase::Finalizing { + return Err(Error::FinalizationInProgress); + } + if is_terminal(game.phase) { + return Err(Error::GameAlreadyEnded); + } + if game.phase != Phase::Revealing { + return Err(Error::GameAlreadyEnded); + } + if env.ledger().sequence() <= game.reveal_deadline { + return Err(Error::RevealDeadlineNotReached); + } - // Get GameHub address - let game_hub_addr: Address = env - .storage() - .instance() - .get(&DataKey::GameHubAddress) - .expect("GameHub address not set"); + let winner = match (game.player1_rolled, game.player2_rolled) { + (true, false) => Some(game.player1.clone()), + (false, true) => Some(game.player2.clone()), + (false, false) => None, + // Both valid reveals atomically transition to Ready. + (true, true) => return Err(Error::GameAlreadyEnded), + }; - // Create GameHub client - let game_hub = GameHubClient::new(&env, &game_hub_addr); + game.first_secret = None; + game.winner = winner.clone(); + game.phase = Phase::Finalizing; + put_game(&env, session_id, &game); - // Call GameHub to end the session - // This unlocks points and updates standings - // Event emitted by the Game Hub contract (GameEnded) - let player1_won = winner == game.player1; // true if player1 won, false if player2 won - game_hub.end_game(&session_id, &player1_won); + if let Some(ref revealed_winner) = winner { + let player1_won = *revealed_winner == game.player1; + end_hub_game(&env, &game.game_hub, session_id, player1_won); + game.phase = Phase::Forfeited; + } else { + cancel_hub_game(&env, &game.game_hub, session_id); + game.phase = Phase::Cancelled; + } + put_game(&env, session_id, &game); Ok(winner) } - /// Get game information. - /// - /// # Arguments - /// * `session_id` - The session ID of the game - /// - /// # Returns - /// * `Game` - The game state (includes dice after game ends) pub fn get_game(env: Env, session_id: u32) -> Result { - let key = DataKey::Game(session_id); env.storage() - .temporary() - .get(&key) + .persistent() + .get(&DataKey::Game(session_id)) .ok_or(Error::GameNotFound) } - // ======================================================================== - // Admin Functions - // ======================================================================== - - /// Get the current admin address - /// - /// # Returns - /// * `Address` - The admin address pub fn get_admin(env: Env) -> Address { env.storage() .instance() @@ -371,10 +518,6 @@ impl DiceDuelContract { .expect("Admin not set") } - /// Set a new admin address - /// - /// # Arguments - /// * `new_admin` - The new admin address pub fn set_admin(env: Env, new_admin: Address) { let admin: Address = env .storage() @@ -382,14 +525,12 @@ impl DiceDuelContract { .get(&DataKey::Admin) .expect("Admin not set"); admin.require_auth(); - env.storage().instance().set(&DataKey::Admin, &new_admin); + env.storage() + .instance() + .extend_ttl(GAME_TTL_LEDGERS, GAME_TTL_LEDGERS); } - /// Get the current GameHub contract address - /// - /// # Returns - /// * `Address` - The GameHub contract address pub fn get_hub(env: Env) -> Address { env.storage() .instance() @@ -397,10 +538,6 @@ impl DiceDuelContract { .expect("GameHub address not set") } - /// Set a new GameHub contract address - /// - /// # Arguments - /// * `new_hub` - The new GameHub contract address pub fn set_hub(env: Env, new_hub: Address) { let admin: Address = env .storage() @@ -408,16 +545,14 @@ impl DiceDuelContract { .get(&DataKey::Admin) .expect("Admin not set"); admin.require_auth(); - env.storage() .instance() .set(&DataKey::GameHubAddress, &new_hub); + env.storage() + .instance() + .extend_ttl(GAME_TTL_LEDGERS, GAME_TTL_LEDGERS); } - /// Update the contract WASM hash (upgrade contract) - /// - /// # Arguments - /// * `new_wasm_hash` - The hash of the new WASM binary pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { let admin: Address = env .storage() @@ -425,14 +560,9 @@ impl DiceDuelContract { .get(&DataKey::Admin) .expect("Admin not set"); admin.require_auth(); - env.deployer().update_current_contract_wasm(new_wasm_hash); } } -// ============================================================================ -// Tests -// ============================================================================ - #[cfg(test)] mod test; diff --git a/contracts/dice-duel/src/test.rs b/contracts/dice-duel/src/test.rs index 2d6ef076..3c7cd48f 100644 --- a/contracts/dice-duel/src/test.rs +++ b/contracts/dice-duel/src/test.rs @@ -1,245 +1,756 @@ #![cfg(test)] -// Unit tests for the dice-duel contract using a simple mock GameHub. -// These tests verify game logic independently of the full GameHub system. - -use crate::{DiceDuelContract, DiceDuelContractClient, Error}; -use soroban_sdk::testutils::{Address as _, Ledger as _}; -use soroban_sdk::{contract, contractimpl, Address, BytesN, Env}; - -// ============================================================================ -// Mock GameHub for Unit Testing -// ============================================================================ - -#[contract] -pub struct MockGameHub; - -#[contractimpl] -impl MockGameHub { - pub fn start_game( - _env: Env, - _game_id: Address, - _session_id: u32, - _player1: Address, - _player2: Address, - _player1_points: i128, - _player2_points: i128, - ) { - // Mock implementation - does nothing - } - - pub fn end_game(_env: Env, _session_id: u32, _player1_won: bool) { - // Mock implementation - does nothing - } - - pub fn add_game(_env: Env, _game_address: Address) { - // Mock implementation - does nothing - } +extern crate std; + +use crate::{ + DataKey, DiceDuelContract, DiceDuelContractClient, Error, Phase, GAME_TTL_LEDGERS, + MAX_REVEAL_WINDOW_LEDGERS, PLAYER1_ROLE, PLAYER2_ROLE, +}; +use mock_game_hub::{MockGameHub, MockGameHubClient, MockGameHubError, SessionStatus}; +use soroban_sdk::testutils::{ + Address as _, AuthorizedFunction, AuthorizedInvocation, Deployer as _, Ledger as _, +}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, IntoVal, Symbol, +}; + +const LEDGER: u32 = 100; +const DEADLINE: u32 = 200; +const PLAYER1_POINTS: i128 = 17; +const PLAYER2_POINTS: i128 = 29; + +struct Fixture { + env: Env, + client: DiceDuelContractClient<'static>, + hub: MockGameHubClient<'static>, + hub_address: Address, + admin: Address, + player1: Address, + player2: Address, + secret1: BytesN<32>, + secret2: BytesN<32>, } -// ============================================================================ -// Test Helpers -// ============================================================================ - -fn setup_test() -> ( - Env, - DiceDuelContractClient<'static>, - MockGameHubClient<'static>, - Address, - Address, -) { - let env = Env::default(); - env.mock_all_auths(); - - // Set ledger info for time-based operations +fn ledger(env: &Env, sequence: u32) { env.ledger().set(soroban_sdk::testutils::LedgerInfo { - timestamp: 1441065600, + timestamp: 1_700_000_000, protocol_version: 27, - sequence_number: 100, - network_id: Default::default(), + sequence_number: sequence, + network_id: [0; 32], base_reserve: 10, - min_temp_entry_ttl: u32::MAX / 2, - min_persistent_entry_ttl: u32::MAX / 2, - max_entry_ttl: u32::MAX / 2, + min_temp_entry_ttl: 100_000, + min_persistent_entry_ttl: 100_000, + max_entry_ttl: 1_000_000, }); +} - // Deploy mock GameHub contract - let hub_addr = env.register(MockGameHub, ()); - let game_hub = MockGameHubClient::new(&env, &hub_addr); +fn setup() -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + ledger(&env, LEDGER); - // Create admin address + let hub_address = env.register(MockGameHub, ()); + let hub = MockGameHubClient::new(&env, &hub_address); let admin = Address::generate(&env); + let contract = env.register(DiceDuelContract, (&admin, &hub_address)); + let client = DiceDuelContractClient::new(&env, &contract); + + Fixture { + env: env.clone(), + client, + hub, + hub_address, + admin, + player1: Address::generate(&env), + player2: Address::generate(&env), + secret1: BytesN::from_array(&env, &[0x11; 32]), + secret2: BytesN::from_array(&env, &[0x22; 32]), + } +} - // Deploy dice-duel with admin and GameHub address - let contract_id = env.register(DiceDuelContract, (&admin, &hub_addr)); - let client = DiceDuelContractClient::new(&env, &contract_id); +impl Fixture { + fn commitment( + &self, + session_id: u32, + role: u32, + revealing_player: &Address, + player1_points: i128, + player2_points: i128, + secret: &BytesN<32>, + ) -> BytesN<32> { + self.client.commitment( + &session_id, + &role, + revealing_player, + &self.player1, + &self.player2, + &player1_points, + &player2_points, + secret, + ) + } - // Register dice-duel as a whitelisted game (mock does nothing) - game_hub.add_game(&contract_id); + fn commitments(&self, session_id: u32) -> (BytesN<32>, BytesN<32>) { + ( + self.commitment( + session_id, + PLAYER1_ROLE, + &self.player1, + PLAYER1_POINTS, + PLAYER2_POINTS, + &self.secret1, + ), + self.commitment( + session_id, + PLAYER2_ROLE, + &self.player2, + PLAYER1_POINTS, + PLAYER2_POINTS, + &self.secret2, + ), + ) + } - let player1 = Address::generate(&env); - let player2 = Address::generate(&env); + fn start(&self, session_id: u32) { + self.start_at(session_id, DEADLINE); + } + + fn start_at(&self, session_id: u32, deadline: u32) { + let (commitment1, commitment2) = self.commitments(session_id); + self.start_with(session_id, commitment1, commitment2, deadline); + } - (env, client, game_hub, player1, player2) + fn start_with( + &self, + session_id: u32, + commitment1: BytesN<32>, + commitment2: BytesN<32>, + deadline: u32, + ) { + self.client.start_game( + &session_id, + &self.player1, + &self.player2, + &PLAYER1_POINTS, + &PLAYER2_POINTS, + &commitment1, + &commitment2, + &deadline, + ); + } } -/// Assert that a Result contains a specific dice_duel error -fn assert_dice_duel_error( +fn assert_error( result: &Result, Result>, - expected_error: Error, + expected: Error, ) { - match result { - Err(Ok(actual_error)) => { - assert_eq!( - *actual_error, expected_error, - "Expected error {:?} (code {}), but got {:?} (code {})", - expected_error, expected_error as u32, actual_error, *actual_error as u32 - ); - } - Err(Err(_invoke_error)) => { - panic!( - "Expected contract error {:?} (code {}), but got invocation error", - expected_error, expected_error as u32 - ); - } - Ok(Err(_conv_error)) => { - panic!( - "Expected contract error {:?} (code {}), but got conversion error", - expected_error, expected_error as u32 - ); - } - Ok(Ok(_)) => { - panic!( - "Expected error {:?} (code {}), but operation succeeded", - expected_error, expected_error as u32 - ); - } - } + assert!(matches!(result, Err(Ok(actual)) if *actual == expected)); } -// ============================================================================ -// Basic Game Flow Tests -// ============================================================================ +fn dice(game: &crate::Game) -> [u32; 4] { + [ + game.player1_die1.unwrap(), + game.player1_die2.unwrap(), + game.player2_die1.unwrap(), + game.player2_die2.unwrap(), + ] +} #[test] -fn test_complete_game() { - let (_env, client, _hub, player1, player2) = setup_test(); - - let session_id = 1u32; - let points = 100_0000000; - - // Start game - client.start_game(&session_id, &player1, &player2, &points, &points); +fn complete_two_reveal_then_settle_flow() { + let f = setup(); + f.start(1); + + let initial = f.client.get_game(&1); + assert_eq!(initial.phase, Phase::Revealing); + assert_eq!(initial.game_hub, f.hub_address); + assert!(!initial.player1_rolled && !initial.player2_rolled); + assert_eq!(initial.winner, None); + + f.client.roll(&1, &f.player1, &f.secret1); + let first = f.client.get_game(&1); + assert_eq!(first.phase, Phase::Revealing); + assert!(first.player1_rolled && !first.player2_rolled); + assert_eq!(first.first_secret, Some(f.secret1.clone())); + assert_eq!(first.player1_die1, None); + + f.client.roll(&1, &f.player2, &f.secret2); + let ready = f.client.get_game(&1); + assert_eq!(ready.phase, Phase::Ready); + assert!(ready.player1_rolled && ready.player2_rolled); + assert_eq!(ready.first_secret, None); + for die in dice(&ready) { + assert!((1..=6).contains(&die)); + } + let expected_winner = ready.winner.clone().unwrap(); + + let winner = f.client.reveal_winner(&1); + assert_eq!(winner, expected_winner); + assert_eq!(f.client.get_game(&1).phase, Phase::Settled); + let hub_session = f.hub.session(&1); + assert_eq!(hub_session.status, SessionStatus::Ended); + assert_eq!(hub_session.player1_won, Some(winner == f.player1)); + assert_eq!(hub_session.player1_points, PLAYER1_POINTS); + assert_eq!(hub_session.player2_points, PLAYER2_POINTS); +} - // Verify initial state - let game = client.get_game(&session_id); - assert!(game.winner.is_none()); - assert_eq!(game.player1, player1); - assert_eq!(game.player2, player2); - assert_eq!(game.player1_points, points); - assert_eq!(game.player2_points, points); - assert_eq!(game.player1_rolled, false); - assert_eq!(game.player2_rolled, false); - assert!(game.player1_die1.is_none()); - assert!(game.player1_die2.is_none()); - assert!(game.player2_die1.is_none()); - assert!(game.player2_die2.is_none()); +#[test] +fn late_start_refreshes_dice_and_hub_instance_ttl() { + let fixture = setup(); + + // Establish one session so both native contract instances have the same + // long initial retention, then move close to the end of that lifetime. + fixture.start(70); + let late_ledger = LEDGER + 400_000; + ledger(&fixture.env, late_ledger); + + let dice_before = fixture + .env + .deployer() + .get_contract_instance_ttl(&fixture.client.address); + let hub_before = fixture + .env + .deployer() + .get_contract_instance_ttl(&fixture.hub_address); + assert!(dice_before < GAME_TTL_LEDGERS); + assert!(hub_before < GAME_TTL_LEDGERS); + + fixture.start_at(71, late_ledger + 100); + + let dice_after = fixture + .env + .deployer() + .get_contract_instance_ttl(&fixture.client.address); + let hub_after = fixture + .env + .deployer() + .get_contract_instance_ttl(&fixture.hub_address); + assert!(dice_after >= GAME_TTL_LEDGERS - 1); + assert!(hub_after >= GAME_TTL_LEDGERS - 1); +} - // Players roll - client.roll(&session_id, &player1); - client.roll(&session_id, &player2); +#[test] +fn active_game_state_uses_restorable_persistent_storage() { + let fixture = setup(); + fixture.start(72); + + fixture.env.as_contract(&fixture.client.address, || { + let key = DataKey::Game(72); + assert!(fixture.env.storage().persistent().has(&key)); + assert!(!fixture.env.storage().temporary().has(&key)); + }); +} - // Reveal winner - let winner = client.reveal_winner(&session_id); - assert!(winner == player1 || winner == player2); +#[test] +fn start_auth_binds_every_argument_for_both_players() { + let f = setup(); + let (commitment1, commitment2) = f.commitments(2); + f.start_with(2, commitment1.clone(), commitment2.clone(), DEADLINE); + + let expected_args = ( + 2u32, + f.player1.clone(), + f.player2.clone(), + PLAYER1_POINTS, + PLAYER2_POINTS, + commitment1, + commitment2, + DEADLINE, + ) + .into_val(&f.env); + let expected_function = AuthorizedFunction::Contract(( + f.client.address.clone(), + Symbol::new(&f.env, "start_game"), + expected_args, + )); + let auths = f.env.auths(); + assert_eq!(auths.len(), 2); + assert_eq!(auths[0].0, f.player1); + assert_eq!(auths[1].0, f.player2); + for (_, invocation) in auths { + assert_eq!( + invocation, + AuthorizedInvocation { + function: expected_function.clone(), + sub_invocations: std::vec![], + } + ); + } +} - // Verify dice values and winner stored - let final_game = client.get_game(&session_id); - assert!(final_game.winner.is_some()); - assert_eq!(final_game.winner.unwrap(), winner); +#[test] +fn wrong_secret_does_not_mutate_reveal_state() { + let f = setup(); + f.start(3); + let wrong = BytesN::from_array(&f.env, &[0x99; 32]); + assert_error( + &f.client.try_roll(&3, &f.player1, &wrong), + Error::WrongSecret, + ); + let game = f.client.get_game(&3); + assert_eq!(game.phase, Phase::Revealing); + assert!(!game.player1_rolled && !game.player2_rolled); + assert_eq!(game.first_secret, None); +} - let p1d1 = final_game.player1_die1.unwrap(); - let p1d2 = final_game.player1_die2.unwrap(); - let p2d1 = final_game.player2_die1.unwrap(); - let p2d2 = final_game.player2_die2.unwrap(); +#[derive(Clone, Copy)] +enum CommitmentMismatch { + Session, + Role, + RevealingPlayer, + Stake, + Contract, +} - assert!((1..=6).contains(&p1d1)); - assert!((1..=6).contains(&p1d2)); - assert!((1..=6).contains(&p2d1)); - assert!((1..=6).contains(&p2d2)); +fn assert_context_mismatch_is_rejected(mismatch: CommitmentMismatch) { + let f = setup(); + let session_id = 40; + let wrong_commitment = match mismatch { + CommitmentMismatch::Session => f.commitment( + session_id + 1, + PLAYER1_ROLE, + &f.player1, + PLAYER1_POINTS, + PLAYER2_POINTS, + &f.secret1, + ), + CommitmentMismatch::Role => f.commitment( + session_id, + PLAYER2_ROLE, + &f.player1, + PLAYER1_POINTS, + PLAYER2_POINTS, + &f.secret1, + ), + CommitmentMismatch::RevealingPlayer => f.commitment( + session_id, + PLAYER1_ROLE, + &f.player2, + PLAYER1_POINTS, + PLAYER2_POINTS, + &f.secret1, + ), + CommitmentMismatch::Stake => f.commitment( + session_id, + PLAYER1_ROLE, + &f.player1, + PLAYER1_POINTS + 1, + PLAYER2_POINTS, + &f.secret1, + ), + CommitmentMismatch::Contract => { + let admin = Address::generate(&f.env); + let other_contract = f.env.register(DiceDuelContract, (&admin, &f.hub_address)); + DiceDuelContractClient::new(&f.env, &other_contract).commitment( + &session_id, + &PLAYER1_ROLE, + &f.player1, + &f.player1, + &f.player2, + &PLAYER1_POINTS, + &PLAYER2_POINTS, + &f.secret1, + ) + } + }; + let commitment2 = f.commitment( + session_id, + PLAYER2_ROLE, + &f.player2, + PLAYER1_POINTS, + PLAYER2_POINTS, + &f.secret2, + ); + f.start_with(session_id, wrong_commitment, commitment2, DEADLINE); + assert_error( + &f.client.try_roll(&session_id, &f.player1, &f.secret1), + Error::WrongSecret, + ); +} - let total1 = p1d1 + p1d2; - let total2 = p2d1 + p2d2; - assert!((2..=12).contains(&total1)); - assert!((2..=12).contains(&total2)); +#[test] +fn commitment_is_bound_to_session_role_player_stake_and_contract() { + for mismatch in [ + CommitmentMismatch::Session, + CommitmentMismatch::Role, + CommitmentMismatch::RevealingPlayer, + CommitmentMismatch::Stake, + CommitmentMismatch::Contract, + ] { + assert_context_mismatch_is_rejected(mismatch); + } } #[test] -fn test_cannot_roll_twice() { - let (_env, client, _hub, player1, player2) = setup_test(); +fn duplicate_reveal_is_rejected_without_erasing_first_secret() { + let f = setup(); + f.start(5); + f.client.roll(&5, &f.player1, &f.secret1); + assert_error( + &f.client.try_roll(&5, &f.player1, &f.secret1), + Error::AlreadyRolled, + ); + let game = f.client.get_game(&5); + assert!(game.player1_rolled); + assert_eq!(game.first_secret, Some(f.secret1)); +} - let session_id = 2u32; - let points = 100_0000000; +#[test] +fn reveal_deadline_is_inclusive_and_timeout_is_strictly_after() { + let f = setup(); + f.start(6); + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = DEADLINE); + f.client.roll(&6, &f.player1, &f.secret1); + f.client.roll(&6, &f.player2, &f.secret2); + assert_eq!(f.client.get_game(&6).phase, Phase::Ready); + + let f = setup(); + f.start(7); + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = DEADLINE); + assert_error( + &f.client.try_resolve_timeout(&7), + Error::RevealDeadlineNotReached, + ); + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = DEADLINE + 1); + assert_eq!(f.client.resolve_timeout(&7), None); + + let f = setup(); + f.start(8); + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = DEADLINE + 1); + assert_error( + &f.client.try_roll(&8, &f.player1, &f.secret1), + Error::RevealDeadlinePassed, + ); +} - client.start_game(&session_id, &player1, &player2, &points, &points); +#[test] +fn either_revealer_wins_a_one_sided_forfeit() { + let f = setup(); + f.start(9); + f.client.roll(&9, &f.player1, &f.secret1); + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = DEADLINE + 1); + assert_eq!(f.client.resolve_timeout(&9), Some(f.player1.clone())); + assert_eq!(f.client.get_game(&9).phase, Phase::Forfeited); + assert_eq!(f.hub.session(&9).player1_won, Some(true)); + + let f = setup(); + f.start(10); + f.client.roll(&10, &f.player2, &f.secret2); + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = DEADLINE + 1); + assert_eq!(f.client.resolve_timeout(&10), Some(f.player2.clone())); + assert_eq!(f.client.get_game(&10).phase, Phase::Forfeited); + assert_eq!(f.hub.session(&10).player1_won, Some(false)); +} - client.roll(&session_id, &player1); - let result = client.try_roll(&session_id, &player1); - assert_dice_duel_error(&result, Error::AlreadyRolled); +#[test] +fn zero_reveal_timeout_cancels_with_exact_asymmetric_refunds() { + let f = setup(); + f.start(11); + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = DEADLINE + 1); + assert_eq!(f.client.resolve_timeout(&11), None); + let game = f.client.get_game(&11); + assert_eq!(game.phase, Phase::Cancelled); + assert_eq!(game.winner, None); + + let session = f.hub.session(&11); + assert_eq!(session.status, SessionStatus::Cancelled); + assert_eq!(session.player1_won, None); + assert_eq!(session.player1_refund, PLAYER1_POINTS); + assert_eq!(session.player2_refund, PLAYER2_POINTS); } #[test] -fn test_cannot_reveal_before_both_roll() { - let (_env, client, _hub, player1, player2) = setup_test(); +fn terminal_operations_are_exactly_once() { + let f = setup(); + f.start(12); + f.client.roll(&12, &f.player1, &f.secret1); + f.client.roll(&12, &f.player2, &f.secret2); + f.client.reveal_winner(&12); + assert_error(&f.client.try_reveal_winner(&12), Error::GameAlreadyEnded); + assert_error( + &f.client.try_roll(&12, &f.player1, &f.secret1), + Error::GameAlreadyEnded, + ); + assert_error(&f.client.try_resolve_timeout(&12), Error::GameAlreadyEnded); + + let f = setup(); + f.start(13); + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = DEADLINE + 1); + f.client.resolve_timeout(&13); + assert_error(&f.client.try_resolve_timeout(&13), Error::GameAlreadyEnded); +} - let session_id = 3u32; - let points = 100_0000000; +#[test] +fn active_game_uses_snapshotted_hub_after_admin_rotation() { + let f = setup(); + f.start(14); + let new_hub_address = f.env.register(MockGameHub, ()); + let new_hub = MockGameHubClient::new(&f.env, &new_hub_address); + f.client.set_hub(&new_hub_address); + + f.client.roll(&14, &f.player1, &f.secret1); + f.client.roll(&14, &f.player2, &f.secret2); + f.client.reveal_winner(&14); + assert_eq!(f.hub.session(&14).status, SessionStatus::Ended); + assert!(matches!( + new_hub.try_session(&14), + Err(Ok(MockGameHubError::SessionNotFound)) + )); + + let (commitment1, commitment2) = f.commitments(15); + f.start_with(15, commitment1, commitment2, DEADLINE); + assert_eq!(new_hub.session(&15).status, SessionStatus::Active); + assert_eq!(f.client.get_game(&15).game_hub, new_hub_address); +} - client.start_game(&session_id, &player1, &player2, &points, &points); - client.roll(&session_id, &player1); +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +enum FailingHubError { + ForcedFailure = 1, +} - let result = client.try_reveal_winner(&session_id); - assert_dice_duel_error(&result, Error::BothPlayersNotRolled); +#[contracttype] +#[derive(Clone)] +enum FailingHubKey { + Fail, } -#[test] -fn test_non_player_cannot_roll() { - let (_env, client, _hub, player1, player2) = setup_test(); +#[contract] +struct FailingHub; - let session_id = 4u32; - let points = 100_0000000; +#[contractimpl] +impl FailingHub { + pub fn set_fail(env: Env, fail: bool) { + env.storage().instance().set(&FailingHubKey::Fail, &fail); + } - client.start_game(&session_id, &player1, &player2, &points, &points); + #[allow(clippy::too_many_arguments)] + pub fn start_game( + _env: Env, + _game_id: Address, + _session_id: u32, + _player1: Address, + _player2: Address, + _player1_points: i128, + _player2_points: i128, + ) -> Result<(), FailingHubError> { + Ok(()) + } + + pub fn end_game(env: Env, _session_id: u32, _player1_won: bool) -> Result<(), FailingHubError> { + if env + .storage() + .instance() + .get(&FailingHubKey::Fail) + .unwrap_or(false) + { + Err(FailingHubError::ForcedFailure) + } else { + Ok(()) + } + } - let non_player = Address::generate(&_env); - let result = client.try_roll(&session_id, &non_player); - assert_dice_duel_error(&result, Error::NotPlayer); + pub fn cancel_game(env: Env, _session_id: u32) -> Result<(), FailingHubError> { + if env + .storage() + .instance() + .get(&FailingHubKey::Fail) + .unwrap_or(false) + { + Err(FailingHubError::ForcedFailure) + } else { + Ok(()) + } + } } #[test] -fn test_cannot_roll_after_game_ended() { - let (_env, client, _hub, player1, player2) = setup_test(); - - let session_id = 5u32; - let points = 100_0000000; - - client.start_game(&session_id, &player1, &player2, &points, &points); - client.roll(&session_id, &player1); - client.roll(&session_id, &player2); - client.reveal_winner(&session_id); - - let result = client.try_roll(&session_id, &player1); - assert_dice_duel_error(&result, Error::GameAlreadyEnded); +fn failed_hub_finalization_rolls_back_and_can_be_retried() { + let env = Env::default(); + env.mock_all_auths(); + ledger(&env, LEDGER); + let hub_address = env.register(FailingHub, ()); + let hub = FailingHubClient::new(&env, &hub_address); + hub.set_fail(&false); + let admin = Address::generate(&env); + let contract = env.register(DiceDuelContract, (&admin, &hub_address)); + let client = DiceDuelContractClient::new(&env, &contract); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let secret1 = BytesN::from_array(&env, &[0x31; 32]); + let secret2 = BytesN::from_array(&env, &[0x32; 32]); + let commitment1 = client.commitment( + &16, + &PLAYER1_ROLE, + &player1, + &player1, + &player2, + &PLAYER1_POINTS, + &PLAYER2_POINTS, + &secret1, + ); + let commitment2 = client.commitment( + &16, + &PLAYER2_ROLE, + &player2, + &player1, + &player2, + &PLAYER1_POINTS, + &PLAYER2_POINTS, + &secret2, + ); + client.start_game( + &16, + &player1, + &player2, + &PLAYER1_POINTS, + &PLAYER2_POINTS, + &commitment1, + &commitment2, + &DEADLINE, + ); + client.roll(&16, &player1, &secret1); + client.roll(&16, &player2, &secret2); + let prepared = client.get_game(&16); + assert_eq!(prepared.phase, Phase::Ready); + + hub.set_fail(&true); + assert!(client.try_reveal_winner(&16).is_err()); + assert_eq!(client.get_game(&16), prepared); + + hub.set_fail(&false); + let winner = client.reveal_winner(&16); + assert_eq!(client.get_game(&16).phase, Phase::Settled); + assert!(winner == player1 || winner == player2); } #[test] -fn test_upgrade_function_exists() { - let (_env, client, _hub, _player1, _player2) = setup_test(); +fn dice_ignore_session_reveal_order_and_ledger_moment() { + let f = setup(); + let deadline = 10_000; + f.start_at(1001, deadline); + f.start_at(9001, deadline); + + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = 101); + f.client.roll(&1001, &f.player1, &f.secret1); + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = 503); + f.client.roll(&1001, &f.player2, &f.secret2); + + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = 997); + f.client.roll(&9001, &f.player2, &f.secret2); + f.env + .ledger() + .with_mut(|ledger| ledger.sequence_number = 4_001); + f.client.roll(&9001, &f.player1, &f.secret1); + + let game_a = f.client.get_game(&1001); + let game_b = f.client.get_game(&9001); + assert_eq!(dice(&game_a), dice(&game_b)); + assert_eq!(game_a.winner, game_b.winner); + assert_eq!(f.client.reveal_winner(&1001), f.client.reveal_winner(&9001)); +} - // Verify upgrade function is callable by admin (mocked auth) - let new_wasm_hash = BytesN::from_array(&_env, &[0u8; 32]); - let result = client.try_upgrade(&new_wasm_hash); +#[test] +fn validation_and_legacy_error_codes_are_stable() { + assert_eq!(Error::GameNotFound as u32, 1); + assert_eq!(Error::NotPlayer as u32, 2); + assert_eq!(Error::AlreadyRolled as u32, 3); + assert_eq!(Error::BothPlayersNotRolled as u32, 4); + assert_eq!(Error::GameAlreadyEnded as u32, 5); + + let f = setup(); + assert_error(&f.client.try_get_game(&999), Error::GameNotFound); + let dummy = BytesN::from_array(&f.env, &[0; 32]); + assert_error( + &f.client.try_start_game( + &17, + &f.player1, + &f.player1, + &PLAYER1_POINTS, + &PLAYER2_POINTS, + &dummy, + &dummy, + &DEADLINE, + ), + Error::SamePlayer, + ); + assert_error( + &f.client.try_start_game( + &17, + &f.player1, + &f.player2, + &PLAYER1_POINTS, + &PLAYER2_POINTS, + &dummy, + &dummy, + &LEDGER, + ), + Error::InvalidDeadline, + ); + assert_error( + &f.client.try_start_game( + &18, + &f.player1, + &f.player2, + &PLAYER1_POINTS, + &PLAYER2_POINTS, + &dummy, + &dummy, + &(LEDGER + MAX_REVEAL_WINDOW_LEDGERS + 1), + ), + Error::InvalidDeadline, + ); + + f.start(19); + let (commitment1, commitment2) = f.commitments(19); + assert_error( + &f.client.try_start_game( + &19, + &f.player1, + &f.player2, + &PLAYER1_POINTS, + &PLAYER2_POINTS, + &commitment1, + &commitment2, + &DEADLINE, + ), + Error::GameAlreadyExists, + ); + let outsider = Address::generate(&f.env); + assert_error(&f.client.try_roll(&19, &outsider, &dummy), Error::NotPlayer); + assert_error( + &f.client.try_reveal_winner(&19), + Error::BothPlayersNotRolled, + ); +} - // Should fail (WASM doesn't exist) but confirms function signature is correct - assert!(result.is_err()); +#[test] +fn upgrade_entrypoint_keeps_admin_guarded_shape() { + let f = setup(); + assert_eq!(f.client.get_admin(), f.admin); + let missing_wasm = BytesN::from_array(&f.env, &[0; 32]); + assert!(f.client.try_upgrade(&missing_wasm).is_err()); } diff --git a/contracts/dice-duel/tests/commitment_vector.rs b/contracts/dice-duel/tests/commitment_vector.rs new file mode 100644 index 00000000..7dae05e0 --- /dev/null +++ b/contracts/dice-duel/tests/commitment_vector.rs @@ -0,0 +1,123 @@ +use dice_duel::{DiceDuelContract, DiceDuelContractClient}; +use sha2::{Digest, Sha256}; +use soroban_sdk::{Address, BytesN, Env, TryFromVal}; +use stellar_xdr::{ + AccountId, ContractId, Hash, Limits, PublicKey, ScAddress, ScBytes, ScMap, ScSymbol, ScVal, + Uint256, WriteXdr, +}; + +const EXPECTED_XDR_LEN: usize = 540; +const EXPECTED_SHA256: [u8; 32] = [ + 0x03, 0x7c, 0x35, 0x6c, 0x8a, 0x9b, 0xab, 0xe4, 0x16, 0x2a, 0x94, 0x3e, 0xb1, 0xc2, 0x93, 0xbb, + 0xf2, 0x97, 0xac, 0xe9, 0xb4, 0x27, 0xa3, 0x3e, 0x60, 0x51, 0x2c, 0xa9, 0x25, 0x00, 0xd4, 0x9b, +]; + +fn account(env: &Env, byte: u8) -> Address { + Address::try_from_val( + env, + &ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256( + [byte; 32], + )))), + ) + .unwrap() +} + +fn contract(env: &Env, byte: u8) -> Address { + Address::try_from_val(env, &ScAddress::Contract(ContractId(Hash([byte; 32])))).unwrap() +} + +fn symbol(value: &str) -> ScVal { + ScVal::Symbol(ScSymbol(value.as_bytes().to_vec().try_into().unwrap())) +} + +fn bytes(value: &[u8]) -> ScVal { + ScVal::Bytes(ScBytes(value.to_vec().try_into().unwrap())) +} + +fn address(value: &Address) -> ScVal { + ScVal::Address(value.into()) +} + +#[allow(clippy::too_many_arguments)] +fn independent_commitment_xdr( + contract_id: &Address, + session_id: u32, + role: u32, + revealing_player: &Address, + player1: &Address, + player2: &Address, + player1_points: i128, + player2_points: i128, + secret: [u8; 32], +) -> Vec { + // Construct the raw SCV_MAP with stellar-xdr, independently of the + // contract's Rust type and its `ToXdr` implementation call site. + let map = ScMap::sorted_from(vec![ + ( + symbol("domain"), + bytes(b"stellar-game-studio:commitment:v1"), + ), + (symbol("contract"), address(contract_id)), + (symbol("game"), bytes(b"dice-duel")), + (symbol("session_id"), ScVal::U32(session_id)), + (symbol("role"), ScVal::U32(role)), + (symbol("revealing_player"), address(revealing_player)), + (symbol("player1"), address(player1)), + (symbol("player2"), address(player2)), + (symbol("player1_points"), ScVal::from(player1_points)), + (symbol("player2_points"), ScVal::from(player2_points)), + (symbol("secret"), bytes(&secret)), + ]) + .unwrap(); + ScVal::Map(Some(map)).to_xdr(Limits::none()).unwrap() +} + +fn hex(input: &[u8]) -> String { + input.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[test] +fn canonical_scv_map_xdr_sha256_golden_vector() { + let env = Env::default(); + let admin = account(&env, 0xa1); + let hub = contract(&env, 0xb2); + let dice = contract(&env, 0xc3); + env.register_at(&dice, DiceDuelContract, (&admin, &hub)); + let client = DiceDuelContractClient::new(&env, &dice); + + let player1 = account(&env, 0x11); + let player2 = account(&env, 0x22); + let secret = [0x5a; 32]; + let xdr = independent_commitment_xdr( + &dice, + 0x0102_0304, + 1, + &player1, + &player1, + &player2, + 1_234_567_890_123, + 9_876_543_210_987, + secret, + ); + let digest: [u8; 32] = Sha256::digest(&xdr).into(); + + // These literals make this a real golden vector rather than two dynamic + // implementations that could accidentally drift together. + assert_eq!(xdr.len(), EXPECTED_XDR_LEN); + assert_eq!(digest, EXPECTED_SHA256); + + let actual = client.commitment( + &0x0102_0304, + &1, + &player1, + &player1, + &player2, + &1_234_567_890_123, + &9_876_543_210_987, + &BytesN::from_array(&env, &secret), + ); + + println!("COMMITMENT_VECTOR xdr_len={} xdr={}", xdr.len(), hex(&xdr)); + println!("COMMITMENT_VECTOR sha256={}", hex(&digest)); + assert_eq!(actual.to_array(), digest); +} diff --git a/contracts/dice-duel/tests/wasm_resource.rs b/contracts/dice-duel/tests/wasm_resource.rs new file mode 100644 index 00000000..7aac719b --- /dev/null +++ b/contracts/dice-duel/tests/wasm_resource.rs @@ -0,0 +1,132 @@ +use dice_duel::DiceDuelContractClient; +use soroban_sdk::testutils::{Address as _, EnvTestConfig, Ledger as _}; +use soroban_sdk::{Address, BytesN, Env}; +use std::path::{Path, PathBuf}; + +const MAINNET_CPU_LIMIT: u64 = 400_000_000; +const MAINNET_MEMORY_LIMIT: u64 = 41_943_040; + +fn optimized_wasm(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("target/wasm32v1-none/release") + .join(name) +} + +fn test_env() -> Env { + let env = Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + env.mock_all_auths(); + env.ledger().set(soroban_sdk::testutils::LedgerInfo { + timestamp: 1_700_000_000, + protocol_version: 27, + sequence_number: 100, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 100_000, + min_persistent_entry_ttl: 100_000, + max_entry_ttl: 1_000_000, + }); + env +} + +fn wasm_fixture( + session_id: u32, +) -> ( + Env, + DiceDuelContractClient<'static>, + Address, + Address, + BytesN<32>, + BytesN<32>, + u32, +) { + let dice_path = optimized_wasm("dice_duel.wasm"); + let hub_path = optimized_wasm("mock_game_hub.wasm"); + let dice_wasm = std::fs::read(&dice_path) + .unwrap_or_else(|e| panic!("read optimized {}: {e}", dice_path.display())); + let hub_wasm = std::fs::read(&hub_path) + .unwrap_or_else(|e| panic!("read optimized {}: {e}", hub_path.display())); + + let env = test_env(); + let hub = env.register(hub_wasm.as_slice(), ()); + let admin = Address::generate(&env); + let dice = env.register(dice_wasm.as_slice(), (&admin, &hub)); + let client = DiceDuelContractClient::new(&env, &dice); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let secret1 = BytesN::from_array(&env, &[0x11; 32]); + let secret2 = BytesN::from_array(&env, &[0x22; 32]); + let player1_points = 17i128; + let player2_points = 29i128; + let deadline = 200u32; + let commitment1 = client.commitment( + &session_id, + &1, + &player1, + &player1, + &player2, + &player1_points, + &player2_points, + &secret1, + ); + let commitment2 = client.commitment( + &session_id, + &2, + &player2, + &player1, + &player2, + &player1_points, + &player2_points, + &secret2, + ); + client.start_game( + &session_id, + &player1, + &player2, + &player1_points, + &player2_points, + &commitment1, + &commitment2, + &deadline, + ); + (env, client, player1, player2, secret1, secret2, deadline) +} + +fn report_last_invocation(env: &Env, path: &str) { + // Capture immediately: any getter would replace the last top-level sample. + let resources = env.cost_estimate().resources(); + let budget = env.cost_estimate().budget(); + let cpu = budget.cpu_instruction_cost(); + let memory = budget.memory_bytes_cost(); + println!("WASM_RESOURCE path={path} cpu={cpu} memory={memory} resources={resources:?}"); + assert!(cpu < MAINNET_CPU_LIMIT, "{path} CPU budget exceeded"); + assert!( + memory < MAINNET_MEMORY_LIMIT, + "{path} memory budget exceeded" + ); +} + +#[test] +#[ignore = "requires freshly built optimized dice-duel and mock-game-hub Wasm"] +fn optimized_wasm_terminal_paths_fit_mainnet_invocation_limits() { + let (env, client, player1, player2, secret1, secret2, _) = wasm_fixture(7001); + client.roll(&7001, &player1, &secret1); + client.roll(&7001, &player2, &secret2); + client.reveal_winner(&7001); + report_last_invocation(&env, "normal_settlement"); + + let (env, client, player1, _player2, secret1, _secret2, deadline) = wasm_fixture(7002); + client.roll(&7002, &player1, &secret1); + env.ledger() + .with_mut(|ledger| ledger.sequence_number = deadline + 1); + client.resolve_timeout(&7002); + report_last_invocation(&env, "one_sided_forfeit"); + + let (env, client, _player1, _player2, _secret1, _secret2, deadline) = wasm_fixture(7003); + env.ledger() + .with_mut(|ledger| ledger.sequence_number = deadline + 1); + client.resolve_timeout(&7003); + report_last_invocation(&env, "zero_reveal_cancel"); +} diff --git a/contracts/mock-game-hub/Cargo.toml b/contracts/mock-game-hub/Cargo.toml index c58fa9ac..6d445719 100644 --- a/contracts/mock-game-hub/Cargo.toml +++ b/contracts/mock-game-hub/Cargo.toml @@ -4,7 +4,8 @@ version = "0.1.0" edition = "2021" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] soroban-sdk = { workspace = true } diff --git a/contracts/mock-game-hub/src/lib.rs b/contracts/mock-game-hub/src/lib.rs index ca91a259..3c678e69 100644 --- a/contracts/mock-game-hub/src/lib.rs +++ b/contracts/mock-game-hub/src/lib.rs @@ -1,36 +1,20 @@ #![no_std] -use soroban_sdk::{contract, contractevent, contractimpl, Address, Env}; +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contracttype, Address, Env, +}; -/// Mock Game Hub contract for game studio development -/// -/// This contract provides the same external interface that games expect -/// (start_game, end_game) but does nothing internally. It exists purely -/// for game contracts to compile and integrate during development. -/// -/// # No lockea puntos, y eso importa mas de lo que parece -/// -/// Los tres juegos del repositorio compilan y pasan sus tests contra este -/// contrato. Como `start_game` y `end_game` aceptan cualquier cosa y no -/// mueven nada, **una suite entera en verde no dice nada sobre si el juego -/// maneja bien los puntos**. -/// -/// Lo que un juego puede estar haciendo mal sin que un solo test falle: -/// -/// - Llamar a `start_game` con apuestas que el jugador no tiene. -/// - Llamar a `end_game` dos veces para la misma sesion. -/// - No llamar a `end_game` nunca, dejando los puntos bloqueados para siempre. -/// - Declarar ganador a alguien que no participo. -/// -/// El hub real rechaza todo eso. Este no rechaza nada. +const SESSION_TTL_BUMP: u32 = 518_400; + +/// Stateful Game Hub substitute used to exercise game lifecycle integration. /// -/// Asi que un contrato con los tests en verde contra este mock **todavia no -/// esta probado**: falta ejercitarlo contra el hub de produccion, o contra un -/// mock que si valide. Tenerlo presente antes de desplegar algo que mueva -/// puntos de verdad. +/// This contract records the exact terms accepted at `start_game` and enforces +/// owner authorization and exactly-once terminal settlement. It deliberately +/// does not model balances or claim to reproduce production Hub economics. #[contract] pub struct MockGameHub; +/// Existing start event. Its field names and order are part of the mock ABI. #[contractevent] pub struct GameStarted { pub session_id: u32, @@ -41,23 +25,84 @@ pub struct GameStarted { pub player2_points: i128, } +/// Existing winner-settlement event. Its shape remains unchanged. #[contractevent] pub struct GameEnded { pub session_id: u32, pub player1_won: bool, } +/// Neutral settlement event. No winner is assigned; each participant receives +/// the exact stake supplied for that participant at `start_game`. +#[contractevent] +pub struct GameCancelled { + pub session_id: u32, + pub player1: Address, + pub player2: Address, + pub player1_refund: i128, + pub player2_refund: i128, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum MockGameHubError { + SessionAlreadyExists = 1, + SessionNotFound = 2, + SessionAlreadyTerminal = 3, + SelfPlay = 4, + InvalidStake = 5, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionStatus { + Active, + Ended, + Cancelled, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Session { + pub game_id: Address, + pub player1: Address, + pub player2: Address, + pub player1_points: i128, + pub player2_points: i128, + pub status: SessionStatus, + pub player1_won: Option, + pub player1_refund: i128, + pub player2_refund: i128, +} + +#[contracttype] +#[derive(Clone)] +enum DataKey { + Session(u32), +} + +fn get_session(env: &Env, session_id: u32) -> Result { + env.storage() + .persistent() + .get(&DataKey::Session(session_id)) + .ok_or(MockGameHubError::SessionNotFound) +} + +fn put_session(env: &Env, session_id: u32, session: &Session) { + let key = DataKey::Session(session_id); + env.storage().persistent().set(&key, session); + env.storage() + .persistent() + .extend_ttl(&key, SESSION_TTL_BUMP, SESSION_TTL_BUMP); + env.storage() + .instance() + .extend_ttl(SESSION_TTL_BUMP, SESSION_TTL_BUMP); +} + #[contractimpl] impl MockGameHub { - /// Start a game session - /// - /// # Arguments - /// * `game_id` - Address of the game contract calling this method - /// * `session_id` - Unique identifier for this game session - /// * `player1` - Address of first player - /// * `player2` - Address of second player - /// * `player1_points` - Points amount for player 1 (ignored in mock) - /// * `player2_points` - Points amount for player 2 (ignored in mock) + /// Records and locks the exact terms of a session in the mock lifecycle. pub fn start_game( env: Env, game_id: Address, @@ -66,8 +111,37 @@ impl MockGameHub { player2: Address, player1_points: i128, player2_points: i128, - ) { - // No auth required for mock + ) -> Result<(), MockGameHubError> { + game_id.require_auth(); + + if player1 == player2 { + return Err(MockGameHubError::SelfPlay); + } + if player1_points <= 0 || player2_points <= 0 { + return Err(MockGameHubError::InvalidStake); + } + + let key = DataKey::Session(session_id); + if env.storage().persistent().has(&key) { + return Err(MockGameHubError::SessionAlreadyExists); + } + + put_session( + &env, + session_id, + &Session { + game_id: game_id.clone(), + player1: player1.clone(), + player2: player2.clone(), + player1_points, + player2_points, + status: SessionStatus::Active, + player1_won: None, + player1_refund: 0, + player2_refund: 0, + }, + ); + GameStarted { session_id, game_id, @@ -77,26 +151,59 @@ impl MockGameHub { player2_points, } .publish(&env); - // bump instance ttl if required - env.storage().instance().extend_ttl(17_280, 518_400); + + Ok(()) } - /// End a game session and declare winner - /// - /// # Arguments - /// * `session_id` - The game session being ended - /// * `player1_won` - True if player1 won, false if player2 won - pub fn end_game( - env: Env, - session_id: u32, - player1_won: bool, - ) { - // No auth required for mock + /// Settles an active session with exactly one winner. + pub fn end_game(env: Env, session_id: u32, player1_won: bool) -> Result<(), MockGameHubError> { + let mut session = get_session(&env, session_id)?; + session.game_id.require_auth(); + if session.status != SessionStatus::Active { + return Err(MockGameHubError::SessionAlreadyTerminal); + } + + session.status = SessionStatus::Ended; + session.player1_won = Some(player1_won); + put_session(&env, session_id, &session); + GameEnded { session_id, player1_won, } .publish(&env); + + Ok(()) + } + + /// Cancels an active session neutrally and records exact asymmetric refunds. + pub fn cancel_game(env: Env, session_id: u32) -> Result<(), MockGameHubError> { + let mut session = get_session(&env, session_id)?; + session.game_id.require_auth(); + if session.status != SessionStatus::Active { + return Err(MockGameHubError::SessionAlreadyTerminal); + } + + session.status = SessionStatus::Cancelled; + session.player1_refund = session.player1_points; + session.player2_refund = session.player2_points; + put_session(&env, session_id, &session); + + GameCancelled { + session_id, + player1: session.player1, + player2: session.player2, + player1_refund: session.player1_refund, + player2_refund: session.player2_refund, + } + .publish(&env); + + Ok(()) + } + + /// Returns the stored lifecycle record for focused integration assertions. + pub fn session(env: Env, session_id: u32) -> Result { + get_session(&env, session_id) } } @@ -104,15 +211,143 @@ impl MockGameHub { mod test { use super::*; use soroban_sdk::testutils::Address as _; - #[test] - fn test_start_and_end_game() { + + /// Calls the Hub as a real contract owner. No global auth mocking is used, + /// so `game_id.require_auth()` is exercised by the host authorization tree. + #[contract] + struct GameOwner; + + #[contractimpl] + impl GameOwner { + pub fn start( + env: Env, + hub: Address, + session_id: u32, + player1: Address, + player2: Address, + player1_points: i128, + player2_points: i128, + ) -> Result<(), MockGameHubError> { + MockGameHubClient::new(&env, &hub).start_game( + &env.current_contract_address(), + &session_id, + &player1, + &player2, + &player1_points, + &player2_points, + ); + Ok(()) + } + + pub fn end( + env: Env, + hub: Address, + session_id: u32, + player1_won: bool, + ) -> Result<(), MockGameHubError> { + MockGameHubClient::new(&env, &hub).end_game(&session_id, &player1_won); + Ok(()) + } + + pub fn cancel(env: Env, hub: Address, session_id: u32) -> Result<(), MockGameHubError> { + MockGameHubClient::new(&env, &hub).cancel_game(&session_id); + Ok(()) + } + } + + fn setup() -> (Env, Address, GameOwnerClient<'static>, Address, Address) { let env = Env::default(); - let contract_id = env.register_contract(None, MockGameHub); - let client = MockGameHubClient::new(&env, &contract_id); - let game_id = Address::generate(&env); + let hub = env.register(MockGameHub, ()); + let owner = env.register(GameOwner, ()); + let owner_client = GameOwnerClient::new(&env, &owner); let player1 = Address::generate(&env); let player2 = Address::generate(&env); - client.start_game(&game_id, &1, &player1, &player2, &1000, &1000); - client.end_game(&1, &true); + (env, hub, owner_client, player1, player2) + } + + fn assert_hub_error( + result: &Result, Result>, + expected: MockGameHubError, + ) { + assert!(matches!(result, Err(Ok(actual)) if *actual == expected)); + } + + #[test] + fn owner_auth_and_exact_asymmetric_refunds() { + let (env, hub, owner, player1, player2) = setup(); + owner.start(&hub, &7, &player1, &player2, &11, &29); + + let active = MockGameHubClient::new(&env, &hub).session(&7); + assert_eq!(active.status, SessionStatus::Active); + assert_eq!(active.player1_points, 11); + assert_eq!(active.player2_points, 29); + + owner.cancel(&hub, &7); + let cancelled = MockGameHubClient::new(&env, &hub).session(&7); + assert_eq!(cancelled.status, SessionStatus::Cancelled); + assert_eq!(cancelled.player1_won, None); + assert_eq!(cancelled.player1_refund, 11); + assert_eq!(cancelled.player2_refund, 29); + } + + #[test] + fn duplicate_start_is_typed_error() { + let (_env, hub, owner, player1, player2) = setup(); + owner.start(&hub, &8, &player1, &player2, &3, &5); + let duplicate = owner.try_start(&hub, &8, &player1, &player2, &3, &5); + assert_hub_error(&duplicate, MockGameHubError::SessionAlreadyExists); + } + + #[test] + fn cancellation_and_winner_settlement_are_mutually_exclusive() { + let (env, hub, owner, player1, player2) = setup(); + + owner.start(&hub, &9, &player1, &player2, &7, &13); + owner.cancel(&hub, &9); + let duplicate_cancel = owner.try_cancel(&hub, &9); + assert_hub_error(&duplicate_cancel, MockGameHubError::SessionAlreadyTerminal); + let end_after_cancel = owner.try_end(&hub, &9, &true); + assert_hub_error(&end_after_cancel, MockGameHubError::SessionAlreadyTerminal); + + owner.start(&hub, &10, &player1, &player2, &17, &19); + owner.end(&hub, &10, &false); + let duplicate_end = owner.try_end(&hub, &10, &true); + assert_hub_error(&duplicate_end, MockGameHubError::SessionAlreadyTerminal); + let cancel_after_end = owner.try_cancel(&hub, &10); + assert_hub_error(&cancel_after_end, MockGameHubError::SessionAlreadyTerminal); + + let ended = MockGameHubClient::new(&env, &hub).session(&10); + assert_eq!(ended.status, SessionStatus::Ended); + assert_eq!(ended.player1_won, Some(false)); + assert_eq!(ended.player1_refund, 0); + assert_eq!(ended.player2_refund, 0); + } + + #[test] + fn invalid_terms_are_typed_errors() { + let (_env, hub, owner, player1, player2) = setup(); + + let missing = owner.try_end(&hub, &999, &true); + assert_hub_error(&missing, MockGameHubError::SessionNotFound); + + let self_play = owner.try_start(&hub, &11, &player1, &player1, &1, &1); + assert_hub_error(&self_play, MockGameHubError::SelfPlay); + + let zero_stake = owner.try_start(&hub, &12, &player1, &player2, &1, &0); + assert_hub_error(&zero_stake, MockGameHubError::InvalidStake); + } + + #[test] + fn a_different_contract_cannot_settle_the_session() { + let (env, hub, owner, player1, player2) = setup(); + owner.start(&hub, &13, &player1, &player2, &23, &31); + + let other_owner_id = env.register(GameOwner, ()); + let other_owner = GameOwnerClient::new(&env, &other_owner_id); + let unauthorized = other_owner.try_end(&hub, &13, &true); + assert!(matches!(unauthorized, Err(Err(_)))); + + let session = MockGameHubClient::new(&env, &hub).session(&13); + assert_eq!(session.status, SessionStatus::Active); } } From 88c6a069dbe2088d7c83fb319ffebf12a3a9c1ce Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:47:56 +0700 Subject: [PATCH 2/2] test(mock-game-hub): cover neutral cancellation auth and event --- contracts/mock-game-hub/src/lib.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/contracts/mock-game-hub/src/lib.rs b/contracts/mock-game-hub/src/lib.rs index 3c678e69..08bc57c9 100644 --- a/contracts/mock-game-hub/src/lib.rs +++ b/contracts/mock-game-hub/src/lib.rs @@ -210,7 +210,10 @@ impl MockGameHub { #[cfg(test)] mod test { use super::*; - use soroban_sdk::testutils::Address as _; + use soroban_sdk::{ + testutils::{Address as _, Events as _}, + Event as _, + }; /// Calls the Hub as a real contract owner. No global auth mocking is used, /// so `game_id.require_auth()` is exercised by the host authorization tree. @@ -283,6 +286,18 @@ mod test { assert_eq!(active.player2_points, 29); owner.cancel(&hub, &7); + assert_eq!( + env.events().all().filter_by_contract(&hub), + [GameCancelled { + session_id: 7, + player1, + player2, + player1_refund: 11, + player2_refund: 29, + } + .to_xdr(&env, &hub)], + ); + let cancelled = MockGameHubClient::new(&env, &hub).session(&7); assert_eq!(cancelled.status, SessionStatus::Cancelled); assert_eq!(cancelled.player1_won, None); @@ -306,6 +321,12 @@ mod test { owner.cancel(&hub, &9); let duplicate_cancel = owner.try_cancel(&hub, &9); assert_hub_error(&duplicate_cancel, MockGameHubError::SessionAlreadyTerminal); + assert!(env + .events() + .all() + .filter_by_contract(&hub) + .events() + .is_empty()); let end_after_cancel = owner.try_end(&hub, &9, &true); assert_hub_error(&end_after_cancel, MockGameHubError::SessionAlreadyTerminal); @@ -338,7 +359,7 @@ mod test { } #[test] - fn a_different_contract_cannot_settle_the_session() { + fn a_different_contract_cannot_settle_or_cancel_the_session() { let (env, hub, owner, player1, player2) = setup(); owner.start(&hub, &13, &player1, &player2, &23, &31); @@ -346,6 +367,8 @@ mod test { let other_owner = GameOwnerClient::new(&env, &other_owner_id); let unauthorized = other_owner.try_end(&hub, &13, &true); assert!(matches!(unauthorized, Err(Err(_)))); + let unauthorized = other_owner.try_cancel(&hub, &13); + assert!(matches!(unauthorized, Err(Err(_)))); let session = MockGameHubClient::new(&env, &hub).session(&13); assert_eq!(session.status, SessionStatus::Active);