Skip to content

Commit

Permalink
feat: renew fidelity bond (#678)
Browse files Browse the repository at this point in the history
  • Loading branch information
theborakompanioni authored Feb 6, 2024
1 parent dc95e64 commit b4948ef
Show file tree
Hide file tree
Showing 10 changed files with 644 additions and 205 deletions.
48 changes: 35 additions & 13 deletions src/components/Earn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import PageTitle from './PageTitle'
import SegmentedTabs from './SegmentedTabs'
import { CreateFidelityBond } from './fb/CreateFidelityBond'
import { ExistingFidelityBond } from './fb/ExistingFidelityBond'
import { SpendFidelityBondModal } from './fb/SpendFidelityBondModal'
import { RenewFidelityBondModal, SpendFidelityBondModal } from './fb/SpendFidelityBondModal'
import { EarnReportOverlay } from './EarnReport'
import { OrderbookOverlay } from './Orderbook'
import Balance from './Balance'
Expand Down Expand Up @@ -424,6 +424,7 @@ export default function Earn({ wallet }: EarnProps) {
}, [currentWalletInfo])

const [moveToJarFidelityBondId, setMoveToJarFidelityBondId] = useState<Api.UtxoId>()
const [renewFidelityBondId, setRenewFidelityBondId] = useState<Api.UtxoId>()

