diff --git a/backend/coins/eth/blockbook/blockbook.go b/backend/coins/eth/blockbook/blockbook.go index f74c5660dd..0c52c97a5b 100644 --- a/backend/coins/eth/blockbook/blockbook.go +++ b/backend/coins/eth/blockbook/blockbook.go @@ -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" ) @@ -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. @@ -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"), } } @@ -77,6 +84,7 @@ 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)) } @@ -84,8 +92,7 @@ func (blockbook *Blockbook) call(ctx context.Context, handler string, params url 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) @@ -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) } @@ -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. + + 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") + } + + 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: ×tamp, + 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. @@ -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) } diff --git a/backend/coins/eth/blockbook/types.go b/backend/coins/eth/blockbook/types.go new file mode 100644 index 0000000000..4276e9da71 --- /dev/null +++ b/backend/coins/eth/blockbook/types.go @@ -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) + } +}