Skip to main content
Every Passport user has an embedded wallet on Immutable Chain. This guide covers wallet operations for developers integrating Passport into games and applications.
For end users: Players can manage their wallet, view balances, and browse transaction history at Immutable Play. Direct your users there for wallet management features.

Overview

This page covers essential wallet operations including retrieving wallet addresses, checking native IMX and ERC-20 token balances, sending transactions (transfers, contract interactions, NFTs), signing messages (ERC-191 personal sign and EIP-712 typed data), and error handling patterns.

Prerequisites

User Authentication

User must be authenticated with Passport before using wallet operations
Web vs Native: Some features like pre-approved transactions require Unity/Unreal native clients and cannot be used in web browsers.

Wallet Connect

Connect the EVM provider and request account access. Required for all wallet operations including Checkout wallet funding and Orderbook trading operations.
Pass getUser from useImmutableSession to connectWallet so the wallet uses your server-managed session instead of its own OAuth flow.
'use client';

import { useImmutableSession } from '@imtbl/auth-next-client';
import { connectWallet, type ZkEvmProvider } from '@imtbl/wallet';
import { useState } from 'react';

function WalletConnect() {
  const { isAuthenticated, getUser } = useImmutableSession();
  const [address, setAddress] = useState<string | null>(null);

  async function handleConnect() {
    const provider = await connectWallet({
      getUser: isAuthenticated ? getUser : undefined,
      clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
    }) as ZkEvmProvider;
    const accounts = await provider.request({ method: 'eth_requestAccounts' });
    setAddress(accounts[0]);
  }

  return (
    <div>
      {address ? (
        <p>Wallet connected: {address}</p>
      ) : (
        <button onClick={handleConnect}>Connect Wallet</button>
      )}
    </div>
  );
}
When getUser is provided, the wallet skips its own OAuth flow and relies on the server-managed session. This avoids duplicate login prompts and keeps token refresh in one place. Pass undefined when the user is not authenticated to allow the wallet to use its own login flow.
Important: Before connecting the wallet, ensure you have initialized Passport and logged in the user. Then complete these steps:
  1. Connect EVM provider (connectEvm() / ConnectEvm())
  2. Request accounts (eth_requestAccounts / ZkEvmRequestAccounts())
Next.js: Once you have the provider from connectWallet({ getUser }) (shown in the Next.js tab above), all wallet operations below work the same way as the TypeScript examples — the provider is the same EIP-1193 compatible ZkEvmProvider. Use the TypeScript tab code with your Next.js provider.

Configuration

The connectWallet function accepts configuration options to customize the wallet provider.

Custom Client ID

const provider = await connectWallet({
  clientId: 'your-client-id',
});

Chain Selection

import {
  connectWallet,
  IMMUTABLE_ZKEVM_MAINNET_CHAIN,
  IMMUTABLE_ZKEVM_TESTNET_CHAIN,
} from '@imtbl/wallet';

const provider = await connectWallet({
  chains: [IMMUTABLE_ZKEVM_MAINNET_CHAIN],
});
const provider = await connectWallet({
  popupOverlayOptions: {
    disableGenericPopupOverlay: true,
    disableBlockedPopupOverlay: true,
  },
});

All Options

OptionTypeDefaultDescription
clientIdstringAuto-detectedImmutable client ID
chainsChainConfig[][testnet, mainnet]Chain configurations
initialChainIdnumberFirst chainInitial chain to connect to
popupOverlayOptionsPopupOverlayOptions-Options for login popup overlays
announceProviderbooleantrueWhether to announce via EIP-6963
feeTokenSymbolstring'IMX'Preferred token symbol for relayer fees
getUserGetUserFunctionInternalCustom function for external auth integration

Using @imtbl/auth (Direct)

For applications that manage authentication outside of Next.js, wrap the Auth class in a getUser function:
import { connectWallet } from '@imtbl/wallet';
import { Auth } from '@imtbl/auth';

const auth = new Auth({
  clientId: 'your-client-id',
  redirectUri: 'https://your-app.com/callback',
});

const getUser = async (forceRefresh?: boolean) => {
  if (forceRefresh) {
    return auth.forceUserRefresh();
  }
  return auth.getUser();
};

const provider = await connectWallet({ getUser });

Chain Configuration

Chain Constants

import {
  IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID,  // 13371
  IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID,  // 13473
} from '@imtbl/wallet';

Presets

import {
  IMMUTABLE_ZKEVM_MAINNET_CHAIN,
  IMMUTABLE_ZKEVM_TESTNET_CHAIN,
  DEFAULT_CHAINS,
} from '@imtbl/wallet';

