Smart Contract
The SpinGame.sol Solidity contract — complete source code with line-by-line explanation of the on-chain game logic.
Complete Source Code
// 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, incrementsnextSpinId
revealAndResolve(uint256 spinId, bytes32 houseSeed, bytes32 playerSeed)
Called by the owner (backend) to reveal both seeds and resolve the spin. The contract:
- Verifies that
keccak256(houseSeed)matches the storedhouseSeedHash - Verifies that
keccak256(playerSeed)matches the storedplayerSeedHash - Computes the segment:
uint8(uint256(keccak256(houseSeed, playerSeed, spinId)) % 8) - Looks up the prize for that segment
- Releases the locked funds (2 QTUM)
- 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
| Function | Returns | Description |
|---|---|---|
getSpin(uint256) | Full spin details | Player, seed hashes, timestamp, result |
getPrize(uint8) | uint256 | Prize for a specific segment |
getAllPrizes() | uint256[8] | All 8 prize amounts |
getBalance() | uint256 | Contract balance in satoshis |
Prize Structure
| Index | Color | Payout (satoshis) | Payout (QTUM) | Player Net |
|---|---|---|---|---|
| 0 | Red | 40,000,000 | 0.4 | -0.6 |
| 1 | Orange | 70,000,000 | 0.7 | -0.3 |
| 2 | Green | 110,000,000 | 1.1 | +0.1 |
| 3 | Blue | 130,000,000 | 1.3 | +0.3 |
| 4 | Red | 40,000,000 | 0.4 | -0.6 |
| 5 | Orange | 70,000,000 | 0.7 | -0.3 |
| 6 | Green | 110,000,000 | 1.1 | +0.1 |
| 7 | Gold | 200,000,000 | 2.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
| Event | Parameters | When 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 |