Skip to content

Commit 52ee531

Browse files
committed
backend: add blockbook/etherscan wrapper.
1 parent 63286f4 commit 52ee531

File tree

3 files changed

+321
-4
lines changed

3 files changed

+321
-4
lines changed

backend/backend.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ import (
3838
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/btc/types"
3939
coinpkg "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin"
4040
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth"
41+
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/blockbook"
4142
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/etherscan"
43+
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/rpcclient/wrapper"
4244
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/ltc"
4345
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/config"
4446
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/devices/bitbox"
@@ -226,6 +228,7 @@ type Backend struct {
226228
// can be a regular or, if Tor is enabled in the config, a SOCKS5 proxy client.
227229
httpClient *http.Client
228230
etherScanHTTPClient *http.Client
231+
blockbookHTTPClient *http.Client
229232
ratesUpdater *rates.RateUpdater
230233
banners *banners.Banners
231234

@@ -291,6 +294,7 @@ func NewBackend(arguments *arguments.Arguments, environment Environment) (*Backe
291294
backend.socksProxy = backendProxy
292295
backend.httpClient = hclient
293296
backend.etherScanHTTPClient = hclient
297+
backend.blockbookHTTPClient = hclient
294298

295299
ratesCache := filepath.Join(arguments.CacheDirectoryPath(), "exchangerates")
296300
if err := os.MkdirAll(ratesCache, 0700); err != nil {
@@ -527,9 +531,11 @@ func (backend *Backend) Coin(code coinpkg.Code) (coinpkg.Coin, error) {
527531
"https://blockchair.com/litecoin/transaction/", backend.socksProxy)
528532
case code == coinpkg.CodeETH:
529533
etherScan := etherscan.NewEtherScan("1", backend.etherScanHTTPClient)
530-
coin = eth.NewCoin(etherScan, code, "Ethereum", "ETH", "ETH", params.MainnetChainConfig,
534+
blockBook := blockbook.NewBlockbook("1", backend.blockbookHTTPClient)
535+
rpcWrapper := wrapper.NewClient(blockBook, etherScan)
536+
coin = eth.NewCoin(rpcWrapper, code, "Ethereum", "ETH", "ETH", params.MainnetChainConfig,
531537
"https://etherscan.io/tx/",
532-
etherScan,
538+
rpcWrapper,
533539
nil)
534540
case code == coinpkg.CodeSEPETH:
535541
etherScan := etherscan.NewEtherScan("11155111", backend.etherScanHTTPClient)
@@ -539,9 +545,11 @@ func (backend *Backend) Coin(code coinpkg.Code) (coinpkg.Coin, error) {
539545
nil)
540546
case erc20Token != nil:
541547
etherScan := etherscan.NewEtherScan("1", backend.etherScanHTTPClient)
542-
coin = eth.NewCoin(etherScan, erc20Token.code, erc20Token.name, erc20Token.unit, "ETH", params.MainnetChainConfig,
548+
blockBook := blockbook.NewBlockbook("1", backend.blockbookHTTPClient)
549+
rpcWrapper := wrapper.NewClient(blockBook, etherScan)
550+
coin = eth.NewCoin(rpcWrapper, erc20Token.code, erc20Token.name, erc20Token.unit, "ETH", params.MainnetChainConfig,
543551
"https://etherscan.io/tx/",
544-
etherScan,
552+
rpcWrapper,
545553
erc20Token.token,
546554
)
547555
default:
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright 2025 Shift Crypto AG
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package blockbook
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"io"
22+
"math/big"
23+
"net/http"
24+
"net/url"
25+
"path"
26+
27+
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts"
28+
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/erc20"
29+
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/rpcclient"
30+
ethtypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/types"
31+
"github.com/BitBoxSwiss/bitbox-wallet-app/util/errp"
32+
"github.com/ethereum/go-ethereum"
33+
"github.com/ethereum/go-ethereum/common"
34+
"github.com/ethereum/go-ethereum/core/types"
35+
"golang.org/x/time/rate"
36+
)
37+
38+
// callsPerSec is the number of blockbook requests allowed
39+
// per second.
40+
// TODO bznein determine a better value for this.
41+
const callsPerSec = 3.8
42+
43+
// Blockbook is a rate-limited blockbook api client.
44+
type Blockbook struct {
45+
url string
46+
httpClient *http.Client
47+
limiter *rate.Limiter
48+
}
49+
50+
// NewBlockbook creates a new instance of EtherScan.
51+
func NewBlockbook(chainId string, httpClient *http.Client) *Blockbook {
52+
// TODO chainID is not used here, do we have blockbook running for SEPETH as well?
53+
return &Blockbook{
54+
url: "https://bb1.shiftcrypto.io/api/",
55+
httpClient: httpClient,
56+
limiter: rate.NewLimiter(rate.Limit(callsPerSec), 1),
57+
}
58+
}
59+
60+
func (blockbook *Blockbook) call(ctx context.Context, handler string, params url.Values, result interface{}) error {
61+
if err := blockbook.limiter.Wait(ctx); err != nil {
62+
return errp.WithStack(err)
63+
}
64+
65+
reqUrl := blockbook.url + handler + "?" + params.Encode()
66+
67+
response, err := blockbook.httpClient.Get(reqUrl)
68+
if err != nil {
69+
return errp.WithStack(err)
70+
}
71+
defer func() { _ = response.Body.Close() }()
72+
if response.StatusCode != http.StatusOK {
73+
return errp.Newf("expected 200 OK, got %d", response.StatusCode)
74+
}
75+
body, err := io.ReadAll(response.Body)
76+
if err != nil {
77+
return errp.WithStack(err)
78+
}
79+
if err := json.Unmarshal(body, result); err != nil {
80+
return errp.Newf("unexpected response from blockbook: %s", string(body))
81+
}
82+
83+
return nil
84+
}
85+
86+
func (blockbook *Blockbook) address(ctx context.Context, account common.Address, result interface{}) error {
87+
params := url.Values{}
88+
address := account.Hex()
89+
90+
if err := blockbook.call(ctx, path.Join("address", address), params, result); err != nil {
91+
return errp.WithStack(err)
92+
}
93+
94+
return nil
95+
}
96+
97+
// Balance implements rpc.Interface.
98+
func (blockbook *Blockbook) Balance(ctx context.Context, account common.Address) (*big.Int, error) {
99+
result := struct {
100+
Balance string `json:"balance"`
101+
}{}
102+
103+
if err := blockbook.address(ctx, account, &result); err != nil {
104+
return nil, errp.WithStack(err)
105+
}
106+
107+
balance, ok := new(big.Int).SetString(result.Balance, 10)
108+
if !ok {
109+
return nil, errp.Newf("could not parse balance %q", result.Balance)
110+
}
111+
return balance, nil
112+
113+
}
114+
115+
// BlockNumber implements rpc.Interface.
116+
func (blockbook *Blockbook) BlockNumber(ctx context.Context) (*big.Int, error) {
117+
return nil, fmt.Errorf("Not yet implemented")
118+
}
119+
120+
// PendingNonceAt implements rpc.Interface.
121+
func (blockbook *Blockbook) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) {
122+
return 0, fmt.Errorf("Not yet implemented")
123+
}
124+
125+
// Transactions implement TransactionSource.
126+
func (blockbook *Blockbook) Transactions(blockTipHeight *big.Int, address common.Address, endBlock *big.Int, erc20Token *erc20.Token) ([]*accounts.TransactionData, error) {
127+
return nil, fmt.Errorf("Not yet implemented")
128+
}
129+
130+
// SendTransaction implements rpc.Interface.
131+
func (blockbook *Blockbook) SendTransaction(ctx context.Context, tx *types.Transaction) error {
132+
return fmt.Errorf("Not yet implemented")
133+
}
134+
135+
// ERC20Balance implements rpc.Interface.
136+
func (blockbook *Blockbook) ERC20Balance(account common.Address, erc20Token *erc20.Token) (*big.Int, error) {
137+
return nil, fmt.Errorf("Not yet implemented")
138+
}
139+
140+
// EstimateGas implements rpc.Interface.
141+
func (blockbook *Blockbook) EstimateGas(ctx context.Context, call ethereum.CallMsg) (gas uint64, err error) {
142+
return 0, fmt.Errorf("Not yet implemented")
143+
}
144+
145+
// FeeTargets implements rpc.Interface.
146+
func (blockbook *Blockbook) FeeTargets(ctx context.Context) ([]*ethtypes.FeeTarget, error) {
147+
return nil, fmt.Errorf("Not yet implemented")
148+
}
149+
150+
// SuggestGasPrice implements rpc.Interface.
151+
func (blockbook *Blockbook) SuggestGasPrice(ctx context.Context) (*big.Int, error) {
152+
return nil, fmt.Errorf("Not yet implemented")
153+
}
154+
155+
// TransactionByHash implements rpc.Interface.
156+
func (blockbook *Blockbook) TransactionByHash(ctx context.Context, hash common.Hash) (tx *types.Transaction, isPending bool, err error) {
157+
return nil, false, fmt.Errorf("Not yet implemented")
158+
}
159+
160+
// TransactionReceiptWithBlockNumber implements rpcclient.Interface.
161+
func (blockbook *Blockbook) TransactionReceiptWithBlockNumber(ctx context.Context, hash common.Hash) (*rpcclient.RPCTransactionReceipt, error) {
162+
return nil, fmt.Errorf("Not yet implemented")
163+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package wrapper
2+
3+
import (
4+
"context"
5+
"math/big"
6+
7+
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts"
8+
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth"
9+
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/rpcclient"
10+
11+
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/erc20"
12+
ethtypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/types"
13+
"github.com/ethereum/go-ethereum"
14+
"github.com/ethereum/go-ethereum/common"
15+
"github.com/ethereum/go-ethereum/core/types"
16+
)
17+
18+
type rpcClientWithTransactionsSource interface {
19+
eth.TransactionsSource
20+
rpcclient.Interface
21+
}
22+
23+
type client struct {
24+
defaultRpcClient rpcClientWithTransactionsSource
25+
fallbackRpcClient rpcClientWithTransactionsSource
26+
27+
// TODO do we maybe want to add some fields to keep track
28+
// of which one we are using? if we switch to the fallback one
29+
// it might make sense to use that for a while instead of wasting
30+
// time each time trying the default first and falling back to the
31+
// other one later.
32+
}
33+
34+
// NewClient creates a new client that uses the default RPC client unless it fails,
35+
// in which case it falls back to the fallback RPC client.
36+
func NewClient(defaultRPCClient, fallbackRPCClient rpcClientWithTransactionsSource) *client {
37+
return &client{
38+
defaultRpcClient: defaultRPCClient,
39+
fallbackRpcClient: fallbackRPCClient,
40+
}
41+
}
42+
43+
// Transactions queries either the default or the fallback endpoint for transactions for the given account, until endBlock.
44+
// Provide erc20Token to filter for those. If nil, standard etheruem transactions will be fetched.
45+
func (c *client) Transactions(
46+
blockTipHeight *big.Int,
47+
address common.Address, endBlock *big.Int, erc20Token *erc20.Token) (
48+
[]*accounts.TransactionData, error) {
49+
res, err := c.defaultRpcClient.Transactions(blockTipHeight, address, endBlock, erc20Token)
50+
if err != nil {
51+
return c.fallbackRpcClient.Transactions(blockTipHeight, address, endBlock, erc20Token)
52+
}
53+
return res, nil
54+
}
55+
56+
// TransactionReceiptWithBlockNumber implements rpcclient.Interface.
57+
func (c *client) TransactionReceiptWithBlockNumber(ctx context.Context, hash common.Hash) (*rpcclient.RPCTransactionReceipt, error) {
58+
res, err := c.defaultRpcClient.TransactionReceiptWithBlockNumber(ctx, hash)
59+
if err != nil {
60+
return c.fallbackRpcClient.TransactionReceiptWithBlockNumber(ctx, hash)
61+
}
62+
return res, nil
63+
}
64+
65+
// BlockNumber implements rpcclient.Interface.
66+
func (c *client) BlockNumber(ctx context.Context) (*big.Int, error) {
67+
res, err := c.defaultRpcClient.BlockNumber(ctx)
68+
if err != nil {
69+
return c.fallbackRpcClient.BlockNumber(ctx)
70+
}
71+
return res, nil
72+
}
73+
74+
// TransactionByHash implements rpcclient.Interface.
75+
// TODO the error here is used by the caller to determine whether the tx has been found or not, so
76+
// right now it wouldn't work using it for our fallback logic. Need to refactor this.
77+
func (c *client) TransactionByHash(ctx context.Context, hash common.Hash) (*types.Transaction, bool, error) {
78+
res, pending, err := c.defaultRpcClient.TransactionByHash(ctx, hash)
79+
if err != nil {
80+
return c.fallbackRpcClient.TransactionByHash(ctx, hash)
81+
}
82+
return res, pending, nil
83+
}
84+
85+
// Balance implements rpcclient.Interface.
86+
func (c *client) Balance(ctx context.Context, account common.Address) (*big.Int, error) {
87+
res, err := c.defaultRpcClient.Balance(ctx, account)
88+
if err != nil {
89+
return c.fallbackRpcClient.Balance(ctx, account)
90+
}
91+
return res, nil
92+
}
93+
94+
// ERC20Balance implements rpcclient.Interface.
95+
func (c *client) ERC20Balance(account common.Address, erc20Token *erc20.Token) (*big.Int, error) {
96+
res, err := c.defaultRpcClient.ERC20Balance(account, erc20Token)
97+
if err != nil {
98+
return c.fallbackRpcClient.ERC20Balance(account, erc20Token)
99+
}
100+
return res, nil
101+
}
102+
103+
// SendTransaction implements rpcclient.Interface.
104+
func (c *client) SendTransaction(ctx context.Context, tx *types.Transaction) error {
105+
err := c.defaultRpcClient.SendTransaction(ctx, tx)
106+
if err != nil {
107+
return c.fallbackRpcClient.SendTransaction(ctx, tx)
108+
}
109+
return nil
110+
}
111+
112+
// PendingNonceAt implements rpcclient.Interface.
113+
func (c *client) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) {
114+
res, err := c.defaultRpcClient.PendingNonceAt(ctx, account)
115+
if err != nil {
116+
return c.fallbackRpcClient.PendingNonceAt(ctx, account)
117+
}
118+
return res, nil
119+
}
120+
121+
// EstimateGas implements rpcclient.Interface.
122+
func (c *client) EstimateGas(ctx context.Context, call ethereum.CallMsg) (uint64, error) {
123+
res, err := c.defaultRpcClient.EstimateGas(ctx, call)
124+
if err != nil {
125+
return c.fallbackRpcClient.EstimateGas(ctx, call)
126+
}
127+
return res, nil
128+
}
129+
130+
// SuggestGasPrice implements rpcclient.Interface.
131+
func (c *client) SuggestGasPrice(ctx context.Context) (*big.Int, error) {
132+
res, err := c.defaultRpcClient.SuggestGasPrice(ctx)
133+
if err != nil {
134+
return c.fallbackRpcClient.SuggestGasPrice(ctx)
135+
}
136+
return res, nil
137+
}
138+
139+
// FeeTargets implements rpcclient.Interface.
140+
func (c *client) FeeTargets(ctx context.Context) ([]*ethtypes.FeeTarget, error) {
141+
res, err := c.defaultRpcClient.FeeTargets(ctx)
142+
if err != nil {
143+
return c.fallbackRpcClient.FeeTargets(ctx)
144+
}
145+
return res, nil
146+
}

0 commit comments

Comments
 (0)