Repository: joinmarket-webui/joinmarket-webui
Pull request #419 (2022-07-21 14:34:42)

refactor: move BalanceSummary to `WalletContext`
Part of #381.

Moves balance calculation from multiple components into `WalletContext`.

Balance Summary will be created _once_ on every reload of the wallet data (responses from `/display` and `/utxo`), instead of calculating it multiple times on every component independently even when the underlying data did not change.

This is solely moving code around and does not add any new functionality.
The actual calculation has been moved from `hooks/BalanceSummary.ts` to `context/BalanceSummary.ts` and not added to file `context/WalletContext.tsx` as to be able to independently test the code (and to keep the context file clearly arranged and separated).


Patch:
diff --git a/src/components/CurrentWalletMagic.jsx b/src/components/CurrentWalletMagic.jsx
index 92e0bf4..7f79202 100644
--- a/src/components/CurrentWalletMagic.jsx
+++ b/src/components/CurrentWalletMagic.jsx
@@ -10,7 +10,6 @@ import { walletDisplayName } from '../utils'
 import styles from './CurrentWalletMagic.module.css'
 import { ExtendedLink } from './ExtendedLink'
 import { routes } from '../constants/routes'
-import { useBalanceSummary } from '../hooks/BalanceSummary'
 import { DisplayAccountsOverlay } from './DisplayAccountsOverlay'
 import { Jars } from './Jars'
 
