From 52ee5317b3441a2d6f628e1ffea04a71d98ae53e Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Mon, 7 Jul 2025 09:56:16 +0100 Subject: [PATCH 1/2] backend: add blockbook/etherscan wrapper. --- backend/backend.go | 16 +- backend/coins/eth/blockbook/blockbook.go | 163 ++++++++++++++++++ .../coins/eth/rpcclient/wrapper/wrapper.go | 146 ++++++++++++++++ 3 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 backend/coins/eth/blockbook/blockbook.go create mode 100644 backend/coins/eth/rpcclient/wrapper/wrapper.go diff --git a/backend/backend.go b/backend/backend.go index 9374490fe2..0da80756d4 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -38,7 +38,9 @@ import ( "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/btc/types" coinpkg "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin" "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth" + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/blockbook" "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/etherscan" + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/rpcclient/wrapper" "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/ltc" "github.com/BitBoxSwiss/bitbox-wallet-app/backend/config" "github.com/BitBoxSwiss/bitbox-wallet-app/backend/devices/bitbox" @@ -226,6 +228,7 @@ type Backend struct { // can be a regular or, if Tor is enabled in the config, a SOCKS5 proxy client. httpClient *http.Client etherScanHTTPClient *http.Client + blockbookHTTPClient *http.Client ratesUpdater *rates.RateUpdater banners *banners.Banners @@ -291,6 +294,7 @@ func NewBackend(arguments *arguments.Arguments, environment Environment) (*Backe backend.socksProxy = backendProxy backend.httpClient = hclient backend.etherScanHTTPClient = hclient + backend.blockbookHTTPClient = hclient ratesCache := filepath.Join(arguments.CacheDirectoryPath(), "exchangerates") if err := os.MkdirAll(ratesCache, 0700); err != nil { @@ -527,9 +531,11 @@ func (backend *Backend) Coin(code coinpkg.Code) (coinpkg.Coin, error) { "https://blockchair.com/litecoin/transaction/", backend.socksProxy) case code == coinpkg.CodeETH: etherScan := etherscan.NewEtherScan("1", backend.etherScanHTTPClient) - coin = eth.NewCoin(etherScan, code, "Ethereum", "ETH", "ETH", params.MainnetChainConfig, + blockBook := blockbook.NewBlockbook("1", backend.blockbookHTTPClient) + rpcWrapper := wrapper.NewClient(blockBook, etherScan) + coin = eth.NewCoin(rpcWrapper, code, "Ethereum", "ETH", "ETH", params.MainnetChainConfig, "https://etherscan.io/tx/", - etherScan, + rpcWrapper, nil) case code == coinpkg.CodeSEPETH: etherScan := etherscan.NewEtherScan("11155111", backend.etherScanHTTPClient) @@ -539,9 +545,11 @@ func (backend *Backend) Coin(code coinpkg.Code) (coinpkg.Coin, error) { nil) case erc20Token != nil: etherScan := etherscan.NewEtherScan("1", backend.etherScanHTTPClient) - coin = eth.NewCoin(etherScan, erc20Token.code, erc20Token.name, erc20Token.unit, "ETH", params.MainnetChainConfig, + blockBook := blockbook.NewBlockbook("1", backend.blockbookHTTPClient) + rpcWrapper := wrapper.NewClient(blockBook, etherScan) + coin = eth.NewCoin(rpcWrapper, erc20Token.code, erc20Token.name, erc20Token.unit, "ETH", params.MainnetChainConfig, "https://etherscan.io/tx/", - etherScan, + rpcWrapper, erc20Token.token, ) default: diff --git a/backend/coins/eth/blockbook/blockbook.go b/backend/coins/eth/blockbook/blockbook.go new file mode 100644 index 0000000000..c364b43cca --- /dev/null +++ b/backend/coins/eth/blockbook/blockbook.go @@ -0,0 +1,163 @@ +// Copyright 2025 Shift Crypto AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package blockbook + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "path" + + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts" + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/erc20" + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/rpcclient" + ethtypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/types" + "github.com/BitBoxSwiss/bitbox-wallet-app/util/errp" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "golang.org/x/time/rate" +) + +// callsPerSec is the number of blockbook requests allowed +// per second. +// TODO bznein determine a better value for this. +const callsPerSec = 3.8 + +// Blockbook is a rate-limited blockbook api client. +type Blockbook struct { + url string + httpClient *http.Client + limiter *rate.Limiter +} + +// NewBlockbook creates a new instance of EtherScan. +func NewBlockbook(chainId string, httpClient *http.Client) *Blockbook { + // TODO chainID is not used here, do we have blockbook running for SEPETH as well? + return &Blockbook{ + url: "https://bb1.shiftcrypto.io/api/", + httpClient: httpClient, + limiter: rate.NewLimiter(rate.Limit(callsPerSec), 1), + } +} + +func (blockbook *Blockbook) call(ctx context.Context, handler string, params url.Values, result interface{}) error { + if err := blockbook.limiter.Wait(ctx); err != nil { + return errp.WithStack(err) + } + + reqUrl := blockbook.url + handler + "?" + params.Encode() + + response, err := blockbook.httpClient.Get(reqUrl) + if err != nil { + return errp.WithStack(err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return errp.Newf("expected 200 OK, got %d", response.StatusCode) + } + body, err := io.ReadAll(response.Body) + if err != nil { + return errp.WithStack(err) + } + if err := json.Unmarshal(body, result); err != nil { + return errp.Newf("unexpected response from blockbook: %s", string(body)) + } + + return nil +} + +func (blockbook *Blockbook) address(ctx context.Context, account common.Address, result interface{}) error { + params := url.Values{} + address := account.Hex() + + if err := blockbook.call(ctx, path.Join("address", address), params, result); err != nil { + return errp.WithStack(err) + } + + return nil +} + +// Balance implements rpc.Interface. +func (blockbook *Blockbook) Balance(ctx context.Context, account common.Address) (*big.Int, error) { + result := struct { + Balance string `json:"balance"` + }{} + + if err := blockbook.address(ctx, account, &result); err != nil { + return nil, errp.WithStack(err) + } + + balance, ok := new(big.Int).SetString(result.Balance, 10) + if !ok { + return nil, errp.Newf("could not parse balance %q", result.Balance) + } + return balance, nil + +} + +// BlockNumber implements rpc.Interface. +func (blockbook *Blockbook) BlockNumber(ctx context.Context) (*big.Int, error) { + return nil, fmt.Errorf("Not yet implemented") +} + +// PendingNonceAt implements rpc.Interface. +func (blockbook *Blockbook) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) { + return 0, fmt.Errorf("Not yet implemented") +} + +// Transactions implement TransactionSource. +func (blockbook *Blockbook) Transactions(blockTipHeight *big.Int, address common.Address, endBlock *big.Int, erc20Token *erc20.Token) ([]*accounts.TransactionData, error) { + return nil, fmt.Errorf("Not yet implemented") +} + +// SendTransaction implements rpc.Interface. +func (blockbook *Blockbook) SendTransaction(ctx context.Context, tx *types.Transaction) error { + return fmt.Errorf("Not yet implemented") +} + +// ERC20Balance implements rpc.Interface. +func (blockbook *Blockbook) ERC20Balance(account common.Address, erc20Token *erc20.Token) (*big.Int, error) { + return nil, fmt.Errorf("Not yet implemented") +} + +// EstimateGas implements rpc.Interface. +func (blockbook *Blockbook) EstimateGas(ctx context.Context, call ethereum.CallMsg) (gas uint64, err error) { + return 0, fmt.Errorf("Not yet implemented") +} + +// FeeTargets implements rpc.Interface. +func (blockbook *Blockbook) FeeTargets(ctx context.Context) ([]*ethtypes.FeeTarget, error) { + return nil, fmt.Errorf("Not yet implemented") +} + +// SuggestGasPrice implements rpc.Interface. +func (blockbook *Blockbook) SuggestGasPrice(ctx context.Context) (*big.Int, error) { + return nil, fmt.Errorf("Not yet implemented") +} + +// TransactionByHash implements rpc.Interface. +func (blockbook *Blockbook) TransactionByHash(ctx context.Context, hash common.Hash) (tx *types.Transaction, isPending bool, err error) { + return nil, false, fmt.Errorf("Not yet implemented") +} + +// TransactionReceiptWithBlockNumber implements rpcclient.Interface. +func (blockbook *Blockbook) TransactionReceiptWithBlockNumber(ctx context.Context, hash common.Hash) (*rpcclient.RPCTransactionReceipt, error) { + return nil, fmt.Errorf("Not yet implemented") +} diff --git a/backend/coins/eth/rpcclient/wrapper/wrapper.go b/backend/coins/eth/rpcclient/wrapper/wrapper.go new file mode 100644 index 0000000000..1a3308a4bc --- /dev/null +++ b/backend/coins/eth/rpcclient/wrapper/wrapper.go @@ -0,0 +1,146 @@ +package wrapper + +import ( + "context" + "math/big" + + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts" + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth" + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/rpcclient" + + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/erc20" + ethtypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/types" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +type rpcClientWithTransactionsSource interface { + eth.TransactionsSource + rpcclient.Interface +} + +type client struct { + defaultRpcClient rpcClientWithTransactionsSource + fallbackRpcClient rpcClientWithTransactionsSource + + // TODO do we maybe want to add some fields to keep track + // of which one we are using? if we switch to the fallback one + // it might make sense to use that for a while instead of wasting + // time each time trying the default first and falling back to the + // other one later. +} + +// NewClient creates a new client that uses the default RPC client unless it fails, +// in which case it falls back to the fallback RPC client. +func NewClient(defaultRPCClient, fallbackRPCClient rpcClientWithTransactionsSource) *client { + return &client{ + defaultRpcClient: defaultRPCClient, + fallbackRpcClient: fallbackRPCClient, + } +} + +// Transactions queries either the default or the fallback endpoint for transactions for the given account, until endBlock. +// Provide erc20Token to filter for those. If nil, standard etheruem transactions will be fetched. +func (c *client) Transactions( + blockTipHeight *big.Int, + address common.Address, endBlock *big.Int, erc20Token *erc20.Token) ( + []*accounts.TransactionData, error) { + res, err := c.defaultRpcClient.Transactions(blockTipHeight, address, endBlock, erc20Token) + if err != nil { + return c.fallbackRpcClient.Transactions(blockTipHeight, address, endBlock, erc20Token) + } + return res, nil +} + +// TransactionReceiptWithBlockNumber implements rpcclient.Interface. +func (c *client) TransactionReceiptWithBlockNumber(ctx context.Context, hash common.Hash) (*rpcclient.RPCTransactionReceipt, error) { + res, err := c.defaultRpcClient.TransactionReceiptWithBlockNumber(ctx, hash) + if err != nil { + return c.fallbackRpcClient.TransactionReceiptWithBlockNumber(ctx, hash) + } + return res, nil +} + +// BlockNumber implements rpcclient.Interface. +func (c *client) BlockNumber(ctx context.Context) (*big.Int, error) { + res, err := c.defaultRpcClient.BlockNumber(ctx) + if err != nil { + return c.fallbackRpcClient.BlockNumber(ctx) + } + return res, nil +} + +// TransactionByHash implements rpcclient.Interface. +// TODO the error here is used by the caller to determine whether the tx has been found or not, so +// right now it wouldn't work using it for our fallback logic. Need to refactor this. +func (c *client) TransactionByHash(ctx context.Context, hash common.Hash) (*types.Transaction, bool, error) { + res, pending, err := c.defaultRpcClient.TransactionByHash(ctx, hash) + if err != nil { + return c.fallbackRpcClient.TransactionByHash(ctx, hash) + } + return res, pending, nil +} + +// Balance implements rpcclient.Interface. +func (c *client) Balance(ctx context.Context, account common.Address) (*big.Int, error) { + res, err := c.defaultRpcClient.Balance(ctx, account) + if err != nil { + return c.fallbackRpcClient.Balance(ctx, account) + } + return res, nil +} + +// ERC20Balance implements rpcclient.Interface. +func (c *client) ERC20Balance(account common.Address, erc20Token *erc20.Token) (*big.Int, error) { + res, err := c.defaultRpcClient.ERC20Balance(account, erc20Token) + if err != nil { + return c.fallbackRpcClient.ERC20Balance(account, erc20Token) + } + return res, nil +} + +// SendTransaction implements rpcclient.Interface. +func (c *client) SendTransaction(ctx context.Context, tx *types.Transaction) error { + err := c.defaultRpcClient.SendTransaction(ctx, tx) + if err != nil { + return c.fallbackRpcClient.SendTransaction(ctx, tx) + } + return nil +} + +// PendingNonceAt implements rpcclient.Interface. +func (c *client) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) { + res, err := c.defaultRpcClient.PendingNonceAt(ctx, account) + if err != nil { + return c.fallbackRpcClient.PendingNonceAt(ctx, account) + } + return res, nil +} + +// EstimateGas implements rpcclient.Interface. +func (c *client) EstimateGas(ctx context.Context, call ethereum.CallMsg) (uint64, error) { + res, err := c.defaultRpcClient.EstimateGas(ctx, call) + if err != nil { + return c.fallbackRpcClient.EstimateGas(ctx, call) + } + return res, nil +} + +// SuggestGasPrice implements rpcclient.Interface. +func (c *client) SuggestGasPrice(ctx context.Context) (*big.Int, error) { + res, err := c.defaultRpcClient.SuggestGasPrice(ctx) + if err != nil { + return c.fallbackRpcClient.SuggestGasPrice(ctx) + } + return res, nil +} + +// FeeTargets implements rpcclient.Interface. +func (c *client) FeeTargets(ctx context.Context) ([]*ethtypes.FeeTarget, error) { + res, err := c.defaultRpcClient.FeeTargets(ctx) + if err != nil { + return c.fallbackRpcClient.FeeTargets(ctx) + } + return res, nil +} From d6f6f2483f1032e74e30779bad00da989e01bd3f Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Mon, 7 Jul 2025 15:38:06 +0100 Subject: [PATCH 2/2] backend: add SendTransaction implementation for blockbook. --- backend/coins/eth/blockbook/blockbook.go | 34 +++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/backend/coins/eth/blockbook/blockbook.go b/backend/coins/eth/blockbook/blockbook.go index c364b43cca..d4e83eea58 100644 --- a/backend/coins/eth/blockbook/blockbook.go +++ b/backend/coins/eth/blockbook/blockbook.go @@ -29,9 +29,12 @@ import ( "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/rpcclient" ethtypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/types" "github.com/BitBoxSwiss/bitbox-wallet-app/util/errp" + "github.com/BitBoxSwiss/bitbox-wallet-app/util/logging" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" + "github.com/sirupsen/logrus" "golang.org/x/time/rate" ) @@ -45,6 +48,8 @@ type Blockbook struct { url string httpClient *http.Client limiter *rate.Limiter + // TODO remove before merging into master? + log *logrus.Entry } // NewBlockbook creates a new instance of EtherScan. @@ -54,6 +59,7 @@ func NewBlockbook(chainId string, httpClient *http.Client) *Blockbook { url: "https://bb1.shiftcrypto.io/api/", httpClient: httpClient, limiter: rate.NewLimiter(rate.Limit(callsPerSec), 1), + log: logging.Get().WithField("ETH Client", "Blockbook"), } } @@ -129,7 +135,33 @@ func (blockbook *Blockbook) Transactions(blockTipHeight *big.Int, address common // SendTransaction implements rpc.Interface. func (blockbook *Blockbook) SendTransaction(ctx context.Context, tx *types.Transaction) error { - return fmt.Errorf("Not yet implemented") + params := url.Values{} + + result := struct { + Txid string `json:"result,omitempty"` + Error struct { + Message string `json:"message,omitempty"` + } `json:"error,omitempty"` + }{} + + encodedTx, err := tx.MarshalBinary() + if err != nil { + blockbook.log.Errorf("Failed to marshal transaction: %v", err) + return errp.WithStack(err) + } + + if err := blockbook.call(ctx, path.Join("sendtx", hexutil.Encode(encodedTx)), params, &result); err != nil { + blockbook.log.Errorf("Failed to send transaction: %v", err) + return errp.WithStack(err) + } + + if result.Error.Message != "" { + blockbook.log.Errorf("Error sending transaction: %s", result.Error.Message) + return errp.Newf("error sending transaction: %s", result.Error.Message) + } + + blockbook.log.Infof("Transaction sent: %s", result.Txid) + return nil } // ERC20Balance implements rpc.Interface.