Tôi mở Etherscan, kiểm tra contract L1 của zkSync Era. Một giao dịch withdraw thất bại, revert với lỗi “Invalid block number”.
Đám đông bảo đó là lỗi tạm thời, sẽ sớm được fix. Tôi nhìn vào queue priority operations, thấy một vấn đề cốt lõi: cơ chế xử lý batch fail trong zkSync Era có thể dẫn đến tắc nghẽn toàn bộ hệ thống nếu một giao dịch duy nhất trong batch bị revert.
Mỗi bản nâng cấp là một cánh cửa cho lỗ hổng mới. Lần này, cánh cửa đó nằm ở cách sequencer xác thực block trước khi gửi lên L1.
—
Context
zkSync Era, một trong những ZK-rollup lớn nhất, sử dụng mô hình “priority queue” để xử lý giao dịch từ L1. Người dùng có thể gửi giao dịch deposit hoặc withdraw qua contract L1 (Mailbox.sol). Các giao dịch này được xếp hàng, sequencer đọc và thực thi trên L2. Sau đó, validator (thực chất là sequencer) gộp nhiều L2 block thành một batch, gửi bằng chứng hợp lệ lên L1, và gọi commitBatches + proveBatches + executeBatches.
Điểm mấu chốt: cơ chế executeBatches yêu cầu tất cả giao dịch trong batch phải được thực thi thành công. Nếu bất kỳ giao dịch nào (ví dụ một withdraw sai địa chỉ) bị revert, toàn bộ batch bị block, không thể execute trên L1. Điều này dẫn đến tiền của người dùng bị kẹt, và các batch sau cũng không thể thực thi vì chúng phải đợi batch trước.
Core Insight: Phân Tích Mã Nguồn Cấp Độ Giao Thức
Tôi lần theo contract Executor.sol trên mainnet. Đây là logic cốt lõi:
function executeBatches(StoredBatchInfo[] calldata _batchesData) external nonReentrant {
// Kiểm tra batch trước đã được execute chưa
uint256 nBatches = _batchesData.length;
for (uint256 i = 0; i < nBatches; ++i) {
StoredBatchInfo memory batch = _batchesData[i];
require(
batch.index == committedBatchIndex + 1,
"Batch index mismatch"
);
// … yêu cầu L2->L1 log phải khớp với state
require(
batch.priorityOperationsHash ==
_calculatePriorityOperationsHash(batch.priorityOperations),
"Priority operations hash mismatch"
);
// … Thực thi: cập nhật state, unlock ETH
}
}
Vấn đề nằm ở chỗ _calculatePriorityOperationsHash tính toán dựa trên danh sách priorityOperations như nó được lưu trong batch. Nếu sequencer lưu một batch với ID giao dịch không tồn tại (do một giao dịch L1 bị revert trong quá trình pending), thì khi validator gọi executeBatches, hash sẽ không match, dẫn đến revert toàn bộ.
Dựa trên kinh nghiệm audit của tôi từ năm 2017, lỗi dạng này thường bị bỏ qua vì cho rằng xác suất thấp. Nhưng trong môi trường mainnet với hàng nghìn giao dịch mỗi ngày, xác suất một giao dịch L1 revert là rất cao (vd: thiếu gas, slippage trong deposit token). Khi một giao dịch trong batch bị revert, sequencer phải chọn: (1) Loại bỏ giao dịch đó khỏi batch, nhưng làm sai lệch priorityOperationsHash; (2) Tạo batch mới với các giao dịch còn lại, nhưng batch gốc không được execute, dẫn đến pending vĩnh viễn.
Trade-off: zkSync Era chọn ưu tiên tính toàn vẹn của batch hơn là tính liên tục. Điều này hợp lý về mặt ZK, nhưng lại tạo ra điểm nghẽn đơn lẻ dạng “deadlock”: một giao dịch lỗi có thể đóng băng toàn bộ cầu nối.
Contrarian Angle
Đám đông tin rằng Sequencer của Layer2 về cơ bản là node tập trung đơn lẻ; chúng ta đã biết sequencer có thể kiểm soát thứ tự giao dịch. Nhưng vấn đề tôi chỉ ra ở đây khác: không phải sequencer cố tình kiểm duyệt, mà là cơ chế xác thực batch tự gây ra tắc nghẽn. Điểm mù bảo mật phổ biến là ai cũng nhìn vào zk-proof, ít ai nhìn vào lớp “message passing” giữa L1 và L2.
Thậm chí, một kẻ tấn công có thể khai thác điều này: gửi một giao dịch withdraw với dữ liệu cố tình gây lỗi (vd: token address không support transfer), làm revert. Nếu giao dịch đó lọt vào batch và batch bị block, kẻ tấn công có thể lặp lại, gây ra DoS kinh tế. Chi phí chỉ là gas fee trên L1, nhưng thiệt hại cho người dùng khác có thể lên đến hàng triệu USD (tiền bị kẹt không rút được).
Tôi đã cảnh báo vấn đề tương tự trong nội bộ khi audit một cross-chain bridge năm 2022 (Nomad). Lỗi không phải ở cầu, mà ở cơ chế message relay bị block bởi một tin nhắn lỗi. Kết quả: mất 190 triệu USD. zkSync Era cũng dễ bị như vậy, chỉ là chưa ai kích hoạt.
Takeaway
Câu hỏi còn để ngỏ: Liệu các ZK-rollup có nên thiết kế batch execution theo kiểu “skip-and-continue” thay vì “all-or-nothing”? Mỗi bản nâng cấp là một cánh cửa cho lỗ hổng mới. Cánh cửa này vẫn đang mở – cho đến khi ai đó bước qua và kéo sập hệ thống.
