diff --git a/backend/accounts.go b/backend/accounts.go index 1669c35c71..f7b9e0a195 100644 --- a/backend/accounts.go +++ b/backend/accounts.go @@ -343,6 +343,45 @@ type KeystoreBalance = struct { CoinsBalance AmountsByCoin `json:"coinsBalance"` } +// AccountsFiatAndCoinBalance returns the total fiat balance and the balance for each coin, of a list of accounts. +func (backend *Backend) AccountsFiatAndCoinBalance(accounts AccountsList, fiatUnit string) (*big.Rat, map[coinpkg.Code]*big.Int, error) { + keystoreBalance := new(big.Rat) + keystoreCoinsBalance := make(map[coinpkg.Code]*big.Int) + + for _, account := range accounts { + if account.Config().Config.Inactive || account.Config().Config.HiddenBecauseUnused { + continue + } + if account.FatalError() { + continue + } + err := account.Initialize() + if err != nil { + return nil, nil, err + } + + accountFiatBalance, err := backend.accountFiatBalance(account, fiatUnit) + if err != nil { + return nil, nil, err + } + keystoreBalance.Add(keystoreBalance, accountFiatBalance) + + coinCode := account.Coin().Code() + balance, err := account.Balance() + if err != nil { + return nil, nil, err + } + accountBalance := balance.Available().BigInt() + if _, ok := keystoreCoinsBalance[coinCode]; !ok { + keystoreCoinsBalance[coinCode] = accountBalance + } else { + keystoreCoinsBalance[coinCode] = new(big.Int).Add(keystoreCoinsBalance[coinCode], accountBalance) + } + } + + return keystoreBalance, keystoreCoinsBalance, nil +} + // keystoresBalance returns a map of accounts' total balances across coins, grouped by keystore. func (backend *Backend) keystoresBalance() (map[string]KeystoreBalance, error) { keystoreBalanceMap := make(map[string]KeystoreBalance) @@ -353,38 +392,9 @@ func (backend *Backend) keystoresBalance() (map[string]KeystoreBalance, error) { return nil, err } for rootFingerprint, accountList := range accountsByKeystore { - keystoreCoinsBalance := make(map[coinpkg.Code]*big.Int) - keystoreTotalBalance := new(big.Rat) - for _, account := range accountList { - if account.Config().Config.Inactive || account.Config().Config.HiddenBecauseUnused { - continue - } - if account.FatalError() { - continue - } - err := account.Initialize() - if err != nil { - return nil, err - } - - accountFiatBalance, err := backend.accountFiatBalance(account, fiatUnit) - if err != nil { - return nil, err - } - keystoreTotalBalance.Add(keystoreTotalBalance, accountFiatBalance) - - coinCode := account.Coin().Code() - balance, err := account.Balance() - if err != nil { - return nil, err - } - accountBalance := balance.Available().BigInt() - if _, ok := keystoreCoinsBalance[coinCode]; !ok { - keystoreCoinsBalance[coinCode] = accountBalance - - } else { - keystoreCoinsBalance[coinCode] = new(big.Int).Add(keystoreCoinsBalance[coinCode], accountBalance) - } + keystoreTotalBalance, keystoreCoinsBalance, err := backend.AccountsFiatAndCoinBalance(accountList, fiatUnit) + if err != nil { + return nil, err } keystoreCoinsAmount := AmountsByCoin{} diff --git a/backend/accounts_test.go b/backend/accounts_test.go index 971bd42c2a..a05ba2d043 100644 --- a/backend/accounts_test.go +++ b/backend/accounts_test.go @@ -1575,3 +1575,62 @@ func TestCoinsTotalBalance(t *testing.T) { require.Equal(t, coinpkg.CodeETH, coinsTotalBalance[2].CoinCode) require.Equal(t, "4", coinsTotalBalance[2].FormattedAmount.Amount) } + +func TestAccountsFiatAndCoinBalance(t *testing.T) { + b := newBackend(t, testnetDisabled, regtestDisabled) + defer b.Close() + + b.makeBtcAccount = func(config *accounts.AccountConfig, coin *btc.Coin, gapLimits *types.GapLimits, getAddress func(*btc.Account, blockchain.ScriptHashHex) (*addresses.AccountAddress, bool, error), log *logrus.Entry) accounts.Interface { + accountMock := MockBtcAccount(t, config, coin, gapLimits, log) + accountMock.BalanceFunc = func() (*accounts.Balance, error) { + return accounts.NewBalance(coinpkg.NewAmountFromInt64(1e8), coinpkg.NewAmountFromInt64(0)), nil + } + return accountMock + } + + b.makeEthAccount = func(config *accounts.AccountConfig, coin *eth.Coin, httpClient *http.Client, log *logrus.Entry) accounts.Interface { + accountMock := MockEthAccount(config, coin, httpClient, log) + accountMock.BalanceFunc = func() (*accounts.Balance, error) { + return accounts.NewBalance(coinpkg.NewAmountFromInt64(1e18), coinpkg.NewAmountFromInt64(0)), nil + } + return accountMock + } + + ks1 := makeBitBox02Multi() + + ks1Fingerprint, err := ks1.RootFingerprint() + require.NoError(t, err) + + b.registerKeystore(ks1) + require.NoError(t, b.SetWatchonly(ks1Fingerprint, true)) + + // Up to 6 hidden accounts for BTC/LTC are added to be scanned even if the accounts are all + // empty. Calling this function too many times does not add more than that. + for i := 1; i <= 10; i++ { + b.maybeAddHiddenUnusedAccounts() + } + + // This needs to be after all changes in accounts, otherwise it will try to fetch + // new values and fail. + b.ratesUpdater = rates.MockRateUpdater() + defer b.ratesUpdater.Stop() + + accountsByKestore, err := b.AccountsByKeystore() + require.NoError(t, err) + + expectedCurrencies := map[rates.Fiat]string{ + rates.USD: "22.00", + rates.EUR: "18.90", + rates.CHF: "19.95", + } + + accountList, ok := accountsByKestore[hex.EncodeToString(ks1Fingerprint)] + require.True(t, ok, "Expected accounts for keystore with fingerprint %s", hex.EncodeToString(ks1Fingerprint)) + + for currency, expectedBalance := range expectedCurrencies { + balance, _, err := b.AccountsFiatAndCoinBalance(accountList, string(currency)) + require.NoError(t, err) + require.Equalf(t, expectedBalance, balance.FloatString(2), "Got balance of %s, expected %s", balance.FloatString(2), expectedBalance) + } + +} diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index ebfd25838b..7bff8ba971 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -26,6 +26,7 @@ import ( "net/http" "os" "runtime/debug" + "slices" "github.com/BitBoxSwiss/bitbox-wallet-app/backend" "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts" @@ -72,6 +73,7 @@ type Backend interface { Testing() bool Accounts() backend.AccountsList AccountsByKeystore() (backend.KeystoresAccountsListMap, error) + AccountsFiatAndCoinBalance(backend.AccountsList, string) (*big.Rat, map[coinpkg.Code]*big.Int, error) Keystore() keystore.Keystore AccountsBalanceSummary() (*backend.AccountsBalanceSummary, error) OnAccountInit(f func(accounts.Interface)) @@ -262,6 +264,7 @@ func NewHandlers( getAPIRouterNoError(apiRouter)("/bluetooth/connect", handlers.postBluetoothConnect).Methods("POST") getAPIRouterNoError(apiRouter)("/online", handlers.getOnline).Methods("GET") + getAPIRouterNoError(apiRouter)("/keystore/show-backup-banner/{rootFingerprint}", handlers.getKeystoreShowBackupBanner).Methods("GET") devicesRouter := getAPIRouterNoError(apiRouter.PathPrefix("/devices").Subrouter()) devicesRouter("/registered", handlers.getDevicesRegistered).Methods("GET") @@ -1494,3 +1497,50 @@ func (handlers *Handlers) postBluetoothConnect(r *http.Request) interface{} { func (handlers *Handlers) getOnline(r *http.Request) interface{} { return handlers.backend.IsOnline() } + +func (handlers *Handlers) getKeystoreShowBackupBanner(r *http.Request) interface{} { + rootFingerint := mux.Vars(r)["rootFingerprint"] + + type response struct { + Success bool `json:"success"` + Show bool `json:"show,omitempty"` + Fiat string `json:"fiat,omitempty"` + } + + keystoresAccounts, err := handlers.backend.AccountsByKeystore() + if err != nil { + handlers.log.WithError(err).Error("Could not retrieve accounts by keystore") + return response{ + Success: false, + } + } + + defaultFiat := handlers.backend.Config().AppConfig().Backend.MainFiat + + if !slices.Contains([]string{"USD", "CHF", "EUR"}, defaultFiat) { + // For the banner, if the fiat currency used by the user is not of these three, + // we default to USD. + defaultFiat = "USD" + } + + accounts, ok := keystoresAccounts[rootFingerint] + if !ok { + handlers.log.WithField("fingerprint", rootFingerint).Error("No accounts found for the given root fingerprint") + return response{ + Success: false, + } + } + balance, _, err := handlers.backend.AccountsFiatAndCoinBalance(accounts, defaultFiat) + if err != nil { + handlers.log.WithError(err).Error("Could not retrieve fiat balance for account") + return response{ + Success: false, + } + } + + return response{ + Success: true, + Show: balance.Cmp(big.NewRat(1000, 1)) > 0, + Fiat: defaultFiat, + } +} diff --git a/backend/rates/mock.go b/backend/rates/mock.go index 6a0bd9bd4b..7c608d2a3c 100644 --- a/backend/rates/mock.go +++ b/backend/rates/mock.go @@ -36,9 +36,13 @@ func MockRateUpdater() *RateUpdater { updater.last = map[string]map[string]float64{ "BTC": { "USD": 21.0, + "EUR": 18.0, + "CHF": 19.0, }, "ETH": { "USD": 1.0, + "EUR": 0.9, + "CHF": 0.95, }, } return updater diff --git a/frontends/web/src/api/backupBanner.ts b/frontends/web/src/api/backupBanner.ts new file mode 100644 index 0000000000..3cb120525a --- /dev/null +++ b/frontends/web/src/api/backupBanner.ts @@ -0,0 +1,28 @@ + +/** + * Copyright 2025 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { apiGet } from '@/utils/request'; + +export type TShowBackupBannerResponse = { + success: boolean; + show?: boolean; + fiat?: string; +} + +export const getShowBackupBanner = (rootFingerprint: string): Promise => { + return apiGet(`keystore/show-backup-banner/${rootFingerprint}`); +}; diff --git a/frontends/web/src/components/banners/backup.tsx b/frontends/web/src/components/banners/backup.tsx new file mode 100644 index 0000000000..509d002fcc --- /dev/null +++ b/frontends/web/src/components/banners/backup.tsx @@ -0,0 +1,83 @@ +/** + * Copyright 2025 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getDeviceList } from '@/api/devices'; +import { Link, useNavigate } from 'react-router-dom'; +import { connectKeystore } from '@/api/account'; +import { Status } from '@/components/status/status'; +import { MultilineMarkup } from '@/utils/markup'; +import { TKeystore } from '@/api/account'; +import { AppContext } from '@/contexts/AppContext'; +import { getShowBackupBanner, TShowBackupBannerResponse } from '@/api/backupBanner'; +import { useContext, useEffect, useState } from 'react'; +import { TAccountsBalanceSummary } from '@/api/account'; + +type BackupReminderProps = { + keystore: TKeystore; + accountsBalanceSummary?: TAccountsBalanceSummary; + accountCode: string; +} + +export const BackupReminder = ({ keystore, accountsBalanceSummary, accountCode }: BackupReminderProps) => { + const { t } = useTranslation(); + const [bannerResponse, setBannerResponse] = useState(null); + const { hideAmounts } = useContext(AppContext); + const navigate = useNavigate(); + + useEffect(() => { + getShowBackupBanner(keystore.rootFingerprint).then(setBannerResponse); + }, [keystore.rootFingerprint, accountsBalanceSummary]); + + + if (hideAmounts) { + // If amounts are hidden, we don't show the backup reminder. + return; + } + + if (!bannerResponse || !bannerResponse.success) { + return null; + } + + const maybeNavigateToSettings = async (e: MouseEvent) => { + e.preventDefault(); + const connectResult = await connectKeystore(accountCode); + if (connectResult.success) { + const devices = await getDeviceList(); + const deviceSettingsURL = `/settings/device-settings/${Object.keys(devices)[0]}`; + // Proceed to the setting screen if the keystore was connected. + navigate(deviceSettingsURL); + } + }; + + return ( + + ); +}; diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 88f0449757..1f9cc73957 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -2,6 +2,8 @@ "account": { "account": "Account", "accounts": "Accounts", + "backupReminder": "Your wallet {{name}} just passed {{fiat}} 1000!\n\nWe recommend creating a paper backup for extra protection. It's quick and simple.", + "backupReminderLink": "Create paper backup", "disconnect": "Connection lost. Retrying…", "export": "Export", "exportTransactions": "Export transactions to downloads folder as CSV file", diff --git a/frontends/web/src/routes/account/summary/accountssummary.tsx b/frontends/web/src/routes/account/summary/accountssummary.tsx index 052b3234d5..6ebd47c3ef 100644 --- a/frontends/web/src/routes/account/summary/accountssummary.tsx +++ b/frontends/web/src/routes/account/summary/accountssummary.tsx @@ -38,6 +38,7 @@ import { AppContext } from '@/contexts/AppContext'; import { getAccountsByKeystore, isAmbiguousName } from '@/routes/account/utils'; import { RatesContext } from '@/contexts/RatesContext'; import { ContentWrapper } from '@/components/contentwrapper/contentwrapper'; +import { BackupReminder } from '@/components/banners/backup'; type TProps = { accounts: accountApi.IAccount[]; @@ -169,6 +170,10 @@ export const AccountsSummary = ({ getAccountsBalanceSummary(); }, [onStatusChanged, getAccountsBalanceSummary, accounts]); + useEffect(() => { + getAccountsBalanceSummary(); + }, [defaultCurrency, getAccountsBalanceSummary]); + return ( @@ -177,6 +182,14 @@ export const AccountsSummary = ({ + {accountsByKeystore.map(({ keystore, accounts }) => ( + + ))}
{t('accountSummary.title')}}>