Dữ Liệu On-Chain Trong Production: Những Điều Không Ai Nói Cho Bạn
Dữ liệu blockchain không sạch, không đáng tin cậy, cũng không dễ dàng. Rate limit RPC, chain reorg, bug BigInt, và các đánh đổi indexing — bài học xương máu từ việc ship sản phẩm DeFi thực tế.
Có một ảo tưởng rằng dữ liệu on-chain vốn dĩ đáng tin cậy. Sổ cái bất biến. Trạng thái minh bạch. Chỉ cần đọc và xong.
Tôi cũng từng tin như vậy. Rồi tôi ship một dashboard DeFi lên production và dành ba tuần tìm hiểu tại sao số dư token sai, lịch sử sự kiện có khoảng trống, và database chứa các giao dịch từ những block không còn tồn tại.
Dữ liệu on-chain là thô, khắc nghiệt, và đầy edge case sẽ phá vỡ ứng dụng của bạn theo những cách bạn không nhận ra cho đến khi người dùng gửi báo cáo lỗi. Bài viết này bao gồm mọi thứ tôi học được một cách đau thương.
Ảo Tưởng Về Dữ Liệu Đáng Tin Cậy#
Đây là điều đầu tiên không ai nói cho bạn: blockchain không cung cấp dữ liệu cho bạn. Nó cung cấp các chuyển đổi trạng thái. Không có SELECT * FROM transfers WHERE user = '0x...'. Chỉ có log, receipt, storage slot, và call trace — tất cả được mã hóa theo format cần ngữ cảnh để giải mã.
Một event log Transfer cho bạn from, to, và value. Nó không cho bạn biết ký hiệu token. Nó không cho bạn biết số thập phân. Nó không cho bạn biết đây là transfer hợp lệ hay token fee-on-transfer đang cắt 3% phí. Nó không cho bạn biết block này có còn tồn tại sau 30 giây nữa không.
Phần "bất biến" là đúng — khi đã finalize. Nhưng finalization không tức thời. Và dữ liệu bạn nhận từ node RPC không nhất thiết đến từ block đã finalize. Hầu hết developer truy vấn latest và coi đó là sự thật. Đó là bug, không phải feature.
Rồi còn vấn đề encoding. Mọi thứ đều là hex. Địa chỉ có checksum hoa-thường (hoặc không). Số lượng token là số nguyên nhân với 10^decimals. Một transfer USDC $100 trông như 100000000 trên chain vì USDC có 6 decimal, không phải 18. Tôi đã thấy code production giả định 18 decimal cho mọi token ERC-20. Số dư kết quả sai lệch hệ số 10^12.
Rate Limit RPC Sẽ Phá Hỏng Cuối Tuần Của Bạn#
Mọi ứng dụng Web3 production đều giao tiếp với endpoint RPC. Và mọi endpoint RPC đều có rate limit khắt khe hơn bạn tưởng.
Đây là những con số quan trọng:
- Alchemy Free: ~30M compute unit/tháng, 40 request/phút. Nghe có vẻ rộng rãi cho đến khi bạn nhận ra một lệnh
eth_getLogsđơn lẻ trên phạm vi block rộng có thể ngốn hàng trăm CU. Bạn sẽ đốt hết hạn mức tháng trong một ngày indexing. - Infura Free: 100K request/ngày, khoảng 1,15 req/giây. Thử phân trang qua 500K block event log với tốc độ đó.
- QuickNode Free: Tương tự Infura — 100K request/ngày.
Các gói trả phí giúp ích, nhưng không loại bỏ vấn đề. Ngay cả ở $200/tháng trên gói Growth của Alchemy, một job indexing nặng sẽ chạm giới hạn throughput. Và khi bạn chạm giới hạn, bạn không nhận được suy giảm uyển chuyển. Bạn nhận được lỗi 429, đôi khi với thông báo vô ích, đôi khi không có header retry-after.
Giải pháp là sự kết hợp của provider dự phòng, logic retry, và rất cẩn thận về những lệnh gọi nào bạn thực hiện. Đây là thiết lập RPC robust trông như thế nào với viem:
import { createPublicClient, fallback, http } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: fallback(
[
http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
retryCount: 3,
retryDelay: 1500,
timeout: 15_000,
}),
http("https://mainnet.infura.io/v3/YOUR_KEY", {
retryCount: 3,
retryDelay: 1500,
timeout: 15_000,
}),
http("https://rpc.ankr.com/eth", {
retryCount: 2,
retryDelay: 2000,
timeout: 20_000,
}),
],
{ rank: true }
),
});Tùy chọn rank: true rất quan trọng. Nó bảo viem đo độ trễ và tỷ lệ thành công cho mỗi transport và tự động ưu tiên cái nhanh nhất, đáng tin cậy nhất. Nếu Alchemy bắt đầu rate-limit bạn, viem chuyển lưu lượng sang Infura. Nếu Infura sập, nó fallback sang Ankr.
Nhưng có một điểm tinh tế: logic retry mặc định của viem sử dụng exponential backoff, thường là điều bạn muốn. Tuy nhiên, tính đến đầu năm 2025, có một vấn đề đã biết khi retryCount không retry đúng cách các lỗi cấp RPC (như 429) khi bật chế độ batch. Nếu bạn đang batch request, hãy kiểm tra hành vi retry một cách rõ ràng. Đừng tin là nó hoạt động.
Reorg: Bug Bạn Không Thấy Trước#
Chain reorganization xảy ra khi mạng tạm thời không đồng thuận về block nào là canonical. Node A thấy block 1000 với giao dịch [A, B, C]. Node B thấy block 1000 khác với giao dịch [A, D]. Cuối cùng mạng hội tụ, và một phiên bản thắng.
Trên các chain proof-of-work, điều này phổ biến — reorg 1-3 block xảy ra nhiều lần mỗi ngày. Ethereum sau merge tốt hơn. Một cuộc tấn công reorg thành công giờ đòi hỏi phối hợp gần 50% validator. Nhưng "tốt hơn" không phải "không thể." Có một reorg 7 block đáng chú ý trên Beacon Chain vào tháng 5 năm 2022, do các client triển khai proposer boost fork không nhất quán.
Và không quan trọng reorg hiếm đến đâu trên Ethereum mainnet. Nếu bạn đang xây dựng trên L2 hay sidechain — Polygon, Arbitrum, Optimism — reorg xảy ra thường xuyên hơn. Polygon trong lịch sử có reorg hơn 10 block.
Đây là vấn đề thực tế: bạn đã index block 18.000.000. Bạn đã ghi event vào database. Rồi block 18.000.000 bị reorg. Giờ database của bạn có event từ block không tồn tại trên chain canonical. Những event đó có thể tham chiếu giao dịch chưa từng xảy ra. Người dùng thấy transfer ma.
Cách sửa phụ thuộc vào kiến trúc:
Cách 1: Trì hoãn xác nhận. Không index dữ liệu cho đến khi N block xác nhận đã qua. Đối với Ethereum mainnet, 64 block (hai epoch) cho bạn đảm bảo finality. Đối với L2, kiểm tra mô hình finality của chain cụ thể. Cách này đơn giản nhưng thêm độ trễ — khoảng 13 phút trên Ethereum.
Cách 2: Phát hiện reorg và rollback. Index mạnh tay nhưng theo dõi block hash. Ở mỗi block mới, xác minh rằng parent hash khớp với block trước đó bạn đã index. Nếu không khớp, bạn đã phát hiện reorg: xóa mọi thứ từ các block mồ côi và re-index chain canonical.
interface IndexedBlock {
number: bigint;
hash: `0x${string}`;
parentHash: `0x${string}`;
}
async function detectReorg(
client: PublicClient,
lastIndexed: IndexedBlock
): Promise<{ reorged: boolean; depth: number }> {
const currentBlock = await client.getBlock({
blockNumber: lastIndexed.number,
});
if (currentBlock.hash === lastIndexed.hash) {
return { reorged: false, depth: 0 };
}
// Đi ngược lại để tìm điểm chain phân nhánh
let depth = 1;
let checkNumber = lastIndexed.number - 1n;
while (checkNumber > 0n && depth < 128) {
const onChain = await client.getBlock({ blockNumber: checkNumber });
const inDb = await getIndexedBlock(checkNumber); // tra cứu DB của bạn
if (onChain.hash === inDb?.hash) {
return { reorged: true, depth };
}
depth++;
checkNumber--;
}
return { reorged: true, depth };
}Đây không phải giả thuyết. Tôi đã có hệ thống production index event ở chain tip mà không phát hiện reorg. Ba tuần nó hoạt động tốt. Rồi một reorg 2 block trên Polygon gây ra event mint NFT trùng lặp trong database. Frontend hiển thị người dùng sở hữu token mà họ không có. Mất hai ngày debug vì không ai nghĩ đến reorg là nguyên nhân gốc.
Bài Toán Indexing: Chọn Nỗi Đau Của Bạn#
Bạn có ba lựa chọn thực sự để đưa dữ liệu on-chain có cấu trúc vào ứng dụng.
Gọi RPC Trực Tiếp#
Chỉ cần gọi getLogs, getBlock, getTransaction trực tiếp. Cách này hoạt động cho đọc quy mô nhỏ — kiểm tra số dư người dùng, fetch event gần đây cho một contract đơn lẻ. Nó không hoạt động cho indexing lịch sử hay truy vấn phức tạp cross-contract.
Vấn đề là tổ hợp. Muốn tất cả swap Uniswap V3 trong 30 ngày qua? Đó là ~200K block. Với giới hạn 2K block mỗi lệnh getLogs của Alchemy, đó là tối thiểu 100 request phân trang. Mỗi cái tính vào rate limit. Và nếu bất kỳ lệnh nào thất bại, bạn cần logic retry, theo dõi cursor, và cách tiếp tục từ nơi dừng lại.
The Graph (Subgraph)#
The Graph là giải pháp OG. Định nghĩa schema, viết mapping bằng AssemblyScript, deploy, và truy vấn bằng GraphQL. Hosted Service đã bị deprecated — mọi thứ giờ trên Graph Network phi tập trung, nghĩa là bạn trả bằng token GRT cho truy vấn.
Ưu điểm: chuẩn hóa, tài liệu tốt, hệ sinh thái lớn các subgraph có sẵn bạn có thể fork.
Nhược điểm: AssemblyScript rất khó chịu. Debug hạn chế. Deploy mất từ phút đến giờ. Nếu subgraph có bug, bạn redeploy và đợi nó sync lại từ đầu. Mạng phi tập trung thêm độ trễ và đôi khi indexer tụt lại sau chain tip.
Tôi đã dùng The Graph cho dashboard đọc nhiều nơi độ tươi dữ liệu 30-60 giây là chấp nhận được. Nó hoạt động tốt ở đó. Tôi sẽ không dùng nó cho bất kỳ thứ gì cần dữ liệu thời gian thực hay business logic phức tạp trong mapping.
Custom Indexer (Ponder, Envio)#
Đây là nơi hệ sinh thái đã trưởng thành đáng kể. Ponder và Envio cho phép bạn viết logic indexing bằng TypeScript (không phải AssemblyScript), chạy local trong quá trình phát triển, và deploy như service độc lập.
Ponder cho bạn quyền kiểm soát tối đa. Bạn định nghĩa event handler bằng TypeScript, nó quản lý pipeline indexing, và bạn nhận được database SQL làm output. Đánh đổi: bạn sở hữu hạ tầng. Scaling, monitoring, xử lý reorg — đều do bạn.
Envio tối ưu cho tốc độ sync. Benchmark của họ cho thấy thời gian sync ban đầu nhanh hơn đáng kể so với The Graph. Họ xử lý reorg native và hỗ trợ HyperSync, một protocol chuyên dụng cho fetch dữ liệu nhanh hơn. Đánh đổi: bạn phụ thuộc vào hạ tầng và API của họ.
Khuyến nghị của tôi: nếu bạn đang xây dựng ứng dụng DeFi production và có năng lực kỹ thuật, hãy dùng Ponder. Nếu bạn cần sync nhanh nhất có thể và không muốn quản lý hạ tầng, hãy đánh giá Envio. Nếu bạn cần prototype nhanh hay muốn subgraph được cộng đồng bảo trì, The Graph vẫn ổn.
getLogs Nguy Hiểm Hơn Vẻ Ngoài#
Phương thức RPC eth_getLogs đơn giản một cách lừa đảo. Đưa cho nó phạm vi block và vài filter, nhận lại event log khớp. Đây là những gì thực sự xảy ra trong production:
Giới hạn phạm vi block khác nhau theo provider. Alchemy giới hạn 2K block (log không giới hạn) hoặc block không giới hạn (tối đa 10K log). Infura có giới hạn khác. QuickNode có giới hạn khác. RPC công cộng có thể giới hạn 1K block. Code của bạn phải xử lý tất cả.
Giới hạn kích thước response tồn tại. Ngay trong phạm vi block, nếu một contract phổ biến emit hàng nghìn event mỗi block, response có thể vượt giới hạn payload của provider (150MB trên Alchemy). Lệnh gọi không trả kết quả một phần. Nó thất bại.
Phạm vi trống không miễn phí. Ngay cả khi không có log khớp, provider vẫn quét phạm vi block. Điều này tính vào compute unit của bạn.
Đây là utility phân trang xử lý các ràng buộc này:
import type { PublicClient, Log, AbiEvent } from "viem";
async function fetchLogsInChunks<T extends AbiEvent>(
client: PublicClient,
params: {
address: `0x${string}`;
event: T;
fromBlock: bigint;
toBlock: bigint;
maxBlockRange?: bigint;
}
): Promise<Log<bigint, number, false, T, true>[]> {
const { address, event, fromBlock, toBlock, maxBlockRange = 2000n } = params;
const allLogs: Log<bigint, number, false, T, true>[] = [];
let currentFrom = fromBlock;
while (currentFrom <= toBlock) {
const currentTo =
currentFrom + maxBlockRange - 1n > toBlock
? toBlock
: currentFrom + maxBlockRange - 1n;
try {
const logs = await client.getLogs({
address,
event,
fromBlock: currentFrom,
toBlock: currentTo,
});
allLogs.push(...logs);
currentFrom = currentTo + 1n;
} catch (error) {
// Phạm vi quá lớn (quá nhiều kết quả), chia đôi
if (isRangeTooLargeError(error) && currentTo > currentFrom) {
const mid = currentFrom + (currentTo - currentFrom) / 2n;
const firstHalf = await fetchLogsInChunks(client, {
address,
event,
fromBlock: currentFrom,
toBlock: mid,
maxBlockRange,
});
const secondHalf = await fetchLogsInChunks(client, {
address,
event,
fromBlock: mid + 1n,
toBlock: currentTo,
maxBlockRange,
});
allLogs.push(...firstHalf, ...secondHalf);
currentFrom = currentTo + 1n;
} else {
throw error;
}
}
}
return allLogs;
}
function isRangeTooLargeError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("Log response size exceeded") ||
message.includes("query returned more than") ||
message.includes("exceed maximum block range")
);
}Insight cốt lõi là chia đôi khi thất bại. Nếu phạm vi 2K block trả về quá nhiều log, chia thành hai phạm vi 1K. Nếu 1K vẫn quá nhiều, chia tiếp. Cách này tự động thích ứng với contract có hoạt động cao mà không cần bạn biết trước mật độ event.
BigInt Sẽ Cho Bạn Bài Học Nhớ Đời#
Kiểu Number của JavaScript là float 64-bit. Nó có thể biểu diễn số nguyên đến 2^53 - 1 — khoảng 9 triệu tỷ. Nghe có vẻ nhiều cho đến khi bạn nhận ra số lượng token 1 ETH tính bằng wei là 1000000000000000000 — một con số có 18 chữ số 0. Đó là 10^18, vượt xa Number.MAX_SAFE_INTEGER.
Nếu bạn vô tình ép BigInt thành Number ở bất kỳ đâu trong pipeline — JSON.parse, database driver, thư viện logging — bạn mất độ chính xác âm thầm. Con số trông gần đúng nhưng vài chữ số cuối sai. Bạn sẽ không bắt được điều này trong test vì số test nhỏ.
Đây là bug tôi đã ship lên production:
// BUG: Trông vô hại, nhưng không phải
function formatTokenAmount(amount: bigint, decimals: number): string {
return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
// Với số nhỏ thì hoạt động:
formatTokenAmount(1000000n, 6); // "1.0000" -- đúng
// Với số lớn thì hỏng âm thầm:
formatTokenAmount(123456789012345678n, 18);
// Trả về "0.1235" -- SAI, độ chính xác bị mất
// Number(123456789012345678n) === 123456789012345680
// Hai chữ số cuối bị làm tròn bởi IEEE 754Cách sửa: không bao giờ chuyển sang Number trước khi chia. Sử dụng utility tích hợp của viem, hoạt động trên string và BigInt:
import { formatUnits, parseUnits } from "viem";
// Đúng: hoạt động trên BigInt, trả về string
function formatTokenAmount(
amount: bigint,
decimals: number,
displayDecimals: number = 4
): string {
const formatted = formatUnits(amount, decimals);
// formatUnits trả về chuỗi đầy đủ độ chính xác như "0.123456789012345678"
// Cắt ngắn (không làm tròn) đến độ chính xác hiển thị mong muốn
const [whole, fraction = ""] = formatted.split(".");
const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
return `${whole}.${truncated}`;
}
// Cũng quan trọng: dùng parseUnits cho input người dùng, không bao giờ dùng parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
// parseUnits xử lý chuyển đổi string-sang-BigInt chính xác
return parseUnits(input, decimals);
}Lưu ý tôi cắt ngắn thay vì làm tròn. Điều này có chủ đích. Trong ngữ cảnh tài chính, hiển thị "1.0001 ETH" khi giá trị thực là "1.00009999..." tốt hơn hiển thị "1.0001" khi giá trị thực là "1.00005001..." và đã bị làm tròn lên. Người dùng đưa ra quyết định dựa trên số tiền hiển thị. Cắt ngắn là lựa chọn bảo thủ.
Một bẫy khác: JSON.stringify không biết serialize BigInt. Nó throw lỗi. Mọi response API bao gồm số lượng token đều cần chiến lược serialization. Tôi dùng chuyển đổi string tại ranh giới API:
// Serializer cho API response
function serializeForApi(data: Record<string, unknown>): string {
return JSON.stringify(data, (_, value) =>
typeof value === "bigint" ? value.toString() : value
);
}Chiến Lược Cache: Cái Gì, Bao Lâu, và Khi Nào Invalidate#
Không phải tất cả dữ liệu on-chain đều có cùng yêu cầu về độ tươi. Đây là phân cấp tôi sử dụng:
Cache vĩnh viễn (bất biến):
- Receipt giao dịch (khi đã được mine, chúng không thay đổi)
- Dữ liệu block đã finalize (block hash, timestamp, danh sách giao dịch)
- Bytecode contract
- Event log lịch sử từ block đã finalize
Cache từ phút đến giờ:
- Metadata token (name, symbol, decimal) — về kỹ thuật là bất biến cho hầu hết token, nhưng proxy upgrade có thể thay đổi implementation
- Phân giải ENS — TTL 5 phút hoạt động tốt
- Giá token — phụ thuộc yêu cầu chính xác, từ 30 giây đến 5 phút
Cache vài giây hoặc không cache:
- Số block hiện tại
- Số dư và nonce tài khoản
- Trạng thái giao dịch pending
- Event log chưa finalize (lại vấn đề reorg)
Implementation không cần phức tạp. Cache hai tầng với LRU trong memory và Redis đáp ứng hầu hết trường hợp:
import { LRUCache } from "lru-cache";
const memoryCache = new LRUCache<string, unknown>({
max: 10_000,
ttl: 1000 * 60, // mặc định 1 phút
});
type CacheTier = "immutable" | "short" | "volatile";
const TTL_MAP: Record<CacheTier, number> = {
immutable: 1000 * 60 * 60 * 24, // 24 giờ trong memory, vĩnh viễn trong Redis
short: 1000 * 60 * 5, // 5 phút
volatile: 1000 * 15, // 15 giây
};
async function cachedRpcCall<T>(
key: string,
tier: CacheTier,
fetcher: () => Promise<T>
): Promise<T> {
// Kiểm tra memory trước
const cached = memoryCache.get(key) as T | undefined;
if (cached !== undefined) return cached;
// Rồi Redis (nếu có)
// const redisCached = await redis.get(key);
// if (redisCached) { ... }
const result = await fetcher();
memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
return result;
}
// Sử dụng:
const receipt = await cachedRpcCall(
`receipt:${txHash}`,
"immutable",
() => client.getTransactionReceipt({ hash: txHash })
);Bài học phản trực giác: lợi ích hiệu năng lớn nhất không phải cache response RPC. Mà là tránh gọi RPC hoàn toàn. Mỗi khi bạn sắp gọi getBlock, hãy tự hỏi: tôi thực sự cần dữ liệu từ chain ngay bây giờ không, hay tôi có thể suy ra từ dữ liệu đã có? Tôi có thể lắng nghe event qua WebSocket thay vì polling không? Tôi có thể batch nhiều lệnh đọc thành một multicall không?
TypeScript và Contract ABI: Cách Đúng#
Hệ thống type của viem, powered by ABIType, cung cấp suy luận type end-to-end đầy đủ từ contract ABI đến code TypeScript. Nhưng chỉ khi bạn thiết lập đúng.
Cách sai:
// Không có suy luận type — args là unknown[], return là unknown
const result = await client.readContract({
address: "0x...",
abi: JSON.parse(abiString), // parse lúc runtime = không có thông tin type
functionName: "balanceOf",
args: ["0x..."],
});Cách đúng:
// Định nghĩa ABI dạng const để có suy luận type đầy đủ
const erc20Abi = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "success", type: "bool" }],
},
] as const;
// Giờ TypeScript biết:
// - functionName autocomplete thành "balanceOf" | "transfer"
// - args cho balanceOf là [address: `0x${string}`]
// - kiểu trả về cho balanceOf là bigint
const balance = await client.readContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: erc20Abi,
functionName: "balanceOf",
args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- fully typedAssertion as const là thứ làm nó hoạt động. Không có nó, TypeScript mở rộng kiểu ABI thành { name: string, type: string, ... }[] và toàn bộ cơ chế suy luận sụp đổ. Đây là lỗi phổ biến nhất tôi thấy trong codebase Web3 TypeScript.
Đối với dự án lớn hơn, hãy dùng @wagmi/cli để tạo contract binding có type trực tiếp từ dự án Foundry hoặc Hardhat. Nó đọc ABI đã compile và tạo file TypeScript với assertion as const đã được áp dụng sẵn. Không sao chép ABI thủ công, không trôi type.
Sự Thật Khó Chịu#
Dữ liệu blockchain là bài toán hệ thống phân tán đội lốt bài toán database. Ngay khi bạn coi nó như "chỉ là API khác," bạn bắt đầu tích lũy bug vô hình trong development và xuất hiện thất thường trong production.
Công cụ đã tốt hơn đáng kể. Viem là cải tiến lớn so với ethers.js về type safety và trải nghiệm developer. Ponder và Envio đã làm cho custom indexing dễ tiếp cận. Nhưng những thách thức cơ bản — reorg, rate limit, encoding, finality — là ở cấp protocol. Không thư viện nào trừu tượng hóa chúng đi được.
Hãy xây dựng với giả định rằng RPC sẽ nói dối bạn, block sẽ reorganize, số sẽ overflow, và cache sẽ phục vụ dữ liệu cũ. Rồi xử lý từng trường hợp một cách tường minh.
Đó là dữ liệu on-chain cấp production trông như thế nào.