From e83464e4b66b85813c2f7f369b3da4afe2d21ee2 Mon Sep 17 00:00:00 2001 From: Samuel Laferriere Date: Mon, 15 Jul 2024 01:31:01 -0700 Subject: [PATCH] refactor(geometric txmgr): delete gasoracle and add its methods to geometric txmgr directly --- chainio/gasoracle/gasoracle.go | 127 -------------- chainio/txmgr/geometric.go | 216 ++++++++++++++++-------- chainio/txmgr/geometric_example_test.go | 4 +- chainio/txmgr/geometric_test.go | 17 ++ 4 files changed, 165 insertions(+), 199 deletions(-) delete mode 100644 chainio/gasoracle/gasoracle.go create mode 100644 chainio/txmgr/geometric_test.go diff --git a/chainio/gasoracle/gasoracle.go b/chainio/gasoracle/gasoracle.go deleted file mode 100644 index 4ed128ea..00000000 --- a/chainio/gasoracle/gasoracle.go +++ /dev/null @@ -1,127 +0,0 @@ -package gasoracle - -import ( - "context" - "math/big" - - "github.com/Layr-Labs/eigensdk-go/chainio/clients/eth" - "github.com/Layr-Labs/eigensdk-go/logging" - "github.com/Layr-Labs/eigensdk-go/utils" - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" -) - -type Params struct { - FallbackGasTipCap uint64 - GasMultiplierPercentage uint64 - GasTipMultiplierPercentage uint64 -} - -var defaultParams = Params{ - FallbackGasTipCap: uint64(5_000_000_000), // 5 gwei - GasMultiplierPercentage: uint64(120), // add an extra 20% gas buffer to the gas limit - GasTipMultiplierPercentage: uint64(125), // add an extra 25% to the gas tip -} - -type GasOracle struct { - params Params - client eth.Client - logger logging.Logger -} - -// params are optional gas parameters any of which will be filled with default values if not provided -func New(client eth.Client, logger logging.Logger, params Params) *GasOracle { - if params.FallbackGasTipCap == 0 { - params.FallbackGasTipCap = defaultParams.FallbackGasTipCap - } - if params.GasMultiplierPercentage == 0 { - params.GasMultiplierPercentage = defaultParams.GasMultiplierPercentage - } - if params.GasTipMultiplierPercentage == 0 { - params.GasTipMultiplierPercentage = defaultParams.GasTipMultiplierPercentage - } - return &GasOracle{ - params: params, - client: client, - logger: logger, - } -} - -// TODO: should this be part of the public API? -func (o *GasOracle) GetLatestGasCaps(ctx context.Context) (gasTipCap, gasFeeCap *big.Int, err error) { - gasTipCap, err = o.client.SuggestGasTipCap(ctx) - if err != nil { - // If the transaction failed because the backend does not support - // eth_maxPriorityFeePerGas, fallback to using the default constant. - // Currently Alchemy is the only backend provider that exposes this - // method, so in the event their API is unreachable we can fallback to a - // degraded mode of operation. This also applies to our test - // environments, as hardhat doesn't support the query either. - o.logger.Info("eth_maxPriorityFeePerGas is unsupported by current backend, using fallback gasTipCap") - gasTipCap = big.NewInt(0).SetUint64(o.params.FallbackGasTipCap) - } - - gasTipCap.Mul(gasTipCap, big.NewInt(int64(o.params.GasTipMultiplierPercentage))).Div(gasTipCap, big.NewInt(100)) - - header, err := o.client.HeaderByNumber(ctx, nil) - if err != nil { - return nil, nil, utils.WrapError("failed to get latest header", err) - } - gasFeeCap = getGasFeeCap(gasTipCap, header.BaseFee) - return -} - -// UpdateGasParams updates the three gas related parameters of a transaction: -// gasTipCap, gasFeeCap, and gasLimit. It uses the provided gasTipCap and gasFeeCap -// which could either come from o.GetLatestGasCaps, or be manually bumped by the caller, -// and estimates the gas limit based on the transaction. -// TODO: should we add a public method to also bump the gasTipCap and gasFeeCap, instead of forcing client to do it -// themselves? -func (o *GasOracle) UpdateGasParams( - ctx context.Context, - tx *types.Transaction, - gasTipCap, gasFeeCap *big.Int, - from common.Address, -) (*types.Transaction, error) { - - // we reestimate the gas limit because the state of the chain may have changed, - // which could cause the previous gas limit to be insufficient - gasLimit, err := o.client.EstimateGas(ctx, ethereum.CallMsg{ - From: from, - To: tx.To(), - GasTipCap: gasTipCap, - GasFeeCap: gasFeeCap, - Value: tx.Value(), - Data: tx.Data(), - }) - if err != nil { - return nil, utils.WrapError("failed to estimate gas", err) - } - // we also add a buffer to the gas limit to account for potential changes in the state of the chain - // between the time of estimation and the time the transaction is mined - bufferedGasLimit := o.addGasBuffer(gasLimit) - - return types.NewTx(&types.DynamicFeeTx{ - ChainID: tx.ChainId(), - Nonce: tx.Nonce(), - GasTipCap: gasTipCap, - GasFeeCap: gasFeeCap, - Gas: bufferedGasLimit, - To: tx.To(), - Value: tx.Value(), - Data: tx.Data(), - AccessList: tx.AccessList(), - }), nil -} - -func (o *GasOracle) addGasBuffer(gasLimit uint64) uint64 { - return o.params.GasMultiplierPercentage * gasLimit / 100 -} - -// getGasFeeCap returns the gas fee cap for a transaction, calculated as: -// gasFeeCap = 2 * baseFee + gasTipCap -// Rationale: https://www.blocknative.com/blog/eip-1559-fees -func getGasFeeCap(gasTipCap *big.Int, baseFee *big.Int) *big.Int { - return new(big.Int).Add(new(big.Int).Mul(baseFee, big.NewInt(2)), gasTipCap) -} diff --git a/chainio/txmgr/geometric.go b/chainio/txmgr/geometric.go index e1c3f653..6fa43136 100644 --- a/chainio/txmgr/geometric.go +++ b/chainio/txmgr/geometric.go @@ -8,12 +8,12 @@ import ( "sync" "time" - "github.com/Layr-Labs/eigensdk-go/chainio/clients/eth" "github.com/Layr-Labs/eigensdk-go/chainio/clients/wallet" - "github.com/Layr-Labs/eigensdk-go/chainio/gasoracle" "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/Layr-Labs/eigensdk-go/utils" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" ) @@ -38,12 +38,18 @@ type txnRequest struct { txAttempts []*transaction } +type EthBackend interface { + BlockNumber(ctx context.Context) (uint64, error) + SuggestGasTipCap(ctx context.Context) (*big.Int, error) + EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) + HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) +} + type GeometricTxManager struct { // FIXME: is this mutex still needed? mu sync.Mutex - ethClient eth.Client - gasOracle *gasoracle.GasOracle + ethClient EthBackend wallet wallet.Wallet logger logging.Logger metrics *Metrics @@ -68,14 +74,23 @@ type GeometricTxnManagerParams struct { // percentage multiplier for gas price. It needs to be >= 10 to properly replace existing transaction // e.g. 10 means 10% increase gasPricePercentageMultiplier *big.Int + // default gas tip cap to use when eth_maxPriorityFeePerGas is not available + FallbackGasTipCap uint64 + // percentage multiplier for gas limit. Should be >= 100 + GasMultiplierPercentage uint64 + // percentage multiplier for gas tip. Should be >= 100 + GasTipMultiplierPercentage uint64 } var defaultParams = GeometricTxnManagerParams{ - confirmationBlocks: 0, // tx mined is considered confirmed - txnBroadcastTimeout: 2 * time.Minute, // fireblocks has had issues so we give it a long time - txnConfirmationTimeout: 5 * 12 * time.Second, // 5 blocks - maxSendTransactionRetry: 3, // arbitrary - gasPricePercentageMultiplier: big.NewInt(10), // 10% + confirmationBlocks: 0, // tx mined is considered confirmed + txnBroadcastTimeout: 2 * time.Minute, // fireblocks has had issues so we give it a long time + txnConfirmationTimeout: 5 * 12 * time.Second, // 5 blocks + maxSendTransactionRetry: 3, // arbitrary + gasPricePercentageMultiplier: big.NewInt(10), // 10% + FallbackGasTipCap: uint64(5_000_000_000), // 5 gwei + GasMultiplierPercentage: uint64(120), // add an extra 20% gas buffer to the gas limit + GasTipMultiplierPercentage: uint64(125), // add an extra 25% to the gas tip } func fillParamsWithDefaultValues(params *GeometricTxnManagerParams) { @@ -91,11 +106,22 @@ func fillParamsWithDefaultValues(params *GeometricTxnManagerParams) { if params.maxSendTransactionRetry == 0 { params.maxSendTransactionRetry = defaultParams.maxSendTransactionRetry } + if params.gasPricePercentageMultiplier == nil { + params.gasPricePercentageMultiplier = defaultParams.gasPricePercentageMultiplier + } + if params.FallbackGasTipCap == 0 { + params.FallbackGasTipCap = defaultParams.FallbackGasTipCap + } + if params.GasMultiplierPercentage == 0 { + params.GasMultiplierPercentage = defaultParams.GasMultiplierPercentage + } + if params.GasTipMultiplierPercentage == 0 { + params.GasTipMultiplierPercentage = defaultParams.GasTipMultiplierPercentage + } } func NewGeometricTxnManager( - ethClient eth.Client, - gasOracle *gasoracle.GasOracle, + ethClient EthBackend, wallet wallet.Wallet, logger logging.Logger, metrics *Metrics, @@ -104,7 +130,6 @@ func NewGeometricTxnManager( fillParamsWithDefaultValues(¶ms) return &GeometricTxManager{ ethClient: ethClient, - gasOracle: gasOracle, wallet: wallet, logger: logger.With("component", "GeometricTxManager"), metrics: metrics, @@ -155,17 +180,16 @@ func (t *GeometricTxManager) processTransaction(ctx context.Context, req *txnReq var txID wallet.TxID var err error retryFromFailure := 0 + from, err := t.wallet.SenderAddress(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get sender address: %w", err) + } for retryFromFailure < t.params.maxSendTransactionRetry { - gasTipCap, gasFeeCap, err := t.gasOracle.GetLatestGasCaps(ctx) + gasTipCap, err := t.estimateGasTipCap(ctx) if err != nil { - return nil, fmt.Errorf("failed to get latest gas caps: %w", err) + return nil, fmt.Errorf("failed to estimate gas tip cap: %w", err) } - - from, err := t.wallet.SenderAddress(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get sender address: %w", err) - } - txn, err = t.gasOracle.UpdateGasParams(ctx, req.tx, gasTipCap, gasFeeCap, from) + txn, err = t.updateGasTipCap(ctx, req.tx, gasTipCap, from) if err != nil { return nil, fmt.Errorf("failed to update gas price: %w", err) } @@ -441,67 +465,121 @@ func (t *GeometricTxManager) speedUpTxn( ctx context.Context, tx *types.Transaction, ) (*types.Transaction, error) { - prevGasTipCap := tx.GasTipCap() - prevGasFeeCap := tx.GasFeeCap() - // get the gas tip cap and gas fee cap based on current network condition - currentGasTipCap, currentGasFeeCap, err := t.gasOracle.GetLatestGasCaps(ctx) - if err != nil { - return nil, err + // bump the current gasTip, and also reestimate it from the node, and take the highest value + var newGasTipCap *big.Int + { + estimatedGasTipCap, err := t.estimateGasTipCap(ctx) + if err != nil { + return nil, fmt.Errorf("failed to estimate gas tip cap: %w", err) + } + bumpedGasTipCap := t.addGasTipCapBuffer(tx.GasTipCap()) + if estimatedGasTipCap.Cmp(bumpedGasTipCap) > 0 { + newGasTipCap = estimatedGasTipCap + } else { + newGasTipCap = bumpedGasTipCap + } } - increasedGasTipCap := increaseGasPrice(prevGasTipCap, t.params.gasPricePercentageMultiplier) - increasedGasFeeCap := increaseGasPrice(prevGasFeeCap, t.params.gasPricePercentageMultiplier) - // make sure increased gas prices are not lower than current gas prices - var newGasTipCap, newGasFeeCap *big.Int - if currentGasTipCap.Cmp(increasedGasTipCap) > 0 { - newGasTipCap = currentGasTipCap - } else { - newGasTipCap = increasedGasTipCap + + from, err := t.wallet.SenderAddress(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get sender address: %w", err) } - if currentGasFeeCap.Cmp(increasedGasFeeCap) > 0 { - newGasFeeCap = currentGasFeeCap - } else { - newGasFeeCap = increasedGasFeeCap + newTx, err := t.updateGasTipCap(ctx, tx, newGasTipCap, from) + if err != nil { + return nil, fmt.Errorf("failed to update gas price: %w", err) } - t.logger.Info( "increasing gas price", - "txHash", - tx.Hash().Hex(), - "nonce", - tx.Nonce(), - "prevGasTipCap", - prevGasTipCap, - "prevGasFeeCap", - prevGasFeeCap, - "newGasTipCap", - newGasTipCap, - "newGasFeeCap", - newGasFeeCap, + "prevTxHash", tx.Hash().Hex(), "newTxHash", newTx.Hash().Hex(), + "nonce", tx.Nonce(), + "prevGasTipCap", tx.GasTipCap(), "newGasTipCap", newGasTipCap, + "prevGasFeeCap", tx.GasFeeCap(), "newGasFeeCap", newTx.GasFeeCap(), ) - from, err := t.wallet.SenderAddress(ctx) + return newTx, nil +} + +// UpdateGasParams updates the three gas related parameters of a transaction: +// - gasTipCap: calls the json-rpc method eth_maxPriorityFeePerGas and +// adds a extra buffer based on o.params.GasTipMultiplierPercentage +// - gasFeeCap: calculates the gas fee cap as 2 * baseFee + gasTipCap +// - gasLimit: calls the json-rpc method eth_estimateGas and +// adds a extra buffer based on o.params.GasMultiplierPercentage +func (t *GeometricTxManager) updateGasTipCap( + ctx context.Context, + tx *types.Transaction, + newGasTipCap *big.Int, + from common.Address, +) (*types.Transaction, error) { + gasFeeCap, err := t.estimateGasFeeCap(ctx, newGasTipCap) if err != nil { - return nil, fmt.Errorf("failed to get sender address: %w", err) + return nil, utils.WrapError("failed to estimate gas fee cap", err) } - return t.gasOracle.UpdateGasParams(ctx, tx, newGasTipCap, newGasFeeCap, from) + + // we reestimate the gas limit because the state of the chain may have changed, + // which could cause the previous gas limit to be insufficient + gasLimit, err := t.ethClient.EstimateGas(ctx, ethereum.CallMsg{ + From: from, + To: tx.To(), + GasTipCap: newGasTipCap, + GasFeeCap: gasFeeCap, + Value: tx.Value(), + Data: tx.Data(), + }) + if err != nil { + return nil, utils.WrapError("failed to estimate gas", err) + } + // we also add a buffer to the gas limit to account for potential changes in the state of the chain + // between the time of estimation and the time the transaction is mined + bufferedGasLimit := t.addGasBuffer(gasLimit) + + return types.NewTx(&types.DynamicFeeTx{ + ChainID: tx.ChainId(), + Nonce: tx.Nonce(), + GasTipCap: newGasTipCap, + GasFeeCap: gasFeeCap, + Gas: bufferedGasLimit, + To: tx.To(), + Value: tx.Value(), + Data: tx.Data(), + AccessList: tx.AccessList(), + }), nil } -// increaseGasPrice increases the gas price by specified percentage. -// i.e. gasPrice + ((gasPrice * gasPricePercentageMultiplier + 99) / 100) -func increaseGasPrice(gasPrice, gasPricePercentageMultiplier *big.Int) *big.Int { - if gasPrice == nil { - return nil +func (t *GeometricTxManager) estimateGasTipCap(ctx context.Context) (gasTipCap *big.Int, err error) { + gasTipCap, err = t.ethClient.SuggestGasTipCap(ctx) + if err != nil { + // If the transaction failed because the backend does not support + // eth_maxPriorityFeePerGas, fallback to using the default constant. + // Currently Alchemy is the only backend provider that exposes this + // method, so in the event their API is unreachable we can fallback to a + // degraded mode of operation. This also applies to our test + // environments, as hardhat doesn't support the query either. + // TODO: error could actually come from node not being down or network being slow, etc. + t.logger.Info("eth_maxPriorityFeePerGas is unsupported by current backend, using fallback gasTipCap") + gasTipCap = big.NewInt(0).SetUint64(t.params.FallbackGasTipCap) } - bump := new(big.Int).Mul(gasPrice, gasPricePercentageMultiplier) - bump = roundUpDivideBig(bump, hundred) - return new(big.Int).Add(gasPrice, bump) + return t.addGasTipCapBuffer(gasTipCap), nil } -func roundUpDivideBig(a, b *big.Int) *big.Int { - if a == nil || b == nil || b.Cmp(big.NewInt(0)) == 0 { - return nil +// addGasTipCapBuffer adds a buffer to the gas tip cap to account for potential changes in the state of the chain +// The result is returned in a new big.Int to avoid modifying the input gasTipCap. +func (t *GeometricTxManager) addGasTipCapBuffer(gasTipCap *big.Int) *big.Int { + bumpedGasTipCap := new(big.Int).Set(gasTipCap) + return bumpedGasTipCap.Mul(bumpedGasTipCap, big.NewInt(int64(t.params.GasTipMultiplierPercentage))).Div(bumpedGasTipCap, big.NewInt(100)) +} + +// estimateGasFeeCap returns the gas fee cap for a transaction, calculated as: +// gasFeeCap = 2 * baseFee + gasTipCap +// Rationale: https://www.blocknative.com/blog/eip-1559-fees +// The result is returned in a new big.Int to avoid modifying gasTipCap. +func (t *GeometricTxManager) estimateGasFeeCap(ctx context.Context, gasTipCap *big.Int) (*big.Int, error) { + header, err := t.ethClient.HeaderByNumber(ctx, nil) + if err != nil { + return nil, utils.WrapError("failed to get latest header", err) } - one := new(big.Int).SetUint64(1) - num := new(big.Int).Sub(new(big.Int).Add(a, b), one) // a + b - 1 - res := new(big.Int).Div(num, b) // (a + b - 1) / b - return res + return new(big.Int).Add(new(big.Int).Mul(header.BaseFee, big.NewInt(2)), gasTipCap), nil +} + +func (t *GeometricTxManager) addGasBuffer(gasLimit uint64) uint64 { + return t.params.GasMultiplierPercentage * gasLimit / 100 } diff --git a/chainio/txmgr/geometric_example_test.go b/chainio/txmgr/geometric_example_test.go index a12aa992..9b74c8dc 100644 --- a/chainio/txmgr/geometric_example_test.go +++ b/chainio/txmgr/geometric_example_test.go @@ -9,7 +9,6 @@ import ( "github.com/Layr-Labs/eigensdk-go/chainio/clients/eth" "github.com/Layr-Labs/eigensdk-go/chainio/clients/wallet" - "github.com/Layr-Labs/eigensdk-go/chainio/gasoracle" "github.com/Layr-Labs/eigensdk-go/logging" "github.com/Layr-Labs/eigensdk-go/signerv2" "github.com/Layr-Labs/eigensdk-go/testutils" @@ -71,7 +70,6 @@ func createTxMgr(rpcUrl string, ecdsaPrivateKey *ecdsa.PrivateKey) (eth.Client, if err != nil { panic(err) } - gasOracle := gasoracle.New(client, logger, gasoracle.Params{}) signerV2, signerAddr, err := signerv2.SignerFromConfig(signerv2.Config{PrivateKey: ecdsaPrivateKey}, chainid) if err != nil { panic(err) @@ -82,5 +80,5 @@ func createTxMgr(rpcUrl string, ecdsaPrivateKey *ecdsa.PrivateKey) (eth.Client, } reg := prometheus.NewRegistry() metrics := NewMetrics(reg, "example", logger) - return client, NewGeometricTxnManager(client, gasOracle, wallet, logger, metrics, GeometricTxnManagerParams{}) + return client, NewGeometricTxnManager(client, wallet, logger, metrics, GeometricTxnManagerParams{}) } diff --git a/chainio/txmgr/geometric_test.go b/chainio/txmgr/geometric_test.go new file mode 100644 index 00000000..5ae6eb1f --- /dev/null +++ b/chainio/txmgr/geometric_test.go @@ -0,0 +1,17 @@ +package txmgr + +import ( + "testing" +) + +func TestGeometricTxManager(t *testing.T) { + +} + +type StubEthBackend struct { + blockNumber uint64 +} + +func (s *StubEthBackend) BlockNumber() (uint64, error) { + return s.blockNumber, nil +}