Transaction trong Ethereum: nonce, gas và chữ ký
1. Tổng quan
Mỗi thay đổi trạng thái trên Ethereum đều bắt đầu từ một transaction. Phần lớn lỗi khi gửi transaction như “nonce too low”, “replacement transaction underpriced” hay “insufficient funds” đều liên quan tới ba trường: nonce, gas và chữ ký.
Trong bài này, bạn sẽ xem từng trường hoạt động ra sao. Sau đó bạn sẽ tự lấy nonce, tính phí theo EIP-1559, ký transaction offline và gửi nó lên mạng bằng web3j trong một ứng dụng Spring Boot 3.x.
2. Cấu trúc một transaction EIP-1559
Từ bản nâng cấp London, kiểu transaction phổ biến nhất là type 2 (EIP-1559). Nó gồm các trường sau:
| Trường | Ý nghĩa |
|---|---|
chainId |
ID của mạng (1 = Mainnet, 11155111 = Sepolia), chống replay giữa các chain |
nonce |
Số thứ tự transaction của tài khoản gửi |
maxPriorityFeePerGas |
Tiền “tip” tối đa trả cho validator, tính trên mỗi đơn vị gas |
maxFeePerGas |
Tổng phí tối đa bạn chấp nhận trả cho mỗi đơn vị gas |
gasLimit |
Số gas tối đa transaction được phép tiêu |
to, value, data |
Địa chỉ nhận, số wei chuyển và dữ liệu gọi contract |
accessList |
Danh sách địa chỉ/storage dự kiến truy cập (tuỳ chọn) |
yParity, r, s |
Chữ ký ECDSA |
Transaction không có trường from. Địa chỉ người gửi được khôi phục từ chữ ký. Đây là điểm mấu chốt bạn sẽ thấy lại ở mục 6.
3. Chuẩn bị dự án
Thêm web3j vào pom.xml:
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.12.2</version>
</dependency>
Khai báo URL của node RPC (Sepolia, Anvil hoặc Hardhat local đều được):
eth.rpc-url=http://localhost:8545
eth.chain-id=31337
Tạo bean Web3j:
@Configuration
public class Web3Config {
@Bean(destroyMethod = "shutdown")
Web3j web3j(@Value("${eth.rpc-url}") String rpcUrl) {
return Web3j.build(new HttpService(rpcUrl));
}
}
destroyMethod = "shutdown" giúp đóng thread pool và HTTP client của web3j khi ứng dụng dừng.
4. Nonce
4.1. Nonce là gì
Nonce là bộ đếm số transaction mà một tài khoản (EOA) đã gửi. Nó bắt đầu từ 0 và tăng đúng 1 sau mỗi transaction được đưa vào block. Node chỉ chấp nhận transaction có nonce đúng bằng giá trị tiếp theo:
- Nonce nhỏ hơn: bị từ chối với lỗi
nonce too low. - Nonce lớn hơn: nằm chờ trong mempool cho tới khi khoảng trống được lấp.
- Nonce bằng một transaction đang pending: được coi là thay thế transaction cũ, nếu phí cao hơn đủ mức (thường ≥ 10%).
Nhờ nonce, cùng một transaction đã ký không thể bị phát lại lần thứ hai.
4.2. Lấy nonce từ node
public BigInteger pendingNonce(Web3j web3j, String address) throws IOException {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send()
.getTransactionCount();
}
Ở đây ta dùng PENDING thay vì LATEST. LATEST chỉ đếm transaction đã vào block. Nếu bạn vừa gửi một transaction và nó chưa được mine, LATEST sẽ trả lại nonce cũ. Transaction tiếp theo khi đó sẽ thay thế transaction trước thay vì nối tiếp nó.
4.3. Quản lý nonce khi gửi đồng thời
Nếu nhiều thread cùng gọi pendingNonce gần như một lúc, chúng có thể nhận cùng một giá trị. Cách đơn giản là chỉ hỏi node một lần, sau đó tự tăng trong bộ nhớ:
@Component
public class NonceManager {
private final Web3j web3j;
private final ConcurrentHashMap<String, AtomicLong> counters = new ConcurrentHashMap<>();
public NonceManager(Web3j web3j) {
this.web3j = web3j;
}
public BigInteger next(String address) {
AtomicLong counter = counters.computeIfAbsent(
address.toLowerCase(), a -> new AtomicLong(fetchPending(a)));
return BigInteger.valueOf(counter.getAndIncrement());
}
public void reset(String address) {
counters.remove(address.toLowerCase());
}
private long fetchPending(String address) {
try {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send().getTransactionCount().longValueExact();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
computeIfAbsent đảm bảo node chỉ được hỏi một lần cho mỗi địa chỉ. getAndIncrement là thao tác atomic, nên hai thread không bao giờ nhận trùng nonce.
Lưu ý: Nếu một transaction gửi thất bại ở bước RPC (ví dụ bị node từ chối), nonce đã cấp sẽ tạo ra “lỗ hổng”. Mọi transaction sau đó sẽ kẹt trong mempool. Khi gặp lỗi, hãy gọi
reset()để đồng bộ lại với node. Cách này cũng không an toàn khi nhiều instance ứng dụng dùng chung một ví. Trường hợp đó bạn cần lock phân tán hoặc một service ký tập trung.
5. Gas
5.1. gasLimit và gasUsed
Mỗi opcode trong EVM tốn một lượng gas cố định. Một lần chuyển ETH thuần tốn đúng 21.000 gas. Gọi contract thì tốn nhiều hơn, tuỳ logic bên trong.
gasLimitlà mức trần do bạn đặt. Nếu EVM tiêu hết mà chưa chạy xong, transaction revert nhưng bạn vẫn mất phí cho toàn bộ gas đã dùng.gasUsedlà lượng gas thực tế. Phần còn thừa không bị tính phí.
Với lời gọi contract, hãy ước lượng trước bằng eth_estimateGas:
public BigInteger estimateGas(Web3j web3j, String from, String to, String data) throws IOException {
var call = org.web3j.protocol.core.methods.request.Transaction
.createEthCallTransaction(from, to, data);
BigInteger estimate = web3j.ethEstimateGas(call).send().getAmountUsed();
return estimate.multiply(BigInteger.valueOf(120)).divide(BigInteger.valueOf(100));
}
Ta cộng thêm 20% biên an toàn. Trạng thái chain có thể thay đổi giữa lúc ước lượng và lúc transaction được mine.
5.2. Phí theo EIP-1559
Giá mỗi đơn vị gas gồm hai phần:
- baseFee: do giao thức tự điều chỉnh theo độ đầy của block, và bị đốt.
- priority fee (tip): trả cho validator để được ưu tiên.
Giá thực tế bạn trả cho mỗi gas là:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
fee = gasUsed * effectiveGasPrice
Một công thức phổ biến là đặt maxFeePerGas = 2 * baseFee + tip. Công thức này cho phép transaction vẫn hợp lệ dù baseFee tăng liên tiếp vài block, mà bạn không trả nhiều hơn mức cần thiết.
public record FeeQuote(BigInteger maxPriorityFeePerGas, BigInteger maxFeePerGas) {}
public FeeQuote quoteFee(Web3j web3j) throws IOException {
BigInteger baseFee = web3j.ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false)
.send().getBlock().getBaseFeePerGas();
BigInteger tip = web3j.ethMaxPriorityFeePerGas().send().getMaxPriorityFeePerGas();
return new FeeQuote(tip, baseFee.multiply(BigInteger.TWO).add(tip));
}
Giả sử baseFee = 20 gwei và tip = 1,5 gwei. Với một lần chuyển ETH, bạn sẽ có:
maxFeePerGas = 41.5 gwei
effectiveGasPrice = min(41.5, 20 + 1.5) = 21.5 gwei
fee = 21000 * 21.5 gwei = 0.0004515 ETH
Ví của bạn phải có ít nhất value + gasLimit * maxFeePerGas. Nếu không, node trả lỗi insufficient funds for gas * price + value, kể cả khi phí thực tế thấp hơn nhiều.
6. Chữ ký
6.1. Ký cái gì và bằng gì
Ethereum dùng ECDSA trên đường cong secp256k1. Với transaction type 2, dữ liệu được ký là:
keccak256( 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
gasLimit, to, value, data, accessList]) )
Kết quả gồm r, s và yParity. Từ chữ ký và dữ liệu, bất kỳ ai cũng khôi phục được public key, từ đó suy ra địa chỉ from. Vì chainId nằm trong phần được ký, transaction ký cho Sepolia không thể phát lại trên Mainnet (EIP-155).
6.2. Ký offline với web3j
@Component
public class TransactionSigner {
private static final BigInteger TRANSFER_GAS = BigInteger.valueOf(21_000);
public String signTransfer(Credentials credentials, long chainId, BigInteger nonce,
String to, BigInteger valueWei, FeeQuote fee) {
RawTransaction tx = RawTransaction.createEtherTransaction(
chainId, nonce, TRANSFER_GAS, to, valueWei,
fee.maxPriorityFeePerGas(), fee.maxFeePerGas());
byte[] signed = TransactionEncoder.signMessage(tx, credentials);
return Numeric.toHexString(signed);
}
}
Giải thích từng bước:
createEtherTransactionvới tham sốchainIdkiểulongtạo transaction EIP-1559.TransactionEncoder.signMessageRLP-encode, băm keccak256, ký bằng private key trongcredentials, rồi encode lại cả chữ ký.Numeric.toHexStringtrả về chuỗi hex sẵn sàng gửi quaeth_sendRawTransaction.
Chuỗi kết quả luôn bắt đầu bằng byte loại transaction:
0x02f873827a6907845968...
Hàm này không cần kết nối mạng. Bạn có thể ký trên một máy cô lập, rồi mang chuỗi hex sang máy khác để gửi.
6.3. Kiểm thử chữ ký
Test dưới đây ký một transaction, decode lại và kiểm tra rằng địa chỉ khôi phục được trùng với người ký:
class TransactionSignerTest {
private static final Credentials ALICE = Credentials.create(
"0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318");
private static final String BOB = "0x3535353535353535353535353535353535353535";
private static final FeeQuote FEE = new FeeQuote(
Convert.toWei("1.5", Convert.Unit.GWEI).toBigInteger(),
Convert.toWei("41.5", Convert.Unit.GWEI).toBigInteger());
private final TransactionSigner signer = new TransactionSigner();
@Test
void signedTransferRecoversSenderAndKeepsFields() throws SignatureException {
String hex = signer.signTransfer(ALICE, 11155111L, BigInteger.valueOf(7), BOB,
Convert.toWei("0.01", Convert.Unit.ETHER).toBigInteger(), FEE);
assertThat(hex).startsWith("0x02");
var decoded = (SignedRawTransaction) TransactionDecoder.decode(hex);
assertThat(decoded.getFrom()).isEqualToIgnoringCase(ALICE.getAddress());
assertThat(decoded.getNonce()).isEqualTo(BigInteger.valueOf(7));
assertThat(((Transaction1559) decoded.getTransaction()).getChainId())
.isEqualTo(11155111L);
}
@Test
void differentChainIdProducesDifferentTransactionHash() {
BigInteger value = BigInteger.ONE;
String sepolia = signer.signTransfer(ALICE, 11155111L, BigInteger.ZERO, BOB, value, FEE);
String mainnet = signer.signTransfer(ALICE, 1L, BigInteger.ZERO, BOB, value, FEE);
assertThat(Hash.sha3(sepolia)).isNotEqualTo(Hash.sha3(mainnet));
}
}
Test thứ nhất xác nhận điều đã nói ở mục 2: from được suy ra từ chữ ký, không lưu sẵn trong transaction. Test thứ hai cho thấy chainId là một phần của dữ liệu được ký. Hash.sha3 trên chuỗi hex đã ký chính là transaction hash mà block explorer hiển thị.
7. Gửi transaction và chờ receipt
Ghép các phần lại trong một service:
@Service
public class PaymentService {
private final Web3j web3j;
private final NonceManager nonceManager;
private final TransactionSigner signer;
private final long chainId;
public PaymentService(Web3j web3j, NonceManager nonceManager, TransactionSigner signer,
@Value("${eth.chain-id}") long chainId) {
this.web3j = web3j;
this.nonceManager = nonceManager;
this.signer = signer;
this.chainId = chainId;
}
public TransactionReceipt pay(Credentials payer, String to, BigInteger valueWei) throws Exception {
BigInteger nonce = nonceManager.next(payer.getAddress());
FeeQuote fee = quoteFee();
String signedHex = signer.signTransfer(payer, chainId, nonce, to, valueWei, fee);
EthSendTransaction response = web3j.ethSendRawTransaction(signedHex).send();
if (response.hasError()) {
nonceManager.reset(payer.getAddress());
throw new IllegalStateException(response.getError().getMessage());
}
return new PollingTransactionReceiptProcessor(web3j, 2_000, 30)
.waitForTransactionReceipt(response.getTransactionHash());
}
private FeeQuote quoteFee() throws IOException {
BigInteger baseFee = web3j.ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false)
.send().getBlock().getBaseFeePerGas();
BigInteger tip = web3j.ethMaxPriorityFeePerGas().send().getMaxPriorityFeePerGas();
return new FeeQuote(tip, baseFee.multiply(BigInteger.TWO).add(tip));
}
}
PollingTransactionReceiptProcessor hỏi node mỗi 2 giây, tối đa 30 lần. Khi nhận được receipt, bạn cần kiểm tra thêm receipt.isStatusOK(). Giá trị false nghĩa là transaction đã vào block nhưng revert, và phí vẫn bị trừ.
8. Lỗi hay gặp
| Thông báo từ node | Nguyên nhân | Cách xử lý |
|---|---|---|
nonce too low |
Nonce đã được dùng | Đồng bộ lại nonce với PENDING |
replacement transaction underpriced |
Trùng nonce với transaction pending nhưng phí không đủ cao | Tăng cả maxFeePerGas và maxPriorityFeePerGas ít nhất 10% |
intrinsic gas too low |
gasLimit thấp hơn mức tối thiểu (21.000 + chi phí data) |
Dùng eth_estimateGas |
insufficient funds for gas * price + value |
Số dư < value + gasLimit * maxFeePerGas |
Nạp thêm hoặc giảm maxFeePerGas |
invalid chain id |
Ký với chainId khác mạng của node |
Đọc chainId từ cấu hình hoặc eth_chainId |
Receipt status = 0x0 |
Revert hoặc hết gas khi chạy | Kiểm tra logic contract, tăng gasLimit |
Lưu ý: Đừng để private key trong
application.propertieshay biến môi trường trên server production. Tự ký bằngCredentialsphù hợp cho môi trường dev, test và các công cụ nội bộ. Với ví giữ tiền thật, hãy ký qua KMS/HSM hoặc một signer riêng biệt. Ứng dụng chỉ nên gửi dữ liệu cần ký, không bao giờ chạm vào khoá.
Lưu ý: Không nên tự quản lý nonce trong bộ nhớ nếu bạn chạy nhiều instance cùng dùng một ví. Hãy dồn mọi lệnh ký về một service duy nhất, hoặc cấp cho mỗi instance một ví riêng.
9. Kết luận
Trong bài này, bạn đã xem ba thành phần quyết định một Ethereum transaction có được chấp nhận hay không:
- Nonce đảm bảo thứ tự và chống phát lại. Hãy lấy theo
PENDINGvà cấp phát atomic khi gửi đồng thời. - Gas gồm
gasLimit(bao nhiêu) vàmaxFeePerGas/maxPriorityFeePerGas(giá bao nhiêu). Bạn chỉ trảgasUsed * min(maxFee, baseFee + tip). - Chữ ký ECDSA secp256k1 trên dữ liệu có chứa
chainId. Nó vừa chứng minh quyền sở hữu, vừa là nguồn duy nhất để suy ra địa chỉfrom.
Với web3j, bạn có thể ký transaction hoàn toàn offline, kiểm thử bằng JUnit mà không cần node, rồi gửi lên mạng chỉ với eth_sendRawTransaction.