Skip to content

frontend: add backup reminder banner. #3446

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
74 changes: 42 additions & 32 deletions backend/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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{}
Expand Down
59 changes: 59 additions & 0 deletions backend/accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

}
50 changes: 50 additions & 0 deletions backend/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change this "1000" to a lower value for testing

Fiat: defaultFiat,
}
}
4 changes: 4 additions & 0 deletions backend/rates/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions frontends/web/src/api/backupBanner.ts
Original file line number Diff line number Diff line change
@@ -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<TShowBackupBannerResponse> => {
return apiGet(`keystore/show-backup-banner/${rootFingerprint}`);
};
83 changes: 83 additions & 0 deletions frontends/web/src/components/banners/backup.tsx
Original file line number Diff line number Diff line change
@@ -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<TShowBackupBannerResponse | null>(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<HTMLAnchorElement>) => {
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 (
<Status
type="info"
hidden={!bannerResponse.show}
dismissible={`banner-backup-${keystore.rootFingerprint}`}>
<MultilineMarkup
tagName="span"
withBreaks
markup={t('account.backupReminder',
{
name: keystore.name,
fiat: bannerResponse.fiat,
})}
/>
<Link to="#" onClick={maybeNavigateToSettings} >{t('account.backupReminderLink')} </Link>
</Status>
);
};
2 changes: 2 additions & 0 deletions frontends/web/src/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"account": {
"account": "Account",
"accounts": "Accounts",
"backupReminder": "Your wallet <strong>{{name}}</strong> 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",
Expand Down
13 changes: 13 additions & 0 deletions frontends/web/src/routes/account/summary/accountssummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -169,6 +170,10 @@ export const AccountsSummary = ({
getAccountsBalanceSummary();
}, [onStatusChanged, getAccountsBalanceSummary, accounts]);

useEffect(() => {
getAccountsBalanceSummary();
}, [defaultCurrency, getAccountsBalanceSummary]);

return (
<GuideWrapper>
<GuidedContent>
Expand All @@ -177,6 +182,14 @@ export const AccountsSummary = ({
<Status hidden={!hasCard} type="warning">
{t('warning.sdcard')}
</Status>
{accountsByKeystore.map(({ keystore, accounts }) => (
<BackupReminder
key={keystore.rootFingerprint}
keystore={keystore}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accountsBalanceSummary?.keystoresBalance[keystore.rootFingerprint].total contains the fiat total already, but I guess the issue is that you need to revert to USD of it's not one of the three currencies?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't actually need the total in the banner, because the total is only used by the backend to determine whether the banner needs to be displayed. but yes, I need to revert to USD as well.

(also, AFAIK accountsBalanceSummary?.keystoresBalance[keystore.rootFingerprint].total has the total as a string, and it would be ugly to have logic to decide whether to display the banner in the frontend AND operating on strings rather than numbers, imho)

accountsBalanceSummary={accountsBalanceSummary}
accountCode={accounts[0].code}
/>
))}
</ContentWrapper>
<Header title={<h2>{t('accountSummary.title')}</h2>}>
<HideAmountsButton />
Expand Down