Commit 771988b6 authored by Roberto Bayardo's avatar Roberto Bayardo Committed by GitHub

update txmgr to support sending blob txs (#8760)

parent 28c35962
...@@ -19,13 +19,14 @@ type priceBumpTest struct { ...@@ -19,13 +19,14 @@ type priceBumpTest struct {
newBasefee int64 newBasefee int64
expectedTip int64 expectedTip int64
expectedFC int64 expectedFC int64
isBlobTx bool
} }
func (tc *priceBumpTest) run(t *testing.T) { func (tc *priceBumpTest) run(t *testing.T) {
prevFC := calcGasFeeCap(big.NewInt(tc.prevBasefee), big.NewInt(tc.prevGasTip)) prevFC := calcGasFeeCap(big.NewInt(tc.prevBasefee), big.NewInt(tc.prevGasTip))
lgr := testlog.Logger(t, log.LvlCrit) lgr := testlog.Logger(t, log.LvlCrit)
tip, fc := updateFees(big.NewInt(tc.prevGasTip), prevFC, big.NewInt(tc.newGasTip), big.NewInt(tc.newBasefee), lgr) tip, fc := updateFees(big.NewInt(tc.prevGasTip), prevFC, big.NewInt(tc.newGasTip), big.NewInt(tc.newBasefee), tc.isBlobTx, lgr)
require.Equal(t, tc.expectedTip, tip.Int64(), "tip must be as expected") require.Equal(t, tc.expectedTip, tip.Int64(), "tip must be as expected")
require.Equal(t, tc.expectedFC, fc.Int64(), "fee cap must be as expected") require.Equal(t, tc.expectedFC, fc.Int64(), "fee cap must be as expected")
...@@ -39,51 +40,111 @@ func TestUpdateFees(t *testing.T) { ...@@ -39,51 +40,111 @@ func TestUpdateFees(t *testing.T) {
newGasTip: 90, newBasefee: 900, newGasTip: 90, newBasefee: 900,
expectedTip: 110, expectedFC: 2310, expectedTip: 110, expectedFC: 2310,
}, },
{
prevGasTip: 100, prevBasefee: 1000,
newGasTip: 90, newBasefee: 900,
expectedTip: 200, expectedFC: 4200,
isBlobTx: true,
},
{ {
prevGasTip: 100, prevBasefee: 1000, prevGasTip: 100, prevBasefee: 1000,
newGasTip: 101, newBasefee: 1000, newGasTip: 101, newBasefee: 1000,
expectedTip: 110, expectedFC: 2310, expectedTip: 110, expectedFC: 2310,
}, },
{
prevGasTip: 100, prevBasefee: 1000,
newGasTip: 101, newBasefee: 1000,
expectedTip: 200, expectedFC: 4200,
isBlobTx: true,
},
{ {
prevGasTip: 100, prevBasefee: 1000, prevGasTip: 100, prevBasefee: 1000,
newGasTip: 100, newBasefee: 1001, newGasTip: 100, newBasefee: 1001,
expectedTip: 110, expectedFC: 2310, expectedTip: 110, expectedFC: 2310,
}, },
{
prevGasTip: 100, prevBasefee: 1000,
newGasTip: 100, newBasefee: 1001,
expectedTip: 200, expectedFC: 4200,
isBlobTx: true,
},
{ {
prevGasTip: 100, prevBasefee: 1000, prevGasTip: 100, prevBasefee: 1000,
newGasTip: 101, newBasefee: 900, newGasTip: 101, newBasefee: 900,
expectedTip: 110, expectedFC: 2310, expectedTip: 110, expectedFC: 2310,
}, },
{
prevGasTip: 100, prevBasefee: 1000,
newGasTip: 101, newBasefee: 900,
expectedTip: 200, expectedFC: 4200,
isBlobTx: true,
},
{ {
prevGasTip: 100, prevBasefee: 1000, prevGasTip: 100, prevBasefee: 1000,
newGasTip: 90, newBasefee: 1010, newGasTip: 90, newBasefee: 1010,
expectedTip: 110, expectedFC: 2310, expectedTip: 110, expectedFC: 2310,
}, },
{
prevGasTip: 100, prevBasefee: 1000,
newGasTip: 90, newBasefee: 1010,
expectedTip: 200, expectedFC: 4200,
isBlobTx: true,
},
{ {
prevGasTip: 100, prevBasefee: 1000, prevGasTip: 100, prevBasefee: 1000,
newGasTip: 101, newBasefee: 2000, newGasTip: 101, newBasefee: 2000,
expectedTip: 110, expectedFC: 4110, expectedTip: 110, expectedFC: 4110,
}, },
{
prevGasTip: 100, prevBasefee: 1000,
newGasTip: 101, newBasefee: 3000,
expectedTip: 200, expectedFC: 6200,
isBlobTx: true,
},
{ {
prevGasTip: 100, prevBasefee: 1000, prevGasTip: 100, prevBasefee: 1000,
newGasTip: 120, newBasefee: 900, newGasTip: 120, newBasefee: 900,
expectedTip: 120, expectedFC: 2310, expectedTip: 120, expectedFC: 2310,
}, },
{
prevGasTip: 100, prevBasefee: 1000,
newGasTip: 220, newBasefee: 900,
expectedTip: 220, expectedFC: 4200,
isBlobTx: true,
},
{ {
prevGasTip: 100, prevBasefee: 1000, prevGasTip: 100, prevBasefee: 1000,
newGasTip: 120, newBasefee: 1100, newGasTip: 120, newBasefee: 1100,
expectedTip: 120, expectedFC: 2320, expectedTip: 120, expectedFC: 2320,
}, },
{
prevGasTip: 100, prevBasefee: 1000,
newGasTip: 220, newBasefee: 2000,
expectedTip: 220, expectedFC: 4220,
isBlobTx: true,
},
{ {
prevGasTip: 100, prevBasefee: 1000, prevGasTip: 100, prevBasefee: 1000,
newGasTip: 120, newBasefee: 1140, newGasTip: 120, newBasefee: 1140,
expectedTip: 120, expectedFC: 2400, expectedTip: 120, expectedFC: 2400,
}, },
{
prevGasTip: 100, prevBasefee: 1000,
newGasTip: 220, newBasefee: 2040,
expectedTip: 220, expectedFC: 4300,
isBlobTx: true,
},
{ {
prevGasTip: 100, prevBasefee: 1000, prevGasTip: 100, prevBasefee: 1000,
newGasTip: 120, newBasefee: 1200, newGasTip: 120, newBasefee: 1200,
expectedTip: 120, expectedFC: 2520, expectedTip: 120, expectedFC: 2520,
}, },
{
prevGasTip: 100, prevBasefee: 1000,
newGasTip: 220, newBasefee: 2100,
expectedTip: 220, expectedFC: 4420,
isBlobTx: true,
},
} }
for i, test := range tests { for i, test := range tests {
i := i i := i
......
...@@ -193,7 +193,7 @@ func TestQueue_Send(t *testing.T) { ...@@ -193,7 +193,7 @@ func TestQueue_Send(t *testing.T) {
return core.ErrNonceTooLow return core.ErrNonceTooLow
} }
txHash := tx.Hash() txHash := tx.Hash()
backend.mine(&txHash, tx.GasFeeCap()) backend.mine(&txHash, tx.GasFeeCap(), nil)
return nil return nil
} }
backend.setTxSender(sendTx) backend.setTxSender(sendTx)
......
...@@ -12,10 +12,14 @@ import ( ...@@ -12,10 +12,14 @@ import (
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus/misc/eip4844"
"github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/txpool" "github.com/ethereum/go-ethereum/core/txpool"
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto/kzg4844"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/params"
"github.com/holiman/uint256"
"github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum-optimism/optimism/op-service/retry" "github.com/ethereum-optimism/optimism/op-service/retry"
...@@ -23,15 +27,24 @@ import ( ...@@ -23,15 +27,24 @@ import (
) )
const ( const (
// Geth requires a minimum fee bump of 10% for tx resubmission // geth requires a minimum fee bump of 10% for regular tx resubmission
priceBump int64 = 10 priceBump int64 = 10
// geth requires a minimum fee bump of 100% for blob tx resubmission
blobPriceBump int64 = 100
) )
// new = old * (100 + priceBump) / 100
var ( var (
priceBumpPercent = big.NewInt(100 + priceBump) priceBumpPercent = big.NewInt(100 + priceBump)
oneHundred = big.NewInt(100) blobPriceBumpPercent = big.NewInt(100 + blobPriceBump)
ninetyNine = big.NewInt(99)
// geth enforces a 1 gwei minimum for blob tx fee
minBlobTxFee = big.NewInt(params.GWei)
oneHundred = big.NewInt(100)
ninetyNine = big.NewInt(99)
two = big.NewInt(2)
ErrBlobFeeLimit = errors.New("blob fee limit reached")
) )
// TxManager is an interface that allows callers to reliably publish txs, // TxManager is an interface that allows callers to reliably publish txs,
...@@ -149,14 +162,21 @@ func (m *SimpleTxManager) txLogger(tx *types.Transaction, logGas bool) log.Logge ...@@ -149,14 +162,21 @@ func (m *SimpleTxManager) txLogger(tx *types.Transaction, logGas bool) log.Logge
if logGas { if logGas {
fields = append(fields, "gasTipCap", tx.GasTipCap(), "gasFeeCap", tx.GasFeeCap(), "gasLimit", tx.Gas()) fields = append(fields, "gasTipCap", tx.GasTipCap(), "gasFeeCap", tx.GasFeeCap(), "gasLimit", tx.Gas())
} }
if len(tx.BlobHashes()) != 0 {
// log the number of blobs a tx has only if it's a blob tx
fields = append(fields, "blobs", len(tx.BlobHashes()))
}
return m.l.New(fields...) return m.l.New(fields...)
} }
// TxCandidate is a transaction candidate that can be submitted to ask the // TxCandidate is a transaction candidate that can be submitted to ask the
// [TxManager] to construct a transaction with gas price bounds. // [TxManager] to construct a transaction with gas price bounds.
type TxCandidate struct { type TxCandidate struct {
// TxData is the transaction data to be used in the constructed tx. // TxData is the transaction calldata to be used in the constructed tx.
TxData []byte TxData []byte
// Blobs to send along in the tx (optional). If len(Blobs) > 0 then a blob tx
// will be sent instead of a DynamicFeeTx.
Blobs []*eth.Blob
// To is the recipient of the constructed tx. Nil means contract creation. // To is the recipient of the constructed tx. Nil means contract creation.
To *common.Address To *common.Address
// GasLimit is the gas limit to be used in the constructed tx. // GasLimit is the gas limit to be used in the constructed tx.
...@@ -212,44 +232,96 @@ func (m *SimpleTxManager) send(ctx context.Context, candidate TxCandidate) (*typ ...@@ -212,44 +232,96 @@ func (m *SimpleTxManager) send(ctx context.Context, candidate TxCandidate) (*typ
// NOTE: If the [TxCandidate.GasLimit] is non-zero, it will be used as the transaction's gas. // NOTE: If the [TxCandidate.GasLimit] is non-zero, it will be used as the transaction's gas.
// NOTE: Otherwise, the [SimpleTxManager] will query the specified backend for an estimate. // NOTE: Otherwise, the [SimpleTxManager] will query the specified backend for an estimate.
func (m *SimpleTxManager) craftTx(ctx context.Context, candidate TxCandidate) (*types.Transaction, error) { func (m *SimpleTxManager) craftTx(ctx context.Context, candidate TxCandidate) (*types.Transaction, error) {
gasTipCap, basefee, err := m.suggestGasPriceCaps(ctx) gasTipCap, basefee, blobBasefee, err := m.suggestGasPriceCaps(ctx)
if err != nil { if err != nil {
m.metr.RPCError() m.metr.RPCError()
return nil, fmt.Errorf("failed to get gas price info: %w", err) return nil, fmt.Errorf("failed to get gas price info: %w", err)
} }
gasFeeCap := calcGasFeeCap(basefee, gasTipCap) gasFeeCap := calcGasFeeCap(basefee, gasTipCap)
rawTx := &types.DynamicFeeTx{ gasLimit := candidate.GasLimit
ChainID: m.chainID,
To: candidate.To,
GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap,
Data: candidate.TxData,
Value: candidate.Value,
}
m.l.Info("Creating tx", "to", rawTx.To, "from", m.cfg.From)
// If the gas limit is set, we can use that as the gas // If the gas limit is set, we can use that as the gas
if candidate.GasLimit != 0 { if gasLimit == 0 {
rawTx.Gas = candidate.GasLimit
} else {
// Calculate the intrinsic gas for the transaction // Calculate the intrinsic gas for the transaction
gas, err := m.backend.EstimateGas(ctx, ethereum.CallMsg{ gas, err := m.backend.EstimateGas(ctx, ethereum.CallMsg{
From: m.cfg.From, From: m.cfg.From,
To: candidate.To, To: candidate.To,
GasTipCap: gasTipCap, GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap, GasFeeCap: gasFeeCap,
Data: rawTx.Data, Data: candidate.TxData,
Value: rawTx.Value, Value: candidate.Value,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to estimate gas: %w", err) return nil, fmt.Errorf("failed to estimate gas: %w", err)
} }
rawTx.Gas = gas gasLimit = gas
}
var sidecar *types.BlobTxSidecar
var blobHashes []common.Hash
if len(candidate.Blobs) > 0 {
if candidate.To == nil {
return nil, errors.New("blob txs cannot deploy contracts")
}
if sidecar, blobHashes, err = makeSidecar(candidate.Blobs); err != nil {
return nil, fmt.Errorf("failed to make sidecar: %w", err)
}
} }
return m.signWithNextNonce(ctx, rawTx) var txMessage types.TxData
if sidecar != nil {
if blobBasefee == nil {
return nil, fmt.Errorf("expected non-nil blobBasefee")
}
blobFeeCap := calcBlobFeeCap(blobBasefee)
message := &types.BlobTx{
To: *candidate.To,
Data: candidate.TxData,
Gas: gasLimit,
BlobHashes: blobHashes,
Sidecar: sidecar,
}
if err := finishBlobTx(message, m.chainID, gasTipCap, gasFeeCap, blobFeeCap, candidate.Value); err != nil {
return nil, fmt.Errorf("failed to create blob transaction: %w", err)
}
txMessage = message
} else {
txMessage = &types.DynamicFeeTx{
ChainID: m.chainID,
To: candidate.To,
GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap,
Value: candidate.Value,
Data: candidate.TxData,
Gas: gasLimit,
}
}
return m.signWithNextNonce(ctx, txMessage) // signer sets the nonce field of the tx
}
// makeSidecar builds & returns the BlobTxSidecar and corresponding blob hashes from the raw blob
// data.
func makeSidecar(blobs []*eth.Blob) (*types.BlobTxSidecar, []common.Hash, error) {
sidecar := &types.BlobTxSidecar{}
blobHashes := []common.Hash{}
for i, blob := range blobs {
rawBlob := *blob.KZGBlob()
sidecar.Blobs = append(sidecar.Blobs, rawBlob)
commitment, err := kzg4844.BlobToCommitment(rawBlob)
if err != nil {
return nil, nil, fmt.Errorf("cannot compute KZG commitment of blob %d in tx candidate: %w", i, err)
}
sidecar.Commitments = append(sidecar.Commitments, commitment)
proof, err := kzg4844.ComputeBlobProof(rawBlob, commitment)
if err != nil {
return nil, nil, fmt.Errorf("cannot compute KZG proof for fast commitment verification of blob %d in tx candidate: %w", i, err)
}
sidecar.Proofs = append(sidecar.Proofs, proof)
blobHashes = append(blobHashes, eth.KZGToVersionedHash(commitment))
}
return sidecar, blobHashes, nil
} }
// signWithNextNonce returns a signed transaction with the next available nonce. // signWithNextNonce returns a signed transaction with the next available nonce.
...@@ -257,7 +329,7 @@ func (m *SimpleTxManager) craftTx(ctx context.Context, candidate TxCandidate) (* ...@@ -257,7 +329,7 @@ func (m *SimpleTxManager) craftTx(ctx context.Context, candidate TxCandidate) (*
// then subsequent calls simply increment this number. If the transaction manager // then subsequent calls simply increment this number. If the transaction manager
// is reset, it will query the eth_getTransactionCount nonce again. If signing // is reset, it will query the eth_getTransactionCount nonce again. If signing
// fails, the nonce is not incremented. // fails, the nonce is not incremented.
func (m *SimpleTxManager) signWithNextNonce(ctx context.Context, rawTx *types.DynamicFeeTx) (*types.Transaction, error) { func (m *SimpleTxManager) signWithNextNonce(ctx context.Context, txMessage types.TxData) (*types.Transaction, error) {
m.nonceLock.Lock() m.nonceLock.Lock()
defer m.nonceLock.Unlock() defer m.nonceLock.Unlock()
...@@ -275,10 +347,17 @@ func (m *SimpleTxManager) signWithNextNonce(ctx context.Context, rawTx *types.Dy ...@@ -275,10 +347,17 @@ func (m *SimpleTxManager) signWithNextNonce(ctx context.Context, rawTx *types.Dy
*m.nonce++ *m.nonce++
} }
rawTx.Nonce = *m.nonce switch x := txMessage.(type) {
case *types.DynamicFeeTx:
x.Nonce = *m.nonce
case *types.BlobTx:
x.Nonce = *m.nonce
default:
return nil, fmt.Errorf("unrecognized tx type: %T", x)
}
ctx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout) ctx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel() defer cancel()
tx, err := m.cfg.Signer(ctx, m.cfg.From, types.NewTx(rawTx)) tx, err := m.cfg.Signer(ctx, m.cfg.From, types.NewTx(txMessage))
if err != nil { if err != nil {
// decrement the nonce, so we can retry signing with the same nonce next time // decrement the nonce, so we can retry signing with the same nonce next time
// signWithNextNonce is called // signWithNextNonce is called
...@@ -512,42 +591,31 @@ func (m *SimpleTxManager) queryReceipt(ctx context.Context, txHash common.Hash, ...@@ -512,42 +591,31 @@ func (m *SimpleTxManager) queryReceipt(ctx context.Context, txHash common.Hash,
return nil return nil
} }
// increaseGasPrice takes the previous transaction, clones it, and returns it with fee values that // increaseGasPrice returns a new transaction that is equivalent to the input transaction but with
// are at least `priceBump` percent higher than the previous ones to satisfy Geth's replacement // higher fees that should satisfy geth's tx replacement rules. It also computes an updated gas
// rules, and no lower than the values returned by the fee suggestion algorithm to ensure it // limit estimate. To avoid runaway price increases, fees are capped at a `feeLimitMultiplier`
// doesn't linger in the mempool. Finally to avoid runaway price increases, fees are capped at a // multiple of the suggested values.
// `feeLimitMultiplier` multiple of the suggested values.
func (m *SimpleTxManager) increaseGasPrice(ctx context.Context, tx *types.Transaction) (*types.Transaction, error) { func (m *SimpleTxManager) increaseGasPrice(ctx context.Context, tx *types.Transaction) (*types.Transaction, error) {
m.txLogger(tx, true).Info("bumping gas price for transaction") m.txLogger(tx, true).Info("bumping gas price for transaction")
tip, basefee, err := m.suggestGasPriceCaps(ctx) tip, basefee, blobBasefee, err := m.suggestGasPriceCaps(ctx)
if err != nil { if err != nil {
m.txLogger(tx, false).Warn("failed to get suggested gas tip and basefee", "err", err) m.txLogger(tx, false).Warn("failed to get suggested gas tip and basefee", "err", err)
return nil, err return nil, err
} }
bumpedTip, bumpedFee := updateFees(tx.GasTipCap(), tx.GasFeeCap(), tip, basefee, m.l) bumpedTip, bumpedFee := updateFees(tx.GasTipCap(), tx.GasFeeCap(), tip, basefee, tx.Type() == types.BlobTxType, m.l)
if err := m.checkLimits(tip, basefee, bumpedTip, bumpedFee); err != nil { if err := m.checkLimits(tip, basefee, bumpedTip, bumpedFee); err != nil {
return nil, err return nil, err
} }
rawTx := &types.DynamicFeeTx{
ChainID: tx.ChainId(),
Nonce: tx.Nonce(),
GasTipCap: bumpedTip,
GasFeeCap: bumpedFee,
To: tx.To(),
Value: tx.Value(),
Data: tx.Data(),
AccessList: tx.AccessList(),
}
// Re-estimate gaslimit in case things have changed or a previous gaslimit estimate was wrong // Re-estimate gaslimit in case things have changed or a previous gaslimit estimate was wrong
gas, err := m.backend.EstimateGas(ctx, ethereum.CallMsg{ gas, err := m.backend.EstimateGas(ctx, ethereum.CallMsg{
From: m.cfg.From, From: m.cfg.From,
To: rawTx.To, To: tx.To(),
GasTipCap: bumpedTip, GasTipCap: bumpedTip,
GasFeeCap: bumpedFee, GasFeeCap: bumpedFee,
Data: rawTx.Data, Data: tx.Data(),
Value: tx.Value(),
}) })
if err != nil { if err != nil {
// If this is a transaction resubmission, we sometimes see this outcome because the // If this is a transaction resubmission, we sometimes see this outcome because the
...@@ -562,38 +630,75 @@ func (m *SimpleTxManager) increaseGasPrice(ctx context.Context, tx *types.Transa ...@@ -562,38 +630,75 @@ func (m *SimpleTxManager) increaseGasPrice(ctx context.Context, tx *types.Transa
m.l.Info("re-estimated gas differs", "tx", tx.Hash(), "oldgas", tx.Gas(), "newgas", gas, m.l.Info("re-estimated gas differs", "tx", tx.Hash(), "oldgas", tx.Gas(), "newgas", gas,
"gasFeeCap", bumpedFee, "gasTipCap", bumpedTip) "gasFeeCap", bumpedFee, "gasTipCap", bumpedTip)
} }
rawTx.Gas = gas
var newTx *types.Transaction
if tx.Type() == types.BlobTxType {
// Blob transactions have an additional blob gas price we must specify, so we must make sure it is
// getting bumped appropriately.
bumpedBlobFee := calcThresholdValue(tx.BlobGasFeeCap(), true)
if bumpedBlobFee.Cmp(blobBasefee) < 0 {
bumpedBlobFee = blobBasefee
}
if err := m.checkBlobFeeLimits(blobBasefee, bumpedBlobFee); err != nil {
return nil, err
}
message := &types.BlobTx{
Nonce: tx.Nonce(),
To: *tx.To(),
Data: tx.Data(),
Gas: gas,
BlobHashes: tx.BlobHashes(),
Sidecar: tx.BlobTxSidecar(),
}
if err := finishBlobTx(message, tx.ChainId(), bumpedTip, bumpedFee, bumpedBlobFee, tx.Value()); err != nil {
return nil, err
}
newTx = types.NewTx(message)
} else {
newTx = types.NewTx(&types.DynamicFeeTx{
ChainID: tx.ChainId(),
Nonce: tx.Nonce(),
To: tx.To(),
GasTipCap: bumpedTip,
GasFeeCap: bumpedFee,
Value: tx.Value(),
Data: tx.Data(),
Gas: gas,
})
}
ctx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout) ctx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel() defer cancel()
newTx, err := m.cfg.Signer(ctx, m.cfg.From, types.NewTx(rawTx)) signedTx, err := m.cfg.Signer(ctx, m.cfg.From, newTx)
if err != nil { if err != nil {
m.l.Warn("failed to sign new transaction", "err", err, "tx", tx.Hash()) m.l.Warn("failed to sign new transaction", "err", err, "tx", tx.Hash())
return tx, nil return tx, nil
} }
return newTx, nil return signedTx, nil
} }
// suggestGasPriceCaps suggests what the new tip & new basefee should be based on the current L1 conditions // suggestGasPriceCaps suggests what the new tip, basefee, and blobfee should be based on the
func (m *SimpleTxManager) suggestGasPriceCaps(ctx context.Context) (*big.Int, *big.Int, error) { // current L1 conditions. blobfee will be nil if 4844 is not yet active.
func (m *SimpleTxManager) suggestGasPriceCaps(ctx context.Context) (*big.Int, *big.Int, *big.Int, error) {
cCtx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout) cCtx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel() defer cancel()
tip, err := m.backend.SuggestGasTipCap(cCtx) tip, err := m.backend.SuggestGasTipCap(cCtx)
if err != nil { if err != nil {
m.metr.RPCError() m.metr.RPCError()
return nil, nil, fmt.Errorf("failed to fetch the suggested gas tip cap: %w", err) return nil, nil, nil, fmt.Errorf("failed to fetch the suggested gas tip cap: %w", err)
} else if tip == nil { } else if tip == nil {
return nil, nil, errors.New("the suggested tip was nil") return nil, nil, nil, errors.New("the suggested tip was nil")
} }
cCtx, cancel = context.WithTimeout(ctx, m.cfg.NetworkTimeout) cCtx, cancel = context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel() defer cancel()
head, err := m.backend.HeaderByNumber(cCtx, nil) head, err := m.backend.HeaderByNumber(cCtx, nil)
if err != nil { if err != nil {
m.metr.RPCError() m.metr.RPCError()
return nil, nil, fmt.Errorf("failed to fetch the suggested basefee: %w", err) return nil, nil, nil, fmt.Errorf("failed to fetch the suggested basefee: %w", err)
} else if head.BaseFee == nil { } else if head.BaseFee == nil {
return nil, nil, errors.New("txmgr does not support pre-london blocks that do not have a basefee") return nil, nil, nil, errors.New("txmgr does not support pre-london blocks that do not have a basefee")
} }
basefee := head.BaseFee basefee := head.BaseFee
m.metr.RecordBasefee(basefee) m.metr.RecordBasefee(basefee)
m.metr.RecordTipCap(tip) m.metr.RecordTipCap(tip)
...@@ -608,7 +713,11 @@ func (m *SimpleTxManager) suggestGasPriceCaps(ctx context.Context) (*big.Int, *b ...@@ -608,7 +713,11 @@ func (m *SimpleTxManager) suggestGasPriceCaps(ctx context.Context) (*big.Int, *b
basefee = new(big.Int).Set(m.cfg.MinBasefee) basefee = new(big.Int).Set(m.cfg.MinBasefee)
} }
return tip, basefee, nil var blobFee *big.Int
if head.ExcessBlobGas != nil {
blobFee = eip4844.CalcBlobFee(*head.ExcessBlobGas)
}
return tip, basefee, blobFee, nil
} }
func (m *SimpleTxManager) checkLimits(tip, basefee, bumpedTip, bumpedFee *big.Int) error { func (m *SimpleTxManager) checkLimits(tip, basefee, bumpedTip, bumpedFee *big.Int) error {
...@@ -630,28 +739,46 @@ func (m *SimpleTxManager) checkLimits(tip, basefee, bumpedTip, bumpedFee *big.In ...@@ -630,28 +739,46 @@ func (m *SimpleTxManager) checkLimits(tip, basefee, bumpedTip, bumpedFee *big.In
return nil return nil
} }
// calcThresholdValue returns ceil(x * priceBumpPercent / 100) func (m *SimpleTxManager) checkBlobFeeLimits(blobBasefee, bumpedBlobFee *big.Int) error {
// If below threshold, don't apply multiplier limit. Note we use same threshold parameter here
// used for non-blob fee limiting.
if thr := m.cfg.FeeLimitThreshold; thr != nil && thr.Cmp(bumpedBlobFee) == 1 {
return nil
}
maxBlobFee := new(big.Int).Mul(calcBlobFeeCap(blobBasefee), big.NewInt(int64(m.cfg.FeeLimitMultiplier)))
if bumpedBlobFee.Cmp(maxBlobFee) > 0 {
return fmt.Errorf(
"bumped blob fee %v is over %dx multiple of the suggested value: %w",
bumpedBlobFee, m.cfg.FeeLimitMultiplier, ErrBlobFeeLimit)
}
return nil
}
// calcThresholdValue returns ceil(x * priceBumpPercent / 100) for non-blob txs, or
// ceil(x * blobPriceBumpPercent / 100) for blob txs.
// It guarantees that x is increased by at least 1 // It guarantees that x is increased by at least 1
func calcThresholdValue(x *big.Int) *big.Int { func calcThresholdValue(x *big.Int, isBlobTx bool) *big.Int {
threshold := new(big.Int).Mul(priceBumpPercent, x) threshold := new(big.Int)
threshold.Add(threshold, ninetyNine) if isBlobTx {
threshold.Div(threshold, oneHundred) threshold.Set(blobPriceBumpPercent)
return threshold } else {
threshold.Set(priceBumpPercent)
}
return threshold.Mul(threshold, x).Add(threshold, ninetyNine).Div(threshold, oneHundred)
} }
// updateFees takes an old transaction's tip & fee cap plus a new tip & basefee, and returns // updateFees takes an old transaction's tip & fee cap plus a new tip & basefee, and returns
// a suggested tip and fee cap such that: // a suggested tip and fee cap such that:
// //
// (a) each satisfies geth's required tx-replacement fee bumps (we use a 10% increase), and // (a) each satisfies geth's required tx-replacement fee bumps, and
// (b) gasTipCap is no less than new tip, and // (b) gasTipCap is no less than new tip, and
// (c) gasFeeCap is no less than calcGasFee(newBaseFee, newTip) // (c) gasFeeCap is no less than calcGasFee(newBaseFee, newTip)
func updateFees(oldTip, oldFeeCap, newTip, newBaseFee *big.Int, lgr log.Logger) (*big.Int, *big.Int) { func updateFees(oldTip, oldFeeCap, newTip, newBaseFee *big.Int, isBlobTx bool, lgr log.Logger) (*big.Int, *big.Int) {
newFeeCap := calcGasFeeCap(newBaseFee, newTip) newFeeCap := calcGasFeeCap(newBaseFee, newTip)
lgr = lgr.New("old_gasTipCap", oldTip, "old_gasFeeCap", oldFeeCap, lgr = lgr.New("old_gasTipCap", oldTip, "old_gasFeeCap", oldFeeCap,
"new_gasTipCap", newTip, "new_gasFeeCap", newFeeCap, "new_gasTipCap", newTip, "new_gasFeeCap", newFeeCap, "new_basefee", newBaseFee)
"new_basefee", newBaseFee) thresholdTip := calcThresholdValue(oldTip, isBlobTx)
thresholdTip := calcThresholdValue(oldTip) thresholdFeeCap := calcThresholdValue(oldFeeCap, isBlobTx)
thresholdFeeCap := calcThresholdValue(oldFeeCap)
if newTip.Cmp(thresholdTip) >= 0 && newFeeCap.Cmp(thresholdFeeCap) >= 0 { if newTip.Cmp(thresholdTip) >= 0 && newFeeCap.Cmp(thresholdFeeCap) >= 0 {
lgr.Debug("Using new tip and feecap") lgr.Debug("Using new tip and feecap")
return newTip, newFeeCap return newTip, newFeeCap
...@@ -680,10 +807,20 @@ func updateFees(oldTip, oldFeeCap, newTip, newBaseFee *big.Int, lgr log.Logger) ...@@ -680,10 +807,20 @@ func updateFees(oldTip, oldFeeCap, newTip, newBaseFee *big.Int, lgr log.Logger)
func calcGasFeeCap(baseFee, gasTipCap *big.Int) *big.Int { func calcGasFeeCap(baseFee, gasTipCap *big.Int) *big.Int {
return new(big.Int).Add( return new(big.Int).Add(
gasTipCap, gasTipCap,
new(big.Int).Mul(baseFee, big.NewInt(2)), new(big.Int).Mul(baseFee, two),
) )
} }
// calcBlobFeeCap computes a suggested blob fee cap that is twice the current header's blob basefee
// value, with a minimum value of minBlobTxFee.
func calcBlobFeeCap(blobBasefee *big.Int) *big.Int {
cap := new(big.Int).Mul(blobBasefee, two)
if cap.Cmp(minBlobTxFee) < 0 {
cap.Set(minBlobTxFee)
}
return cap
}
// errStringMatch returns true if err.Error() is a substring in target.Error() or if both are nil. // errStringMatch returns true if err.Error() is a substring in target.Error() or if both are nil.
// It can accept nil errors without issue. // It can accept nil errors without issue.
func errStringMatch(err, target error) bool { func errStringMatch(err, target error) bool {
...@@ -694,3 +831,24 @@ func errStringMatch(err, target error) bool { ...@@ -694,3 +831,24 @@ func errStringMatch(err, target error) bool {
} }
return strings.Contains(err.Error(), target.Error()) return strings.Contains(err.Error(), target.Error())
} }
// finishBlobTx finishes creating a blob tx message by safely converting bigints to uint256
func finishBlobTx(message *types.BlobTx, chainID, tip, fee, blobFee, value *big.Int) error {
var o bool
if message.ChainID, o = uint256.FromBig(chainID); o {
return fmt.Errorf("ChainID overflow")
}
if message.GasTipCap, o = uint256.FromBig(tip); o {
return fmt.Errorf("GasTipCap overflow")
}
if message.GasFeeCap, o = uint256.FromBig(fee); o {
return fmt.Errorf("GasFeeCap overflow")
}
if message.BlobFeeCap, o = uint256.FromBig(blobFee); o {
return fmt.Errorf("BlobFeeCap overflow")
}
if message.Value, o = uint256.FromBig(value); o {
return fmt.Errorf("Value overflow")
}
return nil
}
...@@ -9,17 +9,30 @@ import ( ...@@ -9,17 +9,30 @@ import (
"testing" "testing"
"time" "time"
"github.com/holiman/uint256"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum-optimism/optimism/op-service/txmgr/metrics"
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus/misc/eip4844"
"github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto/kzg4844"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/params"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum-optimism/optimism/op-service/txmgr/metrics"
)
const (
startingNonce = 1 // we pick something other than 0 so we can confirm nonces are getting set properly
)
var (
blobData1 = eth.Data("this is a blob!")
blobData2 = eth.Data("amazing, the txmgr can handle more than one blob in a tx!!")
) )
type sendTransactionFunc func(ctx context.Context, tx *types.Transaction) error type sendTransactionFunc func(ctx context.Context, tx *types.Transaction) error
...@@ -75,6 +88,21 @@ func (h testHarness) createTxCandidate() TxCandidate { ...@@ -75,6 +88,21 @@ func (h testHarness) createTxCandidate() TxCandidate {
} }
} }
// createBlobTxCandidate creates a mock [TxCandidate] that results in a blob tx
func (h testHarness) createBlobTxCandidate() TxCandidate {
inbox := common.HexToAddress("0x42000000000000000000000000000000000000ff")
var b1, b2 eth.Blob
_ = b1.FromData(blobData1)
_ = b2.FromData(blobData2)
return TxCandidate{
To: &inbox,
TxData: []byte{0x00, 0x01, 0x02, 0x03},
GasLimit: uint64(1337),
Blobs: []*eth.Blob{&b1, &b2},
}
}
func configWithNumConfs(numConfirmations uint64) Config { func configWithNumConfs(numConfirmations uint64) Config {
return Config{ return Config{
ResubmissionTimeout: time.Second, ResubmissionTimeout: time.Second,
...@@ -95,6 +123,7 @@ type gasPricer struct { ...@@ -95,6 +123,7 @@ type gasPricer struct {
mineAtEpoch int64 mineAtEpoch int64
baseGasTipFee *big.Int baseGasTipFee *big.Int
baseBaseFee *big.Int baseBaseFee *big.Int
excessBlobGas uint64
err error err error
mu sync.Mutex mu sync.Mutex
} }
...@@ -104,24 +133,37 @@ func newGasPricer(mineAtEpoch int64) *gasPricer { ...@@ -104,24 +133,37 @@ func newGasPricer(mineAtEpoch int64) *gasPricer {
mineAtEpoch: mineAtEpoch, mineAtEpoch: mineAtEpoch,
baseGasTipFee: big.NewInt(5), baseGasTipFee: big.NewInt(5),
baseBaseFee: big.NewInt(7), baseBaseFee: big.NewInt(7),
// Simulate 100 excess blobs, which results in a blobBaseFee of 50 wei. This default means
// blob txs will be subject to the geth minimum blobgas fee of 1 gwei.
excessBlobGas: 100 * (params.BlobTxBlobGasPerBlob),
} }
} }
func (g *gasPricer) expGasFeeCap() *big.Int { func (g *gasPricer) expGasFeeCap() *big.Int {
_, gasFeeCap := g.feesForEpoch(g.mineAtEpoch) _, gasFeeCap, _ := g.feesForEpoch(g.mineAtEpoch)
return gasFeeCap return gasFeeCap
} }
func (g *gasPricer) expBlobFeeCap() *big.Int {
_, _, excessBlobGas := g.feesForEpoch(g.mineAtEpoch)
return eip4844.CalcBlobFee(excessBlobGas)
}
func (g *gasPricer) shouldMine(gasFeeCap *big.Int) bool { func (g *gasPricer) shouldMine(gasFeeCap *big.Int) bool {
return g.expGasFeeCap().Cmp(gasFeeCap) == 0 return g.expGasFeeCap().Cmp(gasFeeCap) <= 0
} }
func (g *gasPricer) feesForEpoch(epoch int64) (*big.Int, *big.Int) { func (g *gasPricer) shouldMineBlobTx(gasFeeCap, blobFeeCap *big.Int) bool {
epochBaseFee := new(big.Int).Mul(g.baseBaseFee, big.NewInt(epoch)) return g.shouldMine(gasFeeCap) && g.expBlobFeeCap().Cmp(blobFeeCap) <= 0
epochGasTipCap := new(big.Int).Mul(g.baseGasTipFee, big.NewInt(epoch)) }
epochGasFeeCap := calcGasFeeCap(epochBaseFee, epochGasTipCap)
return epochGasTipCap, epochGasFeeCap func (g *gasPricer) feesForEpoch(epoch int64) (*big.Int, *big.Int, uint64) {
e := big.NewInt(epoch)
epochBaseFee := new(big.Int).Mul(g.baseBaseFee, e)
epochGasTipCap := new(big.Int).Mul(g.baseGasTipFee, e)
epochGasFeeCap := calcGasFeeCap(epochBaseFee, epochGasTipCap)
epochExcessBlobGas := g.excessBlobGas * uint64(epoch)
return epochGasTipCap, epochGasFeeCap, epochExcessBlobGas
} }
func (g *gasPricer) basefee() *big.Int { func (g *gasPricer) basefee() *big.Int {
...@@ -130,18 +172,25 @@ func (g *gasPricer) basefee() *big.Int { ...@@ -130,18 +172,25 @@ func (g *gasPricer) basefee() *big.Int {
return new(big.Int).Mul(g.baseBaseFee, big.NewInt(g.epoch)) return new(big.Int).Mul(g.baseBaseFee, big.NewInt(g.epoch))
} }
func (g *gasPricer) sample() (*big.Int, *big.Int) { func (g *gasPricer) excessblobgas() uint64 {
g.mu.Lock()
defer g.mu.Unlock()
return g.excessBlobGas * uint64(g.epoch)
}
func (g *gasPricer) sample() (*big.Int, *big.Int, uint64) {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
g.epoch++ g.epoch++
epochGasTipCap, epochGasFeeCap := g.feesForEpoch(g.epoch) epochGasTipCap, epochGasFeeCap, epochExcessBlobGas := g.feesForEpoch(g.epoch)
return epochGasTipCap, epochGasFeeCap return epochGasTipCap, epochGasFeeCap, epochExcessBlobGas
} }
type minedTxInfo struct { type minedTxInfo struct {
gasFeeCap *big.Int gasFeeCap *big.Int
blobFeeCap *big.Int
blockNumber uint64 blockNumber uint64
} }
...@@ -176,7 +225,7 @@ func (b *mockBackend) setTxSender(s sendTransactionFunc) { ...@@ -176,7 +225,7 @@ func (b *mockBackend) setTxSender(s sendTransactionFunc) {
// mine records a (txHash, gasFeeCap) as confirmed. Subsequent calls to // mine records a (txHash, gasFeeCap) as confirmed. Subsequent calls to
// TransactionReceipt with a matching txHash will result in a non-nil receipt. // TransactionReceipt with a matching txHash will result in a non-nil receipt.
// If a nil txHash is supplied this has the effect of mining an empty block. // If a nil txHash is supplied this has the effect of mining an empty block.
func (b *mockBackend) mine(txHash *common.Hash, gasFeeCap *big.Int) { func (b *mockBackend) mine(txHash *common.Hash, gasFeeCap, blobFeeCap *big.Int) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
...@@ -184,6 +233,7 @@ func (b *mockBackend) mine(txHash *common.Hash, gasFeeCap *big.Int) { ...@@ -184,6 +233,7 @@ func (b *mockBackend) mine(txHash *common.Hash, gasFeeCap *big.Int) {
if txHash != nil { if txHash != nil {
b.minedTxs[*txHash] = minedTxInfo{ b.minedTxs[*txHash] = minedTxInfo{
gasFeeCap: gasFeeCap, gasFeeCap: gasFeeCap,
blobFeeCap: blobFeeCap,
blockNumber: b.blockHeight, blockNumber: b.blockHeight,
} }
} }
...@@ -210,9 +260,11 @@ func (b *mockBackend) HeaderByNumber(ctx context.Context, number *big.Int) (*typ ...@@ -210,9 +260,11 @@ func (b *mockBackend) HeaderByNumber(ctx context.Context, number *big.Int) (*typ
if number != nil { if number != nil {
num.Set(number) num.Set(number)
} }
bg := b.g.excessblobgas()
return &types.Header{ return &types.Header{
Number: num, Number: num,
BaseFee: b.g.basefee(), BaseFee: b.g.basefee(),
ExcessBlobGas: &bg,
}, nil }, nil
} }
...@@ -227,7 +279,7 @@ func (b *mockBackend) EstimateGas(ctx context.Context, msg ethereum.CallMsg) (ui ...@@ -227,7 +279,7 @@ func (b *mockBackend) EstimateGas(ctx context.Context, msg ethereum.CallMsg) (ui
} }
func (b *mockBackend) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { func (b *mockBackend) SuggestGasTipCap(ctx context.Context) (*big.Int, error) {
tip, _ := b.g.sample() tip, _, _ := b.g.sample()
return tip, nil return tip, nil
} }
...@@ -239,21 +291,21 @@ func (b *mockBackend) SendTransaction(ctx context.Context, tx *types.Transaction ...@@ -239,21 +291,21 @@ func (b *mockBackend) SendTransaction(ctx context.Context, tx *types.Transaction
} }
func (b *mockBackend) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) { func (b *mockBackend) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) {
return 0, nil return startingNonce, nil
} }
func (b *mockBackend) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) { func (b *mockBackend) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) {
return 0, nil return startingNonce, nil
} }
func (*mockBackend) ChainID(ctx context.Context) (*big.Int, error) { func (*mockBackend) ChainID(ctx context.Context) (*big.Int, error) {
return big.NewInt(1), nil return big.NewInt(1), nil
} }
// TransactionReceipt queries the mockBackend for a mined txHash. If none is // TransactionReceipt queries the mockBackend for a mined txHash. If none is found, nil is returned
// found, nil is returned for both return values. Otherwise, it returns a // for both return values. Otherwise, it returns a receipt containing the txHash, the gasFeeCap
// receipt containing the txHash and the gasFeeCap used in the GasUsed to make // used in GasUsed, and the blobFeeCap in CumuluativeGasUsed to make the values accessible from our
// the value accessible from our test framework. // test framework.
func (b *mockBackend) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { func (b *mockBackend) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) {
b.mu.RLock() b.mu.RLock()
defer b.mu.RUnlock() defer b.mu.RUnlock()
...@@ -265,10 +317,15 @@ func (b *mockBackend) TransactionReceipt(ctx context.Context, txHash common.Hash ...@@ -265,10 +317,15 @@ func (b *mockBackend) TransactionReceipt(ctx context.Context, txHash common.Hash
// Return the gas fee cap for the transaction in the GasUsed field so that // Return the gas fee cap for the transaction in the GasUsed field so that
// we can assert the proper tx confirmed in our tests. // we can assert the proper tx confirmed in our tests.
var blobFeeCap uint64
if txInfo.blobFeeCap != nil {
blobFeeCap = txInfo.blobFeeCap.Uint64()
}
return &types.Receipt{ return &types.Receipt{
TxHash: txHash, TxHash: txHash,
GasUsed: txInfo.gasFeeCap.Uint64(), GasUsed: txInfo.gasFeeCap.Uint64(),
BlockNumber: big.NewInt(int64(txInfo.blockNumber)), CumulativeGasUsed: blobFeeCap,
BlockNumber: big.NewInt(int64(txInfo.blockNumber)),
}, nil }, nil
} }
...@@ -284,7 +341,7 @@ func TestTxMgrConfirmAtMinGasPrice(t *testing.T) { ...@@ -284,7 +341,7 @@ func TestTxMgrConfirmAtMinGasPrice(t *testing.T) {
gasPricer := newGasPricer(1) gasPricer := newGasPricer(1)
gasTipCap, gasFeeCap := gasPricer.sample() gasTipCap, gasFeeCap, _ := gasPricer.sample()
tx := types.NewTx(&types.DynamicFeeTx{ tx := types.NewTx(&types.DynamicFeeTx{
GasTipCap: gasTipCap, GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap, GasFeeCap: gasFeeCap,
...@@ -293,7 +350,7 @@ func TestTxMgrConfirmAtMinGasPrice(t *testing.T) { ...@@ -293,7 +350,7 @@ func TestTxMgrConfirmAtMinGasPrice(t *testing.T) {
sendTx := func(ctx context.Context, tx *types.Transaction) error { sendTx := func(ctx context.Context, tx *types.Transaction) error {
if gasPricer.shouldMine(tx.GasFeeCap()) { if gasPricer.shouldMine(tx.GasFeeCap()) {
txHash := tx.Hash() txHash := tx.Hash()
h.backend.mine(&txHash, tx.GasFeeCap()) h.backend.mine(&txHash, tx.GasFeeCap(), nil)
} }
return nil return nil
} }
...@@ -315,7 +372,7 @@ func TestTxMgrNeverConfirmCancel(t *testing.T) { ...@@ -315,7 +372,7 @@ func TestTxMgrNeverConfirmCancel(t *testing.T) {
h := newTestHarness(t) h := newTestHarness(t)
gasTipCap, gasFeeCap := h.gasPricer.sample() gasTipCap, gasFeeCap, _ := h.gasPricer.sample()
tx := types.NewTx(&types.DynamicFeeTx{ tx := types.NewTx(&types.DynamicFeeTx{
GasTipCap: gasTipCap, GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap, GasFeeCap: gasFeeCap,
...@@ -341,7 +398,7 @@ func TestTxMgrConfirmsAtHigherGasPrice(t *testing.T) { ...@@ -341,7 +398,7 @@ func TestTxMgrConfirmsAtHigherGasPrice(t *testing.T) {
h := newTestHarness(t) h := newTestHarness(t)
gasTipCap, gasFeeCap := h.gasPricer.sample() gasTipCap, gasFeeCap, _ := h.gasPricer.sample()
tx := types.NewTx(&types.DynamicFeeTx{ tx := types.NewTx(&types.DynamicFeeTx{
GasTipCap: gasTipCap, GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap, GasFeeCap: gasFeeCap,
...@@ -349,7 +406,7 @@ func TestTxMgrConfirmsAtHigherGasPrice(t *testing.T) { ...@@ -349,7 +406,7 @@ func TestTxMgrConfirmsAtHigherGasPrice(t *testing.T) {
sendTx := func(ctx context.Context, tx *types.Transaction) error { sendTx := func(ctx context.Context, tx *types.Transaction) error {
if h.gasPricer.shouldMine(tx.GasFeeCap()) { if h.gasPricer.shouldMine(tx.GasFeeCap()) {
txHash := tx.Hash() txHash := tx.Hash()
h.backend.mine(&txHash, tx.GasFeeCap()) h.backend.mine(&txHash, tx.GasFeeCap(), nil)
} }
return nil return nil
} }
...@@ -364,6 +421,43 @@ func TestTxMgrConfirmsAtHigherGasPrice(t *testing.T) { ...@@ -364,6 +421,43 @@ func TestTxMgrConfirmsAtHigherGasPrice(t *testing.T) {
require.Equal(t, h.gasPricer.expGasFeeCap().Uint64(), receipt.GasUsed) require.Equal(t, h.gasPricer.expGasFeeCap().Uint64(), receipt.GasUsed)
} }
// TestTxMgrConfirmsBlobTxAtMaxGasPrice asserts that Send properly returns the max gas price
// receipt if none of the lower gas price txs were mined when attempting to send a blob tx.
func TestTxMgrConfirmsBlobTxAtHigherGasPrice(t *testing.T) {
t.Parallel()
h := newTestHarness(t)
gasTipCap, gasFeeCap, excessBlobGas := h.gasPricer.sample()
blobFeeCap := eip4844.CalcBlobFee(excessBlobGas)
t.Log("Blob fee cap:", blobFeeCap, "gasFeeCap:", gasFeeCap)
tx := types.NewTx(&types.BlobTx{
GasTipCap: uint256.MustFromBig(gasTipCap),
GasFeeCap: uint256.MustFromBig(gasFeeCap),
BlobFeeCap: uint256.MustFromBig(blobFeeCap),
})
sendTx := func(ctx context.Context, tx *types.Transaction) error {
if h.gasPricer.shouldMineBlobTx(tx.GasFeeCap(), tx.BlobGasFeeCap()) {
txHash := tx.Hash()
h.backend.mine(&txHash, tx.GasFeeCap(), tx.BlobGasFeeCap())
}
return nil
}
h.backend.setTxSender(sendTx)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
receipt, err := h.mgr.sendTx(ctx, tx)
require.Nil(t, err)
require.NotNil(t, receipt)
// the fee cap for the blob tx at epoch == 3 should end up higher than the min required gas
// (expFeeCap()) since blob tx fee caps are bumped 100% with each epoch.
require.Less(t, h.gasPricer.expGasFeeCap().Uint64(), receipt.GasUsed)
require.Equal(t, h.gasPricer.expBlobFeeCap().Uint64(), receipt.CumulativeGasUsed)
}
// errRpcFailure is a sentinel error used in testing to fail publications. // errRpcFailure is a sentinel error used in testing to fail publications.
var errRpcFailure = errors.New("rpc failure") var errRpcFailure = errors.New("rpc failure")
...@@ -375,7 +469,7 @@ func TestTxMgrBlocksOnFailingRpcCalls(t *testing.T) { ...@@ -375,7 +469,7 @@ func TestTxMgrBlocksOnFailingRpcCalls(t *testing.T) {
h := newTestHarness(t) h := newTestHarness(t)
gasTipCap, gasFeeCap := h.gasPricer.sample() gasTipCap, gasFeeCap, _ := h.gasPricer.sample()
tx := types.NewTx(&types.DynamicFeeTx{ tx := types.NewTx(&types.DynamicFeeTx{
GasTipCap: gasTipCap, GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap, GasFeeCap: gasFeeCap,
...@@ -401,22 +495,69 @@ func TestTxMgr_CraftTx(t *testing.T) { ...@@ -401,22 +495,69 @@ func TestTxMgr_CraftTx(t *testing.T) {
candidate := h.createTxCandidate() candidate := h.createTxCandidate()
// Craft the transaction. // Craft the transaction.
gasTipCap, gasFeeCap := h.gasPricer.feesForEpoch(h.gasPricer.epoch + 1) gasTipCap, gasFeeCap, _ := h.gasPricer.feesForEpoch(h.gasPricer.epoch + 1)
tx, err := h.mgr.craftTx(context.Background(), candidate) tx, err := h.mgr.craftTx(context.Background(), candidate)
require.Nil(t, err) require.Nil(t, err)
require.NotNil(t, tx) require.NotNil(t, tx)
require.Equal(t, byte(types.DynamicFeeTxType), tx.Type())
// Validate the gas tip cap and fee cap. // Validate the gas tip cap and fee cap.
require.Equal(t, gasTipCap, tx.GasTipCap()) require.Equal(t, gasTipCap, tx.GasTipCap())
require.Equal(t, gasFeeCap, tx.GasFeeCap()) require.Equal(t, gasFeeCap, tx.GasFeeCap())
// Validate the nonce was set correctly using the backend. // Validate the nonce was set correctly using the backend.
require.Zero(t, tx.Nonce()) require.Equal(t, uint64(startingNonce), tx.Nonce())
// Check that the gas was set using the gas limit. // Check that the gas was set using the gas limit.
require.Equal(t, candidate.GasLimit, tx.Gas()) require.Equal(t, candidate.GasLimit, tx.Gas())
} }
// TestTxMgr_CraftBlobTx ensures that the tx manager will create blob transactions as expected.
func TestTxMgr_CraftBlobTx(t *testing.T) {
t.Parallel()
h := newTestHarness(t)
candidate := h.createBlobTxCandidate()
// Craft the transaction.
gasTipCap, gasFeeCap, _ := h.gasPricer.feesForEpoch(h.gasPricer.epoch + 1)
tx, err := h.mgr.craftTx(context.Background(), candidate)
require.Nil(t, err)
require.NotNil(t, tx)
require.Equal(t, byte(types.BlobTxType), tx.Type())
// Validate the gas tip cap and fee cap.
require.Equal(t, gasTipCap, tx.GasTipCap())
require.Equal(t, gasFeeCap, tx.GasFeeCap())
require.Equal(t, minBlobTxFee, tx.BlobGasFeeCap())
// Validate the nonce was set correctly using the backend.
require.Equal(t, uint64(startingNonce), tx.Nonce())
// Check that the gas was set using the gas limit.
require.Equal(t, candidate.GasLimit, tx.Gas())
// Check the blob fields
require.Equal(t, 2, len(tx.BlobHashes()))
sidecar := tx.BlobTxSidecar()
require.Equal(t, 2, len(sidecar.Blobs))
require.Equal(t, 2, len(sidecar.Commitments))
require.Equal(t, 2, len(sidecar.Proofs))
// verify the blobs
for i := range sidecar.Blobs {
require.NoError(t, kzg4844.VerifyBlobProof(sidecar.Blobs[i], sidecar.Commitments[i], sidecar.Proofs[i]))
}
b1 := eth.Blob(sidecar.Blobs[0])
d1, err := b1.ToData()
require.NoError(t, err)
require.Equal(t, blobData1, d1)
b2 := eth.Blob(sidecar.Blobs[1])
d2, err := b2.ToData()
require.NoError(t, err)
require.Equal(t, blobData2, d2)
}
// TestTxMgr_EstimateGas ensures that the tx manager will estimate // TestTxMgr_EstimateGas ensures that the tx manager will estimate
// the gas when candidate gas limit is zero in [CraftTx]. // the gas when candidate gas limit is zero in [CraftTx].
func TestTxMgr_EstimateGas(t *testing.T) { func TestTxMgr_EstimateGas(t *testing.T) {
...@@ -506,7 +647,7 @@ func TestTxMgrOnlyOnePublicationSucceeds(t *testing.T) { ...@@ -506,7 +647,7 @@ func TestTxMgrOnlyOnePublicationSucceeds(t *testing.T) {
h := newTestHarness(t) h := newTestHarness(t)
gasTipCap, gasFeeCap := h.gasPricer.sample() gasTipCap, gasFeeCap, _ := h.gasPricer.sample()
tx := types.NewTx(&types.DynamicFeeTx{ tx := types.NewTx(&types.DynamicFeeTx{
GasTipCap: gasTipCap, GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap, GasFeeCap: gasFeeCap,
...@@ -519,7 +660,7 @@ func TestTxMgrOnlyOnePublicationSucceeds(t *testing.T) { ...@@ -519,7 +660,7 @@ func TestTxMgrOnlyOnePublicationSucceeds(t *testing.T) {
} }
txHash := tx.Hash() txHash := tx.Hash()
h.backend.mine(&txHash, tx.GasFeeCap()) h.backend.mine(&txHash, tx.GasFeeCap(), nil)
return nil return nil
} }
h.backend.setTxSender(sendTx) h.backend.setTxSender(sendTx)
...@@ -534,14 +675,14 @@ func TestTxMgrOnlyOnePublicationSucceeds(t *testing.T) { ...@@ -534,14 +675,14 @@ func TestTxMgrOnlyOnePublicationSucceeds(t *testing.T) {
} }
// TestTxMgrConfirmsMinGasPriceAfterBumping delays the mining of the initial tx // TestTxMgrConfirmsMinGasPriceAfterBumping delays the mining of the initial tx
// with the minimum gas price, and asserts that it's receipt is returned even // with the minimum gas price, and asserts that its receipt is returned even
// though if the gas price has been bumped in other goroutines. // though if the gas price has been bumped in other goroutines.
func TestTxMgrConfirmsMinGasPriceAfterBumping(t *testing.T) { func TestTxMgrConfirmsMinGasPriceAfterBumping(t *testing.T) {
t.Parallel() t.Parallel()
h := newTestHarness(t) h := newTestHarness(t)
gasTipCap, gasFeeCap := h.gasPricer.sample() gasTipCap, gasFeeCap, _ := h.gasPricer.sample()
tx := types.NewTx(&types.DynamicFeeTx{ tx := types.NewTx(&types.DynamicFeeTx{
GasTipCap: gasTipCap, GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap, GasFeeCap: gasFeeCap,
...@@ -552,7 +693,7 @@ func TestTxMgrConfirmsMinGasPriceAfterBumping(t *testing.T) { ...@@ -552,7 +693,7 @@ func TestTxMgrConfirmsMinGasPriceAfterBumping(t *testing.T) {
if h.gasPricer.shouldMine(tx.GasFeeCap()) { if h.gasPricer.shouldMine(tx.GasFeeCap()) {
time.AfterFunc(5*time.Second, func() { time.AfterFunc(5*time.Second, func() {
txHash := tx.Hash() txHash := tx.Hash()
h.backend.mine(&txHash, tx.GasFeeCap()) h.backend.mine(&txHash, tx.GasFeeCap(), nil)
}) })
} }
return nil return nil
...@@ -573,7 +714,7 @@ func TestTxMgrDoesntAbortNonceTooLowAfterMiningTx(t *testing.T) { ...@@ -573,7 +714,7 @@ func TestTxMgrDoesntAbortNonceTooLowAfterMiningTx(t *testing.T) {
h := newTestHarnessWithConfig(t, configWithNumConfs(2)) h := newTestHarnessWithConfig(t, configWithNumConfs(2))
gasTipCap, gasFeeCap := h.gasPricer.sample() gasTipCap, gasFeeCap, _ := h.gasPricer.sample()
tx := types.NewTx(&types.DynamicFeeTx{ tx := types.NewTx(&types.DynamicFeeTx{
GasTipCap: gasTipCap, GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap, GasFeeCap: gasFeeCap,
...@@ -590,9 +731,9 @@ func TestTxMgrDoesntAbortNonceTooLowAfterMiningTx(t *testing.T) { ...@@ -590,9 +731,9 @@ func TestTxMgrDoesntAbortNonceTooLowAfterMiningTx(t *testing.T) {
// Accept and mine the actual txn we expect to confirm. // Accept and mine the actual txn we expect to confirm.
case h.gasPricer.shouldMine(tx.GasFeeCap()): case h.gasPricer.shouldMine(tx.GasFeeCap()):
txHash := tx.Hash() txHash := tx.Hash()
h.backend.mine(&txHash, tx.GasFeeCap()) h.backend.mine(&txHash, tx.GasFeeCap(), nil)
time.AfterFunc(5*time.Second, func() { time.AfterFunc(5*time.Second, func() {
h.backend.mine(nil, nil) h.backend.mine(nil, nil, nil)
}) })
return nil return nil
...@@ -622,7 +763,7 @@ func TestWaitMinedReturnsReceiptOnFirstSuccess(t *testing.T) { ...@@ -622,7 +763,7 @@ func TestWaitMinedReturnsReceiptOnFirstSuccess(t *testing.T) {
// Create a tx and mine it immediately using the default backend. // Create a tx and mine it immediately using the default backend.
tx := types.NewTx(&types.LegacyTx{}) tx := types.NewTx(&types.LegacyTx{})
txHash := tx.Hash() txHash := tx.Hash()
h.backend.mine(&txHash, new(big.Int)) h.backend.mine(&txHash, new(big.Int), nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
...@@ -664,7 +805,7 @@ func TestWaitMinedMultipleConfs(t *testing.T) { ...@@ -664,7 +805,7 @@ func TestWaitMinedMultipleConfs(t *testing.T) {
// Create an unimined tx. // Create an unimined tx.
tx := types.NewTx(&types.LegacyTx{}) tx := types.NewTx(&types.LegacyTx{})
txHash := tx.Hash() txHash := tx.Hash()
h.backend.mine(&txHash, new(big.Int)) h.backend.mine(&txHash, new(big.Int), nil)
receipt, err := h.mgr.waitMined(ctx, tx, NewSendState(10, time.Hour)) receipt, err := h.mgr.waitMined(ctx, tx, NewSendState(10, time.Hour))
require.Equal(t, err, context.DeadlineExceeded) require.Equal(t, err, context.DeadlineExceeded)
...@@ -674,7 +815,7 @@ func TestWaitMinedMultipleConfs(t *testing.T) { ...@@ -674,7 +815,7 @@ func TestWaitMinedMultipleConfs(t *testing.T) {
defer cancel() defer cancel()
// Mine an empty block, tx should now be confirmed. // Mine an empty block, tx should now be confirmed.
h.backend.mine(nil, nil) h.backend.mine(nil, nil, nil)
receipt, err = h.mgr.waitMined(ctx, tx, NewSendState(10, time.Hour)) receipt, err = h.mgr.waitMined(ctx, tx, NewSendState(10, time.Hour))
require.Nil(t, err) require.Nil(t, err)
require.NotNil(t, receipt) require.NotNil(t, receipt)
...@@ -707,6 +848,7 @@ type failingBackend struct { ...@@ -707,6 +848,7 @@ type failingBackend struct {
returnSuccessHeader bool returnSuccessHeader bool
returnSuccessReceipt bool returnSuccessReceipt bool
baseFee, gasTip *big.Int baseFee, gasTip *big.Int
excessBlobGas *uint64
} }
// BlockNumber for the failingBackend returns errRpcFailure on the first // BlockNumber for the failingBackend returns errRpcFailure on the first
...@@ -743,8 +885,9 @@ func (b *failingBackend) HeaderByNumber(ctx context.Context, _ *big.Int) (*types ...@@ -743,8 +885,9 @@ func (b *failingBackend) HeaderByNumber(ctx context.Context, _ *big.Int) (*types
} }
return &types.Header{ return &types.Header{
Number: big.NewInt(1), Number: big.NewInt(1),
BaseFee: b.baseFee, BaseFee: b.baseFee,
ExcessBlobGas: b.excessBlobGas,
}, nil }, nil
} }
...@@ -923,15 +1066,17 @@ func TestIncreaseGasPrice(t *testing.T) { ...@@ -923,15 +1066,17 @@ func TestIncreaseGasPrice(t *testing.T) {
func TestIncreaseGasPriceLimits(t *testing.T) { func TestIncreaseGasPriceLimits(t *testing.T) {
t.Run("no-threshold", func(t *testing.T) { t.Run("no-threshold", func(t *testing.T) {
testIncreaseGasPriceLimit(t, gasPriceLimitTest{ testIncreaseGasPriceLimit(t, gasPriceLimitTest{
expTipCap: 46, expTipCap: 46,
expFeeCap: 354, // just below 5*100 expFeeCap: 354, // just below 5*100
expBlobFeeCap: 4 * params.GWei,
}) })
}) })
t.Run("with-threshold", func(t *testing.T) { t.Run("with-threshold", func(t *testing.T) {
testIncreaseGasPriceLimit(t, gasPriceLimitTest{ testIncreaseGasPriceLimit(t, gasPriceLimitTest{
thr: big.NewInt(params.GWei), thr: big.NewInt(params.GWei * 10),
expTipCap: 131_326_987, expTipCap: 1_293_535_754,
expFeeCap: 933_286_308, // just below 1 gwei expFeeCap: 9_192_620_686, // just below 10 gwei
expBlobFeeCap: 8 * params.GWei,
}) })
}) })
} }
...@@ -939,6 +1084,7 @@ func TestIncreaseGasPriceLimits(t *testing.T) { ...@@ -939,6 +1084,7 @@ func TestIncreaseGasPriceLimits(t *testing.T) {
type gasPriceLimitTest struct { type gasPriceLimitTest struct {
thr *big.Int thr *big.Int
expTipCap, expFeeCap int64 expTipCap, expFeeCap int64
expBlobFeeCap int64
} }
// testIncreaseGasPriceLimit runs a gas bumping test that increases the gas price until it hits an error. // testIncreaseGasPriceLimit runs a gas bumping test that increases the gas price until it hits an error.
...@@ -948,9 +1094,12 @@ func testIncreaseGasPriceLimit(t *testing.T, lt gasPriceLimitTest) { ...@@ -948,9 +1094,12 @@ func testIncreaseGasPriceLimit(t *testing.T, lt gasPriceLimitTest) {
borkedTip := int64(10) borkedTip := int64(10)
borkedFee := int64(45) borkedFee := int64(45)
// simulate 100 excess blobs which yields a 50 wei blob basefee
borkedExcessBlobGas := uint64(100 * params.BlobTxBlobGasPerBlob)
borkedBackend := failingBackend{ borkedBackend := failingBackend{
gasTip: big.NewInt(borkedTip), gasTip: big.NewInt(borkedTip),
baseFee: big.NewInt(borkedFee), baseFee: big.NewInt(borkedFee),
excessBlobGas: &borkedExcessBlobGas,
returnSuccessHeader: true, returnSuccessHeader: true,
} }
...@@ -972,27 +1121,48 @@ func testIncreaseGasPriceLimit(t *testing.T, lt gasPriceLimitTest) { ...@@ -972,27 +1121,48 @@ func testIncreaseGasPriceLimit(t *testing.T, lt gasPriceLimitTest) {
l: testlog.Logger(t, log.LvlCrit), l: testlog.Logger(t, log.LvlCrit),
metr: &metrics.NoopTxMetrics{}, metr: &metrics.NoopTxMetrics{},
} }
tx := types.NewTx(&types.DynamicFeeTx{ lastGoodTx := types.NewTx(&types.DynamicFeeTx{
GasTipCap: big.NewInt(10), GasTipCap: big.NewInt(10),
GasFeeCap: big.NewInt(100), GasFeeCap: big.NewInt(100),
}) })
// Run IncreaseGasPrice a bunch of times in a row to simulate a very fast resubmit loop. // Run increaseGasPrice a bunch of times in a row to simulate a very fast resubmit loop to make
// sure it errors out without a runaway fee increase.
ctx := context.Background() ctx := context.Background()
var err error
for { for {
newTx, err := mgr.increaseGasPrice(ctx, tx) var tmpTx *types.Transaction
tmpTx, err = mgr.increaseGasPrice(ctx, lastGoodTx)
if err != nil { if err != nil {
break break
} }
tx = newTx lastGoodTx = tmpTx
} }
require.Error(t, err)
lastTip, lastFee := tx.GasTipCap(), tx.GasFeeCap()
// Confirm that fees only rose until expected threshold // Confirm that fees only rose until expected threshold
require.Equal(t, lt.expTipCap, lastTip.Int64()) require.Equal(t, lt.expTipCap, lastGoodTx.GasTipCap().Int64())
require.Equal(t, lt.expFeeCap, lastFee.Int64()) require.Equal(t, lt.expFeeCap, lastGoodTx.GasFeeCap().Int64())
_, err := mgr.increaseGasPrice(ctx, tx)
require.Error(t, err) // Confirm blob txs also don't see runaway fee increase and that blob fee market is also capped
// as expected
blobTx := &types.BlobTx{}
blobTx.GasTipCap = uint256.NewInt(1)
blobTx.GasFeeCap = uint256.NewInt(10)
// set a large initial blobFeeCap to make sure blob fee cap is hit before regular fee cap
blobTx.BlobFeeCap = uint256.NewInt(params.GWei * 2)
lastGoodTx = types.NewTx(blobTx)
for {
var tmpTx *types.Transaction
tmpTx, err = mgr.increaseGasPrice(ctx, lastGoodTx)
if err != nil {
break
}
lastGoodTx = tmpTx
}
require.ErrorIs(t, err, ErrBlobFeeLimit)
// Confirm that fees only rose until expected threshold
require.Equal(t, lt.expBlobFeeCap, lastGoodTx.BlobGasFeeCap().Int64())
} }
func TestErrStringMatch(t *testing.T) { func TestErrStringMatch(t *testing.T) {
...@@ -1032,7 +1202,7 @@ func TestNonceReset(t *testing.T) { ...@@ -1032,7 +1202,7 @@ func TestNonceReset(t *testing.T) {
return core.ErrNonceTooLow return core.ErrNonceTooLow
} }
txHash := tx.Hash() txHash := tx.Hash()
h.backend.mine(&txHash, tx.GasFeeCap()) h.backend.mine(&txHash, tx.GasFeeCap(), nil)
return nil return nil
} }
h.backend.setTxSender(sendTx) h.backend.setTxSender(sendTx)
...@@ -1050,8 +1220,8 @@ func TestNonceReset(t *testing.T) { ...@@ -1050,8 +1220,8 @@ func TestNonceReset(t *testing.T) {
} }
} }
// internal nonce tracking should be reset every 3rd tx // internal nonce tracking should be reset to startingNonce value every 3rd tx
require.Equal(t, []uint64{0, 0, 1, 2, 0, 1, 2, 0}, nonces) require.Equal(t, []uint64{1, 1, 2, 3, 1, 2, 3, 1}, nonces)
} }
func TestMinFees(t *testing.T) { func TestMinFees(t *testing.T) {
...@@ -1103,7 +1273,7 @@ func TestMinFees(t *testing.T) { ...@@ -1103,7 +1273,7 @@ func TestMinFees(t *testing.T) {
conf.MinTipCap = tt.minTipCap conf.MinTipCap = tt.minTipCap
h := newTestHarnessWithConfig(t, conf) h := newTestHarnessWithConfig(t, conf)
tip, basefee, err := h.mgr.suggestGasPriceCaps(context.TODO()) tip, basefee, _, err := h.mgr.suggestGasPriceCaps(context.TODO())
require.NoError(err) require.NoError(err)
if tt.expectMinBasefee { if tt.expectMinBasefee {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment