Snap Integration Guide
Connect your dApp to the QTUM blockchain through MetaMask and the qtum-wallet extension. Send transactions, read balances, and interact with smart contracts.
What Are MetaMask Snaps?
MetaMask Snaps are plugins that extend MetaMask's capabilities beyond Ethereum. They allow developers to add support for new blockchains, custom transaction types, and novel wallet features — all running securely within MetaMask's sandbox.
For QTUM development, we use qtum-wallet — a Snap that adds QTUM
blockchain support to MetaMask. This lets users manage QTUM addresses, sign
transactions, and interact with QTUM smart contracts through the familiar MetaMask
interface.
qtum-wallet is published in the
official MetaMask Snaps directory,
so it works with regular MetaMask — no developer builds required.
How Snaps Work
Setting Up
1. Install MetaMask
Download MetaMask from the Chrome Web Store or Firefox Add-ons. The qtum-wallet
is listed in the official MetaMask Snaps directory, so regular MetaMask is all you need.
2. Install Dependencies
Your frontend project needs ethers.js for ABI encoding and hashing:
npm install ethers
3. Define the Snap ID
The Snap ID tells MetaMask which Snap to use:
// For the published snap
const SNAP_ID = 'npm:qtum-wallet';
// For local development (if running your own snap)
// const SNAP_ID = 'local:http://localhost:8081';
Connecting the Wallet
Check if MetaMask is Available
function checkMetaMask(): boolean {
return typeof window !== 'undefined'
&& !!window.ethereum?.isMetaMask;
}
Check if the Snap is Installed
async function checkSnapInstalled(): Promise<boolean> {
if (!window.ethereum) return false;
try {
const snaps = await window.ethereum.request({
method: 'wallet_getSnaps',
});
return !!snaps[SNAP_ID];
} catch {
return false;
}
}
Install the Snap
If the snap isn't installed, prompt the user to install it. MetaMask will show a permissions dialog:
async function installSnap(): Promise<boolean> {
try {
await window.ethereum.request({
method: 'wallet_requestSnaps',
params: {
[SNAP_ID]: {},
},
});
return true;
} catch (err) {
console.error('Failed to install snap:', err);
return false;
}
}
Invoke Snap Methods
All communication with the snap goes through wallet_invokeSnap. Here's a
generic helper:
async function invokeSnap<T>(
method: string,
params?: unknown
): Promise<T | null> {
const result = await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: SNAP_ID,
request: {
method,
params,
},
},
});
return result as T;
}
Get the Wallet Address
async function getAddress(): Promise<string | null> {
// Returns a QTUM base58 address (e.g., "qHefBTey9Etd...")
const address = await invokeSnap<string>(
'wallet_getAddress'
);
return address;
}
Full Connect Flow
Here's the complete connection flow that checks for MetaMask, installs the snap if needed, and retrieves the wallet address:
async function connect(): Promise<string | null> {
// 1. Check MetaMask is installed
if (!checkMetaMask()) {
throw new Error('Please install MetaMask');
}
// 2. Install snap if needed
const isInstalled = await checkSnapInstalled();
if (!isInstalled) {
const installed = await installSnap();
if (!installed) return null;
}
// 3. Get the QTUM address
const address = await getAddress();
// 4. Get the balance
const balance = await invokeSnap<string>(
'eth_getBalance',
[address, 'latest']
);
return address;
}
QTUM Address Conversion
QTUM uses base58-encoded addresses (like Bitcoin), but smart contracts use hex addresses (like Ethereum). You need to convert between the two formats.
Base58 (user-facing): qHefBTey9EtDo87fPc5eqdxapCzt9ovBz5 — starts with q (testnet) or Q (mainnet)
Hex (contract-facing): 0x7926223070547d2d7526304675aba4bc23423567 — 20-byte address with 0x prefix
Here's the conversion function used in QTUM Dice:
const BASE58_ALPHABET =
'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function qtumAddressToHex(qtumAddress: string): string {
// Decode base58 to a big number
let num = BigInt(0);
for (const char of qtumAddress) {
const index = BASE58_ALPHABET.indexOf(char);
if (index === -1) throw new Error('Invalid base58');
num = num * BigInt(58) + BigInt(index);
}
// Convert to hex (25 bytes: 1 version + 20 address + 4 checksum)
let hex = num.toString(16).padStart(50, '0');
// Extract the 20-byte address (skip version byte and checksum)
return '0x' + hex.slice(2, 42);
}
Sending Transactions
Simple QTUM Transfer
async function sendTransaction(
to: string, // recipient address
amount: string // amount in QTUM
): Promise<string | null> {
const toHex = qtumAddressToHex(to);
const fromHex = qtumAddressToHex(myAddress);
// Convert QTUM to satoshis (8 decimals)
const satoshis = ethers.parseUnits(amount, 8);
const txHash = await invokeSnap<string>(
'eth_sendTransaction',
[{
from: fromHex,
to: toHex,
value: '0x' + satoshis.toString(16),
gas: '0x' + (100000).toString(16),
}]
);
return txHash;
}
Contract Transaction (With Data)
To call a smart contract function, you need to encode the function call as hex data and send it along with the transaction:
async function sendContractTransaction(
contractAddress: string, // hex address without 0x
data: string, // encoded function call
valueQtum: string = '0' // QTUM to send with call
): Promise<string | null> {
const toHex = '0x' + contractAddress;
const dataHex = data.startsWith('0x') ? data : '0x' + data;
const satoshis = ethers.parseUnits(valueQtum, 8);
const txHash = await invokeSnap<string>(
'eth_sendTransaction',
[{
from: myAddressHex,
to: toHex,
value: '0x' + satoshis.toString(16),
data: dataHex,
gas: '0x' + (500000).toString(16),
}]
);
return txHash;
}
ABI Encoding
To call a smart contract function, you need to encode the function name and parameters into a hex string. This is called ABI (Application Binary Interface) encoding.
Function Selectors
The first 4 bytes of the encoded data identify which function to call. The selector is computed as the first 4 bytes of the keccak256 hash of the function signature:
import { keccak256, toUtf8Bytes, AbiCoder } from 'ethers';
function getFunctionSelector(signature: string): string {
// keccak256("placeBet(bytes32,uint8,bool,bytes32)")
// = "0x1a2b3c4d..." → take first 10 chars (4 bytes + "0x")
return keccak256(toUtf8Bytes(signature)).slice(0, 10);
}
Encoding Parameters
After the selector, append the ABI-encoded parameters:
const abiCoder = new AbiCoder();
function encodePlaceBet(
playerSeedHash: string,
target: number,
isOver: boolean,
houseSeedHash: string
): string {
// Get the function selector
const selector = getFunctionSelector(
'placeBet(bytes32,uint8,bool,bytes32)'
);
// Encode the parameters
const params = abiCoder.encode(
['bytes32', 'uint8', 'bool', 'bytes32'],
[playerSeedHash, target, isOver, houseSeedHash]
);
// Combine: selector + params (remove "0x" from params)
return selector + params.slice(2);
}
// Example usage:
const data = encodePlaceBet(
'0xabc...', // playerSeedHash
50, // target
true, // isOver
'0xdef...' // houseSeedHash
);
// Send the transaction with QTUM value
await sendContractTransaction(CONTRACT_ADDRESS, data, '0.1');
React Hook Pattern
In QTUM Dice, wallet functionality is encapsulated in the useQtumWallet
React hook. This provides a clean API for components:
import { useQtumWallet } from './hooks/useQtumWallet';
function MyComponent() {
const {
isConnected,
address,
balance,
connect,
disconnect,
sendContractTransaction,
formatBalance,
} = useQtumWallet();
return (
<div>
{isConnected ? (
<>
<p>Connected: {address}</p>
<p>Balance: {formatBalance(balance)} QTUM</p>
<button onClick={disconnect}>Disconnect</button>
</>
) : (
<button onClick={connect}>Connect Wallet</button>
)}
</div>
);
}
Hook State Management
The hook manages a state object that tracks the wallet connection lifecycle:
| State | Type | Description |
|---|---|---|
isMetaMaskInstalled | boolean | Is MetaMask present? |
isSnapInstalled | boolean | Is qtum-wallet installed? |
isConnected | boolean | Is wallet connected and ready? |
address | string | null | QTUM base58 address |
addressHex | string | null | Hex address (for contracts) |
balance | string | null | Balance in hex wei format |
error | string | null | Last error message |
isLoading | boolean | Operation in progress? |
QTUM-Specific Gotchas
Transaction ID Format
The snap returns transaction IDs with a 0x prefix, but the QTUM block
explorer expects IDs without it:
function formatTxidForExplorer(txid: string): string {
return txid.startsWith('0x') ? txid.slice(2) : txid;
}
// Link to explorer
const explorerUrl =
`https://testnet.qtum.info/tx/${formatTxidForExplorer(txHash)}`;
Gas Price Limits
QTUM has a maximum gas price of 0.000001 QTUM per gas unit. Use 0.0000004
for a safe default. Setting the gas price too high will cause transactions to be rejected.
Unit Conversion
Remember that QTUM uses 8 decimal places (like Bitcoin), not 18 (like Ethereum):
// QTUM: 8 decimals (satoshis)
const satoshis = ethers.parseUnits('1.5', 8); // 150000000
// Ethereum: 18 decimals (wei) - DO NOT USE for QTUM
// const wei = ethers.parseEther('1.5'); // Wrong!
The snap's eth_getBalance returns the balance in a hex format that uses
18 decimals (wei-like). For display purposes, you can use ethers.formatEther()
to convert it. However, when sending transactions, use 8 decimals
(parseUnits(amount, 8)).
Complete Working Example
Here's a minimal but complete example that connects to QTUM via MetaMask and places a bet on the DiceGame contract:
import { ethers } from 'ethers';
const SNAP_ID = 'npm:qtum-wallet';
const CONTRACT = '9840131735f93a4118d2452c9cd61f1233550762';
// 1. Connect wallet
await window.ethereum.request({
method: 'wallet_requestSnaps',
params: { [SNAP_ID]: {} },
});
const address = await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: SNAP_ID,
request: { method: 'wallet_getAddress' },
},
});
// 2. Generate player seed
const seed = new Uint8Array(32);
crypto.getRandomValues(seed);
const playerSeed = '0x' + [...seed]
.map(b => b.toString(16).padStart(2, '0')).join('');
const playerSeedHash = ethers.keccak256(playerSeed);
// 3. Get house seed from backend
const res = await fetch('/api/game/house-seed');
const { data: { houseSeedHash } } = await res.json();
// 4. Encode placeBet call
const selector = ethers.keccak256(
ethers.toUtf8Bytes('placeBet(bytes32,uint8,bool,bytes32)')
).slice(0, 10);
const abiCoder = new ethers.AbiCoder();
const params = abiCoder.encode(
['bytes32', 'uint8', 'bool', 'bytes32'],
[playerSeedHash, 50, true, houseSeedHash]
);
const calldata = selector + params.slice(2);
// 5. Send transaction (bet 0.1 QTUM, roll over 50)
const txHash = await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: SNAP_ID,
request: {
method: 'eth_sendTransaction',
params: [{
from: qtumAddressToHex(address),
to: '0x' + CONTRACT,
value: '0x' + ethers.parseUnits('0.1', 8).toString(16),
data: '0x' + calldata,
gas: '0x' + (500000).toString(16),
}],
},
},
});
console.log('Transaction:', txHash);