Snap Integration Guide

Complete guide to connecting your dApp to the QTUM blockchain through MetaMask and the qtum-wallet-connector library.

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 in a secure sandboxed environment inside MetaMask.

For QTUM, the qtum-wallet enables MetaMask to sign QTUM transactions, manage QTUM addresses, and interact with QTUM smart contracts — just as it natively does for Ethereum.

Available in the Official MetaMask Snaps Directory

qtum-wallet is published in the official MetaMask Snaps directory, so it works with regular MetaMask — no developer builds required.

┌───────────────────────────────────────────────┐ │ MetaMask │ │ │ │ ┌─────────────────┐ ┌──────────────────┐ │ │ │ Ethereum Engine │ │ Snap Sandbox │ │ │ │ (native) │ │ ┌──────────────┐ │ │ │ └─────────────────┘ │ │ qtum-wallet │ │ │ │ │ │ npm:qtum-wallet│ │ │ │ Your dApp │ └──────────────┘ │ │ │ communicates via └──────────────────┘ │ │ wallet_invokeSnap │ └───────────────────────────────────────────────┘

Installing qtum-wallet-connector

The qtum-wallet-connector library provides a TypeScript-friendly wrapper around the raw MetaMask Snap RPC calls. Install it alongside ethers (used for ABI encoding and unit conversion):

Terminal
npm install [email protected] ethers

Then import the key classes and functions:

TypeScript
import {
  QtumWallet,
  RPCMethods,
  fromBase58Check,
  toBase58Check,
  isMetamaskInstalled,
} from 'qtum-wallet-connector';

import { ethers } from 'ethers';

The QtumWallet Class

QtumWallet is the main interface for communicating with the qtum-wallet. It wraps all Snap RPC calls behind familiar method names. Here's what each method does:

new QtumWallet()

Creates a new wallet instance. Defaults to snap ID "npm:qtum-wallet". This does not connect to anything — it just prepares the RPC interface.

const wallet = new QtumWallet();
// Internally stores snapId = "npm:qtum-wallet"

isMetamaskInstalled()

A standalone function (not a method on QtumWallet) that checks if MetaMask is installed in the browser. Returns true if window.ethereum exists and is MetaMask.

const hasMetaMask = await isMetamaskInstalled();
if (!hasMetaMask) {
  alert('Please install MetaMask');
}

wallet.isInstalled()

Checks whether the qtum-wallet is currently installed and active in MetaMask. Queries the MetaMask Snap registry.

const snapReady = await wallet.isInstalled();

wallet.enable()

Installs and/or connects the qtum-wallet. This triggers a MetaMask popup asking the user to approve the snap installation. After approval, the snap is active and ready to sign transactions.

await wallet.enable();
// Snap is now installed and connected

wallet.request({ method: RPCMethods.WalletGetAddress })

Gets the user's QTUM address in base58 format (starts with q or Q on testnet). This is the human-readable QTUM address.

const address = await wallet.request({
  method: RPCMethods.WalletGetAddress,
}) as string;
// e.g., "qUbxboqjBRp96j3La8D1RYkyqx5uQbJPoW"

fromBase58Check(address)

Converts a QTUM base58 address to its hex representation. Smart contracts and the snap's transaction interface work with hex addresses.

import { fromBase58Check } from 'qtum-wallet-connector';

const hex = fromBase58Check('qUbxboqjBRp96j3La8D1RYkyqx5uQbJPoW');
// e.g., "0x7926223070..."

toBase58Check(hex, versionByte)

Converts a hex address back to QTUM base58 format. The version byte determines the network: 120 for testnet, 58 for mainnet.

import { toBase58Check } from 'qtum-wallet-connector';

// Hex to QTUM testnet address (version byte 120)
const qtumAddr = toBase58Check('0x7926223070...', 120);

// Hex to QTUM mainnet address (version byte 58)
const mainnetAddr = toBase58Check('0x7926223070...', 58);

EthGetBalance — Get Balance

Returns the balance as a hex string in satoshis. QTUM uses 8 decimal places (1 QTUM = 100,000,000 satoshis), unlike Ethereum's 18 (wei).

const balanceHex = await wallet.request({
  method: RPCMethods.EthGetBalance,
  params: [address, 'latest'],
}) as string;
// e.g., "0x5f5e100" = 100000000 satoshis = 1 QTUM

