Source Code

The complete Solidity smart contracts that power QTUM Dice. Audited for security with reentrancy guards, input validation, and access control.

Security Features

Both contracts implement the following security measures:

Reentrancy Guard

All functions that transfer funds use a noReentrant modifier that prevents recursive calls.

Access Control

revealAndResolve restricted to contract owner. Only the authorized backend can resolve bets.

Input Validation

Zero-hash rejection, range checks on targets, bet limit enforcement, and seed uniqueness verification.

Coverage Checks

Contract verifies it has sufficient balance to pay maximum potential winnings before accepting any bet.

Checks-Effects-Interactions Pattern

All state-changing functions follow the CEI pattern: validate inputs first (checks), update state variables (effects), then transfer funds last (interactions). Combined with the reentrancy guard, this provides defense-in-depth against reentrancy attacks.

Smart Contracts

DiceGame.sol

Provably fair dice betting game. Players bet on rolling over or under a target (1-100). Uses commit-reveal scheme for verifiable randomness.
Deployed at: 9840131735f93a4118d2452c9cd61f1233550762 (QTUM Testnet)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

/**
 * @title DiceGame
 * @notice Provably fair dice betting game using commit-reveal scheme
 * @dev Players bet on rolling over or under a target number (1-100)
 */
contract DiceGame {
    address public owner;
    // QTUM uses satoshis (1 QTUM = 10^8 satoshis), not wei (1 ETH = 10^18 wei)
    uint256 public minBet = 1000000;    // 0.01 QTUM = 1,000,000 satoshis
    uint256 public maxBet = 1000000000; // 10 QTUM = 1,000,000,000 satoshis
    uint256 public houseEdgeBps = 100;  // 1% house edge (basis points)
    bool private _locked;               // Reentrancy guard

    struct Bet {
        address player;
        uint256 amount;
        uint8   target;
        bool    isOver;
        bytes32 playerSeedHash;
        bytes32 houseSeedHash;
        uint256 timestamp;
        bool    resolved;
        uint8   roll;
        bool    won;
    }

    mapping(uint256 => Bet) public bets;
    mapping(bytes32 => uint256) public houseSeedHashToBetId;
    uint256 public nextBetId = 1;

    event BetPlaced(
        uint256 indexed betId,
        address indexed player,
        uint256 amount,
        uint8   target,
        bool    isOver,
        bytes32 playerSeedHash,
        bytes32 houseSeedHash
    );

    event BetResolved(
        uint256 indexed betId,
        address indexed player,
        uint8   roll,
        bool    won,
        uint256 payout
    );

    event HouseSeedCommitted(bytes32 indexed houseSeedHash, uint256 indexed betId);

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

    modifier noReentrant() {
        require(!_locked, "Reentrant call");
        _locked = true;
        _;
        _locked = false;
    }

    constructor() {
        owner = msg.sender;
    }

    /// @notice Fund the contract (house bankroll)
    receive() external payable {}

    /// @notice Deposit funds to contract (for QTUM compatibility)
    function deposit() external payable {}

    /// @notice Withdraw house funds
    function withdraw(uint256 amount) external onlyOwner {
        require(amount <= address(this).balance, "Insufficient balance");
        payable(owner).transfer(amount);
    }

    /// @notice Calculate payout multiplier in basis points (10000 = 1x)
    function calculateMultiplier(uint8 target, bool isOver)
        public view returns (uint256 multiplierBps)
    {
        uint256 winChance;
        if (isOver) {
            winChance = 100 - target;
        } else {
            winChance = target - 1;
        }
        require(winChance > 0 && winChance < 99, "Invalid target");
        multiplierBps = ((10000 - houseEdgeBps) * 100) / winChance;
    }

    /// @notice Calculate potential payout for a bet
    function calculatePayout(uint256 amount, uint8 target, bool isOver)
        public view returns (uint256 payout)
    {
        uint256 multiplierBps = calculateMultiplier(target, isOver);
        payout = (amount * multiplierBps) / 10000;
    }

    /// @notice Place a bet with player's seed commitment
    function placeBet(
        bytes32 playerSeedHash,
        uint8   target,
        bool    isOver,
        bytes32 houseSeedHash
    ) external payable {
        require(msg.value >= minBet, "Bet too small");
        require(msg.value <= maxBet, "Bet too large");
        require(target >= 2 && target <= 98, "Target must be 2-98");
        require(playerSeedHash != bytes32(0), "Invalid player seed hash");
        require(houseSeedHash != bytes32(0), "Invalid house seed hash");
        require(
            houseSeedHashToBetId[houseSeedHash] == 0,
            "House seed already used"
        );

        uint256 potentialPayout = calculatePayout(msg.value, target, isOver);
        require(
            address(this).balance >= potentialPayout,
            "House cannot cover bet"
        );

        uint256 betId = nextBetId++;

        bets[betId] = Bet({
            player: msg.sender,
            amount: msg.value,
            target: target,
            isOver: isOver,
            playerSeedHash: playerSeedHash,
            houseSeedHash: houseSeedHash,
            timestamp: block.timestamp,
            resolved: false,
            roll: 0,
            won: false
        });

        houseSeedHashToBetId[houseSeedHash] = betId;

        emit BetPlaced(
            betId, msg.sender, msg.value,
            target, isOver, playerSeedHash, houseSeedHash
        );
    }

    /// @notice Resolve a bet by revealing both seeds (owner only)
    function revealAndResolve(
        uint256 betId,
        bytes32 houseSeed,
        bytes32 playerSeed
    ) external onlyOwner noReentrant {
        Bet storage bet = bets[betId];
        require(!bet.resolved, "Bet already resolved");
        require(bet.player != address(0), "Bet does not exist");

        // Verify seeds match commitments
        require(
            keccak256(abi.encodePacked(houseSeed)) == bet.houseSeedHash,
            "Invalid house seed"
        );
        require(
            keccak256(abi.encodePacked(playerSeed)) == bet.playerSeedHash,
            "Invalid player seed"
        );

        // Generate roll from combined seeds
        bytes32 combined = keccak256(
            abi.encodePacked(houseSeed, playerSeed, betId)
        );
        uint8 roll = uint8(uint256(combined) % 100) + 1;

        // Determine winner
        bool won = bet.isOver ? roll > bet.target : roll < bet.target;

        // Effects before interactions (CEI pattern)
        bet.roll = roll;
        bet.won = won;
        bet.resolved = true;

        // Interactions last
        uint256 payout = 0;
        if (won) {
            payout = calculatePayout(bet.amount, bet.target, bet.isOver);
            require(
                address(this).balance >= payout,
                "Insufficient balance for payout"
            );
            payable(bet.player).transfer(payout);
        }

        emit BetResolved(betId, bet.player, roll, won, payout);
    }

    /// @notice Player claims refund if bet not resolved within 1 hour
    function claimRefund(uint256 betId) external noReentrant {
        Bet storage bet = bets[betId];
        require(!bet.resolved, "Bet already resolved");
        require(bet.player == msg.sender, "Not your bet");
        require(
            block.timestamp > bet.timestamp + 1 hours,
            "Timeout not reached"
        );

        bet.resolved = true;
        payable(bet.player).transfer(bet.amount);

        emit BetResolved(betId, bet.player, 0, false, bet.amount);
    }

    /// @notice Get bet details
    function getBet(uint256 betId) external view returns (
        address player, uint256 amount, uint8 target, bool isOver,
        bytes32 playerSeedHash, bytes32 houseSeedHash,
        uint256 timestamp, bool resolved, uint8 roll, bool won
    ) {
        Bet memory bet = bets[betId];
        return (
            bet.player, bet.amount, bet.target, bet.isOver,
            bet.playerSeedHash, bet.houseSeedHash,
            bet.timestamp, bet.resolved, bet.roll, bet.won
        );
    }

    /// @notice Update betting limits
    function setLimits(uint256 _minBet, uint256 _maxBet) external onlyOwner {
        require(_minBet > 0, "Min bet must be > 0");
        require(_maxBet >= _minBet, "Max must be >= min");
        minBet = _minBet;
        maxBet = _maxBet;
    }

    /// @notice Update house edge (in basis points, 100 = 1%)
    function setHouseEdge(uint256 _houseEdgeBps) external onlyOwner {
        require(_houseEdgeBps <= 1000, "Max 10% edge");
        houseEdgeBps = _houseEdgeBps;
    }

    /// @notice Get contract balance
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
LuckySpin.sol

Provably fair wheel spin game. Players pay 1 QTUM to spin a wheel with 8 prize segments ranging from 0.4 QTUM to 2.0 QTUM (jackpot). House edge: ~3.75%.
Deployed at: c3749a3963ca03737b4be0cc2bf2ac0a5ef9b97b (QTUM Testnet)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

/**
 * @title LuckySpin
 * @notice Provably fair wheel spin game using commit-reveal scheme
 * @dev Players pay exactly 1 QTUM to spin a wheel with 8 prize segments
 *
 * Prize Structure (8 segments):
 * Segment 0, 4: 0.4 QTUM  (25% total) - Red
 * Segment 1, 5: 0.7 QTUM  (25% total) - Orange
 * Segment 2, 6: 1.1 QTUM  (25% total) - Green
 * Segment 3:    1.3 QTUM  (12.5%)     - Blue
 * Segment 7:    2.0 QTUM  (12.5%)     - Gold (JACKPOT)
 *
 * House Edge: ~3.75% (EV = 0.9625 QTUM per spin)
 */
contract LuckySpin {
    address public owner;
    bool private _locked; // Reentrancy guard

    uint256 public constant SPIN_COST = 100000000; // 1 QTUM

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

    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;

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

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

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

    modifier noReentrant() {
        require(!_locked, "Reentrant call");
        _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, "Insufficient balance");
        payable(owner).transfer(amount);
    }

    /// @notice Place a spin with seed commitments
    function placeSpin(
        bytes32 playerSeedHash,
        bytes32 houseSeedHash
    ) external payable {
        require(msg.value == SPIN_COST, "Must send exactly 1 QTUM");
        require(playerSeedHash != bytes32(0), "Invalid player seed hash");
        require(houseSeedHash != bytes32(0), "Invalid house seed hash");
        require(
            houseSeedHashToSpinId[houseSeedHash] == 0,
            "House seed already used"
        );
        require(
            address(this).balance >= prizes[7],
            "House cannot cover jackpot"
        );

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

    /// @notice Resolve a spin by revealing both seeds (owner only)
    function revealAndResolve(
        uint256 spinId,
        bytes32 houseSeed,
        bytes32 playerSeed
    ) external onlyOwner noReentrant {
        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) % 8);
        uint256 payout = prizes[segment];

        // Effects before interactions
        spin.segment = segment;
        spin.payout = payout;
        spin.resolved = true;

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

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

    /// @notice Player claims refund if spin not resolved within 1 hour
    function claimRefund(uint256 spinId) external noReentrant {
        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;
        payable(spin.player).transfer(SPIN_COST);

        emit SpinResolved(spinId, spin.player, 0, SPIN_COST);
    }

    /// @notice Get spin details
    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 < 8, "Invalid segment");
        return prizes[segment];
    }

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

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

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

