Smart Contract

The SpinGame.sol Solidity contract — complete source code with line-by-line explanation of the on-chain game logic.

Contract Address (Testnet)

33531da3d5d75c0dc1cd7e852c07c364d6072c8e

Complete Source Code

contracts/SpinGame.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

/**
 * @title SpinGame
 * @notice Provably fair spin wheel game using commit-reveal scheme
 * @dev Players spin for 1 QTUM, landing on 1 of 8 segments with fixed payouts
 *      Expected value: 0.9625 QTUM per spin (house edge ~3.75%)
 */
contract SpinGame {
    address public owner;

    uint256 public constant SPIN_COST = 100000000; // 1 QTUM in satoshis
    uint8 public constant NUM_SEGMENTS = 8;

    uint256[8] public prizes = [
        40000000,   // 0: 0.4 QTUM (Red)
        70000000,   // 1: 0.7 QTUM (Orange)
        110000000,  // 2: 1.1 QTUM (Green)
        130000000,  // 3: 1.3 QTUM (Blue)
        40000000,   // 4: 0.4 QTUM (Red)
        70000000,   // 5: 0.7 QTUM (Orange)
        110000000,  // 6: 1.1 QTUM (Green)
        200000000   // 7: 2.0 QTUM (Gold)
    ];

    struct Spin {
        address player;
        bytes32 playerSeedHash;
        bytes32 houseSeedHash;
        uint256 timestamp;
        bool resolved;
        uint8 segment;
        uint256 payout;
    }

    mapping(uint256 => Spin) public spins;
    mapping(bytes32 => uint256) public houseSeedHashToSpinId;
    uint256 public nextSpinId = 1;

    // Track funds locked for pending spins (max payout = 2 QTUM per spin)
    uint256 public lockedFunds;

    // Reentrancy guard
    bool private _locked;

    event SpinPlaced(
        uint256 indexed spinId,
        address indexed player,
        bytes32 playerSeedHash,
        bytes32 houseSeedHash
    );

    event SpinResolved(
        uint256 indexed spinId,
        address indexed player,
        uint8 segment,
        uint256 payout
    );

    event SpinRefunded(
        uint256 indexed spinId,
        address indexed player,
        uint256 amount
    );

    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner");
        _;
    }

    modifier nonReentrant() {
        require(!_locked, "Reentrancy locked");
        _locked = true;
        _;
        _locked = false;
    }

    constructor() {
        owner = msg.sender;
    }

    receive() external payable {}

    function deposit() external payable {}

    function withdraw(uint256 amount) external onlyOwner {
        require(amount <= address(this).balance - lockedFunds,
            "Would drain locked funds");
        payable(owner).transfer(amount);
    }

    // Two-step ownership transfer
    address public pendingOwner;

    function transferOwnership(address newOwner) external onlyOwner {
        require(newOwner != address(0), "Zero address");
        pendingOwner = newOwner;
    }

    function acceptOwnership() external {
        require(msg.sender == pendingOwner, "Not pending owner");
        owner = pendingOwner;
        pendingOwner = address(0);
    }

    function placeSpin(
        bytes32 playerSeedHash,
        bytes32 houseSeedHash
    ) external payable {
        require(msg.value == SPIN_COST, "Must send exactly 1 QTUM");
        require(address(this).balance - msg.value - lockedFunds >= 200000000,
            "House cannot cover max payout");

        lockedFunds += 200000000;

        uint256 spinId = nextSpinId++;

        spins[spinId] = Spin({
            player: msg.sender,
            playerSeedHash: playerSeedHash,
            houseSeedHash: houseSeedHash,
            timestamp: block.timestamp,
            resolved: false,
            segment: 0,
            payout: 0
        });

        houseSeedHashToSpinId[houseSeedHash] = spinId;

        emit SpinPlaced(spinId, msg.sender, playerSeedHash, houseSeedHash);
    }

    function revealAndResolve(
        uint256 spinId,
        bytes32 houseSeed,
        bytes32 playerSeed
    ) external onlyOwner nonReentrant {
        Spin storage spin = spins[spinId];
        require(!spin.resolved, "Spin already resolved");
        require(spin.player != address(0), "Spin does not exist");

        require(keccak256(abi.encodePacked(houseSeed)) == spin.houseSeedHash,
            "Invalid house seed");
        require(keccak256(abi.encodePacked(playerSeed)) == spin.playerSeedHash,
            "Invalid player seed");

        bytes32 combined = keccak256(
            abi.encodePacked(houseSeed, playerSeed, spinId)
        );
        uint8 segment = uint8(uint256(combined) % NUM_SEGMENTS);

        uint256 payout = prizes[segment];

        spin.segment = segment;
        spin.payout = payout;
        spin.resolved = true;

        lockedFunds -= 200000000;

        require(address(this).balance >= payout, "Insufficient balance");
        payable(spin.player).transfer(payout);

        emit SpinResolved(spinId, spin.player, segment, payout);
    }

    function claimRefund(uint256 spinId) external nonReentrant {
        Spin storage spin = spins[spinId];
        require(!spin.resolved, "Spin already resolved");
        require(spin.player == msg.sender, "Not your spin");
        require(block.timestamp > spin.timestamp + 1 hours,
            "Timeout not reached");

        spin.resolved = true;
        spin.payout = SPIN_COST;

        lockedFunds -= 200000000;

        payable(spin.player).transfer(SPIN_COST);

        emit SpinRefunded(spinId, spin.player, SPIN_COST);
    }

    function getSpin(uint256 spinId) external view returns (
        address player, bytes32 playerSeedHash, bytes32 houseSeedHash,
        uint256 timestamp, bool resolved, uint8 segment, uint256 payout
    ) {
        Spin memory spin = spins[spinId];
        return (spin.player, spin.playerSeedHash, spin.houseSeedHash,
                spin.timestamp, spin.resolved, spin.segment, spin.payout);
    }

    function getPrize(uint8 segment) external view returns (uint256) {
        require(segment < NUM_SEGMENTS, "Invalid segment");
        return prizes[segment];
    }

    function getAllPrizes() external view returns (uint256[8] memory) {
        return prizes;
    }

    function setPrizes(uint256[8] calldata _prizes) external onlyOwner {
        prizes = _prizes;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

Function-by-Function Breakdown

placeSpin(bytes32 playerSeedHash, bytes32 houseSeedHash)

Called by the player to place a spin. Must send exactly 1 QTUM (100000000 satoshis). The contract checks that the house has enough unreserved funds to cover the maximum payout (2 QTUM), then locks those funds and emits a SpinPlaced event.

  • Payable: Yes — must include exactly 1 QTUM
  • Access: Anyone
  • Side effects: Locks 2 QTUM in lockedFunds, increments nextSpinId

revealAndResolve(uint256 spinId, bytes32 houseSeed, bytes32 playerSeed)

Called by the owner (backend) to reveal both seeds and resolve the spin. The contract:

  1. Verifies that keccak256(houseSeed) matches the stored houseSeedHash
  2. Verifies that keccak256(playerSeed) matches the stored playerSeedHash
  3. Computes the segment: uint8(uint256(keccak256(houseSeed, playerSeed, spinId)) % 8)
  4. Looks up the prize for that segment
  5. Releases the locked funds (2 QTUM)
  6. Transfers the prize to the player
  • Access: Owner only (onlyOwner modifier)
  • Guard: nonReentrant modifier

claimRefund(uint256 spinId)

Safety valve for players. If the backend fails to resolve a spin within 1 hour, the player can call this to get their 1 QTUM back. This prevents funds from being permanently locked.

  • Access: Only the player who placed the spin
  • Condition: block.timestamp > spin.timestamp + 1 hours
  • Guard: nonReentrant modifier

deposit() / receive()

Both allow funding the contract's bankroll. deposit() is an explicit function, while receive() catches plain QTUM transfers.

withdraw(uint256 amount)

Owner-only function to withdraw from the bankroll. The contract enforces that withdrawals cannot touch locked funds — only the unreserved balance is available.

transferOwnership(address) / acceptOwnership()

Two-step ownership transfer for safety. The current owner proposes a new owner, who must then call acceptOwnership() to confirm. This prevents accidental transfers to wrong addresses.

View Functions

FunctionReturnsDescription
getSpin(uint256)Full spin detailsPlayer, seed hashes, timestamp, result
getPrize(uint8)uint256Prize for a specific segment
getAllPrizes()uint256[8]All 8 prize amounts
getBalance()uint256Contract balance in satoshis

Prize Structure

IndexColorPayout (satoshis)Payout (QTUM)Player Net
0Red40,000,0000.4-0.6
1Orange70,000,0000.7-0.3
2Green110,000,0001.1+0.1
3Blue130,000,0001.3+0.3
4Red40,000,0000.4-0.6
5Orange70,000,0000.7-0.3
6Green110,000,0001.1+0.1
7Gold200,000,0002.0+1.0

Security Features

Reentrancy Guard

The nonReentrant modifier on revealAndResolve and claimRefund prevents recursive calls during fund transfers.

Locked Funds Tracking

lockedFunds reserves 2 QTUM (max payout) per pending spin. Withdrawals and new spins respect this reservation.

Owner-Only Reveal

Only the contract owner can call revealAndResolve(). This prevents unauthorized parties from resolving spins with known seeds.

Two-Step Ownership

transferOwnership + acceptOwnership pattern prevents accidental ownership transfers to wrong addresses.

Commit-Reveal Integrity

The contract enforces the commit-reveal scheme through hash verification:

// Both seeds must match their committed hashes
require(keccak256(abi.encodePacked(houseSeed)) == spin.houseSeedHash);
require(keccak256(abi.encodePacked(playerSeed)) == spin.playerSeedHash);

// Result computed deterministically from both seeds + spinId
bytes32 combined = keccak256(abi.encodePacked(houseSeed, playerSeed, spinId));
uint8 segment = uint8(uint256(combined) % NUM_SEGMENTS);

Since both hashes are committed on-chain before either seed is revealed, neither party can change their seed to influence the outcome.

Events

EventParametersWhen Emitted
SpinPlaced (uint256 indexed spinId, address indexed player, bytes32 playerSeedHash, bytes32 houseSeedHash) When placeSpin() succeeds
SpinResolved (uint256 indexed spinId, address indexed player, uint8 segment, uint256 payout) When revealAndResolve() succeeds
SpinRefunded (uint256 indexed spinId, address indexed player, uint256 amount) When claimRefund() succeeds