Build Your Own QTUM dApp

A complete walkthrough to set up your development environment, write a smart contract, build a backend and frontend, and deploy your own dApp on QTUM testnet.

Prerequisites

Before you start, make sure you have these installed:

ToolVersionPurpose
Node.js18+Backend server and build tools
npm9+Package management
Docker20+Running QTUM testnet node locally
Git2+Version control
MetaMaskLatestWallet for testing (browser extension)

Step 1: Run a QTUM Testnet Node

Your dApp needs to communicate with the QTUM blockchain. The easiest way to get started is running a testnet node locally using Docker.

Create docker-compose.yml

version: '3'
services:
  qtum-testnet:
    image: qtum/qtum:latest
    container_name: qtum-testnet-node
    ports:
      - "13889:13889"  # Testnet RPC port
    volumes:
      - ./qtum-data:/root/.qtum
      - ./qtum.conf:/root/.qtum/qtum.conf
    command: qtumd -testnet -txindex=1

Create qtum.conf

testnet=1
server=1
rpcuser=qtum
rpcpassword=testpassword
rpcallowip=0.0.0.0/0
rpcbind=0.0.0.0
txindex=1
logevents=1
logevents=1

The logevents=1 setting is critical. Without it, the searchlogs RPC call won't work, and your backend won't be able to detect contract events.

Start the Node

# Start the QTUM testnet node
docker-compose up -d

# Check that it's running and syncing
docker exec qtum-testnet-node qtum-cli -testnet \
  -rpcuser=qtum -rpcpassword=testpassword \
  getblockchaininfo

# Watch the logs
docker logs -f qtum-testnet-node

Wait for the node to sync. On testnet, this usually takes a few hours. You can verify by comparing the block height with a QTUM testnet explorer.

Get Testnet QTUM

You'll need testnet QTUM to deploy contracts and test transactions. Generate a new address and fund it:

# Generate a new address
docker exec qtum-testnet-node qtum-cli -testnet \
  -rpcuser=qtum -rpcpassword=testpassword \
  getnewaddress

# Output: qHefBTey9EtDo87fPc5eqdxapCzt9ovBz5

# Get testnet QTUM from a faucet (use the address above)
# Visit: http://testnet-faucet.qtum.info/

Step 2: Write & Deploy the Smart Contract

See the Smart Contract Beginners Guide for a detailed walkthrough of the contract code. Here we'll focus on the deployment process.

Project Setup

mkdir my-qtum-dapp
cd my-qtum-dapp
mkdir contracts backend frontend

# Create the contract file
touch contracts/DiceGame.sol

Compile

# Install Solidity compiler
npm install -g solc

# Compile (generates .bin and .abi files)
solcjs --bin --abi --optimize contracts/DiceGame.sol \
  -o contracts/build/

Deploy to QTUM Testnet

# Read the compiled bytecode
BYTECODE=$(cat contracts/build/DiceGame.bin)

# Deploy the contract
docker exec qtum-testnet-node qtum-cli -testnet \
  -rpcuser=qtum -rpcpassword=testpassword \
  createcontract $BYTECODE 2500000 0.0000004 \
  qHefBTey9EtDo87fPc5eqdxapCzt9ovBz5

# Response includes the contract address - save it!
# "address": "9840131735f93a4118d2452c9cd61f1233550762"

Fund the Contract

# Send 100 QTUM to the contract as house bankroll
docker exec qtum-testnet-node qtum-cli -testnet \
  -rpcuser=qtum -rpcpassword=testpassword \
  sendtocontract 9840131735f93a4118d2452c9cd61f1233550762 \
  "" 100 2500000 0.0000004

Step 3: Build the Backend

Initialize the Project

cd backend
npm init -y
npm install express cors helmet ws ethers
npm install -D typescript tsx @types/node @types/express \
  @types/cors @types/ws

Create tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  }
}

QTUM RPC Client

The backbone of the backend — a client that talks to the QTUM node via JSON-RPC:

// backend/src/qtum.ts
import crypto from 'crypto';
import { keccak256 } from 'ethers';

export class QtumClient {
  private url: string;

  constructor() {
    const host = process.env.QTUM_HOST || '127.0.0.1';
    const port = process.env.QTUM_PORT || '13889';
    const user = process.env.QTUM_USER || 'qtum';
    const pass = process.env.QTUM_PASSWORD || 'testpassword';
    this.url = `http://${user}:${pass}@${host}:${port}`;
  }