EthSendTransaction — Send Transactions

Sends a signed transaction through the snap. The snap handles signing and broadcasting to the QTUM network. MetaMask shows a confirmation popup to the user.

const txHash = await wallet.request({
  method: RPCMethods.EthSendTransaction,
  params: [{
    from: hexAddress,
    to: '0x' + contractAddress,
    value: '0x' + valueInSatoshis.toString(16),
    data: encodedCallData,
    gas: '0x' + (500000).toString(16),
  }],
}) as string;

Connecting to a Wallet (Step by Step)

The full connection flow is implemented in the useQtumWallet React hook. Here is each step:

Step 1: Check MetaMask Installed

const hasMM = await isMetamaskInstalled();
if (!hasMM) {
  // Show "Install MetaMask" button
  return;
}

Step 2: Check/Install Snap

const wallet = new QtumWallet();
const isInstalled = await wallet.isInstalled();
if (!isInstalled) {
  await wallet.enable(); // Triggers MetaMask popup
}

Step 3: Get Address (QTUM + Hex)

const address = await wallet.request({
  method: RPCMethods.WalletGetAddress,
}) as string;
// "qUbxboqjBRp96j3La8D1RYkyqx5uQbJPoW"

const hex = fromBase58Check(address);
// "0x7926223070..."

Step 4: Get Balance

const balance = await wallet.request({
  method: RPCMethods.EthGetBalance,
  params: [address, 'latest'],
}) as string;

// Format for display
const qtumBalance = ethers.formatEther(balance);
// Note: formatEther assumes 18 decimals, works for display

Step 5: Store State

setState({
  isConnected: true,
  address: address,        // QTUM base58
  addressHex: hex,         // hex for contract calls
  balance: balance,        // hex string
});

Complete useQtumWallet Hook

Here is the full hook as used in production:

frontend/src/hooks/useQtumWallet.ts
import { useState, useCallback, useEffect, useRef } from 'react';
import { ethers } from 'ethers';
import {
  QtumWallet,
  RPCMethods,
  fromBase58Check,
  isMetamaskInstalled,
} from 'qtum-wallet-connector';

export interface WalletState {
  isMetaMaskInstalled: boolean;
  isSnapInstalled: boolean;
  isConnected: boolean;
  address: string | null;
  addressHex: string | null;
  balance: string | null;
  error: string | null;
  isLoading: boolean;
}

export function useQtumWallet() {
  const walletRef = useRef<QtumWallet>(new QtumWallet());

  const [state, setState] = useState<WalletState>({
    isMetaMaskInstalled: false,
    isSnapInstalled: false,
    isConnected: false,
    address: null,
    addressHex: null,
    balance: null,
    error: null,
    isLoading: true,
  });

  // Check if MetaMask is installed
  const checkMetaMask = useCallback(async (): Promise<boolean> => {
    try {
      return await isMetamaskInstalled();
    } catch {
      return false;
    }
  }, []);

  // Check if Qtum Snap is installed
  const checkSnapInstalled = useCallback(async (): Promise<boolean> => {
    try {
      return await walletRef.current.isInstalled();
    } catch {
      return false;
    }
  }, []);

  // Connect wallet (install snap if needed, then get address)
  const connect = useCallback(async (): Promise<string | null> => {
    setState(s => ({ ...s, isLoading: true, error: null }));
    try {
      const hasMM = await checkMetaMask();
      if (!hasMM) {
        setState(s => ({ ...s, error: 'Please install MetaMask', isLoading: false }));
        return null;
      }
      const isInstalled = await checkSnapInstalled();
      if (!isInstalled) {
        await walletRef.current.enable();
      }
      const address = await walletRef.current.request({
        method: RPCMethods.WalletGetAddress,
      }) as string;
      const hex = fromBase58Check(address);
      const balance = await walletRef.current.request({
        method: RPCMethods.EthGetBalance,
        params: [address, 'latest'],
      }) as string;

      setState(s => ({
        ...s, isConnected: true, address, addressHex: hex,
        balance, error: null, isLoading: false,
      }));
      return address;
    } catch (err) {
      const msg = err instanceof Error ? err.message : 'Failed to connect';
      setState(s => ({ ...s, error: msg, isLoading: false }));
      return null;
    }
  }, [checkMetaMask, checkSnapInstalled]);

  // Send contract transaction
  const sendContractTransaction = useCallback(async (
    contractAddress: string, data: string, valueQtum: string = '0'
  ): Promise<string | null> => {
    const toHex = contractAddress.startsWith('0x') ? contractAddress : '0x' + contractAddress;
    const dataHex = data.startsWith('0x') ? data : '0x' + data;
    const valueInSatoshis = ethers.parseUnits(valueQtum, 8);

    return await walletRef.current.request({
      method: RPCMethods.EthSendTransaction,
      params: [{
        from: state.addressHex,
        to: toHex,
        value: '0x' + valueInSatoshis.toString(16),
        data: dataHex,
        gas: '0x' + (500000).toString(16),
      }],
    }) as string;
  }, [state.addressHex]);

  // ... refreshBalance, disconnect, formatBalance
  return { ...state, connect, sendContractTransaction, /* ... */ };
}

