Smart Contract Beginners Guide
Learn Solidity fundamentals by building a provably fair dice game contract from scratch. No prior smart contract experience required.
What Is a Smart Contract?
A smart contract is a program that runs on a blockchain. Once deployed, it executes automatically according to its code — no one can change its rules, stop it, or censor it. Think of it as a vending machine for the blockchain:
- You insert coins (send cryptocurrency with your transaction)
- You press a button (call a function)
- The machine follows its programmed rules (contract logic)
- You get your result (state changes, payouts, events)
Smart contracts are written in Solidity, a language designed specifically for blockchain programming. If you know JavaScript, TypeScript, or any C-like language, Solidity will feel familiar.
QTUM supports the same Solidity language and EVM (Ethereum Virtual Machine) as Ethereum. This means you can write smart contracts exactly the same way — the only differences are in deployment (using QTUM's RPC) and units (satoshis instead of wei).
Solidity Basics
Contract Structure
Every Solidity file follows a standard structure:
// 1. License identifier (required since Solidity 0.6.8)
// SPDX-License-Identifier: MIT
// 2. Compiler version
pragma solidity ^0.8.19;
// 3. Contract definition (like a class)
contract MyContract {
// State variables (stored on the blockchain)
address public owner;
uint256 public counter;
// Constructor (runs once at deployment)
constructor() {
owner = msg.sender; // deployer becomes owner
}
// Functions (callable by anyone)
function increment() external {
counter += 1;
}
}
Key Data Types
| Type | Description | Example |
|---|---|---|
uint256 | Unsigned 256-bit integer (0 to 2^256-1) | uint256 amount = 1000000; |
uint8 | Unsigned 8-bit integer (0-255) | uint8 roll = 42; |
int256 | Signed 256-bit integer | int256 diff = -5; |
address | 20-byte Ethereum/QTUM address | address owner; |
bool | True or false | bool isOver = true; |
bytes32 | Fixed 32-byte value | bytes32 seedHash; |
string | UTF-8 string (dynamic) | string name = "Dice"; |
mapping | Hash table (key-value store) | mapping(uint256 => Bet) bets; |
Visibility Modifiers
| Modifier | Who Can Call | When to Use |
|---|---|---|
public | Anyone (creates auto-getter for variables) | Variables you want readable externally |
external | Only from outside the contract | Functions meant to be called by users |
internal | This contract + derived contracts | Helper functions |
private | This contract only | Internal implementation details |
Special Variables
| Variable | Description |
|---|---|
msg.sender | Address of whoever called the function |
msg.value | Amount of cryptocurrency sent with the call |
block.timestamp | Current block's timestamp |
address(this).balance | Contract's current balance |
Building the DiceGame Contract
Now let's walk through the actual DiceGame contract piece by piece. Each section builds on the previous one, and by the end you'll have a complete, deployable contract.
Step 1: Basic Structure & State
Start with the contract shell and define the data it needs to store:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract DiceGame {
// The deployer is the "house" / admin
address public owner;
// Betting limits (in satoshis: 1 QTUM = 100,000,000 satoshis)
uint256 public minBet = 1000000; // 0.01 QTUM
uint256 public maxBet = 1000000000; // 10 QTUM
uint256 public houseEdgeBps = 100; // 1% house edge
// Auto-incrementing bet counter
uint256 public nextBetId = 1;
constructor() {
owner = msg.sender;
}
}
QTUM uses satoshis (1 QTUM = 108 satoshis), while Ethereum uses wei (1 ETH = 1018 wei). This is a critical difference — using the wrong unit scale will cause all your calculations to be off by a factor of 1010.
Step 2: Define the Bet Structure
Solidity lets you define custom data types with struct. Our Bet stores
everything about a single bet:
struct Bet {
address player; // Who placed the bet
uint256 amount; // How much they bet (satoshis)
uint8 target; // Target number (2-98)
bool isOver; // true = "roll over target"
bytes32 playerSeedHash; // Committed hash of player's seed
bytes32 houseSeedHash; // Committed hash of house's seed
uint256 timestamp; // When the bet was placed
bool resolved; // Has the bet been settled?
uint8 roll; // The dice roll result (1-100)
bool won; // Did the player win?
}
// Store all bets in a mapping (like a dictionary/hash map)
mapping(uint256 => Bet) public bets;
// Prevent house seed reuse (each seed can only be used once)
mapping(bytes32 => uint256) public houseSeedHashToBetId;
Step 3: Events
Events are how smart contracts communicate with the outside world. When an event is emitted, it's recorded in the blockchain's transaction log. Off-chain applications (like our backend) can listen for these events.
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
);
indexed Keyword
Marking a parameter as indexed lets you efficiently search for events
by that value. You can have up to 3 indexed parameters per event. In QTUM Dice,
we index betId and player so the backend can quickly find
specific bets or all bets for a player.
Step 4: Access Control
Modifiers let you add conditions that must be true before a function executes.
The onlyOwner modifier restricts certain functions to the contract deployer:
modifier onlyOwner() {
require(msg.sender == owner, "Only owner");
_; // This is where the function body runs
}
// Usage: only the owner can withdraw funds
function withdraw(uint256 amount) external onlyOwner {
require(amount <= address(this).balance, "Insufficient balance");
payable(owner).transfer(amount);
}
Step 5: The Multiplier Formula
The multiplier determines how much a player wins based on their chances. Higher risk (lower win probability) means a higher payout:
function calculateMultiplier(
uint8 target,
bool isOver
) public view returns (uint256 multiplierBps) {
uint256 winChance;
if (isOver) {
// "Roll over 60" = 40% chance (rolls 61-100)
winChance = 100 - target;
} else {
// "Roll under 40" = 39% chance (rolls 1-39)
winChance = target - 1;
}
require(winChance > 0 && winChance < 99, "Invalid target");
// multiplier = (1 - houseEdge) / winProbability
// In basis points: (10000 - 100) * 100 / winChance
multiplierBps = ((10000 - houseEdgeBps) * 100) / winChance;
}
Example multipliers:
| Bet | Win Chance | Multiplier | Bet 1 QTUM, Win |
|---|---|---|---|
| Over 50 | 50% | 1.98x | 1.98 QTUM |
| Under 50 | 49% | 2.02x | 2.02 QTUM |
| Over 90 | 10% | 9.90x | 9.90 QTUM |
| Under 10 | 9% | 11.00x | 11.00 QTUM |
| Over 2 | 98% | 1.01x | 1.01 QTUM |
Step 6: Placing a Bet
This is the main function players call. It's marked payable so it can
receive QTUM along with the function call:
function placeBet(
bytes32 playerSeedHash,
uint8 target,
bool isOver,
bytes32 houseSeedHash
) external payable {
// Validate the bet
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(
houseSeedHashToBetId[houseSeedHash] == 0,
"House seed already used"
);
// Verify the house can afford to pay if player wins
uint256 potentialPayout = calculatePayout(msg.value, target, isOver);
require(
address(this).balance >= potentialPayout,
"House cannot cover bet"
);
// Store the 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
});
// Record seed usage and emit event
houseSeedHashToBetId[houseSeedHash] = betId;
emit BetPlaced(betId, msg.sender, msg.value,
target, isOver, playerSeedHash, houseSeedHash);
}
Coverage check: The contract ensures it has enough QTUM to pay the maximum possible payout before accepting a bet. This prevents a scenario where the player wins but the contract can't pay.
Seed uniqueness: Each house seed hash can only be used once. This prevents replay attacks where someone could reuse a known seed to predict outcomes.
Step 7: Revealing Seeds & Resolving the Bet
This is the core of the provably fair system. Both seeds are verified against their commitments before computing the result:
function revealAndResolve(
uint256 betId,
bytes32 houseSeed,
bytes32 playerSeed
) external {
Bet storage bet = bets[betId];
require(!bet.resolved, "Already resolved");
require(bet.player != address(0), "Bet does not exist");
// Verify seeds match their committed hashes
require(
keccak256(abi.encodePacked(houseSeed)) == bet.houseSeedHash,
"Invalid house seed"
);
require(
keccak256(abi.encodePacked(playerSeed)) == bet.playerSeedHash,
"Invalid player seed"
);
// Generate the roll from both seeds + betId
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;
// Update state
bet.roll = roll;
bet.won = won;
bet.resolved = true;
// Pay the winner
uint256 payout = 0;
if (won) {
payout = calculatePayout(bet.amount, bet.target, bet.isOver);
payable(bet.player).transfer(payout);
}
emit BetResolved(betId, bet.player, roll, won, payout);
}
Step 8: Player Refund Safety Net
What if the backend goes offline and never calls revealAndResolve()?
The player's QTUM would be stuck in the contract forever. To prevent this, we allow
players to claim a refund after a timeout:
function claimRefund(uint256 betId) external {
Bet storage bet = bets[betId];
require(!bet.resolved, "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);
}
The refund mechanism is crucial for a trustless system. Even if the house operator disappears or the backend server goes down permanently, players can always recover their funds after 1 hour. This is a key advantage of smart contracts over traditional centralized systems.
Step 9: Receiving Funds
The contract needs to be able to receive QTUM from the house (bankroll funding):
// Allow receiving QTUM directly (e.g., from house funding)
receive() external payable {}
// Explicit deposit function (for QTUM compatibility)
function deposit() external payable {}
// Check current balance
function getBalance() external view returns (uint256) {
return address(this).balance;
}
Deploying to QTUM Testnet
Prerequisites
- A running QTUM testnet node (via Docker or direct install)
- Solidity compiler (
solc) version 0.8.19+ - A funded QTUM testnet address (get testnet QTUM from a faucet)
Compile the Contract
# Install solc if needed
npm install -g solc
# Compile to bytecode and ABI
solcjs --bin --abi contracts/DiceGame.sol
Deploy via QTUM RPC
# Using qtum-cli (via Docker or local install)
docker exec qtum-testnet-node qtum-cli -testnet \
-rpcuser=qtum -rpcpassword=testpassword \
createcontract <bytecode> 2500000 0.0000004 \
<your-sender-address>
The response includes the contract address. Save it — you'll need it for your backend and frontend configuration.
Fund the Contract
After deployment, send QTUM to the contract address to fund the house bankroll:
docker exec qtum-testnet-node qtum-cli -testnet \
-rpcuser=qtum -rpcpassword=testpassword \
sendtocontract <contract-address> "" 100 2500000 0.0000004
Common Smart Contract Patterns
The Checks-Effects-Interactions Pattern
Always follow this order in your functions to prevent reentrancy attacks:
- Checks: Validate all conditions (
requirestatements) - Effects: Update state variables
- Interactions: Send QTUM or call external contracts
// Correct order (Checks-Effects-Interactions)
function revealAndResolve(...) {
// 1. CHECKS
require(!bet.resolved, "Already resolved");
require(bet.player != address(0), "Not found");
// 2. EFFECTS (update state BEFORE sending money)
bet.resolved = true;
bet.roll = roll;
bet.won = won;
// 3. INTERACTIONS (send money LAST)
if (won) {
payable(bet.player).transfer(payout);
}
}
The Commit-Reveal Pattern
Used whenever you need fairness in a two-party interaction:
- Both parties submit hash commitments (can't be changed after)
- Both parties reveal their original values
- Contract verifies
hash(value) == commitment - Final result computed from both values
The Guard/Modifier Pattern
Use modifiers for reusable access control:
modifier onlyOwner() {
require(msg.sender == owner, "Only owner");
_;
}
modifier betExists(uint256 betId) {
require(bets[betId].player != address(0), "Not found");
_;
}
Common Mistakes to Avoid
Before Solidity 0.8, integers could overflow silently.
uint8(255) + 1 would equal 0. Solidity 0.8+ has built-in
overflow checks, which is why we use pragma solidity ^0.8.19.
Always update state variables before sending QTUM. If you send QTUM first and the recipient is a contract, it could call back into your function before the state is updated.
block.timestamp, block.number, and blockhash()
are NOT secure sources of randomness. Miners can manipulate these values. Always use
commit-reveal or oracle-based randomness.
Next Steps
Now that you understand the smart contract, learn how to connect it to a frontend through MetaMask Snaps: