Mở đầu bằng một lỗi thực tế

Tôi vẫn nhớ rõ cái ngày thứ 6 tuần đó — deploy xong smart contract, testnet chạy ngon lành, nhưng khi lên mainnet thì... MetaMask RPC Error: User rejected the request. Không phải lỗi code, không phải lỗi contract — chỉ là cách tôi gọi eth_requestAccounts bị sai. 3 tiếng debug, cuối cùng phát hiện ra vấn đề nằm ở cách xử lý Promise trong provider detection.

Bài viết này tôi sẽ chia sẻ toàn bộ kinh nghiệm thực chiến khi tích hợp Web3 wallet — từ kết nối ví, gửi transaction, cho đến xử lý các lỗi phổ biến nhất. Tất cả code mẫu đều test được, dùng HolySheheep AI cho backend verification.

Web3 Provider Detection — Bước đầu tiên bắt buộc

Trước khi làm bất cứ điều gì, bạn cần phát hiện xem trình duyệt có cài đặt Web3 wallet hay không. Đây là nơi nhiều dev mắc lỗi đầu tiên.

// CÁCH SAI - Trực tiếp truy cập window.ethereum
const web3 = new Web3(window.ethereum); // Lỗi: window.ethereum có thể undefined

// CÁCH ĐÚNG - Safe Provider Detection
class Web3Provider {
  constructor() {
    this.provider = null;
    this.accounts = [];
  }

  detectProvider() {
    // Kiểm tra MetaMask (Chrome, Firefox, Brave)
    if (typeof window !== 'undefined' && window.ethereum) {
      this.provider = window.ethereum;
      
      // Kiểm tra phiên bản MetaMask có hỗ trợ method mới
      if (this.provider.isMetaMask && this.provider.isMetaMask === true) {
        console.log('MetaMask detected - version:', this.provider.version);
        return this.provider;
      }
    }

    // Kiểm tra WalletConnect hoặc các ví khác
    if (typeof window !== 'undefined' && window.walletLinkExtension) {
      this.provider = window.walletLinkExtension;
      return this.provider;
    }

    // Fallback: Coinbase Wallet
    if (typeof window !== 'undefined' && window.coinbaseWalletExtension) {
      this.provider = window.coinbaseWalletExtension;
      return this.provider;
    }

    return null;
  }

  async connect() {
    const provider = this.detectProvider();
    
    if (!provider) {
      throw new Error('NO_WALLET_DETECTED: Vui lòng cài đặt MetaMask hoặc ví Web3 tương thích');
    }

    try {
      // eth_requestAccounts sẽ trigger popup MetaMask
      this.accounts = await provider.request({ 
        method: 'eth_requestAccounts' 
      });
      
      console.log('Connected accounts:', this.accounts);
      return this.accounts[0]; // Trả về địa chỉ ví đầu tiên
      
    } catch (error) {
      if (error.code === 4001) {
        throw new Error('USER_REJECTED: Người dùng từ chối kết nối');
      }
      throw error;
    }
  }
}

// Sử dụng
const web3Provider = new Web3Provider();
const userAddress = await web3Provider.connect();

Điểm mấu chốt ở đây là eth_requestAccounts — đây là method chuẩn EIP-1193, thay thế cho enable() đã deprecated. Khi gọi method này, MetaMask sẽ bật popup yêu cầu user approve kết nối.

Kết nối với Backend — Xác thực Nonce

Sau khi lấy được địa chỉ ví từ client, bạn cần xác thực với backend. Đây là flow standard:

  1. Client gửi địa chỉ ví lên server
  2. Server tạo nonce ngẫu nhiên, lưu vào database/cache
  3. Client ký message chứa nonce
  4. Server verify chữ ký và xác nhận ownership
// Server-side (Node.js + Express)
const express = require('express');
const crypto = require('crypto');

const app = express();

// Lưu trữ nonce tạm thời (trong production dùng Redis)
const nonceStore = new Map();

// API endpoint để lấy nonce
app.post('/api/auth/wallet/generate-nonce', async (req, res) => {
  const { walletAddress } = req.body;
  
  if (!walletAddress || !/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) {
    return res.status(400).json({ 
      error: 'INVALID_ADDRESS',
      message: 'Địa chỉ ví không hợp lệ' 
    });
  }

  // Tạo nonce 32 bytes ngẫu nhiên
  const nonce = crypto.randomBytes(32).toString('hex');
  
  // Lưu nonce với thời hạn 5 phút
  nonceStore.set(walletAddress.toLowerCase(), {
    nonce,
    expiresAt: Date.now() + 5 * 60 * 1000
  });

  res.json({
    nonce,
    message: Sign this message to prove you own this wallet: ${nonce}
  });
});

// Verify chữ ký
app.post('/api/auth/wallet/verify', async (req, res) => {
  const { walletAddress, signature } = req.body;
  
  const stored = nonceStore.get(walletAddress.toLowerCase());
  
  if (!stored) {
    return res.status(401).json({ 
      error: 'NONCE_NOT_FOUND',
      message: 'Vui lòng yêu cầu nonce trước' 
    });
  }

  if (Date.now() > stored.expiresAt) {
    nonceStore.delete(walletAddress.toLowerCase());
    return res.status(401).json({ 
      error: 'NONCE_EXPIRED',
      message: 'Nonce đã hết hạn, vui lòng yêu cầu lại' 
    });
  }

  // Verify signature với ethers.js
  const { ethers } = require('ethers');
  const message = Sign this message to prove you own this wallet: ${stored.nonce};
  const recoveredAddress = ethers.utils.verifyMessage(message, signature);

  if (recoveredAddress.toLowerCase() !== walletAddress.toLowerCase()) {
    return res.status(401).json({ 
      error: 'INVALID_SIGNATURE',
      message: 'Chữ ký không hợp lệ' 
    });
  }

  // Xóa nonce sau khi verify thành công
  nonceStore.delete(walletAddress.toLowerCase());

  // Tạo JWT token cho session
  const jwt = require('jsonwebtoken');
  const token = jwt.sign(
    { walletAddress, method: 'wallet' },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );

  res.json({ token, walletAddress });
});

Client-side Signing — Message vs Typed Data

Có hai cách để ký message trong Web3:

class WalletSigner {
  constructor(provider) {
    this.provider = provider;
  }

  // Cách 1: personal_sign (đơn giản)
  async signMessage(message) {
    try {
      const accounts = await this.provider.request({ method: 'eth_accounts' });
      
      if (!accounts || accounts.length === 0) {
        throw new Error('WALLET_NOT_CONNECTED');
      }

      const signature = await this.provider.request({
        method: 'personal_sign',
        params: [
          message,                          // Message cần ký (bytes encoded)
          accounts[0]                        // Địa chỉ ký
        ]
      });

      return {
        signature,
        address: accounts[0],
        method: 'personal_sign'
      };

    } catch (error) {
      if (error.code === 4001) {
        throw new Error('USER_REJECTED_SIGN');
      }
      throw error;
    }
  }

  // Cách 2: eth_signTypedData_v4 (EIP-712 - Khuyến nghị)
  async signTypedData(domain, types, message) {
    const chainId = await this.provider.request({ method: 'eth_chainId' });
    
    const domainSeparator = {
      name: domain.name,
      version: domain.version,
      chainId: parseInt(chainId),
      verifyingContract: domain.contractAddress
    };

    const signature = await this.provider.request({
      method: 'eth_signTypedData_v4',
      params: [
        this.provider.selectedAddress,
        JSON.stringify({
          domain: domainSeparator,
          types: types,
          primaryType: types.PrimaryType,
          message: message
        })
      ]
    });

    // Parse signature
    const { ethers } = require('ethers');
    const recovered = ethers.utils.verifyTypedData(
      domainSeparator,
      types,
      message,
      signature
    );

    return {
      signature,
      address: this.provider.selectedAddress,
      recoveredAddress: recovered,
      method: 'eth_signTypedData_v4',
      verified: recovered.toLowerCase() === this.provider.selectedAddress.toLowerCase()
    };
  }