Sending Contract Transactions

To interact with the SpinGame contract, you need to ABI-encode the function call and send it as a transaction through the snap. Here's how to encode and send a placeSpin call:

ABI Encoding

TypeScript
import { AbiCoder, keccak256, toUtf8Bytes, parseUnits } from 'ethers';

// Encode placeSpin(bytes32 playerSeedHash, bytes32 houseSeedHash)
function encodePlaceSpin(playerSeedHash: string, houseSeedHash: string): string {
  const abiCoder = new AbiCoder();

  // Function selector = first 4 bytes of keccak256 of the signature
  const selector = keccak256(
    toUtf8Bytes('placeSpin(bytes32,bytes32)')
  ).slice(0, 10); // "0x" + 8 hex chars

  // Encode the parameters
  const params = abiCoder.encode(
    ['bytes32', 'bytes32'],
    [playerSeedHash, houseSeedHash]
  );

  // Concatenate selector + params (remove "0x" from params)
  return selector + params.slice(2);
}

Sending the Transaction

TypeScript
const SPIN_CONTRACT_ADDRESS = '33531da3d5d75c0dc1cd7e852c07c364d6072c8e';

// Send 1 QTUM to contract with encoded call data
const valueInSatoshis = parseUnits('1', 8); // 100000000 satoshis = 1 QTUM

const txHash = await wallet.request({
  method: RPCMethods.EthSendTransaction,
  params: [{
    from: hexAddress,
    to: '0x' + SPIN_CONTRACT_ADDRESS,
    value: '0x' + valueInSatoshis.toString(16),
    data: encodePlaceSpin(seedHash, houseSeedHash),
    gas: '0x' + (500000).toString(16),
  }],
});
QTUM-Specific Gotchas

Decimals: QTUM uses 8 decimals (satoshis), not 18 (wei). Use parseUnits(amount, 8).

Gas price: Maximum 0.000001 QTUM per gas unit. Use 0.0000004 for transactions.

Contract addresses: 40 hex characters. Need 0x prefix for the snap.

Transaction IDs: The snap returns txids with 0x prefix, but the QTUM explorer expects no prefix. Strip it: txid.replace(/^0x/, '').

The Full Spin Flow

Here's the complete betting flow from the SpinGame component, broken into each step:

Step 1: Generate Cryptographic Player Seed

import { generateSeed, hashSeed } from '../utils/crypto';

