以太坊交易: nonce、gas 与签名
1. 概述
以太坊上的每一个状态变化都始于一笔交易。大多数发送交易时的错误,如“nonce too low”、“replacement transaction underpriced”或“insufficient funds”,都与三个字段有关:nonce、gas 和签名。
在这篇文章中,你将看到每个字段的工作原理。然后你将自己获取 nonce、按照 EIP-1559 计算费用、离线签署交易,并在一个 Spring Boot 3.x 应用中使用 web3j 将其发送到网络。
2. EIP-1559 交易结构
从伦敦升级以来,最常见的交易类型是第 2 类(EIP-1559)。它包括以下字段:
| 字段 | 含义 |
|---|---|
chainId |
网络的 ID(1 = Mainnet,11155111 = Sepolia),防止链之间的重放攻击 |
nonce |
发送账户的交易序列号 |
maxPriorityFeePerGas |
支付给验证者的最高“小费”,每单位 gas 的费用 |
maxFeePerGas |
你愿意为每单位 gas 支付的最高总费用 |
gasLimit |
交易允许消耗的最大 gas 量 |
to、value、data |
接收地址、转账的 wei 数量和合约调用数据 |
accessList |
预期访问的地址/存储列表(可选) |
yParity、r、s |
ECDSA 签名 |
交易没有 from 字段。发送者地址从签名中恢复。这是你在第 6 节会再次看到的关键点。
3. 项目准备
将 web3j 添加到 pom.xml:
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.12.2</version>
</dependency>
声明 RPC 节点的 URL(Sepolia、Anvil 或本地 Hardhat 都可以):
eth.rpc-url=http://localhost:8545
eth.chain-id=31337
创建 Web3j bean:
@Configuration
public class Web3Config {
@Bean(destroyMethod = "shutdown")
Web3j web3j(@Value("${eth.rpc-url}") String rpcUrl) {
return Web3j.build(new HttpService(rpcUrl));
}
}
destroyMethod = "shutdown" 有助于在应用停止时关闭 web3j 的线程池和 HTTP 客户端。
4. Nonce
4.1. Nonce 是什么
Nonce 是账户(EOA)已发送交易的计数器。它从 0 开始,每次交易被纳入区块后增加 1。节点只接受 nonce 恰好等于下一个值的交易:
- nonce 过低:以
nonce too low错误被拒绝。 - nonce 过高:在 mempool 中等待,直到填补空隙。
- nonce 等于待处理交易:如果费用足够高(通常 ≥ 10%),被视为替换旧交易。
由于 nonce 的存在,同一笔已签署的交易无法被第二次重放。
4.2. 从节点获取 Nonce
public BigInteger pendingNonce(Web3j web3j, String address) throws IOException {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send()
.getTransactionCount();
}
这里我们使用 PENDING 而不是 LATEST。LATEST 只计算已纳入区块的交易。如果你刚刚发送一笔交易且它尚未被挖掘,LATEST 会返回旧的 nonce。下一笔交易将替换前一笔交易而不是接续它。
4.3. 并发发送时的 Nonce 管理
如果多个线程几乎同时调用 pendingNonce,它们可能收到相同的值。简单的方法是只向节点询问一次,然后在内存中自增:
@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 确保每个地址只向节点询问一次。getAndIncrement 是原子操作,所以两个线程永远不会收到相同的 nonce。
注意: 如果交易在 RPC 步骤失败(例如被节点拒绝),分配的 nonce 将创建一个“空隙”。所有后续交易将卡在 mempool 中。出错时,调用
reset()以与节点重新同步。当多个应用实例共享同一个钱包时,这种方法也不安全。在这种情况下,你需要分布式锁或一个集中的签名服务。
5. Gas
5.1. gasLimit 和 gasUsed
EVM 中的每个操作码都消耗固定数量的 gas。一次简单的 ETH 转账恰好消耗 21,000 gas。调用合约则消耗更多,取决于内部逻辑。
gasLimit是你设置的上限。如果 EVM 耗尽 gas 但未完成,交易回滚但你仍然为所有已使用的 gas 支付费用。gasUsed是实际使用的 gas 量。多余部分不收费。
对于合约调用,先使用 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));
}
我们添加 20% 的安全余量。链的状态在估算和挖掘之间可能会改变。
5.2. EIP-1559 费用
每单位 gas 的价格包括两部分:
- baseFee:由协议根据区块饱和度自动调整,并被销毁。
- 优先费(小费):支付给验证者以获得优先权。
你为每单位 gas 实际支付的价格是:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
fee = gasUsed * effectiveGasPrice
一个常见的公式是设置 maxFeePerGas = 2 * baseFee + tip。这个公式允许交易即使在 baseFee 连续上升数个区块后仍然有效,同时你也不会支付超过必要的费用。
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));
}
假设 baseFee = 20 gwei,tip = 1.5 gwei。对于一次 ETH 转账,你会得到:
maxFeePerGas = 41.5 gwei
effectiveGasPrice = min(41.5, 20 + 1.5) = 21.5 gwei
fee = 21000 * 21.5 gwei = 0.0004515 ETH
你的钱包必须至少拥有 value + gasLimit * maxFeePerGas。如果没有,节点返回错误 insufficient funds for gas * price + value,即使实际费用低得多。
6. 签名
6.1. 签什么和用什么签
以太坊在 secp256k1 曲线上使用 ECDSA。对于第 2 类交易,要签署的数据是:
keccak256( 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
gasLimit, to, value, data, accessList]) )
结果包括 r、s 和 yParity。任何人都可以从签名和数据恢复公钥,从而推导出 from 地址。由于 chainId 在签署部分中,为 Sepolia 签署的交易无法在 Mainnet 上重放(EIP-155)。
6.2. 使用 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);
}
}
逐步解释:
createEtherTransaction带有long类型的chainId参数创建 EIP-1559 交易。TransactionEncoder.signMessage进行 RLP 编码、keccak256 哈希、用credentials中的私钥签署,然后重新编码整个签名。Numeric.toHexString返回准备好通过eth_sendRawTransaction发送的十六进制字符串。
结果字符串始终以交易类型字节开头:
0x02f873827a6907845968...
此函数不需要网络连接。你可以在隔离的机器上签署,然后将十六进制字符串转移到另一台机器发送。
6.3. 测试签名
以下测试签署一笔交易、解码它,并验证恢复的地址与签署者一致:
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));
}
}
第一个测试确认第 2 节所述:from 从签名推导而来,不会预先存储在交易中。第二个测试显示 chainId 是签署数据的一部分。对签署的十六进制字符串执行 Hash.sha3 正是区块浏览器显示的交易哈希。
7. 发送交易并等待收据
在一个服务中组合这些部分:
@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 每 2 秒轮询节点一次,最多 30 次。收到收据后,你还需要检查 receipt.isStatusOK()。值为 false 意味着交易已纳入区块但回滚,费用仍然被扣除。
8. 常见错误
| 节点消息 | 原因 | 解决方案 |
|---|---|---|
nonce too low |
Nonce 已被使用 | 用 PENDING 重新同步 nonce |
replacement transaction underpriced |
与待处理交易的 nonce 相同但费用不够高 | 至少将 maxFeePerGas 和 maxPriorityFeePerGas 提高 10% |
intrinsic gas too low |
gasLimit 低于最小值(21,000 + data 成本) |
使用 eth_estimateGas |
insufficient funds for gas * price + value |
余额 < value + gasLimit * maxFeePerGas |
充值或降低 maxFeePerGas |
invalid chain id |
用与节点网络不同的 chainId 签署 |
从配置或 eth_chainId 读取 chainId |
收据 status = 0x0 |
执行时回滚或耗尽 gas | 检查合约逻辑,提高 gasLimit |
注意: 不要在生产服务器的
application.properties或环境变量中放置私钥。使用Credentials自签适合开发、测试和内部工具。对于持有真实资金的钱包,通过 KMS/HSM 或单独的签名者签署。应用程序应该只发送需要签署的数据,永远不要接触密钥。
注意: 如果运行多个实例使用同一个钱包,不要在内存中自行管理 nonce。将所有签名命令合并到单个服务,或为每个实例分配单独的钱包。
9. 总结
在本文中,你已经了解了决定以太坊交易是否被接受的三个组成部分:
- Nonce 确保排序和防止重放。使用
PENDING获取,并在并发发送时原子性分配。 - Gas 由
gasLimit(数量)和maxFeePerGas/maxPriorityFeePerGas(价格)组成。你只需支付gasUsed * min(maxFee, baseFee + tip)。 - 签名 ECDSA secp256k1 在包含
chainId的数据上。它既证明所有权,又是唯一推导from地址的来源。
使用 web3j,你可以完全离线签署交易,用 JUnit 在不需要节点的情况下测试,然后仅用 eth_sendRawTransaction 发送到网络。