diff --git a/chainio/clients/avsregistry/reader.go b/chainio/clients/avsregistry/reader.go index 979eafd9..93633721 100644 --- a/chainio/clients/avsregistry/reader.go +++ b/chainio/clients/avsregistry/reader.go @@ -86,7 +86,7 @@ type AvsRegistryChainReader struct { registryCoordinator *regcoord.ContractRegistryCoordinator operatorStateRetriever *opstateretriever.ContractOperatorStateRetriever stakeRegistry *stakeregistry.ContractStakeRegistry - ethClient eth.EthClient + ethClient eth.Client } // forces AvsReader to implement the clients.ReaderInterface interface @@ -99,7 +99,7 @@ func NewAvsRegistryChainReader( operatorStateRetriever *opstateretriever.ContractOperatorStateRetriever, stakeRegistry *stakeregistry.ContractStakeRegistry, logger logging.Logger, - ethClient eth.EthClient, + ethClient eth.Client, ) *AvsRegistryChainReader { return &AvsRegistryChainReader{ blsApkRegistryAddr: blsApkRegistryAddr, @@ -115,7 +115,7 @@ func NewAvsRegistryChainReader( func BuildAvsRegistryChainReader( registryCoordinatorAddr gethcommon.Address, operatorStateRetrieverAddr gethcommon.Address, - ethClient eth.EthClient, + ethClient eth.Client, logger logging.Logger, ) (*AvsRegistryChainReader, error) { contractRegistryCoordinator, err := regcoord.NewContractRegistryCoordinator(registryCoordinatorAddr, ethClient) diff --git a/chainio/clients/avsregistry/subscriber.go b/chainio/clients/avsregistry/subscriber.go index 7d3651f3..acb4ec90 100644 --- a/chainio/clients/avsregistry/subscriber.go +++ b/chainio/clients/avsregistry/subscriber.go @@ -37,7 +37,7 @@ func NewAvsRegistryChainSubscriber( func BuildAvsRegistryChainSubscriber( blsApkRegistryAddr common.Address, - ethWsClient eth.EthClient, + ethWsClient eth.Client, logger logging.Logger, ) (*AvsRegistryChainSubscriber, error) { blsapkreg, err := blsapkreg.NewContractBLSApkRegistry(blsApkRegistryAddr, ethWsClient) diff --git a/chainio/clients/avsregistry/writer.go b/chainio/clients/avsregistry/writer.go index f3c1310b..235d0bb6 100644 --- a/chainio/clients/avsregistry/writer.go +++ b/chainio/clients/avsregistry/writer.go @@ -79,7 +79,7 @@ type AvsRegistryChainWriter struct { blsApkRegistry *blsapkregistry.ContractBLSApkRegistry elReader elcontracts.ELReader logger logging.Logger - ethClient eth.EthClient + ethClient eth.Client txMgr txmgr.TxManager } @@ -93,7 +93,7 @@ func NewAvsRegistryChainWriter( blsApkRegistry *blsapkregistry.ContractBLSApkRegistry, elReader elcontracts.ELReader, logger logging.Logger, - ethClient eth.EthClient, + ethClient eth.Client, txMgr txmgr.TxManager, ) (*AvsRegistryChainWriter, error) { return &AvsRegistryChainWriter{ @@ -113,7 +113,7 @@ func BuildAvsRegistryChainWriter( registryCoordinatorAddr gethcommon.Address, operatorStateRetrieverAddr gethcommon.Address, logger logging.Logger, - ethClient eth.EthClient, + ethClient eth.Client, txMgr txmgr.TxManager, ) (*AvsRegistryChainWriter, error) { registryCoordinator, err := regcoord.NewContractRegistryCoordinator(registryCoordinatorAddr, ethClient) diff --git a/chainio/clients/builder.go b/chainio/clients/builder.go index 174669e0..5ed00256 100644 --- a/chainio/clients/builder.go +++ b/chainio/clients/builder.go @@ -1,11 +1,14 @@ package clients import ( + "crypto/ecdsa" "errors" + "math/big" "github.com/Layr-Labs/eigensdk-go/chainio/clients/avsregistry" "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" "github.com/Layr-Labs/eigensdk-go/chainio/clients/eth" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/txsender" "github.com/Layr-Labs/eigensdk-go/chainio/txmgr" chainioutils "github.com/Layr-Labs/eigensdk-go/chainio/utils" "github.com/Layr-Labs/eigensdk-go/logging" @@ -37,16 +40,15 @@ type Clients struct { AvsRegistryChainWriter *avsregistry.AvsRegistryChainWriter ElChainReader *elcontracts.ELChainReader ElChainWriter *elcontracts.ELChainWriter - EthHttpClient *eth.Client - EthWsClient *eth.Client + EthHttpClient eth.Client + EthWsClient eth.Client Metrics *metrics.EigenMetrics // exposes main avs node spec metrics that need to be incremented by avs code and used to start the metrics server PrometheusRegistry *prometheus.Registry // Used if avs teams need to register avs-specific metrics } func BuildAll( config BuildAllConfig, - signerAddr gethcommon.Address, - signerFn signerv2.SignerFn, + ecdsaPrivateKey *ecdsa.PrivateKey, logger logging.Logger, ) (*Clients, error) { config.validate(logger) @@ -66,7 +68,16 @@ func BuildAll( return nil, types.WrapError(errors.New("Failed to create Eth WS client"), err) } - txMgr := txmgr.NewSimpleTxManager(ethHttpClient, logger, signerFn, signerAddr) + signerV2, addr, err := signerv2.SignerFromConfig(signerv2.Config{PrivateKey: ecdsaPrivateKey}, big.NewInt(1)) + if err != nil { + panic(err) + } + + txSender, err := txsender.NewPrivateKeyTxSender(ethHttpClient, signerV2, addr, logger) + if err != nil { + return nil, types.WrapError(errors.New("Failed to create transaction sender"), err) + } + txMgr := txmgr.NewSimpleTxManager(txSender, ethHttpClient, logger, signerV2, addr) // creating EL clients: Reader, Writer and Subscriber elChainReader, elChainWriter, err := config.buildElClients( ethHttpClient, @@ -105,7 +116,7 @@ func BuildAll( } func (config *BuildAllConfig) buildElClients( - ethHttpClient eth.EthClient, + ethHttpClient eth.Client, txMgr txmgr.TxManager, logger logging.Logger, eigenMetrics *metrics.EigenMetrics, @@ -170,8 +181,8 @@ func (config *BuildAllConfig) buildElClients( func (config *BuildAllConfig) buildAvsClients( elReader elcontracts.ELReader, - ethHttpClient eth.EthClient, - ethWsClient eth.EthClient, + ethHttpClient eth.Client, + ethWsClient eth.Client, txMgr txmgr.TxManager, logger logging.Logger, ) (*avsregistry.AvsRegistryChainReader, *avsregistry.AvsRegistryChainSubscriber, *avsregistry.AvsRegistryChainWriter, error) { diff --git a/chainio/clients/elcontracts/reader.go b/chainio/clients/elcontracts/reader.go index c8cb9596..80808535 100644 --- a/chainio/clients/elcontracts/reader.go +++ b/chainio/clients/elcontracts/reader.go @@ -67,7 +67,7 @@ type ELChainReader struct { delegationManager delegationmanager.ContractDelegationManagerCalls strategyManager strategymanager.ContractStrategyManagerCalls avsDirectory avsdirectory.ContractAVSDirectoryCalls - ethClient eth.EthClient + ethClient eth.Client } // forces EthReader to implement the chainio.Reader interface @@ -79,7 +79,7 @@ func NewELChainReader( strategyManager strategymanager.ContractStrategyManagerCalls, avsDirectory avsdirectory.ContractAVSDirectoryCalls, logger logging.Logger, - ethClient eth.EthClient, + ethClient eth.Client, ) *ELChainReader { return &ELChainReader{ slasher: slasher, @@ -94,7 +94,7 @@ func NewELChainReader( func BuildELChainReader( delegationManagerAddr gethcommon.Address, avsDirectoryAddr gethcommon.Address, - ethClient eth.EthClient, + ethClient eth.Client, logger logging.Logger, ) (*ELChainReader, error) { elContractBindings, err := chainioutils.NewEigenlayerContractBindings( diff --git a/chainio/clients/elcontracts/writer.go b/chainio/clients/elcontracts/writer.go index ed7a7780..166e9ac0 100644 --- a/chainio/clients/elcontracts/writer.go +++ b/chainio/clients/elcontracts/writer.go @@ -40,7 +40,7 @@ type ELChainWriter struct { strategyManager strategymanager.ContractStrategyManagerTransacts strategyManagerAddr gethcommon.Address elChainReader ELReader - ethClient eth.EthClient + ethClient eth.Client logger logging.Logger txMgr txmgr.TxManager } @@ -53,7 +53,7 @@ func NewELChainWriter( strategyManager strategymanager.ContractStrategyManagerTransacts, strategyManagerAddr gethcommon.Address, elChainReader ELReader, - ethClient eth.EthClient, + ethClient eth.Client, logger logging.Logger, eigenMetrics metrics.Metrics, txMgr txmgr.TxManager, @@ -73,7 +73,7 @@ func NewELChainWriter( func BuildELChainWriter( delegationManagerAddr gethcommon.Address, avsDirectoryAddr gethcommon.Address, - ethClient eth.EthClient, + ethClient eth.Client, logger logging.Logger, eigenMetrics metrics.Metrics, txMgr txmgr.TxManager, diff --git a/chainio/clients/eth/client.go b/chainio/clients/eth/client.go index 5973748b..16ee4e14 100644 --- a/chainio/clients/eth/client.go +++ b/chainio/clients/eth/client.go @@ -3,7 +3,6 @@ package eth import ( "context" "math/big" - "time" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" @@ -11,7 +10,7 @@ import ( "github.com/ethereum/go-ethereum/ethclient" ) -type gethClient interface { +type Client interface { ChainID(ctx context.Context) (*big.Int, error) BalanceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error) BlockByHash(ctx context.Context, hash common.Hash) (*types.Block, error) @@ -53,42 +52,6 @@ type gethClient interface { TransactionSender(ctx context.Context, tx *types.Transaction, block common.Hash, index uint) (common.Address, error) } -// EthClient is modified interface with additional custom methods -type EthClient interface { - gethClient - - WaitForTransactionReceipt( - ctx context.Context, - txHash common.Hash, - ) *types.Receipt -} - -// Client is a wrapper around geth's ethclient.Client struct, that adds a WaitForTransactionReceipt convenience method. -type Client struct { - *ethclient.Client -} - -var _ EthClient = (*Client)(nil) - -func NewClient(rpcAddress string) (*Client, error) { - client, err := ethclient.Dial(rpcAddress) - if err != nil { - return nil, err - } - return &Client{client}, nil -} - -func (e *Client) WaitForTransactionReceipt( - ctx context.Context, - txHash common.Hash, -) *types.Receipt { - for { - // verifying transaction receipt - receipt, err := e.Client.TransactionReceipt(ctx, txHash) - if err != nil { - time.Sleep(2 * time.Second) - } else { - return receipt - } - } +func NewClient(rpcAddress string) (Client, error) { + return ethclient.Dial(rpcAddress) } diff --git a/chainio/clients/eth/instrumented_client.go b/chainio/clients/eth/instrumented_client.go index 45eeac08..939686ae 100644 --- a/chainio/clients/eth/instrumented_client.go +++ b/chainio/clients/eth/instrumented_client.go @@ -27,7 +27,7 @@ type InstrumentedClient struct { clientAndVersion string } -var _ EthClient = (*InstrumentedClient)(nil) +var _ Client = (*InstrumentedClient)(nil) func NewInstrumentedClient(rpcAddress string, rpcCallsCollector *rpccalls.Collector) (*InstrumentedClient, error) { client, err := ethclient.Dial(rpcAddress) diff --git a/chainio/clients/fireblocks/client.go b/chainio/clients/fireblocks/client.go index 38167fa4..9b15860c 100644 --- a/chainio/clients/fireblocks/client.go +++ b/chainio/clients/fireblocks/client.go @@ -14,14 +14,25 @@ import ( "time" "github.com/Layr-Labs/eigensdk-go/logging" - "github.com/ethereum/go-ethereum/common" "github.com/golang-jwt/jwt" "github.com/google/uuid" ) +type AssetID string + +const ( + AssetIDETH AssetID = "ETH" + AssetIDGoerliETH AssetID = "ETH_TEST3" +) + +var AssetIDByChain = map[uint64]AssetID{ + 1: AssetIDETH, // mainnet + 5: AssetIDGoerliETH, // goerli +} + type FireblocksTxID string -type FireblocksClient interface { +type Client interface { // ContractCall makes a ContractCall request to the Fireblocks API. // It signs and broadcasts a transaction and returns the transaction ID and status. // ref: https://developers.fireblocks.com/reference/post_transactions @@ -31,9 +42,15 @@ type FireblocksClient interface { // This call is used to get the contract ID for a whitelisted contract, which is needed as destination account ID by NewContractCallRequest in a ContractCall // ref: https://developers.fireblocks.com/reference/get_contracts ListContracts(ctx context.Context) ([]WhitelistedContract, error) + // ListVaultAccounts makes a ListVaultAccounts request to the Fireblocks API + // It returns a list of vault accounts for the account. + ListVaultAccounts(ctx context.Context) ([]VaultAccount, error) + // GetTransaction makes a GetTransaction request to the Fireblocks API + // It returns the transaction details for the given transaction ID. + GetTransaction(ctx context.Context, txID string) (*Transaction, error) } -type fireblocksClient struct { +type client struct { apiKey string privateKey *rsa.PrivateKey baseURL string @@ -42,32 +59,19 @@ type fireblocksClient struct { logger logging.Logger } -type Asset struct { - ID string `json:"id"` - Status string `json:"status"` - Address common.Address `json:"address"` - Tag string `json:"tag"` -} - type ErrorResponse struct { Message string `json:"message"` Code int `json:"code"` } -type WhitelistedContract struct { - ID string `json:"id"` - Name string `json:"name"` - Assets []Asset `json:"assets"` -} - -func NewFireblocksClient(apiKey string, secretKey []byte, baseURL string, timeout time.Duration, logger logging.Logger) (FireblocksClient, error) { +func NewClient(apiKey string, secretKey []byte, baseURL string, timeout time.Duration, logger logging.Logger) (Client, error) { c := http.Client{Timeout: timeout} privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(secretKey) if err != nil { return nil, fmt.Errorf("error parsing RSA private key: %w", err) } - return &fireblocksClient{ + return &client{ apiKey: apiKey, privateKey: privateKey, baseURL: baseURL, @@ -79,7 +83,7 @@ func NewFireblocksClient(apiKey string, secretKey []byte, baseURL string, timeou // signJwt signs a JWT token for the Fireblocks API // mostly copied from the Fireblocks example: https://github.com/fireblocks/developers-hub/blob/main/authentication_examples/go/test.go -func (f *fireblocksClient) signJwt(path string, bodyJson interface{}, durationSeconds int64) (string, error) { +func (f *client) signJwt(path string, bodyJson interface{}, durationSeconds int64) (string, error) { nonce := uuid.New().String() now := time.Now().Unix() expiration := now + durationSeconds @@ -113,12 +117,25 @@ func (f *fireblocksClient) signJwt(path string, bodyJson interface{}, durationSe // makeRequest makes a request to the Fireblocks API // mostly copied from the Fireblocks example: https://github.com/fireblocks/developers-hub/blob/main/authentication_examples/go/test.go -func (f *fireblocksClient) makeRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) { - url, err := url.JoinPath(f.baseURL, path) +func (f *client) makeRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) { + // remove query parameters from path and join with baseURL + pathURI, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("error parsing URL: %w", err) + } + query := pathURI.Query() + pathURI.RawQuery = "" + urlStr, err := url.JoinPath(f.baseURL, pathURI.String()) if err != nil { return nil, fmt.Errorf("error joining URL path with %s and %s: %w", f.baseURL, path, err) } - f.logger.Debug("making request to Fireblocks", "method", method, "url", url) + url, err := url.Parse(urlStr) + if err != nil { + return nil, fmt.Errorf("error parsing URL: %w", err) + } + // add query parameters back to path + url.RawQuery = query.Encode() + f.logger.Debug("making request to Fireblocks", "method", method, "url", url.String()) var reqBodyBytes []byte if body != nil { var err error @@ -133,7 +150,7 @@ func (f *fireblocksClient) makeRequest(ctx context.Context, method, path string, return nil, fmt.Errorf("error signing JWT: %w", err) } - req, err := http.NewRequest(method, url, bytes.NewBuffer(reqBodyBytes)) + req, err := http.NewRequest(method, url.String(), bytes.NewBuffer(reqBodyBytes)) if err != nil { return nil, fmt.Errorf("error creating HTTP request: %w", err) } diff --git a/chainio/clients/fireblocks/client_test.go b/chainio/clients/fireblocks/client_test.go index e9e0f3c3..4b556c75 100644 --- a/chainio/clients/fireblocks/client_test.go +++ b/chainio/clients/fireblocks/client_test.go @@ -17,15 +17,21 @@ const ( apiKey = "FILL_ME_IN" ) -func TestListContracts(t *testing.T) { - t.Skip("skipping test as it's meant for manual runs only") - +func newFireblocksClient(t *testing.T) fireblocks.Client { secretKey, err := os.ReadFile(secretKeyPath) assert.NoError(t, err) logger, err := logging.NewZapLogger(logging.Development) assert.NoError(t, err) - c, err := fireblocks.NewFireblocksClient(apiKey, secretKey, sandboxAPI, 5*time.Second, logger) + c, err := fireblocks.NewClient(apiKey, secretKey, sandboxAPI, 5*time.Second, logger) assert.NoError(t, err) + return c + +} + +func TestListContracts(t *testing.T) { + t.Skip("skipping test as it's meant for manual runs only") + + c := newFireblocksClient(t) contracts, err := c.ListContracts(context.Background()) assert.NoError(t, err) for _, contract := range contracts { @@ -36,14 +42,7 @@ func TestListContracts(t *testing.T) { func TestContractCall(t *testing.T) { t.Skip("skipping test as it's meant for manual runs only") - secretKey, err := os.ReadFile(secretKeyPath) - assert.NoError(t, err) - - logger, err := logging.NewZapLogger(logging.Development) - assert.NoError(t, err) - - c, err := fireblocks.NewFireblocksClient(apiKey, secretKey, sandboxAPI, 5*time.Second, logger) - assert.NoError(t, err) + c := newFireblocksClient(t) destinationAccountID := "FILL_ME_IN" idempotenceKey := "FILL_ME_IN" // Tests the contract call against this contract: @@ -60,3 +59,24 @@ func TestContractCall(t *testing.T) { assert.NoError(t, err) t.Logf("txID: %s, status: %s", resp.ID, resp.Status) } + +func TestListVaultAccounts(t *testing.T) { + t.Skip("skipping test as it's meant for manual runs only") + + c := newFireblocksClient(t) + accounts, err := c.ListVaultAccounts(context.Background()) + assert.NoError(t, err) + for _, account := range accounts { + t.Logf("Account: %+v", account) + } +} + +func TestGetTransaction(t *testing.T) { + t.Skip("skipping test as it's meant for manual runs only") + + c := newFireblocksClient(t) + txID := "FILL_ME_IN" + tx, err := c.GetTransaction(context.Background(), txID) + assert.NoError(t, err) + t.Logf("Transaction: %+v", tx) +} diff --git a/chainio/clients/fireblocks/contract_call.go b/chainio/clients/fireblocks/contract_call.go index 6ef5a721..1ca3e2da 100644 --- a/chainio/clients/fireblocks/contract_call.go +++ b/chainio/clients/fireblocks/contract_call.go @@ -7,6 +7,17 @@ import ( "strings" ) +type TransactionOperation string + +const ( + ContractCall TransactionOperation = "CONTRACT_CALL" + Transfer TransactionOperation = "TRANSFER" + Mint TransactionOperation = "MINT" + Burn TransactionOperation = "BURN" + TypedMessage TransactionOperation = "TYPED_MESSAGE" + Raw TransactionOperation = "RAW" +) + type account struct { Type string `json:"type"` ID string `json:"id"` @@ -17,30 +28,30 @@ type extraParams struct { } type ContractCallRequest struct { - Operation string `json:"operation"` - ExternalTxID string `json:"externalTxId"` - AssetID string `json:"assetId"` - Source account `json:"source"` - Destination account `json:"destination"` - Amount string `json:"amount"` - ExtraParameters extraParams `json:"extraParameters"` + Operation TransactionOperation `json:"operation"` + ExternalTxID string `json:"externalTxId"` + AssetID AssetID `json:"assetId"` + Source account `json:"source"` + Destination account `json:"destination"` + Amount string `json:"amount"` + ExtraParameters extraParams `json:"extraParameters"` } type ContractCallResponse struct { ID string `json:"id"` - Status string `json:"status"` + Status Status `json:"status"` } func NewContractCallRequest( externalTxID string, - assetID string, + assetID AssetID, sourceAccountID string, destinationAccountID string, amount string, calldata string, ) *ContractCallRequest { return &ContractCallRequest{ - Operation: "CONTRACT_CALL", + Operation: ContractCall, ExternalTxID: externalTxID, AssetID: assetID, Source: account{ @@ -59,7 +70,7 @@ func NewContractCallRequest( } } -func (f *fireblocksClient) ContractCall(ctx context.Context, req *ContractCallRequest) (*ContractCallResponse, error) { +func (f *client) ContractCall(ctx context.Context, req *ContractCallRequest) (*ContractCallResponse, error) { f.logger.Debug("Fireblocks call contract", "req", req) res, err := f.makeRequest(ctx, "POST", "/v1/transactions", req) if err != nil { diff --git a/chainio/clients/fireblocks/get_transaction.go b/chainio/clients/fireblocks/get_transaction.go new file mode 100644 index 00000000..5682c751 --- /dev/null +++ b/chainio/clients/fireblocks/get_transaction.go @@ -0,0 +1,64 @@ +package fireblocks + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// Transaction is a type for the transaction response from Fireblocks +type Transaction struct { + ID string `json:"id"` + ExternalID string `json:"externalId"` + Status Status `json:"status"` + SubStatus string `json:"subStatus"` + TxHash string `json:"txHash"` + Operation TransactionOperation `json:"operation"` + CreatedAt int64 `json:"createdAt"` + LastUpdated int64 `json:"lastUpdated"` + AssetID AssetID `json:"assetId"` + Source account `json:"source"` + SourceAddress string `json:"sourceAddress"` + Destination account `json:"destination"` + DestinationAddress string `json:"destinationAddress"` + DestinationAddressDescription string `json:"destinationAddressDescription"` + DestinationTag string `json:"destinationTag"` + AmountInfo struct { + Amount string `json:"amount"` + RequestedAmount string `json:"requestedAmount"` + NetAmount string `json:"netAmount"` + AmountUSD string `json:"amountUSD"` + } `json:"amountInfo"` + FeeInfo struct { + NetworkFee string `json:"networkFee"` + ServiceFee string `json:"serviceFee"` + GasPrice string `json:"gasPrice"` + } `json:"feeInfo"` + FeeCurrency string `json:"feeCurrency"` + ExtraParameters struct { + ContractCallData string `json:"contractCallData"` + } `json:"extraParameters"` + NumOfConfirmations int `json:"numOfConfirmations"` + // The block hash and height of the block that this transaction was mined in. + BlockInfo struct { + BlockHeight string `json:"blockHeight"` + BlockHash string `json:"blockHash"` + } `json:"blockInfo"` +} + +func (f *client) GetTransaction(ctx context.Context, txID string) (*Transaction, error) { + f.logger.Debug("Fireblocks get transaction", "txID", txID) + url := fmt.Sprintf("/v1/transactions/%s", txID) + res, err := f.makeRequest(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + var tx Transaction + err = json.NewDecoder(strings.NewReader(string(res))).Decode(&tx) + if err != nil { + return nil, fmt.Errorf("error parsing response body: %w", err) + } + + return &tx, nil +} diff --git a/chainio/clients/fireblocks/list_contracts.go b/chainio/clients/fireblocks/list_contracts.go index d8586b39..a964bd6b 100644 --- a/chainio/clients/fireblocks/list_contracts.go +++ b/chainio/clients/fireblocks/list_contracts.go @@ -5,9 +5,22 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/ethereum/go-ethereum/common" ) -func (f *fireblocksClient) ListContracts(ctx context.Context) ([]WhitelistedContract, error) { +type WhitelistedContract struct { + ID string `json:"id"` + Name string `json:"name"` + Assets []struct { + ID AssetID `json:"id"` + Status string `json:"status"` + Address common.Address `json:"address"` + Tag string `json:"tag"` + } `json:"assets"` +} + +func (f *client) ListContracts(ctx context.Context) ([]WhitelistedContract, error) { var contracts []WhitelistedContract res, err := f.makeRequest(ctx, "GET", "/v1/contracts", nil) if err != nil { diff --git a/chainio/clients/fireblocks/list_vault_accounts.go b/chainio/clients/fireblocks/list_vault_accounts.go new file mode 100644 index 00000000..ee575380 --- /dev/null +++ b/chainio/clients/fireblocks/list_vault_accounts.go @@ -0,0 +1,64 @@ +package fireblocks + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" +) + +type Asset struct { + ID AssetID `json:"id"` + Total string `json:"total"` + Balance string `json:"balance"` + Available string `json:"available"` +} + +type VaultAccount struct { + ID string `json:"id"` + Name string `json:"name"` + Assets []Asset `json:"assets"` +} + +func (f *client) ListVaultAccounts(ctx context.Context) ([]VaultAccount, error) { + var accounts []VaultAccount + type paging struct { + Before string `json:"before"` + After string `json:"after"` + } + var response struct { + Accounts []VaultAccount `json:"accounts"` + Paging paging `json:"paging"` + } + p := paging{} + next := true + for next { + u, err := url.Parse("/v1/vault/accounts_paged") + if err != nil { + return accounts, fmt.Errorf("error parsing URL: %w", err) + } + q := u.Query() + q.Set("before", p.Before) + q.Set("after", p.After) + u.RawQuery = q.Encode() + fmt.Println("URL: ", u.String()) + res, err := f.makeRequest(ctx, "GET", u.String(), nil) + if err != nil { + return accounts, fmt.Errorf("error making request: %w", err) + } + body := string(res) + err = json.NewDecoder(strings.NewReader(body)).Decode(&response) + if err != nil { + return accounts, fmt.Errorf("error parsing response body: %s: %w", body, err) + } + + accounts = append(accounts, response.Accounts...) + p = response.Paging + if p.After == "" { + next = false + } + } + + return accounts, nil +} diff --git a/chainio/clients/fireblocks/status.go b/chainio/clients/fireblocks/status.go new file mode 100644 index 00000000..cdbb284d --- /dev/null +++ b/chainio/clients/fireblocks/status.go @@ -0,0 +1,23 @@ +package fireblocks + +type Status string + +// statuses for transactions +// ref: https://developers.fireblocks.com/reference/primary-transaction-statuses +const ( + Submitted Status = "SUBMITTED" + PendingScreening Status = "PENDING_AML_SCREENING" + PendingAuthorization Status = "PENDING_AUTHORIZATION" + Queued Status = "QUEUED" + PendingSignature Status = "PENDING_SIGNATURE" + PendingEmailApproval Status = "PENDING_3RD_PARTY_MANUAL_APPROVAL" + Pending3rdParty Status = "PENDING_3RD_PARTY" + Broadcasting Status = "BROADCASTING" + Confirming Status = "CONFIRMING" + Completed Status = "COMPLETED" + Cancelling Status = "CANCELLING" + Cancelled Status = "CANCELLED" + Blocked Status = "BLOCKED" + Rejected Status = "REJECTED" + Failed Status = "FAILED" +) diff --git a/chainio/clients/mocks/fireblocks.go b/chainio/clients/mocks/fireblocks.go index 376318d7..6e204959 100644 --- a/chainio/clients/mocks/fireblocks.go +++ b/chainio/clients/mocks/fireblocks.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks (interfaces: FireblocksClient) +// Source: github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks (interfaces: Client) // // Generated by this command: // -// mockgen -destination=./chainio/clients/mocks/fireblocks.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks FireblocksClient +// mockgen -destination=./clients/mocks/fireblocks.go -package=mocks -mock_names=Client=MockFireblocksClient github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks Client // // Package mocks is a generated GoMock package. @@ -17,7 +17,7 @@ import ( gomock "go.uber.org/mock/gomock" ) -// MockFireblocksClient is a mock of FireblocksClient interface. +// MockFireblocksClient is a mock of Client interface. type MockFireblocksClient struct { ctrl *gomock.Controller recorder *MockFireblocksClientMockRecorder @@ -55,6 +55,21 @@ func (mr *MockFireblocksClientMockRecorder) ContractCall(arg0, arg1 any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContractCall", reflect.TypeOf((*MockFireblocksClient)(nil).ContractCall), arg0, arg1) } +// GetTransaction mocks base method. +func (m *MockFireblocksClient) GetTransaction(arg0 context.Context, arg1 string) (*fireblocks.Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransaction", arg0, arg1) + ret0, _ := ret[0].(*fireblocks.Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransaction indicates an expected call of GetTransaction. +func (mr *MockFireblocksClientMockRecorder) GetTransaction(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransaction", reflect.TypeOf((*MockFireblocksClient)(nil).GetTransaction), arg0, arg1) +} + // ListContracts mocks base method. func (m *MockFireblocksClient) ListContracts(arg0 context.Context) ([]fireblocks.WhitelistedContract, error) { m.ctrl.T.Helper() @@ -69,3 +84,18 @@ func (mr *MockFireblocksClientMockRecorder) ListContracts(arg0 any) *gomock.Call mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListContracts", reflect.TypeOf((*MockFireblocksClient)(nil).ListContracts), arg0) } + +// ListVaultAccounts mocks base method. +func (m *MockFireblocksClient) ListVaultAccounts(arg0 context.Context) ([]fireblocks.VaultAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListVaultAccounts", arg0) + ret0, _ := ret[0].([]fireblocks.VaultAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListVaultAccounts indicates an expected call of ListVaultAccounts. +func (mr *MockFireblocksClientMockRecorder) ListVaultAccounts(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListVaultAccounts", reflect.TypeOf((*MockFireblocksClient)(nil).ListVaultAccounts), arg0) +} diff --git a/chainio/clients/txsender/fireblocks_txsender.go b/chainio/clients/txsender/fireblocks_txsender.go new file mode 100644 index 00000000..68b70895 --- /dev/null +++ b/chainio/clients/txsender/fireblocks_txsender.go @@ -0,0 +1,156 @@ +package txsender + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/eth" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" +) + +var _ TxSender = (*fireblocksTxSender)(nil) + +type fireblocksTxSender struct { + fireblocksClient fireblocks.Client + ethClient eth.Client + vaultAccountName string + logger logging.Logger + chainID *big.Int + + // caches + account *fireblocks.VaultAccount + whitelistedContracts map[common.Address]*fireblocks.WhitelistedContract +} + +func NewFireblocksTxSender(fireblocksClient fireblocks.Client, ethClient eth.Client, vaultAccountName string, logger logging.Logger) (TxSender, error) { + chainID, err := ethClient.ChainID(context.Background()) + if err != nil { + return nil, fmt.Errorf("error getting chain ID: %w", err) + } + return &fireblocksTxSender{ + fireblocksClient: fireblocksClient, + ethClient: ethClient, + vaultAccountName: vaultAccountName, + logger: logger, + chainID: chainID, + + // caches + account: nil, + whitelistedContracts: make(map[common.Address]*fireblocks.WhitelistedContract), + }, nil +} + +func (t *fireblocksTxSender) getAccount(ctx context.Context) (*fireblocks.VaultAccount, error) { + if t.account == nil { + accounts, err := t.fireblocksClient.ListVaultAccounts(ctx) + if err != nil { + return nil, fmt.Errorf("error listing vault accounts: %w", err) + } + for _, a := range accounts { + if a.Name == t.vaultAccountName { + t.account = &a + break + } + } + } + return t.account, nil +} + +func (t *fireblocksTxSender) getWhitelistedContract(ctx context.Context, address common.Address) (*fireblocks.WhitelistedContract, error) { + assetID, ok := fireblocks.AssetIDByChain[t.chainID.Uint64()] + if !ok { + return nil, fmt.Errorf("unsupported chain %d", t.chainID.Uint64()) + } + contract, ok := t.whitelistedContracts[address] + if !ok { + contracts, err := t.fireblocksClient.ListContracts(ctx) + if err != nil { + return nil, fmt.Errorf("error listing contracts: %w", err) + } + for _, c := range contracts { + for _, a := range c.Assets { + if a.Address == address && a.Status == "ACTIVE" && a.ID == assetID { + t.whitelistedContracts[address] = &c + contract = &c + break + } + } + } + } + + if contract == nil { + return nil, fmt.Errorf("contract %s not found in whitelisted contracts", address.Hex()) + } + return contract, nil +} + +func (t *fireblocksTxSender) SendTransaction(ctx context.Context, tx *types.Transaction) (TxID, error) { + assetID, ok := fireblocks.AssetIDByChain[t.chainID.Uint64()] + if !ok { + return "", fmt.Errorf("unsupported chain %d", t.chainID.Uint64()) + } + account, err := t.getAccount(ctx) + if err != nil { + return "", fmt.Errorf("error getting account: %w", err) + } + foundAsset := false + for _, a := range account.Assets { + if a.ID == assetID { + if a.Available == "0" { + return "", errors.New("insufficient funds") + } + foundAsset = true + break + } + } + if !foundAsset { + return "", fmt.Errorf("asset %s not found in account %s", assetID, t.vaultAccountName) + } + + contract, err := t.getWhitelistedContract(ctx, *tx.To()) + if err != nil { + return "", fmt.Errorf("error getting whitelisted contract %s: %w", tx.To().Hex(), err) + } + req := fireblocks.NewContractCallRequest( + tx.To().Hex(), + assetID, + account.ID, // source account ID + contract.ID, // destination account ID + tx.Value().String(), // amount + hexutil.Encode(tx.Data()), // calldata + ) + res, err := t.fireblocksClient.ContractCall(ctx, req) + if err != nil { + return "", fmt.Errorf("error calling contract %s: %w", tx.To().Hex(), err) + } + + return res.ID, nil +} + +func (t *fireblocksTxSender) GetTransactionReceipt(ctx context.Context, txID TxID) (*types.Receipt, error) { + fireblockTx, err := t.fireblocksClient.GetTransaction(ctx, txID) + if err != nil { + return nil, fmt.Errorf("error getting fireblocks transaction %s: %w", txID, err) + } + if fireblockTx.Status == "COMPLETED" { + txHash := common.HexToHash(fireblockTx.TxHash) + receipt, err := t.ethClient.TransactionReceipt(ctx, txHash) + if err == nil { + return receipt, nil + } + if errors.Is(err, ethereum.NotFound) { + return nil, fmt.Errorf("transaction receipt %s not yet available", txID) + } else { + return nil, fmt.Errorf("Transaction receipt retrieval failed: %w", err) + } + } + + return nil, fmt.Errorf("transaction %s not yet completed: status %s", txID, fireblockTx.Status) +} diff --git a/chainio/clients/txsender/fireblocks_txsender_test.go b/chainio/clients/txsender/fireblocks_txsender_test.go new file mode 100644 index 00000000..e6d21359 --- /dev/null +++ b/chainio/clients/txsender/fireblocks_txsender_test.go @@ -0,0 +1,252 @@ +package txsender_test + +import ( + "context" + "math/big" + "testing" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks" + cmocks "github.com/Layr-Labs/eigensdk-go/chainio/clients/mocks" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/txsender" + "github.com/Layr-Labs/eigensdk-go/chainio/mocks" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +const ( + vaultAccountName = "batcher" + contractAddress = "0x5f9ef6e1bb2acb8f592a483052b732ceb78e58ca" +) + +func TestSendTransaction(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + fireblocksClient := cmocks.NewMockFireblocksClient(ctrl) + ethClient := mocks.NewMockEthClient(ctrl) + logger, err := logging.NewZapLogger(logging.Development) + assert.NoError(t, err) + ethClient.EXPECT().ChainID(gomock.Any()).Return(big.NewInt(5), nil) + sender, err := txsender.NewFireblocksTxSender(fireblocksClient, ethClient, vaultAccountName, logger) + assert.NoError(t, err) + + fireblocksClient.EXPECT().ListContracts(gomock.Any()).Return([]fireblocks.WhitelistedContract{ + { + ID: "contractID", + Name: "TestContract", + Assets: []struct { + ID fireblocks.AssetID `json:"id"` + Status string `json:"status"` + Address common.Address `json:"address"` + Tag string `json:"tag"` + }{{ + ID: "ETH_TEST3", + Status: "ACTIVE", + Address: common.HexToAddress(contractAddress), + Tag: "", + }, + }, + }, + }, nil) + fireblocksClient.EXPECT().ContractCall(gomock.Any(), gomock.Any()).Return(&fireblocks.ContractCallResponse{ + ID: "1234", + Status: fireblocks.Confirming, + }, nil) + fireblocksClient.EXPECT().ListVaultAccounts(gomock.Any()).Return([]fireblocks.VaultAccount{ + { + ID: "vaultAccountID", + Name: vaultAccountName, + Assets: []fireblocks.Asset{ + { + ID: "ETH_TEST3", + Total: "1", + Balance: "1", + Available: "1", + }, + }, + }, + }, nil) + + txID, err := sender.SendTransaction(context.Background(), types.NewTransaction( + 0, // nonce + common.HexToAddress(contractAddress), // to + big.NewInt(0), // value + 100000, // gas + big.NewInt(100), // gasPrice + common.Hex2Bytes("0x6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data + )) + assert.NoError(t, err) + assert.Equal(t, "1234", txID) +} + +func TestSendTransactionNoValidContract(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + fireblocksClient := cmocks.NewMockFireblocksClient(ctrl) + ethClient := mocks.NewMockEthClient(ctrl) + logger, err := logging.NewZapLogger(logging.Development) + assert.NoError(t, err) + ethClient.EXPECT().ChainID(gomock.Any()).Return(big.NewInt(5), nil) + sender, err := txsender.NewFireblocksTxSender(fireblocksClient, ethClient, vaultAccountName, logger) + assert.NoError(t, err) + + fireblocksClient.EXPECT().ListContracts(gomock.Any()).Return([]fireblocks.WhitelistedContract{ + { + ID: "contractID", + Name: "TestContract", + Assets: []struct { + ID fireblocks.AssetID `json:"id"` + Status string `json:"status"` + Address common.Address `json:"address"` + Tag string `json:"tag"` + }{{ + ID: "ETH_TEST123123", // wrong asset ID + Status: "ACTIVE", + Address: common.HexToAddress(contractAddress), + Tag: "", + }, + }, + }, + }, nil) + fireblocksClient.EXPECT().ListVaultAccounts(gomock.Any()).Return([]fireblocks.VaultAccount{ + { + ID: "vaultAccountID", + Name: vaultAccountName, + Assets: []fireblocks.Asset{ + { + ID: "ETH_TEST3", + Total: "1", + Balance: "1", + Available: "1", + }, + }, + }, + }, nil) + + txID, err := sender.SendTransaction(context.Background(), types.NewTransaction( + 0, // nonce + common.HexToAddress(contractAddress), // to + big.NewInt(0), // value + 100000, // gas + big.NewInt(100), // gasPrice + common.Hex2Bytes("0x6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data + )) + assert.Error(t, err) + assert.Equal(t, "", txID) +} + +func TestSendTransactionInvalidVault(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + fireblocksClient := cmocks.NewMockFireblocksClient(ctrl) + ethClient := mocks.NewMockEthClient(ctrl) + logger, err := logging.NewZapLogger(logging.Development) + assert.NoError(t, err) + ethClient.EXPECT().ChainID(gomock.Any()).Return(big.NewInt(5), nil) + sender, err := txsender.NewFireblocksTxSender(fireblocksClient, ethClient, vaultAccountName, logger) + assert.NoError(t, err) + + fireblocksClient.EXPECT().ListVaultAccounts(gomock.Any()).Return([]fireblocks.VaultAccount{ + { + ID: "vaultAccountID", + Name: vaultAccountName, + Assets: []fireblocks.Asset{ + { + ID: "ETH_TEST123123", // wrong asset ID + Total: "1", + Balance: "1", + Available: "1", + }, + }, + }, + }, nil) + + txID, err := sender.SendTransaction(context.Background(), types.NewTransaction( + 0, // nonce + common.HexToAddress(contractAddress), // to + big.NewInt(0), // value + 100000, // gas + big.NewInt(100), // gasPrice + common.Hex2Bytes("0x6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data + )) + assert.Error(t, err) + assert.Equal(t, "", txID) +} + +func TestWaitForTransactionReceipt(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + fireblocksClient := cmocks.NewMockFireblocksClient(ctrl) + ethClient := mocks.NewMockEthClient(ctrl) + logger, err := logging.NewZapLogger(logging.Development) + assert.NoError(t, err) + ethClient.EXPECT().ChainID(gomock.Any()).Return(big.NewInt(5), nil) + sender, err := txsender.NewFireblocksTxSender(fireblocksClient, ethClient, vaultAccountName, logger) + assert.NoError(t, err) + + expectedTxHash := "0x0000000000000000000000000000000000000000000000000000000000001234" + fireblocksClient.EXPECT().GetTransaction(gomock.Any(), expectedTxHash).Return(&fireblocks.Transaction{ + ID: expectedTxHash, + Status: fireblocks.Completed, + TxHash: expectedTxHash, + }, nil) + ethClient.EXPECT().TransactionReceipt(gomock.Any(), common.HexToHash(expectedTxHash)).Return(&types.Receipt{ + TxHash: common.HexToHash(expectedTxHash), + BlockNumber: big.NewInt(1234), + }, nil) + + receipt, err := sender.GetTransactionReceipt(context.Background(), expectedTxHash) + assert.NoError(t, err) + assert.Equal(t, expectedTxHash, receipt.TxHash.String()) + assert.Equal(t, big.NewInt(1234), receipt.BlockNumber) +} + +func TestWaitForTransactionReceiptFailFromFireblocks(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + fireblocksClient := cmocks.NewMockFireblocksClient(ctrl) + ethClient := mocks.NewMockEthClient(ctrl) + logger, err := logging.NewZapLogger(logging.Development) + assert.NoError(t, err) + ethClient.EXPECT().ChainID(gomock.Any()).Return(big.NewInt(5), nil) + sender, err := txsender.NewFireblocksTxSender(fireblocksClient, ethClient, vaultAccountName, logger) + assert.NoError(t, err) + + expectedTxHash := "0x0000000000000000000000000000000000000000000000000000000000001234" + fireblocksClient.EXPECT().GetTransaction(gomock.Any(), expectedTxHash).Return(&fireblocks.Transaction{ + ID: expectedTxHash, + Status: fireblocks.Confirming, // not completed + TxHash: expectedTxHash, + }, nil) + + receipt, err := sender.GetTransactionReceipt(context.Background(), expectedTxHash) + assert.Error(t, err) + assert.Nil(t, receipt) +} + +func TestWaitForTransactionReceiptFailFromChain(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + fireblocksClient := cmocks.NewMockFireblocksClient(ctrl) + ethClient := mocks.NewMockEthClient(ctrl) + logger, err := logging.NewZapLogger(logging.Development) + assert.NoError(t, err) + ethClient.EXPECT().ChainID(gomock.Any()).Return(big.NewInt(5), nil) + sender, err := txsender.NewFireblocksTxSender(fireblocksClient, ethClient, vaultAccountName, logger) + assert.NoError(t, err) + + expectedTxHash := "0x0000000000000000000000000000000000000000000000000000000000001234" + fireblocksClient.EXPECT().GetTransaction(gomock.Any(), expectedTxHash).Return(&fireblocks.Transaction{ + ID: expectedTxHash, + Status: fireblocks.Completed, + TxHash: expectedTxHash, + }, nil) + ethClient.EXPECT().TransactionReceipt(gomock.Any(), common.HexToHash(expectedTxHash)).Return(nil, ethereum.NotFound) + + receipt, err := sender.GetTransactionReceipt(context.Background(), expectedTxHash) + assert.Error(t, err) + assert.Nil(t, receipt) +} diff --git a/chainio/clients/txsender/privatekey_txsender.go b/chainio/clients/txsender/privatekey_txsender.go new file mode 100644 index 00000000..559ff366 --- /dev/null +++ b/chainio/clients/txsender/privatekey_txsender.go @@ -0,0 +1,137 @@ +package txsender + +import ( + "context" + "fmt" + "math/big" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/eth" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/Layr-Labs/eigensdk-go/signerv2" + sdktypes "github.com/Layr-Labs/eigensdk-go/types" + "github.com/ethereum/go-ethereum" + "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 ( + FallbackGasTipCap = big.NewInt(15_000_000_000) +) + +var _ TxSender = (*privateKeyTxSender)(nil) + +type privateKeyTxSender struct { + ethClient eth.Client + address common.Address + signerFn signerv2.SignerFn + logger logging.Logger + + // cache + contracts map[common.Address]*bind.BoundContract +} + +func NewPrivateKeyTxSender(ethClient eth.Client, signer signerv2.SignerFn, signerAddress common.Address, logger logging.Logger) (TxSender, error) { + return &privateKeyTxSender{ + ethClient: ethClient, + address: signerAddress, + signerFn: signer, + logger: logger, + }, nil +} + +func (t *privateKeyTxSender) SendTransaction(ctx context.Context, tx *types.Transaction) (TxID, 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 + t.logger.Debug("Estimating gas and nonce") + tx, err := t.estimateGasAndNonce(ctx, tx) + if err != nil { + return "", err + } + + t.logger.Debug("Getting signer for tx") + signer, err := t.signerFn(ctx, t.address) + if err != nil { + return "", err + } + + 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 + } + + tx, err = contract.RawTransact(opts, tx.Data()) + if err != nil { + return "", sdktypes.WrapError(fmt.Errorf("send: tx %v failed.", tx.Hash().String()), err) + } + + return tx.Hash().Hex(), nil +} + +func (t *privateKeyTxSender) GetTransactionReceipt(ctx context.Context, txID TxID) (*types.Receipt, error) { + txHash := common.HexToHash(txID) + return t.ethClient.TransactionReceipt(ctx, txHash) +} + +// 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 (t *privateKeyTxSender) estimateGasAndNonce(ctx context.Context, tx *types.Transaction) (*types.Transaction, 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. + t.logger.Info("eth_maxPriorityFeePerGas is unsupported by current backend, using fallback gasTipCap") + gasTipCap = FallbackGasTipCap + } + + header, err := t.ethClient.HeaderByNumber(ctx, nil) + if err != nil { + return nil, err + } + + gasFeeCap := new(big.Int).Add(header.BaseFee, gasTipCap) + + gasLimit, err := t.ethClient.EstimateGas(ctx, ethereum.CallMsg{ + From: t.address, + To: tx.To(), + GasTipCap: gasTipCap, + GasFeeCap: gasFeeCap, + Value: tx.Value(), + Data: tx.Data(), + }) + if err != nil { + return nil, err + } + + rawTx := &types.DynamicFeeTx{ + ChainID: tx.ChainId(), + To: tx.To(), + GasTipCap: gasTipCap, + GasFeeCap: gasFeeCap, + Data: tx.Data(), + Value: tx.Value(), + Gas: gasLimit, // TODO(add buffer) + 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/clients/txsender/txsender.go b/chainio/clients/txsender/txsender.go new file mode 100644 index 00000000..aeb764a2 --- /dev/null +++ b/chainio/clients/txsender/txsender.go @@ -0,0 +1,19 @@ +package txsender + +import ( + "context" + + "github.com/ethereum/go-ethereum/core/types" +) + +type TxID = string + +// TxSender is an interface for signing and sending transactions to the network +// 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 +// and retrieved via an Ethereum client. +type TxSender interface { + SendTransaction(ctx context.Context, tx *types.Transaction) (TxID, error) + GetTransactionReceipt(ctx context.Context, txID TxID) (*types.Receipt, error) +} diff --git a/chainio/gen.go b/chainio/gen.go index 3623b43a..8d221d63 100644 --- a/chainio/gen.go +++ b/chainio/gen.go @@ -7,4 +7,4 @@ package chainio //go:generate mockgen -destination=./mocks/elContractsWriter.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts ELWriter //go:generate mockgen -destination=./mocks/ethclient.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/eth EthClient //go:generate mockgen -destination=./mocks/eventSubscription.go -package=mocks github.com/ethereum/go-ethereum/event Subscription -//go:generate mockgen -destination=./clients/mocks/fireblocks.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks FireblocksClient +//go:generate mockgen -destination=./clients/mocks/fireblocks.go -package=mocks -mock_names=Client=MockFireblocksClient github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks Client diff --git a/chainio/mocks/avsRegistryContractsReader.go b/chainio/mocks/avsRegistryContractsReader.go index 5ab1e664..9101cc29 100644 --- a/chainio/mocks/avsRegistryContractsReader.go +++ b/chainio/mocks/avsRegistryContractsReader.go @@ -5,6 +5,7 @@ // // mockgen -destination=./mocks/avsRegistryContractsReader.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/avsregistry AvsRegistryReader // + // Package mocks is a generated GoMock package. package mocks diff --git a/chainio/mocks/avsRegistryContractsSubscriber.go b/chainio/mocks/avsRegistryContractsSubscriber.go index d1098925..5ce6c5e2 100644 --- a/chainio/mocks/avsRegistryContractsSubscriber.go +++ b/chainio/mocks/avsRegistryContractsSubscriber.go @@ -5,6 +5,7 @@ // // mockgen -destination=./mocks/avsRegistryContractsSubscriber.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/avsregistry AvsRegistrySubscriber // + // Package mocks is a generated GoMock package. package mocks diff --git a/chainio/mocks/avsRegistryContractsWriter.go b/chainio/mocks/avsRegistryContractsWriter.go index 4b8c1099..913765e7 100644 --- a/chainio/mocks/avsRegistryContractsWriter.go +++ b/chainio/mocks/avsRegistryContractsWriter.go @@ -5,6 +5,7 @@ // // mockgen -destination=./mocks/avsRegistryContractsWriter.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/avsregistry AvsRegistryWriter // + // Package mocks is a generated GoMock package. package mocks diff --git a/chainio/mocks/elContractsReader.go b/chainio/mocks/elContractsReader.go index 60f595d2..1ba7173e 100644 --- a/chainio/mocks/elContractsReader.go +++ b/chainio/mocks/elContractsReader.go @@ -5,6 +5,7 @@ // // mockgen -destination=./mocks/elContractsReader.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts ELReader // + // Package mocks is a generated GoMock package. package mocks diff --git a/chainio/mocks/elContractsWriter.go b/chainio/mocks/elContractsWriter.go index 0d20e29e..cd773d00 100644 --- a/chainio/mocks/elContractsWriter.go +++ b/chainio/mocks/elContractsWriter.go @@ -5,6 +5,7 @@ // // mockgen -destination=./mocks/elContractsWriter.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts ELWriter // + // Package mocks is a generated GoMock package. package mocks diff --git a/chainio/mocks/ethclient.go b/chainio/mocks/ethclient.go index ee930f0c..ca929034 100644 --- a/chainio/mocks/ethclient.go +++ b/chainio/mocks/ethclient.go @@ -5,6 +5,7 @@ // // mockgen -destination=./mocks/ethclient.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/eth EthClient // + // Package mocks is a generated GoMock package. package mocks diff --git a/chainio/mocks/eventSubscription.go b/chainio/mocks/eventSubscription.go index f588e50e..953a5aa1 100644 --- a/chainio/mocks/eventSubscription.go +++ b/chainio/mocks/eventSubscription.go @@ -5,6 +5,7 @@ // // mockgen -destination=./mocks/eventSubscription.go -package=mocks github.com/ethereum/go-ethereum/event Subscription // + // Package mocks is a generated GoMock package. package mocks diff --git a/chainio/txmgr/txmgr.go b/chainio/txmgr/txmgr.go index 1dc01bc4..91b23404 100644 --- a/chainio/txmgr/txmgr.go +++ b/chainio/txmgr/txmgr.go @@ -3,25 +3,19 @@ package txmgr import ( "context" "errors" - "fmt" "math/big" "time" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/txsender" "github.com/Layr-Labs/eigensdk-go/logging" "github.com/Layr-Labs/eigensdk-go/signerv2" - sdktypes "github.com/Layr-Labs/eigensdk-go/types" "github.com/ethereum/go-ethereum" - "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" "github.com/ethereum/go-ethereum/log" ) -var ( - FallbackGasTipCap = big.NewInt(15_000_000_000) -) - // We are taking inspiration from the optimism TxManager interface // https://github.com/ethereum-optimism/optimism/blob/develop/op-service/txmgr/txmgr.go @@ -48,11 +42,11 @@ type EthBackend interface { } type SimpleTxManager struct { - backend EthBackend - signerFn signerv2.SignerFn - log logging.Logger - sender common.Address - contracts map[common.Address]*bind.BoundContract + txSender txsender.TxSender + backend EthBackend + signerFn signerv2.SignerFn + log logging.Logger + sender common.Address } var _ TxManager = (*SimpleTxManager)(nil) @@ -60,17 +54,19 @@ 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( + txSender txsender.TxSender, + // TODO: replace EthBackend with eth.Client backend EthBackend, log logging.Logger, signerFn signerv2.SignerFn, sender common.Address, ) *SimpleTxManager { return &SimpleTxManager{ - backend: backend, - log: log, - signerFn: signerFn, - sender: sender, - contracts: map[common.Address]*bind.BoundContract{}, + txSender: txSender, + backend: backend, + log: log, + signerFn: signerFn, + sender: sender, } } @@ -81,50 +77,17 @@ func NewSimpleTxManager( // 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) + txID, err := m.txSender.SendTransaction(ctx, tx) if err != nil { return nil, err } - m.log.Debug("Getting signer for tx") - signer, err := m.signerFn(ctx, m.sender) + receipt, err := m.waitForReceipt(ctx, txID) if err != nil { + log.Info("Transaction receipt not found", "err", err) return nil, err } - m.log.Debug("Sending transaction") - opts := &bind.TransactOpts{ - From: m.sender, - Nonce: new(big.Int).SetUint64(tx.Nonce()), - Signer: signer, - Value: tx.Value(), - GasFeeCap: tx.GasFeeCap(), - GasTipCap: tx.GasTipCap(), - GasLimit: tx.Gas(), - Context: ctx, - } - - contract := m.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{}, m.backend, m.backend, m.backend) - // cache the contract for later use - m.contracts[*tx.To()] = contract - } - - tx, err = contract.RawTransact(opts, tx.Data()) - if err != nil { - return nil, sdktypes.WrapError(fmt.Errorf("send: tx %v failed.", tx.Hash().String()), err) - } - - receipt := m.waitForTx(ctx, tx) - return receipt, nil } @@ -143,65 +106,8 @@ func (m *SimpleTxManager) GetNoSendTxOpts() (*bind.TransactOpts, error) { return noSendTxOpts, nil } -// 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.backend.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.backend.HeaderByNumber(ctx, nil) - if err != nil { - return nil, err - } - - gasFeeCap := new(big.Int).Add(header.BaseFee, gasTipCap) - - gasLimit, err := m.backend.EstimateGas(ctx, ethereum.CallMsg{ - From: m.sender, - To: tx.To(), - GasTipCap: gasTipCap, - GasFeeCap: gasFeeCap, - Value: tx.Value(), - Data: tx.Data(), - }) - if err != nil { - return nil, err - } - - rawTx := &types.DynamicFeeTx{ - ChainID: tx.ChainId(), - To: tx.To(), - GasTipCap: gasTipCap, - GasFeeCap: gasFeeCap, - Data: tx.Data(), - Value: tx.Value(), - Gas: gasLimit, // TODO(add buffer) - 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 -} - -// waitForTx calls waitMined, and then return the receipt -func (m *SimpleTxManager) waitForTx(ctx context.Context, tx *types.Transaction) *types.Receipt { - // Poll for the transaction to be ready & then send the result to receiptChan - receipt, err := m.waitMined(ctx, tx) - if err != nil { - log.Info("Transaction receipt not found", "err", err) - return nil - } - return receipt -} - -// waitMined waits for the transaction to be mined or for the context to be cancelled. -func (m *SimpleTxManager) waitMined(ctx context.Context, tx *types.Transaction) (*types.Receipt, error) { - txHash := tx.Hash() +func (m *SimpleTxManager) waitForReceipt(ctx context.Context, txID txsender.TxID) (*types.Receipt, error) { + // TODO: make this ticker adjustable queryTicker := time.NewTicker(2 * time.Second) defer queryTicker.Stop() for { @@ -209,16 +115,16 @@ func (m *SimpleTxManager) waitMined(ctx context.Context, tx *types.Transaction) case <-ctx.Done(): return nil, ctx.Err() case <-queryTicker.C: - if receipt := m.queryReceipt(ctx, txHash); receipt != nil { + if receipt := m.queryReceipt(ctx, txID); receipt != nil { return receipt, nil } } } } -// queryReceipt queries for the receipt and returns the receipt -func (m *SimpleTxManager) queryReceipt(ctx context.Context, txHash common.Hash) *types.Receipt { - receipt, err := m.backend.TransactionReceipt(ctx, txHash) +func (m *SimpleTxManager) queryReceipt(ctx context.Context, txID txsender.TxID) *types.Receipt { + txHash := common.HexToHash(txID) + receipt, err := m.txSender.GetTransactionReceipt(ctx, txHash.Hex()) if errors.Is(err, ethereum.NotFound) { m.log.Info("Transaction not yet mined", "hash", txHash) return nil diff --git a/chainio/utils/bindings.go b/chainio/utils/bindings.go index c9b95449..7674f69e 100644 --- a/chainio/utils/bindings.go +++ b/chainio/utils/bindings.go @@ -38,7 +38,7 @@ type EigenlayerContractBindings struct { func NewEigenlayerContractBindings( delegationManagerAddr gethcommon.Address, avsDirectoryAddr gethcommon.Address, - ethclient eth.EthClient, + ethclient eth.Client, logger logging.Logger, ) (*EigenlayerContractBindings, error) { contractDelegationManager, err := delegationmanager.NewContractDelegationManager(delegationManagerAddr, ethclient) @@ -101,7 +101,7 @@ type AvsRegistryContractBindings struct { func NewAVSRegistryContractBindings( registryCoordinatorAddr gethcommon.Address, operatorStateRetrieverAddr gethcommon.Address, - ethclient eth.EthClient, + ethclient eth.Client, logger logging.Logger, ) (*AvsRegistryContractBindings, error) { contractBlsRegistryCoordinator, err := regcoordinator.NewContractRegistryCoordinator( diff --git a/metrics/eigenmetrics_example_test.go b/metrics/eigenmetrics_example_test.go index a92e0b2d..18181764 100644 --- a/metrics/eigenmetrics_example_test.go +++ b/metrics/eigenmetrics_example_test.go @@ -6,7 +6,6 @@ package metrics_test import ( "context" - "math/big" "github.com/Layr-Labs/eigensdk-go/chainio/clients" "github.com/Layr-Labs/eigensdk-go/chainio/clients/eth" @@ -14,7 +13,6 @@ import ( "github.com/Layr-Labs/eigensdk-go/metrics" "github.com/Layr-Labs/eigensdk-go/metrics/collectors/economic" rpccalls "github.com/Layr-Labs/eigensdk-go/metrics/collectors/rpc_calls" - "github.com/Layr-Labs/eigensdk-go/signerv2" "github.com/Layr-Labs/eigensdk-go/types" "github.com/ethereum/go-ethereum/crypto" "github.com/prometheus/client_golang/prometheus" @@ -38,11 +36,6 @@ func ExampleEigenMetrics() { } operatorEcdsaAddr := crypto.PubkeyToAddress(ecdsaPrivateKey.PublicKey) - signerV2, _, err := signerv2.SignerFromConfig(signerv2.Config{PrivateKey: ecdsaPrivateKey}, big.NewInt(1)) - if err != nil { - panic(err) - } - chainioConfig := clients.BuildAllConfig{ EthHttpUrl: "http://localhost:8545", EthWsUrl: "ws://localhost:8545", @@ -51,7 +44,7 @@ func ExampleEigenMetrics() { AvsName: "exampleAvs", PromMetricsIpPortAddress: ":9090", } - clients, err := clients.BuildAll(chainioConfig, operatorEcdsaAddr, signerV2, logger) + clients, err := clients.BuildAll(chainioConfig, ecdsaPrivateKey, logger) if err != nil { panic(err) } diff --git a/signer/basic_signer.go b/signer/basic_signer.go index 4306c518..52b58648 100644 --- a/signer/basic_signer.go +++ b/signer/basic_signer.go @@ -24,7 +24,7 @@ var FallbackGasTipCap = big.NewInt(15000000000) type BasicSigner struct { logger logging.Logger - ethClient sdkethclient.EthClient + ethClient sdkethclient.Client privateKey *ecdsa.PrivateKey accountAddress gethcommon.Address contracts map[gethcommon.Address]*bind.BoundContract @@ -33,7 +33,7 @@ type BasicSigner struct { func NewBasicSigner( privateKey *ecdsa.PrivateKey, - ethClient sdkethclient.EthClient, + ethClient sdkethclient.Client, logger logging.Logger, fallbackGasTipCap *big.Int, ) (*BasicSigner, error) {