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 + Solidity

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

TypeDescriptionExample
uint256Unsigned 256-bit integer (0 to 2^256-1)uint256 amount = 1000000;
uint8Unsigned 8-bit integer (0-255)uint8 roll = 42;
int256Signed 256-bit integerint256 diff = -5;
address20-byte Ethereum/QTUM addressaddress owner;
boolTrue or falsebool isOver = true;
bytes32Fixed 32-byte valuebytes32 seedHash;
stringUTF-8 string (dynamic)string name = "Dice";
mappingHash table (key-value store)mapping(uint256 => Bet) bets;

Visibility Modifiers

ModifierWho Can CallWhen to Use
publicAnyone (creates auto-getter for variables)Variables you want readable externally
externalOnly from outside the contractFunctions meant to be called by users
internalThis contract + derived contractsHelper functions
privateThis contract onlyInternal implementation details

Special Variables

VariableDescription
msg.senderAddress of whoever called the function
msg.valueAmount of cryptocurrency sent with the call
block.timestampCurrent block's timestamp
address(this).balanceContract'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;
    }
}
Satoshis, Not Wei

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
);
💡 The 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:

BetWin ChanceMultiplierBet 1 QTUM, Win
Over 5050%1.98x1.98 QTUM
Under 5049%2.02x2.02 QTUM
Over 9010%9.90x9.90 QTUM
Under 109%11.00x11.00 QTUM
Over 298%1.01x1.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);
}
Key Design Decisions

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

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

  1. A running QTUM testnet node (via Docker or direct install)
  2. Solidity compiler (solc) version 0.8.19+
  3. 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:

  1. Checks: Validate all conditions (require statements)
  2. Effects: Update state variables
  3. 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:

  1. Both parties submit hash commitments (can't be changed after)
  2. Both parties reveal their original values
  3. Contract verifies hash(value) == commitment
  4. 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

Integer Overflow (Pre-0.8)

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.

Reentrancy

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.

Using Block Hash as Randomness

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: