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:
- Client gửi địa chỉ ví lên server
- Server tạo nonce ngẫu nhiên, lưu vào database/cache
- Client ký message chứa nonce
- 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:
- personal_sign: Ký message thuần túy (đơn giản, dễ hiểu)
- eth_signTypedData_v4: Ký structured data theo EIP-712 (bảo mật hơn, hiển thị rõ ràng data được ký)
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:
- GPT-4.1: $8/MTok — thay thế hoàn hảo cho việc parse ABI và verify typed data
- DeepSeek V3.2: $0.42/MTok — lý tưởng cho batch transaction validation
- Độ trễ thực tế: <50ms cho mọi request
- Thanh toán: Hỗ trợ WeChat Pay, Alipay — tiện lợi cho dev Việt Nam
// 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:
- Provider Detection: Luôn kiểm tra ví tồn tại trước khi gọi API
- Signature Methods: Ưu tiên
eth_signTypedData_v4cho production - Transaction Management: Implement retry với nonce tracking
- Error Handling: Xử lý graceful tất cả các error codes phổ biến
Đ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.