Skip to content

Commit

Permalink
fireblocks client
Browse files Browse the repository at this point in the history
  • Loading branch information
ian-shim committed Feb 22, 2024
1 parent 733f6f0 commit 739875a
Show file tree
Hide file tree
Showing 9 changed files with 421 additions and 0 deletions.
11 changes: 11 additions & 0 deletions chainio/clients/clients_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package clients_test

import (
"os"
"testing"
)

func TestMain(m *testing.M) {
code := m.Run()
os.Exit(code)
}
169 changes: 169 additions & 0 deletions chainio/clients/fireblocks/client.go
Original file line number Diff line number Diff line change
@@ -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
}
62 changes: 62 additions & 0 deletions chainio/clients/fireblocks/client_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
78 changes: 78 additions & 0 deletions chainio/clients/fireblocks/contract_call.go
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 23 additions & 0 deletions chainio/clients/fireblocks/list_contracts.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 739875a

Please sign in to comment.