Skip to content

backend: add Transactions implementation to blockbook. #3443

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 1 commit 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
123 changes: 117 additions & 6 deletions backend/coins/eth/blockbook/blockbook.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,19 @@ import (
"math/big"
"net/http"
"net/url"
"time"

"github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin"
"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/core/types"
"github.com/sirupsen/logrus"
"golang.org/x/time/rate"
)

Expand All @@ -44,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.
Expand All @@ -55,6 +61,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"),
}
}

Expand All @@ -77,15 +84,15 @@ func (blockbook *Blockbook) call(ctx context.Context, handler string, params url
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{}
func (blockbook *Blockbook) address(ctx context.Context, account common.Address, params url.Values, result interface{}) error {
address := account.Hex()

addressPath := fmt.Sprintf("address/%s", address)
Expand All @@ -103,7 +110,7 @@ func (blockbook *Blockbook) Balance(ctx context.Context, account common.Address)
Balance string `json:"balance"`
}{}

if err := blockbook.address(ctx, account, &result); err != nil {
if err := blockbook.address(ctx, account, url.Values{}, &result); err != nil {
return nil, errp.WithStack(err)
}

Expand Down Expand Up @@ -136,9 +143,114 @@ func (blockbook *Blockbook) PendingNonceAt(ctx context.Context, account common.A
return 0, fmt.Errorf("Not yet implemented")
}

// prepareTransactions casts to []accounts.Transactions and removes duplicate entries and sets the
// transaction type (send, receive, send to self) based on the account address.
func prepareTransactions(
isERC20 bool,
blockTipHeight *big.Int,
isInternal bool,
transactions []*Tx, address common.Address) ([]*accounts.TransactionData, error) {
seen := map[string]struct{}{}

// TODO figure out if needed. Etherscan.go uses this to compute the num of confirmations.
// But numConfirmations is already returned by the API call.
_ = blockTipHeight

_ = isInternal // TODO figure out how to deal with internal txs.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do, my ERC20 accounts don't show any transactions with this PR 😅 I should probably review once it works for ETH and ERC20, or do you want to separate it into two PRs?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think since we are on a staging branch, is fine to do it in a separate PR. Definitely need to do some investigation for it :)


castedTransactions := make([]*accounts.TransactionData, 0, len(transactions))
ours := address.Hex()
for _, tx := range transactions {
if _, ok := seen[tx.Txid]; ok {
// Skip duplicate transactions.
continue
}
seen[tx.Txid] = struct{}{}

fee := coin.NewAmount(tx.FeesSat.Int)
timestamp := time.Unix(tx.Blocktime, 0)
status, err := tx.Status()
// TODO do not ignore unconfirmed tx
if status == accounts.TxStatusPending {
continue
}
if err != nil {
return nil, errp.WithStack(err)
}
from := tx.Vin[0].Addresses[0]
var to string
if len(tx.TokenTransfers) > 0 {
to = tx.TokenTransfers[0].To
} else {
to = tx.Vout[0].Addresses[0]
}
if ours != from && ours != to {
return nil, errp.New("transaction does not belong to our account")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This errors on my ETH account I use for testing, because some spam tx touched it:

https://etherscan.io/tx/0x1a926b5296dc6803a8a07daeebbd6b1b6b476790af5719ec628d5eeca08027f2

This tx has a ton of ERC-1155 token transfers (NFTs?), one of which touches my address.

I am not sure so you should check, but I think the Etherscan-based account includes such transactions in the list if it originated from the account, in other words, if the ETH accounts paid the fees for it (which impacts the account balance and portfolio chart calculation). I guess it does not make sense to list "incoming" token transfers in the main ETH account, these are shown separately in the respective ERC20 accounts.

}

var txType accounts.TxType
switch {
case ours == from && ours == to:
txType = accounts.TxTypeSendSelf
case ours == from:
txType = accounts.TxTypeSend
default:
txType = accounts.TxTypeReceive
}

addresses, err := tx.Addresses(isERC20)
if err != nil {
return nil, errp.WithStack(err)
}
castedTransaction := &accounts.TransactionData{
Fee: &fee,
FeeIsDifferentUnit: isERC20,
Timestamp: &timestamp,
TxID: tx.Txid,
InternalID: tx.Txid,
Height: tx.Blockheight,
NumConfirmations: int(tx.Confirmations),
NumConfirmationsComplete: ethtypes.NumConfirmationsComplete,
Status: status,
Type: txType,
Amount: tx.Amount(address.Hex(), isERC20),
Gas: tx.EthereumSpecific.GasUsed.Uint64(),
Nonce: &tx.EthereumSpecific.Nonce,
Addresses: addresses,
IsErc20: isERC20,
}
castedTransactions = append(castedTransactions, castedTransaction)
}
return castedTransactions, nil
}

// 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")
params := url.Values{}
isERC20 := erc20Token != nil
if isERC20 {
params.Set("contract", erc20Token.ContractAddress().Hex())
}
params.Set("details", "txslight")
if endBlock != nil {
params.Set("endBlock", endBlock.String())
}
result := struct {
Transactions []*Tx `json:"transactions"`
}{}

if err := blockbook.address(context.Background(), address, params, &result); err != nil {
return nil, errp.WithStack(err)
}

transactionsNormal, err := prepareTransactions(isERC20, blockTipHeight, false, result.Transactions, address)

if err != nil {
return nil, errp.WithStack(err)
}

return transactionsNormal, nil

}

// SendTransaction implements rpc.Interface.
Expand All @@ -155,8 +267,7 @@ func (blockbook *Blockbook) ERC20Balance(account common.Address, erc20Token *erc
} `json:"tokens"`
}{}

