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.

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.

How Snaps Work

┌──────────────────────────────────────────────┐ │ YOUR DAPP (Browser) │ │ │ │ window.ethereum.request({ │ │ method: 'wallet_invokeSnap', │ │ params: { │ │ snapId: 'npm:qtum-wallet', │ │ request: { method: '...', params: ... } │ │ } │ │ }) │ └─────────────────────┬────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────┐ │ METAMASK (Extension) │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ qtum-wallet (Sandboxed) │ │ │ │ │ │ │ │ - Manages QTUM keys │ │ │ │ - Signs QTUM transactions │ │ │ │ - Converts addresses (base58/hex) │ │ │ │ - Communicates with QTUM nodes │ │ │ └─────────────────────────────────────────┘ │ └─────────────────────┬────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────┐ │ QTUM BLOCKCHAIN │ └──────────────────────────────────────────────┘

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.

Address 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:

StateTypeDescription
isMetaMaskInstalledbooleanIs MetaMask present?
isSnapInstalledbooleanIs qtum-wallet installed?
isConnectedbooleanIs wallet connected and ready?
addressstring | nullQTUM base58 address
addressHexstring | nullHex address (for contracts)
balancestring | nullBalance in hex wei format
errorstring | nullLast error message
isLoadingbooleanOperation 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!
Balance Display

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);