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:
| Tool | Version | Purpose |
|---|---|---|
| Node.js | 18+ | Backend server and build tools |
| npm | 9+ | Package management |
| Docker | 20+ | Running QTUM testnet node locally |
| Git | 2+ | Version control |
| MetaMask | Latest | Wallet 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
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
- Open the app and click "Connect Wallet"
- Approve the snap installation in MetaMask
- Get testnet QTUM from a faucet to your address
- Set a target (e.g., 50), choose Over/Under
- Enter a bet amount and click "Place Bet"
- Approve the transaction in MetaMask
- Wait for on-chain confirmation and resolution
- 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:
- A running QTUM testnet node (or mainnet)
- Node.js installed for the backend
- Nginx for reverse proxy and serving the frontend
- 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
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.