Repository: joinmarket-webui/jam
Pull request #556 (2022-11-01 16:57:34)

feat: spend fidelity bond
Partly addresses #547

First iteration of moving a fidelity bond after it expired.
Feedback is highly welcome, especially in regard to wording.

A follow-up PR for "renewing" the FB is in the making (also part of #547).

First attempts of this feature were based on the "Create FB" flow:
- Select target jar
- Freeze non-participating UTXOs manually
- Unfreeze the Fidelity Bond UTXO
- Display payment info and confirm
- Send FB UTXO via direct-send
- Restore state of previously frozen UTXOs


While this method gives users a better understanding of what is going on, it was annoying, resulted in so much code and just felt bloated.

Now it is just:
- Select target jar
- Display payment info and confirm

With the current approach, the necessary intermediate steps are hidden from the user. This makes for a much nicer UX. Any input on this is highly appreciated.

Also, the resulting method has been generalized: Sending any amount of specific UTXOs to a generic destination (internal or external). This can be reused in the upcoming PR when renewing the FB and also elsewhere.

## Other stuff
- disallow unfreezing of expired fidelity bonds from `JarDetailsOverlay` component
  - this is necessary as unfrozen, expired FB cannot take part in taker operations (when starting a maker operation they are auto-frozen by JM, but unfortunately not for taker operations)
  - Fidelity Bond UTXOs will always show the lock icon
- externalized `PaymentConfirmModal` to be used from other components than `Send`
- prepare and lay ground for #252 to improve error messages coming from the backend
- order fidelity bond UTXOs by value and locked state
- prevent `Balance` component from unnecessary re-renders (+handle possible `NaN` values)

**Note**: Spending an expired fidelity bond UTXO can still fail if the median time of the chain is still lower than the locktime of the bond. There is currently no way to verify if it is successfully spendable and a bond will always be marked as "expired" if its locktime is in the past.

## How to test?

Create an expired fidelity bond (only possible when starting via `npm run dev:start`) and try moving it into a jar.
Also note, that only the actual fidelity bond UTXO should be spent, all other UTXOs should be frozen and not be moved. Once the payment is done (regardless of success), UTXOs should have their original state again.

## :camera_flash: 
<img src="https://user-images.githubusercontent.com/3358649/199501898-a562eab9-b13b-4c60-91de-efc23ca08225.png" width=300 /> <img src="https://user-images.githubusercontent.com/3358649/199501744-30b1ef23-d677-4c59-9bb4-985c3417351f.png" width=300 />

<img src="https://user-images.githubusercontent.com/3358649/199300809-90aed0a7-71b0-4dd9-9bec-bffd99df8c42.png" width=300 />
<img src="https://user-images.githubusercontent.com/3358649/199302359-19f7e778-2bfe-4e3d-b74a-4b8890c195ec.png" width=300 />
<img src="https://user-images.githubusercontent.com/3358649/199302452-38943ac1-3bf0-4c22-93b9-1b05173210d6.png" width=300 />
<img src="https://user-images.githubusercontent.com/3358649/199302489-a9e6851e-f29c-4a24-b521-d3d71051f704.png" width=300 />


editwentyone: Nice work so far! 

Is the source jar selectable to move the fb utxo inside there? If not, Maybe it should be grayed out? 

„Payment without privacy improvements“ I think will stick stay with „moving without privacy improvements“ because we are not paying anything in the users eye 

I like it. Where is this „moving“ icon inside the button from? 
theborakompanioni: > Nice work so far!
> 
> Is the source jar selectable to move the fb utxo inside there? If not, Maybe it should be grayed out?

Yes. You can move the expired FB anywhere.

> 
> „Payment without privacy improvements“ I think will stick stay with „moving without privacy improvements“ because we are not paying anything in the users eye

I was thinking of completely removing this phrase, as users might be confused and it is not really needed here. What do you think?

> 
> I like it. Where is this „moving“ icon inside the button from?

"Transfer" icon on https://bitcoinicons.com
I know that there is no icon in your template. Can also be removed, if you say so!


editwentyone: All good so far. Will have a look asap from my desktop. But overall you nailed it. Maybe we should rephrase it to something like: make fb usable again… bla bla

so the user gets the context that before that it’s not possible to access the funds 
theborakompanioni: > All good so far. Will have a look asap from my desktop. But overall you nailed it. Maybe we should rephrase it to something like: make fb usable again… bla bla
> 
> so the user gets the context that before that it’s not possible to access the funds

That's true.. maybe "Move to Jar" is misleading as it already is in Jar A, it is just not able to take part in a taker/maker operation.
Any input is highly appreciated. Maybe just something plain and simple "Spend"?


dergigi: Oh wow, that's big. Will do my best to review this fully until EOW.
dergigi: Conflicts introduced due to merge of #566 - please resolve @theborakompanioni 🙏 
theborakompanioni: > Conflicts introduced due to merge of #566 - please resolve @theborakompanioni pray

Resolved. :pray: 
dergigi: What about: "Unlock expired fidelity bond?"

That's what you want to do after all, no? In some sense the FB is locked away, both in time and space. Once the timelock expires you can unlock it completely, by moving it to any jar.

Thoughts on this terminology @theborakompanioni @editwentyone?
dergigi: Another thought: Why give the user the option to select a jar in the first place? 

I'd default to **Jar A**, or at least pre-select A and hide the selection below a fold. Why make the user think? It's not terribly important, is it? The user just wants to press "Unlock" and wants to use the funds again.

Here is how I would simplify the flow & the language:

![Screenshot 2022-12-09 at 13-25-42 Jam for JoinMarket](https://user-images.githubusercontent.com/109058/206702316-9feac1e7-370e-41ba-87b1-b7ca6eb88953.png)

![Screenshot 2022-12-09 at 13-26-27 Jam for JoinMarket](https://user-images.githubusercontent.com/109058/206702345-ac2435b2-97b6-4fa3-8e83-63a670c7bc7d.png)

Two clicks, the second one being a confirmation. 
theborakompanioni: > Another thought: Why give the user the option to select a jar in the first place?
> 
> I'd default to **Jar A**, or at least pre-select A and hide the selection below a fold. Why make the user think? It's not terribly important, is it? The user just wants to press "Unlock" and wants to use the funds again.

Done.

> 
> Two clicks, the second one being a confirmation.

Not too convinced, as this is an action not performed very often and the described approach provides less freedom users.
It is now changed to skip the selected automatically and default to Jar A - the code to selected a Jar is still present for now and can be enabled with a flag - hope that is okay (in case it is decided to enable this flow or provide a toggle)

> (I'd also remove the red warning that tells the user that this is a send without privacy improvement. The user can't change it at this point, there is no need to show a warning. It's just scary and confusing.)

Done.


dergigi: Nice! I like it. Much more streamlined.

> It is now changed to skip the jar selection and default to Jar A automatically - the code to select a Jar is still present for now and can be enabled with a flag - hope that is okay (in case it is decided to enable this flow or provide a toggle).

If we want to keep it in, we could add an `Unlock Options` to the Unlock modal, just like we have on Receive?

Add this "fold" ...

![Screenshot 2022-12-13 at 11-53-26 Jam for JoinMarket](https://user-images.githubusercontent.com/109058/207299537-c9d31a27-7367-4406-aa24-b1e959b477c1.png)

...to this screen:

![Screenshot 2022-12-13 at 11-54-49 Jam for JoinMarket](https://user-images.githubusercontent.com/109058/207299592-4ffc1596-00c1-425b-b13a-3f2999ac43d7.png)

---

Would still default to A (unless you go through the trouble of changing it) and would hide the complexity nicely, while still providing the option.

What do you think?

theborakompanioni: > What do you think?

Full ACK.. Much better, imho. : -)
I'll merge this as it's an improvement to the current state and it feels "good enough" for now.
It is more important that users are able to spend their expired fidelity bonds. Currently, only acting as maker would freeze expired bonds automatically (in case it was unfrozen), but not when acting as a taker. This can potentially lead to confusion and/or needing additional support.

An issue for this has been created: https://github.com/joinmarket-webui/jam/issues/574




Patch:
diff --git a/public/sprite.svg b/public/sprite.svg
index f0224b6..16087a1 100644
--- a/public/sprite.svg
+++ b/public/sprite.svg
@@ -244,6 +244,10 @@
         <rect width="14" height="10" x="5" y="10.968" rx="2"></rect>
         <path d="M15.486 10.984V7.243a1.5 1.5 0 00-1.5-1.5h-3.972a1.5 1.5 0 00-1.5 1.5v3.74"></path>
     </symbol>
+    <symbol id="unlock" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <rect width="14" height="10" x="5" y="10.989" rx="2" />
+        <path stroke-linecap="round" d="M15.5 8l-.008-1.742a1.5 1.5 0 00-1.5-1.494H10.014a1.5 1.5 0 00-1.5 1.5v4.73" />
+    </symbol>
     <symbol id="cancel" stroke="currentColor" viewBox="0 0 31 31">
         <line x1="10.6642" y1="10.75" x2="20.5637" y2="20.6495" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
         <line x1="20.5" y1="10.9142" x2="10.6005" y2="20.8137" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
diff --git a/src/components/Balance.jsx b/src/components/Balance.jsx
index 17e9caf..2fc15a4 100644
--- a/src/components/Balance.jsx
+++ b/src/components/Balance.jsx
@@ -1,5 +1,5 @@
-import { useState, useEffect } from 'react'
-import { SATS, BTC, btcToSats, satsToBtc, formatBtc, formatSats } from '../utils'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { SATS, BTC, btcToSats, satsToBtc, formatBtc, formatSats, isValidNumber } from '../utils'
 import Sprite from './Sprite'
 import * as rb from 'react-bootstrap'
 import styles from './Balance.module.css'
@@ -57,25 +57,14 @@ export default function Balance({
     setDisplayMode(getDisplayMode(convertToUnit, isBalanceVisible))
   }, [convertToUnit, isBalanceVisible])
 
-  if (loading) {
-    return (
-      <rb.Placeholder as="div" animation="wave">
-        <rb.Placeholder
-          data-testid="balance-component-placeholder"
-          className={styles['balance-component-placeholder']}
-        />
-      </rb.Placeholder>
-    )
-  }
-
-  const toggleVisibility = (e) => {
+  const toggleVisibility = useCallback((e) => {
     e.preventDefault()
     e.stopPropagation()
 
     setIsBalanceVisible((current) => !current)
-  }
+  }, [])
 
-  const balanceComponent = (() => {
+  const balanceComponent = useMemo(() => {
     if (displayMode === DISPLAY_MODE_HIDDEN) {
       return (
         <BalanceComponent
@@ -90,15 +79,15 @@ export default function Balance({
       )
     }
 
-    if (typeof valueString !== 'string') {
-      console.warn('<Balance /> component expects string input')
+    if (typeof valueString !== 'string' || !isValidNumber(parseFloat(valueString))) {
+      console.warn('<Balance /> component expects number input as string')
       return <BalanceComponent symbol="" value={valueString} symbolIsPrefix={false} />
     }
 
     // Treat integers as sats.
-    const valueIsSats = valueString === Number.parseInt(valueString).toString()
+    const valueIsSats = valueString === parseInt(valueString, 10).toString()
     // Treat decimal numbers as btc.
-    const valueIsBtc = !valueIsSats && !Number.isNaN(Number.parseFloat(valueString)) && valueString.indexOf('.') > -1
+    const valueIsBtc = !valueIsSats && !isNaN(parseFloat(valueString)) && valueString.indexOf('.') > -1
 
     const btcSymbol = (
       <span className="balance-symbol" style={{ paddingRight: '0.1em' }}>
@@ -118,16 +107,27 @@ export default function Balance({
       return <BalanceComponent symbol={btcSymbol} value={formatBtc(satsToBtc(valueString))} symbolIsPrefix={true} />
 
     console.warn('<Balance /> component cannot determine balance format')
-    return <BalanceComponent symbol={''} value={valueString} symbolIsPrefix={false} />
-  })()
+    return <BalanceComponent symbol="" value={valueString} symbolIsPrefix={false} />
+  }, [valueString, displayMode])
+
+  if (loading) {
+    return (
+      <rb.Placeholder as="div" animation="wave">
+        <rb.Placeholder
+          data-testid="balance-component-placeholder"
+          className={styles['balance-component-placeholder']}
+        />
+      </rb.Placeholder>
+    )
+  }
 
   if (!enableVisibilityToggle) {
     return <>{balanceComponent}</>
+  } else {
+    return (
+      <span onClick={toggleVisibility} style={{ cursor: 'pointer' }}>
+        {balanceComponent}
+      </span>
+    )
   }
-
-  return (
-    <span onClick={toggleVisibility} style={{ cursor: 'pointer' }}>
-      {balanceComponent}
-    </span>
-  )
 }
diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx
index 24c1538..5760df4 100644
--- a/src/components/Earn.jsx
+++ b/src/components/Earn.jsx
@@ -7,11 +7,13 @@ import { useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/Wal
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { factorToPercentage, percentageToFactor } from '../utils'
 import * as Api from '../libs/JmWalletApi'
+import * as fb from './fb/utils'
 import Sprite from './Sprite'
 import PageTitle from './PageTitle'
 import SegmentedTabs from './SegmentedTabs'
 import { CreateFidelityBond } from './fb/CreateFidelityBond'
 import { ExistingFidelityBond } from './fb/ExistingFidelityBond'
+import { SpendFidelityBondModal } from './fb/SpendFidelityBondModal'
 import { EarnReportOverlay } from './EarnReport'
 import { OrderbookOverlay } from './Orderbook'
 import Balance from './Balance'
@@ -62,7 +64,7 @@ const persistFormValues = (values) => {
   }
 }
 
-const initialFormValues = (settings) => ({
+const initialFormValues = () => ({
   offertype:
     window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.offertype) || FORM_INPUT_DEFAULT_VALUES.offertype,
   feeRel:
@@ -187,6 +189,8 @@ export default function Earn({ wallet }) {
     return currentWalletInfo?.fidelityBondSummary.fbOutputs || []
   }, [currentWalletInfo])
 
+  const [moveToJarFidelityBondId, setMoveToJarFidelityBondId] = useState()
+
   const startMakerService = (ordertype, minsize, cjfee_a, cjfee_r) => {
     setIsSending(true)
     setIsWaitingMakerStart(true)
@@ -280,8 +284,8 @@ export default function Earn({ wallet }) {
     setIsLoading(true)
 
     new Promise((resolve) => {
-      setTimeout(() => {
-        resolve(reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }))
+      setTimeout(async () => {
+        resolve(await reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }))
       }, delay)
     })
       .catch((err) => {
@@ -298,7 +302,7 @@ export default function Earn({ wallet }) {
   const feeRelMax = 0.1 // 10%
   const feeRelPercentageStep = 0.0001
 
-  const initialValues = initialFormValues(settings)
+  const initialValues = initialFormValues()
 
   const validate = (values) => {
     const errors = {}
@@ -396,11 +400,53 @@ export default function Earn({ wallet }) {
                 subtitle={t('earn.subtitle_fidelity_bonds')}
               />
               <div className="d-flex flex-column gap-3">
-                {fidelityBonds.length > 0 && (
+                {currentWalletInfo && fidelityBonds.length > 0 && (
                   <>
-                    {fidelityBonds.map((fidelityBond, index) => (
-                      <ExistingFidelityBond key={index} fidelityBond={fidelityBond} />
-                    ))}
+                    {moveToJarFidelityBondId && (
+                      <SpendFidelityBondModal
+                        show={true}
+                        fidelityBondId={moveToJarFidelityBondId}
+                        wallet={wallet}
+                        walletInfo={currentWalletInfo}
+                        destinationJarIndex={0}
+                        onClose={({ mustReload }) => {
+                          setMoveToJarFidelityBondId(undefined)
+                          if (mustReload) {
+                            reloadFidelityBonds({ delay: 0 })
+                          }
+                        }}
+                      />
+                    )}
+                    {fidelityBonds.map((fidelityBond, index) => {
+                      const isExpired = !fb.utxo.isLocked(fidelityBond)
+                      const actionsEnabled =
+                        isExpired &&
+                        serviceInfo &&
+                        !serviceInfo.coinjoinInProgress &&
+                        !serviceInfo.makerRunning &&
+                        !isWaitingMakerStart &&
+                        !isWaitingMakerStop &&
+                        !isLoading
+                      return (
+                        <ExistingFidelityBond key={index} fidelityBond={fidelityBond}>
+                          {actionsEnabled && (
+                            <div className="mt-4">
+                              <div className="">
+                                <rb.Button
+                                  variant={settings.theme === 'dark' ? 'light' : 'dark'}
+                                  className="w-50 d-flex justify-content-center align-items-center"
+                                  disabled={moveToJarFidelityBondId !== undefined}
+                                  onClick={() => setMoveToJarFidelityBondId(fidelityBond.utxo)}
+                                >
+                                  <Sprite className="me-1 mb-1" symbol="unlock" width="24" height="24" />
+                                  {t('earn.fidelity_bond.existing.button_spend')}
+                                </rb.Button>
+                              </div>
+                            </div>
+                          )}
+                        </ExistingFidelityBond>
+                      )
+                    })}
                   </>
                 )}
                 <>
diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx
index d87e123..8c22b26 100644
--- a/src/components/Jam.jsx
+++ b/src/components/Jam.jsx
@@ -111,13 +111,18 @@ export default function Jam({ wallet }) {
     const abortCtrl = new AbortController()
     const loadingServiceInfo = reloadServiceInfo({ signal: abortCtrl.signal }).catch((err) => {
       if (abortCtrl.signal.aborted) return
-      const message = err.message || t('send.error_loading_wallet_failed')
+      // reusing "wallet failed" message here is okay, as session info also contains wallet information
+      const message = t('global.errors.error_loading_wallet_failed', {
+        reason: err.message || t('global.errors.reason_unknown'),
+      })
       setAlert({ variant: 'danger', message })
     })
 
     const loadingWalletInfo = reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }).catch((err) => {
       if (abortCtrl.signal.aborted) return
-      const message = err.message || t('send.error_loading_wallet_failed')
+      const message = t('global.errors.error_loading_wallet_failed', {
+        reason: err.message || t('global.errors.reason_unknown'),
+      })
       setAlert({ variant: 'danger', message })
     })
 
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index d65b8ba..4e84f6f 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
 import styles from './Modal.module.css'
 import Sprite from './Sprite'
 
-interface ConfirmModalProps {
+export interface ConfirmModalProps {
   isShown: boolean
   title: ReactNode | string
   onCancel: () => void
diff --git a/src/components/PaymentConfirmModal.module.css b/src/components/PaymentConfirmModal.module.css
new file mode 100644
index 0000000..c2efcd9
--- /dev/null
+++ b/src/components/PaymentConfirmModal.module.css
@@ -0,0 +1,6 @@
+.infoIcon {
+  margin: 2px 0 0 0.25rem;
+  color: var(--bs-gray-500);
+  border: 1px solid var(--bs-gray-500);
+  border-radius: 50%;
+}
diff --git a/src/components/PaymentConfirmModal.tsx b/src/components/PaymentConfirmModal.tsx
new file mode 100644
index 0000000..feb3080
--- /dev/null
+++ b/src/components/PaymentConfirmModal.tsx
@@ -0,0 +1,208 @@
+import { useMemo } from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+import * as rb from 'react-bootstrap'
+import Sprite from './Sprite'
+import Balance from './Balance'
+import { useSettings } from '../context/SettingsContext'
+import { estimateMaxCollaboratorFee, FeeValues, toTxFeeValueUnit } from '../hooks/Fees'
+
+import { isValidNumber } from '../utils'
+import { ConfirmModal, ConfirmModalProps } from './Modal'
+
+import styles from './PaymentConfirmModal.module.css'
+import { AmountSats } from '../libs/JmWalletApi'
+import { jarInitial } from './jars/Jar'
+
+interface PaymentDisplayInfo {
+  sourceJarIndex?: JarIndex
+  destination: String
+  amount: AmountSats
+  isSweep: boolean
+  isCoinjoin: boolean
+  numCollaborators?: number
+  feeConfigValues?: FeeValues
+  showPrivacyInfo?: boolean
+}
+
+interface PaymentConfirmModalProps extends ConfirmModalProps {
+  data: PaymentDisplayInfo
+}
+
+export function PaymentConfirmModal({
+  data: {
+    sourceJarIndex,
+    destination,
+    amount,
+    isSweep,
+    isCoinjoin,
+    numCollaborators,
+    feeConfigValues,
+    showPrivacyInfo = true,
+  },
+  ...confirmModalProps
+}: PaymentConfirmModalProps) {
+  const { t } = useTranslation()
+  const settings = useSettings()
+
+  const estimatedMaxCollaboratorFee = useMemo(() => {
+    if (!isCoinjoin || !feeConfigValues) return null
+    if (!isValidNumber(amount) || !isValidNumber(numCollaborators)) return null
+    if (!isValidNumber(feeConfigValues.max_cj_fee_abs) || !isValidNumber(feeConfigValues.max_cj_fee_rel)) return null
+    return estimateMaxCollaboratorFee({
+      amount,
+      collaborators: numCollaborators!,
+      maxFeeAbs: feeConfigValues.max_cj_fee_abs!,
+      maxFeeRel: feeConfigValues.max_cj_fee_rel!,
+    })
+  }, [amount, isCoinjoin, numCollaborators, feeConfigValues])
+
+  const miningFeeText = useMemo(() => {
+    if (!feeConfigValues) return null
+    if (!isValidNumber(feeConfigValues.tx_fees) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null
+
+    const unit = toTxFeeValueUnit(feeConfigValues.tx_fees)
+    if (!unit) {
+      return null
+    } else if (unit === 'blocks') {
+      return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees })
+    } else {
+      const feeTargetInSatsPerVByte = feeConfigValues.tx_fees! / 1_000
+      if (feeConfigValues.tx_fees_factor === 0) {
+        return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_exact', {
+          value: feeTargetInSatsPerVByte.toLocaleString(undefined, {
+            maximumFractionDigits: Math.log10(1_000),
+          }),
+        })
+      }
+
+      const minFeeSatsPerVByte = Math.max(1, feeTargetInSatsPerVByte * (1 - feeConfigValues.tx_fees_factor!))
+      const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + feeConfigValues.tx_fees_factor!)
+
+      return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_randomized', {
+        min: minFeeSatsPerVByte.toLocaleString(undefined, {
+          maximumFractionDigits: 1,
+        }),
+        max: maxFeeSatsPerVByte.toLocaleString(undefined, {
+          maximumFractionDigits: 1,
+        }),
+      })
+    }
+  }, [t, feeConfigValues])
+
+  return (
+    <ConfirmModal {...confirmModalProps}>
+      <rb.Container className="mt-2">
+        {showPrivacyInfo && (
+          <rb.Row className="mt-2 mb-3">
+            <rb.Col xs={12} className="text-center">
+              {isCoinjoin ? (
+                <strong className="text-success">{t('send.confirm_send_modal.text_collaborative_tx_enabled')}</strong>
+              ) : (
+                <strong className="text-danger">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</strong>
+              )}
+            </rb.Col>
+          </rb.Row>
+        )}
+        {sourceJarIndex !== undefined && (
+          <rb.Row>
+            <rb.Col xs={4} md={3} className="text-end">
+              <strong>{t('send.confirm_send_modal.label_source_jar')}</strong>
+            </rb.Col>
+            <rb.Col xs={8} md={9} className="text-start">
+              {t('send.confirm_send_modal.text_source_jar', { jarId: jarInitial(sourceJarIndex) })}
+            </rb.Col>
+          </rb.Row>
+        )}
+        <rb.Row>
+          <rb.Col xs={4} md={3} className="text-end">
+            <strong>{t('send.confirm_send_modal.label_recipient')}</strong>
+          </rb.Col>
+          <rb.Col xs={8} md={9} className="text-start text-break slashed-zeroes">
+            {destination}
+          </rb.Col>
+        </rb.Row>
+        <rb.Row>
+          <rb.Col xs={4} md={3} className="text-end">
+            <strong>{t('send.confirm_send_modal.label_amount')}</strong>
+          </rb.Col>
+          <rb.Col xs={8} md={9} className="text-start">
+            {isSweep ? (
+              <>
+                <Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
+                  Sweep
+                  <Balance valueString={String(amount)} convertToUnit={settings.unit} showBalance={true} />
+                </Trans>
+                <rb.OverlayTrigger
+                  placement="right"
+                  overlay={
+                    <rb.Popover>
+                      <rb.Popover.Body>{t('send.confirm_send_modal.text_sweep_info_popover')}</rb.Popover.Body>
+                    </rb.Popover>
+                  }
+                >
+                  <div className="d-inline-flex align-items-center">
+                    <Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
+                  </div>
+                </rb.OverlayTrigger>
+              </>
+            ) : (
+              <Balance valueString={String(amount)} convertToUnit={settings.unit} showBalance={true} />
+            )}
+          </rb.Col>
+        </rb.Row>
+
+        {miningFeeText && (
+          <rb.Row>
+            <rb.Col xs={4} md={3} className="text-end">
+              <strong>{t('send.confirm_send_modal.label_miner_fee')}</strong>
+            </rb.Col>
+            <rb.Col xs={8} md={9} className="text-start">
+              {miningFeeText}
+            </rb.Col>
+          </rb.Row>
+        )}
+        {isCoinjoin && (
+          <rb.Row>
+            <rb.Col xs={4} md={3} className="text-end">
+              <strong>{t('send.confirm_send_modal.label_num_collaborators')}</strong>
+            </rb.Col>
+            <rb.Col xs={8} md={9} className="text-start">
+              {numCollaborators}
+            </rb.Col>
+          </rb.Row>
+        )}
+        {estimatedMaxCollaboratorFee && (
+          <rb.Row>
+            <rb.Col xs={4} md={3} className="text-end">
+              <strong>{t('send.confirm_send_modal.label_estimated_max_collaborator_fee')}</strong>
+            </rb.Col>
+            <rb.Col xs={8} md={9} className="d-inline-flex align-items-center text-start">
+              <div>
+                &le;
+                <Balance
+                  valueString={`${estimatedMaxCollaboratorFee}`}
+                  convertToUnit={settings.unit}
+                  showBalance={true}
+                />
+                <rb.OverlayTrigger
+                  placement="right"
+                  overlay={
+                    <rb.Popover>
+                      <rb.Popover.Body>
+                        {t('send.confirm_send_modal.text_estimated_max_collaborator_fee_info_popover')}
+                      </rb.Popover.Body>
+                    </rb.Popover>
+                  }
+                >
+                  <div className="d-inline-flex align-items-center">
+                    <Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
+                  </div>
+                </rb.OverlayTrigger>
+              </div>
+            </rb.Col>
+          </rb.Row>
+        )}
+      </rb.Container>
+    </ConfirmModal>
+  )
+}
diff --git a/src/components/Send.jsx b/src/components/Send.jsx
index d00a11f..ba02671 100644
--- a/src/components/Send.jsx
+++ b/src/components/Send.jsx
@@ -7,21 +7,22 @@ import PageTitle from './PageTitle'
 import ToggleSwitch from './ToggleSwitch'
 import Sprite from './Sprite'
 import Balance from './Balance'
+import { ConfirmModal } from './Modal'
 import JarSelectorModal from './JarSelectorModal'
+import { PaymentConfirmModal } from './PaymentConfirmModal'
+import { CoinjoinPreconditionViolationAlert } from './CoinjoinPreconditionViolationAlert'
 
 import { useReloadCurrentWalletInfo, useCurrentWalletInfo } from '../context/WalletContext'
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { useLoadConfigValue } from '../context/ServiceConfigContext'
 import { useSettings } from '../context/SettingsContext'
-import { estimateMaxCollaboratorFee, toTxFeeValueUnit, useLoadFeeConfigValues } from '../hooks/Fees'
+import { useLoadFeeConfigValues } from '../hooks/Fees'
 import { buildCoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
 
 import * as Api from '../libs/JmWalletApi'
 import { SATS, formatBtc, formatSats, satsToBtc, isValidNumber } from '../utils'
 import { routes } from '../constants/routes'
-import { ConfirmModal } from './Modal'
-import { CoinjoinPreconditionViolationAlert } from './CoinjoinPreconditionViolationAlert'
-import { jarInitial, jarName } from './jars/Jar'
+import { jarName } from './jars/Jar'
 
 import styles from './Send.module.css'
 
@@ -195,174 +196,6 @@ function SweepAccordionToggle({ eventKey }) {
   )
 }
 
-function PaymentConfirmModal({
-  isShown,
-  title,
-  onCancel,
-  onConfirm,
-  data: { sourceJarId, destination, amount, isSweep, isCoinjoin, numCollaborators, feeConfigValues },
-}) {
-  const { t } = useTranslation()
-  const settings = useSettings()
-
-  const estimatedMaxCollaboratorFee = useMemo(() => {
-    if (!amount || !isCoinjoin || !numCollaborators || !feeConfigValues) return null
-    if (!isValidNumber(feeConfigValues.max_cj_fee_abs) || !isValidNumber(feeConfigValues.max_cj_fee_rel)) return null
-    return estimateMaxCollaboratorFee({
-      amount,
-      collaborators: numCollaborators,
-      maxFeeAbs: feeConfigValues.max_cj_fee_abs,
-      maxFeeRel: feeConfigValues.max_cj_fee_rel,
-    })
-  }, [amount, isCoinjoin, numCollaborators, feeConfigValues])
-
-  const miningFeeText = useMemo(() => {
-    if (!feeConfigValues) return null
-    if (!isValidNumber(feeConfigValues.tx_fees) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null
-
-    const unit = toTxFeeValueUnit(feeConfigValues.tx_fees)
-    if (!unit) {
-      return null
-    } else if (unit === 'blocks') {
-      return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees })
-    } else {
-      const feeTargetInSatsPerVByte = feeConfigValues.tx_fees / 1_000
-      if (feeConfigValues.tx_fees_factor === 0) {
-        return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_exact', {
-          value: feeTargetInSatsPerVByte.toLocaleString(undefined, {
-            maximumFractionDigits: Math.log10(1_000),
-          }),
-        })
-      }
-
-      const minFeeSatsPerVByte = Math.max(1, feeTargetInSatsPerVByte * (1 - feeConfigValues.tx_fees_factor))
-      const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + feeConfigValues.tx_fees_factor)
-
-      return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_randomized', {
-        min: minFeeSatsPerVByte.toLocaleString(undefined, {
-          maximumFractionDigits: 1,
-        }),
-        max: maxFeeSatsPerVByte.toLocaleString(undefined, {
-          maximumFractionDigits: 1,
-        }),
-      })
-    }
-  }, [t, feeConfigValues])
-
-  return (
-    <ConfirmModal isShown={isShown} title={title} onCancel={onCancel} onConfirm={onConfirm}>
-      <rb.Container className="mt-2">
-        <rb.Row className="mt-2 mb-3">
-          <rb.Col xs={12} className="text-center">
-            {isCoinjoin ? (
-              <strong className="text-success">{t('send.confirm_send_modal.text_collaborative_tx_enabled')}</strong>
-            ) : (
-              <strong className="text-danger">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</strong>
-            )}
-          </rb.Col>
-        </rb.Row>
-        <rb.Row>
-          <rb.Col xs={4} md={3} className="text-end">
-            <strong>{t('send.confirm_send_modal.label_source_jar')}</strong>
-          </rb.Col>
-          <rb.Col xs={8} md={9} className="text-start">
-            {t('send.confirm_send_modal.text_source_jar', { jarId: sourceJarId })}
-          </rb.Col>
-        </rb.Row>
-        <rb.Row>
-          <rb.Col xs={4} md={3} className="text-end">
-            <strong>{t('send.confirm_send_modal.label_recipient')}</strong>
-          </rb.Col>
-          <rb.Col xs={8} md={9} className="text-start text-break slashed-zeroes">
-            {destination}
-          </rb.Col>
-        </rb.Row>
-        <rb.Row>
-          <rb.Col xs={4} md={3} className="text-end">
-            <strong>{t('send.confirm_send_modal.label_amount')}</strong>
-          </rb.Col>
-          <rb.Col xs={8} md={9} className="text-start">
-            {isSweep ? (
-              <>
-                <Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
-                  Sweep
-                  <Balance valueString={amount} convertToUnit={settings.unit} showBalance={true} />
-                </Trans>
-                <rb.OverlayTrigger
-                  placement="right"
-                  overlay={
-                    <rb.Popover>
-                      <rb.Popover.Body>{t('send.confirm_send_modal.text_sweep_info_popover')}</rb.Popover.Body>
-                    </rb.Popover>
-                  }
-                >
-                  <div className="d-inline-flex align-items-center">
-                    <Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
-                  </div>
-                </rb.OverlayTrigger>
-              </>
-            ) : (
-              <Balance valueString={amount} convertToUnit={settings.unit} showBalance={true} />
-            )}
-          </rb.Col>
-        </rb.Row>
-
-        {miningFeeText && (
-          <rb.Row>
-            <rb.Col xs={4} md={3} className="text-end">
-              <strong>{t('send.confirm_send_modal.label_miner_fee')}</strong>
-            </rb.Col>
-            <rb.Col xs={8} md={9} className="text-start">
-              {miningFeeText}
-            </rb.Col>
-          </rb.Row>
-        )}
-        {isCoinjoin && (
-          <rb.Row>
-            <rb.Col xs={4} md={3} className="text-end">
-              <strong>{t('send.confirm_send_modal.label_num_collaborators')}</strong>
-            </rb.Col>
-            <rb.Col xs={8} md={9} className="text-start">
-              {numCollaborators}
-            </rb.Col>
-          </rb.Row>
-        )}
-        {estimatedMaxCollaboratorFee && (
-          <rb.Row>
-            <rb.Col xs={4} md={3} className="text-end">
-              <strong>{t('send.confirm_send_modal.label_estimated_max_collaborator_fee')}</strong>
-            </rb.Col>
-            <rb.Col xs={8} md={9} className="d-inline-flex align-items-center text-start">
-              <div>
-                &le;
-                <Balance
-                  valueString={`${estimatedMaxCollaboratorFee}`}
-                  convertToUnit={settings.unit}
-                  showBalance={true}
-                />
-                <rb.OverlayTrigger
-                  placement="right"
-                  overlay={
-                    <rb.Popover>
-                      <rb.Popover.Body>
-                        {t('send.confirm_send_modal.text_estimated_max_collaborator_fee_info_popover')}
-                      </rb.Popover.Body>
-                    </rb.Popover>
-                  }
-                >
-                  <div className="d-inline-flex align-items-center">
-                    <Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
-                  </div>
-                </rb.OverlayTrigger>
-              </div>
-            </rb.Col>
-          </rb.Row>
-        )}
-      </rb.Container>
-    </ConfirmModal>
-  )
-}
-
 export default function Send({ wallet }) {
   const { t } = useTranslation()
   const walletInfo = useCurrentWalletInfo()
@@ -513,7 +346,9 @@ export default function Send({ wallet }) {
           // the user that loading the wallet info failed
           setWaitForUtxosToBeSpent([])
 
-          const message = err.message || t('send.error_loading_wallet_failed')
+          const message = t('global.errors.error_reloading_wallet_failed', {
+            reason: err.message || t('global.errors.reason_unknown'),
+          })
           setAlert({ variant: 'danger', message })
         })
     }, initialDelayInMs)
@@ -538,14 +373,20 @@ export default function Send({ wallet }) {
     // reloading service info is important, is it must be known as soon as possible
     // if the operation is even allowed, i.e. if no other service is running
     const loadingServiceInfo = reloadServiceInfo({ signal: abortCtrl.signal }).catch((err) => {
+      if (abortCtrl.signal.aborted) return
       // reusing "wallet failed" message here is okay, as session info also contains wallet information
-      const message = err.message || t('send.error_loading_wallet_failed')
-      !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message })
+      const message = t('global.errors.error_loading_wallet_failed', {
+        reason: err.message || t('global.errors.reason_unknown'),
+      })
+      setAlert({ variant: 'danger', message })
     })
 
     const loadingWalletInfoAndUtxos = reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }).catch((err) => {
-      const message = err.message || t('send.error_loading_wallet_failed')
-      !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message })
+      if (abortCtrl.signal.aborted) return
+      const message = t('global.errors.error_loading_wallet_failed', {
+        reason: err.message || t('global.errors.reason_unknown'),
+      })
+      setAlert({ variant: 'danger', message })
     })
 
     const loadingMinimumMakerConfig = loadConfigValue({
@@ -1112,32 +953,36 @@ export default function Send({ wallet }) {
           )}
         </rb.Button>
 
-        <ConfirmModal
-          isShown={showConfirmAbortModal}
-          title={t('send.confirm_abort_modal.title')}
-          onCancel={() => setShowConfirmAbortModal(false)}
-          onConfirm={() => abortCoinjoin()}
-        >
-          {t('send.confirm_abort_modal.text_body')}
-        </ConfirmModal>
-
-        <PaymentConfirmModal
-          isShown={showConfirmSendModal}
-          title={t('send.confirm_send_modal.title')}
-          onCancel={() => setShowConfirmSendModal(false)}
-          onConfirm={() => {
-            submitButtonRef.current?.click()
-          }}
-          data={{
-            sourceJarId: jarInitial(sourceJarIndex),
-            destination,
-            amount: amountFieldValue().toString(),
-            isSweep,
-            isCoinjoin,
-            numCollaborators,
-            feeConfigValues,
-          }}
-        />
+        {showConfirmAbortModal && (
+          <ConfirmModal
+            isShown={showConfirmAbortModal}
+            title={t('send.confirm_abort_modal.title')}
+            onCancel={() => setShowConfirmAbortModal(false)}
+            onConfirm={() => abortCoinjoin()}
+          >
+            {t('send.confirm_abort_modal.text_body')}
+          </ConfirmModal>
+        )}
+
+        {showConfirmSendModal && (
+          <PaymentConfirmModal
+            isShown={true}
+            title={t('send.confirm_send_modal.title')}
+            onCancel={() => setShowConfirmSendModal(false)}
+            onConfirm={() => {
+              submitButtonRef.current?.click()
+            }}
+            data={{
+              sourceJarIndex,
+              destination,
+              amount: parseInt(amountFieldValue(), 10),
+              isSweep,
+              isCoinjoin,
+              numCollaborators,
+              feeConfigValues,
+            }}
+          />
+        )}
       </div>
     </>
   )
diff --git a/src/components/Send.module.css b/src/components/Send.module.css
index 4ffe3dd..da81d26 100644
--- a/src/components/Send.module.css
+++ b/src/components/Send.module.css
@@ -152,13 +152,6 @@ input[type='number'] {
   filter: blur(2px);
 }
 
-.infoIcon {
-  margin: 2px 0 0 0.25rem;
-  color: var(--bs-gray-500);
-  border: 1px solid var(--bs-gray-500);
-  border-radius: 50%;
-}
-
 .accordion-button {
   border: none;
   width: 100%;
diff --git a/src/components/fb/CreateFidelityBond.jsx b/src/components/fb/CreateFidelityBond.jsx
index 3589ba3..9006606 100644
--- a/src/components/fb/CreateFidelityBond.jsx
+++ b/src/components/fb/CreateFidelityBond.jsx
@@ -211,13 +211,16 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
 
           setUtxoIdsToBeSpent([...utxoIdsStillPresent])
         })
