Ethereum のトランザクション: nonce、gas、署名

1. 概要

Ethereum のすべての状態変更はトランザクションから始まります。トランザクション送信時の「nonce too low」「replacement transaction underpriced」「insufficient funds」などのほとんどのエラーは、3 つのフィールド:nonce、gas、署名に関連しています。

このガイドでは、各フィールドの動作を確認します。その後、nonce を取得し、EIP-1559 に従って手数料を計算し、トランザクションをオフラインで署名し、Spring Boot 3.x アプリケーションで web3j を使ってネットワークに送信します。

2. EIP-1559 トランザクションの構造

London アップグレード以降、最も一般的なトランザクションタイプはタイプ 2(EIP-1559)です。以下のフィールドで構成されています:

フィールド 意味
chainId ネットワークの ID(1 = Mainnet、11155111 = Sepolia)、チェーン間での再生攻撃を防止
nonce 送信者アカウントのトランザクション序番
maxPriorityFeePerGas バリデーターへの最大チップ、ガスユニットあたり
maxFeePerGas ガスユニットあたりの最大手数料の合計
gasLimit トランザクションが消費できる最大ガス量
to、value、data 受信者アドレス、転送する wei、コントラクト呼び出しデータ
accessList アクセスが予想されるアドレス/ストレージのリスト(オプション)
yParity、r、s ECDSA 署名

トランザクションは from フィールドを持たないことに注意してください。送信者のアドレスは署名から復元されます。これは 6 番目のセクションで再度出てくる重要なポイントです。

3. プロジェクトのセットアップ

pom.xml に web3j を追加します:

<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 が大きい場合:空きが埋まるまでメモリプール内で待機します。
  • Nonce が保留中トランザクションと同じ場合:古いトランザクションの置き換えと見なされます(手数料が十分に高い場合、通常 ≥ 10%)。

Nonce のおかげで、同じ署名済みトランザクションが 2 回目に再発行されることはありません。

4.2. ノードから nonce を取得

public BigInteger pendingNonce(Web3j web3j, String address) throws IOException {
    return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
            .send()
            .getTransactionCount();
}

ここで LATEST ではなく PENDING を使用しています。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 は各アドレスのノードへの問い合わせが 1 回だけに確実になります。getAndIncrement はアトミック操作なので、2 つのスレッドが同じ nonce を受け取ることはありません。

注意: トランザクション送信が RPC ステップで失敗した場合(例えば、ノードに拒否された場合)、割り当てられた nonce は「ギャップ」を作成します。その後のすべてのトランザクションはメモリプール内でスタックします。エラーが発生した場合は、reset() を呼び出してノードと再同期してください。この方法も、複数のアプリケーションインスタンスが同じウォレットを共有している場合は安全ではありません。その場合は、分散ロックまたは集中署名サービスが必要です。

5. Gas

5.1. gasLimit と gasUsed

EVM 内の各オペコードは固定量のガスを消費します。純粋な ETH 転送には正確に 21,000 gas かかります。コントラクト呼び出しはロジックによってそれ以上かかります。

  • gasLimit はあなたが設定した上限です。EVM が実行を終える前にガスを使い切った場合、トランザクションはリバートしますが、使用されたすべてのガスの手数料を失います。
  • gasUsed は実際に使用されたガス量です。余った分は手数料の対象にはなりません。

コントラクト呼び出しについては、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 による手数料

ガスユニットあたりの価格は 2 つの部分で構成されます:

  • baseFee: プロトコルがブロックの充満度に応じて自動調整し、バーンされます。
  • priority fee (tip): バリデーターに支払われ、優先処理を受けるために。

ガスごとに実際に支払う価格は次の通りです:

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. 何に署名し、どのように署名するか

Ethereum は 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 に chainId パラメータを long 型で指定すると、EIP-1559 トランザクションを作成します。
  • TransactionEncoder.signMessage は RLP エンコード、keccak256 ハッシュ、credentials の秘密鍵で署名し、署名全体を再度エンコードします。
  • Numeric.toHexString は eth_sendRawTransaction 経由で送信する準備ができた 16 進数文字列を返します。

結果の文字列は常にトランザクションタイプのバイトで始まります:

0x02f873827a6907845968...

この関数はネットワーク接続を必要としません。孤立したマシンで署名してから、16 進数文字列を別のマシンに持ってきて送信できます。

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 は署名から推測され、トランザクションに事前に保存されていません。2 番目のテストは chainId が署名されたデータの一部であることを示します。hex 文字列の Hash.sha3 は、ブロックエクスプローラーが表示するトランザクションハッシュそのものです。

7. トランザクション送信とレシート待機

すべてのパーツを 1 つのサービスに統合します:

@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 コントラクト実行中のリバートまたはガス不足 コントラクトロジックを確認し、gasLimit を増やします

注意: 本番環境のサーバーで秘密鍵を application.properties または環境変数に入れないでください。Credentials による自署は開発、テスト、内部ツール環境に適しています。実際の資金を保有するウォレットの場合は、KMS/HSM 経由で署名するか、別の署名者を使用してください。アプリケーションは署名が必要なデータのみを送信し、秘密鍵に触れてはいけません。

注意: 複数のアプリケーションインスタンスが同じウォレットを使用している場合は、メモリ内で nonce を自分で管理しないでください。すべての署名コマンドを 1 つのサービスに集約するか、各インスタンスに別々のウォレットを割り当ててください。

9. 結論

このガイドでは、Ethereum トランザクションが受け入れられるかどうかを決定する 3 つの重要な要素を見てきました:

  • Nonce は順序を保証し、再生を防ぎます。PENDING から取得し、同時送信時にアトミックに割り当てます。
  • Gas は gasLimit(いくら)と maxFeePerGas/maxPriorityFeePerGas(価格)で構成されます。gasUsed * min(maxFee, baseFee + tip) のみを支払います。
  • 署名 は secp256k1 上の ECDSA で、chainId を含むデータに対するもの。所有権を証明し、from アドレスを推測する唯一の情報源です。

web3j を使えば、トランザクションを完全にオフラインで署名し、ノードなしで JUnit でテストし、eth_sendRawTransaction だけでネットワークに送信できます。

ethereumevmweb3jtransactionjava