@@ -50,7 +49,6 @@ export default function CurrentWalletMagic() {
   const settingsDispatch = useSettingsDispatch()
   const currentWallet = useCurrentWallet()
   const currentWalletInfo = useCurrentWalletInfo()
-  const balanceSummary = useBalanceSummary(currentWalletInfo)
   const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
 
   const [alert, setAlert] = useState(null)
@@ -66,7 +64,7 @@ export default function CurrentWalletMagic() {
 
   const onJarClicked = (accountIndex) => {
     if (accountIndex === 0) {
-      const isEmpty = Number(balanceSummary?.accountBalances[accountIndex]?.totalBalance) === 0
+      const isEmpty = Number(currentWalletInfo?.balanceSummary.accountBalances[accountIndex]?.totalBalance) === 0
 
       if (isEmpty) {
         navigate(routes.receive, { state: { account: accountIndex } })
@@ -115,7 +113,7 @@ export default function CurrentWalletMagic() {
       <rb.Row onClick={() => settingsDispatch({ showBalance: !settings.showBalance })} style={{ cursor: 'pointer' }}>
         <WalletHeader
           name={currentWallet?.name}
-          balance={balanceSummary?.totalBalance}
+          balance={currentWalletInfo?.balanceSummary.totalBalance}
           unit={settings.unit}
           showBalance={settings.showBalance}
           loading={isLoading}
@@ -165,8 +163,8 @@ export default function CurrentWalletMagic() {
                 </rb.Placeholder>
               ) : (
                 <Jars
-                  accountBalances={balanceSummary?.accountBalances}
-                  totalBalance={balanceSummary?.totalBalance}
+                  accountBalances={currentWalletInfo?.balanceSummary.accountBalances}
+                  totalBalance={currentWalletInfo?.balanceSummary.totalBalance}
                   onClick={onJarClicked}
                 />
               )}
diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx
index 05219ca..6114b48 100644
--- a/src/components/Earn.jsx
+++ b/src/components/Earn.jsx
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
 import { useSettings } from '../context/SettingsContext'
 import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
-import { useBalanceSummary } from '../hooks/BalanceSummary'
 import Sprite from './Sprite'
 import PageTitle from './PageTitle'
 import SegmentedTabs from './SegmentedTabs'
@@ -91,7 +90,6 @@ export default function Earn() {
   const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
   const serviceInfo = useServiceInfo()
   const reloadServiceInfo = useReloadServiceInfo()
-  const balanceSummary = useBalanceSummary(currentWalletInfo)
 
   const [showAdvancedSettings, setShowAdvancedSettings] = useState(false)
   const [alert, setAlert] = useState(null)
@@ -308,11 +306,11 @@ export default function Earn() {
                   {!serviceInfo?.makerRunning &&
                     !isWaitingMakerStart &&
                     !isWaitingMakerStop &&
-                    (!isLoading && balanceSummary ? (
+                    (!isLoading && currentWalletInfo ? (
                       <CreateFidelityBond
                         otherFidelityBondExists={fidelityBonds.length > 0}
-                        accountBalances={balanceSummary?.accountBalances}
-                        totalBalance={balanceSummary?.totalBalance}
+                        accountBalances={currentWalletInfo.balanceSummary.accountBalances}
+                        totalBalance={currentWalletInfo.balanceSummary.totalBalance}
                         wallet={currentWallet}
                         walletInfo={currentWalletInfo}
                         onDone={() => reloadFidelityBonds({ delay: RELOAD_FIDELITY_BONDS_DELAY_MS })}
diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx
index 10e017e..5cd7b91 100644
--- a/src/components/Jam.jsx
+++ b/src/components/Jam.jsx
@@ -7,7 +7,6 @@ import { useSettings } from '../context/SettingsContext'
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
 import { isDebugFeatureEnabled } from '../constants/debugFeatures'
-import { useBalanceSummary } from '../hooks/BalanceSummary'
 import { COINJOIN_PRECONDITIONS, useCoinjoinPreconditionSummary } from '../hooks/CoinjoinPrecondition'
 import PageTitle from './PageTitle'
 import ToggleSwitch from './ToggleSwitch'
@@ -46,7 +45,6 @@ export default function Jam() {
   const reloadServiceInfo = useReloadServiceInfo()
   const wallet = useCurrentWallet()
   const walletInfo = useCurrentWalletInfo()
-  const balanceSummary = useBalanceSummary(walletInfo)
   const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
 
   const [alert, setAlert] = useState(null)
@@ -315,7 +313,7 @@ export default function Jam() {
               </>
             </rb.Alert>
           </rb.Fade>
-          {!collaborativeOperationRunning && balanceSummary && (
+          {!collaborativeOperationRunning && walletInfo && (
             <>
               <div className="d-flex align-items-center justify-content-between mb-4">
                 <div className="d-flex align-items-center gap-2">
@@ -329,7 +327,7 @@ export default function Jam() {
                 </div>
                 <>
                   <Balance
-                    valueString={`${balanceSummary.calculatedAvailableBalanceInSats}`}
+                    valueString={`${walletInfo.balanceSummary.calculatedAvailableBalanceInSats}`}
                     convertToUnit={settings.unit}
                     showBalance={settings.showBalance}
                   />
diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx
index aa00da0..5099470 100644
--- a/src/components/Navbar.jsx
+++ b/src/components/Navbar.jsx
@@ -10,7 +10,6 @@ import { useCurrentWallet, useCurrentWalletInfo } from '../context/WalletContext
 import { useServiceInfo, useSessionConnectionError } from '../context/ServiceInfoContext'
 import { walletDisplayName } from '../utils'
 import { routes } from '../constants/routes'
-import { useBalanceSummary } from '../hooks/BalanceSummary'
 
 const WalletPreview = ({ wallet, totalBalance, unit, showBalance }) => {
   return (
@@ -131,7 +130,6 @@ export default function Navbar() {
   const settings = useSettings()
   const currentWallet = useCurrentWallet()
   const currentWalletInfo = useCurrentWalletInfo()
-  const balanceSummary = useBalanceSummary(currentWalletInfo)
 
   const serviceInfo = useServiceInfo()
   const sessionConnectionError = useSessionConnectionError()
@@ -206,7 +204,7 @@ export default function Navbar() {
                       <>
                         <WalletPreview
                           wallet={currentWallet}
-                          totalBalance={balanceSummary?.totalBalance}
+                          totalBalance={currentWalletInfo?.balanceSummary.totalBalance}
                           showBalance={settings.showBalance}
                           unit={settings.unit}
                         />
diff --git a/src/components/Receive.jsx b/src/components/Receive.jsx
index d2623dc..9277f00 100644
--- a/src/components/Receive.jsx
+++ b/src/components/Receive.jsx
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
 
 import { useSettings } from '../context/SettingsContext'
 import { useCurrentWallet, useCurrentWalletInfo } from '../context/WalletContext'
-import { useBalanceSummary } from '../hooks/BalanceSummary'
 import * as Api from '../libs/JmWalletApi'
 
 import { BitcoinQR } from './BitcoinQR'
@@ -22,7 +21,6 @@ export default function Receive() {
   const settings = useSettings()
   const currentWallet = useCurrentWallet()
   const walletInfo = useCurrentWalletInfo()
-  const balanceSummary = useBalanceSummary(walletInfo)
   const [validated, setValidated] = useState(false)
   const [alert, setAlert] = useState(null)
   const [isLoading, setIsLoading] = useState(false)
@@ -33,9 +31,11 @@ export default function Receive() {
   const [showSettings, setShowSettings] = useState(false)
 
   const sortedAccountBalances = useMemo(() => {
-    if (!balanceSummary) return []
-    return Object.values(balanceSummary.accountBalances).sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex)
-  }, [balanceSummary])
+    if (!walletInfo) return []
+    return Object.values(walletInfo.balanceSummary.accountBalances).sort(
+      (lhs, rhs) => lhs.accountIndex - rhs.accountIndex
+    )
+  }, [walletInfo])
 
   useEffect(() => {
     const abortCtrl = new AbortController()
@@ -119,7 +119,7 @@ export default function Receive() {
           </rb.Button>
           {showSettings && (
             <div className="my-4">
-              {!balanceSummary || sortedAccountBalances.length === 0 ? (
+              {!walletInfo || sortedAccountBalances.length === 0 ? (
                 <rb.Placeholder as="div" animation="wave">
                   <rb.Placeholder className={styles.jarsPlaceholder} />
                 </rb.Placeholder>
@@ -132,7 +132,7 @@ export default function Receive() {
                       balance={it.totalBalance}
                       isSelectable={true}
                       isSelected={it.accountIndex === selectedJarIndex}
-                      fillLevel={calculateFillLevel(it.totalBalance, balanceSummary.totalBalance)}
+                      fillLevel={calculateFillLevel(it.totalBalance, walletInfo.balanceSummary.totalBalance)}
                       onClick={() => setSelectedJarIndex(it.accountIndex)}
                     />
                   ))}
diff --git a/src/components/Send.jsx b/src/components/Send.jsx
index da5eea1..a96fe07 100644
--- a/src/components/Send.jsx
+++ b/src/components/Send.jsx
@@ -13,7 +13,6 @@ import { useReloadCurrentWalletInfo, useCurrentWallet, useCurrentWalletInfo } fr
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { useLoadConfigValue } from '../context/ServiceConfigContext'
 import { useSettings } from '../context/SettingsContext'
-import { useBalanceSummary } from '../hooks/BalanceSummary'
 import { COINJOIN_PRECONDITIONS, useCoinjoinPreconditionSummary } from '../hooks/CoinjoinPrecondition'
 
 import * as Api from '../libs/JmWalletApi'
@@ -309,10 +308,9 @@ export default function Send() {
   const [numCollaborators, setNumCollaborators] = useState(initialNumCollaborators(minNumCollaborators))
   const [formIsValid, setFormIsValid] = useState(false)
 
-  const balanceSummary = useBalanceSummary(walletInfo)
   const accountBalanceOrNull = useMemo(
-    () => (balanceSummary && balanceSummary.accountBalances[account]) || null,
-    [balanceSummary, account]
+    () => (walletInfo && walletInfo.balanceSummary.accountBalances[account]) || null,
+    [walletInfo, account]
   )
 
   const sourceJarUtxos = useMemo(() => {
@@ -708,12 +706,12 @@ export default function Send() {
           <CoinjoinPreconditionFailedAlert coinjoinPreconditionSummary={coinjoinPreconditionSummary} />
         )}
 
-        {!isLoading && balanceSummary && (
+        {!isLoading && walletInfo && (
           <JarSelectorModal
             isShown={destinationJarPickerShown}
             title={'Select a jar from your wallet to send the funds to.'}
-            accountBalances={balanceSummary?.accountBalances}
-            totalBalance={balanceSummary?.totalBalance}
+            accountBalances={walletInfo.balanceSummary.accountBalances}
+            totalBalance={walletInfo.balanceSummary.totalBalance}
             disabledJar={account}
             onCancel={() => setDestinationJarPickerShown(false)}
             onConfirm={(selectedJar) => {
@@ -755,8 +753,8 @@ export default function Send() {
                 isInvalid={!isValidAccount(account)}
                 disabled={isOperationDisabled}
               >
-                {balanceSummary &&
-                  Object.values(balanceSummary.accountBalances)
+                {walletInfo &&
+                  Object.values(walletInfo.balanceSummary.accountBalances)
                     .sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex)
                     .map(({ accountIndex, totalBalance, calculatedTotalBalanceInSats }) => (
                       <option key={accountIndex} value={accountIndex}>
diff --git a/src/components/fb/FidelityBondSteps.tsx b/src/components/fb/FidelityBondSteps.tsx
index 25cf64a..b2c96c2 100644
--- a/src/components/fb/FidelityBondSteps.tsx
+++ b/src/components/fb/FidelityBondSteps.tsx
@@ -4,7 +4,7 @@ import classnamesBind from 'classnames/bind'
 import * as Api from '../../libs/JmWalletApi'
 import { useTranslation } from 'react-i18next'
 import { useSettings } from '../../context/SettingsContext'
-import { AccountBalances, AccountBalanceSummary } from '../../hooks/BalanceSummary'
+import { AccountBalances, AccountBalanceSummary } from '../../context/BalanceSummary'
 import { Utxo, AddressStatus, WalletInfo } from '../../context/WalletContext'
 import { calculateFillLevel, SelectableJar } from '../jars/Jar'
 import Sprite from '../Sprite'
diff --git a/src/context/BalanceSummary.ts b/src/context/BalanceSummary.ts
new file mode 100644
index 0000000..45d7c00
--- /dev/null
+++ b/src/context/BalanceSummary.ts
@@ -0,0 +1,124 @@
+import { CombinedRawWalletData, BalanceString, Utxos } from '../context/WalletContext'
+
+import * as fb from '../components/fb/utils'
+import { AmountSats } from '../libs/JmWalletApi'
+
+type Milliseconds = number
+
+interface BalanceSummary {
+  totalBalance: BalanceString
+  /**
+   * @since clientserver v0.9.7
+   * @description available balance (total - frozen - locked)
+   *   This value can report less than available in case of address reuse.
+   *   See https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1285#issuecomment-1136438072
+   *   Utxos controlled by the same key will not be taken into account if at least one output is
+   *   frozen (last checked on 2022-05-24).
+   *
+   *   Please use {@link BalanceSummarySupport#calculatedAvailableBalanceInSats}) if applicable.
+   */
+  availableBalance: BalanceString
+}
+
+type BalanceSummarySupport = BalanceSummary & {
+  /**
+   * @description Manually calculated total balance in sats.
+   */
+  calculatedTotalBalanceInSats: AmountSats
+  /**
+   * @description Manually calculated available balance in sats.
+   *   Same as {@link BalanceSummary#availableBalance} except address reuse is taken into account.
+   */
+  calculatedAvailableBalanceInSats: AmountSats
+  /**
+   * @description Manually calculated frozen or locked balance in sats.
+   */
+  calculatedFrozenOrLockedBalanceInSats: AmountSats
+}
+
+type AccountBalanceSummary = BalanceSummarySupport & {
+  accountIndex: number
+}
+
+type AccountBalances = {
+  [key: number]: AccountBalanceSummary
+}
+
+export type WalletBalanceSummary = BalanceSummarySupport & {
+  accountBalances: AccountBalances
+}
+
+const calculateFrozenOrLockedBalance = (utxos: Utxos, refTime: Milliseconds = Date.now()) => {
+  const frozenOrLockedUtxos = utxos.filter((utxo) => utxo.frozen || fb.utxo.isLocked(utxo, refTime))
+  return frozenOrLockedUtxos.reduce((acc, utxo) => acc + utxo.value, 0)
+}
+
+const toBalanceSummary = (rawWalletData: CombinedRawWalletData, now?: Milliseconds): WalletBalanceSummary => {
+  const refTime = now !== undefined ? now : Date.now()
+
+  const accounts = rawWalletData.display.walletinfo.accounts
+  const utxos = rawWalletData.utxos.utxos
+
+  const walletBalanceSummary: BalanceSummary = {
+    totalBalance: rawWalletData.display.walletinfo.total_balance,
+    availableBalance: rawWalletData.display.walletinfo.available_balance,
+  }
+  const utxosByAccount = utxos.reduce((acc, utxo) => {
+    const key = `${utxo.mixdepth}`
+    acc[key] = acc[key] || []
+    acc[key].push(utxo)
+    return acc
+  }, {} as { [key: string]: Utxos })
+
+  const totalCalculatedByAccount = Object.fromEntries(
+    Object.entries(utxosByAccount).map(([account, utxos]) => {
+      return [account, utxos.reduce((acc, utxo) => acc + utxo.value, 0)]
+    })
+  )
+  const frozenOrLockedCalculatedByAccount = Object.fromEntries(
+    Object.entries(utxosByAccount).map(([account, utxos]) => {
+      return [account, calculateFrozenOrLockedBalance(utxos, refTime)]
+    })
+  )
+
+  const accountsBalanceSummary = accounts
+    .map(({ account, account_balance, available_balance }) => {
+      const accountBalanceSummary: BalanceSummary = {
+        totalBalance: account_balance,
+        availableBalance: available_balance,
+      }
+      const accountTotalCalculated: AmountSats = totalCalculatedByAccount[account] || 0
+      const accountFrozenOrLockedCalculated: AmountSats = frozenOrLockedCalculatedByAccount[account] || 0
+      const accountAvailableCalculated: AmountSats = accountTotalCalculated - accountFrozenOrLockedCalculated
+      return {
+        ...accountBalanceSummary,
+        calculatedTotalBalanceInSats: accountTotalCalculated,
+        calculatedFrozenOrLockedBalanceInSats: accountFrozenOrLockedCalculated,
+        calculatedAvailableBalanceInSats: accountAvailableCalculated,
+        accountIndex: parseInt(account, 10),
+      } as AccountBalanceSummary
+    })
+    .reduce((acc, curr) => ({ ...acc, [curr.accountIndex]: curr }), {} as AccountBalances)
+
+  const walletTotalCalculated: AmountSats = Object.values(totalCalculatedByAccount).reduce(
+    (acc, totalSats) => acc + totalSats,
+    0
+  )
+
+  const walletFrozenOrLockedCalculated: AmountSats = Object.values(frozenOrLockedCalculatedByAccount).reduce(
+    (acc, frozenOrLockedSats) => acc + frozenOrLockedSats,
+    0
+  )
+
+  const walletAvailableCalculated = walletTotalCalculated - walletFrozenOrLockedCalculated
+
+  return {
+    ...walletBalanceSummary,
+    accountBalances: accountsBalanceSummary,
+    calculatedTotalBalanceInSats: walletTotalCalculated,
+    calculatedFrozenOrLockedBalanceInSats: walletFrozenOrLockedCalculated,
+    calculatedAvailableBalanceInSats: walletAvailableCalculated,
+  }
+}
+
+export { toBalanceSummary, AccountBalances, AccountBalanceSummary }
diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx
index 8fa506b..1d6fe90 100644
--- a/src/context/WalletContext.tsx
+++ b/src/context/WalletContext.tsx
@@ -3,6 +3,8 @@ import React, { createContext, useEffect, useCallback, useState, useContext, Pro
 import { getSession } from '../session'
 import * as Api from '../libs/JmWalletApi'
 
+import { WalletBalanceSummary, toBalanceSummary } from './BalanceSummary'
+
 export interface CurrentWallet {
   name: string
   token: string
@@ -69,7 +71,7 @@ export interface BranchEntry {
   extradata: string
 }
 
-type CombinedRawWalletData = {
+export type CombinedRawWalletData = {
   utxos: UtxosResponse
   display: WalletDisplayResponse
 }
@@ -88,6 +90,7 @@ type FidenlityBondSummary = {
 }
 
 export interface WalletInfo {
+  balanceSummary: WalletBalanceSummary
   addressSummary: AddressSummary
   fidelityBondSummary: FidenlityBondSummary
   data: CombinedRawWalletData
@@ -151,10 +154,12 @@ const loadWalletInfoData = async ({
 }
 
 const toWalletInfo = (data: CombinedRawWalletData): WalletInfo => {
+  const balanceSummary = toBalanceSummary(data)
   const addressSummary = toAddressSummary(data)
   const fidelityBondSummary = toFidelityBondSummary(data)
 
   return {
+    balanceSummary,
     addressSummary,
     fidelityBondSummary,
     data,
diff --git a/src/hooks/BalanceSummary.ts b/src/hooks/BalanceSummary.ts
deleted file mode 100644
index a797bc0..0000000
--- a/src/hooks/BalanceSummary.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { useEffect, useMemo, useState } from 'react'
-import { WalletInfo, BalanceString, Utxos } from '../context/WalletContext'
-
-import * as fb from '../components/fb/utils'
-import { AmountSats } from '../libs/JmWalletApi'
-
-type Milliseconds = number
-
-interface BalanceSummary {
-  totalBalance: BalanceString
-  /**
-   * @since clientserver v0.9.7
-   * @description available balance (total - frozen - locked)
-   *   This value can report less than available in case of address reuse.
-   *   See https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1285#issuecomment-1136438072
-   *   Utxos controlled by the same key will not be taken into account if at least one output is
-   *   frozen (last checked on 2022-05-24).
-   *
-   *   Please use {@link BalanceSummarySupport.calculatedAvailableBalanceInSats}) if applicable.
-   */
-  availableBalance: BalanceString
-}
-
-type BalanceSummarySupport = BalanceSummary & {
-  /**
-   * @description Manually calculated total balance in sats.
-   */
-  calculatedTotalBalanceInSats: AmountSats
-  /**
-   * @description Manually calculated available balance in sats.
-   *   Same as {@link BalanceSummary.availableBalance} except address reuse is taken into account.
-   */
-  calculatedAvailableBalanceInSats: AmountSats
-  /**
-   * @description Manually calculated frozen or locked balance in sats.
-   */
-  calculatedFrozenOrLockedBalanceInSats: AmountSats
-}
-
-type AccountBalanceSummary = BalanceSummarySupport & {
-  accountIndex: number
-}
-
-type AccountBalances = {
-  [key: number]: AccountBalanceSummary
-}
-
-export type WalletBalanceSummary = BalanceSummarySupport & {
-  accountBalances: AccountBalances
-}
-
-const calculateFrozenOrLockedBalance = (utxos: Utxos, refTime: Milliseconds = Date.now()) => {
-  const frozenOrLockedUtxos = utxos.filter((utxo) => utxo.frozen || fb.utxo.isLocked(utxo, refTime))
-  return frozenOrLockedUtxos.reduce((acc, utxo) => acc + utxo.value, 0)
-}
-
-const useBalanceSummary = (currentWalletInfo: WalletInfo | null, now?: Milliseconds): WalletBalanceSummary | null => {
-  const [refTime, setRefTime] = useState<Milliseconds>(now !== undefined ? now : Date.now())
-
-  useEffect(() => {
-    if (!currentWalletInfo) return
-    setRefTime(now !== undefined ? now : Date.now())
-  }, [now, currentWalletInfo])
-
-  const balanceSummary = useMemo(() => {
-    if (!currentWalletInfo) {
-      return null
-    }
-
-    const accounts = currentWalletInfo.data.display.walletinfo.accounts
-    const utxos = currentWalletInfo.data.utxos.utxos
-
-    try {
-      const walletBalanceSummary: BalanceSummary = {
-        totalBalance: currentWalletInfo.data.display.walletinfo.total_balance,
-        availableBalance: currentWalletInfo.data.display.walletinfo.available_balance,
-      }
-      const utxosByAccount = utxos.reduce((acc, utxo) => {
-        const key = `${utxo.mixdepth}`
-        acc[key] = acc[key] || []
-        acc[key].push(utxo)
-        return acc
-      }, {} as { [key: string]: Utxos })
-
-      const totalCalculatedByAccount = Object.fromEntries(
-        Object.entries(utxosByAccount).map(([account, utxos]) => {
-          return [account, utxos.reduce((acc, utxo) => acc + utxo.value, 0)]
-        })
-      )
-      const frozenOrLockedCalculatedByAccount = Object.fromEntries(
-        Object.entries(utxosByAccount).map(([account, utxos]) => {
-          return [account, calculateFrozenOrLockedBalance(utxos, refTime)]
-        })
-      )
-
-      const accountsBalanceSummary = accounts
-        .map(({ account, account_balance, available_balance }) => {
-          const accountBalanceSummary: BalanceSummary = {
-            totalBalance: account_balance,
-            availableBalance: available_balance,
-          }
-          const accountTotalCalculated: AmountSats = totalCalculatedByAccount[account] || 0
-          const accountFrozenOrLockedCalculated: AmountSats = frozenOrLockedCalculatedByAccount[account] || 0
-          const accountAvailableCalculated: AmountSats = accountTotalCalculated - accountFrozenOrLockedCalculated
-          return {
-            ...accountBalanceSummary,
-            calculatedTotalBalanceInSats: accountTotalCalculated,
-            calculatedFrozenOrLockedBalanceInSats: accountFrozenOrLockedCalculated,
-            calculatedAvailableBalanceInSats: accountAvailableCalculated,
-            accountIndex: parseInt(account, 10),
-          } as AccountBalanceSummary
-        })
-        .reduce((acc, curr) => ({ ...acc, [curr.accountIndex]: curr }), {} as AccountBalances)
-
-      const walletTotalCalculated: AmountSats = Object.values(totalCalculatedByAccount).reduce(
-        (acc, totalSats) => acc + totalSats,
-        0
-      )
-
-      const walletFrozenOrLockedCalculated: AmountSats = Object.values(frozenOrLockedCalculatedByAccount).reduce(
-        (acc, frozenOrLockedSats) => acc + frozenOrLockedSats,
-        0
-      )
-
-      const walletAvailableCalculated = walletTotalCalculated - walletFrozenOrLockedCalculated
-
-      return {
-        ...walletBalanceSummary,
-        accountBalances: accountsBalanceSummary,
-        calculatedTotalBalanceInSats: walletTotalCalculated,
-        calculatedFrozenOrLockedBalanceInSats: walletFrozenOrLockedCalculated,
-        calculatedAvailableBalanceInSats: walletAvailableCalculated,
-      }
-    } catch (e) {
-      console.warn('"useBalanceSummary" hook cannot determine balance format', e)
-      return null
-    }
-  }, [currentWalletInfo, refTime])
-
-  return balanceSummary
-}
-
-export { useBalanceSummary, AccountBalances, AccountBalanceSummary }


Test patch:
diff --git a/src/context/BalanceSummary.test.tsx b/src/context/BalanceSummary.test.tsx
new file mode 100644
index 0000000..143dfd2
--- /dev/null
+++ b/src/context/BalanceSummary.test.tsx
@@ -0,0 +1,188 @@
+import { toBalanceSummary } from './BalanceSummary'
+import { Utxo } from '../context/WalletContext'
+
+const now = Date.UTC(2009, 0, 3)
+
+describe('BalanceSummary', () => {
+  it('should handle <=v0.9.6 balance format', () => {
+    const balanceSummary = toBalanceSummary(
+      {
+        utxos: {
+          utxos: [],
+        },
+        display: {
+          walletinfo: {
+            wallet_name: 'test.jmdat',
+            total_balance: '2.00000000',
+            available_balance: '1.00000000',
+            accounts: [],
+          },
+        },
+      },
+      now
+    )
+
+    expect(balanceSummary).not.toBeNull()
+    expect(balanceSummary!.totalBalance).toBe('2.00000000')
+    expect(balanceSummary!.availableBalance).toBe('1.00000000')
+  })
+
+  it('should populate balance properties calculated from utxo data', () => {
+    const balanceSummary = toBalanceSummary(
+      {
+        utxos: {
+          utxos: [
+            {
+              value: 1,
+              mixdepth: 0,
+              frozen: false,
+            } as Utxo,
+            {
+              value: 2,
+              mixdepth: 0,
+              frozen: false,
+              // unfrozen but not yet expired
+              locktime: '2999-12',
+              path: `m/84'/1'/0'/0/2:${now / 1_000 + 1}`,
+            } as Utxo,
+            {
+              value: 3,
+              mixdepth: 0,
+              confirmations: 0,
+              frozen: true,
+            } as Utxo,
+            {
+              value: 4,
+              mixdepth: 0,
+              // unfrozen and expired
+              frozen: false,
+              locktime: '2009-01',
+              path: `m/84'/1'/0'/0/2:${now / 1_000 - 1}`,
+            } as Utxo,
+          ],
+        },
+        display: {
+          walletinfo: {
+            wallet_name: 'test.jmdat',
+            total_balance: '0.00000010',
+            available_balance: '0.00000005',
+            accounts: [],
+          },
+        },
+      },
+      now
+    )
+
+    expect(balanceSummary).not.toBeNull()
+    expect(balanceSummary!.totalBalance).toBe('0.00000010')
+    expect(balanceSummary!.availableBalance).toBe('0.00000005')
+    expect(balanceSummary!.calculatedTotalBalanceInSats).toBe(10)
+    expect(balanceSummary!.calculatedAvailableBalanceInSats).toBe(5)
+    expect(balanceSummary!.calculatedFrozenOrLockedBalanceInSats).toBe(5)
+    expect(balanceSummary!.accountBalances).toEqual({})
+  })
+
+  it('should populate account balance data', () => {
+    const balanceSummary = toBalanceSummary(
+      {
+        utxos: {
+          utxos: [
+            {
+              value: 111111111,
+              mixdepth: 1,
+            } as Utxo,
+            {
+              value: 222222222,
+              mixdepth: 2,
+            } as Utxo,
+            {
+              value: 11111111,
+              mixdepth: 2,
+              frozen: true,
+            } as Utxo,
+            {
+              value: 333333333,
+              mixdepth: 3,
+              confirmations: 0,
+              frozen: true,
+            } as Utxo,
+          ],
+        },
+        display: {
+          walletinfo: {
+            wallet_name: 'test.jmdat',
+            total_balance: '6.77777777',
+            available_balance: '3.33333333',
+            accounts: [
+              {
+                account: '0',
+                account_balance: '0.00000000',
+                available_balance: '0.00000000',
+                branches: [],
+              },
+              {
+                account: '1',
+                account_balance: '1.11111111',
+                available_balance: '1.11111111',
+                branches: [],
+              },
+              {
+                account: '2',
+                account_balance: '2.33333333',
+                available_balance: '2.22222222',
+                branches: [],
+              },
+              {
+                account: '3',
+                account_balance: '3.33333333',
+                available_balance: '0.00000000',
+                branches: [],
+              },
+            ],
+          },
+        },
+      },
+      now
+    )
+
+    expect(balanceSummary).not.toBeNull()
+    expect(balanceSummary!.totalBalance).toBe('6.77777777')
+    expect(balanceSummary!.availableBalance).toBe('3.33333333')
+    expect(balanceSummary!.calculatedTotalBalanceInSats).toBe(677777777)
+    expect(balanceSummary!.calculatedAvailableBalanceInSats).toBe(333333333)
+    expect(balanceSummary!.calculatedFrozenOrLockedBalanceInSats).toBe(344444444)
+    expect(Object.keys(balanceSummary!.accountBalances)).toHaveLength(4)
+
+    const account0 = balanceSummary!.accountBalances[0]
+    expect(account0.accountIndex).toBe(0)
+    expect(account0.totalBalance).toBe('0.00000000')
+    expect(account0.availableBalance).toBe('0.00000000')
+    expect(account0.calculatedTotalBalanceInSats).toBe(0)
+    expect(account0.calculatedAvailableBalanceInSats).toBe(0)
+    expect(account0.calculatedFrozenOrLockedBalanceInSats).toBe(0)
+
+    const account1 = balanceSummary!.accountBalances[1]
+    expect(account1.accountIndex).toBe(1)
+    expect(account1.totalBalance).toBe('1.11111111')
+    expect(account1.availableBalance).toBe('1.11111111')
+    expect(account1.calculatedTotalBalanceInSats).toBe(111111111)
+    expect(account1.calculatedAvailableBalanceInSats).toBe(111111111)
+    expect(account1.calculatedFrozenOrLockedBalanceInSats).toBe(0)
+
+    const account2 = balanceSummary!.accountBalances[2]
+    expect(account2.accountIndex).toBe(2)
+    expect(account2.totalBalance).toBe('2.33333333')
+    expect(account2.availableBalance).toBe('2.22222222')
+    expect(account2.calculatedTotalBalanceInSats).toBe(233333333)
+    expect(account2.calculatedAvailableBalanceInSats).toBe(222222222)
+    expect(account2.calculatedFrozenOrLockedBalanceInSats).toBe(11111111)
+
+    const account3 = balanceSummary!.accountBalances[3]
+    expect(account3.accountIndex).toBe(3)
+    expect(account3.totalBalance).toBe('3.33333333')
+    expect(account3.availableBalance).toBe('0.00000000')
+    expect(account3.calculatedTotalBalanceInSats).toBe(333333333)
+    expect(account3.calculatedAvailableBalanceInSats).toBe(0)
+    expect(account3.calculatedFrozenOrLockedBalanceInSats).toBe(333333333)
+  })
+})
diff --git a/src/hooks/BalanceSummary.test.tsx b/src/hooks/BalanceSummary.test.tsx
deleted file mode 100644
index 9b44f1c..0000000
--- a/src/hooks/BalanceSummary.test.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-import React from 'react'
-import { render } from '../testUtils'
-import { act } from 'react-dom/test-utils'
-
-import { useBalanceSummary, WalletBalanceSummary } from './BalanceSummary'
-import { WalletInfo, Utxo } from '../context/WalletContext'
-
-const now = Date.UTC(2009, 0, 3)
-
-describe('BalanceSummary', () => {
-  function setup(walletInfo: WalletInfo | null, refTime: number) {
-    const returnVal: { data: WalletBalanceSummary | null | undefined } = { data: undefined }
-    const TestComponent: React.FunctionComponent = () => {
-      returnVal.data = useBalanceSummary(walletInfo, refTime)
-      return <></>
-    }
-
-    render(<TestComponent />)
-
-    return returnVal
-  }
-
-  it('should handle missing wallet info without errors', () => {
-    let balanceSummary: WalletBalanceSummary | null | undefined
-
-    act(() => {
-      balanceSummary = setup(null, now).data
-    })
-
-    expect(balanceSummary).toBeNull()
-  })
-
-  it('should handle <=v0.9.6 balance format', () => {
-    let balanceSummary: WalletBalanceSummary | null | undefined
-
-    act(() => {
-      balanceSummary = setup(
-        {
-          addressSummary: {},
-          fidelityBondSummary: { fbOutputs: [] },
-          data: {
-            utxos: {
-              utxos: [],
-            },
-            display: {
-              walletinfo: {
-                wallet_name: 'test.jmdat',
-                total_balance: '2.00000000',
-                available_balance: '1.00000000',
-                accounts: [],
-              },
-            },
-          },
-        },
-        now
-      ).data
-    })
-
-    expect(balanceSummary).not.toBeNull()
-    expect(balanceSummary!.totalBalance).toBe('2.00000000')
-    expect(balanceSummary!.availableBalance).toBe('1.00000000')
-  })
-
-  it('should populate balance properties calculated from utxo data', () => {
-    let balanceSummary: WalletBalanceSummary | null | undefined
-
-    act(() => {
-      balanceSummary = setup(
-        {
-          addressSummary: {},
-          fidelityBondSummary: { fbOutputs: [] },
-          data: {
-            utxos: {
-              utxos: [
-                {
-                  value: 1,
-                  mixdepth: 0,
-                  frozen: false,
-                } as Utxo,
-                {
-                  value: 2,
-                  mixdepth: 0,
-                  frozen: false,
-                  // unfrozen but not yet expired
-                  locktime: '2999-12',
-                  path: `m/84'/1'/0'/0/2:${now / 1_000 + 1}`,
-                } as Utxo,
-                {
-                  value: 3,
-                  mixdepth: 0,
-                  confirmations: 0,
-                  frozen: true,
-                } as Utxo,
-                {
-                  value: 4,
-                  mixdepth: 0,
-                  // unfrozen and expired
-                  frozen: false,
-                  locktime: '2009-01',
-                  path: `m/84'/1'/0'/0/2:${now / 1_000 - 1}`,
-                } as Utxo,
-              ],
-            },
-            display: {
-              walletinfo: {
-                wallet_name: 'test.jmdat',
-                total_balance: '0.00000010',
-                available_balance: '0.00000005',
-                accounts: [],
-              },
-            },
-          },
-        },
-        now
-      ).data
-    })
-
-    expect(balanceSummary).not.toBeNull()
-    expect(balanceSummary!.totalBalance).toBe('0.00000010')
-    expect(balanceSummary!.availableBalance).toBe('0.00000005')
-    expect(balanceSummary!.calculatedTotalBalanceInSats).toBe(10)
-    expect(balanceSummary!.calculatedAvailableBalanceInSats).toBe(5)
-    expect(balanceSummary!.calculatedFrozenOrLockedBalanceInSats).toBe(5)
-    expect(balanceSummary!.accountBalances).toEqual({})
-  })
-
-  it('should populate account balance data', () => {
-    let balanceSummary: WalletBalanceSummary | null | undefined
-
-    act(() => {
-      balanceSummary = setup(
-        {
-          addressSummary: {},
-          fidelityBondSummary: { fbOutputs: [] },
-          data: {
-            utxos: {
-              utxos: [
-                {
-                  value: 111111111,
-                  mixdepth: 1,
-                } as Utxo,
-                {
-                  value: 222222222,
-                  mixdepth: 2,
-                } as Utxo,
-                {
-                  value: 11111111,
-                  mixdepth: 2,
-                  frozen: true,
-                } as Utxo,
-                {
-                  value: 333333333,
-                  mixdepth: 3,
-                  confirmations: 0,
-                  frozen: true,
-                } as Utxo,
-              ],
-            },
-            display: {
-              walletinfo: {
-                wallet_name: 'test.jmdat',
-                total_balance: '6.77777777',
-                available_balance: '3.33333333',
-                accounts: [
-                  {
-                    account: '0',
-                    account_balance: '0.00000000',
-                    available_balance: '0.00000000',
-                    branches: [],
-                  },
-                  {
-                    account: '1',
-                    account_balance: '1.11111111',
-                    available_balance: '1.11111111',
-                    branches: [],
-                  },
-                  {
-                    account: '2',
-                    account_balance: '2.33333333',
-                    available_balance: '2.22222222',
-                    branches: [],
-                  },
-                  {
-                    account: '3',
-                    account_balance: '3.33333333',
-                    available_balance: '0.00000000',
-                    branches: [],
-                  },
-                ],
-              },
-            },
-          },
-        },
-        now
-      ).data
-    })
-
-    expect(balanceSummary).not.toBeNull()
-    expect(balanceSummary!.totalBalance).toBe('6.77777777')
-    expect(balanceSummary!.availableBalance).toBe('3.33333333')
-    expect(balanceSummary!.calculatedTotalBalanceInSats).toBe(677777777)
-    expect(balanceSummary!.calculatedAvailableBalanceInSats).toBe(333333333)
-    expect(balanceSummary!.calculatedFrozenOrLockedBalanceInSats).toBe(344444444)
-    expect(Object.keys(balanceSummary!.accountBalances)).toHaveLength(4)
-
-    const account0 = balanceSummary!.accountBalances[0]
-    expect(account0.accountIndex).toBe(0)
-    expect(account0.totalBalance).toBe('0.00000000')
-    expect(account0.availableBalance).toBe('0.00000000')
-    expect(account0.calculatedTotalBalanceInSats).toBe(0)
-    expect(account0.calculatedAvailableBalanceInSats).toBe(0)
-    expect(account0.calculatedFrozenOrLockedBalanceInSats).toBe(0)
-
-    const account1 = balanceSummary!.accountBalances[1]
-    expect(account1.accountIndex).toBe(1)
-    expect(account1.totalBalance).toBe('1.11111111')
-    expect(account1.availableBalance).toBe('1.11111111')
-    expect(account1.calculatedTotalBalanceInSats).toBe(111111111)
-    expect(account1.calculatedAvailableBalanceInSats).toBe(111111111)
-    expect(account1.calculatedFrozenOrLockedBalanceInSats).toBe(0)
-
-    const account2 = balanceSummary!.accountBalances[2]
-    expect(account2.accountIndex).toBe(2)
-    expect(account2.totalBalance).toBe('2.33333333')
-    expect(account2.availableBalance).toBe('2.22222222')
-    expect(account2.calculatedTotalBalanceInSats).toBe(233333333)
-    expect(account2.calculatedAvailableBalanceInSats).toBe(222222222)
-    expect(account2.calculatedFrozenOrLockedBalanceInSats).toBe(11111111)
-
-    const account3 = balanceSummary!.accountBalances[3]
-    expect(account3.accountIndex).toBe(3)
-    expect(account3.totalBalance).toBe('3.33333333')
-    expect(account3.availableBalance).toBe('0.00000000')
-    expect(account3.calculatedTotalBalanceInSats).toBe(333333333)
-    expect(account3.calculatedAvailableBalanceInSats).toBe(0)
-    expect(account3.calculatedFrozenOrLockedBalanceInSats).toBe(333333333)
-  })
-})