-        .catch(() => {
+        .catch((err) => {
           if (abortCtrl.signal.aborted) return
 
           setUtxoIdsToBeSpent([])
           setIsLoading(false)
 
-          setAlert({ variant: 'danger', message: t('earn.fidelity_bond.error_reloading_wallet') })
+          const message = t('global.errors.error_reloading_wallet_failed', {
+            reason: err.message || t('global.errors.reason_unknown'),
+          })
+          setAlert({ variant: 'danger', message })
         })
     }, TIMEOUT_RELOAD_UTXOS_AFTER_FB_CREATE_MS)
 
diff --git a/src/components/fb/ExistingFidelityBond.module.css b/src/components/fb/ExistingFidelityBond.module.css
index 0a96e8c..518663a 100644
--- a/src/components/fb/ExistingFidelityBond.module.css
+++ b/src/components/fb/ExistingFidelityBond.module.css
@@ -8,6 +8,15 @@
   border-color: var(--bs-gray-700);
 }
 
+.expired {
+  background-color: var(--bs-gray-200);
+  border-color: var(--bs-gray-400) !important;
+}
+
+:root[data-theme='dark'] .expired {
+  background-color: var(--bs-gray-800);
+}
+
 .title {
   width: 100%;
   font-size: 1.2rem;
diff --git a/src/components/fb/ExistingFidelityBond.tsx b/src/components/fb/ExistingFidelityBond.tsx
index 82a1221..8f14252 100644
--- a/src/components/fb/ExistingFidelityBond.tsx
+++ b/src/components/fb/ExistingFidelityBond.tsx
@@ -1,5 +1,6 @@
-import { useMemo } from 'react'
-import { useTranslation } from 'react-i18next'
+import { PropsWithChildren, useMemo } from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+import classNames from 'classnames'
 import { useSettings } from '../../context/SettingsContext'
 import { Utxo } from '../../context/WalletContext'
 import Sprite from '../Sprite'
@@ -12,21 +13,38 @@ interface ExistingFidelityBondProps {
   fidelityBond: Utxo
 }
 
-const ExistingFidelityBond = ({ fidelityBond }: ExistingFidelityBondProps) => {
+const ExistingFidelityBond = ({ fidelityBond, children }: PropsWithChildren<ExistingFidelityBondProps>) => {
   const settings = useSettings()
   const { t, i18n } = useTranslation()
 
   const isExpired = useMemo(() => !fb.utxo.isLocked(fidelityBond), [fidelityBond])
+  const humanReadableDuration = useMemo(() => {
+    if (!fidelityBond.locktime) return '-'
+    return fb.time.humanReadableDuration({
+      to: new Date(fidelityBond.locktime).getTime(),
+      locale: i18n.resolvedLanguage || i18n.language,
+    })
+  }, [i18n, fidelityBond])
 
   if (!fb.utxo.isFidelityBond(fidelityBond)) {
     return <></>
   }
 
   return (
-    <div className={styles.container}>
+    <div
+      className={classNames(styles.container, {
+        [styles.expired]: isExpired,
+      })}
+    >
       <div className="d-flex justify-content-between align-items-center">
         <div className={styles.title}>
-          {t(`earn.fidelity_bond.existing.${isExpired ? 'title_expired' : 'title_active'}`)}
+          {isExpired ? (
+            <Trans i18nKey="earn.fidelity_bond.existing.title_expired">
+              Fidelity Bond <strong>expired</strong>
+            </Trans>
+          ) : (
+            t('earn.fidelity_bond.existing.title_active')
+          )}
         </div>
         <div className="d-flex align-items-center gap-1">
           <Sprite symbol="coins" width="24" height="24" />
@@ -45,24 +63,17 @@ const ExistingFidelityBond = ({ fidelityBond }: ExistingFidelityBondProps) => {
           height="74px"
         />
         <div className="d-flex flex-column gap-3">
-          {fidelityBond.locktime && (
-            <div className="d-flex align-items-center gap-2">
-              <Sprite symbol="clock" width="18" height="18" className={styles.icon} />
-              <div className="d-flex flex-column">
-                <div className={styles.label}>
-                  {t(`earn.fidelity_bond.existing.${isExpired ? 'label_expired_on' : 'label_locked_until'}`)}
-                </div>
-                <div className={styles.content}>
-                  {fidelityBond.locktime} (
-                  {fb.time.humanReadableDuration({
-                    to: new Date(fidelityBond.locktime).getTime(),
-                    locale: i18n.resolvedLanguage || i18n.language,
-                  })}
-                  )
-                </div>
+          <div className="d-flex align-items-center gap-2">
+            <Sprite symbol="clock" width="18" height="18" className={styles.icon} />
+            <div className="d-flex flex-column">
+              <div className={styles.label}>
+                {t(`earn.fidelity_bond.existing.${isExpired ? 'label_expired_on' : 'label_locked_until'}`)}
+              </div>
+              <div className={styles.content}>
+                {fidelityBond.locktime} ({humanReadableDuration})
               </div>
             </div>
-          )}
+          </div>
           <div className="d-flex align-items-center gap-2">
             <CopyButton
               showSprites={false}
@@ -80,6 +91,7 @@ const ExistingFidelityBond = ({ fidelityBond }: ExistingFidelityBondProps) => {
           </div>
         </div>
       </div>
+      {children}
     </div>
   )
 }
diff --git a/src/components/fb/FidelityBondSteps.tsx b/src/components/fb/FidelityBondSteps.tsx
index f977365..b19cfe7 100644
--- a/src/components/fb/FidelityBondSteps.tsx
+++ b/src/components/fb/FidelityBondSteps.tsx
@@ -27,7 +27,7 @@ interface SelectJarProps {
   accountBalances: AccountBalances
   totalBalance: Api.AmountSats
   isJarSelectable: (jarIndex: JarIndex) => boolean
-  selectedJar: JarIndex | null
+  selectedJar?: JarIndex
   onJarSelected: (jarIndex: JarIndex) => void
 }
 
@@ -205,7 +205,7 @@ const FreezeUtxos = ({ walletInfo, jar, utxos, selectedUtxos, isLoading = false
   const utxosToFreeze = useMemo(() => fb.utxo.utxosToFreeze(utxos, selectedUtxos), [utxos, selectedUtxos])
 
   return (
-    <div className="d-flex flex-column gap-4">
+    <div className="d-flex flex-column gap-2">
       <div className={styles.stepDescription}>{t('earn.fidelity_bond.freeze_utxos.description_selected_utxos')}</div>
       {selectedUtxos.map((utxo, index) => (
         <UtxoCard
@@ -219,11 +219,11 @@ const FreezeUtxos = ({ walletInfo, jar, utxos, selectedUtxos, isLoading = false
       {utxosToFreeze.length > 0 && (
         <>
           {fb.utxo.allAreFrozen(utxosToFreeze) ? (
-            <div className={styles.stepDescription}>
+            <div className={`mt-2 ${styles.stepDescription}`}>
               {t('earn.fidelity_bond.freeze_utxos.description_unselected_utxos')}
             </div>
           ) : (
-            <div className={styles.stepDescription}>
+            <div className={`mt-2 ${styles.stepDescription}`}>
               {t('earn.fidelity_bond.freeze_utxos.description_unselected_utxos')}{' '}
               {t('earn.fidelity_bond.freeze_utxos.description_selected_utxos_to_freeze', { jar: jarInitial(jar) })}
             </div>
diff --git a/src/components/fb/SpendFidelityBondModal.module.css b/src/components/fb/SpendFidelityBondModal.module.css
new file mode 100644
index 0000000..d0ed42c
--- /dev/null
+++ b/src/components/fb/SpendFidelityBondModal.module.css
@@ -0,0 +1,15 @@
+.successCheckmark {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 2rem;
+  height: 2rem;
+  background-color: rgba(39, 174, 96, 1);
+  color: white;
+  border-radius: 50%;
+}
+
+.successSummaryTitle {
+  font-size: 1.2rem;
+  font-weight: 500;
+}
diff --git a/src/components/fb/SpendFidelityBondModal.tsx b/src/components/fb/SpendFidelityBondModal.tsx
new file mode 100644
index 0000000..462cf79
--- /dev/null
+++ b/src/components/fb/SpendFidelityBondModal.tsx
@@ -0,0 +1,445 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import * as rb from 'react-bootstrap'
+import { TFunction } from 'i18next'
+import { useTranslation } from 'react-i18next'
+import { CurrentWallet, useReloadCurrentWalletInfo, Utxo, Utxos, WalletInfo } from '../../context/WalletContext'
+import * as Api from '../../libs/JmWalletApi'
+import * as fb from './utils'
+import Alert from '../Alert'
+import Sprite from '../Sprite'
+import { SelectJar } from './FidelityBondSteps'
+import { PaymentConfirmModal } from '../PaymentConfirmModal'
+import { jarInitial } from '../jars/Jar'
+import { FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees'
+
+import styles from './SpendFidelityBondModal.module.css'
+
+type Input = {
+  outpoint: Api.UtxoId
+  scriptSig: string
+  nSequence: number
+  witness: string
+}
+
+type Output = {
+  value_sats: Api.AmountSats
+  scriptPubKey: string
+  address: string
+}
+
+type TxInfo = {
+  hex: string
+  inputs: Input[]
+  outputs: Output[]
+  txid: Api.TxId
+  nLocktime: number
+  nVersion: number
+}
+
+interface Result {
+  txInfo?: TxInfo
+  mustReload: boolean
+}
+
+const errorResolver = (t: TFunction<'translation', undefined>, i18nKey: string | string[]) => ({
+  resolver: (_: Response, reason: string) => `${t(i18nKey)} ${reason}`,
+  fallbackReason: t('global.errors.reason_unknown'),
+})
+
+type UtxoDirectSendRequest = {
+  destination: Api.BitcoinAddress
+  sourceJarIndex: JarIndex
+  utxos: Utxos
+}
+
+type UtxoDirectSendHook = {
+  onReloadWalletError: (res: Response) => Promise<never>
+  onFreezeUtxosError: (res: Response) => Promise<never>
+  onUnfreezeUtxosError: (res: Response) => Promise<never>
+  onSendError: (res: Response) => Promise<never>
+}
+
+const spendUtxosWithDirectSend = async (
+  context: Api.WalletRequestContext,
+  request: UtxoDirectSendRequest,
+  hooks: UtxoDirectSendHook
+) => {
+  const utxosFromSameJar = request.utxos.every((it) => it.mixdepth === request.sourceJarIndex)
+  if (!utxosFromSameJar || request.utxos.length === 0) {
+    // this is a programming error (no translation needed)
+    throw new Error('Precondition failed: UTXOs must be from the same jar')
+  }
+
+  const spendableUtxoIds = request.utxos.map((it) => it.utxo)
+
+  // reload utxos
+  const utxosFromSourceJar = (
+    await Api.getWalletUtxos(context)
+      .then((res) => (res.ok ? res.json() : hooks.onReloadWalletError(res)))
+      .then((data) => data.utxos as Utxos)
+  ).filter((utxo) => utxo.mixdepth === request.sourceJarIndex)
+
+  const utxosToSpend = utxosFromSourceJar.filter((it) => spendableUtxoIds.includes(it.utxo))
+
+  const utxosToFreeze = utxosFromSourceJar
+    .filter((it) => !it.frozen)
+    .filter((it) => !spendableUtxoIds.includes(it.utxo))
+
+  const utxosThatWereFrozen: Api.UtxoId[] = []
+  const utxosThatWereUnfrozen: Api.UtxoId[] = []
+
+  try {
+    const freezeCalls = utxosToFreeze.map((utxo) =>
+      Api.postFreeze(context, { utxo: utxo.utxo, freeze: true }).then((res) => {
+        if (!res.ok) return hooks.onFreezeUtxosError(res)
+        utxosThatWereFrozen.push(utxo.utxo)
+      })
+    )
+    // freeze unused coins not part of the payment
+    await Promise.all(freezeCalls)
+
+    const unfreezeCalls = utxosToSpend
+      .filter((it) => it.frozen)
+      .map((utxo) =>
+        Api.postFreeze(context, { utxo: utxo.utxo, freeze: false }).then((res) => {
+          if (!res.ok) return hooks.onUnfreezeUtxosError(res)
+          utxosThatWereUnfrozen.push(utxo.utxo)
+        })
+      )
+    // unfreeze potentially frozen coins that are about to be spent
+    await Promise.all(unfreezeCalls)
+
+    // spend fidelity bond (by sweeping whole jar)
+    return await Api.postDirectSend(context, {
+      destination: request.destination,
+      mixdepth: request.sourceJarIndex,
+      amount_sats: 0, // sweep
+    }).then((res) => (res.ok ? res.json() : hooks.onSendError(res)))
+  } finally {
+    try {
+      // try unfreezing all previously frozen coins
+      const unfreezeCalls = utxosThatWereFrozen.map((utxo) => Api.postFreeze(context, { utxo, freeze: false }))
+
+      await Promise.allSettled(unfreezeCalls)
+    } catch (e) {
+      // don't throw, just log, as we are in a finally block
+      console.error('Error while unfreezing previously frozen UTXOs', e)
+    }
+
+    try {
+      // try freezing all previously unfrozen coins
+      const freezeCalls = utxosThatWereUnfrozen.map((utxo) => Api.postFreeze(context, { utxo, freeze: true }))
+
+      await Promise.allSettled(freezeCalls)
+    } catch (e) {
+      // don't throw, just log, as we are in a finally block
+      console.error('Error while freezing previously unfrozen UTXOs', e)
+    }
+  }
+}
+
+type SpendFidelityBondModalProps = {
+  fidelityBondId: Api.UtxoId
+  wallet: CurrentWallet
+  walletInfo: WalletInfo
+  onClose: (result: Result) => void
+  destinationJarIndex?: JarIndex
+} & Omit<rb.ModalProps, 'onHide'>
+
+const SpendFidelityBondModal = ({
+  fidelityBondId,
+  wallet,
+  walletInfo,
+  onClose,
+  destinationJarIndex,
+  ...modalProps
+}: SpendFidelityBondModalProps) => {
+  const { t } = useTranslation()
+  const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
+  const loadFeeConfigValues = useLoadFeeConfigValues()
+
+  const [alert, setAlert] = useState<(rb.AlertProps & { message: string }) | undefined>()
+  const [selectedDestinationJarIndex, setSelectedDestinationJarIndex] = useState<JarIndex | undefined>(
+    destinationJarIndex
+  )
+
+  const [txInfo, setTxInfo] = useState<TxInfo | undefined>()
+  const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState<Api.UtxoId[]>([])
+
+  const [parentMustReload, setParentMustReload] = useState(false)
+  const [isSending, setIsSending] = useState(false)
+  const isLoading = useMemo(() => isSending || waitForUtxosToBeSpent.length > 0, [isSending, waitForUtxosToBeSpent])
+
+  const enableDestinationJarSelection = useMemo(() => destinationJarIndex === undefined, [destinationJarIndex])
+  const [showConfirmSendModal, setShowConfirmSendModal] = useState(!enableDestinationJarSelection)
+  const [feeConfigValues, setFeeConfigValues] = useState<FeeValues>()
+
+  const submitButtonRef = useRef<HTMLButtonElement>(null)
+
+  const fidelityBond = useMemo(() => {
+    return walletInfo.data.utxos.utxos.find((utxo) => utxo.utxo === fidelityBondId)
+  }, [walletInfo, fidelityBondId])
+
+  useEffect(() => {
+    const abortCtrl = new AbortController()
+
+    loadFeeConfigValues(abortCtrl.signal)
+      .then((data) => {
+        if (abortCtrl.signal.aborted) return
+        setFeeConfigValues(data)
+      })
+      .catch((e) => {
+        if (abortCtrl.signal.aborted) return
+        // As fee config is not essential, don't raise an error on purpose.
+        // Fee settings cannot be displayed, but making a payment is still possible.
+        setFeeConfigValues(undefined)
+      })
+
+    return () => {
+      abortCtrl.abort()
+    }
+  }, [loadFeeConfigValues])
+
+  // This callback is responsible for updating the loading state when the
+  // the payment is made. The wallet needs some time after a tx is sent
+  // to reflect the changes internally. All outputs in
+  // `waitForUtxosToBeSpent` must have been removed from the wallet
+  // for the payment to be considered done.
+  useEffect(() => {
+    if (waitForUtxosToBeSpent.length === 0) return
+
+    const abortCtrl = new AbortController()
+
+    // Delaying the poll requests gives the wallet some time to synchronize
+    // the utxo set and reduces amount of http requests
+    const initialDelayInMs = 250
+    const timer = setTimeout(() => {
+      if (abortCtrl.signal.aborted) return
+
+      reloadCurrentWalletInfo
+        .reloadUtxos({ signal: abortCtrl.signal })
+        .then((res) => {
+          if (abortCtrl.signal.aborted) return
+
+          const outputs = res.utxos.map((it) => it.utxo)
+          const utxosStillPresent = waitForUtxosToBeSpent.filter((it) => outputs.includes(it))
+          setWaitForUtxosToBeSpent([...utxosStillPresent])
+        })
+        .catch((err) => {
+          if (abortCtrl.signal.aborted) return
+
+          // Stop waiting for wallet synchronization on errors, but inform
+          // the user that loading the wallet info failed
+          setWaitForUtxosToBeSpent([])
+
+          const message = t('global.errors.error_reloading_wallet_failed', {
+            reason: err.message || t('global.errors.reason_unknown'),
+          })
+          setAlert({ variant: 'danger', message })
+        })
+    }, initialDelayInMs)
+
+    return () => {
+      abortCtrl.abort()
+      clearTimeout(timer)
+    }
+  }, [waitForUtxosToBeSpent, reloadCurrentWalletInfo, t])
+
+  const onPrimaryButtonClicked = () => {
+    if (isLoading) return
+    if (selectedDestinationJarIndex === undefined) return
+    if (waitForUtxosToBeSpent.length > 0) return
+
+    if (txInfo) {
+      onClose({ txInfo, mustReload: parentMustReload })
+    } else if (!showConfirmSendModal) {
+      setShowConfirmSendModal(true)
+    } else {
+      setShowConfirmSendModal(false)
+      setParentMustReload(true)
+
+      setAlert(undefined)
+      setIsSending(true)
+
+      sendFidelityBondToJar(fidelityBond, selectedDestinationJarIndex)
+        .then((data) => data.txinfo as TxInfo)
+        .then((txinfo) => {
+          setTxInfo(txinfo)
+
+          setWaitForUtxosToBeSpent(txinfo.inputs.map((it) => it.outpoint))
+
+          setIsSending(false)
+        })
+        .catch((e) => {
+          setIsSending(false)
+
+          const message = e instanceof Error ? e.message : t('global.errors.reason_unknown')
+          setAlert({ variant: 'danger', message })
+        })
+    }
+  }
+
+  const sendFidelityBondToJar = async (fidelityBond: Utxo | undefined, targetJarIndex: JarIndex) => {
+    if (!fidelityBond || fb.utxo.isLocked(fidelityBond)) {
+      throw new Error(t('earn.fidelity_bond.move.error_fidelity_bond_still_locked'))
+    }
+
+    const abortCtrl = new AbortController()
+    const { name: walletName, token } = wallet
+    const requestContext = { walletName, token, signal: abortCtrl.signal }
+
+    const destination = await Api.getAddressNew({ ...requestContext, mixdepth: targetJarIndex })
+      .then((res) => {
+        if (res.ok) return res.json()
+        return Api.Helper.throwResolved(res, errorResolver(t, 'earn.fidelity_bond.move.error_loading_address'))
+      })
+      .then((data) => data.address as Api.BitcoinAddress)
+
+    return await spendUtxosWithDirectSend(
+      requestContext,
+      {
+        destination,
+        sourceJarIndex: fidelityBond.mixdepth,
+        utxos: [fidelityBond],
+      },
+      {
+        onReloadWalletError: (res) =>
+          Api.Helper.throwResolved(res, errorResolver(t, 'global.errors.error_reloading_wallet_failed')),
+        onFreezeUtxosError: (res) =>
+          Api.Helper.throwResolved(res, errorResolver(t, 'earn.fidelity_bond.move.error_freezing_utxos')),
+        onUnfreezeUtxosError: (res) =>
+          Api.Helper.throwResolved(res, errorResolver(t, 'earn.fidelity_bond.move.error_unfreezing_fidelity_bond')),
+        onSendError: (res) =>
+          Api.Helper.throwResolved(res, errorResolver(t, 'earn.fidelity_bond.move.error_spending_fidelity_bond')),
+      }
+    )
+  }
+
+  const PrimaryButtonContent = () => {
+    if (isSending) {
+      return (
+        <>
+          <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
+          {t('earn.fidelity_bond.move.text_sending')}
+        </>
+      )
+    } else if (txInfo) {
+      return <>{t('earn.fidelity_bond.move.text_button_done')}</>
+    }
+    return <>{t('earn.fidelity_bond.move.text_button_submit')}</>
+  }
+
+  const Done = ({ text }: { text: string }) => {
+    return (
+      <div className="d-flex flex-column justify-content-center align-items-center gap-1">
+        <div className={styles.successCheckmark}>
+          <Sprite symbol="checkmark" width="24" height="30" />
+        </div>
+        <div className={styles.successSummaryTitle}>{text}</div>
+      </div>
+    )
+  }
+
+  const ModalBodyContent = () => {
+    if (isLoading) {
+      return (
+        <div className="d-flex justify-content-center align-items-center my-5">
+          <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
+          <div>{t(`earn.fidelity_bond.move.${isSending ? 'text_sending' : 'text_loading'}`)}</div>
+        </div>
+      )
+    }
+
+    if (txInfo) {
+      return (
+        <div className="my-4">
+          <Done text={t('earn.fidelity_bond.move.success_text')} />
+        </div>
+      )
+    }
+
+    return (
+      <SelectJar
+        description={t('earn.fidelity_bond.move.select_jar.description')}
+        accountBalances={walletInfo.balanceSummary.accountBalances}
+        totalBalance={walletInfo.balanceSummary.calculatedAvailableBalanceInSats}
+        isJarSelectable={() => true}
+        selectedJar={selectedDestinationJarIndex}
+        onJarSelected={setSelectedDestinationJarIndex}
+      />
+    )
+  }
+
+  return (
+    <>
+      <rb.Modal
+        animation={true}
+        backdrop="static"
+        centered={true}
+        keyboard={false}
+        size="lg"
+        {...modalProps}
+        onHide={() => onClose({ txInfo, mustReload: parentMustReload })}
+        dialogClassName={showConfirmSendModal ? 'invisible' : ''}
+      >
+        <rb.Modal.Header closeButton>
+          <rb.Modal.Title>{t('earn.fidelity_bond.move.title')}</rb.Modal.Title>
+        </rb.Modal.Header>
+        <rb.Modal.Body>
+          {alert && <Alert {...alert} className="mt-0" onClose={() => setAlert(undefined)} />}
+          {ModalBodyContent()}
+        </rb.Modal.Body>
+        <rb.Modal.Footer>
+          <div className="w-100 d-flex gap-4 justify-content-center align-items-center">
+            <rb.Button
+              variant="light"
+              disabled={isSending}
+              onClick={() => onClose({ txInfo, mustReload: parentMustReload })}
+              className="flex-1 d-flex justify-content-center align-items-center"
+            >
+              {t('earn.fidelity_bond.move.text_button_cancel')}
+            </rb.Button>
+            <rb.Button
+              ref={submitButtonRef}
+              variant="dark"
+              className="flex-1 d-flex justify-content-center align-items-center"
+              disabled={isLoading || selectedDestinationJarIndex === undefined}
+              onClick={onPrimaryButtonClicked}
+            >
+              {PrimaryButtonContent()}
+            </rb.Button>
+          </div>
+        </rb.Modal.Footer>
+      </rb.Modal>
+      {showConfirmSendModal && fidelityBond && selectedDestinationJarIndex !== undefined && (
+        <PaymentConfirmModal
+          isShown={true}
+          title={t(`earn.fidelity_bond.move.${enableDestinationJarSelection ? 'confirm_send_modal.title' : 'title'}`)}
+          onCancel={() => {
+            setShowConfirmSendModal(false)
+            if (!enableDestinationJarSelection) {
+              onClose({ txInfo, mustReload: parentMustReload })
+            }
+          }}
+          onConfirm={() => {
+            submitButtonRef.current?.click()
+          }}
+          data={{
+            sourceJarIndex: undefined, // dont show a source jar - might be confusing in this context
+            destination: String(
+              t('send.confirm_send_modal.text_source_jar', { jarId: jarInitial(selectedDestinationJarIndex) })
+            ),
+            amount: fidelityBond.value,
+            isSweep: true,
+            isCoinjoin: false, // not sent as collaborative transaction
+            numCollaborators: undefined,
+            feeConfigValues,
+            showPrivacyInfo: false,
+          }}
+        />
+      )}
+    </>
+  )
+}
+
+export { SpendFidelityBondModal }
diff --git a/src/components/jar_details/JarDetailsOverlay.tsx b/src/components/jar_details/JarDetailsOverlay.tsx
index 76d5101..fc1214b 100644
--- a/src/components/jar_details/JarDetailsOverlay.tsx
+++ b/src/components/jar_details/JarDetailsOverlay.tsx
@@ -180,8 +180,18 @@ const JarDetailsOverlay = (props: JarDetailsOverlayProps) => {
     [serviceInfo]
   )
 
+  /**
+   * Always allow freezing, but only allow unfreezing of non-timelocked UTXOs.
+   *
+   * Expired, unfrozen FBs cannot be used in taker or maker operation. Hence,
+   * unfreezing of FBs is forbidden in this component. The FB should be spent
+   * (unfreeze and sweep) via other mechanisms (_not_ in this component).
+   *
+   * @param utxo UTXO to check whether freez/unfreeze is allowed
+   * @returns true when UTXO can be frozen/unfrozen
+   */
   const canBeFrozenOrUnfrozen = (utxo: Utxo) => {
-    const isUnfreezeEnabled = !fb.utxo.isLocked(utxo)
+    const isUnfreezeEnabled = !fb.utxo.isFidelityBond(utxo)
     const allowedToExecute = !utxo.frozen || isUnfreezeEnabled
 
     return allowedToExecute
diff --git a/src/components/jar_details/UtxoList.module.css b/src/components/jar_details/UtxoList.module.css
index 8f76f93..e398745 100644
--- a/src/components/jar_details/UtxoList.module.css
+++ b/src/components/jar_details/UtxoList.module.css
@@ -7,11 +7,11 @@
   margin-bottom: 1px;
 }
 
-.utxoList .frozenLockedTag > .iconFrozen {
-  margin-bottom: 2px;
+.utxoList .utxoIcon > .iconFrozen {
+  margin-bottom: 1px;
 }
 
-.utxoList .frozenLockedTag > .iconLocked {
+.utxoList .utxoIcon > .iconLocked {
   margin-bottom: 3px;
 }
 
diff --git a/src/components/jar_details/UtxoList.tsx b/src/components/jar_details/UtxoList.tsx
index 997be82..fddc327 100644
--- a/src/components/jar_details/UtxoList.tsx
+++ b/src/components/jar_details/UtxoList.tsx
@@ -170,12 +170,15 @@ const UtxoList = ({ utxos, walletInfo, selectState, setSelectedUtxoIds, setDetai
           array.sort((a, b) => {
             const aUtxo = toUtxo(a)
             const bUtxo = toUtxo(b)
-            if (fb.utxo.isLocked(aUtxo) && !fb.utxo.isLocked(bUtxo)) return -1
-            if (!fb.utxo.isLocked(aUtxo) && fb.utxo.isLocked(bUtxo)) return 1
-            if (fb.utxo.isLocked(aUtxo) && fb.utxo.isLocked(bUtxo)) return 0
+            const aLocked = fb.utxo.isLocked(aUtxo)
+            const bLocked = fb.utxo.isLocked(bUtxo)
+
+            if (aLocked && !bLocked) return -1
+            if (!aLocked && bLocked) return 1
+            if (aLocked && bLocked) return aUtxo.value - bUtxo.value
             if (aUtxo.frozen && !bUtxo.frozen) return -1
             if (!aUtxo.frozen && bUtxo.frozen) return 1
-            if (aUtxo.frozen && bUtxo.frozen) return 0
+            if (aUtxo.frozen && bUtxo.frozen) return aUtxo.value - bUtxo.value
             return 0
           }),
         [SORT_KEYS.value]: (array) => array.sort((a, b) => a.value - b.value),
@@ -184,16 +187,16 @@ const UtxoList = ({ utxos, walletInfo, selectState, setSelectedUtxoIds, setDetai
     }
   )
 
-  const frozenOrLockedIcon = (utxo: Utxo) => {
-    if (fb.utxo.isLocked(utxo)) {
+  const utxoIcon = (utxo: Utxo) => {
+    if (fb.utxo.isFidelityBond(utxo)) {
       return (
-        <div className={styles.frozenLockedTag}>
+        <div className={styles.utxoIcon}>
           <Sprite className={styles.iconLocked} symbol="timelock" width="20" height="20" />
         </div>
       )
     } else if (utxo.frozen) {
       return (
-        <div className={styles.frozenLockedTag}>
+        <div className={styles.utxoIcon}>
           <Sprite className={styles.iconFrozen} symbol="snowflake" width="20" height="20" />
         </div>
       )
@@ -236,7 +239,7 @@ const UtxoList = ({ utxos, walletInfo, selectState, setSelectedUtxoIds, setDetai
                 return (
                   <Row key={item.id} item={item}>
                     <CellSelect item={item} />
-                    <Cell>{frozenOrLockedIcon(utxo)}</Cell>
+                    <Cell>{utxoIcon(utxo)}</Cell>
                     <Cell>
                       <Balance
                         valueString={utxo.value.toString()}
diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx
index fd33e54..50ac471 100644
--- a/src/context/WalletContext.tsx
+++ b/src/context/WalletContext.tsx
@@ -131,7 +131,18 @@ const toAddressSummary = (res: WalletDisplayResponse): AddressSummary => {
 }
 
 const toFidelityBondSummary = (res: UtxosResponse): FidenlityBondSummary => {
-  const fbOutputs = res.utxos.filter((utxo) => fb.utxo.isFidelityBond(utxo))
+  const fbOutputs = res.utxos
+    .filter((utxo) => fb.utxo.isFidelityBond(utxo))
+    .sort((a, b) => {
+      const aLocked = fb.utxo.isLocked(a)
+      const bLocked = fb.utxo.isLocked(b)
+
+      if (aLocked && bLocked) {
+        return b.value - a.value
+      } else {
+        return aLocked ? -1 : 1
+      }
+    })
   return {
     fbOutputs,
   }
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index ef4f100..07c95f2 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -21,6 +21,11 @@
           "label_last": "Last"
         }
       }
+    },
+    "errors": {
+      "error_loading_wallet_failed": "Loading wallet failed. {{ reason }}",
+      "error_reloading_wallet_failed": "Reloading wallet failed. {{ reason }}",
+      "reason_unknown": "Unknown reason."
     }
   },
   "app": {
@@ -215,7 +220,6 @@
     "error_invalid_num_collaborators": "Please use between {{ minNumCollaborators }} and {{ maxNumCollaborators }} collaborators.",
     "taker_error_message_max_fees_config_missing": "Config variables 'max_cj_fee_rel' and 'max_cj_fee_abs' must be set in your joinmarket.cfg in order to send collaborative transactions. Consider adding them to your config manually.",
     "direct_payment_error_message_bad_request": "Please verify your inputs carefully.",
-    "error_loading_wallet_failed": "Loading wallet failed.",
     "error_loading_min_makers_failed": "Loading config value 'minimum_makers' failed.",
     "alert_payment_successful": "Payment successful: Sent {{ amount }} sats to {{ address }} in transaction {{ txid }}.",
     "text_maker_running": "Earn is active. Stop the service in order to send collaborative transactions.",
@@ -244,7 +248,7 @@
     "confirm_send_modal": {
       "title": "Confirm payment",
       "label_amount": "Amount",
-      "text_sweep_balance": "Sweep whole jar (<1></1>)",
+      "text_sweep_balance": "Sweep (<1></1>)",
       "text_sweep_info_popover": "The exact transaction amount can only be calculated by JoinMarket at the point when the transaction is made.",
       "label_recipient": "Recipient",
       "label_source_jar": "Send from",
@@ -350,7 +354,6 @@
       "error_unfreezing_utxos": "Could not unfreeze UTXOs.",
       "error_loading_address": "Could not load time-locked address.",
       "error_creating_fidelity_bond": "The transaction to create the fidelity bond failed.",
-      "error_reloading_wallet": "Could not reload the wallet.",
       "text_loading": "Loading...",
       "text_creating": "Creating...",
       "text_unfreezing": "Unfreezing...",
@@ -431,10 +434,31 @@
       },
       "existing": {
         "title_active": "Fidelity Bond",
-        "title_expired": "Expired Fidelity Bond",
+        "title_expired": "Fidelity Bond <1>expired</1>",
         "label_locked_until": "Locked until",
         "label_expired_on": "Expired on",
-        "label_address": "Timelocked address"
+        "label_address": "Timelocked address",
+        "button_spend": "Unlock Funds"
+      },
+      "move": {
+        "title": "Unlock Funds",
+        "success_text": "Fidelity Bond unlocked successfully!",
+        "text_loading": "Loading...",
+        "text_sending": "Unlocking...",
+        "text_button_cancel": "Cancel",
+        "text_button_submit": "Unlock Funds",
+        "text_button_done": "Done",
+        "error_fidelity_bond_still_locked": "The given fidelity bond is still locked.",
+        "error_loading_address": "Unable to get new address.",
+        "error_freezing_utxos": "Could not freeze UTXOs.",
+        "error_unfreezing_fidelity_bond": "Error while unfreezing expired fidelity bond.",
+        "error_spending_fidelity_bond": "Error while unlocking expired fidelity bond.",
+        "select_jar": {
+          "description": "Select a jar to move the expired fidelity bond into."
+        },
+        "confirm_send_modal": {
+          "title": "Confirm unlocking expired Fidelity Bond"
+        }
       }
     }
   },
diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json
index 21056ad..83cfbf9 100644
--- a/src/i18n/locales/fr/translation.json
+++ b/src/i18n/locales/fr/translation.json
@@ -1,4 +1,9 @@
 {
+  "global": {
+    "errors": {
+      "error_loading_wallet_failed": "Le chargement du portefeuille a échoué. {{ reason }}"
+    }
+  },
   "navbar": {
     "title": "Jam",
     "button_create_wallet": "Créer un porte-monnaie",
@@ -179,7 +184,6 @@
     "error_invalid_num_collaborators": "Veuillez utiliser entre {{ minNumCollaborators }} et {{ maxNumCollaborators }} collaborateurs.",
     "taker_error_message_max_fees_config_missing": "Les variables de configuration 'max_cj_fee_rel' et 'max_cj_fee_abs' doivent être définies dans votre fichier joinmarket.cfg afin d'envoyer des transactions collaboratives. Pensez à les ajouter manuellement à votre configuration.",
     "direct_payment_error_message_bad_request": " Veuillez vérifier soigneusement vos entrées.",
-    "error_loading_wallet_failed": "Le chargement du portefeuille a échoué.",
     "error_loading_min_makers_failed": "Le chargement de la valeur de configuration 'minimum_makers' a échoué.",
     "alert_payment_successful": "Paiement réussi : Envoyé {{ amount }} sats à {{ address }} en transaction {{ txid }}.",
     "text_maker_running": "Générer est actif. Arrêtez le service afin d'envoyer des transactions collaboratives.",
diff --git a/src/libs/JamApi.ts b/src/libs/JamApi.ts
index 7d3d0a6..f0dbe04 100644
--- a/src/libs/JamApi.ts
+++ b/src/libs/JamApi.ts
@@ -27,7 +27,7 @@ interface AuthApiRequestContext extends ApiRequestContext {
 const withExpectedContentTypeOrThrow = (res: Response, expectedContentType: string) => {
   const contentType = res.headers.get('Content-Type')
   if (res.ok && (contentType === null || !contentType.startsWith(expectedContentType))) {
-    throw new JmApiError('Unexpected Content-Type', res)
+    throw new JmApiError(res, 'Unexpected Content-Type')
   }
   return res
 }
diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts
index 44f6aec..8323cc5 100644
--- a/src/libs/JmWalletApi.ts
+++ b/src/libs/JmWalletApi.ts
@@ -161,8 +161,22 @@ const Helper = (() => {
     }
   }
 
+  /**
+   * @deprecated Use `throwResolved()` instead
+   */
   const throwError = async (response: Response, fallbackReason = response.statusText): Promise<never> => {
-    throw new JmApiError(await extractErrorMessage(response, fallbackReason), response)
+    throw new JmApiError(response, await extractErrorMessage(response, fallbackReason))
+  }
+
+  const DEFAULT_RESOLVER = (res: Response, reason: string) => reason
+
+  const throwResolved = async (
+    response: Response,
+    { resolver = DEFAULT_RESOLVER, fallbackReason = response.statusText } = {}
+  ): Promise<never> => {
+    const reason = await extractErrorMessage(response, fallbackReason)
+    const errorMessage = resolver(response, reason) || reason
+    throw new JmApiError(response, errorMessage)
   }
 
   /**
@@ -180,6 +194,7 @@ const Helper = (() => {
 
   return {
     throwError,
+    throwResolved,
     extractErrorMessage,
     buildAuthHeader,
   }
@@ -407,7 +422,7 @@ const postConfigGet = async ({ token, signal, walletName }: WalletRequestContext
 export class JmApiError extends Error {
   public response: Response
 
-  constructor(message: string, response: Response) {
+  constructor(response: Response, message: string) {
     super(message)
     this.response = response
   }


Test patch:
diff --git a/src/components/Balance.test.jsx b/src/components/Balance.test.jsx
index 8c62461..0981ab8 100644
--- a/src/components/Balance.test.jsx
+++ b/src/components/Balance.test.jsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import { act } from 'react-dom/test-utils'
 import user from '@testing-library/user-event'
 import { render, screen } from '../testUtils'
@@ -12,6 +11,11 @@ describe('<Balance />', () => {
     expect(screen.getByTestId('balance-component-placeholder')).toBeInTheDocument()
   })
 
+  it('should render invalid param as given', () => {
+    render(<Balance valueString={'NaN'} convertToUnit={BTC} showBalance={true} />)
+    expect(screen.getByText(`NaN`)).toBeInTheDocument()
+  })
+
   it('should render BTC using satscomma formatting', () => {
     render(<Balance valueString={'123.456'} convertToUnit={BTC} showBalance={true} />)
     expect(screen.getByText(`123.45 600 000`)).toBeInTheDocument()