const startMakerService = useCallback(
(values: EarnFormValues) => {
Expand Down Expand Up @@ -619,6 +620,20 @@ export default function Earn({ wallet }: EarnProps) {
}}
/>
)}
{currentWalletInfo && renewFidelityBondId && (
<RenewFidelityBondModal
show={true}
fidelityBondId={renewFidelityBondId}
wallet={wallet}
walletInfo={currentWalletInfo}
onClose={({ mustReload }) => {
setRenewFidelityBondId(undefined)
if (mustReload) {
reloadFidelityBonds({ delay: 0 })
}
}}
/>
)}
{fidelityBonds.map((fidelityBond, index) => {
const isExpired = !fb.utxo.isLocked(fidelityBond)
const actionsEnabled =
Expand All @@ -633,18 +648,25 @@ export default function Earn({ wallet }: EarnProps) {
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 className="mt-4 d-flex gap-2">
<rb.Button
variant={settings.theme === 'dark' ? 'light' : 'dark'}
className="w-100 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>
<rb.Button
variant={settings.theme === 'dark' ? 'light' : 'dark'}
className="w-100 d-flex justify-content-center align-items-center"
disabled={renewFidelityBondId !== undefined}
onClick={() => setRenewFidelityBondId(fidelityBond.utxo)}
>
<Sprite className="me-1" symbol="refresh" width="24" height="24" />
{t('earn.fidelity_bond.existing.button_renew')}
</rb.Button>
</div>
)}
</ExistingFidelityBond>
Expand Down
16 changes: 11 additions & 5 deletions src/components/PaymentConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { useMemo } from 'react'
import { PropsWithChildren, 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 { FeeValues, TxFee, useEstimatedMaxCollaboratorFee } from '../hooks/Fees'
import { ConfirmModal, ConfirmModalProps } from './Modal'
import styles from './PaymentConfirmModal.module.css'
import { AmountSats } from '../libs/JmWalletApi'
import { AmountSats, BitcoinAddress } from '../libs/JmWalletApi'
import { jarInitial } from './jars/Jar'
import { isValidNumber } from '../utils'
import styles from './PaymentConfirmModal.module.css'

const feeRange: (txFee: TxFee, txFeeFactor: number) => [number, number] = (txFee, txFeeFactor) => {
if (txFee.unit !== 'sats/kilo-vbyte') {
Expand Down Expand Up @@ -57,7 +57,7 @@ const useMiningFeeText = ({ tx_fees, tx_fees_factor }: Pick<FeeValues, 'tx_fees'

interface PaymentDisplayInfo {
sourceJarIndex?: JarIndex
destination: String
destination: BitcoinAddress | string
amount: AmountSats
isSweep: boolean
isCoinjoin: boolean
Expand All @@ -81,8 +81,9 @@ export function PaymentConfirmModal({
feeConfigValues,
showPrivacyInfo = true,
},
children,
...confirmModalProps
}: PaymentConfirmModalProps) {
}: PropsWithChildren<PaymentConfirmModalProps>) {
const { t } = useTranslation()
const settings = useSettings()

Expand Down Expand Up @@ -206,6 +207,11 @@ export function PaymentConfirmModal({
</rb.Col>
</rb.Row>
)}
{children && (
<rb.Row>
<rb.Col xs={12}>{children}</rb.Col>
</rb.Row>
)}
</rb.Container>
</ConfirmModal>
)
Expand Down
68 changes: 19 additions & 49 deletions src/components/Send/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ import * as rb from 'react-bootstrap'
import * as Api from '../../libs/JmWalletApi'
import PageTitle from '../PageTitle'
import Sprite from '../Sprite'
import { SendForm, SendFormValues } from './SendForm'
import { ConfirmModal } from '../Modal'
import { scrollToTop } from '../../utils'
import { PaymentConfirmModal } from '../PaymentConfirmModal'
import FeeConfigModal, { FeeConfigSectionKey } from '../settings/FeeConfigModal'
import { FeeValues, TxFee, useFeeConfigValues } from '../../hooks/Fees'
import { useReloadCurrentWalletInfo, useCurrentWalletInfo, CurrentWallet } from '../../context/WalletContext'
import { useServiceInfo, useReloadServiceInfo } from '../../context/ServiceInfoContext'
import { useLoadConfigValue } from '../../context/ServiceConfigContext'
import { useWaitForUtxosToBeSpent } from '../../hooks/WaitForUtxosToBeSpent'
import { routes } from '../../constants/routes'
import { JM_MINIMUM_MAKERS_DEFAULT } from '../../constants/config'
import { scrollToTop } from '../../utils'

import { initialNumCollaborators } from './helpers'
import { SendForm, SendFormValues } from './SendForm'

const INITIAL_DESTINATION = null
const INITIAL_SOURCE_JAR_INDEX = null
Expand Down Expand Up @@ -105,7 +107,7 @@ export default function Send({ wallet }: SendProps) {
[feeConfigValues],
)

const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([])
const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState<Api.UtxoId[]>([])
const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState<SimpleAlert>()

const isOperationDisabled = useMemo(
Expand Down Expand Up @@ -160,54 +162,22 @@ export default function Send({ wallet }: SendProps) {
[wallet, setAlert, t],
)

// This callback is responsible for updating `waitForUtxosToBeSpent` while
// the wallet is synchronizing. The wallet needs some time after a tx is sent
// to reflect the changes internally. In order to show the actual balance,
// all outputs in `waitForUtxosToBeSpent` must have been removed from the
// wallet's utxo set.
useEffect(
function updateWaitForUtxosToBeSpentHook() {
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 waitForUtxosToBeSpentContext = useMemo(
() => ({
waitForUtxosToBeSpent,
setWaitForUtxosToBeSpent,
onError: (error: any) => {
const message = t('global.errors.error_reloading_wallet_failed', {
reason: error.message || t('global.errors.reason_unknown'),
})
setAlert({ variant: 'danger', message })
},
}),
[waitForUtxosToBeSpent, t],
)

useWaitForUtxosToBeSpent(waitForUtxosToBeSpentContext)

useEffect(
function initialize() {
if (isOperationDisabled) {
Expand Down
84 changes: 58 additions & 26 deletions src/components/fb/CreateFidelityBond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Trans, useTranslation } from 'react-i18next'
import { CurrentWallet, Utxo, Utxos, WalletInfo, useReloadCurrentWalletInfo } from '../../context/WalletContext'
import Alert from '../Alert'
import Sprite from '../Sprite'
import { ConfirmModal } from '../Modal'
import {
SelectJar,
SelectUtxos,
Expand All @@ -18,9 +17,33 @@ import {
import * as fb from './utils'
import { isDebugFeatureEnabled } from '../../constants/debugFeatures'
import styles from './CreateFidelityBond.module.css'
import { PaymentConfirmModal } from '../PaymentConfirmModal'
import { useFeeConfigValues } from '../../hooks/Fees'

const TIMEOUT_RELOAD_UTXOS_AFTER_FB_CREATE_MS = 2_500

export const LockInfoAlert = ({ lockDate, className }: { lockDate: Api.Lockdate; className?: string }) => {
const { t, i18n } = useTranslation()

return (
<Alert
className={className}
variant="warning"
message={
<>
{t('earn.fidelity_bond.confirm_modal.body', {
date: new Date(fb.lockdate.toTimestamp(lockDate)).toUTCString(),
humanReadableDuration: fb.time.humanReadableDuration({
to: fb.lockdate.toTimestamp(lockDate),
locale: i18n.resolvedLanguage || i18n.language,
}),
})}
</>
}
/>
)
}

const steps = {
selectDate: 0,
selectJar: 1,
Expand All @@ -41,8 +64,9 @@ interface CreateFidelityBondProps {
}

const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDone }: CreateFidelityBondProps) => {
const { t } = useTranslation()
const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
const { t, i18n } = useTranslation()
const feeConfigValues = useFeeConfigValues()[0]

const [isExpanded, setIsExpanded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
Expand All @@ -53,17 +77,19 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
const [lockDate, setLockDate] = useState<Api.Lockdate | null>(null)
const [selectedJar, setSelectedJar] = useState<JarIndex>()
const [selectedUtxos, setSelectedUtxos] = useState<Utxos>([])
const [timelockedAddress, setTimelockedAddress] = useState(null)
const [timelockedAddress, setTimelockedAddress] = useState<Api.BitcoinAddress>()
const [utxoIdsToBeSpent, setUtxoIdsToBeSpent] = useState([])
const [createdFidelityBondUtxo, setCreatedFidelityBondUtxo] = useState<Utxo>()
const [frozenUtxos, setFrozenUtxos] = useState<Utxos>([])

const allUtxosSelected = useMemo(() => {
return (
walletInfo.balanceSummary.calculatedTotalBalanceInSats ===
selectedUtxos.map((it) => it.value).reduce((prev, curr) => prev + curr, 0)
)
}, [walletInfo, selectedUtxos])
const selectedUtxosTotalValue = useMemo(
() => selectedUtxos.map((it) => it.value).reduce((prev, curr) => prev + curr, 0),
[selectedUtxos],
)
const allUtxosSelected = useMemo(
() => walletInfo.balanceSummary.calculatedTotalBalanceInSats === selectedUtxosTotalValue,
[walletInfo, selectedUtxosTotalValue],
)

const yearsRange = useMemo(() => {
if (isDebugFeatureEnabled('allowCreatingExpiredFidelityBond')) {
Expand All @@ -79,7 +105,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
setSelectedJar(undefined)
setSelectedUtxos([])
setLockDate(null)
setTimelockedAddress(null)
setTimelockedAddress(undefined)
setAlert(undefined)
setCreatedFidelityBondUtxo(undefined)
setFrozenUtxos([])
Expand Down Expand Up @@ -254,8 +280,8 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
return (
<SelectDate
description={t('earn.fidelity_bond.select_date.description')}
selectableYearsRange={yearsRange}
onDateSelected={(date) => setLockDate(date)}
yearsRange={yearsRange}
onChange={(date) => setLockDate(date)}
/>
)
case steps.selectJar:
Expand Down Expand Up @@ -307,7 +333,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
)
}

if (timelockedAddress === null) {
if (!timelockedAddress) {
return <div>{t('earn.fidelity_bond.error_loading_address')}</div>
}

Expand Down Expand Up @@ -386,7 +412,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon

return t('earn.fidelity_bond.freeze_utxos.text_primary_button')
case steps.reviewInputs:
if (timelockedAddress === null) return t('earn.fidelity_bond.review_inputs.text_primary_button_error')
if (!timelockedAddress) return t('earn.fidelity_bond.review_inputs.text_primary_button_error')

if (!onlyCjOutOrFbUtxosSelected()) {
return t('earn.fidelity_bond.review_inputs.text_primary_button_unsafe')
Expand Down Expand Up @@ -522,7 +548,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
loadTimeLockedAddress(lockDate!)
}

if (step === steps.reviewInputs && timelockedAddress === null) {
if (step === steps.reviewInputs && !timelockedAddress) {
loadTimeLockedAddress(lockDate!)
return
}
Expand Down Expand Up @@ -568,26 +594,32 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
return (
<div className={styles.container}>
{alert && <Alert {...alert} className="mt-0" onClose={() => setAlert(undefined)} />}
{lockDate && (
<ConfirmModal
{lockDate && timelockedAddress && selectedJar !== undefined && (
<PaymentConfirmModal
isShown={showConfirmInputsModal}
size="lg"
title={t('earn.fidelity_bond.confirm_modal.title')}
onCancel={() => setShowConfirmInputsModal(false)}
onConfirm={() => {
setStep(steps.createFidelityBond)
setShowConfirmInputsModal(false)
directSweepToFidelityBond(selectedJar!, timelockedAddress!)
directSweepToFidelityBond(selectedJar, timelockedAddress)
}}
data={{
sourceJarIndex: undefined, // dont show a source jar - might be confusing in this context
destination: timelockedAddress,
amount: selectedUtxosTotalValue,
isSweep: true,
isCoinjoin: false, // not sent as collaborative transaction
numCollaborators: undefined,
feeConfigValues,
showPrivacyInfo: false,
}}
>
{t('earn.fidelity_bond.confirm_modal.body', {
date: new Date(fb.lockdate.toTimestamp(lockDate)).toUTCString(),
humanReadableDuration: fb.time.humanReadableDuration({
to: fb.lockdate.toTimestamp(lockDate),
locale: i18n.resolvedLanguage || i18n.language,
}),
})}
</ConfirmModal>
<LockInfoAlert className="text-start mt-4" lockDate={lockDate} />
</PaymentConfirmModal>
)}

<div className={styles.header} onClick={() => setIsExpanded(!isExpanded)}>
<div className="d-flex justify-content-between align-items-center">
<div className={styles.title}>
Expand Down
Loading

0 comments on commit b4948ef

Please sign in to comment.