// Generate 32 random bytes
function generateSeed(): string {
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  return '0x' + Array.from(bytes)
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

// Hash with keccak256
function hashSeed(seed: string): string {
  return keccak256(seed);
}

const seed = generateSeed();       // random bytes32
const seedHash = hashSeed(seed);   // keccak256 hash

Step 2: Get House Seed Hash from Backend

const houseSeedRes = await spinApi.getHouseSeed();
if (!houseSeedRes.success || !houseSeedRes.data) {
  throw new Error(houseSeedRes.error || 'Failed to get house seed');
}
const houseSeedHash = houseSeedRes.data.houseSeedHash;

Step 3: Store Recovery Data

// Save to localStorage in case the page reloads mid-spin
localStorage.setItem('pendingSpinBet', JSON.stringify({
  seed, seedHash, houseSeedHash
}));

Step 4: ABI-Encode and Send Transaction

const data = encodePlaceSpin(seedHash, houseSeedHash);
const hash = await wallet.sendContractTransaction(
  SPIN_CONTRACT_ADDRESS, data, '1'  // 1 QTUM
);
if (!hash) throw new Error('Transaction cancelled or failed');

Step 5: Poll for On-Chain Confirmation

// Poll every 3 seconds, up to 60 attempts (3 minutes)
const foundSpinId = await pollUntil(async () => {
  const res = await spinApi.findSpinByHouseSeedHash(houseSeedHash);
  return (res.success && res.data) ? res.data.spinId : null;
}, 60, 3000);

if (!foundSpinId) throw new Error('Spin not found on-chain after timeout.');

Step 6: Submit Player Seed (with Retries)

let seedSubmitted = false;
for (let attempt = 0; attempt < 3; attempt++) {
  const submitRes = await spinApi.submitPlayerSeed(foundSpinId, seed);
  if (submitRes.success) { seedSubmitted = true; break; }
  if (attempt < 2) await new Promise(r => setTimeout(r, 5000));
}
if (!seedSubmitted) {
  throw new Error('Failed to submit player seed. Bet can be refunded after 1 hour.');
}

Step 7: Backend Calls revealAndResolve

This happens on the backend automatically. When the player seed is submitted, the backend:

  1. Looks up the stored house seed for this spin
  2. Calls revealAndResolve(spinId, houseSeed, playerSeed) on the contract
  3. The contract verifies seeds, computes the segment, and sends the payout

Step 8: Poll for Resolution

const resolved = await pollUntil(async () => {
  const res = await spinApi.getSpinDetails(foundSpinId);
  if (res.success && res.data && res.data.status === 'resolved') {
    return { segment: res.data.segment, payout: res.data.payout };
  }
  return null;
}, 60, 3000);

if (!resolved) throw new Error('Spin resolution timed out.');

Step 9: Display Result

// Animate the 3D wheel to land on the correct segment
await wheelRef.current.landOnSegment(resolved.segment);
wheelRef.current.showWinEffect(resolved.segment);

// Clean up recovery data
localStorage.removeItem('pendingSpinBet');

// Refresh balance (with delays for blockchain confirmation)
wallet.refreshBalance();
setTimeout(() => wallet.refreshBalance(), 15000);
setTimeout(() => wallet.refreshBalance(), 45000);

Address Conversion

QTUM uses Base58Check-encoded addresses (like Bitcoin), but smart contracts and the snap's transaction interface work with hex addresses. The qtum-wallet-connector provides conversion utilities:

TypeScript
import { fromBase58Check, toBase58Check } from 'qtum-wallet-connector';

// QTUM testnet address -> hex
const hex = fromBase58Check('qUbxboqjBRp96j3La8D1RYkyqx5uQbJPoW');
// "0x7926223070..."

// hex -> QTUM testnet address (version byte 120)
const qtumTestnet = toBase58Check('0x7926223070...', 120);

// hex -> QTUM mainnet address (version byte 58)
const qtumMainnet = toBase58Check('0x7926223070...', 58);
Version Bytes

The version byte determines the address prefix. Testnet addresses start with q (version byte 120) and mainnet addresses start with Q (version byte 58). This is shown in the SpinGame component's hexToQtumAddress helper:

function hexToQtumAddress(hex: string): string {
  const addr = hex.startsWith('0x') ? hex : '0x' + hex;
  return toBase58Check(addr, 120); // 120 = testnet
}

Available RPC Methods

The RPCMethods enum from qtum-wallet-connector provides all available methods. Here are the key ones for dApp development:

Method Description Returns
WalletGetAddress Get the user's QTUM address (base58) string
EthSendTransaction Sign and broadcast a transaction string (txid)
EthGetBalance Get address balance in hex satoshis string
EthRequestAccounts Request account access (fallback) string[]
EthChainId Get the current chain ID string
EthGetTransactionReceipt Get receipt for a transaction object | null
PersonalSign Sign a message with the user's key string
Usage Pattern

All RPC methods follow the same pattern:

const result = await wallet.request({
  method: RPCMethods.MethodName,
  params: [...],  // optional, depends on method
});