From 4e28489aeac44e6a14a0f441b6f1d09c241a29cb Mon Sep 17 00:00:00 2001 From: barrytra Date: Tue, 20 Aug 2024 21:15:20 +0530 Subject: [PATCH 01/16] ui(FB): epic/redesign --- src/components/Earn.tsx | 21 +- .../fb2/CreateFidelityBond.module.css | 113 +++ src/components/fb2/CreateFidelityBond.tsx | 534 +++++++++++++ .../fb2/ExistingFidelityBond.module.css | 53 ++ src/components/fb2/ExistingFidelityBond.tsx | 99 +++ .../fb2/FidelityBondSteps.module.css | 275 +++++++ src/components/fb2/FidelityBondSteps.tsx | 249 ++++++ src/components/fb2/LockdateForm.test.tsx | 214 +++++ src/components/fb2/LockdateForm.tsx | 155 ++++ .../fb2/SpendFidelityBondModal.module.css | 15 + src/components/fb2/SpendFidelityBondModal.tsx | 751 ++++++++++++++++++ src/components/fb2/utils.test.ts | 319 ++++++++ src/components/fb2/utils.ts | 164 ++++ src/i18n/locales/en/translation.json | 14 +- 14 files changed, 2965 insertions(+), 11 deletions(-) create mode 100644 src/components/fb2/CreateFidelityBond.module.css create mode 100644 src/components/fb2/CreateFidelityBond.tsx create mode 100644 src/components/fb2/ExistingFidelityBond.module.css create mode 100644 src/components/fb2/ExistingFidelityBond.tsx create mode 100644 src/components/fb2/FidelityBondSteps.module.css create mode 100644 src/components/fb2/FidelityBondSteps.tsx create mode 100644 src/components/fb2/LockdateForm.test.tsx create mode 100644 src/components/fb2/LockdateForm.tsx create mode 100644 src/components/fb2/SpendFidelityBondModal.module.css create mode 100644 src/components/fb2/SpendFidelityBondModal.tsx create mode 100644 src/components/fb2/utils.test.ts create mode 100644 src/components/fb2/utils.ts diff --git a/src/components/Earn.tsx b/src/components/Earn.tsx index 1c0dab1fb..d2facaa78 100644 --- a/src/components/Earn.tsx +++ b/src/components/Earn.tsx @@ -27,6 +27,7 @@ import Sprite from './Sprite' import PageTitle from './PageTitle' import SegmentedTabs from './SegmentedTabs' import { CreateFidelityBond } from './fb/CreateFidelityBond' +import { CreateFidelityBond2 } from './fb2/CreateFidelityBond' import { ExistingFidelityBond } from './fb/ExistingFidelityBond' import { RenewFidelityBondModal, SpendFidelityBondModal } from './fb/SpendFidelityBondModal' import { EarnReportOverlay } from './EarnReport' @@ -714,12 +715,20 @@ export default function Earn({ wallet }: EarnProps) { !isWaitingMakerStart && !isWaitingMakerStop && (!isLoading && currentWalletInfo ? ( - 0} - wallet={wallet} - walletInfo={currentWalletInfo} - onDone={() => reloadFidelityBonds({ delay: RELOAD_FIDELITY_BONDS_DELAY_MS })} - /> + <> + 0} + wallet={wallet} + walletInfo={currentWalletInfo} + onDone={() => reloadFidelityBonds({ delay: RELOAD_FIDELITY_BONDS_DELAY_MS })} + /> + 0} + wallet={wallet} + walletInfo={currentWalletInfo} + onDone={() => reloadFidelityBonds({ delay: RELOAD_FIDELITY_BONDS_DELAY_MS })} + /> + ) : ( diff --git a/src/components/fb2/CreateFidelityBond.module.css b/src/components/fb2/CreateFidelityBond.module.css new file mode 100644 index 000000000..f16d589fd --- /dev/null +++ b/src/components/fb2/CreateFidelityBond.module.css @@ -0,0 +1,113 @@ +.container { + border: 1px solid var(--bs-gray-200); + border-radius: 0.3rem; + padding: 1.25rem; +} + +:root[data-theme='dark'] .container { + border-color: var(--bs-gray-700); +} + +.header { + display: flex; + flex-direction: column; + gap: 0.5rem; + cursor: pointer; +} + +.header .subtitleJar { + flex-shrink: 0; +} + +.header .subtitle { + flex-shrink: 1; + font-size: 0.9rem; + color: var(--bs-gray-600); +} + +.formMessageWhenBondAlreadyExists { + flex-shrink: 1; + font-size: 0.8rem; + color: var(--bs-gray-600); +} + +.formMessageWhenBondAlreadyExists a { + color: inherit; +} + +.header .title { + width: 100%; + font-size: 1.2rem; + color: var(--bs-body-color); +} + +.header svg { + color: var(--bs-body-color); +} + +.header :global .accordion-button:after { + display: none; +} + +.successCheckmark { + width: '2rem'; + height: '2rem'; + background-color: 'rgba(39, 174, 96, 1)'; + color: 'white'; + border-radius: '50%'; +} + +.tabs { + display: flex; + width: 100%; + flex-direction: column; +} +.tab { + cursor: pointer; + padding: 10px 20px; + color: var(--bs-white); + background-color: var(--bs-gray-800); + margin-bottom: 5px; + display: flex; + justify-content: space-between; + align-items: center; +} + +:root[data-theme='dark'] .tab { + color: var(--bs-body-color); + border-color: var(--bs-gray-700); +} + +.circle { + display: flex; + justify-content: center; + align-items: center; + padding: 0.8rem 0.8rem; + border-radius: 100%; + background-color: var(--bs-gray-100); +} + +.step { + position: absolute; + color: var(--bs-gray-900); +} + +.addressLabel { + color: var(--bs-gray-600); + font-size: 1rem; +} + +.addressContent { + font-size: 1rem; + word-break: break-all; +} + +.timelockedAddress { + text-align: left; + padding: 0; + font-size: 0.8rem; +} + +.subTitle { + font-size: 0.8rem; +} diff --git a/src/components/fb2/CreateFidelityBond.tsx b/src/components/fb2/CreateFidelityBond.tsx new file mode 100644 index 000000000..480bf51fc --- /dev/null +++ b/src/components/fb2/CreateFidelityBond.tsx @@ -0,0 +1,534 @@ +import { useState, useEffect, useMemo, useCallback } from 'react' +import * as rb from 'react-bootstrap' +import * as Api from '../../libs/JmWalletApi' +import { Trans, useTranslation } from 'react-i18next' +import { + CurrentWallet, + Utxos, + WalletInfo, + useCurrentWalletInfo, + useReloadCurrentWalletInfo, +} from '../../context/WalletContext' +import Alert from '../Alert' +import Sprite from '../Sprite' +import { SelectJar, SelectUtxos, SelectDate, Confirmation } from './FidelityBondSteps' +import * as fb from './utils' +import { isDebugFeatureEnabled } from '../../constants/debugFeatures' +import styles from './CreateFidelityBond.module.css' +import { jarName } from '../jars/Jar' +import { spendUtxosWithDirectSend, errorResolver } from './SpendFidelityBondModal' + +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, + selectUtxos: 2, + confirmation: 3, + done: 4, + failed: 5, +} + +interface CreateFidelityBondProps { + otherFidelityBondExists: boolean + wallet: CurrentWallet + walletInfo: WalletInfo + onDone: () => void +} + +const CreateFidelityBond2 = ({ otherFidelityBondExists, wallet, walletInfo, onDone }: CreateFidelityBondProps) => { + const { t } = useTranslation() + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + const [showCreateFidelityBondModal, setShowCreateFidelityBondModal] = useState(false) + const [creatingFidelityBond, setCreatingFidelityBond] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [alert, setAlert] = useState() + const [step, setStep] = useState(steps.selectDate) + const [lockDate, setLockDate] = useState(null) + const [selectedJar, setSelectedJar] = useState() + const [selectedUtxos, setSelectedUtxos] = useState([]) + const [timelockedAddress, setTimelockedAddress] = useState() + + // Check if all utxos are selected + 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')) { + return fb.toYearsRange(-1, fb.DEFAULT_MAX_TIMELOCK_YEARS) + } + return fb.toYearsRange(0, fb.DEFAULT_MAX_TIMELOCK_YEARS) + }, []) + + const reset = () => { + setIsLoading(false) + setShowCreateFidelityBondModal(false) + setStep(steps.selectDate) + setSelectedJar(undefined) + setSelectedUtxos([]) + setLockDate(null) + setTimelockedAddress(undefined) + setAlert(undefined) + } + + // fidelity Bonds data + const currentWalletInfo = useCurrentWalletInfo() + const fidelityBonds = useMemo(() => { + return currentWalletInfo?.fidelityBondSummary.fbOutputs || [] + }, [currentWalletInfo]) + + // Check if bond with selected lockDate already exists + const bondWithSelectedLockDateAlreadyExists = useMemo(() => { + return lockDate && fidelityBonds.some((it) => fb.utxo.getLocktime(it) === fb.lockdate.toTimestamp(lockDate)) + }, [fidelityBonds, lockDate]) + + const onlyCjOutOrFbUtxosSelected = () => { + return selectedUtxos.every( + (utxo) => walletInfo.addressSummary[utxo.address]?.status === 'cj-out' || utxo.locktime !== undefined, + ) + } + + useEffect(() => { + if (!showCreateFidelityBondModal) { + reset() + } else { + setIsLoading(true) + const abortCtrl = new AbortController() + reloadCurrentWalletInfo + .reloadAll({ signal: abortCtrl.signal }) + .catch(() => { + if (abortCtrl.signal.aborted) return + setAlert({ variant: 'danger', message: t('earn.fidelity_bond.error_reloading_wallet') }) + }) + .finally(() => { + if (abortCtrl.signal.aborted) return + setIsLoading(false) + }) + return () => abortCtrl.abort() + } + }, [showCreateFidelityBondModal, reloadCurrentWalletInfo, t]) + + const loadTimeLockedAddress = useCallback( + (lockDate: Api.Lockdate) => { + const abortCtrl = new AbortController() + + Api.getAddressTimelockNew({ + ...wallet, + signal: abortCtrl.signal, + lockdate: lockDate, + }) + .then((res) => { + return res.ok ? res.json() : Api.Helper.throwError(res, t('earn.fidelity_bond.error_loading_address')) + }) + .then((data) => setTimelockedAddress(data.address)) + .then((_) => setAlert(undefined)) + .catch((err) => { + setAlert({ variant: 'danger', message: err.message }) + }) + }, + [t, wallet], + ) + + useEffect(() => { + if (lockDate) loadTimeLockedAddress(lockDate) + }, [lockDate, loadTimeLockedAddress]) + + const [countdown, setCountdown] = useState(5) + const [isClickable, setIsClickable] = useState(false) + + const startCountdown = () => { + const interval = setInterval(() => { + console.log(countdown) + setCountdown((prevCountdown) => { + if (prevCountdown <= 1) { + clearInterval(interval) + setIsClickable(true) + return 0 + } + return prevCountdown - 1 + }) + }, 1000) + } + + const primaryButtonText = (currentStep: number) => { + switch (currentStep) { + case steps.selectDate: + return t('earn.fidelity_bond.select_date.text_primary_button') + case steps.selectJar: + return t('earn.fidelity_bond.select_jar.text_primary_button') + case steps.selectUtxos: + if (!onlyCjOutOrFbUtxosSelected()) { + return t('earn.fidelity_bond.select_utxos.text_primary_button_unsafe') + } + return t('earn.fidelity_bond.select_utxos.text_primary_button') + case steps.confirmation: + if (isClickable) return t('earn.fidelity_bond.confirmation.text_primary_button') + else return `Wait ${countdown} Seconds` + default: + return null + } + } + + const secondaryButtonText = (currentStep: number) => { + if (nextStep(step) === steps.failed) { + return null + } + + switch (currentStep) { + case steps.selectDate: + return t('earn.fidelity_bond.select_date.text_secondary_button') + case steps.selectJar: + return t('earn.fidelity_bond.select_jar.text_secondary_button') + case steps.selectUtxos: + return t('earn.fidelity_bond.select_utxos.text_secondary_button') + case steps.confirmation: + return t('earn.fidelity_bond.confirmation.text_secondary_button') + default: + return null + } + } + + const nextStep = (currentStep: number) => { + if (currentStep === steps.selectDate) { + if (lockDate !== null) { + return steps.selectJar + } + } + + if (currentStep === steps.selectJar) { + if (selectedJar !== undefined) { + return steps.selectUtxos + } + } + + if (currentStep === steps.selectUtxos) { + if (selectedUtxos.length > 0) { + return steps.confirmation + } + } + + if (currentStep === steps.confirmation) { + if (isClickable) { + if (alert) { + return steps.failed + } + return steps.done + } + } + + return null + } + + const onPrimaryButtonClicked = async () => { + if (nextStep(step) === null) { + return + } + + if (nextStep(step) === steps.confirmation) { + setIsClickable(false) + setCountdown(5) + startCountdown() + } + + if (nextStep(step) === steps.failed) { + reset() + return + } + + if (nextStep(step) === steps.done) { + const abortCtrl = new AbortController() + const requestContext = { ...wallet, signal: abortCtrl.signal } + reset() + setCreatingFidelityBond(true) + await spendUtxosWithDirectSend( + requestContext, + { + destination: timelockedAddress!, + sourceJarIndex: selectedJar!, + utxos: selectedUtxos, + }, + { + 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')), + }, + ) + setCreatingFidelityBond(false) + onDone() + return + } + + const next = nextStep(step) + if (next !== null) { + setStep(next) + } else { + reset() + setStep(0) + } + } + + const onSecondaryButtonClicked = () => { + if (step !== steps.selectDate) { + setStep(step - 1) + } + } + + const stepTitle = (currentStep: Number) => { + if (currentStep === steps.selectDate && lockDate !== null) { + return ( + {`- ${new Date(fb.lockdate.toTimestamp(lockDate)).toDateString()}`} + ) + } + if (currentStep === steps.selectJar && selectedJar !== undefined) { + return {`- Jar ${jarName(selectedJar)}`} + } + } + + return ( +
+ {otherFidelityBondExists ? ( +
+ setShowCreateFidelityBondModal(!showCreateFidelityBondModal)} + > + + {t('earn.fidelity_bond.title_fidelity_bond_exists')} + +
+ ) : ( +
+
setShowCreateFidelityBondModal(!showCreateFidelityBondModal)}> +
+
{t('earn.fidelity_bond.title')}
+ +
+
+
+ + {t('earn.fidelity_bond.subtitle')} +
+
+
+
+ )} + { + setCreatingFidelityBond(false)} + > + + {t('earn.fidelity_bond.create_fidelity_bond.title')} + + +
+
+
+
+ } + setShowCreateFidelityBondModal(false)} + > + + {t('earn.fidelity_bond.create_fidelity_bond.title')} + + + {alert && setAlert(undefined)} />} + {otherFidelityBondExists && ( + + )} + +
+
+ +
+
+
{t('earn.fidelity_bond.review_inputs.label_address')}
+
+ {timelockedAddress ? ( + {timelockedAddress} + ) : ( +
{t('earn.fidelity_bond.error_loading_address')}
+ )} +
+
+
+ +
+ {['Expiration date', 'Funding Source', 'UTXO Overview', 'Confirmation'].map((tab, index) => ( +
+
+
+
+
{index + 1}
+
+ {tab} + {stepTitle(index)} +
+ +
+ + {index === 0 && isLoading && ( +
+
+ )} + + {!isLoading && step === index && step === 0 && ( +
+ setLockDate(date)} + /> + {bondWithSelectedLockDateAlreadyExists && ( + } + /> + )} +
+ )} + + {step === index && step === 1 && ( +
+ + walletInfo.utxosByJar[jarIndex] && walletInfo.utxosByJar[jarIndex].length > 0 + } + selectedJar={selectedJar} + onJarSelected={(accountIndex) => { + setSelectedJar(accountIndex) + setSelectedUtxos([]) + }} + /> +
+ )} + {step === index && step === 2 && ( +
+ { + setSelectedUtxos([...selectedUtxos, utxo]) + }} + onUtxoDeselected={(utxo) => { + setSelectedUtxos(selectedUtxos.filter((it) => it.utxo !== utxo.utxo)) + }} + /> + {allUtxosSelected && ( + } + /> + )} +
+ )} + {step === index && step === 3 && ( +
+ +
+ )} +
+ ))} +
+
+ +
+ {!isLoading && secondaryButtonText(step) !== null && ( + + {secondaryButtonText(step)} + + )} + {!isLoading && primaryButtonText(step) !== null && ( + + {primaryButtonText(step)} + + )} +
+
+
+
+ ) +} + +export { CreateFidelityBond2 } diff --git a/src/components/fb2/ExistingFidelityBond.module.css b/src/components/fb2/ExistingFidelityBond.module.css new file mode 100644 index 000000000..518663a24 --- /dev/null +++ b/src/components/fb2/ExistingFidelityBond.module.css @@ -0,0 +1,53 @@ +.container { + border: 1px solid var(--bs-gray-200); + border-radius: 0.3rem; + padding: 1.25rem; +} + +:root[data-theme='dark'] .container { + 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; + color: var(--bs-body-color); +} + +.jar { + flex-shrink: 0; +} + +.jar > svg { + color: var(--bs-body-color); +} + +.label { + color: var(--bs-gray-600); + font-size: 0.8rem; +} + +.content { + font-size: 0.8rem; + word-break: break-all; +} + +.icon { + flex-shrink: 0; + padding: 0 !important; + width: 18px; + height: 18px; +} + +.icon svg { + color: var(--bs-body-color); +} diff --git a/src/components/fb2/ExistingFidelityBond.tsx b/src/components/fb2/ExistingFidelityBond.tsx new file mode 100644 index 000000000..7a9dfda66 --- /dev/null +++ b/src/components/fb2/ExistingFidelityBond.tsx @@ -0,0 +1,99 @@ +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' +import Balance from '../Balance' +import { CopyButton } from '../CopyButton' +import * as fb from './utils' +import styles from './ExistingFidelityBond.module.css' + +interface ExistingFidelityBondProps { + fidelityBond: Utxo +} + +const ExistingFidelityBond = ({ fidelityBond, children }: PropsWithChildren) => { + const settings = useSettings() + const { t, i18n } = useTranslation() + + const isExpired = useMemo(() => !fb.utxo.isLocked(fidelityBond), [fidelityBond]) + const humanReadableLockDuration = useMemo(() => { + const locktime = fb.utxo.getLocktime(fidelityBond) + if (!locktime) return '-' + return fb.time.humanReadableDuration({ + to: locktime, + locale: i18n.resolvedLanguage || i18n.language, + }) + }, [i18n, fidelityBond]) + + if (!fb.utxo.isFidelityBond(fidelityBond)) { + return <> + } + + return ( +
+
+
+ {isExpired ? ( + + Fidelity Bond expired + + ) : ( + t('earn.fidelity_bond.existing.title_active') + )} +
+
+ + +
+
+
+ +
+
+ +
+
+ {t(`earn.fidelity_bond.existing.${isExpired ? 'label_expired_on' : 'label_locked_until'}`)} +
+
+ {fidelityBond.locktime} ({humanReadableLockDuration}) +
+
+
+
+ } + successText={} + value={fidelityBond.address} + className={styles.icon} + /> +
+
{t('earn.fidelity_bond.existing.label_address')}
+
+ {fidelityBond.address} +
+
+
+
+
+ {children} +
+ ) +} + +export { ExistingFidelityBond } diff --git a/src/components/fb2/FidelityBondSteps.module.css b/src/components/fb2/FidelityBondSteps.module.css new file mode 100644 index 000000000..dafd8efb6 --- /dev/null +++ b/src/components/fb2/FidelityBondSteps.module.css @@ -0,0 +1,275 @@ +.stepDescription { + font-size: 1rem; + color: var(--bs-gray-600); +} + +.jarsContainer { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 2rem; +} + +.jarsContainer :global .balance-hook { + font-size: 0.75rem; +} + +@media only screen and (min-width: 768px) { + .jarsContainer { + flex-direction: row; + justify-content: space-around; + gap: 0.5rem; + flex-wrap: wrap; + } +} + +.utxoCard { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + border: 2px solid var(--bs-gray-100); + border-radius: 0.5rem; +} + +:root[data-theme='dark'] .utxoCard { + border-color: var(--bs-gray-700); +} + +.utxoCard.selectable { + cursor: pointer; +} + +.utxoCard:not(.selected):not(.selectable) { + color: var(--bs-gray-600); +} + +.utxoCard.selected { + background-color: rgb(45, 156, 219, 0.03); + border: 2px solid rgb(45, 156, 219, 1); +} + +:root[data-theme='dark'] .utxoCard.selected { + background-color: var(--bs-gray-800); + border-color: var(--bs-gray-500); +} + +.utxoCard.selected:not(.selectable) { + background-color: var(--bs-gray-100); + border: 2px solid var(--bs-gray-200); +} + +.utxoCard > .utxoSelectionMarker { + width: 1.25rem; + height: 1.25rem; + border-radius: 0.3rem; + border: 2px solid var(--bs-gray-200); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +:root[data-theme='dark'] .utxoCard > .utxoSelectionMarker { + border-color: var(--bs-gray-500); +} + +.utxoCard:not(.selected):not(.selectable) > .utxoSelectionMarker { + visibility: hidden; +} + +.utxoCard.selected > .utxoSelectionMarker { + border: 2px solid #2d9cdb; + background-color: white; +} + +.utxoCard.selected > .utxoSelectionMarker > svg { + color: #2d9cdb; +} + +.utxoCard.selected:not(.selectable) > .utxoSelectionMarker { + border: 2px solid var(--bs-gray-200); + background-color: white; +} + +.utxoCard.selected:not(.selectable) > .utxoSelectionMarker > svg { + color: var(--bs-gray-200); +} + +:root[data-theme='dark'] .utxoCard.selected:not(.selectable) > .utxoSelectionMarker { + border-color: var(--bs-gray-500); +} + +.utxoCard > .utxoBody { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.utxoCard > .utxoBody > .utxoAddress { + font-size: 0.8rem; + color: var(--bs-gray-600); + word-break: break-all; + margin-bottom: 0.1rem; +} + +.utxoCard > .utxoBody > .utxoDetails { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 0.2rem; + font-size: 0.6rem; + color: var(--bs-gray-600); +} + +.utxoCard > .utxoLabel { + display: flex; + justify-content: center; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background-color: var(--bs-gray-100); + border-radius: 0.3rem; + color: var(--bs-gray-600); + min-width: 5.5rem; + font-size: 0.9rem; +} + +:root[data-theme='dark'] .utxoCard > .utxoLabel { + background-color: var(--bs-gray-700); + color: var(--bs-gray-400); +} + +.utxoCard.selected:not(.selectable) > .utxoLabel { + background-color: var(--bs-gray-200); +} + +.utxoCard > .utxoLabel.utxoFrozen > svg { + color: #2d9cdb; +} + +.utxoCard > .utxoLabel.utxoFidelityBond > svg { + color: #f7cf09; +} + +.utxoCard > .utxoLabel.utxoCjOut > svg { + color: #27ae60; +} + +.utxoCard > .utxoLoadingSpinner { + color: var(--bs-gray-600); + margin-right: 1rem; +} + +.fbIcon { + margin-top: -0.8rem; + flex-shrink: 0; +} + +.confirmationStepIcon { + flex-shrink: 0; + padding: 0 !important; + width: 18px; + height: 18px; +} + +.confirmationStepIcon svg { + color: var(--bs-body-color); +} + +.confirmationStepLabel { + color: var(--bs-gray-600); + font-size: 0.8rem; +} + +.confirmationStepContent { + font-size: 0.8rem; + word-break: break-all; +} + +.timelockedAddress { + text-align: left; + padding: 0; + font-size: 0.8rem; +} + +.utxoSummaryIconLock { + margin-bottom: 3px; +} + +.utxoSummaryTitle { + font-size: 0.8rem; + color: var(--bs-gray-800); +} + +:root[data-theme='dark'] .utxoSummaryTitle { + color: var(--bs-gray-600); +} + +.utxoSummaryCard { + display: flex; + flex-direction: column; + flex-shrink: 0; + padding: 0.3rem 0.5rem; + border: 1px solid var(--bs-gray-200); + background-color: var(--bs-gray-100); + border-radius: 0.3rem; +} + +:root[data-theme='dark'] .utxoSummaryCard { + border-color: var(--bs-gray-600); + background-color: var(--bs-gray-800); +} + +.utxoSummaryCardTitleContainer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.utxoSummaryCard > .utxoSummaryCardTitleContainer > .utxoSummaryCardTitle { + font-size: 0.8rem; +} + +.utxoSummaryCard > .utxoSummaryCardTitleContainer > .utxoSummaryCardTitleLabel { + display: flex; + justify-content: center; + align-items: center; + gap: 0.15rem; + border-radius: 0.1rem; + color: var(--bs-gray-600); + font-size: 0.55rem; +} + +.utxoSummaryCard > .utxoSummaryCardTitleContainer > .utxoSummaryCardTitleLabel.utxoFrozen > svg { + color: #2d9cdb; + margin-bottom: 0.1rem; +} + +.utxoSummaryCard > .utxoSummaryCardTitleContainer > .utxoSummaryCardTitleLabel.utxoCjOut > svg { + color: #27ae60; +} + +.utxoSummaryCard > .utxoSummaryCardSubtitle { + font-size: 0.55rem; + color: var(--bs-gray-600); + word-break: break-all; +} + +.createdCheckmark { + display: flex; + justify-content: center; + align-items: center; + width: 2rem; + height: 2rem; + background-color: rgba(39, 174, 96, 1); + color: white; + border-radius: 50%; +} + +.createdSummaryTitle { + font-size: 1.2rem; + font-weight: 500; +} diff --git a/src/components/fb2/FidelityBondSteps.tsx b/src/components/fb2/FidelityBondSteps.tsx new file mode 100644 index 000000000..71323c176 --- /dev/null +++ b/src/components/fb2/FidelityBondSteps.tsx @@ -0,0 +1,249 @@ +import { useMemo, useState, useEffect } from 'react' +import * as Api from '../../libs/JmWalletApi' +import { useTranslation } from 'react-i18next' +import { useSettings } from '../../context/SettingsContext' +import { AccountBalances, AccountBalanceSummary } from '../../context/BalanceSummary' +import { Utxo, WalletInfo } from '../../context/WalletContext' +import { SelectableJar, jarFillLevel } from '../jars/Jar' +import Sprite from '../Sprite' +import Balance from '../Balance' +import { CopyButton } from '../CopyButton' +import LockdateForm, { LockdateFormProps } from './LockdateForm' +import * as fb from './utils' +import styles from './FidelityBondSteps.module.css' +import { UtxoListDisplay } from '../Send/ShowUtxos' +import Divider from '../Divider' + +type SelectDateProps = { + description: string +} & LockdateFormProps + +interface SelectJarProps { + description: string + accountBalances: AccountBalances + totalBalance: Api.AmountSats + isJarSelectable: (jarIndex: JarIndex) => boolean + selectedJar?: JarIndex + onJarSelected: (jarIndex: JarIndex) => void +} + +interface SelectUtxosProps { + walletInfo: WalletInfo + jar: JarIndex + utxos: Utxo[] + selectedUtxos: Utxo[] + onUtxoSelected: (utxo: Utxo) => void + onUtxoDeselected: (utxo: Utxo) => void +} + +interface ConfirmationProps { + lockDate: Api.Lockdate + jar: JarIndex + selectedUtxos: Utxo[] + timelockedAddress: Api.BitcoinAddress +} + +const SelectDate = ({ description, yearsRange, disabled, onChange }: SelectDateProps) => { + return ( +
+ +
+
{description}
+ +
+
+ ) +} + +const SelectJar = ({ + description, + accountBalances, + totalBalance, + isJarSelectable, + selectedJar, + onJarSelected, +}: SelectJarProps) => { + const sortedAccountBalances: Array = useMemo(() => { + if (!accountBalances) return [] + return Object.values(accountBalances).sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex) + }, [accountBalances]) + + return ( +
+
{description}
+
+ {sortedAccountBalances.map((account, index) => ( + onJarSelected(jarIndex)} + /> + ))} +
+
+ ) +} + +type SelectableUtxo = Utxo & { checked: boolean; selectable: boolean } + +const SelectUtxos = ({ selectedUtxos, utxos, onUtxoSelected, onUtxoDeselected }: SelectUtxosProps) => { + const settings = useSettings() + const upperUtxos = utxos + .filter((it) => !it.frozen) + .filter((it) => !it.locktime) + .map((it) => + fb.utxo.isInList(it, selectedUtxos) + ? { + ...it, + checked: true, + selectable: true, + } + : { + ...it, + checked: false, + selectable: true, + }, + ) + .sort((a, b) => a.confirmations - b.confirmations) + + const frozenNonTimelockedUtxos = utxos + .filter((it) => it.frozen) + .filter((it) => !it.locktime) + .map((it) => + fb.utxo.isInList(it, selectedUtxos) + ? { + ...it, + checked: true, + selectable: true, + } + : { + ...it, + checked: false, + selectable: true, + }, + ) + .sort((a, b) => a.confirmations - b.confirmations) + + const timelockedUtxos = utxos + .filter((it) => it.locktime !== undefined) + .map((it) => ({ ...it, checked: false, selectable: false })) + .sort((a, b) => a.confirmations - b.confirmations) + + const lowerUtxos = [...frozenNonTimelockedUtxos, ...timelockedUtxos] + + const [showFrozenUtxos, setShowFrozenUtxos] = useState(upperUtxos.length === 0 && lowerUtxos.length > 0) + + const handleToggle = (utxo: SelectableUtxo) => { + utxo.checked = !utxo.checked + if (utxo.checked) { + onUtxoSelected(utxo) + } else { + onUtxoDeselected(utxo) + } + } + + return ( + <> +
+ + {upperUtxos.length > 0 && lowerUtxos.length > 0 && ( + setShowFrozenUtxos((current) => !current)} + className={`mt-4 ${showFrozenUtxos && 'mb-4'}`} + /> + )} + {showFrozenUtxos && ( + + )} +
+ + ) +} + +const Confirmation = ({ lockDate, jar, selectedUtxos, timelockedAddress }: ConfirmationProps) => { + useEffect(() => { + console.log('lockDate', lockDate) + console.log('jar', jar) + console.log('selected', selectedUtxos) + }, [jar, lockDate, selectedUtxos]) + + const settings = useSettings() + const { t, i18n } = useTranslation() + + const confirmationItems = [ + { + icon: , + label: t('earn.fidelity_bond.review_inputs.label_amount'), + content: ( + acc + utxo.value, 0).toString()} + convertToUnit={settings.unit} + showBalance={true} + /> + ), + }, + { + icon: , + label: t('earn.fidelity_bond.review_inputs.label_lock_date'), + content: ( + <> + {new Date(fb.lockdate.toTimestamp(lockDate)).toUTCString()} ( + {fb.time.humanReadableDuration({ + to: fb.lockdate.toTimestamp(lockDate), + locale: i18n.resolvedLanguage || i18n.language, + })} + ) + + ), + }, + { + icon: ( + } + successText={} + value={timelockedAddress} + className={styles.confirmationStepIcon} + /> + ), + label: t('earn.fidelity_bond.review_inputs.label_address'), + content: {timelockedAddress}, + }, + ] + return ( + <> +
+ +
+ {confirmationItems.map((item, index) => ( +
+ {item.icon} +
+
{item.label}
+
{item.content}
+
+
+ ))} +
+
+ + ) +} + +const Done = ({ text }: { text: string }) => { + return ( +
+
+ +
+
{text}
+
+ ) +} + +export { SelectJar, SelectUtxos, SelectDate, Done, Confirmation } diff --git a/src/components/fb2/LockdateForm.test.tsx b/src/components/fb2/LockdateForm.test.tsx new file mode 100644 index 000000000..5360b8152 --- /dev/null +++ b/src/components/fb2/LockdateForm.test.tsx @@ -0,0 +1,214 @@ +import { render, screen } from '@testing-library/react' +import user from '@testing-library/user-event' +import { I18nextProvider } from 'react-i18next' +import * as Api from '../../libs/JmWalletApi' +import * as fb from './utils' +import i18n from '../../i18n/testConfig' + +import LockdateForm, { _minMonth, _selectableMonths, _selectableYears } from './LockdateForm' + +describe('', () => { + const now = new Date(Date.UTC(2009, 0, 3)) + const setup = (onChange: (lockdate: Api.Lockdate | null) => void) => { + render( + + + , + ) + } + + it('should render without errors', () => { + setup(() => {}) + + expect(screen.getByTestId('select-lockdate-year')).toBeVisible() + expect(screen.getByTestId('select-lockdate-month')).toBeVisible() + }) + + it('should initialize 3 month ahead by default', () => { + const onChange = jest.fn() + + setup(onChange) + + expect(onChange).toHaveBeenCalledWith(fb.lockdate.initial(now)) + }) + + it('should be able to select 10 years by default', async () => { + const expectedSelectableYears = 10 + const currentYear = now.getUTCFullYear() + + let selectedLockdate: Api.Lockdate | null = null + const onChange = (lockdate: Api.Lockdate | null) => (selectedLockdate = lockdate) + + setup(onChange) + + const yearDropdown = screen.getByTestId('select-lockdate-year') + + for (let i = 0; i < expectedSelectableYears; i++) { + const yearValue = currentYear + i + + await user.selectOptions(yearDropdown, [`${yearValue}`]) + + expect(new Date(fb.lockdate.toTimestamp(selectedLockdate!)).getUTCFullYear()).toBe(yearValue) + } + + try { + const unavailableYearPast = `${currentYear - 1}` + await user.selectOptions(yearDropdown, [unavailableYearPast]) + expect(false).toBe(true) + } catch (err: any) { + expect(err.name).toBe('TestingLibraryElementError') + } + + try { + const unavailableYearFuture = `${currentYear + expectedSelectableYears + 1}` + await user.selectOptions(yearDropdown, [unavailableYearFuture]) + expect(false).toBe(true) + } catch (err: any) { + expect(err.name).toBe('TestingLibraryElementError') + } + }) + + it('should not be able to select current month', async () => { + const currentYear = now.getUTCFullYear() + const currentMonth = now.getUTCMonth() + 1 // utc month ranges from [0, 11] + + let selectedLockdate: Api.Lockdate | null = null + const onChange = (lockdate: Api.Lockdate | null) => (selectedLockdate = lockdate) + + setup(onChange) + + const initialLockdate = selectedLockdate + expect(initialLockdate).not.toBeNull() + + const monthDropdown = screen.getByTestId('select-lockdate-month') + + await user.selectOptions(monthDropdown, [`${currentMonth}`]) + expect(selectedLockdate).toBe(initialLockdate) // select lockdate has not changed + + const expectedLockdate = fb.lockdate.fromTimestamp(Date.UTC(currentYear, currentMonth + 3 - 1)) + await user.selectOptions(monthDropdown, [`${currentMonth + 3}`]) + expect(selectedLockdate).toBe(expectedLockdate) + + await user.selectOptions(monthDropdown, [`${currentMonth}`]) + expect(selectedLockdate).toBe(expectedLockdate) // select lockdate has not changed + }) + + describe('_minMonth', () => { + const yearsRange = fb.toYearsRange(0, 10) + const yearsRangeMinusOne = fb.toYearsRange(-1, 10) + const yearsRangePlusOne = fb.toYearsRange(1, 10) + + const january2009 = new Date(Date.UTC(2009, 0)) + const july2009 = new Date(Date.UTC(2009, 6)) + const december2009 = new Date(Date.UTC(2009, 11)) + + it('should calculate min month correctly', () => { + expect(_minMonth(2009, yearsRange, january2009)).toBe(2) + expect(_minMonth(2009, yearsRange, july2009)).toBe(8) + expect(_minMonth(2009, yearsRange, december2009)).toBe(13) + + expect(_minMonth(2009, yearsRangeMinusOne, january2009)).toBe(1) + expect(_minMonth(2009, yearsRangeMinusOne, july2009)).toBe(1) + expect(_minMonth(2009, yearsRangeMinusOne, december2009)).toBe(1) + + expect(_minMonth(2009, yearsRangePlusOne, january2009)).toBe(13) + expect(_minMonth(2009, yearsRangePlusOne, july2009)).toBe(13) + expect(_minMonth(2009, yearsRangePlusOne, december2009)).toBe(13) + }) + }) + + describe('_selectableMonth', () => { + const yearsRange = fb.toYearsRange(0, 2) + + const january2009 = new Date(Date.UTC(2009, 0)) + const july2009 = new Date(Date.UTC(2009, 6)) + const december2009 = new Date(Date.UTC(2009, 11)) + + it('should display month name', () => { + const selectableMonths = _selectableMonths(2009, yearsRange, january2009) + expect(selectableMonths).toHaveLength(12) + + expect(selectableMonths[0].displayValue).toBe('January') + expect(selectableMonths[11].displayValue).toBe('December') + }) + + it('should set disabled flag correctly for january', () => { + const selectableMonths2008 = _selectableMonths(2008, yearsRange, january2009) + expect(selectableMonths2008).toHaveLength(12) + expect(selectableMonths2008[0].disabled).toBe(true) + expect(selectableMonths2008[11].disabled).toBe(true) + + const selectableMonths2009 = _selectableMonths(2009, yearsRange, january2009) + expect(selectableMonths2009).toHaveLength(12) + expect(selectableMonths2009[0].disabled).toBe(true) + expect(selectableMonths2009[1].disabled).toBe(false) + expect(selectableMonths2009[11].disabled).toBe(false) + + const selectableMonths2010 = _selectableMonths(2010, yearsRange, january2009) + expect(selectableMonths2010).toHaveLength(12) + expect(selectableMonths2010[0].disabled).toBe(false) + expect(selectableMonths2010[11].disabled).toBe(false) + }) + + it('should set disabled flag correctly for july', () => { + const selectableMonths2008 = _selectableMonths(2008, yearsRange, july2009) + expect(selectableMonths2008).toHaveLength(12) + expect(selectableMonths2008[0].disabled).toBe(true) + expect(selectableMonths2008[11].disabled).toBe(true) + + const selectableMonths2009 = _selectableMonths(2009, yearsRange, july2009) + expect(selectableMonths2009).toHaveLength(12) + expect(selectableMonths2009[0].disabled).toBe(true) + expect(selectableMonths2009[1].disabled).toBe(true) + expect(selectableMonths2009[6].disabled).toBe(true) + expect(selectableMonths2009[7].disabled).toBe(false) + expect(selectableMonths2009[11].disabled).toBe(false) + + const selectableMonths2010 = _selectableMonths(2010, yearsRange, july2009) + expect(selectableMonths2010).toHaveLength(12) + expect(selectableMonths2010[0].disabled).toBe(false) + expect(selectableMonths2010[11].disabled).toBe(false) + }) + + it('should set disabled flag correctly for december', () => { + const selectableMonths2008 = _selectableMonths(2008, yearsRange, december2009) + expect(selectableMonths2008).toHaveLength(12) + expect(selectableMonths2008[0].disabled).toBe(true) + expect(selectableMonths2008[11].disabled).toBe(true) + + const selectableMonths2009 = _selectableMonths(2009, yearsRange, december2009) + expect(selectableMonths2009).toHaveLength(12) + expect(selectableMonths2009[0].disabled).toBe(true) + expect(selectableMonths2009[11].disabled).toBe(true) + + const selectableMonths2010 = _selectableMonths(2010, yearsRange, december2009) + expect(selectableMonths2010).toHaveLength(12) + expect(selectableMonths2010[0].disabled).toBe(false) + expect(selectableMonths2010[11].disabled).toBe(false) + }) + }) + + describe('_selectableYears', () => { + const yearsRange = fb.toYearsRange(0, 2) + const yearsRangeMinusOne = fb.toYearsRange(-1, 2) + const yearsRangePlusOne = fb.toYearsRange(1, 2) + + const january2009 = new Date(Date.UTC(2009, 0)) + const july2009 = new Date(Date.UTC(2009, 6)) + const december2009 = new Date(Date.UTC(2009, 11)) + + it('should calculate selectable years correctly', () => { + expect(_selectableYears(yearsRange, january2009)).toEqual([2009, 2010]) + expect(_selectableYears(yearsRange, july2009)).toEqual([2009, 2010]) + expect(_selectableYears(yearsRange, december2009)).toEqual([2010, 2011]) + + expect(_selectableYears(yearsRangeMinusOne, january2009)).toEqual([2008, 2009, 2010]) + expect(_selectableYears(yearsRangeMinusOne, july2009)).toEqual([2008, 2009, 2010]) + expect(_selectableYears(yearsRangeMinusOne, december2009)).toEqual([2009, 2010, 2011]) + + expect(_selectableYears(yearsRangePlusOne, january2009)).toEqual([2010]) + expect(_selectableYears(yearsRangePlusOne, july2009)).toEqual([2010]) + expect(_selectableYears(yearsRangePlusOne, december2009)).toEqual([2011]) + }) + }) +}) diff --git a/src/components/fb2/LockdateForm.tsx b/src/components/fb2/LockdateForm.tsx new file mode 100644 index 000000000..fef84ce48 --- /dev/null +++ b/src/components/fb2/LockdateForm.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useState } from 'react' +import * as rb from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import * as Api from '../../libs/JmWalletApi' +import * as fb from './utils' + +const monthFormatter = (locales: string) => new Intl.DateTimeFormat(locales, { month: 'long' }) + +const DEFAULT_MONTH_FORMATTER = monthFormatter('en-US') + +const getOrCreateMonthFormatter = (locale: string) => + DEFAULT_MONTH_FORMATTER.resolvedOptions().locale === locale ? DEFAULT_MONTH_FORMATTER : monthFormatter(locale) + +const displayMonth = (date: Date, locale: string = 'en-US') => { + return getOrCreateMonthFormatter(locale).format(date) +} + +type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 + +// exported for tests only +export const _minMonth = (year: number, yearsRange: fb.YearsRange, now = new Date()): Month | 13 => { + if (year > now.getUTCFullYear() + yearsRange.min) return 1 as Month + if (year < now.getUTCFullYear() + yearsRange.min) return 13 + return (now.getUTCMonth() + 1 + 1) as Month | 13 +} + +type SelectableMonth = { + value: Month + displayValue: string + disabled: boolean +} + +// exported for tests only +export const _selectableMonths = ( + year: number, + yearsRange: fb.YearsRange, + now = new Date(), + locale?: string, +): SelectableMonth[] => { + const minMonth = _minMonth(year, yearsRange, now) + return Array(12) + .fill('') + .map((_, index) => (index + 1) as Month) + .map((month) => ({ + value: month, + displayValue: displayMonth(new Date(Date.UTC(year, month - 1, 1)), locale), + disabled: month < minMonth, + })) +} + +// exported for tests only +export const _selectableYears = (yearsRange: fb.YearsRange, now = new Date()): number[] => { + const years = yearsRange.max - yearsRange.min + const extra = yearsRange.min + (now.getUTCMonth() === 11 ? 1 : 0) + return Array(years) + .fill('') + .map((_, index) => index + now.getUTCFullYear() + extra) +} + +export interface LockdateFormProps { + onChange: (lockdate: Api.Lockdate | null) => void + yearsRange?: fb.YearsRange + now?: Date + disabled?: boolean +} + +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]) + + const initialValue = useMemo(() => fb.lockdate.initial(_now, _yearsRange), [_now, _yearsRange]) + const initialDate = useMemo(() => new Date(fb.lockdate.toTimestamp(initialValue)), [initialValue]) + const initialYear = useMemo(() => initialDate.getUTCFullYear(), [initialDate]) + const initialMonth = useMemo(() => (initialDate.getUTCMonth() + 1) as Month, [initialDate]) + + const [lockdateYear, setLockdateYear] = useState(initialYear) + const [lockdateMonth, setLockdateMonth] = useState(initialMonth) + + const selectableYears = useMemo(() => _selectableYears(_yearsRange, _now), [_yearsRange, _now]) + const selectableMonths = useMemo( + () => _selectableMonths(lockdateYear, _yearsRange, _now, i18n.resolvedLanguage || i18n.language), + [lockdateYear, _yearsRange, _now, i18n], + ) + + const isLockdateYearValid = useMemo(() => selectableYears.includes(lockdateYear), [lockdateYear, selectableYears]) + const isLockdateMonthValid = useMemo( + () => + selectableMonths + .filter((it) => !it.disabled) + .map((it) => it.value) + .includes(lockdateMonth), + [lockdateMonth, selectableMonths], + ) + + useEffect(() => { + if (isLockdateYearValid && isLockdateMonthValid) { + const timestamp = Date.UTC(lockdateYear, lockdateMonth - 1, 1) + onChange(fb.lockdate.fromTimestamp(timestamp)) + } else { + onChange(null) + } + }, [lockdateYear, lockdateMonth, isLockdateYearValid, isLockdateMonthValid, onChange]) + + return ( + + + + + + Month + + setLockdateMonth(parseInt(e.target.value, 10) as Month)} + required + isInvalid={!isLockdateMonthValid} + disabled={disabled} + data-testid="select-lockdate-month" + > + {selectableMonths.map((it) => ( + + ))} + + + + + + + Year + + setLockdateYear(parseInt(e.target.value, 10))} + required + isInvalid={!isLockdateYearValid} + disabled={disabled} + data-testid="select-lockdate-year" + > + {selectableYears.map((year) => ( + + ))} + + + + + + ) +} + +export default LockdateForm diff --git a/src/components/fb2/SpendFidelityBondModal.module.css b/src/components/fb2/SpendFidelityBondModal.module.css new file mode 100644 index 000000000..d0ed42c7d --- /dev/null +++ b/src/components/fb2/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/fb2/SpendFidelityBondModal.tsx b/src/components/fb2/SpendFidelityBondModal.tsx new file mode 100644 index 000000000..9f41412c5 --- /dev/null +++ b/src/components/fb2/SpendFidelityBondModal.tsx @@ -0,0 +1,751 @@ +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, 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 { 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 = { + 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, 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 + onFreezeUtxosError: (res: Response) => Promise + onUnfreezeUtxosError: (res: Response) => Promise + onSendError: (res: Response) => Promise +} + +const spendUtxosWithDirectSend = async ( + context: Api.WalletRequestContext, + 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) { + // 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)) + + 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)) + + 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 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 ( + <> +