Skip to content

Commit

Permalink
[fireblocks] Add transfer method (#227)
Browse files Browse the repository at this point in the history
* fireblocks transfer

* fix error msg
  • Loading branch information
ian-shim committed May 2, 2024
1 parent feeb72b commit 501e40a
Show file tree
Hide file tree
Showing 9 changed files with 491 additions and 99 deletions.
12 changes: 11 additions & 1 deletion chainio/clients/fireblocks/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ 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
ContractCall(ctx context.Context, body *ContractCallRequest) (*ContractCallResponse, error)
ContractCall(ctx context.Context, body *TransactionRequest) (*TransactionResponse, error)
// Transfer makes a Transfer 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
Transfer(ctx context.Context, body *TransactionRequest) (*TransactionResponse, error)
// CancelTransaction makes a CancelTransaction request to the Fireblocks API
// It cancels a transaction by its transaction ID.
// It returns true if the transaction was successfully canceled.
Expand All @@ -48,6 +52,12 @@ type Client interface {
// NewContractCallRequest in a ContractCall
// ref: https://developers.fireblocks.com/reference/get_contracts
ListContracts(ctx context.Context) ([]WhitelistedContract, error)
// ListExternalWallets makes a ListExternalWallets request to the Fireblocks API
// It returns a list of external wallets for the account.
// This call is used to get the external wallet ID, which is needed as destination account ID by NewTransferRequest
// in a Transfer
// ref: https://developers.fireblocks.com/reference/get_external-wallets
ListExternalWallets(ctx context.Context) ([]WhitelistedAccount, 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)
Expand Down
34 changes: 34 additions & 0 deletions chainio/clients/fireblocks/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,29 @@ func TestContractCall(t *testing.T) {
t.Logf("txID: %s, status: %s", resp.ID, resp.Status)
}

func TestTransfer(t *testing.T) {
t.Skip("skipping test as it's meant for manual runs only")

c := newFireblocksClient(t)
destinationAccountID := "FILL_ME_IN"
req := fireblocks.NewTransferRequest(
"",
"ETH_TEST6",
"5", // source account ID
destinationAccountID,
"1", // amount
"", // replaceTxByHash
"", // gasPrice
"", // gasLimit
"", // maxFee
"", // priorityFee
fireblocks.FeeLevelHigh,
)
resp, err := c.Transfer(context.Background(), req)
assert.NoError(t, err)
t.Logf("txID: %s, status: %s", resp.ID, resp.Status)
}

func TestCancelTransaction(t *testing.T) {
t.Skip("skipping test as it's meant for manual runs only")

Expand All @@ -76,6 +99,17 @@ func TestCancelTransaction(t *testing.T) {
t.Logf("txID: %s, success: %t", txID, success)
}

func TestListExternalWallets(t *testing.T) {
t.Skip("skipping test as it's meant for manual runs only")

c := newFireblocksClient(t)
wallets, err := c.ListExternalWallets(context.Background())
assert.NoError(t, err)
for _, wallet := range wallets {
t.Logf("Wallet: %+v", wallet)
}
}

func TestListVaultAccounts(t *testing.T) {
t.Skip("skipping test as it's meant for manual runs only")

Expand Down
72 changes: 6 additions & 66 deletions chainio/clients/fireblocks/contract_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,81 +7,21 @@ import (
"strings"
)

type TransactionOperation string
type FeeLevel string

const (
ContractCall TransactionOperation = "CONTRACT_CALL"
Transfer TransactionOperation = "TRANSFER"
Mint TransactionOperation = "MINT"
Burn TransactionOperation = "BURN"
TypedMessage TransactionOperation = "TYPED_MESSAGE"
Raw TransactionOperation = "RAW"

FeeLevelHigh FeeLevel = "HIGH"
FeeLevelMedium FeeLevel = "MEDIUM"
FeeLevelLow FeeLevel = "LOW"
)

type account struct {
Type string `json:"type"`
ID string `json:"id"`
}

type extraParams struct {
Calldata string `json:"contractCallData"`
}

type ContractCallRequest struct {
Operation TransactionOperation `json:"operation"`
// ExternalTxID is an optional field that can be used as an idempotency key.
ExternalTxID string `json:"externalTxId,omitempty"`
AssetID AssetID `json:"assetId"`
Source account `json:"source"`
Destination account `json:"destination"`
Amount string `json:"amount,omitempty"`
ExtraParameters extraParams `json:"extraParameters"`
// In case a transaction is stuck, specify the hash of the stuck transaction to replace it
// by this transaction with a higher fee, or to replace it with this transaction with a zero fee and drop it from
// the blockchain.
ReplaceTxByHash string `json:"replaceTxByHash,omitempty"`
// GasPrice and GasLimit are the gas price and gas limit for the transaction.
// If GasPrice is specified (non-1559), MaxFee and PriorityFee are not required.
GasPrice string `json:"gasPrice,omitempty"`
GasLimit string `json:"gasLimit,omitempty"`
// MaxFee and PriorityFee are the maximum and priority fees for the transaction.
// If the transaction is stuck, the Fireblocks platform will replace the transaction with a new one with a higher
// fee.
// These fields are required if FeeLevel is not specified.
MaxFee string `json:"maxFee,omitempty"`
PriorityFee string `json:"priorityFee,omitempty"`
// FeeLevel is the fee level for the transaction which Fireblocks estimates based on the current network conditions.
// The fee level can be HIGH, MEDIUM, or LOW.
// If MaxFee and PriorityFee are not specified, the Fireblocks platform will use the default fee level MEDIUM.
// Ref: https://developers.fireblocks.com/docs/gas-estimation#estimated-network-fee
FeeLevel FeeLevel `json:"feeLevel,omitempty"`
}

type ContractCallResponse struct {
ID string `json:"id"`
Status TxStatus `json:"status"`
}

func NewContractCallRequest(
externalTxID string,
assetID AssetID,
sourceAccountID string,
destinationAccountID string,
amount string,
amount string, // amount in ETH
calldata string,
replaceTxByHash string,
gasPrice string,
gasLimit string,
maxFee string,
priorityFee string,
feeLevel FeeLevel,
) *ContractCallRequest {
req := &ContractCallRequest{
) *TransactionRequest {
req := &TransactionRequest{
Operation: ContractCall,
ExternalTxID: externalTxID,
AssetID: assetID,
Expand Down Expand Up @@ -114,19 +54,19 @@ func NewContractCallRequest(
return req
}

func (f *client) ContractCall(ctx context.Context, req *ContractCallRequest) (*ContractCallResponse, error) {
func (f *client) ContractCall(ctx context.Context, req *TransactionRequest) (*TransactionResponse, 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
var response TransactionResponse
err = json.NewDecoder(strings.NewReader(string(res))).Decode(&response)
if err != nil {
return nil, fmt.Errorf("error parsing response body: %w", err)
}

return &ContractCallResponse{
return &TransactionResponse{
ID: response.ID,
Status: response.Status,
}, nil
Expand Down
38 changes: 38 additions & 0 deletions chainio/clients/fireblocks/list_external_accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package fireblocks

import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/ethereum/go-ethereum/common"
)

type WhitelistedAccount struct {
ID string `json:"id"`
Name string `json:"name"`
Assets []struct {
ID AssetID `json:"id"`
Balance string `json:"balance"`
LockedAmount string `json:"lockedAmount"`
Status string `json:"status"`
Address common.Address `json:"address"`
Tag string `json:"tag"`
} `json:"assets"`
}

func (f *client) ListExternalWallets(ctx context.Context) ([]WhitelistedAccount, error) {
var accounts []WhitelistedAccount
res, err := f.makeRequest(ctx, "GET", "/v1/external_wallets", nil)
if err != nil {
return accounts, fmt.Errorf("error making request: %w", err)
}
body := string(res)
err = json.NewDecoder(strings.NewReader(body)).Decode(&accounts)
if err != nil {
return accounts, fmt.Errorf("error parsing response body: %s: %w", body, err)
}

return accounts, nil
}
61 changes: 61 additions & 0 deletions chainio/clients/fireblocks/transaction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package fireblocks

type TransactionOperation string
type FeeLevel string

const (
ContractCall TransactionOperation = "CONTRACT_CALL"
Transfer TransactionOperation = "TRANSFER"
Mint TransactionOperation = "MINT"
Burn TransactionOperation = "BURN"
TypedMessage TransactionOperation = "TYPED_MESSAGE"
Raw TransactionOperation = "RAW"

FeeLevelHigh FeeLevel = "HIGH"
FeeLevelMedium FeeLevel = "MEDIUM"
FeeLevelLow FeeLevel = "LOW"
)

type account struct {
Type string `json:"type"`
ID string `json:"id"`
}

type extraParams struct {
Calldata string `json:"contractCallData"`
}

type TransactionRequest struct {
Operation TransactionOperation `json:"operation"`
// ExternalTxID is an optional field that can be used as an idempotency key.
ExternalTxID string `json:"externalTxId,omitempty"`
AssetID AssetID `json:"assetId"`
Source account `json:"source"`
Destination account `json:"destination"`
Amount string `json:"amount,omitempty"`
ExtraParameters extraParams `json:"extraParameters"`
// In case a transaction is stuck, specify the hash of the stuck transaction to replace it
// by this transaction with a higher fee, or to replace it with this transaction with a zero fee and drop it from
// the blockchain.
ReplaceTxByHash string `json:"replaceTxByHash,omitempty"`
// GasPrice and GasLimit are the gas price and gas limit for the transaction.
// If GasPrice is specified (non-1559), MaxFee and PriorityFee are not required.
GasPrice string `json:"gasPrice,omitempty"`
GasLimit string `json:"gasLimit,omitempty"`
// MaxFee and PriorityFee are the maximum and priority fees for the transaction.
// If the transaction is stuck, the Fireblocks platform will replace the transaction with a new one with a higher
// fee.
// These fields are required if FeeLevel is not specified.
MaxFee string `json:"maxFee,omitempty"`
PriorityFee string `json:"priorityFee,omitempty"`
// FeeLevel is the fee level for the transaction which Fireblocks estimates based on the current network conditions.
// The fee level can be HIGH, MEDIUM, or LOW.
// If MaxFee and PriorityFee are not specified, the Fireblocks platform will use the default fee level MEDIUM.
// Ref: https://developers.fireblocks.com/docs/gas-estimation#estimated-network-fee
FeeLevel FeeLevel `json:"feeLevel,omitempty"`
}

type TransactionResponse struct {
ID string `json:"id"`
Status TxStatus `json:"status"`
}
69 changes: 69 additions & 0 deletions chainio/clients/fireblocks/transfer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package fireblocks

import (
"context"
"encoding/json"
"fmt"
"strings"
)

func NewTransferRequest(
externalTxID string,
assetID AssetID,
sourceAccountID string,
destinationAccountID string,
amount string, // amount in ETH
replaceTxByHash string,
gasPrice string,
gasLimit string,
maxFee string,
priorityFee string,
feeLevel FeeLevel,
) *TransactionRequest {
req := &TransactionRequest{
Operation: Transfer,
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,
ReplaceTxByHash: replaceTxByHash,
GasLimit: gasLimit,
}

if maxFee != "" && priorityFee != "" {
req.MaxFee = maxFee
req.PriorityFee = priorityFee
} else if gasPrice != "" {
req.GasPrice = gasPrice
} else {
req.FeeLevel = feeLevel
}

return req
}

func (f *client) Transfer(ctx context.Context, req *TransactionRequest) (*TransactionResponse, error) {
f.logger.Debug("Fireblocks transfer", "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 TransactionResponse
err = json.NewDecoder(strings.NewReader(string(res))).Decode(&response)
if err != nil {
return nil, fmt.Errorf("error parsing response body: %w", err)
}

return &TransactionResponse{
ID: response.ID,
Status: response.Status,
}, nil
}
Loading

0 comments on commit 501e40a

Please sign in to comment.