Security Audit Notes

Protections Applied

VulnerabilityMitigationDetails
Reentrancy noReentrant modifier + CEI pattern All fund-transferring functions use the mutex lock. State is updated before any transfer() call.
Unauthorized resolution onlyOwner on revealAndResolve Only the contract owner (backend) can resolve bets. Prevents front-running by third parties who may intercept seeds.
Zero-hash seeds bytes32(0) rejection Both player and house seed hashes are validated as non-zero before accepting a bet.
Seed replay houseSeedHashToBetId mapping Each house seed hash can only be used once. Prevents replaying known seeds.
Insolvency Coverage check in placeBet Contract verifies it holds enough balance to pay the maximum possible payout before accepting any bet.
Stuck funds claimRefund with 1-hour timeout Players can recover their bet if the house fails to resolve within 1 hour. No trust required.
Integer overflow Solidity 0.8.19+ built-in checks All arithmetic operations automatically revert on overflow/underflow.
Invalid bet limits setLimits validation Minimum bet must be > 0 and maximum must be >= minimum.

Known Limitations

Owner Trust Assumptions

The withdraw function allows the owner to withdraw funds that may be needed to cover pending bets. In a production system, you should track total pending obligations and prevent withdrawals that would make the contract insolvent. For this demo, the house operator is trusted not to rug-pull active bets. The claimRefund mechanism provides a safety net if the house becomes unresponsive.

Prize Mutability (LuckySpin)

The setPrizes function allows the owner to change prize amounts while spins may be pending. Since prizes are read at resolution time (not placement), this could affect pending spins. A production system should snapshot prizes at spin time or freeze prize changes while spins are pending.