  async rpc(method: string, params: unknown[] = []) {
    const res = await fetch(this.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        jsonrpc: '1.0',
        method,
        params,
      }),
    });
    const data = await res.json();
    if (data.error) throw new Error(data.error.message);
    return data.result;
  }

  // Read-only contract call
  async callContract(address: string, data: string) {
    return this.rpc('callcontract', [address, data]);
  }

  // State-changing contract call
  async sendToContract(
    address: string, data: string,
    amount: number, gasLimit: number,
    gasPrice: number, sender: string
  ) {
    return this.rpc('sendtocontract', [
      address, data, amount, gasLimit, gasPrice, sender
    ]);
  }

  // Search for contract events in block range
  async searchlogs(fromBlock: number, toBlock: number,
                   addresses: string[]) {
    return this.rpc('searchlogs', [
      fromBlock, toBlock, { addresses }
    ]);
  }
}

// Utility functions
export function generateSeed(): string {
  return '0x' + crypto.randomBytes(32).toString('hex');
}

export function hashSeed(seed: string): string {
  return keccak256(seed);
}

Express Server

// backend/src/index.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { DiceGameManager } from './game';
import { ContractMonitor } from './contractMonitor';
import { createGameRouter } from './routes/game';

const app = express();
app.use(cors());
app.use(helmet());
app.use(express.json());

// Initialize game manager
const gameManager = new DiceGameManager(
  process.env.CONTRACT_ADDRESS!,
  process.env.OWNER_ADDRESS!
);

// Start blockchain event monitor
const monitor = new ContractMonitor(gameManager);
monitor.start();  // polls every 5 seconds

// Mount API routes
app.use('/api/game', createGameRouter(gameManager));

// Health check
app.get('/health', (req, res) =>
  res.json({ status: 'ok' })
);

app.listen(3002, () => {
  console.log('Backend running on port 3002');
});

API Routes

The key endpoints your frontend needs. See the full API Reference for details.

// backend/src/routes/game.ts
import { Router } from 'express';

export function createGameRouter(gameManager) {
  const router = Router();

  // Generate a house seed hash for a new bet
  router.get('/house-seed', (req, res) => {
    const { houseSeedHash, expiresAt } =
      gameManager.generateHouseSeed();
    res.json({ success: true, data: { houseSeedHash, expiresAt } });
  });

  // Accept player seed and trigger reveal
  router.post('/submit-player-seed', async (req, res) => {
    const { betId, playerSeed } = req.body;
    gameManager.storePlayerSeed(betId, playerSeed);
    const result = await gameManager.revealBet(betId);
    res.json({
      success: true,
      data: { betId, revealTxid: result?.txid }
    });
  });

  // Get bet status (pending or resolved)
  router.get('/bet/:betId', (req, res) => {
    const betId = parseInt(req.params.betId);
    const resolved = gameManager.getResolvedBet(betId);
    if (resolved) {
      res.json({ success: true, data: { ...resolved, status: 'resolved' } });
    } else {
      const pending = gameManager.getPendingReveal(betId);
      res.json({ success: true, data: { ...pending, status: 'pending' } });
    }
  });

  return router;
}

Environment Configuration

Create a .env file in the backend directory:

PORT=3002
QTUM_HOST=127.0.0.1
QTUM_PORT=13889
QTUM_USER=qtum
QTUM_PASSWORD=testpassword
CONTRACT_ADDRESS=9840131735f93a4118d2452c9cd61f1233550762
OWNER_ADDRESS=qHefBTey9EtDo87fPc5eqdxapCzt9ovBz5
ALLOWED_ORIGINS=http://localhost:5173

Step 4: Build the Frontend

Initialize with Vite

cd frontend
npm create vite@latest . -- --template react-ts
npm install
npm install ethers react-router-dom

Wallet Integration

Create the useQtumWallet hook. See the Snap Integration Guide for the full implementation. The key function is sendContractTransaction.

Contract Interaction

Create the useContract hook for ABI encoding:

// frontend/src/hooks/useContract.ts
import { AbiCoder, keccak256, toUtf8Bytes } from 'ethers';

const CONTRACT_ADDRESS = '9aac22498a...';
const abiCoder = new AbiCoder();

function selector(sig: string): string {
  return keccak256(toUtf8Bytes(sig)).slice(0, 10);
}

export function useContract({ sendContractTransaction }) {
  const placeBet = async (
    playerSeedHash, target, isOver, houseSeedHash, amount
  ) => {
    const sel = selector(
      'placeBet(bytes32,uint8,bool,bytes32)'
    );
    const params = abiCoder.encode(
      ['bytes32', 'uint8', 'bool', 'bytes32'],
      [playerSeedHash, target, isOver, houseSeedHash]
    );
    const data = sel + params.slice(2);
    return sendContractTransaction(CONTRACT_ADDRESS, data, amount);
  };

  return { placeBet };
}

