From e13eb1f17b861de0a88e1b393fd13b0fcde9843c Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 14 Oct 2023 23:03:34 +0200 Subject: [PATCH] wip(renew): ability to renew specific fidelity bond --- src/components/Earn.tsx | 16 +- src/components/fb/CreateFidelityBond.tsx | 4 +- src/components/fb/FidelityBondSteps.tsx | 12 +- src/components/fb/LockdateForm.tsx | 9 +- src/components/fb/SpendFidelityBondModal.tsx | 455 ++++++++++++++++--- src/i18n/locales/en/translation.json | 12 + 6 files changed, 442 insertions(+), 66 deletions(-) diff --git a/src/components/Earn.tsx b/src/components/Earn.tsx index ddc32c8ca..57386b991 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 && ( + { + 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 bd21685c7..c83c55cad 100644 --- a/src/components/fb/CreateFidelityBond.tsx +++ b/src/components/fb/CreateFidelityBond.tsx @@ -254,8 +254,8 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon return ( 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 1dfd3174d..bc6f3fcce 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 } -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 d4228e233..f9efb8a17 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 ( +
+
+ +
+
{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 reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + 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 [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(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 ( + <> +