Skip to content
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- Mobile: Move settings into bottom navigation
- Enable Tether USDT for BTC Direct
- Allow users to upgrade firmware during setup
- Ethereum: improve QR code scanning and fix ERC20 QR payment requests

## v4.51.4
- Bundle BitBox02 and BitBox02 Nova firmware version v9.26.5
Expand Down
6 changes: 6 additions & 0 deletions backend/coins/eth/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
)

func isMixedCase(s string) bool {
s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X")
return strings.ToLower(s) != s && strings.ToUpper(s) != s
}

Expand Down Expand Up @@ -546,6 +547,11 @@ func (account *Account) newTx(args *accounts.TxProposalArgs) (*TxProposal, error
}
value = parsedAmount.BigInt()
}
if account.coin.erc20Token != nil {
if value.BitLen() > 256 {
return nil, errp.WithStack(errors.ErrInvalidAmount)
}
}

var message ethereum.CallMsg

Expand Down
20 changes: 19 additions & 1 deletion backend/coins/eth/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts/errors"
"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"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/rpcclient/mocks"
ethtypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/types"
Expand Down Expand Up @@ -167,7 +168,7 @@ func TestTxProposal(t *testing.T) {
})
t.Run("valid-address-uppercase", func(t *testing.T) {
_, _, _, err := acct.TxProposal(&accounts.TxProposalArgs{
RecipientAddress: "0XA29163852021BF4C139D03DFF59AE763AC73E84E",
RecipientAddress: "0xA29163852021BF4C139D03DFF59AE763AC73E84E",
Amount: coin.NewSendAmount("0.1"),
FeeTargetCode: accounts.FeeTargetCodeCustom,
CustomFee: "20",
Expand Down Expand Up @@ -196,6 +197,23 @@ func TestTxProposal(t *testing.T) {
})
}

func TestERC20TxProposalRejectsAmountOverflow(t *testing.T) {
acct := newAccount(t)
defer acct.Close()
acct.coin.erc20Token = erc20.NewToken("0x89205a3a3b2a69de6dbf7f01ed13b2108b2c43e7", 0)
require.NoError(t, acct.Update(big.NewInt(1e18), big.NewInt(100), nil))
require.Eventually(t, acct.Synced, time.Second, time.Millisecond*200)

_, _, _, err := acct.TxProposal(&accounts.TxProposalArgs{
RecipientAddress: "0xa29163852021BF4C139D03Dff59ae763AC73e84e",
Amount: coin.NewSendAmount(
"115792089237316195423570985008687907853269984665640564039457584007913129639936"),
FeeTargetCode: accounts.FeeTargetCodeCustom,
CustomFee: "20",
})
require.Equal(t, errors.ErrInvalidAmount, errp.Cause(err))
}

func newTestOutgoingTx() *gethtypes.Transaction {
to := common.HexToAddress("0xa29163852021BF4C139D03Dff59ae763AC73e84e")
return gethtypes.NewTx(&gethtypes.LegacyTx{
Expand Down
174 changes: 174 additions & 0 deletions backend/coins/eth/payment_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: Apache-2.0

package eth

import (
"math/big"
"net/url"
"regexp"
"strconv"
"strings"

"github.com/BitBoxSwiss/bitbox-wallet-app/util/errp"
"github.com/ethereum/go-ethereum/common"
)

var (
errInvalidPaymentRequest = errp.New("invalid Ethereum payment request")
// ERC-681 (EIP-681) URL grammar: https://eips.ethereum.org/EIPS/eip-681
paymentRequestTargetRE = regexp.MustCompile(`^(?:pay-)?(0x[0-9a-fA-F]{40})(?:@(\d+))?$`)
paymentRequestAmountRE = regexp.MustCompile(`^(\d+)(?:\.(\d+))?(?:[eE](\d+))?$`)
)

// ErrPaymentRequestAccountMismatch means that the request targets a different chain or asset than
// the selected account.
const ErrPaymentRequestAccountMismatch errp.ErrorCode = "accountMismatch"

// PaymentRequest contains the recipient and optional amount from an ERC-681 payment request.
type PaymentRequest struct {
Recipient string
Amount string
}

// ParsePaymentRequest parses an ERC-681 payment request for this coin.
func (coin *Coin) ParsePaymentRequest(input string) (*PaymentRequest, error) {
parsed, err := url.Parse(strings.TrimSpace(input))
if err != nil || !strings.EqualFold(parsed.Scheme, "ethereum") || parsed.Fragment != "" || parsed.Opaque == "" {
return nil, errInvalidPaymentRequest
}

pathParts := strings.Split(parsed.Opaque, "/")
if len(pathParts) > 2 {
return nil, errInvalidPaymentRequest
}
targetMatch := paymentRequestTargetRE.FindStringSubmatch(pathParts[0])
if targetMatch == nil || !IsValidEthAddress(targetMatch[1]) {
return nil, errInvalidPaymentRequest
}
if targetMatch[2] != "" && normalizeNumber(targetMatch[2]) != coin.ChainIDstr() {
return nil, ErrPaymentRequestAccountMismatch
}

parameters, err := url.ParseQuery(parsed.RawQuery)
if err != nil {
return nil, errInvalidPaymentRequest
}
if len(pathParts) == 2 {
if pathParts[1] != "transfer" {
return nil, errInvalidPaymentRequest
}
return coin.parseERC20Transfer(targetMatch[1], parameters)
}
for key := range parameters {
if key != "value" {
return nil, errInvalidPaymentRequest
}
}
values := parameters["value"]
if len(values) > 1 {
return nil, errInvalidPaymentRequest
}
request := &PaymentRequest{Recipient: targetMatch[1]}
if len(values) == 0 {
return request, nil
}
atomicAmount, ok := parsePaymentRequestAtomicAmount(values[0])
if !ok {
return nil, errInvalidPaymentRequest
}
if coin.erc20Token != nil {
return nil, ErrPaymentRequestAccountMismatch
}
request.Amount = formatPaymentRequestAmount(atomicAmount, coin.Decimals(false))
return request, nil
}

func (coin *Coin) parseERC20Transfer(target string, parameters url.Values) (*PaymentRequest, error) {
for key := range parameters {
if key != "address" && key != "uint256" {
return nil, errInvalidPaymentRequest
}
}
recipients := parameters["address"]
amounts := parameters["uint256"]
if len(recipients) != 1 || len(amounts) > 1 {
return nil, errInvalidPaymentRequest
}
if !IsValidEthAddress(recipients[0]) {
return nil, errInvalidPaymentRequest
}
if coin.erc20Token == nil || common.HexToAddress(target) != coin.erc20Token.ContractAddress() {
return nil, ErrPaymentRequestAccountMismatch
}

request := &PaymentRequest{Recipient: recipients[0]}
if len(amounts) == 0 {
return request, nil
}
atomicAmount, ok := parsePaymentRequestAtomicAmount(amounts[0])
if !ok {
return nil, errInvalidPaymentRequest
}
request.Amount = formatPaymentRequestAmount(atomicAmount, coin.Decimals(false))
return request, nil
}

func normalizeNumber(number string) string {
normalized := strings.TrimLeft(number, "0")
if normalized == "" {
return "0"
}
return normalized
}

func parsePaymentRequestAtomicAmount(value string) (*big.Int, bool) {
match := paymentRequestAmountRE.FindStringSubmatch(value)
if match == nil {
return nil, false
}
fraction := match[2]
exponent := uint64(0)
if match[3] != "" {
var err error
exponent, err = strconv.ParseUint(match[3], 10, 64)
if err != nil {
return nil, false
}
}
if exponent < uint64(len(fraction)) {
return nil, false
}

significantDigits := strings.TrimLeft(match[1]+fraction, "0")
if significantDigits == "" {
return new(big.Int), true
}
trailingZeros := exponent - uint64(len(fraction))
if trailingZeros > 78 || len(significantDigits) > 78-int(trailingZeros) {
return nil, false
}
atomicAmount, ok := new(big.Int).SetString(
significantDigits+strings.Repeat("0", int(trailingZeros)),
10,
)
if !ok || atomicAmount.BitLen() > 256 {
return nil, false
}
return atomicAmount, true
}

func formatPaymentRequestAmount(atomicAmount *big.Int, decimals uint) string {
amount := atomicAmount.String()
if decimals == 0 {
return amount
}
if uint(len(amount)) <= decimals {
amount = strings.Repeat("0", int(decimals)-len(amount)+1) + amount
}
split := len(amount) - int(decimals)
fraction := strings.TrimRight(amount[split:], "0")
if fraction == "" {
return amount[:split]
}
return amount[:split] + "." + fraction
}
Loading