// Testnet only
const provider = await connectWallet({
  chains: [IMMUTABLE_ZKEVM_TESTNET_CHAIN],
});

// Mainnet only
const provider = await connectWallet({
  chains: [IMMUTABLE_ZKEVM_MAINNET_CHAIN],
});

// Both (default)
const provider = await connectWallet({
  chains: DEFAULT_CHAINS,
});
Spread-friendly presets are also available:
import {
  IMMUTABLE_ZKEVM_MAINNET,
  IMMUTABLE_ZKEVM_TESTNET,
  IMMUTABLE_ZKEVM_MULTICHAIN,
} from '@imtbl/wallet';

const provider = await connectWallet({
  ...IMMUTABLE_ZKEVM_MAINNET,
});

Custom Chain Configuration

import type { ChainConfig } from '@imtbl/wallet';

const customChain: ChainConfig = {
  chainId: 13473,
  name: 'Immutable zkEVM Testnet',
  rpcUrl: 'https://rpc.testnet.immutable.com',
  relayerUrl: 'https://api.sandbox.immutable.com/relayer-mr',
  apiUrl: 'https://api.sandbox.immutable.com',
  passportDomain: 'https://passport.sandbox.immutable.com',
};

Get Wallet Address

// First, initialize the zkEVM provider
const provider = await connectWallet({ auth });

// Then get the wallet address
const accounts = await provider.request({ method: 'eth_requestAccounts' });
const address = accounts[0];

console.log('Wallet address:', address);

Linked Addresses

Users can link external wallets (like MetaMask, WalletConnect, etc.) to their Passport account, allowing them to use the same identity across multiple wallets.
import { getLinkedAddresses } from '@imtbl/wallet';
import { ImmutableConfiguration } from '@imtbl/config';
import { Environment } from '@imtbl/config';

// Initialize API client
const apiClient = new ImmutableConfiguration({
  environment: Environment.SANDBOX,
}).getMultiRollupApiClients();

// Get all wallets linked to the current Passport account
const linkedAddresses = await getLinkedAddresses(auth, apiClient);

console.log('Linked addresses:', linkedAddresses);
// Returns: ['0x123...', '0x456...']
Link an external wallet (such as MetaMask or a hardware wallet) to the user’s Passport account. This requires a signature from the external wallet to prove ownership.
import { linkExternalWallet } from '@imtbl/wallet';
import type { LinkWalletParams, LinkedWallet } from '@imtbl/wallet';

const walletAddress = '0x...';
const nonce = 'unique-nonce-123';
const message = `Link wallet ${walletAddress} with nonce ${nonce}`;

// Sign the message with the external wallet (e.g., using ethers.js or viem)
const signature = await externalWallet.signMessage(message);

const params: LinkWalletParams = {
  type: 'metamask',
  walletAddress,
  signature,
  nonce,
};

const linkedWallet: LinkedWallet = await linkExternalWallet(
  auth,
  apiClient,
  params
);

console.log('Linked wallet:', linkedWallet.address);

Linking Errors

Error CodeDescription
ALREADY_LINKEDThis wallet is already linked to an account
MAX_WALLETS_LINKEDMaximum number of linked wallets reached
DUPLICATE_NONCEThe nonce has already been used
VALIDATION_ERRORInvalid signature or parameters

Check Balances

Native IMX and ERC-20 Token Balances
import { BrowserProvider, formatEther, Contract } from 'ethers';

const provider = connectWallet({ auth });

const accounts = await provider.request({ method: 'eth_requestAccounts' });
const address = accounts[0];

const balance = await provider.request({
  method: 'eth_getBalance',
  params: [address, 'latest']
});

console.log('IMX Balance:', formatEther(balance), 'IMX');

// Get ERC-20 token balance
const erc20Abi = [
  'function balanceOf(address owner) view returns (uint256)',
  'function decimals() view returns (uint8)',
  'function symbol() view returns (string)',
];

const tokenAddress = '0x...'; // Your ERC-20 token contract address
const ethersProvider = new BrowserProvider(provider);
const tokenContract = new Contract(tokenAddress, erc20Abi, ethersProvider);

const tokenBalance = await tokenContract.balanceOf(address);
const decimals = await tokenContract.decimals();
const symbol = await tokenContract.symbol();

console.log(`Token Balance: ${formatEther(tokenBalance)} ${symbol}`);

Send Transactions