  // Ví dụ: Ký permit cho ERC-20 token
  async signTokenPermit(tokenAddress, spender, value, deadline) {
    const domain = {
      name: 'USD Coin',
      version: '2',
      contractAddress: tokenAddress,
      version: '2'
    };

    const types = {
      EIP712Domain: [
        { name: 'name', type: 'string' },
        { name: 'version', type: 'string' },
        { name: 'chainId', type: 'uint256' },
        { name: 'verifyingContract', type: 'address' }
      ],
      Permit: [
        { name: 'owner', type: 'address' },
        { name: 'spender', type: 'address' },
        { name: 'value', type: 'uint256' },
        { name: 'nonce', type: 'uint256' },
        { name: 'deadline', type: 'uint256' }
      ]
    };

    const message = {
      owner: this.provider.selectedAddress,
      spender,
      value: value.toString(),
      nonce: await this.getNonce(tokenAddress),
      deadline
    };

    return this.signTypedData(domain, types, message);
  }

  async getNonce(tokenAddress) {
    const nonceData = await this.provider.request({
      method: 'eth_call',
      params: [{
        to: tokenAddress,
        data: '0xbeabacc8' + this.provider.selectedAddress.slice(2).padStart(64, '0')
        // function nonces(address) selector: 0xbeabacc8
      }]
    });
    return parseInt(nonceData, 16);
  }
}

Gửi Transaction — Từ Contract Call đến Confirmation

class TransactionManager {
  constructor(provider) {
    this.provider = provider;
    this.web3 = new Web3(provider);
  }

  async sendTransaction(params) {
    const { to, value, data, gasLimit } = params;

    // Lấy gas price hiện tại
    const gasPrice = await this.provider.request({ 
      method: 'eth_gasPrice' 
    });

    // Estimate gas nếu không truyền vào
    let estimatedGas = gasLimit;
    if (!estimatedGas) {
      estimatedGas = await this.provider.request({
        method: 'eth_estimateGas',
        params: [{
          from: this.provider.selectedAddress,
          to,
          value: value || '0x0',
          data
        }]
      });
    }

    // Tạo transaction object
    const txObject = {
      from: this.provider.selectedAddress,
      to,
      value: value || '0x0',
      gas: estimatedGas,
      gasPrice: gasPrice,
      data: data || '0x'
    };

    // Gửi transaction
    const txHash = await this.provider.request({
      method: 'eth_sendTransaction',
      params: [txObject]
    });

    console.log('Transaction sent:', txHash);
    
    // Chờ confirmation
    return this.waitForConfirmation(txHash);
  }

  async waitForConfirmation(txHash, confirmations = 1) {
    return new Promise((resolve, reject) => {
      this.provider.once('confirmation', (confirmationNumber, receipt) => {
        if (confirmationNumber >= confirmations) {
          resolve({
            txHash: receipt.transactionHash,
            blockNumber: receipt.blockNumber,
            status: receipt.status === '0x1' ? 'SUCCESS' : 'FAILED',
            confirmations: confirmationNumber
          });
        }
      });

      // Timeout sau 5 phút
      setTimeout(() => {
        reject(new Error('TRANSACTION_TIMEOUT'));
      }, 5 * 60 * 1000);
    });
  }

  // Ví dụ: Gọi contract function
  async callContract(contractAddress, abi, functionName, args = []) {
    const contract = new this.web3.eth.Contract(abi, contractAddress);
    const method = contract.methods[functionName](...args);

    return {
      callData: method.encodeABI(),
      estimatedGas: await method.estimateGas({ from: this.provider.selectedAddress }),
      to: contractAddress
    };
  }
}

// Sử dụng
const txManager = new TransactionManager(ethereumProvider);

const result = await txManager.sendTransaction({
  to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0eB98',
  value: web3.utils.toWei('0.1', 'ether'), // 0.1 ETH
  gasLimit: '0x5208' // 21000 gas cho ETH transfer
});

console.log('Transaction confirmed:', result);

Lỗi thường gặp và cách khắc phục

1. Lỗi "User rejected the request" (Code 4001)

