diff --git a/src/components/Earn.tsx b/src/components/Earn.tsx index ddc32c8c..57386b99 100644 --- a/src/components/Earn.tsx +++ b/src/components/Earn.tsx @@ -13,7 +13,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' @@ -632,6 +632,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 = diff --git a/src/components/fb/CreateFidelityBond.tsx b/src/components/fb/CreateFidelityBond.tsx index bd21685c..c83c55ca 100644 --- a/src/components/fb/CreateFidelityBond.tsx +++ b/src/components/fb/CreateFidelityBond.tsx @@ -254,8 +254,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: diff --git a/src/components/fb/FidelityBondSteps.tsx b/src/components/fb/FidelityBondSteps.tsx index 1dfd3174..bc6f3fcc 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 @@ -70,11 +68,11 @@ interface CreatedFidelityBondProps { frozenUtxos: Array<Utxo> } -const SelectDate = ({ description, selectableYearsRange, onDateSelected }: SelectDateProps) => { +const SelectDate = ({ description, yearsRange, disabled, onChange }: SelectDateProps) => { return ( <div className="d-flex flex-column gap-4"> <div className={styles.stepDescription}>{description}</div> - <LockdateForm onChange={(date) => onDateSelected(date)} yearsRange={selectableYearsRange} /> + <LockdateForm yearsRange={yearsRange} onChange={onChange} disabled={disabled} /> </div> ) } diff --git a/src/components/fb/LockdateForm.tsx b/src/components/fb/LockdateForm.tsx index 5f6005df..748d2f27 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<Date>(() => now || new Date(), [now]) const _yearsRange = useMemo<fb.YearsRange>(() => 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 d4228e23..f9efb8a1 100644 --- a/src/components/fb/SpendFidelityBondModal.tsx +++ b/src/components/fb/SpendFidelityBondModal.tsx @@ -1,4 +1,4 @@ -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' @@ -7,12 +7,14 @@ 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 styles from './SpendFidelityBondModal.module.css' +import { isDebugFeatureEnabled } from '../../constants/debugFeatures' +import { CopyButton } from '../CopyButton' type Input = { outpoint: Api.UtxoId @@ -138,6 +140,394 @@ 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 ( + <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> + ) +} + +type RenewFidelityBondModalProps = { + fidelityBondId: Api.UtxoId + wallet: CurrentWallet + walletInfo: WalletInfo + onClose: (result: Result) => void +} & Omit<rb.ModalProps, 'onHide'> + +const RenewFidelityBondModal = ({ + fidelityBondId, + wallet, + walletInfo, + onClose, + ...modalProps +}: RenewFidelityBondModalProps) => { + const { t } = useTranslation() + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + const feeConfigValues = useFeeConfigValues()[0] + + const [alert, setAlert] = useState<SimpleAlert>() + + const [txInfo, setTxInfo] = useState<TxInfo>() + const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState<Api.UtxoId[]>([]) + + const [lockDate, setLockDate] = useState<Api.Lockdate>() + const [timelockedAddress, setTimelockedAddress] = useState<Api.BitcoinAddress>() + + const [parentMustReload, setParentMustReload] = useState(false) + const [isSending, setIsSending] = useState(false) + const [isLoadingTimelockedAddress, setIsLoadingTimelockAddress] = useState(false) + const isLoading = useMemo(() => isSending || waitForUtxosToBeSpent.length > 0, [isSending, waitForUtxosToBeSpent]) + + const [showConfirmSendModal, setShowConfirmSendModal] = useState(false) + + const submitButtonRef = useRef<HTMLButtonElement>(null) + + const fidelityBond = useMemo(() => { + return walletInfo.data.utxos.utxos.find((utxo) => utxo.utxo === fidelityBondId) + }, [walletInfo, fidelityBondId]) + + // 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 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) => { + setIsLoadingTimelockAddress(true) + setAlert(undefined) + + return ( + Api.getAddressTimelockNew({ + ...wallet, + lockdate, + signal, + }) + .then((res) => { + return res.ok ? res.json() : Api.Helper.throwError(res, t('earn.fidelity_bond.error_loading_address')) + }) + // show the loader a little longer to avoid flickering + .then((result) => new Promise((r) => setTimeout(() => r(result), 221))) + .then((data: any) => { + if (signal.aborted) return + setTimelockedAddress(data.address) + setIsLoadingTimelockAddress(false) + }) + .catch((err) => { + if (signal.aborted) return + setAlert({ variant: 'danger', message: err.message }) + setIsLoadingTimelockAddress(false) + }) + ) + }, + [wallet, t], + ) + + useEffect(() => { + if (!lockDate) return + const abortCtrl = new AbortController() + loadTimeLockedAddress(lockDate, abortCtrl.signal) + return () => abortCtrl.abort() + }, [loadTimeLockedAddress, lockDate]) + + const primaryButtonContent = useMemo(() => { + if (isSending) { + return ( + <> + <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" /> + {t('earn.fidelity_bond.renew.text_sending')} + </> + ) + } else if (txInfo) { + return <>{t('global.done')}</> + } + return <>{t('earn.fidelity_bond.renew.text_button_submit')}</> + }, [isSending, txInfo, t]) + + const onSelectedDateChanged = useCallback((date) => { + setTimelockedAddress(undefined) + setLockDate(date ?? undefined) + }, []) + + const modalBodyContent = useMemo(() => { + 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.renew.${isSending ? 'text_sending' : 'text_loading'}`)}</div> + </div> + ) + } + + if (txInfo) { + return ( + <div className="my-4"> + <Done text={t('earn.fidelity_bond.renew.success_text')} /> + </div> + ) + } + + return ( + <div className="my-2 d-flex flex-column gap-4"> + <SelectDate + description={t('earn.fidelity_bond.select_date.description')} + yearsRange={yearsRange} + disabled={isLoading || isLoadingTimelockedAddress} + onChange={onSelectedDateChanged} + /> + {timelockedAddress && ( + <> + <div className="d-flex flex-column gap-3"> + <div className="d-flex align-items-center gap-2"> + <CopyButton + text={<Sprite symbol="copy" width="18" height="18" />} + successText={<Sprite symbol="checkmark" width="18" height="18" />} + value={timelockedAddress} + /> + <div className="d-flex flex-column"> + <div>{t('earn.fidelity_bond.review_inputs.label_address')}</div> + <div> + <code>{timelockedAddress}</code> + </div> + </div> + </div> + </div> + </> + )} + </div> + ) + }, [ + isLoading, + isSending, + yearsRange, + timelockedAddress, + isLoadingTimelockedAddress, + txInfo, + onSelectedDateChanged, + t, + ]) + + const onPrimaryButtonClicked = async () => { + if (isLoading) return + if (isLoadingTimelockedAddress) return + if (timelockedAddress === undefined) return + if (waitForUtxosToBeSpent.length > 0) return + + if (txInfo) { + onClose({ txInfo, mustReload: parentMustReload }) + } else if (!showConfirmSendModal) { + setShowConfirmSendModal(true) + } else { + if (!fidelityBond || fb.utxo.isLocked(fidelityBond)) { + throw new Error(t('earn.fidelity_bond.move.error_fidelity_bond_still_locked')) + } + setShowConfirmSendModal(false) + setParentMustReload(true) + + setAlert(undefined) + setIsSending(true) + + return sendFidelityBondToAddress({ + destination: timelockedAddress, + fidelityBond, + wallet, + t, + }) + .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 }) + }) + } + } + + 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.renew.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={isLoading} + onClick={() => onClose({ txInfo, mustReload: parentMustReload })} + className="flex-1 d-flex justify-content-center align-items-center" + > + {t('global.cancel')} + </rb.Button> + <rb.Button + ref={submitButtonRef} + variant="dark" + className="flex-1 d-flex justify-content-center align-items-center" + disabled={isLoading || timelockedAddress === undefined} + onClick={onPrimaryButtonClicked} + > + {primaryButtonContent} + </rb.Button> + </div> + </rb.Modal.Footer> + </rb.Modal> + {showConfirmSendModal && fidelityBond && timelockedAddress !== undefined && ( + <PaymentConfirmModal + isShown={true} + title={t(`earn.fidelity_bond.renew.${timelockedAddress ? 'confirm_send_modal.title' : 'title'}`)} + onCancel={() => { + setShowConfirmSendModal(false) + onClose({ txInfo, mustReload: parentMustReload }) + }} + onConfirm={() => { + submitButtonRef.current?.click() + }} + data={{ + sourceJarIndex: undefined, // dont show a source jar - might be confusing in this context + destination: timelockedAddress, + amount: fidelityBond.value, + isSweep: true, + isCoinjoin: false, // not sent as collaborative transaction + numCollaborators: undefined, + feeConfigValues, + showPrivacyInfo: false, + }} + /> + )} + </> + ) +} + type SpendFidelityBondModalProps = { fidelityBondId: Api.UtxoId wallet: CurrentWallet @@ -240,7 +630,12 @@ const SpendFidelityBondModal = ({ setAlert(undefined) setIsSending(true) - sendFidelityBondToJar(fidelityBond, selectedDestinationJarIndex) + sendFidelityBondToJar({ + fidelityBond, + targetJarIndex: selectedDestinationJarIndex, + wallet, + t, + }) .then((data) => data.txinfo as TxInfo) .then((txinfo) => { setTxInfo(txinfo) @@ -258,42 +653,7 @@ const SpendFidelityBondModal = ({ } } - 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 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 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 = () => { + const primaryButtonContent = useMemo(() => { if (isSending) { return ( <> @@ -305,18 +665,7 @@ const SpendFidelityBondModal = ({ 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> - ) - } + }, [isSending, txInfo, t]) const ModalBodyContent = () => { if (isLoading) { @@ -384,7 +733,7 @@ const SpendFidelityBondModal = ({ disabled={isLoading || selectedDestinationJarIndex === undefined} onClick={onPrimaryButtonClicked} > - {PrimaryButtonContent()} + {primaryButtonContent} </rb.Button> </div> </rb.Modal.Footer> @@ -420,4 +769,4 @@ const SpendFidelityBondModal = ({ ) } -export { SpendFidelityBondModal } +export { RenewFidelityBondModal, SpendFidelityBondModal } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 700b1351..86bcb109 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": { @@ -504,6 +505,17 @@ "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", "success_text": "Fidelity Bond unlocked successfully!",