Wei Conversion: 1 IMX = 10^18 wei. For TypeScript, use parseUnits('1', 18) from ethers or viem. Unity/Unreal require string values in wei.

Send Transaction with Confirmation

Recommended for critical operations - Waits for blockchain confirmation before returning.
import { BrowserProvider } from 'ethers';

// Get the provider from Passport
const provider = await connectWallet({ auth });

// Wrap provider in ethers BrowserProvider
const browserProvider = new BrowserProvider(provider);

// Get the signer (represents the user's wallet)
const signer = await browserProvider.getSigner();

// Send transaction using signer
const tx = await signer.sendTransaction({
  to: '0xRecipient...',
  value: '1500000000000000000', // 1.5 IMX in wei
});

// Wait for blockchain confirmation
const receipt = await tx.wait();

console.log('Transaction confirmed!');
console.log('Hash:', receipt.hash);
console.log('Status:', receipt.status === 1 ? 'Success' : 'Failed');
When to use: Critical operations like transfers, mints, or state changes. Ensures transaction succeeded before updating game state.
import { BrowserProvider, ethers } from 'ethers';

// Setup provider and signer
const provider = await connectWallet({ auth });
const browserProvider = new BrowserProvider(provider);
const signer = await browserProvider.getSigner();

// Define contract ABI (just the functions you need)
const abi = [
  'function safeTransferFrom(address from, address to, uint256 tokenId)'
];

// Create contract instance
const contract = new ethers.Contract(
  '0xYourNFTContract...', // Contract address
  abi,
  signer // Signer enables sending transactions
);

// Call contract function - automatically sends transaction
const tx = await contract.safeTransferFrom(
  userAddress,
  recipientAddress,
  tokenId
);

// Wait for confirmation
const receipt = await tx.wait();

console.log('NFT transferred!', receipt.hash);

Send Transaction without Confirmation

For fire-and-forget operations - Returns transaction hash immediately without waiting.
import { BrowserProvider } from 'ethers';

// Get the provider from Passport
const provider = await connectWallet({ auth });

// Wrap provider in ethers BrowserProvider
const browserProvider = new BrowserProvider(provider);

// Get the signer (represents the user's wallet)
const signer = await browserProvider.getSigner();

// Send transaction - returns immediately without waiting
const tx = await signer.sendTransaction({
  to: '0xRecipient...',
  value: '1500000000000000000', // 1.5 IMX in wei
});

console.log('Transaction sent:', tx.hash);
// User can continue - transaction confirms in background
Fire-and-Forget: Returns immediately without waiting for blockchain confirmation. Use for non-critical operations where you want responsive UI (cosmetic purchases, achievements, analytics).
Transaction Hash ≠ Success: Obtaining the transaction hash does not guarantee a successful transaction. To determine the transaction’s status, use ZkEvmGetTransactionReceipt along with the transaction hash received. Follow best practices for client-side polling: set maximum attempts, polling intervals, and implement timeout handling.
When to use: Non-critical operations or when you need custom polling/retry logic. Most games should use “with confirmation” variant for critical operations.

NFT Transfer

const ERC721_ABI = [{
  name: 'transferFrom',
  type: 'function',
  inputs: [
    { name: 'from', type: 'address' },
    { name: 'to', type: 'address' },
    { name: 'tokenId', type: 'uint256' },
  ],
  outputs: [],
  stateMutability: 'nonpayable',
}] as const;

export async function transferNFT(
  nftContract: `0x${string}`,
  from: `0x${string}`,
  to: `0x${string}`,
  tokenId: bigint
) {
  // highlight-start
  const hash = await walletClient.writeContract({
    account: from,
    address: nftContract,
    abi: ERC721_ABI,
    functionName: 'transferFrom',
    args: [from, to, tokenId],
  });
  // highlight-end

  const receipt = await publicClient.waitForTransactionReceipt({ hash });
  console.log('NFT transferred! Block:', receipt.blockNumber);
  return receipt;
}

Sign Messages

Personal Sign (ERC-191)

For authentication or simple message signing:
// Assuming you have the Passport provider from connectEvm()
const provider = connectWallet({ auth });

const accounts = await provider.request({ method: 'eth_requestAccounts' });
const address = accounts[0];
const message = 'Hello from Immutable!';

// Sign the message using personal_sign (ERC-191)
const signature = await provider.request({
  method: 'personal_sign',
  params: [message, address],
});

console.log('Signature:', signature);

Typed Data (EIP-712)

For structured data signing (used by protocols like Seaport):
// Assuming you have the Passport provider from connectEvm()
const provider = connectWallet({ auth });

