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
131 changes: 131 additions & 0 deletions backend/account_planning.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: Apache-2.0

package backend

import (
coinpkg "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/config"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/keystore"
"github.com/BitBoxSwiss/bitbox-wallet-app/util/errp"
)

type accountCandidate struct {
account *config.Account
number uint16
}

// accountCandidates returns persisted accounts for the coin, root fingerprint, and valid account
// numbers. Accounts with malformed signing configurations are ignored.
func accountCandidates(
accountsConfig *config.AccountsConfig,
rootFingerprint []byte,
coinCode coinpkg.Code,
) []accountCandidate {
var candidates []accountCandidate
for _, account := range accountsConfig.Accounts {
if coinCode != account.CoinCode {
continue
}
if !account.SigningConfigurations.ContainsRootFingerprint(rootFingerprint) {
continue
}
accountNumber, err := account.SigningConfigurations.AccountNumber()
if err != nil {
continue
}
candidates = append(candidates, accountCandidate{
account: account,
number: accountNumber,
})
}
return candidates
}

// findHiddenAccount finds the hidden unused account with the lowest account number.
func findHiddenAccount(
coinCode coinpkg.Code,
keystore keystore.Keystore,
accountsConfig *config.AccountsConfig,
) (*config.Account, error) {
rootFingerprint, err := keystore.RootFingerprint()
if err != nil {
return nil, err
}
return lowestHiddenAccount(accountCandidates(accountsConfig, rootFingerprint, coinCode)), nil
}

func lowestHiddenAccount(candidates []accountCandidate) *config.Account {
var result *config.Account
var resultNumber uint16
for _, candidate := range candidates {
if !candidate.account.HiddenBecauseUnused {
continue
}
if result == nil || candidate.number < resultNumber {
result = candidate.account
resultNumber = candidate.number
}
}
return result
}

// nextAccountNumber checks if an account for the given coin can be added, and if so, returns the
// account number of the new account.
func nextAccountNumber(
coinCode coinpkg.Code,
keystore keystore.Keystore,
accountsConfig *config.AccountsConfig,
) (uint16, error) {
rootFingerprint, err := keystore.RootFingerprint()
if err != nil {
return 0, err
}
candidates := accountCandidates(accountsConfig, rootFingerprint, coinCode)
return nextManualAccountNumber(coinCode, candidates)
}

func nextManualAccountNumber(
coinCode coinpkg.Code,
candidates []accountCandidate,
) (uint16, error) {
nextAccountNumber := nextAccountNumberAfter(candidates)
if int(nextAccountNumber) >= accountsHardLimit(coinCode) {
return 0, errp.WithStack(errAccountLimitReached)
}
return nextAccountNumber, nil
}

func nextAccountNumberAfter(candidates []accountCandidate) uint16 {
nextAccountNumber := uint16(0)
for _, candidate := range candidates {
if candidate.number+1 > nextAccountNumber {
nextAccountNumber = candidate.number + 1
}
}
return nextAccountNumber
}

func nextDiscoveryAccountNumber(
coinCode coinpkg.Code,
candidates []accountCandidate,
) (uint16, bool) {
maxAccountNumber := -1
var maxAccount *config.Account
for _, candidate := range candidates {
if maxAccount == nil || int(candidate.number) > maxAccountNumber {
maxAccountNumber = int(candidate.number)
maxAccount = candidate.account
}
}

// Account scan gap limit:
// - Previous account must be used for the next one to be scanned, but:
// - The first accounts up to the hard limit are always scanned as before we had accounts
// discovery, the BitBoxApp allowed manual creation of that many accounts, so we need to scan
// these.
nextAccountNumber := maxAccountNumber + 1
if maxAccount == nil || maxAccount.Used || nextAccountNumber < accountsHardLimit(coinCode) {
return uint16(nextAccountNumber), true
}
return 0, false
}
227 changes: 227 additions & 0 deletions backend/account_planning_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// SPDX-License-Identifier: Apache-2.0

package backend

