⛓️

EIP-1559なトランザクションのリトライ実行でエラーになる場合がある

2022/09/09に公開

想定されるシチュエーション

EIP-1559なトランザクションを送信した後、ガス代を10%上乗せしてトランザクションキャンセルしたりしたい場合がある。

この時にあまりにもトランザクション送信の間隔が短すぎると、10%以上ガス代上乗せしていても replacement transaction underpriced なエラーがEthereumノードから返ってくる。

上乗せロジックとしてはmaxPriorityFeePerGasとmaxFeePerGasをどちらも10%以上上乗せしておけば、問題ないはずだがエラーとなってしまう。

検証

検証のためにgo-ethereum(geth)に少し手を入れて、ローカルのEthereumノードでも問題が再現するか確認してみた。

使ったのはv1.10.23
https://github.com/ethereum/go-ethereum/releases/tag/v1.10.23

以下のような変更を入れる。

diff --git a/core/tx_list.go b/core/tx_list.go
index f141a03bb..a506e1b73 100644
--- a/core/tx_list.go
+++ b/core/tx_list.go
@@ -18,6 +18,7 @@ package core

 import (
        "container/heap"
+       "log"
        "math"
        "math/big"
        "sort"
@@ -282,7 +283,9 @@ func (l *txList) Add(tx *types.Transaction, priceBump uint64) (bool, *types.Tran
        // If there's an older better transaction, abort
        old := l.txs.Get(tx.Nonce())
        if old != nil {
+               log.Println("v:", old.GasFeeCap(), old.GasTipCap())
                if old.GasFeeCapCmp(tx) >= 0 || old.GasTipCapCmp(tx) >= 0 {
+                       log.Printf("v: old-fee=%v old-tip=%v, tx-fee=%v tx-tip=%v", old.GasFeeCap(), old.GasTipCap(), tx.GasFeeCap(), tx.GasTipCap())
                        return false, nil
                }
                // thresholdFeeCap = oldFC  * (100 + priceBump) / 100
@@ -299,6 +302,7 @@ func (l *txList) Add(tx *types.Transaction, priceBump uint64) (bool, *types.Tran
                // old ones as well as checking the percentage threshold to ensure that
                // this is accurate for low (Wei-level) gas price replacements.
                if tx.GasFeeCapIntCmp(thresholdFeeCap) < 0 || tx.GasTipCapIntCmp(thresholdTip) < 0 {
+                       log.Printf("s: old-fee=%v old-tip=%v, tx-fee=%v tx-tip=%v", old.GasFeeCap(), old.GasTipCap(), tx.GasFeeCap(), tx.GasTipCap())
                        return false, nil
                }
        }

make geth でビルド。これを動かす。

The Merge以降、consensus clientと一緒に動かさないとexecution client(geth)でブロックの取り込みが行われない。そこに気づくのに時間がかかってしまった。
consensus clientは prysm 使った。そのあたりはドキュメント読めばOK。
https://geth.ethereum.org/docs/interface/merge

トランザクション投げるスクリプト

長いけど、やりたいこととしてはEIP-1559なトランザクション投げて、任意の秒数スリープした後、ガス代を10%増にしたトランザクションを投げる、という感じ。


const ethers = require("ethers")
const { BigNumber } = require("@ethersproject/bignumber")

const CHAIN_ID = 5

const privateKey = ("YOUR_PRIVATE_KEY").toString('hex');
const wallet = new ethers.Wallet(privateKey);

const address = wallet.address;
console.log("Public Address:", address);

const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))

const httpsUrl = "http://localhost:8545";
console.log("HTTPS Target", httpsUrl);

const init = async function () {
  const httpsProvider = new ethers.providers.JsonRpcProvider(httpsUrl);

  const nonce = await httpsProvider.getTransactionCount(address);
  console.log("Nonce:", nonce);

  const feeData = await httpsProvider.getFeeData();
  console.log("Fee Data:", feeData);

  const maxPriorityFee = feeData["maxPriorityFeePerGas"]
  const maxFee = feeData["maxFeePerGas"]
  // const maxPriorityFee = BigNumber.from(1500000000)
  // const maxFee = BigNumber.from(1500000016)
  console.log("tx1's fee", maxPriorityFee.toString(), maxFee.toString())

  // first
  const tx = {
    type: 2,
    nonce: nonce,
    to: "0xD3e5D9c622D536cC07d085a72A825c323d8BEDBa",
    maxPriorityFeePerGas: maxPriorityFee,
    maxFeePerGas: maxFee,
    value: ethers.utils.parseEther("0.001"),
    gasLimit: "21000",
    chainId: CHAIN_ID,
  };
  console.log("Tx1:", tx);

  const signedTx = await wallet.signTransaction(tx);
  console.log("Signed Transaction:", signedTx);

  const txHash = ethers.utils.keccak256(signedTx);
  console.log("Precomputed txHash:", txHash);
  console.log(`https://goerli.etherscan.io/tx/${txHash}`);

  httpsProvider.sendTransaction(signedTx).then(console.log);

  await sleep(1500);

  const ratio = 110 // percent

  const maxPriorityFee2 = maxPriorityFee.mul(BigNumber.from(ratio)).div(BigNumber.from(100))
  const maxFee2 = maxFee.mul(BigNumber.from(ratio)).div(BigNumber.from(100))
  console.log("tx2's fee", maxPriorityFee2.toString(), maxFee2.toNumber())

  // first
  const tx2 = {
    type: 2,
    nonce: nonce,
    to: "0xD3e5D9c622D536cC07d085a72A825c323d8BEDBa",
    maxPriorityFeePerGas: maxPriorityFee2,
    maxFeePerGas: maxFee2,
    value: ethers.utils.parseEther("0.001"),
    gasLimit: "21000",
    chainId: CHAIN_ID,
  };
  console.log("Tx2:", tx2);

  const signedTx2 = await wallet.signTransaction(tx2);
  console.log("Signed Transaction:", signedTx2);

  const txHash2 = ethers.utils.keccak256(signedTx2);
  console.log("Precomputed txHash:", txHash2);
  console.log(`https://goerli.etherscan.io/tx/${txHash2}`);

  httpsProvider.sendTransaction(signedTx2).then(console.log);
};

init();

これ実行すると、スリープ時間なしとか500msスリープとかだと replacement transaction underpriced エラーが返ってきて、1500ms以上にすると返ってこなくなる。

このエラー発生する時にgethで出力されてたログが以下。

2022/09/09 17:38:30 v: 1650000024 1650000000
2022/09/09 17:38:30 v: old-fee=1650000024 old-tip=1650000000, tx-fee=1500000022 tx-tip=1500000000

ログ出力されたまま解釈すると、古い(初めに投げた)Txのフィーが高い方で、新しい(2通目)Txのフィーが低い方ということになる。周辺のコードを読んでみると、nonceをベースにtxpool?からトランザクションのデータを取り出してるっぽくて、geth内部の順序等で2通目のtxを古い方として扱い、1通目のtxを新しい方として扱ったために、このエラーになっているような感じだった。

結論

これ最終的には(ノードからエラーが返ってくるものの)2通目のガス代でちゃんと処理されるので、結果的に動きとしては問題ない感じ。

通常、トランザクションリトライでこんなに早くリトライすることはほぼないと思われるので、通常想定される使い方では実害はないと思う。

もしこんな事象に遭遇した方のための参考ということで。

Discussion