以太坊交易: 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 发送到网络。

ethereumevmweb3jtransactionjava