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);