Skip to content

[Do Not Review yet] backend: add SendTransaction implementation for blockbook. #3442

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: staging-blockbook
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
195 changes: 195 additions & 0 deletions backend/coins/eth/blockbook/blockbook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// 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/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"
)

// 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
// TODO remove before merging into master?
log *logrus.Entry
}

// 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),
log: logging.Get().WithField("ETH Client", "Blockbook"),
}
}

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 {
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.
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")
}
146 changes: 146 additions & 0 deletions backend/coins/eth/rpcclient/wrapper/wrapper.go
Original file line number Diff line number Diff line change
@@ -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
}