-
Notifications
You must be signed in to change notification settings - Fork 58
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
9 changed files
with
421 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.