// TODO why is there no context in the signature of this interface method?
if err := blockbook.address(context.Background(), account, &result); err != nil {
if err := blockbook.address(context.Background(), account, url.Values{}, &result); err != nil {
return nil, errp.WithStack(err)
}

Expand Down
135 changes: 135 additions & 0 deletions backend/coins/eth/blockbook/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package blockbook

import (
"encoding/json"
"math/big"

"github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin"
"github.com/BitBoxSwiss/bitbox-wallet-app/util/errp"
)

// TxStatus represents the status of a transaction in the Blockbook API.
type TxStatus int

const (
// TxStatusPending indicates that the transaction is pending.
TxStatusPending TxStatus = -1
// TxStatusOK indicates that the transaction is complete and successful.
TxStatusOK TxStatus = 1
// TxStatusFailure indicates that the transaction has failed.
TxStatusFailure TxStatus = 0
)

// TokenTransfer represents a token transfer in a transaction.
type TokenTransfer struct {
Type string `json:"type"`
From string `json:"from"`
To string `json:"to"`
Contract string `json:"contract"`
Value Amount `json:"value"`
}

// Vin represents an input in a transaction.
type Vin struct {
Txid string `json:"txid"`
Addresses []string `json:"addresses"`
}

// Vout represents an output in a transaction.
type Vout struct {
Txid string `json:"txid,omitempty"`
Value Amount `json:"value"`
Addresses []string `json:"addresses"`
}

// Amount is a wrapper to big.Int to handle JSON unmarshalling.
type Amount struct {
*big.Int
}

// UnmarshalJSON implements the json.Unmarshaler interface for Amount.
func (a *Amount) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return errp.WithStack(err)
}
intValue, ok := new(big.Int).SetString(s, 10)
if !ok {
return errp.Newf("could not parse amount %q", s)
}
a.Int = intValue
return nil
}

// Tx holds information about a transaction.
type Tx struct {
Txid string `json:"txid"`
Vin []Vin `json:"vin"`
Vout []Vout `json:"vout"`
Blockhash string `json:"blockHash,omitempty"`
Blockheight int `json:"blockHeight"`
Confirmations uint32 `json:"confirmations"`
Blocktime int64 `json:"blockTime"`
ValueOutSat Amount `json:"value"`
ValueInSat Amount `json:"valueIn,omitempty"`
FeesSat Amount `json:"fees,omitempty"`
TokenTransfers []TokenTransfer `json:"tokenTransfers,omitempty"`
EthereumSpecific *EthereumSpecific `json:"ethereumSpecific,omitempty"`
}

// EthereumSpecific contains ethereum specific transaction data.
type EthereumSpecific struct {
Status TxStatus `json:"status"`
Nonce uint64 `json:"nonce"`
GasLimit *big.Int `json:"gasLimit"`
GasUsed *big.Int `json:"gasUsed,omitempty"`
GasPrice Amount `json:"gasPrice,omitempty"`
}

// Amount returns the total amount of the transaction.
func (tx *Tx) Amount(address string, isERC20 bool) coin.Amount {
if isERC20 {
for _, transfer := range tx.TokenTransfers {
if transfer.Type == "ERC20" {
if transfer.To == address || transfer.From == address {
return coin.NewAmount(transfer.Value.Int)
}
}
}
}
return coin.NewAmount(tx.ValueOutSat.Int)
}

// Addresses returns the receiving address of the transaction.
func (tx *Tx) Addresses(isERC20 bool) ([]accounts.AddressAndAmount, error) {
var address string
switch {
case isERC20:
address = tx.TokenTransfers[0].To
case len(tx.Vout) > 0:
address = tx.Vout[0].Addresses[0]
default:
return nil, errp.New("transaction has no outputs or token transfers")
}

return []accounts.AddressAndAmount{{
Address: address,
Amount: tx.Amount(address, isERC20),
}}, nil
}

// Status returns the status of the transaction.
func (tx *Tx) Status() (accounts.TxStatus, error) {
switch tx.EthereumSpecific.Status {
case TxStatusPending:
return accounts.TxStatusPending, nil
case TxStatusOK:
return accounts.TxStatusComplete, nil
case TxStatusFailure:
return accounts.TxStatusFailed, nil
default:
// This should never happen
return "", errp.Newf("unknown transaction status %d", tx.EthereumSpecific.Status)
}
}