From 4a0d3a3c0146c589b23cc4bd94b2f6e0ec9b711e Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 29 Oct 2022 15:36:15 +0200 Subject: [PATCH 01/28] dev: ability to append content to ExistingFidelityBond component --- src/components/fb/ExistingFidelityBond.tsx | 37 +++++++++++----------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/components/fb/ExistingFidelityBond.tsx b/src/components/fb/ExistingFidelityBond.tsx index 82a122177..dd1f65fc9 100644 --- a/src/components/fb/ExistingFidelityBond.tsx +++ b/src/components/fb/ExistingFidelityBond.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { PropsWithChildren, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useSettings } from '../../context/SettingsContext' import { Utxo } from '../../context/WalletContext' @@ -12,11 +12,18 @@ interface ExistingFidelityBondProps { fidelityBond: Utxo } -const ExistingFidelityBond = ({ fidelityBond }: ExistingFidelityBondProps) => { +const ExistingFidelityBond = ({ fidelityBond, children }: PropsWithChildren) => { const settings = useSettings() const { t, i18n } = useTranslation() const isExpired = useMemo(() => !fb.utxo.isLocked(fidelityBond), [fidelityBond]) + const humanReadableDuration = useMemo(() => { + if (!fidelityBond.locktime) return '-' + return fb.time.humanReadableDuration({ + to: new Date(fidelityBond.locktime).getTime(), + locale: i18n.resolvedLanguage || i18n.language, + }) + }, [i18n, fidelityBond]) if (!fb.utxo.isFidelityBond(fidelityBond)) { return <> @@ -45,24 +52,17 @@ const ExistingFidelityBond = ({ fidelityBond }: ExistingFidelityBondProps) => { height="74px" />
- {fidelityBond.locktime && ( -
- -
-
- {t(`earn.fidelity_bond.existing.${isExpired ? 'label_expired_on' : 'label_locked_until'}`)} -
-
- {fidelityBond.locktime} ( - {fb.time.humanReadableDuration({ - to: new Date(fidelityBond.locktime).getTime(), - locale: i18n.resolvedLanguage || i18n.language, - })} - ) -
+
+ +
+
+ {t(`earn.fidelity_bond.existing.${isExpired ? 'label_expired_on' : 'label_locked_until'}`)} +
+
+ {fidelityBond.locktime} ({humanReadableDuration})
- )} +
{
+ {children}
) } From bcb09cba941236278621f843fabdc8256ce99774 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 30 Oct 2022 13:21:29 +0100 Subject: [PATCH 02/28] send-fb - wip --- public/sprite.svg | 4 + src/components/Earn.jsx | 108 ++++- src/components/fb/FidelityBondSteps.tsx | 8 +- src/components/fb/SpendFidelityBond.tsx | 435 ++++++++++++++++++ .../jar_details/JarDetailsOverlay.tsx | 14 +- src/i18n/locales/en/translation.json | 10 +- 6 files changed, 568 insertions(+), 11 deletions(-) create mode 100644 src/components/fb/SpendFidelityBond.tsx diff --git a/public/sprite.svg b/public/sprite.svg index f0224b60a..80b2e2a1e 100644 --- a/public/sprite.svg +++ b/public/sprite.svg @@ -339,4 +339,8 @@ + + + + diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx index 9b706bc86..d3ea370a9 100644 --- a/src/components/Earn.jsx +++ b/src/components/Earn.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Formik } from 'formik' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' @@ -7,11 +7,13 @@ import { useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/Wal import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext' import { factorToPercentage, percentageToFactor } from '../utils' import * as Api from '../libs/JmWalletApi' +import * as fb from './fb/utils' import Sprite from './Sprite' import PageTitle from './PageTitle' import SegmentedTabs from './SegmentedTabs' import { CreateFidelityBond } from './fb/CreateFidelityBond' import { ExistingFidelityBond } from './fb/ExistingFidelityBond' +import { SpendFidelityBondModal } from './fb/SpendFidelityBond' import { EarnReportOverlay } from './EarnReport' import { OrderbookOverlay } from './Orderbook' import Balance from './Balance' @@ -185,6 +187,8 @@ export default function Earn({ wallet }) { const [isShowOrderbook, setIsShowOrderbook] = useState(false) const [fidelityBonds, setFidelityBonds] = useState([]) + const [moveToJarFidelityBondId, setMoveToJarFidelityBondId] = useState() + const startMakerService = (ordertype, minsize, cjfee_a, cjfee_r) => { setIsSending(true) setIsWaitingMakerStart(true) @@ -359,6 +363,72 @@ export default function Earn({ wallet }) { } } + const sendFidelityBondToJar = async (fidelityBond, destinationJarIndex) => { + if (isLoading || isSending || isWaitingMakerStart || isWaitingMakerStop) { + return + } + + setAlert(null) + + try { + const abortCtrl = new AbortController() + const { name: walletName, token } = wallet + const requestContext = { walletName, token, signal: abortCtrl.signal } + + const destination = await Api.getAddressNew({ ...requestContext, mixdepth: destinationJarIndex }) + .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed')))) + .then((data) => data.address) + + // reload utxos + const utxos = await Api.getWalletUtxos(requestContext) + .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res))) + .then((data) => data.utxos) + const utxosToFreeze = utxos.filter((it) => it.mixdepth === fidelityBond.mixdepth).filter((it) => !it.frozen) + + const utxosThatWereFrozen = [] + const freezeCalls = utxosToFreeze.map((utxo) => + Api.postFreeze(requestContext, { utxo: utxo.utxo, freeze: true }) + .then((res) => { + if (!res.ok) throw Api.Helper.throwError(res, t('earn.fidelity_bond.error_freezing_utxos')) + }) + .then((_) => utxosThatWereFrozen.push(utxo)) + ) + + try { + // freeze other coins + await Promise.all(freezeCalls) + // unfreeze fidelity bond + await Api.postFreeze(requestContext, { utxo: fidelityBond.utxo, freeze: false }).then((res) => { + // TODO: translate + if (!res.ok) throw Api.Helper.throwError(res, 'Error while unfreezing fidelity bond') + }) + // spend fidelity bond (by sweeping whole jar) + await Api.postDirectSend(requestContext, { + destination, + mixdepth: fidelityBond.mixdepth, + amount_sats: 0, // sweep + }).then((res) => { + // TODO: translate + if (!res.ok) throw Api.Helper.throwError(res, 'Error while spending fidelity bond') + }) + } finally { + // unfreeze all previously frozen coins + const unfreezeCalls = utxosThatWereFrozen.map((utxo) => + Api.postFreeze(requestContext, { utxo: utxo.utxo, freeze: false }) + ) + + try { + await Promise.all(unfreezeCalls) + } catch (e) { + // don't throw, just log, as we are in a finally block + console.error(e) + } + } + } catch (e) { + setAlert({ variant: 'danger', message: e.message }) + } + } + return (
@@ -395,11 +465,39 @@ export default function Earn({ wallet }) { subtitle={t('earn.subtitle_fidelity_bonds')} />
- {fidelityBonds.length > 0 && ( + {currentWalletInfo && fidelityBonds.length > 0 && ( <> - {fidelityBonds.map((fidelityBond, index) => ( - - ))} + {moveToJarFidelityBondId && ( + { + setMoveToJarFidelityBondId(undefined) + reloadFidelityBonds({ delay: RELOAD_FIDELITY_BONDS_DELAY_MS }) + }} + /> + )} + {fidelityBonds.map((fidelityBond, index) => { + const isExpired = !fb.utxo.isLocked(fidelityBond) + return ( + + {isExpired && ( +
+ setMoveToJarFidelityBondId(fidelityBond.utxo)} + > + + {t('earn.fidelity_bond.existing.button_move_to_jar')} + +
+ )} +
+ ) + })} )} <> diff --git a/src/components/fb/FidelityBondSteps.tsx b/src/components/fb/FidelityBondSteps.tsx index 0c12b19fd..77423342d 100644 --- a/src/components/fb/FidelityBondSteps.tsx +++ b/src/components/fb/FidelityBondSteps.tsx @@ -27,7 +27,7 @@ interface SelectJarProps { accountBalances: AccountBalances totalBalance: BalanceString isJarSelectable: (jarIndex: JarIndex) => boolean - selectedJar: JarIndex | null + selectedJar?: JarIndex onJarSelected: (jarIndex: JarIndex) => void } @@ -205,7 +205,7 @@ const FreezeUtxos = ({ walletInfo, jar, utxos, selectedUtxos, isLoading = false const utxosToFreeze = useMemo(() => fb.utxo.utxosToFreeze(utxos, selectedUtxos), [utxos, selectedUtxos]) return ( -
+
{t('earn.fidelity_bond.freeze_utxos.description_selected_utxos')}
{selectedUtxos.map((utxo, index) => ( 0 && ( <> {fb.utxo.allAreFrozen(utxosToFreeze) ? ( -
+
{t('earn.fidelity_bond.freeze_utxos.description_unselected_utxos')}
) : ( -
+
{t('earn.fidelity_bond.freeze_utxos.description_unselected_utxos')}{' '} {t('earn.fidelity_bond.freeze_utxos.description_selected_utxos_to_freeze', { jar: jarInitial(jar) })}
diff --git a/src/components/fb/SpendFidelityBond.tsx b/src/components/fb/SpendFidelityBond.tsx new file mode 100644 index 000000000..70a9ee6cb --- /dev/null +++ b/src/components/fb/SpendFidelityBond.tsx @@ -0,0 +1,435 @@ +import { useEffect, useMemo, useState } from 'react' +import * as rb from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { CurrentWallet, useReloadCurrentWalletInfo, Utxo, Utxos, WalletInfo } from '../../context/WalletContext' +import { Done, FreezeUtxos, SelectJar } from './FidelityBondSteps' +import * as fb from './utils' +import * as Api from '../../libs/JmWalletApi' +import Alert from '../Alert' + +const steps = { + selectJar: 1, + freezeUtxos: 2, + spendFidelityBond: 3, + unfreezeUtxos: 4, + done: 5, + failed: 6, +} + +interface SpendFidelityBondProps { + fidelityBond?: Utxo + walletInfo: WalletInfo + step: number + isLoading: boolean + selectedJar?: JarIndex + setSelectedJar: (jar: JarIndex) => void +} + +const SpendFidelityBond = ({ + fidelityBond, + walletInfo, + step, + isLoading, + selectedJar, + setSelectedJar, +}: SpendFidelityBondProps) => { + const { t } = useTranslation() + + const stepComponent = (currentStep: number) => { + switch (currentStep) { + case steps.selectJar: + return ( + true} + selectedJar={selectedJar} + onJarSelected={setSelectedJar} + /> + ) + case steps.freezeUtxos: + return ( + <> + {fidelityBond && ( + + )} + + ) + /*case steps.reviewInputs: + if (isLoading) { + return ( +
+
+ ) + } + + if (timelockedAddress === null) { + return
{t('earn.fidelity_bond.error_loading_address')}
+ } + + return ( + + ) + + case steps.createFidelityBond: + return isLoading ? ( +
+
+ ) : ( +
+ {alert === null ? ( + + ) : ( + <>{t('earn.fidelity_bond.error_creating_fidelity_bond')} + )} +
+ )*/ + case steps.unfreezeUtxos: + return isLoading ? ( +
+
+ ) : ( +
+ {alert === null ? ( + + ) : ( + <>{t('earn.fidelity_bond.error_unfreezing_utxos')} + )} +
+ ) + default: + return null + } + } + + return <>{stepComponent(step)} +} + +type SpendFidelityBondModalProps = { + fidelityBondId: Api.UtxoId + wallet: CurrentWallet + walletInfo: WalletInfo +} & rb.ModalProps + +const SpendFidelityBondModal = ({ fidelityBondId, wallet, walletInfo, ...modalProps }: SpendFidelityBondModalProps) => { + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + const { t } = useTranslation() + + const [alert, setAlert] = useState<(rb.AlertProps & { message: string }) | undefined>() + const [isLoading, setIsLoading] = useState(false) + const [step, setStep] = useState(steps.selectJar) + //const [nextStep, setNextStep] = useState() + + const [destinationJarIndex, setDestinationJarIndex] = useState() + const [frozenUtxoIds, setFrozenUtxoIds] = useState([]) + + const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([]) + + const fidelityBond = useMemo(() => { + return walletInfo.data.utxos.utxos.find((utxo) => utxo.utxo === fidelityBondId) + }, [walletInfo, fidelityBondId]) + + // This callback is responsible for updating the `isLoading` flag 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(() => { + 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({ signal: abortCtrl.signal }) + .then((data) => { + if (abortCtrl.signal.aborted) return + + const outputs = data.data.utxos.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 = err.message || t('send.error_loading_wallet_failed') + setAlert({ variant: 'danger', message }) + }) + }, initialDelayInMs) + + return () => { + abortCtrl.abort() + clearTimeout(timer) + } + }, [waitForUtxosToBeSpent, reloadCurrentWalletInfo, t]) + + const freezeUtxos = (utxos: Utxos) => { + return changeUtxoFreeze(utxos, true) + } + + const unfreezeUtxos = (utxos: Utxos) => { + return changeUtxoFreeze(utxos, false) + } + + const changeUtxoFreeze = (utxos: Utxos, freeze: boolean) => { + setIsLoading(true) + + let utxosThatWereFrozen: Api.UtxoId[] = [] + + const { name: walletName, token } = wallet + const freezeCalls = utxos.map((utxo) => + Api.postFreeze({ walletName, token }, { utxo: utxo.utxo, freeze: freeze }).then((res) => { + if (res.ok) { + if (!utxo.frozen && freeze) { + utxosThatWereFrozen.push(utxo.utxo) + } + } else { + return Api.Helper.throwError( + res, + freeze ? t('earn.fidelity_bond.error_freezing_utxos') : t('earn.fidelity_bond.error_unfreezing_utxos') + ) + } + }) + ) + + const abortCtrl = new AbortController() + return Promise.all(freezeCalls) + .then((_) => reloadCurrentWalletInfo({ signal: abortCtrl.signal })) + .then( + (_) => + freeze && + setFrozenUtxoIds((current) => { + const notIncluded = utxosThatWereFrozen.filter((utxo) => !current.includes(utxo)) + return [...current, ...notIncluded] + }) + ) + .finally(() => { + setIsLoading(false) + }) + } + + const nextStep = (currentStep: number) => { + if (currentStep === steps.selectJar) { + if (fidelityBond === undefined) return null + if (destinationJarIndex === undefined) return null + + const utxosAreFrozen = fb.utxo.allAreFrozen( + fb.utxo.utxosToFreeze(walletInfo.utxosByJar[fidelityBond.mixdepth], [fidelityBond]) + ) + + if (utxosAreFrozen && fidelityBond.frozen !== true) { + return steps.spendFidelityBond + } else { + return steps.freezeUtxos + } + } + + if (currentStep === steps.freezeUtxos) { + if (isLoading) return null + if (fidelityBond === undefined) return null + + const utxosAreFrozen = fb.utxo.allAreFrozen( + fb.utxo.utxosToFreeze(walletInfo.utxosByJar[fidelityBond.mixdepth], [fidelityBond]) + ) + + if (utxosAreFrozen && fidelityBond.frozen !== true) { + return steps.spendFidelityBond + } + + return steps.freezeUtxos + } + + if (currentStep === steps.spendFidelityBond) { + if (isLoading) return null + if (alert !== undefined) return steps.failed + if (fidelityBond !== undefined) return null + + if (frozenUtxoIds.length > 0) return steps.unfreezeUtxos + + return steps.done + } + + if (currentStep === steps.unfreezeUtxos) { + if (isLoading) { + return null + } + + return steps.done + } + + return null + } + + const primaryButtonText = (currentStep: number) => { + switch (currentStep) { + case steps.selectJar: + return t('global.next') + case steps.freezeUtxos: + if (fidelityBond === undefined) return 'Error' + const utxosAreFrozen = fb.utxo.allAreFrozen( + fb.utxo.utxosToFreeze(walletInfo.utxosByJar[fidelityBond.mixdepth], [fidelityBond]) + ) + + if (utxosAreFrozen && fidelityBond.frozen !== true) { + return t('global.next') + } else if (alert !== undefined) { + return t('global.retry') + } + + return t('global.freeze.utxos') + + case steps.spendFidelityBond: + if (alert !== undefined) { + return t('try again') + } + + return t('global.spend') + /*case steps.unfreezeUtxos: + return t('earn.fidelity_bond.unfreeze_utxos.text_primary_button')*/ + default: + return null + } + } + + const onPrimaryButtonClicked = () => { + const next = nextStep(step) + console.log(`current: ${step} -> next: ${next}`) + + if (step === steps.freezeUtxos) { + if (fidelityBond === undefined) return + const utxosToFreeze = fb.utxo.utxosToFreeze(walletInfo.utxosByJar[fidelityBond.mixdepth], [fidelityBond]) + const utxosAreFrozen = fb.utxo.allAreFrozen(utxosToFreeze) + + Promise.all([ + ...(!utxosAreFrozen ? [freezeUtxos(utxosToFreeze)] : []), + ...(fidelityBond.frozen === true ? [unfreezeUtxos([fidelityBond])] : []), + ]) + .then((_) => setAlert(undefined)) + .catch((err) => { + setAlert({ variant: 'danger', message: err.message, dismissible: false }) + }) + } + + if (step === steps.spendFidelityBond) { + if (fidelityBond === undefined) return + if (destinationJarIndex === undefined) return + if (isLoading) return + + const abortCtrl = new AbortController() + const { name: walletName, token } = wallet + const requestContext = { walletName, token, signal: abortCtrl.signal } + + setIsLoading(true) + Api.getAddressNew({ ...requestContext, mixdepth: destinationJarIndex }) + .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed')))) + .then((data) => data.address) + // spend fidelity bond (by sweeping whole jar) + .then((destination) => + Api.postDirectSend(requestContext, { + destination, + mixdepth: fidelityBond!.mixdepth, + amount_sats: 0, // sweep + }) + ) + .then((res) => { + // TODO: translate + if (!res.ok) throw Api.Helper.throwError(res, 'Error while spending fidelity bond') + return res.json() + }) + .then((data) => { + const inputs = data.txinfo.inputs as Array + setWaitForUtxosToBeSpent(inputs.map((it) => it.outpoint as Api.UtxoId)) + }) + // setShowConfirmInputsModal(true) + //return + } + + if (next === steps.unfreezeUtxos) { + //unfreezeUtxos(frozenUtxos) + } + + if (next === steps.failed) { + //reset() + return + } + + /*if (next === steps.done) { + //reset() + modalProps.onHide && modalProps.onHide() + return + }*/ + + if (next !== null) { + setStep(next) + } + } + + return ( + + + {t('settings.fees.title')} + + + {alert && setAlert(undefined)} />} + + {step} + + + +
+ + {t('settings.fees.text_button_cancel')} + + + {primaryButtonText(step)} + +
+
+
+ ) +} + +export { SpendFidelityBondModal } diff --git a/src/components/jar_details/JarDetailsOverlay.tsx b/src/components/jar_details/JarDetailsOverlay.tsx index e1056554f..661f717dd 100644 --- a/src/components/jar_details/JarDetailsOverlay.tsx +++ b/src/components/jar_details/JarDetailsOverlay.tsx @@ -144,8 +144,20 @@ const JarDetailsOverlay = (props: JarDetailsOverlayProps) => { [serviceInfo] ) + /** + * Always allow freezing UTXOs. + * Only allow unfreezing for non-timelocked UTXOs. + * + * Expired, unfrozen FBs cannot be used in taker or maker + * operation. Hence, unfreezing of FBs is forbidden in this + * component. The FB should be spent (unfreeze and sweep) + * via other mechanisms (_not_ in this component). + * + * @param utxo UTXO to check whether freez/unfreeze is allowed + * @returns true when UTXO can be frozen/unfrozen + */ const canBeFrozenOrUnfrozen = (utxo: Utxo) => { - const isUnfreezeEnabled = !fb.utxo.isLocked(utxo) + const isUnfreezeEnabled = !fb.utxo.isFidelityBond(utxo) const allowedToExecute = !utxo.frozen || isUnfreezeEnabled return allowedToExecute diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 5a73bfaac..ca288422a 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -432,7 +432,15 @@ "title_expired": "Expired Fidelity Bond", "label_locked_until": "Locked until", "label_expired_on": "Expired on", - "label_address": "Timelocked address" + "label_address": "Timelocked address", + "button_move_to_jar": "Move to Jar", + "move_to_jar": { + "title": "Move to Jar", + "select_jar": { + "description": "Select a jar to move the fidelity bond to." + }, + "button_move": "Move to jar" + } } } }, From c0bb83641c83c8c239f5927a634f9d5d528c9886 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Mon, 31 Oct 2022 17:57:15 +0100 Subject: [PATCH 03/28] refactor: remove most code and revert to simplest method --- src/components/Earn.jsx | 66 --- .../fb/SpendFidelityBond.module.css | 15 + src/components/fb/SpendFidelityBond.tsx | 446 ++++++------------ 3 files changed, 149 insertions(+), 378 deletions(-) create mode 100644 src/components/fb/SpendFidelityBond.module.css diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx index d3ea370a9..40893b85f 100644 --- a/src/components/Earn.jsx +++ b/src/components/Earn.jsx @@ -363,72 +363,6 @@ export default function Earn({ wallet }) { } } - const sendFidelityBondToJar = async (fidelityBond, destinationJarIndex) => { - if (isLoading || isSending || isWaitingMakerStart || isWaitingMakerStop) { - return - } - - setAlert(null) - - try { - const abortCtrl = new AbortController() - const { name: walletName, token } = wallet - const requestContext = { walletName, token, signal: abortCtrl.signal } - - const destination = await Api.getAddressNew({ ...requestContext, mixdepth: destinationJarIndex }) - .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed')))) - .then((data) => data.address) - - // reload utxos - const utxos = await Api.getWalletUtxos(requestContext) - .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res))) - .then((data) => data.utxos) - const utxosToFreeze = utxos.filter((it) => it.mixdepth === fidelityBond.mixdepth).filter((it) => !it.frozen) - - const utxosThatWereFrozen = [] - const freezeCalls = utxosToFreeze.map((utxo) => - Api.postFreeze(requestContext, { utxo: utxo.utxo, freeze: true }) - .then((res) => { - if (!res.ok) throw Api.Helper.throwError(res, t('earn.fidelity_bond.error_freezing_utxos')) - }) - .then((_) => utxosThatWereFrozen.push(utxo)) - ) - - try { - // freeze other coins - await Promise.all(freezeCalls) - // unfreeze fidelity bond - await Api.postFreeze(requestContext, { utxo: fidelityBond.utxo, freeze: false }).then((res) => { - // TODO: translate - if (!res.ok) throw Api.Helper.throwError(res, 'Error while unfreezing fidelity bond') - }) - // spend fidelity bond (by sweeping whole jar) - await Api.postDirectSend(requestContext, { - destination, - mixdepth: fidelityBond.mixdepth, - amount_sats: 0, // sweep - }).then((res) => { - // TODO: translate - if (!res.ok) throw Api.Helper.throwError(res, 'Error while spending fidelity bond') - }) - } finally { - // unfreeze all previously frozen coins - const unfreezeCalls = utxosThatWereFrozen.map((utxo) => - Api.postFreeze(requestContext, { utxo: utxo.utxo, freeze: false }) - ) - - try { - await Promise.all(unfreezeCalls) - } catch (e) { - // don't throw, just log, as we are in a finally block - console.error(e) - } - } - } catch (e) { - setAlert({ variant: 'danger', message: e.message }) - } - } - return (
diff --git a/src/components/fb/SpendFidelityBond.module.css b/src/components/fb/SpendFidelityBond.module.css new file mode 100644 index 000000000..d0ed42c7d --- /dev/null +++ b/src/components/fb/SpendFidelityBond.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/fb/SpendFidelityBond.tsx b/src/components/fb/SpendFidelityBond.tsx index 70a9ee6cb..d10f46ccd 100644 --- a/src/components/fb/SpendFidelityBond.tsx +++ b/src/components/fb/SpendFidelityBond.tsx @@ -1,127 +1,15 @@ import { useEffect, useMemo, useState } from 'react' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' -import { CurrentWallet, useReloadCurrentWalletInfo, Utxo, Utxos, WalletInfo } from '../../context/WalletContext' -import { Done, FreezeUtxos, SelectJar } from './FidelityBondSteps' -import * as fb from './utils' +import { CurrentWallet, useReloadCurrentWalletInfo, Utxos, WalletInfo } from '../../context/WalletContext' +import { SelectJar } from './FidelityBondSteps' import * as Api from '../../libs/JmWalletApi' import Alert from '../Alert' +import Sprite from '../Sprite' +import styles from './SpendFidelityBond.module.css' -const steps = { - selectJar: 1, - freezeUtxos: 2, - spendFidelityBond: 3, - unfreezeUtxos: 4, - done: 5, - failed: 6, -} - -interface SpendFidelityBondProps { - fidelityBond?: Utxo - walletInfo: WalletInfo - step: number - isLoading: boolean - selectedJar?: JarIndex - setSelectedJar: (jar: JarIndex) => void -} - -const SpendFidelityBond = ({ - fidelityBond, - walletInfo, - step, - isLoading, - selectedJar, - setSelectedJar, -}: SpendFidelityBondProps) => { - const { t } = useTranslation() - - const stepComponent = (currentStep: number) => { - switch (currentStep) { - case steps.selectJar: - return ( - true} - selectedJar={selectedJar} - onJarSelected={setSelectedJar} - /> - ) - case steps.freezeUtxos: - return ( - <> - {fidelityBond && ( - - )} - - ) - /*case steps.reviewInputs: - if (isLoading) { - return ( -
-
- ) - } - - if (timelockedAddress === null) { - return
{t('earn.fidelity_bond.error_loading_address')}
- } - - return ( - - ) - - case steps.createFidelityBond: - return isLoading ? ( -
-
- ) : ( -
- {alert === null ? ( - - ) : ( - <>{t('earn.fidelity_bond.error_creating_fidelity_bond')} - )} -
- )*/ - case steps.unfreezeUtxos: - return isLoading ? ( -
-
- ) : ( -
- {alert === null ? ( - - ) : ( - <>{t('earn.fidelity_bond.error_unfreezing_utxos')} - )} -
- ) - default: - return null - } - } - - return <>{stepComponent(step)} +type InputPartial = { + outpoint: Api.UtxoId } type SpendFidelityBondModalProps = { @@ -135,15 +23,14 @@ const SpendFidelityBondModal = ({ fidelityBondId, wallet, walletInfo, ...modalPr const { t } = useTranslation() const [alert, setAlert] = useState<(rb.AlertProps & { message: string }) | undefined>() - const [isLoading, setIsLoading] = useState(false) - const [step, setStep] = useState(steps.selectJar) - //const [nextStep, setNextStep] = useState() - const [destinationJarIndex, setDestinationJarIndex] = useState() - const [frozenUtxoIds, setFrozenUtxoIds] = useState([]) + const [successfulSendData, setSuccessfulSendData] = useState() const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([]) + const [isSending, setIsSending] = useState(false) + const isLoading = useMemo(() => isSending || waitForUtxosToBeSpent.length > 0, [isSending, waitForUtxosToBeSpent]) + const fidelityBond = useMemo(() => { return walletInfo.data.utxos.utxos.find((utxo) => utxo.utxo === fidelityBondId) }, [walletInfo, fidelityBondId]) @@ -190,205 +77,149 @@ const SpendFidelityBondModal = ({ fidelityBondId, wallet, walletInfo, ...modalPr } }, [waitForUtxosToBeSpent, reloadCurrentWalletInfo, t]) - const freezeUtxos = (utxos: Utxos) => { - return changeUtxoFreeze(utxos, true) - } + const onPrimaryButtonClicked = () => { + if (isLoading) return + if (destinationJarIndex === undefined) return + if (waitForUtxosToBeSpent.length > 0) return - const unfreezeUtxos = (utxos: Utxos) => { - return changeUtxoFreeze(utxos, false) - } + if (successfulSendData) { + modalProps.onHide && modalProps.onHide() + } else { + setAlert(undefined) + setIsSending(true) + sendFidelityBondToJar(destinationJarIndex) + .then((data) => { + setSuccessfulSendData(data) - const changeUtxoFreeze = (utxos: Utxos, freeze: boolean) => { - setIsLoading(true) + const inputs = data.txinfo.inputs as InputPartial[] + setWaitForUtxosToBeSpent(inputs.map((it) => it.outpoint as Api.UtxoId)) - let utxosThatWereFrozen: Api.UtxoId[] = [] + setIsSending(false) + }) + .catch((e) => { + setIsSending(false) - const { name: walletName, token } = wallet - const freezeCalls = utxos.map((utxo) => - Api.postFreeze({ walletName, token }, { utxo: utxo.utxo, freeze: freeze }).then((res) => { - if (res.ok) { - if (!utxo.frozen && freeze) { - utxosThatWereFrozen.push(utxo.utxo) - } - } else { - return Api.Helper.throwError( - res, - freeze ? t('earn.fidelity_bond.error_freezing_utxos') : t('earn.fidelity_bond.error_unfreezing_utxos') - ) - } - }) - ) + const message = e instanceof Error ? e.message : 'Unknown Error' + setAlert({ variant: 'danger', message }) + }) + } + } + + const sendFidelityBondToJar = async (targetJarIndex: JarIndex) => { + if (!fidelityBond) { + throw new Error('Precondition failed') + } const abortCtrl = new AbortController() - return Promise.all(freezeCalls) - .then((_) => reloadCurrentWalletInfo({ signal: abortCtrl.signal })) - .then( - (_) => - freeze && - setFrozenUtxoIds((current) => { - const notIncluded = utxosThatWereFrozen.filter((utxo) => !current.includes(utxo)) - return [...current, ...notIncluded] + const { name: walletName, token } = wallet + const requestContext = { walletName, token, signal: abortCtrl.signal } + + const destination = await Api.getAddressNew({ ...requestContext, mixdepth: targetJarIndex }) + .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed')))) + .then((data) => data.address as Api.BitcoinAddress) + + // reload utxos + const utxos = await Api.getWalletUtxos(requestContext) + .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res))) + .then((data) => data.utxos as Utxos) + const utxosToFreeze = utxos.filter((it) => it.mixdepth === fidelityBond.mixdepth).filter((it) => !it.frozen) + + const utxosThatWereFrozen: Api.UtxoId[] = [] + + try { + const freezeCalls = utxosToFreeze.map((utxo) => + Api.postFreeze(requestContext, { utxo: utxo.utxo, freeze: true }) + .then((res) => { + if (!res.ok) { + throw Api.Helper.throwError(res, t('earn.fidelity_bond.error_freezing_utxos')) + } }) + .then((_) => utxosThatWereFrozen.push(utxo.utxo)) ) - .finally(() => { - setIsLoading(false) + // freeze other coins + await Promise.all(freezeCalls) + + // unfreeze fidelity bond + await Api.postFreeze(requestContext, { utxo: fidelityBond.utxo, freeze: false }).then((res) => { + // TODO: translate + if (!res.ok) { + throw Api.Helper.throwError(res, 'Error while unfreezing fidelity bond') + } }) - } - - const nextStep = (currentStep: number) => { - if (currentStep === steps.selectJar) { - if (fidelityBond === undefined) return null - if (destinationJarIndex === undefined) return null - - const utxosAreFrozen = fb.utxo.allAreFrozen( - fb.utxo.utxosToFreeze(walletInfo.utxosByJar[fidelityBond.mixdepth], [fidelityBond]) - ) - - if (utxosAreFrozen && fidelityBond.frozen !== true) { - return steps.spendFidelityBond - } else { - return steps.freezeUtxos + // spend fidelity bond (by sweeping whole jar) + return await Api.postDirectSend(requestContext, { + destination, + mixdepth: fidelityBond.mixdepth, + amount_sats: 0, // sweep + }).then((res) => { + // TODO: translate + if (!res.ok) { + throw Api.Helper.throwError(res, 'Error while spending fidelity bond') + } + return res.json() + }) + } finally { + // unfreeze all previously frozen coins + const unfreezeCalls = utxosThatWereFrozen.map((utxo) => Api.postFreeze(requestContext, { utxo, freeze: false })) + + try { + await Promise.all(unfreezeCalls) + } catch (e) { + // don't throw, just log, as we are in a finally block + console.error(e) } } + } - if (currentStep === steps.freezeUtxos) { - if (isLoading) return null - if (fidelityBond === undefined) return null - - const utxosAreFrozen = fb.utxo.allAreFrozen( - fb.utxo.utxosToFreeze(walletInfo.utxosByJar[fidelityBond.mixdepth], [fidelityBond]) + const primaryButtonContent = () => { + if (isSending) { + return ( + <> +