import (
"testing"

accountsTypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts/types"
coinpkg "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/config"
keystoremock "github.com/BitBoxSwiss/bitbox-wallet-app/backend/keystore/mocks"
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/signing"
"github.com/BitBoxSwiss/bitbox-wallet-app/util/errp"
"github.com/BitBoxSwiss/bitbox-wallet-app/util/test"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/stretchr/testify/require"
)

func planningAccount(
code string,
coinCode coinpkg.Code,
rootFingerprint []byte,
accountNumber uint32,
) *config.Account {
return &config.Account{
Code: accountsTypes.Code(code),
CoinCode: coinCode,
SigningConfigurations: signing.Configurations{
signing.NewBitcoinConfiguration(
signing.ScriptTypeP2WPKH,
rootFingerprint,
signing.NewAbsoluteKeypathFromUint32(
84+hardenedKeystart,
hardenedKeystart,
accountNumber+hardenedKeystart,
),
test.TstMustXKey("xpub6Cxa67Bfe1Aw5VvLM1Ppua9x28CXH1zUYoAuBzFRjR6hWnA6aUcny84KYkeVcZWnWXxKSkxCEyMA8xic54ydBPWm5oziXpsXq6nX8FELMQn"),
),
},
}
}

func planningAccountWithInvalidAccountNumber(
code string,
coinCode coinpkg.Code,
rootFingerprint []byte,
) *config.Account {
return &config.Account{
Code: accountsTypes.Code(code),
CoinCode: coinCode,
SigningConfigurations: signing.Configurations{
signing.NewBitcoinConfiguration(
signing.ScriptTypeP2WPKH,
rootFingerprint,
mustKeypath("m/84'/0'/0/1"),
test.TstMustXKey("xpub6Cxa67Bfe1Aw5VvLM1Ppua9x28CXH1zUYoAuBzFRjR6hWnA6aUcny84KYkeVcZWnWXxKSkxCEyMA8xic54ydBPWm5oziXpsXq6nX8FELMQn"),
),
},
}
}

func TestNextAccountNumber(t *testing.T) {
fingerprintEmpty := []byte{0x77, 0x77, 0x77, 0x77}
ks := func(fingerprint []byte) *keystoremock.KeystoreMock {
return &keystoremock.KeystoreMock{
SupportsCoinFunc: func(coin coinpkg.Coin) bool {
return true
},
RootFingerprintFunc: func() ([]byte, error) {
return fingerprint, nil
},
}
}

xprv, err := hdkeychain.NewMaster(make([]byte, hdkeychain.RecommendedSeedLen), &chaincfg.TestNet3Params)
require.NoError(t, err)
xpub, err := xprv.Neuter()
require.NoError(t, err)

accountsConfig := &config.AccountsConfig{
Accounts: []*config.Account{
{
CoinCode: coinpkg.CodeBTC,
SigningConfigurations: signing.Configurations{
signing.NewBitcoinConfiguration(
signing.ScriptTypeP2WPKH,
rootFingerprint1,
mustKeypath("m/84'/0'/0'"),
xpub,
),
},
},
{
CoinCode: coinpkg.CodeBTC,
SigningConfigurations: signing.Configurations{
signing.NewBitcoinConfiguration(
signing.ScriptTypeP2WPKHP2SH,
rootFingerprint1,
mustKeypath("m/49'/0'/0'"),
xpub,
),
},
},
{
CoinCode: coinpkg.CodeTBTC,
SigningConfigurations: signing.Configurations{
signing.NewBitcoinConfiguration(
signing.ScriptTypeP2WPKH,
rootFingerprint1,
mustKeypath("m/84'/0'/3'"),
xpub,
),
},
},
{
CoinCode: coinpkg.CodeTBTC,
SigningConfigurations: signing.Configurations{
signing.NewBitcoinConfiguration(
signing.ScriptTypeP2WPKH,
rootFingerprint2,
mustKeypath("m/84'/0'/5'"),
xpub,
),
},
},
},
}

num, err := nextAccountNumber(coinpkg.CodeTBTC, ks(fingerprintEmpty), accountsConfig)
require.NoError(t, err)
require.Equal(t, uint16(0), num)

num, err = nextAccountNumber(coinpkg.CodeBTC, ks(rootFingerprint1), accountsConfig)
require.NoError(t, err)
require.Equal(t, uint16(1), num)

num, err = nextAccountNumber(coinpkg.CodeTBTC, ks(rootFingerprint1), accountsConfig)
require.NoError(t, err)
require.Equal(t, uint16(4), num)

_, err = nextAccountNumber(coinpkg.CodeTBTC, ks(rootFingerprint2), accountsConfig)
require.Equal(t, errAccountLimitReached, errp.Cause(err))
}