const accounts = await provider.request({ method: 'eth_requestAccounts' });
const address = accounts[0];

// Get chain ID
const chainIdHex = await provider.request({ method: 'eth_chainId' });
const chainId = parseInt(chainIdHex, 16);

// Define EIP-712 typed data structure
const typedData = {
  domain: {
    name: 'My Game',
    version: '1',
    chainId,
    verifyingContract: address, // Your contract address
  },
  message: {
    itemId: 123,
    price: '1000000000000000000', // 1 token in wei
  },
  primaryType: 'Trade',
  types: {
    EIP712Domain: [
      { name: 'name', type: 'string' },
      { name: 'version', type: 'string' },
      { name: 'chainId', type: 'uint256' },
      { name: 'verifyingContract', type: 'address' },
    ],
    Trade: [
      { name: 'itemId', type: 'uint256' },
      { name: 'price', type: 'uint256' },
    ],
  },
};

// Sign typed data using eth_signTypedData_v4
const signature = await provider.request({
  method: 'eth_signTypedData_v4',
  params: [address, JSON.stringify(typedData)],
});

console.log('Typed data signature:', signature);

Provider Events

The provider emits standard EIP-1193 events that you can subscribe to:
provider.on('accountsChanged', (accounts: string[]) => {
  console.log('Accounts changed:', accounts);
});

provider.on('chainChanged', (chainId: string) => {
  console.log('Chain changed:', chainId);
});

provider.on('disconnect', (error: Error) => {
  console.log('Disconnected:', error);
});

// Remove a specific listener
provider.removeListener('accountsChanged', handler);

EIP-6963 Provider Discovery

The wallet automatically announces itself via EIP-6963 for wallet discovery by dApps. Disable this if needed:
const provider = await connectWallet({
  announceProvider: false,
});
For manual announcement:
import { announceProvider, passportProviderInfo } from '@imtbl/wallet';

announceProvider({
  info: passportProviderInfo,
  provider: yourProvider,
});

Error Handling

Use WalletError and WalletErrorType for typed error handling:
import { connectWallet, WalletError, WalletErrorType } from '@imtbl/wallet';

try {
  const provider = await connectWallet();
  const accounts = await provider.request({ method: 'eth_requestAccounts' });
} catch (error) {
  if (error instanceof WalletError) {
    switch (error.type) {
      case WalletErrorType.NOT_LOGGED_IN_ERROR:
        console.log('User is not logged in');
        break;
      case WalletErrorType.WALLET_CONNECTION_ERROR:
        console.log('Failed to connect wallet:', error.message);
        break;
      case WalletErrorType.TRANSACTION_REJECTED:
        console.log('User rejected the transaction');
        break;
      case WalletErrorType.UNAUTHORIZED:
        console.log('Unauthorized - call eth_requestAccounts first');
        break;
      case WalletErrorType.GUARDIAN_ERROR:
        console.log('Guardian validation failed');
        break;
      case WalletErrorType.INVALID_CONFIGURATION:
        console.log('Invalid wallet configuration');
        break;
      case WalletErrorType.SERVICE_UNAVAILABLE_ERROR:
        console.log('Service temporarily unavailable');
        break;
      default:
        console.error('Wallet error:', error.message);
    }
  } else {
    console.error('Unexpected error:', error);
  }
}

Error Types

Error TypeDescription
NOT_LOGGED_IN_ERRORUser is not authenticated
WALLET_CONNECTION_ERRORFailed to connect or link wallet
TRANSACTION_REJECTEDUser rejected a transaction
UNAUTHORIZEDOperation requires authentication
GUARDIAN_ERRORGuardian (security) validation failed
INVALID_CONFIGURATIONInvalid wallet configuration
SERVICE_UNAVAILABLE_ERRORBackend service unavailable

JSON-RPC Error Codes

import { ProviderErrorCode, RpcErrorCode } from '@imtbl/wallet';

// Standard provider error codes (EIP-1193)
ProviderErrorCode.USER_REJECTED_REQUEST  // 4001
ProviderErrorCode.UNAUTHORIZED           // 4100
ProviderErrorCode.UNSUPPORTED_METHOD     // 4200
ProviderErrorCode.DISCONNECTED           // 4900

// Standard RPC error codes
RpcErrorCode.INVALID_REQUEST             // -32600
RpcErrorCode.METHOD_NOT_FOUND            // -32601
RpcErrorCode.INVALID_PARAMS              // -32602
RpcErrorCode.INTERNAL_ERROR              // -32603

Next Steps