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.
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
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;
}
}
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
| Vulnerability | Mitigation | Details |
|---|---|---|
| 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
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.
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.