func TestAccountCandidates(t *testing.T) {
accountsConfig := &config.AccountsConfig{
Accounts: []*config.Account{
planningAccount("btc-0", coinpkg.CodeBTC, rootFingerprint1, 0),
planningAccount("btc-other-fingerprint", coinpkg.CodeBTC, rootFingerprint2, 1),
planningAccount("ltc-2", coinpkg.CodeLTC, rootFingerprint1, 2),
planningAccountWithInvalidAccountNumber("btc-invalid-account-number", coinpkg.CodeBTC, rootFingerprint1),
{Code: "btc-no-config", CoinCode: coinpkg.CodeBTC},
planningAccount("btc-3", coinpkg.CodeBTC, rootFingerprint1, 3),
},
}

candidates := accountCandidates(accountsConfig, rootFingerprint1, coinpkg.CodeBTC)
require.Equal(t, []accountCandidate{
{account: accountsConfig.Accounts[0], number: 0},
{account: accountsConfig.Accounts[5], number: 3},
}, candidates)
}

func TestLowestHiddenAccount(t *testing.T) {
account0 := &config.Account{Code: "account-0"}
account1 := &config.Account{Code: "account-1", HiddenBecauseUnused: true}
account3 := &config.Account{Code: "account-3", HiddenBecauseUnused: true}

require.Equal(t, account1, lowestHiddenAccount([]accountCandidate{
{account: account3, number: 3},
{account: account0, number: 0},
{account: account1, number: 1},
}))
require.Nil(t, lowestHiddenAccount([]accountCandidate{
{account: account0, number: 0},
}))
}

func TestNextManualAccountNumber(t *testing.T) {
account0 := &config.Account{Code: "account-0"}
account3 := &config.Account{Code: "account-3"}
account5 := &config.Account{Code: "account-5"}

accountNumber, err := nextManualAccountNumber(coinpkg.CodeBTC, []accountCandidate{
{account: account0, number: 0},
{account: account3, number: 3},
})
require.NoError(t, err)
require.Equal(t, uint16(4), accountNumber)

accountNumber, err = nextManualAccountNumber(coinpkg.CodeBTC, nil)
require.NoError(t, err)
require.Equal(t, uint16(0), accountNumber)

_, err = nextManualAccountNumber(coinpkg.CodeBTC, []accountCandidate{
{account: account5, number: 5},
})
require.Equal(t, errAccountLimitReached, errp.Cause(err))
}

func TestNextDiscoveryAccountNumber(t *testing.T) {
account4 := &config.Account{Code: "account-4"}
account5 := &config.Account{Code: "account-5"}
usedAccount5 := &config.Account{Code: "used-account-5", Used: true}

accountNumber, ok := nextDiscoveryAccountNumber(coinpkg.CodeBTC, nil)
require.True(t, ok)
require.Equal(t, uint16(0), accountNumber)

accountNumber, ok = nextDiscoveryAccountNumber(coinpkg.CodeBTC, []accountCandidate{
{account: account4, number: 4},
})
require.True(t, ok)
require.Equal(t, uint16(5), accountNumber)

_, ok = nextDiscoveryAccountNumber(coinpkg.CodeBTC, []accountCandidate{
{account: account5, number: 5},
})
require.False(t, ok)

accountNumber, ok = nextDiscoveryAccountNumber(coinpkg.CodeBTC, []accountCandidate{
{account: usedAccount5, number: 5},
})
require.True(t, ok)
require.Equal(t, uint16(6), accountNumber)
}
Loading