diff --git a/chainio/README.md b/chainio/README.md index 1371e7ab..2051311b 100644 --- a/chainio/README.md +++ b/chainio/README.md @@ -1,20 +1,30 @@ ## ChainIO -This module is used to facilitate reading/writing/subscribing to [eigenlayer core](./clients/elcontracts/) contracts and [avs registry](./clients/avsregistry/) contracts. -To make it easier to understand the different structs in this package, and their hierarchical relationship, we describe each of them below: -- geth's ethClient -- eigensdk [ethClient](./clients/eth/client.go) - - wraps geth's ethClient and adds convenience methods -- [eigenlayerContractBindings](../contracts/bindings/) +### Interacting with a json-rpc node + +We have a basic [ethClient](./clients/eth/client.go) which simply wraps geth's ethClient and adds some convenience methods. The Client interface is also implemented by [instrumentedClient](./clients/eth/instrumented_client.go) which adds metrics to the ethClient to conform to the node spec's [rpc metrics](https://docs.eigenlayer.xyz/eigenlayer/avs-guides/spec/metrics/metrics-prom-spec#rpc-metrics) requirements. + + +### Building Transactions + +In order to facilitate reading/writing/subscribing to [eigenlayer core](./clients/elcontracts/) contracts and [avs registry](./clients/avsregistry/) contracts, we use geth's abigen created bindings for low-level interactions, as well as our own high-level clients with higher utility functions: +- [Eigenlayer Contract Bindings](./clients/elcontracts/bindings.go) - generated by abigen - low level bindings to eigenlayer core contracts, which wrap our ethClient -- [elContractsClient](./clients/eth/client.go) - - wraps eigenlayerContractBindings and hides a little bit of the underlying complexity, which is not needed in 99% of cases. - - abigen also doesn't create an interface for the bindings it generates, whereas elContractsClient has a well defined interface which we use to generate mocks to help with testing. - [ELChainReader](./clients/elcontracts/reader.go) / [ELChainWriter](./clients/elcontracts/writer.go) / [ELChainSubscriber](./clients/avsregistry/subscriber.go) - - wraps elContractsClient and adds convenience methods - - hides even more complexity than elContractsClient + - wraps bindings and adds convenience methods - These structs should be the only ones used by AVS developers, apart from interacting with an ethClient directly to make direct json rpc calls such as waiting for a transaction receipt. -A similar hierarchy applies for the avs registry contracts. +There's a similar setup for the [avs registry](./clients/avsregistry/) contracts. + +### Signing, Sending, and Managing Transactions + +After building transactions, we need to sign them, send them to the network, and manage the nonce and gas price to ensure they are mined. This functionality is provided by: +- [txmgr](./txmgr/README.md) + - uses a wallet to sign and submit transactions, but then manages them by resubmitting with higher gas prices until they are mined. +- [wallet](./clients/wallet) + - uses a signerv2 to sign transactions, sends them to the network and can query for their receipts + - wallet abstraction is needed because "wallets", such as fireblocks, both sign and send transactions to the network (they don't simply return signed bytes so that we can send them ourselves) +- [signerv2](../signerv2/README.md) + - signs transactions \ No newline at end of file diff --git a/chainio/clients/wallet/README.md b/chainio/clients/wallet/README.md new file mode 100644 index 00000000..86d53d82 --- /dev/null +++ b/chainio/clients/wallet/README.md @@ -0,0 +1,3 @@ +# Wallet + +TODO \ No newline at end of file diff --git a/chainio/clients/wallet/privatekey_wallet.go b/chainio/clients/wallet/privatekey_wallet.go index acec1ee8..f98a4225 100644 --- a/chainio/clients/wallet/privatekey_wallet.go +++ b/chainio/clients/wallet/privatekey_wallet.go @@ -3,31 +3,30 @@ package wallet import ( "context" "fmt" - "math/big" "github.com/Layr-Labs/eigensdk-go/logging" "github.com/Layr-Labs/eigensdk-go/signerv2" "github.com/Layr-Labs/eigensdk-go/utils" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" ) -var _ Wallet = (*privateKeyWallet)(nil) +type EthBackend interface { + SendTransaction(ctx context.Context, tx *types.Transaction) error + TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) +} type privateKeyWallet struct { - ethClient ethClient + ethClient EthBackend address common.Address signerFn signerv2.SignerFn logger logging.Logger - - // cache - contracts map[common.Address]*bind.BoundContract } +var _ Wallet = (*privateKeyWallet)(nil) + func NewPrivateKeyWallet( - ethClient ethClient, + ethClient EthBackend, signer signerv2.SignerFn, signerAddress common.Address, logger logging.Logger, @@ -37,7 +36,6 @@ func NewPrivateKeyWallet( address: signerAddress, signerFn: signer, logger: logger, - contracts: make(map[common.Address]*bind.BoundContract, 0), }, nil } @@ -50,32 +48,17 @@ func (t *privateKeyWallet) SendTransaction(ctx context.Context, tx *types.Transa } t.logger.Debug("Sending transaction") - opts := &bind.TransactOpts{ - From: t.address, - Nonce: new(big.Int).SetUint64(tx.Nonce()), - Signer: signer, - Value: tx.Value(), - GasFeeCap: tx.GasFeeCap(), - GasTipCap: tx.GasTipCap(), - GasLimit: tx.Gas(), - Context: ctx, - } - - contract := t.contracts[*tx.To()] - // if the contract has not been cached - if contract == nil { - // create a dummy bound contract tied to the `to` address of the transaction - contract = bind.NewBoundContract(*tx.To(), abi.ABI{}, t.ethClient, t.ethClient, t.ethClient) - // cache the contract for later use - t.contracts[*tx.To()] = contract + signedTx, err := signer(t.address, tx) + if err != nil { + return "", utils.WrapError(fmt.Errorf("sign: tx %v failed.", tx.Hash().String()), err) } - sendingTx, err := contract.RawTransact(opts, tx.Data()) + err = t.ethClient.SendTransaction(ctx, signedTx) if err != nil { return "", utils.WrapError(fmt.Errorf("send: tx %v failed.", tx.Hash().String()), err) } - return sendingTx.Hash().Hex(), nil + return signedTx.Hash().Hex(), nil } func (t *privateKeyWallet) GetTransactionReceipt(ctx context.Context, txID TxID) (*types.Receipt, error) { diff --git a/chainio/clients/wallet/privatekey_wallet_test.go b/chainio/clients/wallet/privatekey_wallet_test.go new file mode 100644 index 00000000..1cb1a9ce --- /dev/null +++ b/chainio/clients/wallet/privatekey_wallet_test.go @@ -0,0 +1,70 @@ +package wallet + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/Layr-Labs/eigensdk-go/signerv2" + "github.com/Layr-Labs/eigensdk-go/testutils" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +var ( + chainId = big.NewInt(31337) +) + +func TestPrivateKeyWallet(t *testing.T) { + logger := testutils.NewTestLogger() + + t.Run("SendTransaction + GetTransactionReceipt", func(t *testing.T) { + anvilC, err := testutils.StartAnvilContainer("") + require.NoError(t, err) + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + anvilHttpEndpoint, err := anvilC.Endpoint(ctxWithTimeout, "http") + require.NoError(t, err) + ethClient, err := ethclient.Dial(anvilHttpEndpoint) + require.NoError(t, err) + + ecdsaPrivKeyHex := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + ecdsaPrivKey, err := crypto.HexToECDSA(ecdsaPrivKeyHex) + require.NoError(t, err) + signerV2, signerAddr, err := signerv2.SignerFromConfig(signerv2.Config{PrivateKey: ecdsaPrivKey}, chainId) + if err != nil { + panic(err) + } + + skWallet, err := NewPrivateKeyWallet(ethClient, signerV2, signerAddr, logger) + require.NoError(t, err) + + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainId, + Nonce: 0, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(1_000_000_000), + Gas: 21000, + To: &signerAddr, + Value: big.NewInt(1), + }) + ctxWithTimeout, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + txId, err := skWallet.SendTransaction(ctxWithTimeout, tx) + require.NoError(t, err) + + // need to give some time for anvil to process the tx and mine the block + // TODO: shall we expose a public WaitForTxReceipt function in the wallet interface, or somewhere else? + time.Sleep(3 * time.Second) + + ctxWithTimeout, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + receipt, err := skWallet.GetTransactionReceipt(ctxWithTimeout, txId) + require.NoError(t, err) + // make sure the txHash in the mined tx receipt matches the once we sent + require.Equal(t, txId, receipt.TxHash.String()) + }) +} diff --git a/chainio/clients/wallet/wallet.go b/chainio/clients/wallet/wallet.go index 95f9b526..ff300704 100644 --- a/chainio/clients/wallet/wallet.go +++ b/chainio/clients/wallet/wallet.go @@ -9,7 +9,8 @@ import ( type TxID = string -// Wallet is an interface for signing and sending transactions to the network +// Wallet is an interface for signing and sending transactions to the txpool. +// For a higher-level interface that includes nonce management and gas bumping, use the TxManager interface. // This interface is used to abstract the process of sending transactions to the Ethereum network // For example, for an MPC signer, the transaction would be broadcasted via an external API endpoint // and the status is tracked via another external endpoint instead of being broadcasted diff --git a/chainio/txmgr/README.md b/chainio/txmgr/README.md index 186d3647..b8cc6715 100644 --- a/chainio/txmgr/README.md +++ b/chainio/txmgr/README.md @@ -1,12 +1,20 @@ ## Transaction Manager + Transaction Manager is responsible for * Building transactions -* Estimating fees and adding buffer +* Estimating fees and adding gas limit buffer * Signing transactions * Sending transactions to the network +* Doing transaction nonce and gas price management to ensure transactions are mined -### Simple Transaction Manager -Here's the flow of the simple transaction manager which is used to send smart contract -transactions to the network. +Here's the flow of the simple transaction manager which is used to send smart contract transactions to the network. ![Simple Transaction Manager](./simple-tx-manager-flow.png) + +### Simple Transaction Manager + +The simple txmgr simply sends transactions to the network, waits for them to be mined, and returns the receipt. It doesn't do any managing. + +### Geometric Transaction Manager + +The geometric txmgr is a more advanced version of the simple txmgr. It sends transactions to the network, waits for them to be mined, and if they are not mined within a certain time, it bumps the gas price geometrically and resubmits the transaction. This process is repeated until the transaction is mined. \ No newline at end of file diff --git a/chainio/txmgr/geometric/geometric.go b/chainio/txmgr/geometric/geometric.go new file mode 100644 index 00000000..896c80b0 --- /dev/null +++ b/chainio/txmgr/geometric/geometric.go @@ -0,0 +1,623 @@ +package geometric + +import ( + "context" + "errors" + "fmt" + "math/big" + "net/url" + "time" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/wallet" + "github.com/Layr-Labs/eigensdk-go/chainio/txmgr" + "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" +) + +type transaction struct { + *types.Transaction + TxID wallet.TxID + requestedAt time.Time +} + +type txnRequest struct { + tx *types.Transaction + + requestedAt time.Time + // txAttempts are the transactions that have been attempted to be mined for this request. + // If a transaction hasn't been confirmed within the timeout and a replacement transaction is sent, + // the original transaction hash will be kept in this slice + 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 { + ethClient ethBackend + wallet wallet.Wallet + logger logging.Logger + metrics Metrics + + // consts + params GeometricTxnManagerParams +} + +var _ txmgr.TxManager = (*GeometricTxManager)(nil) + +// GeometricTxnManagerParams contains the parameters for the GeometricTxManager. +// If a parameter is not set (aka its zero value is present in the struct), the default value will be used. +type GeometricTxnManagerParams struct { + // number of blocks to wait for a transaction to be confirmed + // default: 0 + ConfirmationBlocks int + // time to wait for a transaction to be broadcasted to the network + // could be direct via eth_sendRawTransaction or indirect via a wallet service such as fireblocks + // default: 2 minutes + TxnBroadcastTimeout time.Duration + // time to wait for a transaction to be confirmed (mined + confirmationBlocks blocks) + // default: 5 * 12 seconds + TxnConfirmationTimeout time.Duration + // max number of times to retry sending a transaction before failing + // this applies to every transaction attempt when a nonce is bumped + // default: 3 + MaxSendTransactionRetry int + // time to wait between checking for each transaction receipt + // while monitoring transactions to get mined + // default: 3 seconds + GetTxReceiptTickerDuration time.Duration + // default gas tip cap to use when eth_maxPriorityFeePerGas is not available + // default: 5 gwei + FallbackGasTipCap uint64 + // multiplier for gas limit to add a buffer and increase chance of tx getting included. Should be >= 1.0 + // default: 1.2 + GasMultiplier float64 + // multiplier for gas tip. Should be >= 1.0 + // default: 1.25 + GasTipMultiplier float64 +} + +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 + GetTxReceiptTickerDuration: 3 * time.Second, + FallbackGasTipCap: uint64(5_000_000_000), // 5 gwei + GasMultiplier: float64(1.20), // add an extra 20% gas buffer to the gas limit + GasTipMultiplier: float64(1.25), // add an extra 25% to the gas tip +} + +func fillUnsetParamsWithDefaultValues(params *GeometricTxnManagerParams) { + if params.ConfirmationBlocks == 0 { + params.ConfirmationBlocks = defaultParams.ConfirmationBlocks + } + if params.TxnBroadcastTimeout == 0 { + params.TxnBroadcastTimeout = defaultParams.TxnBroadcastTimeout + } + if params.TxnConfirmationTimeout == 0 { + params.TxnConfirmationTimeout = defaultParams.TxnConfirmationTimeout + } + if params.MaxSendTransactionRetry == 0 { + params.MaxSendTransactionRetry = defaultParams.MaxSendTransactionRetry + } + if params.GetTxReceiptTickerDuration == 0 { + params.GetTxReceiptTickerDuration = defaultParams.GetTxReceiptTickerDuration + } + if params.FallbackGasTipCap == 0 { + params.FallbackGasTipCap = defaultParams.FallbackGasTipCap + } + if params.GasMultiplier == 0 { + params.GasMultiplier = defaultParams.GasMultiplier + } + if params.GasTipMultiplier == 0 { + params.GasTipMultiplier = defaultParams.GasTipMultiplier + } +} + +func NewGeometricTxnManager( + ethClient ethBackend, + wallet wallet.Wallet, + logger logging.Logger, + metrics Metrics, + params GeometricTxnManagerParams, +) *GeometricTxManager { + fillUnsetParamsWithDefaultValues(¶ms) + return &GeometricTxManager{ + ethClient: ethClient, + wallet: wallet, + logger: logger.With("component", "GeometricTxManager"), + metrics: metrics, + params: params, + } +} + +// GetNoSendTxOpts This generates a noSend TransactOpts so that we can use +// this to generate the transaction without actually sending it +func (m *GeometricTxManager) GetNoSendTxOpts() (*bind.TransactOpts, error) { + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + from, err := m.wallet.SenderAddress(ctxWithTimeout) + if err != nil { + return nil, utils.WrapError("failed to get sender address", err) + } + return &bind.TransactOpts{ + From: from, + NoSend: true, + Signer: txmgr.NoopSigner, + }, nil +} + +func newTxnRequest(tx *types.Transaction) *txnRequest { + return &txnRequest{ + tx: tx, + requestedAt: time.Now(), + txAttempts: make([]*transaction, 0), + } +} + +// Send is used to sign and send a transaction to an evm chain. +// It does gas estimation and gas price bumping to ensure the transaction gets mined, +// but it does not do nonce management, so the tx argument must have the correct nonce already set. +// +// Send is blocking and safe to call concurrently, so sending multiple txs in parallel is safe. +func (t *GeometricTxManager) Send(ctx context.Context, tx *types.Transaction) (*types.Receipt, error) { + return t.processTransaction(ctx, newTxnRequest(tx)) +} + +// processTransaction sends the transaction and runs a monitoring loop to bump the gasPrice until the tx get included. +// processTransaction can be called concurrently, so sending multiple txs in parallel is safe. +// However, the nonces have to be set correctly by the caller. One could send txs with nonces 3,2,1,0 in this order. +// But sending nonces 2,1 and forgetting 0 would cause the manager to get stuck waiting for nonce 0 to be mined. +// Thus a wallet which manages nonces should be used to ensure the correct nonce is set. +func (t *GeometricTxManager) processTransaction(ctx context.Context, req *txnRequest) (*types.Receipt, error) { + t.logger.Debug("new transaction", + "nonce", req.tx.Nonce(), "gasFeeCap", req.tx.GasFeeCap(), "gasTipCap", req.tx.GasTipCap(), + ) + t.metrics.IncrementProcessingTxCount() + defer t.metrics.DecrementProcessingTxCount() + + from, err := t.wallet.SenderAddress(ctx) + if err != nil { + return nil, utils.WrapError("failed to get sender address", err) + } + + var txn *types.Transaction + var txID wallet.TxID + retryFromFailure := 0 + for retryFromFailure < t.params.MaxSendTransactionRetry { + gasTipCap, err := t.estimateGasTipCap(ctx) + if err != nil { + return nil, utils.WrapError("failed to estimate gas tip cap", err) + } + txn, err = t.updateGasTipCap(ctx, req.tx, gasTipCap, from) + if err != nil { + return nil, utils.WrapError("failed to update gas price", err) + } + txID, err = t.wallet.SendTransaction(ctx, txn) + // the fireblocks and privatekey wallets use go's net.Http client which returns a url.Error on timeouts + // see https://pkg.go.dev/net/http#Client.Do + // context.DeadlineExceeded error is returned by any other (non-network) context timeouting. + // so we need this ugly code to catch both forms of timeout. + // Perhaps we could in the fireblocks client convert the url.Error into context.DeadlineExceeded errors? + var urlErr *url.Error + didTimeout := false + if errors.As(err, &urlErr) { + didTimeout = urlErr.Timeout() + } + if didTimeout || errors.Is(err, context.DeadlineExceeded) { + t.logger.Warn( + "failed to send txn due to timeout", + "hash", + txn.Hash().Hex(), + "numRetries", + retryFromFailure, + "maxRetry", + t.params.MaxSendTransactionRetry, + "err", + err, + ) + // TODO: why do we only retry on client or server timeouts? what about other errors? + retryFromFailure++ + continue + } else if err != nil { + return nil, utils.WrapError(fmt.Errorf("failed to send txn %s", txn.Hash().Hex()), err) + } else { + t.logger.Debug("successfully sent txn", "txID", txID, "txHash", txn.Hash().Hex()) + break + } + } + // if all attempts to send the tx failed, return an error + if txn == nil || txID == "" { + return nil, utils.WrapError(fmt.Errorf("failed to send txn %s", req.tx.Hash().Hex()), err) + } + + req.tx = txn + req.txAttempts = append(req.txAttempts, &transaction{ + TxID: txID, + Transaction: txn, + requestedAt: time.Now(), + }) + + receipt, err := t.monitorTransaction(ctx, req) + if err == nil { + if receipt.GasUsed > 0 { + t.metrics.ObserveGasUsedWei(receipt.GasUsed) + } + t.metrics.ObserveConfirmationLatencyMs(time.Since(req.requestedAt).Milliseconds()) + } + return receipt, err +} + +// ensureAnyFireblocksTransactionBroadcasted waits until at least one of the bumped transactions are broadcasted to the +// network. +// this is only needed for the Fireblocks wallet, where some processing is done in their backend before broadcasting to +// the ethereum network. +func (t *GeometricTxManager) ensureAnyFireblocksTransactionBroadcasted(ctx context.Context, txs []*transaction) error { + queryTicker := time.NewTicker(t.params.GetTxReceiptTickerDuration) + defer queryTicker.Stop() + + for { + for _, tx := range txs { + _, err := t.wallet.GetTransactionReceipt(ctx, tx.TxID) + if err == nil || errors.Is(err, wallet.ErrReceiptNotYetAvailable) { + t.metrics.ObserveBroadcastLatencyMs(time.Since(tx.requestedAt).Milliseconds()) + return nil + } + } + + // Wait for the next round. + select { + case <-ctx.Done(): + return ctx.Err() + case <-queryTicker.C: + } + } +} + +// ensureAnyTransactionConfirmed waits until at least one of the transactions is confirmed (mined + confirmationBlocks +// blocks). It returns the receipt of the first transaction that is confirmed (only one tx can ever be mined given they +// all have the same nonce). +func (t *GeometricTxManager) ensureAnyTransactionConfirmed( + ctx context.Context, + txs []*transaction, +) (*types.Receipt, error) { + queryTicker := time.NewTicker(t.params.GetTxReceiptTickerDuration) + defer queryTicker.Stop() + var receipt *types.Receipt + var err error + // transactions that need to be queried. Some transactions will be removed from this map depending on their status. + txnsToQuery := make(map[wallet.TxID]*types.Transaction, len(txs)) + for _, tx := range txs { + txnsToQuery[tx.TxID] = tx.Transaction + } + + for { + for txID, tx := range txnsToQuery { + receipt, err = t.wallet.GetTransactionReceipt(ctx, txID) + if err == nil { + chainTip, err := t.ethClient.BlockNumber(ctx) + if err == nil { + if receipt.BlockNumber.Uint64()+uint64(t.params.ConfirmationBlocks) > chainTip { + t.logger.Debug( + "transaction has been mined but don't have enough confirmations at current chain tip", + "txnBlockNumber", + receipt.BlockNumber.Uint64(), + "numConfirmations", + t.params.ConfirmationBlocks, + "chainTip", + chainTip, + ) + break + } else { + return receipt, nil + } + } else { + t.logger.Debug("failed to get chain tip while waiting for transaction to mine", "err", err) + } + } + + // TODO(samlaf): how to maintain these better? How do we know which errors to use and where they are + // returned from? + if errors.Is(err, ethereum.NotFound) || errors.Is(err, wallet.ErrReceiptNotYetAvailable) { + t.logger.Debug("Transaction not yet mined", "nonce", tx.Nonce(), "txID", txID, "txHash", + tx.Hash().Hex(), "err", err) + } else if errors.Is(err, wallet.ErrTransactionFailed) { + t.logger.Debug("Transaction failed", "txID", txID, "txHash", tx.Hash().Hex(), "err", err) + // Remove the transaction from the list of transactions to query. + // go seemingly allows deleting from a map while iterating over it: + // https://groups.google.com/g/golang-nuts/c/rEmaoxi11_A + // although the official spec and faq don't seem to mention this anywhere... + delete(txnsToQuery, txID) + } else if errors.Is(err, wallet.ErrNotYetBroadcasted) { + t.logger.Error("Transaction has not been broadcasted to network but attempted to retrieve receipt", "err", err) + } else { + t.logger.Debug("Transaction receipt retrieval failed", "err", err) + } + } + + if len(txnsToQuery) == 0 { + return nil, fmt.Errorf("all transactions failed") + } + + // Wait for the next round. + select { + case <-ctx.Done(): + return receipt, ctx.Err() + case <-queryTicker.C: + } + } +} + +// monitorTransaction waits until the transaction is confirmed (or failed) and resends it with a higher gas price if it +// is not mined without a timeout. +// It returns the receipt once the transaction has been confirmed. +// It returns an error if the transaction fails to be sent for reasons other than timeouts. +func (t *GeometricTxManager) monitorTransaction(ctx context.Context, req *txnRequest) (*types.Receipt, error) { + numSpeedUps := 0 + retryFromFailure := 0 + + var receipt *types.Receipt + var err error + + rpcCallAttempt := func() error { + t.logger.Debug("monitoring transaction", "txHash", req.tx.Hash().Hex(), "nonce", req.tx.Nonce()) + + ctxWithTimeout, cancelBroadcastTimeout := context.WithTimeout(ctx, t.params.TxnBroadcastTimeout) + defer cancelBroadcastTimeout() + + if fireblocksWallet, ok := t.wallet.(interface { + CancelTransactionBroadcast(ctx context.Context, txID wallet.TxID) (bool, error) + }); ok { + // Fireblocks wallet is used, there may be delays in broadcasting the transaction due to + // latency from cosigning and MPC operations. We thus make sure that at least one of the + // bumped transactions are broadcasted to the network before querying for its receipt. + err = t.ensureAnyFireblocksTransactionBroadcasted(ctxWithTimeout, req.txAttempts) + if err != nil && errors.Is(err, context.DeadlineExceeded) { + t.logger.Warn( + "transaction not broadcasted within timeout", + "txHash", + req.tx.Hash().Hex(), + "nonce", + req.tx.Nonce(), + ) + // Consider these transactions failed as they haven't been broadcasted within timeout. + // Cancel these transactions to avoid blocking the next transactions. + for _, tx := range req.txAttempts { + cancelled, err := fireblocksWallet.CancelTransactionBroadcast(ctx, tx.TxID) + if err != nil { + t.logger.Warn("failed to cancel Fireblocks transaction broadcast", "txID", tx.TxID, "err", err) + } else if cancelled { + t.logger.Info("cancelled Fireblocks transaction broadcast because it didn't get broadcasted within timeout", "txID", tx.TxID, "timeout", t.params.TxnBroadcastTimeout.String()) + } + } + return fmt.Errorf("transaction %x (with nonce %d) not broadcasted", req.tx.Hash(), req.tx.Nonce()) + } else if err != nil { + t.logger.Error("unexpected error while waiting for Fireblocks transaction to broadcast", "txHash", req.tx.Hash().Hex(), "err", err) + return err + } + } + + ctxWithTimeout, cancelEvaluationTimeout := context.WithTimeout(ctx, t.params.TxnConfirmationTimeout) + defer cancelEvaluationTimeout() + receipt, err = t.ensureAnyTransactionConfirmed( + ctxWithTimeout, + req.txAttempts, + ) + return err + } + + queryTicker := time.NewTicker(t.params.GetTxReceiptTickerDuration) + defer queryTicker.Stop() + for { + err = rpcCallAttempt() + if err == nil { + t.metrics.ObserveSpeedups(numSpeedUps) + t.metrics.IncrementProcessedTxsTotal("success") + return receipt, nil + } + + if errors.Is(err, context.DeadlineExceeded) { + if receipt != nil { + t.logger.Warn( + "transaction has been mined, but hasn't accumulated the required number of confirmations", + "txHash", + req.tx.Hash().Hex(), + "nonce", + req.tx.Nonce(), + ) + continue + } + t.logger.Warn( + "transaction not mined within timeout, resending with higher gas price", + "txHash", + req.tx.Hash().Hex(), + "nonce", + req.tx.Nonce(), + ) + newTx, err := t.speedUpTxn(ctx, req.tx, numSpeedUps) + if err != nil { + t.logger.Error("failed to speed up transaction", "err", err) + t.metrics.IncrementProcessedTxsTotal("failure") + return nil, err + } + txID, err := t.wallet.SendTransaction(ctx, newTx) + if err != nil { + if retryFromFailure >= t.params.MaxSendTransactionRetry { + t.logger.Warn( + "failed to send txn - retries exhausted", + "txn", + req.tx.Hash().Hex(), + "attempt", + retryFromFailure, + "maxRetry", + t.params.MaxSendTransactionRetry, + "err", + err, + ) + t.metrics.IncrementProcessedTxsTotal("failure") + return nil, err + } else { + t.logger.Warn("failed to send txn - retrying", "txn", req.tx.Hash().Hex(), "attempt", retryFromFailure, "maxRetry", t.params.MaxSendTransactionRetry, "err", err) + } + retryFromFailure++ + continue + } + + t.logger.Debug("successfully sent txn", "txID", txID, "txHash", newTx.Hash().Hex()) + req.tx = newTx + req.txAttempts = append(req.txAttempts, &transaction{ + TxID: txID, + Transaction: newTx, + }) + numSpeedUps++ + } else { + t.logger.Error("transaction failed", "txHash", req.tx.Hash().Hex(), "err", err) + t.metrics.IncrementProcessedTxsTotal("failure") + return nil, err + } + + // Wait for the next round. + select { + case <-ctx.Done(): + return receipt, ctx.Err() + case <-queryTicker.C: + } + } +} + +// speedUpTxn increases the gas price of the existing transaction by specified percentage. +// It makes sure the new gas price is not lower than the current gas price. +func (t *GeometricTxManager) speedUpTxn( + ctx context.Context, + tx *types.Transaction, + numSpeedUps int, +) (*types.Transaction, error) { + // 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, utils.WrapError("failed to estimate gas tip cap", err) + } + bumpedGasTipCap := t.addGasTipCapBuffer(tx.GasTipCap()) + if estimatedGasTipCap.Cmp(bumpedGasTipCap) > 0 { + newGasTipCap = estimatedGasTipCap + } else { + newGasTipCap = bumpedGasTipCap + } + } + + from, err := t.wallet.SenderAddress(ctx) + if err != nil { + return nil, utils.WrapError("failed to get sender address", err) + } + newTx, err := t.updateGasTipCap(ctx, tx, newGasTipCap, from) + if err != nil { + return nil, utils.WrapError("failed to update gas price", err) + } + t.logger.Info( + "increasing gas price", + "numSpeedUps", numSpeedUps, + "prevTxHash", tx.Hash().Hex(), "newTxHash", newTx.Hash().Hex(), + "nonce", tx.Nonce(), + "prevGasTipCap", tx.GasTipCap(), "newGasTipCap", newGasTipCap, + "prevGasFeeCap", tx.GasFeeCap(), "newGasFeeCap", newTx.GasFeeCap(), + ) + 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, utils.WrapError("failed to estimate gas fee cap", err) + } + + // 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 +} + +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) + } + return t.addGasTipCapBuffer(gasTipCap), 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.GasTipMultiplier*100))). + 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) + } + return new(big.Int).Add(new(big.Int).Mul(header.BaseFee, big.NewInt(2)), gasTipCap), nil +} + +func (t *GeometricTxManager) addGasBuffer(gasLimit uint64) uint64 { + return uint64(t.params.GasMultiplier * float64(gasLimit)) +} diff --git a/chainio/txmgr/geometric/geometric_example_test.go b/chainio/txmgr/geometric/geometric_example_test.go new file mode 100644 index 00000000..c7f0e75a --- /dev/null +++ b/chainio/txmgr/geometric/geometric_example_test.go @@ -0,0 +1,85 @@ +package geometric + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "os" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/eth" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/wallet" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/Layr-Labs/eigensdk-go/signerv2" + "github.com/Layr-Labs/eigensdk-go/testutils" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + chainid = big.NewInt(31337) +) + +func ExampleGeometricTxManager() { + anvilC, err := testutils.StartAnvilContainer("") + if err != nil { + panic(err) + } + anvilUrl, err := anvilC.Endpoint(context.TODO(), "http") + if err != nil { + panic(err) + } + + ecdsaPrivateKey, err := crypto.HexToECDSA("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + if err != nil { + panic(err) + } + pk := ecdsaPrivateKey.PublicKey + address := crypto.PubkeyToAddress(pk) + + client, txmgr := createTxMgr(anvilUrl, ecdsaPrivateKey) + + tx := createTx(client, address) + _, err = txmgr.Send(context.TODO(), tx) + if err != nil { + panic(err) + } + + // we just add this to make sure the example runs + fmt.Println("Tx sent") + // Output: Tx sent +} + +func createTx(client eth.HttpBackend, address common.Address) *types.Transaction { + zeroAddr := common.HexToAddress("0x0") + nonce, err := client.PendingNonceAt(context.TODO(), address) + if err != nil { + panic(err) + } + return types.NewTx(&types.DynamicFeeTx{ + To: &zeroAddr, + Nonce: nonce, + }) +} + +func createTxMgr(rpcUrl string, ecdsaPrivateKey *ecdsa.PrivateKey) (eth.HttpBackend, *GeometricTxManager) { + logger := logging.NewTextSLogger(os.Stdout, &logging.SLoggerOptions{}) + client, err := ethclient.Dial(rpcUrl) + if err != nil { + panic(err) + } + signerV2, signerAddr, err := signerv2.SignerFromConfig(signerv2.Config{PrivateKey: ecdsaPrivateKey}, chainid) + if err != nil { + panic(err) + } + wallet, err := wallet.NewPrivateKeyWallet(client, signerV2, signerAddr, logger) + if err != nil { + panic(err) + } + reg := prometheus.NewRegistry() + metrics := NewMetrics(reg, "example", logger) + return client, NewGeometricTxnManager(client, wallet, logger, metrics, GeometricTxnManagerParams{}) +} diff --git a/chainio/txmgr/geometric/geometric_test.go b/chainio/txmgr/geometric/geometric_test.go new file mode 100644 index 00000000..250e79fe --- /dev/null +++ b/chainio/txmgr/geometric/geometric_test.go @@ -0,0 +1,417 @@ +package geometric + +import ( + "context" + "fmt" + "math/big" + "sync" + "testing" + "time" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/wallet" + "github.com/Layr-Labs/eigensdk-go/crypto/ecdsa" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/Layr-Labs/eigensdk-go/signerv2" + "github.com/Layr-Labs/eigensdk-go/testutils" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" +) + +var chainId = big.NewInt(31337) + +type testHarness struct { + fakeEthBackend *fakeEthBackend + txmgr *GeometricTxManager +} + +func (h *testHarness) validateTxReceipt(t *testing.T, txReceipt *types.Receipt) { + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + receiptFromEthBackend, err := h.fakeEthBackend.TransactionReceipt(ctxWithTimeout, txReceipt.TxHash) + require.NoError(t, err) + require.Equal(t, txReceipt, receiptFromEthBackend) +} + +func newTestHarness(t *testing.T, geometricTxnManagerParams *GeometricTxnManagerParams) *testHarness { + logger := testutils.NewTestLogger() + ethBackend := NewFakeEthBackend() + + ecdsaSk, ecdsaAddr, err := testutils.NewEcdsaSkAndAddress() + require.NoError(t, err) + + signerFn, _, err := signerv2.SignerFromConfig(signerv2.Config{PrivateKey: ecdsaSk}, chainId) + require.NoError(t, err) + + skWallet, err := wallet.NewPrivateKeyWallet(ethBackend, signerFn, ecdsaAddr, logger) + require.NoError(t, err) + + if geometricTxnManagerParams == nil { + geometricTxnManagerParams = &GeometricTxnManagerParams{ + GetTxReceiptTickerDuration: 100 * time.Millisecond, + // set to 100 so that no buffer is added to the gasTipCap + // this way we can test that the txmgr will bump the gasTipCap to a working value + // and also simulate a congested network (with fakeEthBackend.congestedBlocks) where txs won't be mined + GasTipMultiplier: 100, + // set to 1 second (instead of default 2min) so that we can test that the txmgr will bump the gasTipCap to a + // working value + TxnBroadcastTimeout: 1 * time.Second, + } + } + txmgr := NewGeometricTxnManager(ethBackend, skWallet, logger, NewNoopMetrics(), *geometricTxnManagerParams) + + return &testHarness{ + fakeEthBackend: ethBackend, + txmgr: txmgr, + } +} + +func TestGeometricTxManager(t *testing.T) { + t.Run("Send 1 tx", func(t *testing.T) { + h := newTestHarness(t, nil) + + unsignedTx := newUnsignedEthTransferTx(0, nil) + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + txReceipt, err := h.txmgr.Send(ctxWithTimeout, unsignedTx) + require.NoError(t, err) + + h.validateTxReceipt(t, txReceipt) + }) + + t.Run("Send 1 tx to congested network", func(t *testing.T) { + h := newTestHarness(t, nil) + h.fakeEthBackend.setCongestedBlocks(3) + h.txmgr.params.TxnConfirmationTimeout = 1 * time.Second + + unsignedTx := newUnsignedEthTransferTx(0, nil) + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + txReceipt, err := h.txmgr.Send(ctxWithTimeout, unsignedTx) + require.NoError(t, err) + + h.validateTxReceipt(t, txReceipt) + }) + + t.Run("gasFeeCap gets overwritten when sending tx", func(t *testing.T) { + h := newTestHarness(t, nil) + + unsignedTx := newUnsignedEthTransferTx(0, big.NewInt(1)) + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + txReceipt, err := h.txmgr.Send(ctxWithTimeout, unsignedTx) + // ethBackend returns an error if the tx's gasFeeCap is less than the baseFeePerGas + // this test makes sure that even setting a gasFeeCap less than the baseFeePerGas in the tx (1 above) still + // works, + // because the txmgr will overwrite it and set the gasFeeCap to a working value + require.NoError(t, err) + + h.validateTxReceipt(t, txReceipt) + }) + + t.Run("Send n=3 txs in parallel", func(t *testing.T) { + n := 3 + h := newTestHarness(t, nil) + + g := new(errgroup.Group) + + txs := make([]*types.Transaction, n) + txReceipts := make([]*types.Receipt, n) + + for nonce := 0; nonce < n; nonce++ { + tx := newUnsignedEthTransferTx(uint64(nonce), nil) + txs[nonce] = tx + nonce := nonce + g.Go(func() error { + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + txReceipt, err := h.txmgr.Send(ctxWithTimeout, tx) + if err == nil { + txReceipts[nonce] = txReceipt + } + return err + }) + } + + err := g.Wait() + require.NoError(t, err) + + }) + + t.Run("Send n=3 txs in parallel, with nonces in reverse order", func(t *testing.T) { + n := 3 + h := newTestHarness(t, nil) + + g := new(errgroup.Group) + + txs := make([]*types.Transaction, n) + txReceipts := make([]*types.Receipt, n) + + for nonce := n - 1; nonce >= 0; nonce-- { + tx := newUnsignedEthTransferTx(uint64(nonce), nil) + txs[nonce] = tx + nonce := nonce + g.Go(func() error { + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + txReceipt, err := h.txmgr.Send(ctxWithTimeout, tx) + if err == nil { + txReceipts[nonce] = txReceipt + } + return err + }) + time.Sleep(1 * time.Second) + } + + err := g.Wait() + require.NoError(t, err) + + }) + + t.Run("Send n=3 txs sequentially", func(t *testing.T) { + n := uint64(3) + h := newTestHarness(t, nil) + + for nonce := uint64(0); nonce < n; nonce++ { + tx := newUnsignedEthTransferTx(nonce, nil) + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := h.txmgr.Send(ctxWithTimeout, tx) + require.NoError(t, err) + } + + }) + + t.Run("Send tx with incorrect nonce should result in context.DeadlineExceeded error", func(t *testing.T) { + h := newTestHarness(t, nil) + + tx := newUnsignedEthTransferTx(uint64(100), nil) + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, err := h.txmgr.Send(ctxWithTimeout, tx) + require.Equal(t, context.DeadlineExceeded, err) + + }) + + t.Run("Send 2 txs with same nonce", func(t *testing.T) { + h := newTestHarness(t, nil) + + unsignedTx := newUnsignedEthTransferTx(0, nil) + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := h.txmgr.Send(ctxWithTimeout, unsignedTx) + require.NoError(t, err) + + unsignedTx = newUnsignedEthTransferTx(0, nil) + _, err = h.txmgr.Send(ctxWithTimeout, unsignedTx) + require.Error(t, err) + }) +} + +func newUnsignedEthTransferTx(nonce uint64, gasFeeCap *big.Int) *types.Transaction { + if gasFeeCap == nil { + // 1 gwei is anvil's default starting baseFeePerGas on its genesis block + gasFeeCap = big.NewInt(1_000_000_000) + } + return types.NewTx(&types.DynamicFeeTx{ + ChainID: chainId, + Nonce: nonce, + GasTipCap: big.NewInt(1), + GasFeeCap: gasFeeCap, + Gas: 21000, + To: testutils.ZeroAddress(), + Value: big.NewInt(1), + }) +} + +type fakeEthBackend struct { + // congestedBlocks can be set to a positive number to start a congestion period. + // while congestedBlocks > 0, gasTipCap will increase everytime it is read, simulating a congested network + // this way whenever the txmgr reads the gasTipCap to bump a tx and resend it, the gasTipCap will increase + // so as to prevent the tx from being mined + // everytime a block is mined, congestedBlocks will decrease by 1 + congestedBlocks uint64 + gasTipCap *big.Int + baseFeePerGas *big.Int + // mu protects all the below fields which are updated in "mining" goroutines (see Send) + mu sync.Mutex + blockNumber uint64 + // we use a single nonce for now because the txmgr only supports a single sender + nonce uint64 + mempool map[uint64]*types.Transaction // nonce -> tx + minedTxs map[common.Hash]*types.Receipt + logger logging.Logger +} + +func NewFakeEthBackend() *fakeEthBackend { + logger := testutils.NewTestLogger().With("component", "fakeEthBackend") + backend := &fakeEthBackend{ + congestedBlocks: 0, + gasTipCap: big.NewInt(1), // 1 wei, same default as anvil + baseFeePerGas: big.NewInt(1_000_000_000), // 1 gwei, same default as anvil + mu: sync.Mutex{}, + blockNumber: 0, + nonce: 0, + mempool: make(map[uint64]*types.Transaction), + minedTxs: make(map[common.Hash]*types.Receipt), + logger: logger, + } + backend.startMining() + return backend +} + +func (s *fakeEthBackend) WithBaseFeePerGas(baseFeePerGas *big.Int) *fakeEthBackend { + s.baseFeePerGas = baseFeePerGas + return s +} + +func (s *fakeEthBackend) startMining() { + go func() { + for { + s.mu.Lock() + // if there's a tx in the mempool with the current nonce and its gasTipCap is >= baseFeePerGas, mine it + if tx, ok := s.mempool[s.nonce]; ok { + if tx.GasTipCapIntCmp(s.gasTipCap) >= 0 { + delete(s.mempool, s.nonce) + s.minedTxs[tx.Hash()] = &types.Receipt{ + BlockNumber: big.NewInt(int64(s.blockNumber)), + TxHash: tx.Hash(), + } + s.blockNumber++ + s.nonce++ + s.logger.Debug("mined tx", "txHash", tx.Hash(), "nonce", tx.Nonce()) + } else { + s.logger.Info("tx.gasTipCap < fakeEthBackend.gasTipCap, not mining", "txHash", tx.Hash(), "nonce", tx.Nonce(), "tx.gasTipCap", tx.GasTipCap(), "fakeEthBackend.gasTipCap", s.gasTipCap) + } + } + if s.congestedBlocks > 0 { + s.congestedBlocks-- + } + s.mu.Unlock() + // mine a block every 100 ms + time.Sleep(100 * time.Millisecond) + } + }() +} + +func (s *fakeEthBackend) setCongestedBlocks(n uint64) { + s.mu.Lock() + defer s.mu.Unlock() + s.congestedBlocks = n + +} + +func (s *fakeEthBackend) BlockNumber(context.Context) (uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.blockNumber, nil +} + +func (s *fakeEthBackend) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { + s.mu.Lock() + defer s.mu.Unlock() + gasTipCap := big.NewInt(0).Set(s.gasTipCap) + if s.congestedBlocks > 0 { + s.gasTipCap = s.gasTipCap.Add(s.gasTipCap, big.NewInt(1)) + } + return gasTipCap, nil +} + +func (s *fakeEthBackend) EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) { + return 0, nil + +} + +func (s *fakeEthBackend) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { + return &types.Header{ + BaseFee: big.NewInt(0).Set(s.baseFeePerGas), + }, nil +} + +func (s *fakeEthBackend) SendTransaction(ctx context.Context, tx *types.Transaction) error { + if tx.GasFeeCapIntCmp(s.baseFeePerGas) < 0 { + return fmt.Errorf("tx.gasFeeCap (%d) < baseFeeCap (%d)", tx.GasFeeCap(), s.baseFeePerGas) + } + s.mu.Lock() + defer s.mu.Unlock() + if tx.Nonce() < s.nonce { + return fmt.Errorf("tx.nonce (%d) < current nonce (%d)", tx.Nonce(), s.nonce) + } + s.mempool[tx.Nonce()] = tx + return nil +} + +func (s *fakeEthBackend) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if receipt, ok := s.minedTxs[txHash]; ok { + return receipt, nil + } + return nil, ethereum.NotFound +} + +// ========================== INTEGRATION TESTS ========================== + +type integrationEthBackend interface { + wallet.EthBackend + ethBackend +} +type integrationTestHarness struct { + ethBackend integrationEthBackend + txmgr *GeometricTxManager +} + +func newIntegrationTestHarness(t *testing.T) *integrationTestHarness { + logger := testutils.NewTestLogger() + anvilC, err := testutils.StartAnvilContainer("") + require.NoError(t, err) + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + anvilHttpEndpoint, err := anvilC.Endpoint(ctxWithTimeout, "http") + require.NoError(t, err) + anvilHttpClient, err := ethclient.Dial(anvilHttpEndpoint) + require.NoError(t, err) + + ecdsaSk, ecdsaAddr, err := ecdsa.KeyAndAddressFromHexKey( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ) + require.NoError(t, err) + + signerFn, _, err := signerv2.SignerFromConfig(signerv2.Config{PrivateKey: ecdsaSk}, chainId) + require.NoError(t, err) + + skWallet, err := wallet.NewPrivateKeyWallet(anvilHttpClient, signerFn, ecdsaAddr, logger) + require.NoError(t, err) + + txmgr := NewGeometricTxnManager(anvilHttpClient, skWallet, logger, NewNoopMetrics(), GeometricTxnManagerParams{}) + return &integrationTestHarness{ + ethBackend: anvilHttpClient, + txmgr: txmgr, + } +} + +func (h *integrationTestHarness) validateTxReceipt(t *testing.T, txReceipt *types.Receipt) { + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + receiptFromEthBackend, err := h.ethBackend.TransactionReceipt(ctxWithTimeout, txReceipt.TxHash) + require.NoError(t, err) + require.Equal(t, txReceipt, receiptFromEthBackend) +} + +func TestGeometricTxManagerIntegration(t *testing.T) { + t.Run("Send 1 tx", func(t *testing.T) { + h := newIntegrationTestHarness(t) + + unsignedTx := newUnsignedEthTransferTx(0, nil) + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + txReceipt, err := h.txmgr.Send(ctxWithTimeout, unsignedTx) + require.NoError(t, err) + + h.validateTxReceipt(t, txReceipt) + }) +} diff --git a/chainio/txmgr/geometric/metrics.go b/chainio/txmgr/geometric/metrics.go new file mode 100644 index 00000000..00e21264 --- /dev/null +++ b/chainio/txmgr/geometric/metrics.go @@ -0,0 +1,142 @@ +package geometric + +import ( + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +type Metrics interface { + ObserveBroadcastLatencyMs(latencyMs int64) + ObserveConfirmationLatencyMs(latencyMs int64) + ObserveGasUsedWei(gasUsedWei uint64) + ObserveSpeedups(speedUps int) + IncrementProcessingTxCount() + DecrementProcessingTxCount() + IncrementProcessedTxsTotal(state string) +} + +const namespace = "txmgr" + +type PromMetrics struct { + broadcastLatencyMs prometheus.Summary + confirmationLatencyMs prometheus.Summary + gasUsedGwei prometheus.Summary + speedUps prometheus.Histogram + processingTxCount prometheus.Gauge + processedTxsTotal *prometheus.CounterVec +} + +var _ Metrics = (*PromMetrics)(nil) + +func NewMetrics(reg prometheus.Registerer, subsystem string, logger logging.Logger) *PromMetrics { + + return &PromMetrics{ + // TODO: we only observe latency of txs that were successfully broadcasted or confirmed. + // We should also observe latency of txs that failed to broadcast or confirm. + broadcastLatencyMs: promauto.With(reg).NewSummary( + prometheus.SummaryOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "broadcast_latency_ms", + Help: "transaction confirmation latency summary in milliseconds", + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.95: 0.01, 0.99: 0.001}, + }, + ), + confirmationLatencyMs: promauto.With(reg).NewSummary( + prometheus.SummaryOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "confirmation_latency_ms", + Help: "total transaction confirmation latency summary in milliseconds", + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.95: 0.01, 0.99: 0.001}, + }, + ), + gasUsedGwei: promauto.With(reg).NewSummary( + prometheus.SummaryOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "gas_used_total", + Help: "total gas used to submit each transaction onchain", + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.95: 0.01, 0.99: 0.001}, + }, + ), + speedUps: promauto.With(reg).NewHistogram( + prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "speedups_total", + Help: "number of times a transaction's gas price was increased", + Buckets: prometheus.LinearBuckets(0, 1, 10), + }, + ), + processingTxCount: promauto.With(reg).NewGauge( + prometheus.GaugeOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "processing_tx_count", + Help: "number of transactions currently being processed", + }, + ), + processedTxsTotal: promauto.With(reg).NewCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "processed_txs_total", + Help: "number of transactions processed by state (success, error)", + }, + []string{"state"}, + ), + } +} + +func (m *PromMetrics) ObserveBroadcastLatencyMs(latencyMs int64) { + m.broadcastLatencyMs.Observe(float64(latencyMs)) +} + +func (m *PromMetrics) ObserveConfirmationLatencyMs(latencyMs int64) { + m.confirmationLatencyMs.Observe(float64(latencyMs)) +} + +func (m *PromMetrics) ObserveGasUsedWei(gasUsedWei uint64) { + gasUsedGwei := gasUsedWei / 1e9 + m.gasUsedGwei.Observe(float64(gasUsedGwei)) +} + +func (m *PromMetrics) ObserveSpeedups(speedUps int) { + m.speedUps.Observe(float64(speedUps)) +} + +func (m *PromMetrics) IncrementProcessingTxCount() { + m.processingTxCount.Inc() +} + +func (m *PromMetrics) DecrementProcessingTxCount() { + m.processingTxCount.Dec() +} + +func (m *PromMetrics) IncrementProcessedTxsTotal(state string) { + m.processedTxsTotal.WithLabelValues(state).Inc() +} + +type NoopMetrics struct{} + +var _ Metrics = (*NoopMetrics)(nil) + +func NewNoopMetrics() *NoopMetrics { + return &NoopMetrics{} +} + +func (t *NoopMetrics) ObserveBroadcastLatencyMs(latencyMs int64) {} + +func (t *NoopMetrics) ObserveConfirmationLatencyMs(latencyMs int64) {} + +func (t *NoopMetrics) ObserveGasUsedWei(gasUsedWei uint64) {} + +func (t *NoopMetrics) ObserveSpeedups(speedUps int) {} + +func (t *NoopMetrics) IncrementProcessingTxCount() {} + +func (t *NoopMetrics) DecrementProcessingTxCount() {} + +func (t *NoopMetrics) IncrementProcessedTxsTotal(state string) {} diff --git a/chainio/txmgr/simple-tx-manager-flow.png b/chainio/txmgr/simple-tx-manager-flow.png index 4b07b30b..ac0b5f06 100644 Binary files a/chainio/txmgr/simple-tx-manager-flow.png and b/chainio/txmgr/simple-tx-manager-flow.png differ diff --git a/chainio/txmgr/simple.go b/chainio/txmgr/simple.go new file mode 100644 index 00000000..ff4479b8 --- /dev/null +++ b/chainio/txmgr/simple.go @@ -0,0 +1,201 @@ +package txmgr + +import ( + "context" + "errors" + "math/big" + "time" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/wallet" + "github.com/Layr-Labs/eigensdk-go/logging" + "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" + "github.com/ethereum/go-ethereum/log" +) + +var ( + // FallbackGasTipCap 5 gwei in case the backend does not support eth_maxPriorityFeePerGas (no idea if/when this ever + // happens..) + FallbackGasTipCap = big.NewInt(5_000_000_000) + // FallbackGasLimitMultiplier 1.20x gas limit multiplier. This is arbitrary but should be safe for most cases + FallbackGasLimitMultiplier = 1.20 +) + +type ethBackend interface { + SuggestGasTipCap(ctx context.Context) (*big.Int, error) + HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) + EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) +} + +type SimpleTxManager struct { + wallet wallet.Wallet + client ethBackend + logger logging.Logger + sender common.Address + gasLimitMultiplier float64 +} + +var _ TxManager = (*SimpleTxManager)(nil) + +// NewSimpleTxManager creates a new simpleTxManager which can be used +// to send a transaction to smart contracts on the Ethereum node +func NewSimpleTxManager( + wallet wallet.Wallet, + client ethBackend, + logger logging.Logger, + sender common.Address, +) *SimpleTxManager { + return &SimpleTxManager{ + wallet: wallet, + client: client, + logger: logger, + sender: sender, + gasLimitMultiplier: FallbackGasLimitMultiplier, + } +} + +func (m *SimpleTxManager) WithGasLimitMultiplier(multiplier float64) *SimpleTxManager { + m.gasLimitMultiplier = multiplier + return m +} + +// Send is used to send a transaction to the Ethereum node. It takes an unsigned/signed transaction +// and then sends it to the Ethereum node. +// It also takes care of gas estimation and adds a buffer to the gas limit +// If you pass in a signed transaction it will ignore the signature +// and resign the transaction after adding the nonce and gas limit. +// To check out the whole flow on how this works, check out the README.md in this folder +func (m *SimpleTxManager) Send(ctx context.Context, tx *types.Transaction) (*types.Receipt, error) { + // Estimate gas and nonce + // can't print tx hash in logs because the tx changes below when we complete and sign it + // so the txHash is meaningless at this point + m.logger.Debug("Estimating gas and nonce") + tx, err := m.estimateGasAndNonce(ctx, tx) + if err != nil { + return nil, err + } + bumpedGasTx := &types.DynamicFeeTx{ + To: tx.To(), + Nonce: tx.Nonce(), + GasFeeCap: tx.GasFeeCap(), + GasTipCap: tx.GasTipCap(), + Gas: uint64(float64(tx.Gas()) * m.gasLimitMultiplier), + Value: tx.Value(), + Data: tx.Data(), + } + txID, err := m.wallet.SendTransaction(ctx, types.NewTx(bumpedGasTx)) + if err != nil { + return nil, errors.Join(errors.New("send: failed to estimate gas and nonce"), err) + } + + receipt, err := m.waitForReceipt(ctx, txID) + if err != nil { + log.Info("Transaction receipt not found", "err", err) + return nil, err + } + + return receipt, nil +} + +func NoopSigner(addr common.Address, tx *types.Transaction) (*types.Transaction, error) { + return tx, nil +} + +// GetNoSendTxOpts This generates a noSend TransactOpts so that we can use +// this to generate the transaction without actually sending it +func (m *SimpleTxManager) GetNoSendTxOpts() (*bind.TransactOpts, error) { + return &bind.TransactOpts{ + From: m.sender, + NoSend: true, + Signer: NoopSigner, + }, nil +} + +func (m *SimpleTxManager) waitForReceipt(ctx context.Context, txID wallet.TxID) (*types.Receipt, error) { + // TODO: make this ticker adjustable + queryTicker := time.NewTicker(2 * time.Second) + defer queryTicker.Stop() + for { + select { + case <-ctx.Done(): + return nil, errors.Join(errors.New("Context done before tx was mined"), ctx.Err()) + case <-queryTicker.C: + if receipt := m.queryReceipt(ctx, txID); receipt != nil { + return receipt, nil + } + } + } +} + +func (m *SimpleTxManager) queryReceipt(ctx context.Context, txID wallet.TxID) *types.Receipt { + receipt, err := m.wallet.GetTransactionReceipt(ctx, txID) + if errors.Is(err, ethereum.NotFound) { + m.logger.Info("Transaction not yet mined", "txID", txID) + return nil + } else if err != nil { + m.logger.Info("Receipt retrieval failed", "txID", txID, "err", err) + return nil + } else if receipt == nil { + m.logger.Warn("Receipt and error are both nil", "txID", txID) + return nil + } + + return receipt +} + +// estimateGasAndNonce we are explicitly implementing this because +// * We want to support legacy transactions (i.e. not dynamic fee) +// * We want to support gas management, i.e. add buffer to gas limit +func (m *SimpleTxManager) estimateGasAndNonce(ctx context.Context, tx *types.Transaction) (*types.Transaction, error) { + gasTipCap, err := m.client.SuggestGasTipCap(ctx) + if err != nil { + // If the transaction failed because the backend does not support + // eth_maxPriorityFeePerGas, fallback to using the default constant. + m.logger.Info("eth_maxPriorityFeePerGas is unsupported by current backend, using fallback gasTipCap") + gasTipCap = FallbackGasTipCap + } + + header, err := m.client.HeaderByNumber(ctx, nil) + if err != nil { + return nil, err + } + + // 2*baseFee + gasTipCap makes sure that the tx remains includeable for 6 consecutive 100% full blocks. + // see https://www.blocknative.com/blog/eip-1559-fees + gasFeeCap := new(big.Int).Add(header.BaseFee.Mul(header.BaseFee, big.NewInt(2)), gasTipCap) + + gasLimit := tx.Gas() + // we only estimate if gasLimit is not already set + if gasLimit == 0 { + from, err := m.wallet.SenderAddress(ctx) + if err != nil { + return nil, errors.Join(errors.New("send: failed to get sender address"), err) + } + gasLimit, err = m.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, errors.Join(errors.New("send: failed to estimate gas"), err) + } + } + + rawTx := &types.DynamicFeeTx{ + ChainID: tx.ChainId(), + To: tx.To(), + GasTipCap: gasTipCap, + GasFeeCap: gasFeeCap, + Data: tx.Data(), + Value: tx.Value(), + Gas: gasLimit, + Nonce: tx.Nonce(), // We are not doing any nonce management for now but we probably should later for more robustness + } + + return types.NewTx(rawTx), nil +} diff --git a/chainio/txmgr/txmgr.go b/chainio/txmgr/txmgr.go index d237840d..da5f369d 100644 --- a/chainio/txmgr/txmgr.go +++ b/chainio/txmgr/txmgr.go @@ -2,214 +2,22 @@ package txmgr import ( "context" - "errors" - "math/big" - "time" - "github.com/Layr-Labs/eigensdk-go/chainio/clients/wallet" - "github.com/Layr-Labs/eigensdk-go/logging" - "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" - "github.com/ethereum/go-ethereum/log" ) -var ( - // FallbackGasTipCap 5 gwei in case the backend does not support eth_maxPriorityFeePerGas (no idea if/when this ever - // happens..) - FallbackGasTipCap = big.NewInt(5_000_000_000) - // FallbackGasLimitMultiplier 1.20x gas limit multiplier. This is arbitrary but should be safe for most cases - FallbackGasLimitMultiplier = 1.20 -) - -// We are taking inspiration from the optimism TxManager interface -// https://github.com/ethereum-optimism/optimism/blob/develop/op-service/txmgr/txmgr.go - type TxManager interface { - // Send is used to send a transaction + // Send is used to sign and send a transaction to an evm chain // It takes an unsigned transaction and then signs it before sending - // It also takes care of nonce management and gas estimation + // It might also take care of nonce management and gas estimation, depending on the implementation Send(ctx context.Context, tx *types.Transaction) (*types.Receipt, error) - // GetNoSendTxOpts This generates a noSend TransactOpts so that we can use + // GetNoSendTxOpts generates a TransactOpts with + // - NoSend=true: b/c we want to manage the sending ourselves + // - Signer=NoopSigner: b/c we want the wallet to manage signing + // - From=sender: unfortunately needed as first parameter to + // This is needed when using abigen to construct transactions. // this to generate the transaction without actually sending it GetNoSendTxOpts() (*bind.TransactOpts, error) } - -type ethClient interface { - SuggestGasTipCap(ctx context.Context) (*big.Int, error) - HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) - EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) -} - -type SimpleTxManager struct { - wallet wallet.Wallet - client ethClient - log logging.Logger - sender common.Address - gasLimitMultiplier float64 -} - -var _ TxManager = (*SimpleTxManager)(nil) - -// NewSimpleTxManager creates a new simpleTxManager which can be used -// to send a transaction to smart contracts on the Ethereum node -func NewSimpleTxManager( - wallet wallet.Wallet, - client ethClient, - log logging.Logger, - sender common.Address, -) *SimpleTxManager { - return &SimpleTxManager{ - wallet: wallet, - client: client, - log: log, - sender: sender, - gasLimitMultiplier: FallbackGasLimitMultiplier, - } -} - -func (m *SimpleTxManager) WithGasLimitMultiplier(multiplier float64) *SimpleTxManager { - m.gasLimitMultiplier = multiplier - return m -} - -// Send is used to send a transaction to the Ethereum node. It takes an unsigned/signed transaction -// and then sends it to the Ethereum node. -// It also takes care of gas estimation and adds a buffer to the gas limit -// If you pass in a signed transaction it will ignore the signature -// and resign the transaction after adding the nonce and gas limit. -// To check out the whole flow on how this works, check out the README.md in this folder -func (m *SimpleTxManager) Send(ctx context.Context, tx *types.Transaction) (*types.Receipt, error) { - // Estimate gas and nonce - // can't print tx hash in logs because the tx changes below when we complete and sign it - // so the txHash is meaningless at this point - m.log.Debug("Estimating gas and nonce") - tx, err := m.estimateGasAndNonce(ctx, tx) - if err != nil { - return nil, err - } - bumpedGasTx := &types.DynamicFeeTx{ - To: tx.To(), - Nonce: tx.Nonce(), - GasFeeCap: tx.GasFeeCap(), - GasTipCap: tx.GasTipCap(), - Gas: uint64(float64(tx.Gas()) * m.gasLimitMultiplier), - Value: tx.Value(), - Data: tx.Data(), - } - txID, err := m.wallet.SendTransaction(ctx, types.NewTx(bumpedGasTx)) - if err != nil { - return nil, errors.Join(errors.New("send: failed to estimate gas and nonce"), err) - } - - receipt, err := m.waitForReceipt(ctx, txID) - if err != nil { - log.Info("Transaction receipt not found", "err", err) - return nil, err - } - - return receipt, nil -} - -func NoopSigner(addr common.Address, tx *types.Transaction) (*types.Transaction, error) { - return tx, nil -} - -// GetNoSendTxOpts This generates a noSend TransactOpts so that we can use -// this to generate the transaction without actually sending it -func (m *SimpleTxManager) GetNoSendTxOpts() (*bind.TransactOpts, error) { - return &bind.TransactOpts{ - From: m.sender, - NoSend: true, - Signer: NoopSigner, - }, nil -} - -func (m *SimpleTxManager) waitForReceipt(ctx context.Context, txID wallet.TxID) (*types.Receipt, error) { - // TODO: make this ticker adjustable - queryTicker := time.NewTicker(2 * time.Second) - defer queryTicker.Stop() - for { - select { - case <-ctx.Done(): - return nil, errors.Join(errors.New("Context done before tx was mined"), ctx.Err()) - case <-queryTicker.C: - if receipt := m.queryReceipt(ctx, txID); receipt != nil { - return receipt, nil - } - } - } -} - -func (m *SimpleTxManager) queryReceipt(ctx context.Context, txID wallet.TxID) *types.Receipt { - receipt, err := m.wallet.GetTransactionReceipt(ctx, txID) - if errors.Is(err, ethereum.NotFound) { - m.log.Info("Transaction not yet mined", "txID", txID) - return nil - } else if err != nil { - m.log.Info("Receipt retrieval failed", "txID", txID, "err", err) - return nil - } else if receipt == nil { - m.log.Warn("Receipt and error are both nil", "txID", txID) - return nil - } - - return receipt -} - -// estimateGasAndNonce we are explicitly implementing this because -// * We want to support legacy transactions (i.e. not dynamic fee) -// * We want to support gas management, i.e. add buffer to gas limit -func (m *SimpleTxManager) estimateGasAndNonce(ctx context.Context, tx *types.Transaction) (*types.Transaction, error) { - gasTipCap, err := m.client.SuggestGasTipCap(ctx) - if err != nil { - // If the transaction failed because the backend does not support - // eth_maxPriorityFeePerGas, fallback to using the default constant. - m.log.Info("eth_maxPriorityFeePerGas is unsupported by current backend, using fallback gasTipCap") - gasTipCap = FallbackGasTipCap - } - - header, err := m.client.HeaderByNumber(ctx, nil) - if err != nil { - return nil, err - } - - // 2*baseFee + gasTipCap makes sure that the tx remains includeable for 6 consecutive 100% full blocks. - // see https://www.blocknative.com/blog/eip-1559-fees - gasFeeCap := new(big.Int).Add(header.BaseFee.Mul(header.BaseFee, big.NewInt(2)), gasTipCap) - - gasLimit := tx.Gas() - // we only estimate if gasLimit is not already set - if gasLimit == 0 { - from, err := m.wallet.SenderAddress(ctx) - if err != nil { - return nil, errors.Join(errors.New("send: failed to get sender address"), err) - } - gasLimit, err = m.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, errors.Join(errors.New("send: failed to estimate gas"), err) - } - } - - rawTx := &types.DynamicFeeTx{ - ChainID: tx.ChainId(), - To: tx.To(), - GasTipCap: gasTipCap, - GasFeeCap: gasFeeCap, - Data: tx.Data(), - Value: tx.Value(), - Gas: gasLimit, - Nonce: tx.Nonce(), // We are not doing any nonce management for now but we probably should later for more robustness - } - - return types.NewTx(rawTx), nil -} diff --git a/crypto/ecdsa/utils.go b/crypto/ecdsa/utils.go index c769d628..e7ee72d9 100644 --- a/crypto/ecdsa/utils.go +++ b/crypto/ecdsa/utils.go @@ -7,7 +7,9 @@ import ( "fmt" "os" "path/filepath" + "strings" + "github.com/ethereum/go-ethereum/common" gethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/accounts/keystore" @@ -113,3 +115,14 @@ func GetAddressFromKeyStoreFile(keyStoreFile string) (gethcommon.Address, error) return gethcommon.HexToAddress(address), nil } } + +func KeyAndAddressFromHexKey(hexkey string) (*ecdsa.PrivateKey, common.Address, error) { + hexkey = strings.TrimPrefix(hexkey, "0x") + ecdsaSk, err := crypto.HexToECDSA(hexkey) + if err != nil { + return nil, common.Address{}, fmt.Errorf("failed to convert hexkey %s to ecdsa key: %w", hexkey, err) + } + pk := ecdsaSk.Public() + address := crypto.PubkeyToAddress(*pk.(*ecdsa.PublicKey)) + return ecdsaSk, address, nil +} diff --git a/signer/README.md b/signer/README.md index a61c1a95..a7057dab 100644 --- a/signer/README.md +++ b/signer/README.md @@ -1,4 +1,8 @@ ## Signer + +> [!WARNING] +> signer is deprecated. Please use [signerv2](../signerv2/README.md) instead. + This module is used for initializing the signer used to sign smart contract transactions. There are two types of signer we support diff --git a/signerv2/README.md b/signerv2/README.md new file mode 100644 index 00000000..b56ed0be --- /dev/null +++ b/signerv2/README.md @@ -0,0 +1,3 @@ +# Signerv2 + +TODO \ No newline at end of file diff --git a/testutils/anvil.go b/testutils/anvil.go index ebaf0a9c..3cec5400 100644 --- a/testutils/anvil.go +++ b/testutils/anvil.go @@ -51,7 +51,9 @@ func StartAnvilContainer(anvilStateFileName string) (testcontainers.Container, e // Still need to advance the chain by at least 1 block b/c some tests need to query the latest block, // and the blocks dumped/loaded by anvil don't contain full transactions, which leads to panics in tests. // See https://github.com/foundry-rs/foundry/issues/8213, which will hopefully get fixed soon. - AdvanceChainByNBlocksExecInContainer(ctx, 1, anvilC) + if anvilStateFileName != "" { + AdvanceChainByNBlocksExecInContainer(ctx, 1, anvilC) + } return anvilC, nil } diff --git a/testutils/crypto.go b/testutils/crypto.go new file mode 100644 index 00000000..5dffb9ce --- /dev/null +++ b/testutils/crypto.go @@ -0,0 +1,22 @@ +package testutils + +import ( + "crypto/ecdsa" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +func NewEcdsaSkAndAddress() (*ecdsa.PrivateKey, common.Address, error) { + ecdsaSk, err := crypto.GenerateKey() + if err != nil { + return nil, common.Address{}, err + } + pk := ecdsaSk.Public() + address := crypto.PubkeyToAddress(*pk.(*ecdsa.PublicKey)) + return ecdsaSk, address, nil +} + +func ZeroAddress() *common.Address { + return &common.Address{} +} diff --git a/testutils/logging.go b/testutils/logging.go new file mode 100644 index 00000000..91ee832f --- /dev/null +++ b/testutils/logging.go @@ -0,0 +1,18 @@ +package testutils + +import ( + "log/slog" + "os" + + "github.com/Layr-Labs/eigensdk-go/logging" +) + +// NewTestLogger is just a utility function to create a logger for testing +// It returns an slog textHandler logger that outputs to os.Stdout, with source code information and debug level +func NewTestLogger() logging.Logger { + // we don't use colors because the test output panel in vscode doesn't support them + return logging.NewTextSLogger( + os.Stdout, + &logging.SLoggerOptions{AddSource: true, Level: slog.LevelDebug, NoColor: true}, + ) +}