From 739875a6c42448493b75ffd3c5e0ce704ae9e9cf Mon Sep 17 00:00:00 2001 From: Ian Shim Date: Wed, 21 Feb 2024 09:23:11 -0800 Subject: [PATCH] fireblocks client --- chainio/clients/clients_test.go | 11 ++ chainio/clients/fireblocks/client.go | 169 +++++++++++++++++++ chainio/clients/fireblocks/client_test.go | 62 +++++++ chainio/clients/fireblocks/contract_call.go | 78 +++++++++ chainio/clients/fireblocks/list_contracts.go | 23 +++ chainio/clients/mocks/fireblocks.go | 71 ++++++++ chainio/gen.go | 1 + go.mod | 3 + go.sum | 3 + 9 files changed, 421 insertions(+) create mode 100644 chainio/clients/clients_test.go create mode 100644 chainio/clients/fireblocks/client.go create mode 100644 chainio/clients/fireblocks/client_test.go create mode 100644 chainio/clients/fireblocks/contract_call.go create mode 100644 chainio/clients/fireblocks/list_contracts.go create mode 100644 chainio/clients/mocks/fireblocks.go diff --git a/chainio/clients/clients_test.go b/chainio/clients/clients_test.go new file mode 100644 index 00000000..9166e7ea --- /dev/null +++ b/chainio/clients/clients_test.go @@ -0,0 +1,11 @@ +package clients_test + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + code := m.Run() + os.Exit(code) +} diff --git a/chainio/clients/fireblocks/client.go b/chainio/clients/fireblocks/client.go new file mode 100644 index 00000000..38167fa4 --- /dev/null +++ b/chainio/clients/fireblocks/client.go @@ -0,0 +1,169 @@ +package fireblocks + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/ethereum/go-ethereum/common" + "github.com/golang-jwt/jwt" + "github.com/google/uuid" +) + +type FireblocksTxID string + +type FireblocksClient 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 + ContractCall(ctx context.Context, body *ContractCallRequest) (*ContractCallResponse, error) + // ListContracts makes a ListContracts request to the Fireblocks API + // It returns a list of whitelisted contracts and their assets for the account. + // 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) +} + +type fireblocksClient struct { + apiKey string + privateKey *rsa.PrivateKey + baseURL string + timeout time.Duration + client *http.Client + 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) { + 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{ + apiKey: apiKey, + privateKey: privateKey, + baseURL: baseURL, + timeout: timeout, + client: &c, + logger: logger, + }, nil +} + +// 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) { + nonce := uuid.New().String() + now := time.Now().Unix() + expiration := now + durationSeconds + + bodyBytes, err := json.Marshal(bodyJson) + if err != nil { + return "", fmt.Errorf("error marshaling JSON: %w", err) + } + + h := sha256.New() + h.Write(bodyBytes) + hashed := h.Sum(nil) + + claims := jwt.MapClaims{ + "uri": path, + "nonce": nonce, + "iat": now, + "exp": expiration, + "sub": f.apiKey, + "bodyHash": hex.EncodeToString(hashed), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tokenString, err := token.SignedString(f.privateKey) + if err != nil { + return "", fmt.Errorf("error signing token: %w", err) + } + + return tokenString, nil +} + +// 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) + 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) + var reqBodyBytes []byte + if body != nil { + var err error + reqBodyBytes, err = json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("error marshaling request body: %w", err) + } + } + + token, err := f.signJwt(path, body, int64(f.timeout.Seconds())) + if err != nil { + return nil, fmt.Errorf("error signing JWT: %w", err) + } + + req, err := http.NewRequest(method, url, bytes.NewBuffer(reqBodyBytes)) + if err != nil { + return nil, fmt.Errorf("error creating HTTP request: %w", err) + } + + if method == "POST" { + req.Header.Set("Content-Type", "application/json") + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-API-KEY", f.apiKey) + + resp, err := f.client.Do(req) + if err != nil { + return nil, fmt.Errorf("error sending HTTP request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + err = json.Unmarshal(respBody, &errResp) + if err != nil { + return nil, fmt.Errorf("error parsing error response: %w", err) + } + return nil, fmt.Errorf("error response (%d) from Fireblocks with code %d: %s", resp.StatusCode, errResp.Code, errResp.Message) + } + + return respBody, nil +} diff --git a/chainio/clients/fireblocks/client_test.go b/chainio/clients/fireblocks/client_test.go new file mode 100644 index 00000000..e9e0f3c3 --- /dev/null +++ b/chainio/clients/fireblocks/client_test.go @@ -0,0 +1,62 @@ +package fireblocks_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/stretchr/testify/assert" +) + +const ( + sandboxAPI = "https://sandbox-api.fireblocks.io" + secretKeyPath = "FILL_ME_IN" + apiKey = "FILL_ME_IN" +) + +func TestListContracts(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) + contracts, err := c.ListContracts(context.Background()) + assert.NoError(t, err) + for _, contract := range contracts { + t.Logf("Contract: %+v", contract) + } +} + +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) + destinationAccountID := "FILL_ME_IN" + idempotenceKey := "FILL_ME_IN" + // Tests the contract call against this contract: + // https://goerli.etherscan.io/address/0x5f9ef6e1bb2acb8f592a483052b732ceb78e58ca + req := fireblocks.NewContractCallRequest( + idempotenceKey, + "ETH_TEST3", + "1", + destinationAccountID, + "0", + "0x6057361d00000000000000000000000000000000000000000000000000000000000f4240", + ) + resp, err := c.ContractCall(context.Background(), req) + assert.NoError(t, err) + t.Logf("txID: %s, status: %s", resp.ID, resp.Status) +} diff --git a/chainio/clients/fireblocks/contract_call.go b/chainio/clients/fireblocks/contract_call.go new file mode 100644 index 00000000..6ef5a721 --- /dev/null +++ b/chainio/clients/fireblocks/contract_call.go @@ -0,0 +1,78 @@ +package fireblocks + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +type account struct { + Type string `json:"type"` + ID string `json:"id"` +} + +type extraParams struct { + Calldata string `json:"contractCallData"` +} + +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"` +} + +type ContractCallResponse struct { + ID string `json:"id"` + Status string `json:"status"` +} + +func NewContractCallRequest( + externalTxID string, + assetID string, + sourceAccountID string, + destinationAccountID string, + amount string, + calldata string, +) *ContractCallRequest { + return &ContractCallRequest{ + Operation: "CONTRACT_CALL", + ExternalTxID: externalTxID, + AssetID: assetID, + Source: account{ + Type: "VAULT_ACCOUNT", + ID: sourceAccountID, + }, + // https://developers.fireblocks.com/reference/transaction-sources-destinations + Destination: account{ + Type: "EXTERNAL_WALLET", + ID: destinationAccountID, + }, + Amount: amount, + ExtraParameters: extraParams{ + Calldata: calldata, + }, + } +} + +func (f *fireblocksClient) 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 { + return nil, fmt.Errorf("error making request: %w", err) + } + var response ContractCallResponse + err = json.NewDecoder(strings.NewReader(string(res))).Decode(&response) + if err != nil { + return nil, fmt.Errorf("error parsing response body: %w", err) + } + + return &ContractCallResponse{ + ID: response.ID, + Status: response.Status, + }, nil +} diff --git a/chainio/clients/fireblocks/list_contracts.go b/chainio/clients/fireblocks/list_contracts.go new file mode 100644 index 00000000..d8586b39 --- /dev/null +++ b/chainio/clients/fireblocks/list_contracts.go @@ -0,0 +1,23 @@ +package fireblocks + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +func (f *fireblocksClient) ListContracts(ctx context.Context) ([]WhitelistedContract, error) { + var contracts []WhitelistedContract + res, err := f.makeRequest(ctx, "GET", "/v1/contracts", nil) + if err != nil { + return contracts, fmt.Errorf("error making request: %w", err) + } + body := string(res) + err = json.NewDecoder(strings.NewReader(body)).Decode(&contracts) + if err != nil { + return contracts, fmt.Errorf("error parsing response body: %s: %w", body, err) + } + + return contracts, nil +} diff --git a/chainio/clients/mocks/fireblocks.go b/chainio/clients/mocks/fireblocks.go new file mode 100644 index 00000000..376318d7 --- /dev/null +++ b/chainio/clients/mocks/fireblocks.go @@ -0,0 +1,71 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks (interfaces: FireblocksClient) +// +// Generated by this command: +// +// mockgen -destination=./chainio/clients/mocks/fireblocks.go -package=mocks github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks FireblocksClient +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + fireblocks "github.com/Layr-Labs/eigensdk-go/chainio/clients/fireblocks" + gomock "go.uber.org/mock/gomock" +) + +// MockFireblocksClient is a mock of FireblocksClient interface. +type MockFireblocksClient struct { + ctrl *gomock.Controller + recorder *MockFireblocksClientMockRecorder +} + +// MockFireblocksClientMockRecorder is the mock recorder for MockFireblocksClient. +type MockFireblocksClientMockRecorder struct { + mock *MockFireblocksClient +} + +// NewMockFireblocksClient creates a new mock instance. +func NewMockFireblocksClient(ctrl *gomock.Controller) *MockFireblocksClient { + mock := &MockFireblocksClient{ctrl: ctrl} + mock.recorder = &MockFireblocksClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFireblocksClient) EXPECT() *MockFireblocksClientMockRecorder { + return m.recorder +} + +// ContractCall mocks base method. +func (m *MockFireblocksClient) ContractCall(arg0 context.Context, arg1 *fireblocks.ContractCallRequest) (*fireblocks.ContractCallResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ContractCall", arg0, arg1) + ret0, _ := ret[0].(*fireblocks.ContractCallResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ContractCall indicates an expected call of ContractCall. +func (mr *MockFireblocksClientMockRecorder) ContractCall(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContractCall", reflect.TypeOf((*MockFireblocksClient)(nil).ContractCall), arg0, arg1) +} + +// ListContracts mocks base method. +func (m *MockFireblocksClient) ListContracts(arg0 context.Context) ([]fireblocks.WhitelistedContract, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListContracts", arg0) + ret0, _ := ret[0].([]fireblocks.WhitelistedContract) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListContracts indicates an expected call of ListContracts. +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) +} diff --git a/chainio/gen.go b/chainio/gen.go index 3e7486f7..3623b43a 100644 --- a/chainio/gen.go +++ b/chainio/gen.go @@ -7,3 +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 diff --git a/go.mod b/go.mod index c3d4be1c..ffab3429 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require github.com/stretchr/objx v0.5.0 // indirect + require ( dario.cat/mergo v1.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect @@ -43,6 +45,7 @@ require ( github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang/protobuf v1.5.3 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/holiman/uint256 v1.2.4 // indirect diff --git a/go.sum b/go.sum index bd2a4660..8c451f17 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -232,6 +234,7 @@ github.com/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobt github.com/status-im/keycard-go v0.2.0/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=