Skip to content

Commit 9946d6a

Browse files
committed
frontend: add backup reminder banner.
For each wallet, the first time it crosses 1000 USD/CHF/EUR, display a banner suggesting users to create a paper backup of their seed.
1 parent 63286f4 commit 9946d6a

File tree

8 files changed

+284
-33
lines changed

8 files changed

+284
-33
lines changed

backend/accounts.go

Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,45 @@ type KeystoreBalance = struct {
343343
CoinsBalance AmountsByCoin `json:"coinsBalance"`
344344
}
345345

346+
// AccountsFiatAndCoinBalance returns the total fiat balance of a list of accounts.
347+
func (backend *Backend) AccountsFiatAndCoinBalance(accounts AccountsList, fiatUnit string) (*big.Rat, map[coinpkg.Code]*big.Int, error) {
348+
keystoreBalance := new(big.Rat)
349+
keystoreCoinsBalance := make(map[coinpkg.Code]*big.Int)
350+
351+
for _, account := range accounts {
352+
if account.Config().Config.Inactive || account.Config().Config.HiddenBecauseUnused {
353+
continue
354+
}
355+
if account.FatalError() {
356+
continue
357+
}
358+
err := account.Initialize()
359+
if err != nil {
360+
return nil, nil, err
361+
}
362+
363+
accountFiatBalance, err := backend.accountFiatBalance(account, fiatUnit)
364+
if err != nil {
365+
return nil, nil, err
366+
}
367+
keystoreBalance.Add(keystoreBalance, accountFiatBalance)
368+
369+
coinCode := account.Coin().Code()
370+
balance, err := account.Balance()
371+
if err != nil {
372+
return nil, nil, err
373+
}
374+
accountBalance := balance.Available().BigInt()
375+
if _, ok := keystoreCoinsBalance[coinCode]; !ok {
376+
keystoreCoinsBalance[coinCode] = accountBalance
377+
} else {
378+
keystoreCoinsBalance[coinCode] = new(big.Int).Add(keystoreCoinsBalance[coinCode], accountBalance)
379+
}
380+
}
381+
382+
return keystoreBalance, keystoreCoinsBalance, nil
383+
}
384+
346385
// keystoresBalance returns a map of accounts' total balances across coins, grouped by keystore.
347386
func (backend *Backend) keystoresBalance() (map[string]KeystoreBalance, error) {
348387
keystoreBalanceMap := make(map[string]KeystoreBalance)
@@ -353,38 +392,9 @@ func (backend *Backend) keystoresBalance() (map[string]KeystoreBalance, error) {
353392
return nil, err
354393
}
355394
for rootFingerprint, accountList := range accountsByKeystore {
356-
keystoreCoinsBalance := make(map[coinpkg.Code]*big.Int)
357-
keystoreTotalBalance := new(big.Rat)
358-
for _, account := range accountList {
359-
if account.Config().Config.Inactive || account.Config().Config.HiddenBecauseUnused {
360-
continue
361-
}
362-
if account.FatalError() {
363-
continue
364-
}
365-
err := account.Initialize()
366-
if err != nil {
367-
return nil, err
368-
}
369-
370-
accountFiatBalance, err := backend.accountFiatBalance(account, fiatUnit)
371-
if err != nil {
372-
return nil, err
373-
}
374-
keystoreTotalBalance.Add(keystoreTotalBalance, accountFiatBalance)
375-
376-
coinCode := account.Coin().Code()
377-
balance, err := account.Balance()
378-
if err != nil {
379-
return nil, err
380-
}
381-
accountBalance := balance.Available().BigInt()
382-
if _, ok := keystoreCoinsBalance[coinCode]; !ok {
383-
keystoreCoinsBalance[coinCode] = accountBalance
384-
385-
} else {
386-
keystoreCoinsBalance[coinCode] = new(big.Int).Add(keystoreCoinsBalance[coinCode], accountBalance)
387-
}
395+
keystoreTotalBalance, keystoreCoinsBalance, err := backend.AccountsFiatAndCoinBalance(accountList, fiatUnit)
396+
if err != nil {
397+
return nil, err
388398
}
389399

390400
keystoreCoinsAmount := AmountsByCoin{}

backend/accounts_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1575,3 +1575,62 @@ func TestCoinsTotalBalance(t *testing.T) {
15751575
require.Equal(t, coinpkg.CodeETH, coinsTotalBalance[2].CoinCode)
15761576
require.Equal(t, "4", coinsTotalBalance[2].FormattedAmount.Amount)
15771577
}
1578+
1579+
func TestAccountsFiatAndCoinBalance(t *testing.T) {
1580+
b := newBackend(t, testnetDisabled, regtestDisabled)
1581+
defer b.Close()
1582+
1583+
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 {
1584+
accountMock := MockBtcAccount(t, config, coin, gapLimits, log)
1585+
accountMock.BalanceFunc = func() (*accounts.Balance, error) {
1586+
return accounts.NewBalance(coinpkg.NewAmountFromInt64(1e8), coinpkg.NewAmountFromInt64(0)), nil
1587+
}
1588+
return accountMock
1589+
}
1590+
1591+
b.makeEthAccount = func(config *accounts.AccountConfig, coin *eth.Coin, httpClient *http.Client, log *logrus.Entry) accounts.Interface {
1592+
accountMock := MockEthAccount(config, coin, httpClient, log)
1593+
accountMock.BalanceFunc = func() (*accounts.Balance, error) {
1594+
return accounts.NewBalance(coinpkg.NewAmountFromInt64(1e18), coinpkg.NewAmountFromInt64(0)), nil
1595+
}
1596+
return accountMock
1597+
}
1598+
1599+
ks1 := makeBitBox02Multi()
1600+
1601+
ks1Fingerprint, err := ks1.RootFingerprint()
1602+
require.NoError(t, err)
1603+
1604+
b.registerKeystore(ks1)
1605+
require.NoError(t, b.SetWatchonly(ks1Fingerprint, true))
1606+
1607+
// Up to 6 hidden accounts for BTC/LTC are added to be scanned even if the accounts are all
1608+
// empty. Calling this function too many times does not add more than that.
1609+
for i := 1; i <= 10; i++ {
1610+
b.maybeAddHiddenUnusedAccounts()
1611+
}
1612+
1613+
// This needs to be after all changes in accounts, otherwise it will try to fetch
1614+
// new values and fail.
1615+
b.ratesUpdater = rates.MockRateUpdater()
1616+
defer b.ratesUpdater.Stop()
1617+
1618+
accountsByKestore, err := b.AccountsByKeystore()
1619+
require.NoError(t, err)
1620+
1621+
expectedCurrencies := map[rates.Fiat]string{
1622+
rates.USD: "22.00",
1623+
rates.EUR: "18.90",
1624+
rates.CHF: "19.95",
1625+
}
1626+
1627+
accountList, ok := accountsByKestore[hex.EncodeToString(ks1Fingerprint)]
1628+
require.True(t, ok, "Expected accounts for keystore with fingerprint %s", hex.EncodeToString(ks1Fingerprint))
1629+
1630+
for currency, expectedBalance := range expectedCurrencies {
1631+
balance, _, err := b.AccountsFiatAndCoinBalance(accountList, string(currency))
1632+
require.NoError(t, err)
1633+
require.Equalf(t, expectedBalance, balance.FloatString(2), "Got balance of %s, expected %s", balance.FloatString(2), expectedBalance)
1634+
}
1635+
1636+
}

backend/handlers/handlers.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"net/http"
2727
"os"
2828
"runtime/debug"
29+
"slices"
2930

3031
"github.com/BitBoxSwiss/bitbox-wallet-app/backend"
3132
"github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts"
@@ -72,6 +73,7 @@ type Backend interface {
7273
Testing() bool
7374
Accounts() backend.AccountsList
7475
AccountsByKeystore() (backend.KeystoresAccountsListMap, error)
76+
AccountsFiatAndCoinBalance(backend.AccountsList, string) (*big.Rat, map[coinpkg.Code]*big.Int, error)
7577
Keystore() keystore.Keystore
7678
AccountsBalanceSummary() (*backend.AccountsBalanceSummary, error)
7779
OnAccountInit(f func(accounts.Interface))
@@ -262,6 +264,7 @@ func NewHandlers(
262264
getAPIRouterNoError(apiRouter)("/bluetooth/connect", handlers.postBluetoothConnect).Methods("POST")
263265

264266
getAPIRouterNoError(apiRouter)("/online", handlers.getOnline).Methods("GET")
267+
getAPIRouterNoError(apiRouter)("/keystore/show-backup-banner/{rootFingerprint}", handlers.getKeystoreShowBackupBanner).Methods("GET")
265268

266269
devicesRouter := getAPIRouterNoError(apiRouter.PathPrefix("/devices").Subrouter())
267270
devicesRouter("/registered", handlers.getDevicesRegistered).Methods("GET")
@@ -1494,3 +1497,50 @@ func (handlers *Handlers) postBluetoothConnect(r *http.Request) interface{} {
14941497
func (handlers *Handlers) getOnline(r *http.Request) interface{} {
14951498
return handlers.backend.IsOnline()
14961499
}
1500+
1501+
func (handlers *Handlers) getKeystoreShowBackupBanner(r *http.Request) interface{} {
1502+
rootFingerint := mux.Vars(r)["rootFingerprint"]
1503+
1504+
type response struct {
1505+
Success bool `json:"success"`
1506+
Show bool `json:"show,omitempty"`
1507+
Fiat string `json:"fiat,omitempty"`
1508+
}
1509+
1510+
keystoresAccounts, err := handlers.backend.AccountsByKeystore()
1511+
if err != nil {
1512+
handlers.log.WithError(err).Error("Could not retrieve accounts by keystore")
1513+
return response{
1514+
Success: false,
1515+
}
1516+
}
1517+
1518+
defaultFiat := handlers.backend.Config().AppConfig().Backend.MainFiat
1519+
1520+
if !slices.Contains([]string{"USD", "CHF", "EUR"}, defaultFiat) {
1521+
// For the banner, if the fiat currency used by the user is not of these three,
1522+
// we default to USD.
1523+
defaultFiat = "USD"
1524+
}
1525+
1526+
accounts, ok := keystoresAccounts[rootFingerint]
1527+
if !ok {
1528+
handlers.log.WithField("fingerprint", rootFingerint).Error("No accounts found for the given root fingerprint")
1529+
return response{
1530+
Success: false,
1531+
}
1532+
}
1533+
balance, _, err := handlers.backend.AccountsFiatAndCoinBalance(accounts, defaultFiat)
1534+
if err != nil {
1535+
handlers.log.WithError(err).Error("Could not retrieve fiat balance for account")
1536+
return response{
1537+
Success: false,
1538+
}
1539+
}
1540+
1541+
return response{
1542+
Success: true,
1543+
Show: balance.Cmp(big.NewRat(1000, 1)) > 0,
1544+
Fiat: defaultFiat,
1545+
}
1546+
}

backend/rates/mock.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,13 @@ func MockRateUpdater() *RateUpdater {
3636
updater.last = map[string]map[string]float64{
3737
"BTC": {
3838
"USD": 21.0,
39+
"EUR": 18.0,
40+
"CHF": 19.0,
3941
},
4042
"ETH": {
4143
"USD": 1.0,
44+
"EUR": 0.9,
45+
"CHF": 0.95,
4246
},
4347
}
4448
return updater

frontends/web/src/api/backupBanner.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
2+
/**
3+
* Copyright 2025 Shift Crypto AG
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { apiGet } from '@/utils/request';
19+
20+
export type TShowBackupBannerResponse = {
21+
success: boolean;
22+
show?: boolean;
23+
fiat?: string;
24+
}
25+
26+
export const getShowBackupBanner = (rootFingerprint: string): Promise<TShowBackupBannerResponse> => {
27+
return apiGet(`keystore/show-backup-banner/${rootFingerprint}`);
28+
};
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Copyright 2025 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { MouseEvent } from 'react';
17+
import { useTranslation } from 'react-i18next';
18+
import { getDeviceList } from '@/api/devices';
19+
import { Link, useNavigate } from 'react-router-dom';
20+
import { connectKeystore } from '@/api/account';
21+
import { Status } from '@/components/status/status';
22+
import { MultilineMarkup } from '@/utils/markup';
23+
import { TKeystore } from '@/api/account';
24+
import { AppContext } from '@/contexts/AppContext';
25+
import { getShowBackupBanner, TShowBackupBannerResponse } from '@/api/backupBanner';
26+
import { useContext, useEffect, useState } from 'react';
27+
import { TAccountsBalanceSummary } from '@/api/account';
28+
29+
type BackupReminderProps = {
30+
keystore: TKeystore;
31+
accountsBalanceSummary?: TAccountsBalanceSummary;
32+
accountCode: string;
33+
}
34+
35+
// TODO remove and possibly accountCode
36+
export const BackupReminder = ({ keystore, accountsBalanceSummary, accountCode }: BackupReminderProps) => {
37+
const { t } = useTranslation();
38+
const [bannerResponse, setBannerResponse] = useState<TShowBackupBannerResponse | null>(null);
39+
const { hideAmounts } = useContext(AppContext);
40+
const navigate = useNavigate();
41+
42+
useEffect(() => {
43+
getShowBackupBanner(keystore.rootFingerprint).then(setBannerResponse);
44+
}, [keystore.rootFingerprint, accountsBalanceSummary]);
45+
46+
47+
if (hideAmounts) {
48+
// If amounts are hidden, we don't show the backup reminder.
49+
return;
50+
}
51+
52+
if (!bannerResponse || !bannerResponse.success) {
53+
return null;
54+
}
55+
56+
const maybeNavigateToSettings = async (e: MouseEvent<HTMLAnchorElement>) => {
57+
e.preventDefault();
58+
const connectResult = await connectKeystore(accountCode);
59+
if (connectResult.success) {
60+
const devices = await getDeviceList();
61+
const deviceSettingsURL = `/settings/device-settings/${Object.keys(devices)[0]}`;
62+
// Proceed to the setting screen if the keystore was connected.
63+
navigate(deviceSettingsURL);
64+
}
65+
};
66+
67+
return (
68+
<Status
69+
type="info"
70+
hidden={!bannerResponse.show}
71+
dismissible={`banner-backup-${keystore.rootFingerprint}`}>
72+
<MultilineMarkup
73+
tagName="span"
74+
withBreaks
75+
markup={t('account.backupReminder',
76+
{
77+
name: keystore.name,
78+
fiat: bannerResponse.fiat,
79+
})}
80+
/>
81+
<Link to="#" onClick={maybeNavigateToSettings} >{t('account.backupReminderLink')} </Link>
82+
</Status>
83+
);
84+
};

frontends/web/src/locales/en/app.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"account": {
33
"account": "Account",
44
"accounts": "Accounts",
5+
"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.",
6+
"backupReminderLink": "Create paper backup",
57
"disconnect": "Connection lost. Retrying…",
68
"export": "Export",
79
"exportTransactions": "Export transactions to downloads folder as CSV file",
@@ -638,7 +640,8 @@
638640
"confirmOnDevice": "Please confirm on your device.",
639641
"connectKeystore": {
640642
"promptNoName": "Please connect your BitBox to continue",
641-
"promptWithName": "Please connect your BitBox named \"{{name}}\" to continue"
643+
"promptWithName": "Please connect your BitBox named \"{{name}}\" to continue",
644+
"promptWithNameForBackup": "Please connect your BitBox named \"{{name}}\" to create a paper backup"
642645
},
643646
"darkmode": {
644647
"toggle": "Dark mode"

0 commit comments

Comments
 (0)