// Nguyên nhân: User click "Reject" trên MetaMask popup
// Cách khắc phục: Handle graceful và hiển thị UI phù hợp

async function safeConnect() {
  try {
    const accounts = await ethereum.request({ 
      method: 'eth_requestAccounts' 
    });
    return { success: true, accounts };
    
  } catch (error) {
    if (error.code === 4001) {
      // User rejected - Hiển thị message thân thiện
      return { 
        success: false, 
        error: 'USER_REJECTED',
        message: 'Bạn đã từ chối kết nối ví. Vui lòng approve để tiếp tục.'
      };
    }
    
    // Các lỗi khác
    return { 
      success: false, 
      error: error.code || 'UNKNOWN',
      message: error.message 
    };
  }
}

// Trong component React
function ConnectButton() {
  const [status, setStatus] = useState('idle');

  const handleConnect = async () => {
    setStatus('connecting');
    const result = await safeConnect();
    
    if (result.success) {
      setStatus('connected');
      // Xử lý đăng nhập
    } else {
      setStatus('error');
      // Hiển thị toast message
      toast.error(result.message);
    }
  };

  return (
    <button onClick={handleConnect} disabled={status === 'connecting'>
      {status === 'connecting' ? 'Đang kết nối...' : 'Kết nối ví'}
    </button>
  );
}

2. Lỗi "Nonces too low" hoặc "nonce too low"

// Nguyên nhân: Transaction queue bị stuck, nonce không liên tục
// Thường xảy ra khi: Transaction trước chưa confirm, hoặc network chậm

class NonceManager {
  constructor(provider) {
    this.provider = provider;
    this.pendingNonces = new Map();
  }

  async getNextNonce(address) {
    // Lấy pending nonce từ provider
    const pendingNonce = await this.provider.request({
      method: 'eth_getTransactionCount',
      params: [address, 'pending']
    });

    // Lấy latest nonce
    const latestNonce = await this.provider.request({
      method: 'eth_getTransactionCount',
      params: [address, 'latest']
    });

    const pending = parseInt(pendingNonce, 16);
    const latest = parseInt(latestNonce, 16);
    const maxPending = this.pendingNonces.get(address) || latest;

    // Sử dụng giá trị lớn nhất trong số pending nonce
    const nextNonce = Math.max(pending, latest, maxPending);

    // Cập nhật pending nonce tracker
    this.pendingNonces.set(address, nextNonce + 1);

    return '0x' + nextNonce.toString(16);
  }

  async sendWithRetry(params, maxRetries = 3) {
    for (let i = 0; i < maxRetries; i++) {
      try {
        const nonce = await this.getNextNonce(params.from);
        
        const txHash = await this.provider.request({
          method: 'eth_sendTransaction',
          params: [{ ...params, nonce }]
        });

        return { success: true, txHash, nonce };

      } catch (error) {
        // Kiểm tra lỗi nonce
        if (error.message.includes('nonce too low') || 
            error.message.includes('replacement transaction underpriced')) {
          
          console.log(Retry ${i + 1}/${maxRetries} due to nonce issue);
          await new Promise(r => setTimeout(r, 1000 * (i + 1))); // Exponential backoff
          continue;
        }
        
        throw error; // Không phải lỗi nonce - throw ngay
      }
    }
    throw new Error('MAX_RETRIES_EXCEEDED');
  }
}

3. Lỗi "insufficient funds" hoặc "gas required exceeds allowance"

// Nguyên nhân: Không đủ ETH/TOKEN để trả gas, hoặc gas limit quá thấp

async function validateTransaction(address, txParams) {
  const errors = [];

  // 1. Kiểm tra số dư ETH
  const balance = await ethereum.request({
    method: 'eth_getBalance',
    params: [address, 'latest']
  });
  const balanceInEth = parseInt(balance, 16) / 1e18;

  // 2. Estimate gas và tính toán chi phí
  let gasLimit;
  try {
    gasLimit = await ethereum.request({
      method: 'eth_estimateGas',
      params: [txParams]
    });
  } catch (estimateError) {
    // Gas estimation failed - có thể contract sẽ revert
    errors.push({
      type: 'GAS_ESTIMATION_FAILED',
      message: 'Không thể ước tính gas. Contract có thể sẽ revert.',
      originalError: estimateError.message
    });
    gasLimit = '0x15f90'; // 90000 fallback
  }

  const gasPrice = await ethereum.request({ method: 'eth_gasPrice' });
  const gasLimitDec = parseInt(gasLimit, 16);
  const gasPriceDec = parseInt(gasPrice, 16);
  const maxCost = (gasLimitDec * gasPriceDec) / 1e18;
  const txValue = txParams.value ? parseInt(txParams.value, 16) / 1e18 : 0;

  // 3. Kiểm tra đủ không
  if (balanceInEth < maxCost + txValue) {
    errors.push({
      type: 'INSUFFICIENT_FUNDS',
      message: Số dư không đủ. Cần: ${(maxCost + txValue).toFixed(6)} ETH, Có: ${balanceInEth.toFixed(6)} ETH,
      required: maxCost + txValue,
      available: balanceInEth
    });
  }

  // 4. Warning nếu gas cao bất thường
  if (gasLimitDec > 1000000) {
    errors.push({
      type: 'HIGH_GAS_WARNING',
      message: Gas limit cao bất thường: ${gasLimitDec}. Vui lòng xác nhận lại transaction.
    });
  }

  return {
    valid: errors.filter(e => e.type === 'INSUFFICIENT_FUNDS').length === 0,
    errors,
    gasLimit: gasLimit,
    gasPrice: gasPrice,
    estimatedCost: maxCost
  };
}

// Sử dụng
const validation = await validateTransaction(userAddress, {
  from: userAddress,
  to: contractAddress,
  data: callData,
  value: '0x0'
});

if (!validation.valid) {
  // Hiển thị modal yêu cầu nạp thêm tiền
  showInsufficientFundsModal(validation.errors);
}

Tối ưu chi phí với HolySheep AI

Khi xây dựng hệ thống Web3 backend, bạn cần xử lý rất nhiều tác vụ như verify signature, decode transaction data, query blockchain data. HolySheep AI là giải pháp tiết kiệm đến 85%+ chi phí so với các provider khác:

// Ví dụ: Sử dụng HolySheep AI để verify và decode batch transactions
const https = require('https');

async function batchVerifyTransactions(transactions) {
  const payload = JSON.stringify({
    model: 'deepseek-v3.2',
    messages: [
      {
        role: 'system',
        content: 'Bạn là chuyên gia blockchain. Phân tích và verify các transaction sau.'
      },
      {
        role: 'user', 
        content: Verify danh sách ${transactions.length} transactions:\n${JSON.stringify(transactions, null, 2)}
      }
    ],
    temperature: 0.1,
    max_tokens: 2000
  });

  const options = {
    hostname: 'api.holysheep.ai',
    port: 443,
    path: '/v1/chat/completions',
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer YOUR_HOLYSHEEP_API_KEY',
      'Content-Length': Buffer.byteLength(payload)
    }
  };

  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        const result = JSON.parse(data);
        if (result.error) reject(new Error(result.error.message));
        else resolve(result.choices[0].message.content);
      });
    });

    req.on('error', reject);
    req.write(payload);
    req.end();
  });
}

// Sử dụng - Chi phí chỉ ~$0.000084 cho 1 batch verify
const txs = [
  { hash: '0x123...', from: '0xabc...', value: '0.5 ETH' },
  { hash: '0x456...', from: '0xdef...', value: '1.2 ETH' }
];

const result = await batchVerifyTransactions(txs);
console.log('Batch verification:', result);

Tổng kết

Qua bài viết này, tôi đã chia sẻ toàn bộ kinh nghiệm thực chiến khi tích hợp Web3 wallet:

Điều quan trọng nhất khi làm việc với Web3: luôn luôn handle error cases trước, test trên nhiều wallet khác nhau (MetaMask, WalletConnect, Coinbase Wallet), và validate tất cả data từ client trước khi trust.

👉 Đăng ký HolySheep AI — nhận tín dụng miễn phí khi đăng ký