Betting Flow Component

The core betting logic lives in a BetForm component that orchestrates the entire commit-reveal flow:

// Simplified betting flow
async function handlePlaceBet() {
  // 1. Generate player seed
  const seed = generateSeed();
  const seedHash = hashSeed(seed);

  // 2. Get house seed hash from backend
  const { houseSeedHash } = await api.getHouseSeed();

  // 3. Place bet on-chain via MetaMask Snap
  const txHash = await contract.placeBet(
    seedHash, target, isOver, houseSeedHash, betAmount
  );

  // 4. Wait for on-chain confirmation (poll backend)
  let betId;
  while (!betId) {
    const result = await api.findBetByHouseSeedHash(houseSeedHash);
    if (result) betId = result.betId;
    else await sleep(3000);
  }

  // 5. Submit player seed to trigger reveal
  await api.submitPlayerSeed(betId, seed);

  // 6. Poll for resolution
  while (true) {
    const bet = await api.getBetDetails(betId);
    if (bet.status === 'resolved') {
      // Show result: bet.roll, bet.won, bet.payout
      break;
    }
    await sleep(3000);
  }
}

Step 5: Local Development

Run Everything

Open three terminal windows:

# Terminal 1: QTUM node
docker-compose up

# Terminal 2: Backend
cd backend && npm run dev

# Terminal 3: Frontend
cd frontend && npm run dev

Visit http://localhost:5173 in a browser with MetaMask installed.

Testing the Flow

  1. Open the app and click "Connect Wallet"
  2. Approve the snap installation in MetaMask
  3. Get testnet QTUM from a faucet to your address
  4. Set a target (e.g., 50), choose Over/Under
  5. Enter a bet amount and click "Place Bet"
  6. Approve the transaction in MetaMask
  7. Wait for on-chain confirmation and resolution
  8. See the result (roll number, win/loss, payout)

Step 6: Deploy to Production

Build for Production

# Build frontend
cd frontend && npm run build
# Output goes to frontend/dist/

# Build backend
cd backend && npm run build
# Output goes to backend/dist/

Server Setup

On your production server, you need:

  1. A running QTUM testnet node (or mainnet)
  2. Node.js installed for the backend
  3. Nginx for reverse proxy and serving the frontend
  4. A systemd service for the backend

Nginx Configuration

server {
    listen 443 ssl;
    server_name yourdomain.com;

    # SSL certificates (use Let's Encrypt)
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # Serve frontend static files
    location / {
        root /home/user/my-dapp/frontend/dist;
        try_files $uri $uri/ /index.html;
    }

    # Proxy API requests to backend
    location /api/ {
        proxy_pass http://localhost:3002;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    # WebSocket support
    location /ws {
        proxy_pass http://localhost:3002;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Systemd Service

# /etc/systemd/system/my-dapp-backend.service
[Unit]
Description=My QTUM dApp Backend
After=network.target

[Service]
Type=simple
User=user
WorkingDirectory=/home/user/my-dapp/backend
ExecStart=/usr/bin/node dist/index.js
Restart=always
EnvironmentFile=/home/user/my-dapp/backend/.env

[Install]
WantedBy=multi-user.target
# Enable and start the service
sudo systemctl enable my-dapp-backend
sudo systemctl start my-dapp-backend

# Check status
sudo systemctl status my-dapp-backend
journalctl -u my-dapp-backend -f

Tips for Your Own dApp

Adapt, Don't Copy

QTUM Dice is a reference implementation. You don't need a dice game — the same patterns apply to any dApp: NFT marketplaces, DeFi protocols, voting systems, prediction markets, and more. The commit-reveal scheme, snap integration, and backend architecture are all reusable.

  • Start on testnet. Always develop on testnet first. Testnet QTUM is free.
  • Keep contracts simple. Every line of smart contract code is public and immutable. Minimize complexity.
  • Test edge cases. What happens if the backend crashes mid-bet? What if the user closes their browser? Build in recovery mechanisms.
  • Monitor your contract. Use the contract monitor pattern to stay in sync with on-chain state.
  • Handle errors gracefully. Blockchain transactions can fail for many reasons. Show clear error messages.
  • Consider gas costs. QTUM gas is cheap, but contract calls still cost gas. Optimize where possible.