From b4948ef9f013c6e127251d85def0096e9d4f21f2 Mon Sep 17 00:00:00 2001 From: Thebora Kompanioni Date: Tue, 6 Feb 2024 11:02:16 +0100 Subject: [PATCH] feat: renew fidelity bond (#678) --- src/components/Earn.tsx | 48 +- src/components/PaymentConfirmModal.tsx | 16 +- src/components/Send/index.tsx | 68 +-- src/components/fb/CreateFidelityBond.tsx | 84 ++- src/components/fb/FidelityBondSteps.tsx | 14 +- src/components/fb/LockdateForm.tsx | 9 +- src/components/fb/SpendFidelityBondModal.tsx | 527 +++++++++++++++---- src/hooks/WaitForUtxosToBeSpent.ts | 64 +++ src/i18n/locales/en/translation.json | 15 +- src/index.css | 4 + 10 files changed, 644 insertions(+), 205 deletions(-) create mode 100644 src/hooks/WaitForUtxosToBeSpent.ts diff --git a/src/components/Earn.tsx b/src/components/Earn.tsx index 9b5da7bc7..64b10007b 100644 --- a/src/components/Earn.tsx +++ b/src/components/Earn.tsx @@ -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' @@ -424,6 +424,7 @@ export default function Earn({ wallet }: EarnProps) { }, [currentWalletInfo]) const [moveToJarFidelityBondId, setMoveToJarFidelityBondId] = useState() + const [renewFidelityBondId, setRenewFidelityBondId] = useState() const startMakerService = useCallback( (values: EarnFormValues) => { @@ -619,6 +620,20 @@ export default function Earn({ wallet }: EarnProps) { }} /> )} + {currentWalletInfo && renewFidelityBondId && ( + { + setRenewFidelityBondId(undefined) + if (mustReload) { + reloadFidelityBonds({ delay: 0 }) + } + }} + /> + )} {fidelityBonds.map((fidelityBond, index) => { const isExpired = !fb.utxo.isLocked(fidelityBond) const actionsEnabled = @@ -633,18 +648,25 @@ export default function Earn({ wallet }: EarnProps) { return ( {actionsEnabled && ( -
-
- setMoveToJarFidelityBondId(fidelityBond.utxo)} - > - - {t('earn.fidelity_bond.existing.button_spend')} - -
+
+ setMoveToJarFidelityBondId(fidelityBond.utxo)} + > + + {t('earn.fidelity_bond.existing.button_spend')} + + setRenewFidelityBondId(fidelityBond.utxo)} + > + + {t('earn.fidelity_bond.existing.button_renew')} +
)} diff --git a/src/components/PaymentConfirmModal.tsx b/src/components/PaymentConfirmModal.tsx index 6554ce4aa..68c466726 100644 --- a/src/components/PaymentConfirmModal.tsx +++ b/src/components/PaymentConfirmModal.tsx @@ -1,4 +1,4 @@ -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' @@ -6,10 +6,10 @@ 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') { @@ -57,7 +57,7 @@ const useMiningFeeText = ({ tx_fees, tx_fees_factor }: Pick) { const { t } = useTranslation() const settings = useSettings() @@ -206,6 +207,11 @@ export function PaymentConfirmModal({ )} + {children && ( + + {children} + + )} ) diff --git a/src/components/Send/index.tsx b/src/components/Send/index.tsx index 553b14b0b..3fa33fd5d 100644 --- a/src/components/Send/index.tsx +++ b/src/components/Send/index.tsx @@ -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 @@ -105,7 +107,7 @@ export default function Send({ wallet }: SendProps) { [feeConfigValues], ) - const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([]) + const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([]) const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState() const isOperationDisabled = useMemo( @@ -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) { diff --git a/src/components/fb/CreateFidelityBond.tsx b/src/components/fb/CreateFidelityBond.tsx index bd21685c7..0a299a88c 100644 --- a/src/components/fb/CreateFidelityBond.tsx +++ b/src/components/fb/CreateFidelityBond.tsx @@ -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, @@ -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 ( + + {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, @@ -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) @@ -53,17 +77,19 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon const [lockDate, setLockDate] = useState(null) const [selectedJar, setSelectedJar] = useState() const [selectedUtxos, setSelectedUtxos] = useState([]) - const [timelockedAddress, setTimelockedAddress] = useState(null) + const [timelockedAddress, setTimelockedAddress] = useState() const [utxoIdsToBeSpent, setUtxoIdsToBeSpent] = useState([]) const [createdFidelityBondUtxo, setCreatedFidelityBondUtxo] = useState() const [frozenUtxos, setFrozenUtxos] = useState([]) - 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')) { @@ -79,7 +105,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon setSelectedJar(undefined) setSelectedUtxos([]) setLockDate(null) - setTimelockedAddress(null) + setTimelockedAddress(undefined) setAlert(undefined) setCreatedFidelityBondUtxo(undefined) setFrozenUtxos([]) @@ -254,8 +280,8 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon return ( setLockDate(date)} + yearsRange={yearsRange} + onChange={(date) => setLockDate(date)} /> ) case steps.selectJar: @@ -307,7 +333,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon ) } - if (timelockedAddress === null) { + if (!timelockedAddress) { return
{t('earn.fidelity_bond.error_loading_address')}
} @@ -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') @@ -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 } @@ -568,26 +594,32 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon return (
{alert && setAlert(undefined)} />} - {lockDate && ( - 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, - }), - })} - + + )} +
setIsExpanded(!isExpanded)}>
diff --git a/src/components/fb/FidelityBondSteps.tsx b/src/components/fb/FidelityBondSteps.tsx index 1dfd3174d..de41423b6 100644 --- a/src/components/fb/FidelityBondSteps.tsx +++ b/src/components/fb/FidelityBondSteps.tsx @@ -10,17 +10,15 @@ import { SelectableJar, jarInitial, jarFillLevel } from '../jars/Jar' import Sprite from '../Sprite' import Balance from '../Balance' import { CopyButton } from '../CopyButton' -import LockdateForm from './LockdateForm' +import LockdateForm, { LockdateFormProps } from './LockdateForm' import * as fb from './utils' import styles from './FidelityBondSteps.module.css' const cx = classnamesBind.bind(styles) -interface SelectDateProps { +type SelectDateProps = { description: string - selectableYearsRange: fb.YearsRange - onDateSelected: (lockdate: Api.Lockdate | null) => void -} +} & LockdateFormProps interface SelectJarProps { description: string @@ -62,7 +60,7 @@ interface ReviewInputsProps { jar: JarIndex utxos: Array selectedUtxos: Array - timelockedAddress: string + timelockedAddress: Api.BitcoinAddress } interface CreatedFidelityBondProps { @@ -70,11 +68,11 @@ interface CreatedFidelityBondProps { frozenUtxos: Array } -const SelectDate = ({ description, selectableYearsRange, onDateSelected }: SelectDateProps) => { +const SelectDate = ({ description, yearsRange, disabled, onChange }: SelectDateProps) => { return (
{description}
- onDateSelected(date)} yearsRange={selectableYearsRange} /> +
) } diff --git a/src/components/fb/LockdateForm.tsx b/src/components/fb/LockdateForm.tsx index 5f6005dfd..748d2f27f 100644 --- a/src/components/fb/LockdateForm.tsx +++ b/src/components/fb/LockdateForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import * as rb from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' @@ -58,13 +58,14 @@ export const _selectableYears = (yearsRange: fb.YearsRange, now = new Date()): n .map((_, index) => index + now.getUTCFullYear() + extra) } -interface LockdateFormProps { +export interface LockdateFormProps { onChange: (lockdate: Api.Lockdate | null) => void yearsRange?: fb.YearsRange now?: Date + disabled?: boolean } -const LockdateForm = ({ onChange, now, yearsRange }: LockdateFormProps) => { +const LockdateForm = ({ onChange, now, yearsRange, disabled }: LockdateFormProps) => { const { i18n } = useTranslation() const _now = useMemo(() => now || new Date(), [now]) const _yearsRange = useMemo(() => yearsRange || fb.DEFAULT_TIMELOCK_YEARS_RANGE, [yearsRange]) @@ -115,6 +116,7 @@ const LockdateForm = ({ onChange, now, yearsRange }: LockdateFormProps) => { onChange={(e) => setLockdateYear(parseInt(e.target.value, 10))} required isInvalid={!isLockdateYearValid} + disabled={disabled} data-testid="select-lockdate-year" > {selectableYears.map((year) => ( @@ -135,6 +137,7 @@ const LockdateForm = ({ onChange, now, yearsRange }: LockdateFormProps) => { onChange={(e) => setLockdateMonth(parseInt(e.target.value, 10) as Month)} required isInvalid={!isLockdateMonthValid} + disabled={disabled} data-testid="select-lockdate-month" > {selectableMonths.map((it) => ( diff --git a/src/components/fb/SpendFidelityBondModal.tsx b/src/components/fb/SpendFidelityBondModal.tsx index 741cebc8e..d9f92681e 100644 --- a/src/components/fb/SpendFidelityBondModal.tsx +++ b/src/components/fb/SpendFidelityBondModal.tsx @@ -1,17 +1,20 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, 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 { CurrentWallet, 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 { SelectDate, SelectJar } from './FidelityBondSteps' import { PaymentConfirmModal } from '../PaymentConfirmModal' import { jarInitial } from '../jars/Jar' import { useFeeConfigValues } from '../../hooks/Fees' - +import { isDebugFeatureEnabled } from '../../constants/debugFeatures' +import { CopyButton } from '../CopyButton' +import { LockInfoAlert } from './CreateFidelityBond' +import { useWaitForUtxosToBeSpent } from '../../hooks/WaitForUtxosToBeSpent' import styles from './SpendFidelityBondModal.module.css' type Input = { @@ -64,10 +67,15 @@ const spendUtxosWithDirectSend = async ( request: UtxoDirectSendRequest, hooks: UtxoDirectSendHook, ) => { + if (request.utxos.length === 0) { + // this is a programming error (no translation needed) + throw new Error('Precondition failed: No UTXO(s) provided.') + } + const utxosFromSameJar = request.utxos.every((it) => it.mixdepth === request.sourceJarIndex) - if (!utxosFromSameJar || request.utxos.length === 0) { + if (!utxosFromSameJar) { // this is a programming error (no translation needed) - throw new Error('Precondition failed: UTXOs must be from the same jar') + throw new Error('Precondition failed: UTXOs must be from the same jar.') } const spendableUtxoIds = request.utxos.map((it) => it.utxo) @@ -81,6 +89,10 @@ const spendUtxosWithDirectSend = async ( const utxosToSpend = utxosFromSourceJar.filter((it) => spendableUtxoIds.includes(it.utxo)) + if (spendableUtxoIds.length !== utxosToSpend.length) { + throw new Error('Precondition failed: Specified UTXO(s) cannot be used for this payment.') + } + const utxosToFreeze = utxosFromSourceJar .filter((it) => !it.frozen) .filter((it) => !spendableUtxoIds.includes(it.utxo)) @@ -138,6 +150,392 @@ const spendUtxosWithDirectSend = async ( } } +type SendFidelityBondToAddressProps = { + fidelityBond: Utxo | undefined + destination: Api.BitcoinAddress + wallet: CurrentWallet + t: TFunction +} + +const sendFidelityBondToAddress = async ({ fidelityBond, destination, wallet, t }: SendFidelityBondToAddressProps) => { + if (!fidelityBond || fb.utxo.isLocked(fidelityBond)) { + throw new Error(t('earn.fidelity_bond.move.error_fidelity_bond_still_locked')) + } + + const abortCtrl = new AbortController() + const requestContext = { ...wallet, signal: abortCtrl.signal } + + 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')), + }, + ) +} + +type SendFidelityBondToJarProps = { + fidelityBond: Utxo | undefined + targetJarIndex: JarIndex + wallet: CurrentWallet + t: TFunction +} + +const sendFidelityBondToJar = async ({ fidelityBond, targetJarIndex, wallet, t }: SendFidelityBondToJarProps) => { + if (!fidelityBond || fb.utxo.isLocked(fidelityBond)) { + throw new Error(t('earn.fidelity_bond.move.error_fidelity_bond_still_locked')) + } + + const abortCtrl = new AbortController() + const requestContext = { ...wallet, 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 sendFidelityBondToAddress({ destination, fidelityBond, wallet, t }) +} + +const Done = ({ text }: { text: string }) => { + return ( +
+
+ +
+
{text}
+
+ ) +} + +type RenewFidelityBondModalProps = { + fidelityBondId: Api.UtxoId + wallet: CurrentWallet + walletInfo: WalletInfo + onClose: (result: Result) => void +} & Omit + +const RenewFidelityBondModal = ({ + fidelityBondId, + wallet, + walletInfo, + onClose, + ...modalProps +}: RenewFidelityBondModalProps) => { + const { t } = useTranslation() + const feeConfigValues = useFeeConfigValues()[0] + + const [alert, setAlert] = useState() + + const [txInfo, setTxInfo] = useState() + const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([]) + + const [lockDate, setLockDate] = useState() + const [timelockedAddress, setTimelockedAddress] = useState() + const [isLoadingTimelockedAddress, setIsLoadingTimelockAddress] = useState(false) + const [timelockedAddressAlert, setTimelockedAddressAlert] = useState() + + const [parentMustReload, setParentMustReload] = useState(false) + const [isSending, setIsSending] = useState(false) + + const isLoading = useMemo(() => isSending || waitForUtxosToBeSpent.length > 0, [isSending, waitForUtxosToBeSpent]) + + const [showConfirmSendModal, setShowConfirmSendModal] = useState(false) + + const submitButtonRef = useRef(null) + + const fidelityBond = useMemo(() => { + return walletInfo.data.utxos.utxos.find((utxo) => utxo.utxo === fidelityBondId) + }, [walletInfo, fidelityBondId]) + + 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) + + const yearsRange = useMemo(() => { + if (isDebugFeatureEnabled('allowCreatingExpiredFidelityBond')) { + return fb.toYearsRange(-1, fb.DEFAULT_MAX_TIMELOCK_YEARS) + } + return fb.toYearsRange(0, fb.DEFAULT_MAX_TIMELOCK_YEARS) + }, []) + + const loadTimeLockedAddress = useCallback( + (lockdate: Api.Lockdate, signal: AbortSignal) => { + return Api.getAddressTimelockNew({ + ...wallet, + lockdate, + signal, + }).then((res) => { + return res.ok ? res.json() : Api.Helper.throwError(res, t('earn.fidelity_bond.error_loading_address')) + }) + }, + [wallet, t], + ) + + useEffect( + function loadTimelockedAddressOnLockDateChange() { + if (!lockDate) return + const abortCtrl = new AbortController() + + setIsLoadingTimelockAddress(true) + setTimelockedAddressAlert(undefined) + + const timer = setTimeout( + () => + loadTimeLockedAddress(lockDate, abortCtrl.signal) + .then((data: any) => { + if (abortCtrl.signal.aborted) return + setTimelockedAddress(data.address) + setIsLoadingTimelockAddress(false) + }) + .catch((err) => { + if (abortCtrl.signal.aborted) return + setIsLoadingTimelockAddress(false) + setTimelockedAddress(undefined) + setTimelockedAddressAlert({ variant: 'danger', message: err.message }) + }), + 250, + ) + + return () => { + clearTimeout(timer) + abortCtrl.abort() + } + }, + [loadTimeLockedAddress, lockDate], + ) + + const primaryButtonContent = useMemo(() => { + if (isSending) { + return ( + <> +
@@ -421,4 +748,4 @@ const SpendFidelityBondModal = ({ ) } -export { SpendFidelityBondModal } +export { RenewFidelityBondModal, SpendFidelityBondModal } diff --git a/src/hooks/WaitForUtxosToBeSpent.ts b/src/hooks/WaitForUtxosToBeSpent.ts new file mode 100644 index 000000000..b54da5cd2 --- /dev/null +++ b/src/hooks/WaitForUtxosToBeSpent.ts @@ -0,0 +1,64 @@ +import { useEffect } from 'react' +import { useReloadCurrentWalletInfo } from '../context/WalletContext' +import { UtxoId } from '../libs/JmWalletApi' + +// Delaying the poll requests gives the wallet some time to synchronize +// the utxo set and reduces amount of http requests +const DEFAUL_DELAY: Milliseconds = 1_000 + +interface WaitForUtxosToBeSpentArgs { + waitForUtxosToBeSpent: UtxoId[] + setWaitForUtxosToBeSpent: (utxos: UtxoId[]) => void + onError: (error: any) => void + delay?: Milliseconds + resetOnErrors?: boolean +} + +// This callback is responsible for updating the utxo array when a +// payment is made. The wallet needs some time after a tx is sent +// to reflect the changes internally. All outputs given in +// `waitForUtxosToBeSpent` must have been removed from the wallet +// for a payment to be considered done. +export const useWaitForUtxosToBeSpent = ({ + waitForUtxosToBeSpent, + setWaitForUtxosToBeSpent, + onError, + delay = DEFAUL_DELAY, + resetOnErrors = true, +}: WaitForUtxosToBeSpentArgs): void => { + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + return useEffect(() => { + if (waitForUtxosToBeSpent.length === 0) return + + const abortCtrl = new AbortController() + + 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)) + + // updating the utxos array will trigger a recursive call + setWaitForUtxosToBeSpent([...utxosStillPresent]) + }) + .catch((error: any) => { + if (abortCtrl.signal.aborted) return + if (resetOnErrors) { + // Stop waiting for wallet synchronization on errors + setWaitForUtxosToBeSpent([]) + } + onError(error) + }) + }, delay) + + return () => { + abortCtrl.abort() + clearTimeout(timer) + } + }, [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent, resetOnErrors, onError, delay, reloadCurrentWalletInfo]) +} diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 05b0a2280..4965e2c48 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -8,6 +8,7 @@ "close": "Close", "abort": "Abort", "cancel": "Cancel", + "done": "Done", "table": { "pagination": { "items_per_page": { @@ -515,7 +516,19 @@ "label_locked_until": "Locked until", "label_expired_on": "Expired on", "label_address": "Timelocked address", - "button_spend": "Unlock Funds" + "button_spend": "Unlock Funds", + "button_renew": "Renew Bond" + }, + "renew": { + "title": "Renew Bond", + "text_loading": "Loading...", + "text_sending": "Renewing...", + "text_button_submit": "Renew Bond", + "success_text": "Fidelity Bond renewed successfully!", + "error_renewing_fidelity_bond": "Error while renewing expired fidelity bond.", + "confirm_send_modal": { + "title": "Confirm renewing expired Fidelity Bond" + } }, "move": { "title": "Unlock Funds", diff --git a/src/index.css b/src/index.css index c7c165250..748ffb2a3 100644 --- a/src/index.css +++ b/src/index.css @@ -532,6 +532,10 @@ h2 { color: inherit; } +.modal { + --bs-modal-width: 800px; +} + .modal-header { background-color: var(--bs-gray-800); color: var(--bs-white);