From a9ed550185e08ca18c217da14b1574261c135c84 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Tue, 19 May 2026 14:33:50 +0000 Subject: [PATCH] backend: extract account derivation specs --- backend/account_derivation_spec.go | 110 ++++++++++++++++++++ backend/account_derivation_spec_test.go | 129 ++++++++++++++++++++++++ backend/accounts.go | 76 ++++---------- backend/coins/coin/codes.go | 16 +++ backend/coins/coin/codes_test.go | 39 +++++++ 5 files changed, 315 insertions(+), 55 deletions(-) create mode 100644 backend/account_derivation_spec.go create mode 100644 backend/account_derivation_spec_test.go create mode 100644 backend/coins/coin/codes_test.go diff --git a/backend/account_derivation_spec.go b/backend/account_derivation_spec.go new file mode 100644 index 0000000000..68a49fc432 --- /dev/null +++ b/backend/account_derivation_spec.go @@ -0,0 +1,110 @@ +// 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/signing" + "github.com/BitBoxSwiss/bitbox-wallet-app/util/errp" +) + +type accountDerivationKind int + +const ( + accountDerivationKindBTC accountDerivationKind = iota + accountDerivationKindETH +) + +type scriptTypeWithKeypath struct { + scriptType signing.ScriptType + keypath signing.AbsoluteKeypath +} + +type accountDerivationSpec struct { + kind accountDerivationKind + btcConfigs []scriptTypeWithKeypath + ethKeypath signing.AbsoluteKeypath +} + +// newAccountDerivationSpec returns the standard account-level derivation for a coin. +func newAccountDerivationSpec(coinCode coinpkg.Code, accountNumber uint16) (accountDerivationSpec, error) { + coinType, ok := coinpkg.BIP44CoinType(coinCode) + if !ok { + return accountDerivationSpec{}, errp.Newf("Unrecognized coin code: %s", coinCode) + } + + switch coinCode { + case coinpkg.CodeBTC, coinpkg.CodeTBTC, coinpkg.CodeRBTC: + return bitcoinAccountDerivationSpec(coinType, accountNumber), nil + case coinpkg.CodeLTC, coinpkg.CodeTLTC: + return litecoinAccountDerivationSpec(coinType, accountNumber), nil + case coinpkg.CodeETH, coinpkg.CodeSEPETH: + return ethereumAccountDerivationSpec(coinType, accountNumber), nil + default: + return accountDerivationSpec{}, errp.Newf("Unrecognized coin code: %s", coinCode) + } +} + +// bitcoinAccountDerivationSpec returns the BTC account configurations in persistence order. +func bitcoinAccountDerivationSpec( + coinType uint32, + accountNumber uint16, +) accountDerivationSpec { + return accountDerivationSpec{ + kind: accountDerivationKindBTC, + btcConfigs: []scriptTypeWithKeypath{ + btcScriptDerivation(signing.ScriptTypeP2WPKH, 84, coinType, accountNumber), + btcScriptDerivation(signing.ScriptTypeP2TR, 86, coinType, accountNumber), + btcScriptDerivation(signing.ScriptTypeP2WPKHP2SH, 49, coinType, accountNumber), + btcScriptDerivation(signing.ScriptTypeP2PKH, 44, coinType, accountNumber), + }, + } +} + +// litecoinAccountDerivationSpec returns the LTC account configurations in persistence order. +func litecoinAccountDerivationSpec( + coinType uint32, + accountNumber uint16, +) accountDerivationSpec { + return accountDerivationSpec{ + kind: accountDerivationKindBTC, + btcConfigs: []scriptTypeWithKeypath{ + btcScriptDerivation(signing.ScriptTypeP2WPKH, 84, coinType, accountNumber), + btcScriptDerivation(signing.ScriptTypeP2WPKHP2SH, 49, coinType, accountNumber), + }, + } +} + +// ethereumAccountDerivationSpec returns the ETH account derivation path. +func ethereumAccountDerivationSpec( + coinType uint32, + accountNumber uint16, +) accountDerivationSpec { + return accountDerivationSpec{ + kind: accountDerivationKindETH, + ethKeypath: signing.NewAbsoluteKeypathFromUint32( + 44+hardenedKeystart, + coinType+hardenedKeystart, + hardenedKeystart, + 0, + uint32(accountNumber), + ), + } +} + +// btcScriptDerivation returns one script-specific account derivation. +func btcScriptDerivation( + scriptType signing.ScriptType, + purpose uint32, + coinType uint32, + accountNumber uint16, +) scriptTypeWithKeypath { + return scriptTypeWithKeypath{ + scriptType: scriptType, + keypath: signing.NewAbsoluteKeypathFromUint32( + purpose+hardenedKeystart, + coinType+hardenedKeystart, + uint32(accountNumber)+hardenedKeystart, + ), + } +} diff --git a/backend/account_derivation_spec_test.go b/backend/account_derivation_spec_test.go new file mode 100644 index 0000000000..08bc74a832 --- /dev/null +++ b/backend/account_derivation_spec_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "testing" + + coinpkg "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin" + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/signing" + "github.com/stretchr/testify/require" +) + +type expectedScriptDerivation struct { + scriptType signing.ScriptType + keypath string +} + +func TestNewAccountDerivationSpecBitcoinLike(t *testing.T) { + tests := []struct { + name string + coinCode coinpkg.Code + expected []expectedScriptDerivation + }{ + { + name: "btc", + coinCode: coinpkg.CodeBTC, + expected: []expectedScriptDerivation{ + {signing.ScriptTypeP2WPKH, "m/84'/0'/7'"}, + {signing.ScriptTypeP2TR, "m/86'/0'/7'"}, + {signing.ScriptTypeP2WPKHP2SH, "m/49'/0'/7'"}, + {signing.ScriptTypeP2PKH, "m/44'/0'/7'"}, + }, + }, + { + name: "tbtc", + coinCode: coinpkg.CodeTBTC, + expected: []expectedScriptDerivation{ + {signing.ScriptTypeP2WPKH, "m/84'/1'/7'"}, + {signing.ScriptTypeP2TR, "m/86'/1'/7'"}, + {signing.ScriptTypeP2WPKHP2SH, "m/49'/1'/7'"}, + {signing.ScriptTypeP2PKH, "m/44'/1'/7'"}, + }, + }, + { + name: "rbtc", + coinCode: coinpkg.CodeRBTC, + expected: []expectedScriptDerivation{ + {signing.ScriptTypeP2WPKH, "m/84'/1'/7'"}, + {signing.ScriptTypeP2TR, "m/86'/1'/7'"}, + {signing.ScriptTypeP2WPKHP2SH, "m/49'/1'/7'"}, + {signing.ScriptTypeP2PKH, "m/44'/1'/7'"}, + }, + }, + { + name: "ltc", + coinCode: coinpkg.CodeLTC, + expected: []expectedScriptDerivation{ + {signing.ScriptTypeP2WPKH, "m/84'/2'/7'"}, + {signing.ScriptTypeP2WPKHP2SH, "m/49'/2'/7'"}, + }, + }, + { + name: "tltc", + coinCode: coinpkg.CodeTLTC, + expected: []expectedScriptDerivation{ + {signing.ScriptTypeP2WPKH, "m/84'/1'/7'"}, + {signing.ScriptTypeP2WPKHP2SH, "m/49'/1'/7'"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + spec, err := newAccountDerivationSpec(test.coinCode, 7) + require.NoError(t, err) + require.Equal(t, accountDerivationKindBTC, spec.kind) + require.Empty(t, spec.ethKeypath) + requireScriptDerivations(t, test.expected, spec.btcConfigs) + }) + } +} + +func TestNewAccountDerivationSpecEthereum(t *testing.T) { + tests := []struct { + name string + coinCode coinpkg.Code + keypath string + }{ + { + name: "eth", + coinCode: coinpkg.CodeETH, + keypath: "m/44'/60'/0'/0/7", + }, + { + name: "sepeth", + coinCode: coinpkg.CodeSEPETH, + keypath: "m/44'/1'/0'/0/7", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + spec, err := newAccountDerivationSpec(test.coinCode, 7) + require.NoError(t, err) + require.Equal(t, accountDerivationKindETH, spec.kind) + require.Empty(t, spec.btcConfigs) + require.Equal(t, test.keypath, spec.ethKeypath.Encode()) + }) + } +} + +func TestNewAccountDerivationSpecUnsupportedCoin(t *testing.T) { + _, err := newAccountDerivationSpec(coinpkg.Code("doge"), 0) + require.EqualError(t, err, "Unrecognized coin code: doge") +} + +func requireScriptDerivations( + t *testing.T, + expected []expectedScriptDerivation, + actual []scriptTypeWithKeypath, +) { + t.Helper() + + require.Len(t, actual, len(expected)) + for i, expectedDerivation := range expected { + require.Equal(t, expectedDerivation.scriptType, actual[i].scriptType) + require.Equal(t, expectedDerivation.keypath, actual[i].keypath.Encode()) + } +} diff --git a/backend/accounts.go b/backend/accounts.go index 19683c882a..248cf83ed8 100644 --- a/backend/accounts.go +++ b/backend/accounts.go @@ -600,55 +600,30 @@ func (backend *Backend) createAndPersistAccountConfig( WithField("coinCode", coinCode). WithField("accountNumber", accountNumber) log.Info("Persisting new account config") - accountNumberHardened := uint32(accountNumber) + hardenedKeystart - switch coinCode { - case coinpkg.CodeBTC, coinpkg.CodeTBTC, coinpkg.CodeRBTC: - bip44Coin := 1 + hardenedKeystart - if coinCode == coinpkg.CodeBTC { - bip44Coin = hardenedKeystart - } - return accountCode, backend.persistBTCAccountConfig(keystore, accountCoin, - accountCode, - hiddenBecauseUnused, - name, - []scriptTypeWithKeypath{ - {signing.ScriptTypeP2WPKH, signing.NewAbsoluteKeypathFromUint32(84+hardenedKeystart, bip44Coin, accountNumberHardened)}, - {signing.ScriptTypeP2TR, signing.NewAbsoluteKeypathFromUint32(86+hardenedKeystart, bip44Coin, accountNumberHardened)}, - {signing.ScriptTypeP2WPKHP2SH, signing.NewAbsoluteKeypathFromUint32(49+hardenedKeystart, bip44Coin, accountNumberHardened)}, - {signing.ScriptTypeP2PKH, signing.NewAbsoluteKeypathFromUint32(44+hardenedKeystart, bip44Coin, accountNumberHardened)}, - }, - accountsConfig, - ) - case coinpkg.CodeLTC, coinpkg.CodeTLTC: - bip44Coin := 1 + hardenedKeystart - if coinCode == coinpkg.CodeLTC { - bip44Coin = 2 + hardenedKeystart - } + derivationSpec, err := newAccountDerivationSpec(coinCode, accountNumber) + if err != nil { + return "", err + } + + switch derivationSpec.kind { + case accountDerivationKindBTC: return accountCode, backend.persistBTCAccountConfig(keystore, accountCoin, accountCode, hiddenBecauseUnused, name, - []scriptTypeWithKeypath{ - {signing.ScriptTypeP2WPKH, signing.NewAbsoluteKeypathFromUint32(84+hardenedKeystart, bip44Coin, accountNumberHardened)}, - {signing.ScriptTypeP2WPKHP2SH, signing.NewAbsoluteKeypathFromUint32(49+hardenedKeystart, bip44Coin, accountNumberHardened)}, - }, + derivationSpec.btcConfigs, accountsConfig, ) - case coinpkg.CodeETH, coinpkg.CodeSEPETH: - bip44Coin := "1'" - if coinCode == coinpkg.CodeETH { - bip44Coin = "60'" - } + case accountDerivationKindETH: return accountCode, backend.persistETHAccountConfig( keystore, accountCoin, accountCode, hiddenBecauseUnused, - // TODO: Use []uint32 instead of a string keypath - fmt.Sprintf("m/44'/%s/0'/0/%d", bip44Coin, accountNumber), + derivationSpec.ethKeypath, name, activeTokens, accountsConfig) default: - return "", errp.Newf("Unrecognized coin code: %s", coinCode) + panic("unhandled account derivation kind") } } @@ -1167,11 +1142,6 @@ func (backend *Backend) persistAccount(account config.Account, accountsConfig *c return nil } -type scriptTypeWithKeypath struct { - scriptType signing.ScriptType - keypath signing.AbsoluteKeypath -} - // adds a combined BTC account with the given script types. func (backend *Backend) persistBTCAccountConfig( keystore keystore.Keystore, @@ -1235,7 +1205,7 @@ func (backend *Backend) persistETHAccountConfig( coin coinpkg.Coin, code accountsTypes.Code, hiddenBecauseUnused bool, - keypath string, + keypath signing.AbsoluteKeypath, name string, activeTokens []string, accountsConfig *config.AccountsConfig, @@ -1243,7 +1213,7 @@ func (backend *Backend) persistETHAccountConfig( log := backend.log. WithField("code", code). WithField("name", name). - WithField("keypath", keypath) + WithField("keypath", keypath.Encode()) if !keystore.SupportsAccount(coin, nil) { log.Info("skipping unsupported account") @@ -1251,11 +1221,7 @@ func (backend *Backend) persistETHAccountConfig( } log.Info("persist account") - absoluteKeypath, err := signing.NewAbsoluteKeypath(keypath) - if err != nil { - panic(err) - } - extendedPublicKey, err := keystore.ExtendedPublicKey(coin, absoluteKeypath) + extendedPublicKey, err := keystore.ExtendedPublicKey(coin, keypath) if err != nil { return err } @@ -1267,7 +1233,7 @@ func (backend *Backend) persistETHAccountConfig( signingConfigurations := signing.Configurations{ signing.NewEthereumConfiguration( rootFingerprint, - absoluteKeypath, + keypath, extendedPublicKey, ), } @@ -1401,18 +1367,18 @@ func (backend *Backend) maybeAddP2TR(keystore keystore.Keystore, accounts []*con if err != nil { return err } - bip44Coin := 1 + hardenedKeystart - if account.CoinCode == coinpkg.CodeBTC { - bip44Coin = hardenedKeystart + bip44Coin, ok := coinpkg.BIP44CoinType(account.CoinCode) + if !ok { + return errp.Newf("Unrecognized coin code: %s", account.CoinCode) } accountNumber, err := account.SigningConfigurations[0].AccountNumber() if err != nil { return err } keypath := signing.NewAbsoluteKeypathFromUint32( - 86+hdkeychain.HardenedKeyStart, - bip44Coin, - uint32(accountNumber)+hdkeychain.HardenedKeyStart) + 86+hardenedKeystart, + bip44Coin+hardenedKeystart, + uint32(accountNumber)+hardenedKeystart) extendedPublicKey, err := keystore.ExtendedPublicKey(accountCoin, keypath) if err != nil { return err diff --git a/backend/coins/coin/codes.go b/backend/coins/coin/codes.go index 3f1e29adf2..c7d4b8d12c 100644 --- a/backend/coins/coin/codes.go +++ b/backend/coins/coin/codes.go @@ -24,6 +24,22 @@ const ( // There are some more coin codes for the supported erc20 tokens in erc20.go. ) +var bip44CoinTypes = map[Code]uint32{ + CodeBTC: 0, + CodeTBTC: 1, + CodeRBTC: 1, + CodeLTC: 2, + CodeTLTC: 1, + CodeETH: 60, + CodeSEPETH: 1, +} + +// BIP44CoinType returns the unhardened BIP44 coin type for a coin code. +func BIP44CoinType(code Code) (uint32, bool) { + coinType, ok := bip44CoinTypes[code] + return coinType, ok +} + // BtcUnit defines how BTC values are formatted. type BtcUnit string diff --git a/backend/coins/coin/codes_test.go b/backend/coins/coin/codes_test.go new file mode 100644 index 0000000000..0a729c6de8 --- /dev/null +++ b/backend/coins/coin/codes_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 + +package coin_test + +import ( + "testing" + + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin" + "github.com/stretchr/testify/require" +) + +func TestBIP44CoinType(t *testing.T) { + tests := []struct { + name string + code coin.Code + coinType uint32 + }{ + {name: "btc", code: coin.CodeBTC, coinType: 0}, + {name: "tbtc", code: coin.CodeTBTC, coinType: 1}, + {name: "rbtc", code: coin.CodeRBTC, coinType: 1}, + {name: "ltc", code: coin.CodeLTC, coinType: 2}, + {name: "tltc", code: coin.CodeTLTC, coinType: 1}, + {name: "eth", code: coin.CodeETH, coinType: 60}, + {name: "sepeth", code: coin.CodeSEPETH, coinType: 1}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + coinType, ok := coin.BIP44CoinType(test.code) + require.True(t, ok) + require.Equal(t, test.coinType, coinType) + }) + } +} + +func TestBIP44CoinTypeUnsupportedCoin(t *testing.T) { + _, ok := coin.BIP44CoinType("doge") + require.False(t, ok) +}