diff --git a/chainio/clients/fireblocks/client.go b/chainio/clients/fireblocks/client.go index daa19bd0..6e52fd20 100644 --- a/chainio/clients/fireblocks/client.go +++ b/chainio/clients/fireblocks/client.go @@ -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. @@ -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) diff --git a/chainio/clients/fireblocks/client_test.go b/chainio/clients/fireblocks/client_test.go index 742ce987..bb911ac3 100644 --- a/chainio/clients/fireblocks/client_test.go +++ b/chainio/clients/fireblocks/client_test.go @@ -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") @@ -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") diff --git a/chainio/clients/fireblocks/contract_call.go b/chainio/clients/fireblocks/contract_call.go index 975d6e99..83e79245 100644 --- a/chainio/clients/fireblocks/contract_call.go +++ b/chainio/clients/fireblocks/contract_call.go @@ -7,72 +7,12 @@ 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, @@ -80,8 +20,8 @@ func NewContractCallRequest( maxFee string, priorityFee string, feeLevel FeeLevel, -) *ContractCallRequest { - req := &ContractCallRequest{ +) *TransactionRequest { + req := &TransactionRequest{ Operation: ContractCall, ExternalTxID: externalTxID, AssetID: assetID, @@ -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 diff --git a/chainio/clients/fireblocks/list_external_accounts.go b/chainio/clients/fireblocks/list_external_accounts.go new file mode 100644 index 00000000..ac3715ea --- /dev/null +++ b/chainio/clients/fireblocks/list_external_accounts.go @@ -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 +} diff --git a/chainio/clients/fireblocks/transaction.go b/chainio/clients/fireblocks/transaction.go new file mode 100644 index 00000000..7aa510aa --- /dev/null +++ b/chainio/clients/fireblocks/transaction.go @@ -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"` +} diff --git a/chainio/clients/fireblocks/transfer.go b/chainio/clients/fireblocks/transfer.go new file mode 100644 index 00000000..a1f70d22 --- /dev/null +++ b/chainio/clients/fireblocks/transfer.go @@ -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 +} diff --git a/chainio/clients/mocks/fireblocks.go b/chainio/clients/mocks/fireblocks.go index 39da09f4..42a8c94f 100644 --- a/chainio/clients/mocks/fireblocks.go +++ b/chainio/clients/mocks/fireblocks.go @@ -56,10 +56,10 @@ func (mr *MockFireblocksClientMockRecorder) CancelTransaction(arg0, arg1 any) *g } // ContractCall mocks base method. -func (m *MockFireblocksClient) ContractCall(arg0 context.Context, arg1 *fireblocks.ContractCallRequest) (*fireblocks.ContractCallResponse, error) { +func (m *MockFireblocksClient) ContractCall(arg0 context.Context, arg1 *fireblocks.TransactionRequest) (*fireblocks.TransactionResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ContractCall", arg0, arg1) - ret0, _ := ret[0].(*fireblocks.ContractCallResponse) + ret0, _ := ret[0].(*fireblocks.TransactionResponse) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -115,6 +115,21 @@ func (mr *MockFireblocksClientMockRecorder) ListContracts(arg0 any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListContracts", reflect.TypeOf((*MockFireblocksClient)(nil).ListContracts), arg0) } +// ListExternalWallets mocks base method. +func (m *MockFireblocksClient) ListExternalWallets(arg0 context.Context) ([]fireblocks.WhitelistedAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListExternalWallets", arg0) + ret0, _ := ret[0].([]fireblocks.WhitelistedAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListExternalWallets indicates an expected call of ListExternalWallets. +func (mr *MockFireblocksClientMockRecorder) ListExternalWallets(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListExternalWallets", reflect.TypeOf((*MockFireblocksClient)(nil).ListExternalWallets), arg0) +} + // ListVaultAccounts mocks base method. func (m *MockFireblocksClient) ListVaultAccounts(arg0 context.Context) ([]fireblocks.VaultAccount, error) { m.ctrl.T.Helper() @@ -129,3 +144,18 @@ func (mr *MockFireblocksClientMockRecorder) ListVaultAccounts(arg0 any) *gomock. mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListVaultAccounts", reflect.TypeOf((*MockFireblocksClient)(nil).ListVaultAccounts), arg0) } + +// Transfer mocks base method. +func (m *MockFireblocksClient) Transfer(arg0 context.Context, arg1 *fireblocks.TransactionRequest) (*fireblocks.TransactionResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Transfer", arg0, arg1) + ret0, _ := ret[0].(*fireblocks.TransactionResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Transfer indicates an expected call of Transfer. +func (mr *MockFireblocksClientMockRecorder) Transfer(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transfer", reflect.TypeOf((*MockFireblocksClient)(nil).Transfer), arg0, arg1) +} diff --git a/chainio/clients/wallet/fireblocks_wallet.go b/chainio/clients/wallet/fireblocks_wallet.go index cd3ccc51..e550a35e 100644 --- a/chainio/clients/wallet/fireblocks_wallet.go +++ b/chainio/clients/wallet/fireblocks_wallet.go @@ -51,6 +51,7 @@ type fireblocksWallet struct { // caches account *fireblocks.VaultAccount whitelistedContracts map[common.Address]*fireblocks.WhitelistedContract + whitelistedAccounts map[common.Address]*fireblocks.WhitelistedAccount } func NewFireblocksWallet( @@ -77,6 +78,7 @@ func NewFireblocksWallet( // caches account: nil, whitelistedContracts: make(map[common.Address]*fireblocks.WhitelistedContract), + whitelistedAccounts: make(map[common.Address]*fireblocks.WhitelistedAccount), }, nil } @@ -96,6 +98,37 @@ func (t *fireblocksWallet) getAccount(ctx context.Context) (*fireblocks.VaultAcc return t.account, nil } +func (f *fireblocksWallet) getWhitelistedAccount( + ctx context.Context, + address common.Address, +) (*fireblocks.WhitelistedAccount, error) { + assetID, ok := fireblocks.AssetIDByChain[f.chainID.Uint64()] + if !ok { + return nil, fmt.Errorf("unsupported chain %d", f.chainID.Uint64()) + } + whitelistedAccount, ok := f.whitelistedAccounts[address] + if !ok { + accounts, err := f.fireblocksClient.ListExternalWallets(ctx) + if err != nil { + return nil, fmt.Errorf("error listing external wallets: %w", err) + } + for _, a := range accounts { + for _, asset := range a.Assets { + if asset.Address == address && asset.Status == "APPROVED" && asset.ID == assetID { + f.whitelistedAccounts[address] = &a + whitelistedAccount = &a + return whitelistedAccount, nil + } + } + } + } + + if whitelistedAccount == nil { + return nil, fmt.Errorf("account %s not found in whitelisted accounts", address.Hex()) + } + return whitelistedAccount, nil +} + func (t *fireblocksWallet) getWhitelistedContract( ctx context.Context, address common.Address, @@ -150,11 +183,6 @@ func (t *fireblocksWallet) SendTransaction(ctx context.Context, tx *types.Transa 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) - } - t.mu.Lock() defer t.mu.Unlock() // if the nonce is already in the map, it means that the transaction was already submitted @@ -191,23 +219,52 @@ func (t *fireblocksWallet) SendTransaction(ctx context.Context, tx *types.Transa feeLevel = fireblocks.FeeLevelHigh } - req := fireblocks.NewContractCallRequest( - "", // externalTxID - assetID, - account.ID, // source account ID - contract.ID, // destination account ID - tx.Value().String(), // amount - hexutil.Encode(tx.Data()), // calldata - replaceTxByHash, // replaceTxByHash - gasPrice, - gasLimit, - maxFee, - priorityFee, - feeLevel, - ) - res, err := t.fireblocksClient.ContractCall(ctx, req) + var res *fireblocks.TransactionResponse + if len(tx.Data()) == 0 && tx.Value().Cmp(big.NewInt(0)) > 0 { + targetAccount, clientErr := t.getWhitelistedAccount(ctx, *tx.To()) + if clientErr != nil { + return "", fmt.Errorf("error getting whitelisted account %s: %w", tx.To().Hex(), clientErr) + } + req := fireblocks.NewTransferRequest( + "", // externalTxID + assetID, + account.ID, // source account ID + targetAccount.ID, // destination account ID + weiToEther(tx.Value()).String(), // amount in ETH + replaceTxByHash, // replaceTxByHash + gasPrice, + gasLimit, + maxFee, + priorityFee, + feeLevel, + ) + res, err = t.fireblocksClient.Transfer(ctx, req) + } else if len(tx.Data()) > 0 { + contract, clientErr := t.getWhitelistedContract(ctx, *tx.To()) + if clientErr != nil { + return "", fmt.Errorf("error getting whitelisted contract %s: %w", tx.To().Hex(), clientErr) + } + req := fireblocks.NewContractCallRequest( + "", // externalTxID + assetID, + account.ID, // source account ID + contract.ID, // destination account ID + weiToEther(tx.Value()).String(), // amount + hexutil.Encode(tx.Data()), // calldata + replaceTxByHash, // replaceTxByHash + gasPrice, + gasLimit, + maxFee, + priorityFee, + feeLevel, + ) + res, err = t.fireblocksClient.ContractCall(ctx, req) + } else { + return "", errors.New("transaction has no value and no data") + } + if err != nil { - return "", fmt.Errorf("error calling contract %s: %w", tx.To().Hex(), err) + return "", fmt.Errorf("error sending a transaction %s: %w", tx.To().Hex(), err) } t.nonceToTxID[nonce] = res.ID t.txIDToNonce[res.ID] = nonce @@ -289,3 +346,7 @@ func (f *fireblocksWallet) SenderAddress(ctx context.Context) (common.Address, e func weiToGwei(wei *big.Int) *big.Float { return new(big.Float).Quo(new(big.Float).SetInt(wei), big.NewFloat(params.GWei)) } + +func weiToEther(wei *big.Int) *big.Float { + return new(big.Float).Quo(new(big.Float).SetInt(wei), big.NewFloat(params.Ether)) +} diff --git a/chainio/clients/wallet/fireblocks_wallet_test.go b/chainio/clients/wallet/fireblocks_wallet_test.go index 43dc2e22..f07ecba5 100644 --- a/chainio/clients/wallet/fireblocks_wallet_test.go +++ b/chainio/clients/wallet/fireblocks_wallet_test.go @@ -20,6 +20,7 @@ import ( const ( vaultAccountName = "batcher" contractAddress = "0x5f9ef6e1bb2acb8f592a483052b732ceb78e58ca" + externalAccount = "0x1111111111111111111111111111111111111111" ) func TestSendTransaction(t *testing.T) { @@ -51,7 +52,7 @@ func TestSendTransaction(t *testing.T) { }, }, }, nil) - fireblocksClient.EXPECT().ContractCall(gomock.Any(), gomock.Any()).Return(&fireblocks.ContractCallResponse{ + fireblocksClient.EXPECT().ContractCall(gomock.Any(), gomock.Any()).Return(&fireblocks.TransactionResponse{ ID: "1234", Status: fireblocks.Confirming, }, nil) @@ -76,7 +77,95 @@ func TestSendTransaction(t *testing.T) { big.NewInt(0), // value 100000, // gas big.NewInt(100), // gasPrice - common.Hex2Bytes("0x6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data + common.Hex2Bytes("6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data + )) + assert.NoError(t, err) + assert.Equal(t, "1234", txID) +} + +func TestTransfer(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 := wallet.NewFireblocksWallet(fireblocksClient, ethClient, vaultAccountName, logger) + assert.NoError(t, err) + + fireblocksClient.EXPECT().ListExternalWallets(gomock.Any()).Return([]fireblocks.WhitelistedAccount{ + { + ID: "accountID", + Name: "Test Account", + Assets: []struct { + ID fireblocks.AssetID `json:"id"` + Balance string `json:"balance"` + LockedAmount string `json:"lockedAmount"` + Status string `json:"status"` + Address common.Address `json:"address"` + Tag string `json:"tag"` + }{{ + ID: "ETH_TEST3", + Balance: "", + LockedAmount: "", + Status: "APPROVED", + Address: common.HexToAddress(externalAccount), + Tag: "", + }, + }, + }, + }, nil) + fireblocksClient.EXPECT().Transfer(gomock.Any(), &fireblocks.TransactionRequest{ + Operation: fireblocks.Transfer, + ExternalTxID: "", + AssetID: "ETH_TEST3", + Source: struct { + Type string `json:"type"` + ID string `json:"id"` + }{ + Type: "VAULT_ACCOUNT", + ID: "vaultAccountID", + }, + Destination: struct { + Type string `json:"type"` + ID string `json:"id"` + }{ + Type: "EXTERNAL_WALLET", + ID: "accountID", + }, + Amount: "1", + ReplaceTxByHash: "", + GasPrice: "", + GasLimit: "100000", + MaxFee: "1e-07", + PriorityFee: "1e-07", + }).Return(&fireblocks.TransactionResponse{ + 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(externalAccount), // to + big.NewInt(0).Exp(big.NewInt(10), big.NewInt(18), nil), // value 1 ETH + 100000, // gas + big.NewInt(100), // gasPrice + []byte{}, // data )) assert.NoError(t, err) assert.Equal(t, "1234", txID) @@ -132,7 +221,67 @@ func TestSendTransactionNoValidContract(t *testing.T) { big.NewInt(0), // value 100000, // gas big.NewInt(100), // gasPrice - common.Hex2Bytes("0x6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data + common.Hex2Bytes("6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data + )) + assert.Error(t, err) + assert.Equal(t, "", txID) +} + +func TestSendTransactionNoValidExternalAccount(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 := wallet.NewFireblocksWallet(fireblocksClient, ethClient, vaultAccountName, logger) + assert.NoError(t, err) + + fireblocksClient.EXPECT().ListExternalWallets(gomock.Any()).Return([]fireblocks.WhitelistedAccount{ + { + ID: "accountID", + Name: "TestAccount", + Assets: []struct { + ID fireblocks.AssetID `json:"id"` + Balance string `json:"balance"` + LockedAmount string `json:"lockedAmount"` + Status string `json:"status"` + Address common.Address `json:"address"` + Tag string `json:"tag"` + }{{ + ID: "ETH_TEST123123", // wrong asset ID + Balance: "", + LockedAmount: "", + Status: "APPROVED", + Address: common.HexToAddress(externalAccount), + 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(externalAccount), // to + big.NewInt(0).Exp(big.NewInt(10), big.NewInt(18), nil), // value 1 ETH + 100000, // gas + big.NewInt(100), // gasPrice + []byte{}, // data )) assert.Error(t, err) assert.Equal(t, "", txID) @@ -170,7 +319,7 @@ func TestSendTransactionInvalidVault(t *testing.T) { big.NewInt(0), // value 100000, // gas big.NewInt(100), // gasPrice - common.Hex2Bytes("0x6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data + common.Hex2Bytes("6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data )) assert.Error(t, err) assert.Equal(t, "", txID) @@ -205,7 +354,7 @@ func TestSendTransactionReplaceTx(t *testing.T) { }, }, }, nil) - fireblocksClient.EXPECT().ContractCall(gomock.Any(), gomock.Any()).Return(&fireblocks.ContractCallResponse{ + fireblocksClient.EXPECT().ContractCall(gomock.Any(), gomock.Any()).Return(&fireblocks.TransactionResponse{ ID: "1234", Status: fireblocks.Confirming, }, nil) @@ -230,7 +379,7 @@ func TestSendTransactionReplaceTx(t *testing.T) { big.NewInt(0), // value 100000, // gas big.NewInt(100), // gasPrice - common.Hex2Bytes("0x6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data + common.Hex2Bytes("6057361d00000000000000000000000000000000000000000000000000000000000f4240"), // data )) assert.NoError(t, err) assert.Equal(t, "1234", txID) @@ -244,7 +393,7 @@ func TestSendTransactionReplaceTx(t *testing.T) { GasTipCap: big.NewInt(1_000_000_000), Gas: gasLimit, Value: big.NewInt(0), - Data: common.Hex2Bytes("0x6057361d00000000000000000000000000000000000000000000000000000000000f4240"), + Data: common.Hex2Bytes("6057361d00000000000000000000000000000000000000000000000000000000000f4240"), } replacementTx := types.NewTx(baseTx) expectedTxHash := "0xdeadbeef" @@ -259,14 +408,14 @@ func TestSendTransactionReplaceTx(t *testing.T) { "vaultAccountID", "contractID", "0", - "0x", + "0x6057361d00000000000000000000000000000000000000000000000000000000000f4240", expectedTxHash, "", // gasPrice "1000000", // gasLimit "10", // maxFee "1", // priorityFee "", // feeLevel - )).Return(&fireblocks.ContractCallResponse{ + )).Return(&fireblocks.TransactionResponse{ ID: "5678", Status: fireblocks.Confirming, }, nil)