From ddada6de033f9ee760671bd764d75a2e2b9809cc Mon Sep 17 00:00:00 2001 From: amitx13 Date: Sun, 2 Jun 2024 12:10:26 +0530 Subject: [PATCH 01/29] Implemented UTXO Selection Modal --- public/sprite.svg | 3 + src/components/Send/SendForm.tsx | 8 +- src/components/Send/ShowUtxos.module.css | 99 ++++++ src/components/Send/ShowUtxos.tsx | 293 ++++++++++++++++++ .../Send/SourceJarSelector.module.css | 1 + src/components/Send/SourceJarSelector.tsx | 72 +++-- src/components/Send/index.tsx | 1 + src/components/jar_details/UtxoList.tsx | 2 +- src/components/jars/Jar.tsx | 67 +++- src/i18n/locales/en/translation.json | 9 + 10 files changed, 534 insertions(+), 21 deletions(-) create mode 100644 src/components/Send/ShowUtxos.module.css create mode 100644 src/components/Send/ShowUtxos.tsx diff --git a/public/sprite.svg b/public/sprite.svg index 67f0b222f..6777e65d5 100644 --- a/public/sprite.svg +++ b/public/sprite.svg @@ -361,4 +361,7 @@ + + + diff --git a/src/components/Send/SendForm.tsx b/src/components/Send/SendForm.tsx index 7b16481f9..7eeaf130b 100644 --- a/src/components/Send/SendForm.tsx +++ b/src/components/Send/SendForm.tsx @@ -26,7 +26,7 @@ import { isValidNumCollaborators, } from './helpers' import { AccountBalanceSummary } from '../../context/BalanceSummary' -import { WalletInfo } from '../../context/WalletContext' +import { WalletInfo, CurrentWallet } from '../../context/WalletContext' import { useSettings } from '../../context/SettingsContext' import styles from './SendForm.module.css' import { TxFeeInputField, validateTxFee } from '../settings/TxFeeInputField' @@ -221,6 +221,7 @@ interface InnerSendFormProps { className?: string isLoading: boolean walletInfo?: WalletInfo + wallet: CurrentWallet loadNewWalletAddress: (props: { signal: AbortSignal; jarIndex: JarIndex }) => Promise minNumCollaborators: number feeConfigValues?: FeeValues @@ -233,6 +234,7 @@ const InnerSendForm = ({ className, isLoading, walletInfo, + wallet, loadNewWalletAddress, minNumCollaborators, feeConfigValues, @@ -272,6 +274,7 @@ const InnerSendForm = ({ name="sourceJarIndex" label={t('send.label_source_jar')} walletInfo={walletInfo} + wallet={wallet} isLoading={isLoading} disabled={disabled} variant={showCoinjoinPreconditionViolationAlert ? 'warning' : 'default'} @@ -375,6 +378,7 @@ type SendFormProps = Omit & { onSubmit: (values: SendFormValues) => Promise formRef?: React.Ref> blurred?: boolean + wallet: CurrentWallet } export const SendForm = ({ @@ -383,6 +387,7 @@ export const SendForm = ({ formRef, blurred = false, walletInfo, + wallet, minNumCollaborators, ...innerProps }: SendFormProps) => { @@ -446,6 +451,7 @@ export const SendForm = ({ props={props} className={blurred ? styles.blurred : undefined} walletInfo={walletInfo} + wallet={wallet} minNumCollaborators={minNumCollaborators} {...innerProps} /> diff --git a/src/components/Send/ShowUtxos.module.css b/src/components/Send/ShowUtxos.module.css new file mode 100644 index 000000000..d10bdef1f --- /dev/null +++ b/src/components/Send/ShowUtxos.module.css @@ -0,0 +1,99 @@ +/* ShowUtxos.module.css */ + +.utxoRowUnfrozen { + background-color: #27ae600d; + padding: 8px 30px; + height: 46px; + color: #27ae60; + margin-bottom: 4px; + cursor: pointer; +} + +.utxoRowFrozen { + background-color: #2d9cdb0d; + padding: 8px 30px; + height: 46px; + color: #2d9cdb; + margin-bottom: 4px; + cursor: pointer; +} + +.iconMixed { + color: #27ae60; +} + +.iconFrozen { + color: #2d9cdb; +} + +.iconConfirmations { + color: #27ae60; + margin-bottom: 4px; +} + +.iconConfirmationsFreeze { + color: #2d9cdb; + margin-bottom: 4px; +} + +.valueColumn { + margin-right: 20px; +} + +.subTitle { + color: #777777; + border: none; + margin-bottom: 1.5rem; +} + +.NextButton { + width: 47%; + height: 48px; + padding: 14px 20px 14px 20px; + margin-right: 19px; + border-radius: 5px; + opacity: 0px; +} + +.BackButton { + width: 47%; + height: 48px; + padding: 14px 20px 14px 20px; + margin-right: 19px; + border-radius: 5px; + opacity: 0px; +} + +.utxoTagUnFreeze { + white-space: nowrap; + border: 1px solid #27ae60; + background-color: #c6eed7; + border-radius: 0.35rem; + padding: 0rem 0.25rem; + display: inline-block; +} + +.utxoTagFreeze { + white-space: nowrap; + border: 1px solid #2d9cdb; + background-color: #bce7ff; + border-radius: 0.35rem; + padding: 0rem 0.25rem; + display: inline-block; +} + +.parent-class .utxoTag { + border: 1px solid #27ae60 !important; + border-radius: 0.2rem !important; +} + +.squareToggleButton { + height: 22px !important; + border-radius: 3px !important; +} + +.squareFrozenToggleButton { + height: 22px !important; + border-radius: 3px !important; + border: 1px solid #2d9cdb !important; +} diff --git a/src/components/Send/ShowUtxos.tsx b/src/components/Send/ShowUtxos.tsx new file mode 100644 index 000000000..18f4afdf6 --- /dev/null +++ b/src/components/Send/ShowUtxos.tsx @@ -0,0 +1,293 @@ +import { useState, useEffect, useCallback } from 'react' +import * as rb from 'react-bootstrap' +import { WalletInfo, CurrentWallet, useReloadCurrentWalletInfo } from '../../context/WalletContext' +import Sprite from '../Sprite' +import Alert from '../Alert' +import { useTranslation } from 'react-i18next' +import { TFunction } from 'i18next' +import * as Api from '../../libs/JmWalletApi' +import { utxoTags } from '../jar_details/UtxoList' +import mainStyles from '../MainWalletView.module.css' +import styles from './ShowUtxos.module.css' + +type UtxoType = { + address: Api.BitcoinAddress + path: string + label: string + checked: boolean + value: Api.AmountSats + tries: number + tries_remaining: number + external: boolean + mixdepth: number + confirmations: number + frozen: boolean + utxo: Api.UtxoId + locktime?: string +} + +type UtxoList = UtxoType[] + +interface SignModalProps { + walletInfo: WalletInfo + wallet: CurrentWallet + show: boolean + onHide: () => void + index: String +} + +interface UtxoRowProps { + utxo: UtxoType + index: number + onToggle: (index: number, type: 'frozen' | 'unfrozen') => void + walletInfo: WalletInfo + t: TFunction + isFrozen: boolean +} + +interface UtxoListDisplayProps { + utxos: UtxoList + onToggle: (index: number, type: 'frozen' | 'unfrozen') => void + walletInfo: WalletInfo + t: TFunction + isFrozen: boolean +} + +// Utility function to format Bitcoin address +const formatAddress = (address: string) => `${address.slice(0, 10)}...${address.slice(-8)}` + +// Utility function to format the confirmations +const formatConfirmations = (conf: number) => { + if (conf === 0) return { symbol: 'confs-0', confirmations: conf } + if (conf === 1) return { symbol: 'confs-1', confirmations: conf } + if (conf === 2) return { symbol: 'confs-2', confirmations: conf } + if (conf === 3) return { symbol: 'confs-3', confirmations: conf } + if (conf === 4) return { symbol: 'confs-4', confirmations: conf } + if (conf === 5) return { symbol: 'confs-5', confirmations: conf } + if (conf >= 9999) return { symbol: 'confs-full', confirmations: '9999+' } + return { symbol: 'confs-full', confirmations: conf } +} + +// Utility function to convert Satoshi to Bitcoin +const satsToBtc = (sats: number) => (sats / 100000000).toFixed(8) + +const UtxoRow = ({ utxo, index, onToggle, walletInfo, t, isFrozen }: UtxoRowProps) => { + const address = formatAddress(utxo.address) + const conf = formatConfirmations(utxo.confirmations) + const value = satsToBtc(utxo.value) + const tags = utxoTags(utxo, walletInfo, t) + const rowClass = isFrozen ? styles.utxoRowFrozen : styles.utxoRowUnfrozen + const icon = isFrozen ? 'snowflake' : 'mixed' + const tagClass = isFrozen ? styles.utxoTagFreeze : styles.utxoTagUnFreeze + + return ( + onToggle(index, isFrozen ? 'frozen' : 'unfrozen')} className={rowClass}> + + onToggle(index, isFrozen ? 'frozen' : 'unfrozen')} + className={isFrozen ? styles.squareFrozenToggleButton : styles.squareToggleButton} + /> + + + + + {address} + + + {conf.confirmations} + + {`₿${value}`} + +
{tags[0].tag}
+
+
+ ) +} + +const UtxoListDisplay = ({ utxos, onToggle, walletInfo, t, isFrozen }: UtxoListDisplayProps) => ( +
+ {utxos.map((utxo, index) => ( + + ))} +
+) + +const ShowUtxos = ({ walletInfo, wallet, show, onHide, index }: SignModalProps) => { + const abortCtrl = new AbortController() + + const { t } = useTranslation() + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + + const [alert, setAlert] = useState() + const [showFrozenUtxos, setShowFrozenUtxos] = useState(false) + const [unFrozenUtxos, setUnFrozenUtxos] = useState([]) + const [frozenUtxos, setFrozenUtxos] = useState([]) + + // Effect to load UTXO data when component mounts or index/walletInfo changes + useEffect(() => { + const loadData = () => { + const data = Object.entries(walletInfo.utxosByJar).find(([key]) => key === index) + const utxos = data ? data[1] : [] + + const frozen = utxos.filter((utxo) => utxo.frozen).map((utxo) => ({ ...utxo, checked: false })) + const unfrozen = utxos.filter((utxo) => !utxo.frozen).map((utxo) => ({ ...utxo, checked: true })) + + setFrozenUtxos(frozen) + setUnFrozenUtxos(unfrozen) + + if (utxos && unfrozen.length === 0) { + setAlert({ variant: 'danger', message: t('showUtxos.alert_for_empty_utxos'), dismissible: true }) + } else { + setAlert(undefined) + } + } + + loadData() + }, [index, walletInfo.utxosByJar, t]) + + // Handler to toggle UTXO selection + const handleToggle = useCallback((utxoIndex: number, type: 'frozen' | 'unfrozen') => { + if (type === 'unfrozen') { + setUnFrozenUtxos((prevUtxos) => + prevUtxos.map((utxo, i) => (i === utxoIndex ? { ...utxo, checked: !utxo.checked } : utxo)), + ) + } else { + setFrozenUtxos((prevUtxos) => + prevUtxos.map((utxo, i) => (i === utxoIndex ? { ...utxo, checked: !utxo.checked } : utxo)), + ) + } + }, []) + + // Handler for the "Next" button click + const handleNext = async () => { + const frozenUtxosToUpdate = frozenUtxos + .filter((utxo) => utxo.checked && !utxo.locktime) + .map((utxo) => ({ utxo: utxo.utxo, freeze: false })) + const unFrozenUtxosToUpdate = unFrozenUtxos + .filter((utxo) => !utxo.checked) + .map((utxo) => ({ utxo: utxo.utxo, freeze: true })) + + for (const utxo of frozenUtxos) { + if (utxo.checked && utxo.locktime) { + setAlert({ + variant: 'danger', + message: `${t('showUtxos.alert_for_time_locked')} ${utxo.locktime}`, + dismissible: true, + }) + return + } + } + + if (frozenUtxosToUpdate.length >= 1) { + try { + const freezeCalls = frozenUtxosToUpdate.map((utxo) => + Api.postFreeze({ ...wallet, signal: abortCtrl.signal }, { utxo: utxo.utxo, freeze: utxo.freeze }).then( + (res) => { + if (!res.ok) { + return Api.Helper.throwError(res) + } + }, + ), + ) + + await Promise.all(freezeCalls) + } catch (err: any) { + if (!abortCtrl.signal.aborted) { + setAlert({ variant: 'danger', message: err.message, dismissible: true }) + } + return + } + } + + const uncheckedUnfrozen = unFrozenUtxos.filter((utxo) => !utxo.checked) + if (uncheckedUnfrozen.length === unFrozenUtxos.length && frozenUtxosToUpdate.length === 0) { + setAlert({ variant: 'danger', message: t('showUtxos.alert_for_unfreeze_utxos'), dismissible: true }) + return + } + + try { + const unfreezeCalls = unFrozenUtxosToUpdate.map((utxo) => + Api.postFreeze({ ...wallet, signal: abortCtrl.signal }, { utxo: utxo.utxo, freeze: utxo.freeze }).then( + (res) => { + if (!res.ok) { + return Api.Helper.throwError(res) + } + }, + ), + ) + + await Promise.all(unfreezeCalls) + } catch (err: any) { + if (!abortCtrl.signal.aborted) { + setAlert({ variant: 'danger', message: err.message, dismissible: true }) + } + } + + await reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }) + onHide() + } + + return ( + + + {t('showUtxos.show_utxo_title')} + + +
{t('showUtxos.show_utxo_subtitle')}
+ {alert && ( + + setAlert(undefined)} + /> + + )} + + + +
+
+ +
+
+
+
+ {showFrozenUtxos && ( + + )} +
+ + + {t('showUtxos.back_button')} + + + {t('showUtxos.next_button')} + + +
+ ) +} + +export default ShowUtxos diff --git a/src/components/Send/SourceJarSelector.module.css b/src/components/Send/SourceJarSelector.module.css index 74f910d85..9dc43eea7 100644 --- a/src/components/Send/SourceJarSelector.module.css +++ b/src/components/Send/SourceJarSelector.module.css @@ -7,6 +7,7 @@ gap: 1rem; color: var(--bs-body-color); margin-bottom: 1.5rem; + margin-top: 2rem; } .sourceJarsPlaceholder { diff --git a/src/components/Send/SourceJarSelector.tsx b/src/components/Send/SourceJarSelector.tsx index 3f77374ae..952c54085 100644 --- a/src/components/Send/SourceJarSelector.tsx +++ b/src/components/Send/SourceJarSelector.tsx @@ -1,10 +1,11 @@ -import { useMemo } from 'react' +import { useState, useMemo } from 'react' import { useField, useFormikContext } from 'formik' import * as rb from 'react-bootstrap' -import { jarFillLevel, SelectableJar } from '../jars/Jar' +import { jarFillLevel, SelectableSendJar } from '../jars/Jar' import { noop } from '../../utils' -import { WalletInfo } from '../../context/WalletContext' +import { WalletInfo, CurrentWallet } from '../../context/WalletContext' import styles from './SourceJarSelector.module.css' +import ShowUtxos from './ShowUtxos' export type SourceJarSelectorProps = { name: string @@ -12,20 +13,31 @@ export type SourceJarSelectorProps = { className?: string variant: 'default' | 'warning' walletInfo?: WalletInfo + wallet: CurrentWallet isLoading: boolean disabled?: boolean } +interface showingUtxosProps { + index: String + show: boolean +} + export const SourceJarSelector = ({ name, label, walletInfo, + wallet, variant, isLoading, disabled = false, }: SourceJarSelectorProps) => { const [field] = useField(name) const form = useFormikContext() + const [showingUTXOS, setshowingUTXOS] = useState({ + index: '', + show: false, + }) const jarBalances = useMemo(() => { if (!walletInfo) return [] @@ -44,22 +56,46 @@ export const SourceJarSelector = ({ ) : (
- {jarBalances.map((it) => ( - 0} - isSelected={it.accountIndex === field.value} - fillLevel={jarFillLevel( - it.calculatedTotalBalanceInSats, - walletInfo.balanceSummary.calculatedTotalBalanceInSats, - )} - variant={it.accountIndex === field.value ? variant : undefined} - onClick={(jarIndex) => form.setFieldValue(field.name, jarIndex, true)} + {showingUTXOS.show && ( + { + setshowingUTXOS({ + index: '', + show: false, + }) + }} + index={showingUTXOS.index} /> - ))} + )} + {jarBalances.map((it) => { + return ( +
+ 0} + isSelected={it.accountIndex === field.value} + fillLevel={jarFillLevel( + it.calculatedTotalBalanceInSats, + walletInfo.balanceSummary.calculatedTotalBalanceInSats, + )} + variant={it.accountIndex === field.value ? variant : undefined} + onClick={(jarIndex) => { + form.setFieldValue(field.name, jarIndex, true) + setshowingUTXOS({ + index: jarIndex.toString(), + show: true, + }) + }} + /> +
+ ) + })}
)} diff --git a/src/components/Send/index.tsx b/src/components/Send/index.tsx index 3fa33fd5d..e5f920062 100644 --- a/src/components/Send/index.tsx +++ b/src/components/Send/index.tsx @@ -480,6 +480,7 @@ export default function Send({ wallet }: SendProps) { disabled={isOperationDisabled} isLoading={isLoading} walletInfo={walletInfo} + wallet={wallet} minNumCollaborators={minNumCollaborators} loadNewWalletAddress={loadNewWalletAddress} feeConfigValues={feeConfigValues} diff --git a/src/components/jar_details/UtxoList.tsx b/src/components/jar_details/UtxoList.tsx index 32219a307..e782f0800 100644 --- a/src/components/jar_details/UtxoList.tsx +++ b/src/components/jar_details/UtxoList.tsx @@ -37,7 +37,7 @@ const ADDRESS_STATUS_COLORS: { [key: string]: string } = { type Tag = { tag: string; color: string } -const utxoTags = (utxo: Utxo, walletInfo: WalletInfo, t: TFunction): Tag[] => { +export const utxoTags = (utxo: Utxo, walletInfo: WalletInfo, t: TFunction): Tag[] => { const rawStatus = walletInfo.addressSummary[utxo.address]?.status let status: string | null = null diff --git a/src/components/jars/Jar.tsx b/src/components/jars/Jar.tsx index 7da0271ac..bf97fd26f 100644 --- a/src/components/jars/Jar.tsx +++ b/src/components/jars/Jar.tsx @@ -27,6 +27,14 @@ export type SelectableJarProps = JarProps & { onClick: (index: JarIndex) => void } +export type SelectableSendJarProps = JarProps & { + tooltipText: string + isSelectable: boolean + isSelected: boolean + variant?: 'default' | 'warning' + onClick: (index: JarIndex) => void +} + export type OpenableJarProps = Omit & { tooltipText: string onClick: () => void @@ -225,4 +233,61 @@ const OpenableJar = ({ tooltipText, onClick, ...jarProps }: OpenableJarProps) => ) } -export { SelectableJar, OpenableJar, jarName, jarInitial, jarFillLevel } +const SelectableSendJar = ({ + tooltipText, + isSelectable, + isSelected, + onClick, + index, + variant = 'default', + ...jarProps +}: SelectableSendJarProps) => { + const [jarIsOpen, setJarIsOpen] = useState(false) + const onMouseOver = () => setJarIsOpen(true) + const onMouseOut = () => setJarIsOpen(false) + + return ( +
isSelectable && onClick(index)} onMouseOver={onMouseOver} onMouseOut={onMouseOut}> + { + return isSelectable ? {tooltipText} : <> + }} + > +
+ +
+ isSelectable && onClick(index)} + className={styles.selectionCircle} + disabled={!isSelectable} + /> + {variant === 'warning' && ( +
+ +
+ )} +
+
+
+
+ ) +} + +export { SelectableSendJar, SelectableJar, OpenableJar, jarName, jarInitial, jarFillLevel } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 18e909ef0..dbe9c304e 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -698,5 +698,14 @@ "utxo_detail_label_locktime": "Locktime", "utxo_detail_label_status": "Address status" } + }, + "showUtxos": { + "show_utxo_title": "Select UTXOS to send", + "show_utxo_subtitle": "The following UTXOs are selected to be sent. Modify if needed.", + "alert_for_unfreeze_utxos": "At least one UTXO is required to perform a transaction", + "alert_for_time_locked": "Selected UTXO is Time Locked till", + "alert_for_empty_utxos": "Please Unfreeze UTXOs to send", + "back_button": "Back", + "next_button": "Next" } } From 4c5be8fb758eba7f9cdd50b1e49e11ffd774454c Mon Sep 17 00:00:00 2001 From: amitx13 Date: Mon, 3 Jun 2024 21:01:02 +0530 Subject: [PATCH 02/29] Done with the suggested changes in ShowUtxos --- src/components/Send/ShowUtxos.module.css | 5 ++ src/components/Send/ShowUtxos.tsx | 86 +++++++----------------- 2 files changed, 31 insertions(+), 60 deletions(-) diff --git a/src/components/Send/ShowUtxos.module.css b/src/components/Send/ShowUtxos.module.css index d10bdef1f..25720d45c 100644 --- a/src/components/Send/ShowUtxos.module.css +++ b/src/components/Send/ShowUtxos.module.css @@ -97,3 +97,8 @@ border-radius: 3px !important; border: 1px solid #2d9cdb !important; } + +.utxoListDisplay { + margin-left: -20px; + margin-right: 20px; +} diff --git a/src/components/Send/ShowUtxos.tsx b/src/components/Send/ShowUtxos.tsx index 18f4afdf6..8f53b79b7 100644 --- a/src/components/Send/ShowUtxos.tsx +++ b/src/components/Send/ShowUtxos.tsx @@ -115,7 +115,7 @@ const UtxoRow = ({ utxo, index, onToggle, walletInfo, t, isFrozen }: UtxoRowProp } const UtxoListDisplay = ({ utxos, onToggle, walletInfo, t, isFrozen }: UtxoListDisplayProps) => ( -
+
{utxos.map((utxo, index) => ( { + const frozenUtxosToUpdate = frozenUtxos.filter((utxo) => utxo.checked && !utxo.locktime) + const timeLockedUtxo = frozenUtxos.find((utxo) => utxo.checked && utxo.locktime) + const noUnfrozenUtxos = unFrozenUtxos.length === 0 + const allUnfrozenUnchecked = unFrozenUtxos.every((utxo) => !utxo.checked) + + if (timeLockedUtxo) { + setAlert({ variant: 'danger', message: `${t('showUtxos.alert_for_time_locked')} ${timeLockedUtxo.locktime}` }) + } else if (noUnfrozenUtxos) { + setAlert({ variant: 'danger', message: t('showUtxos.alert_for_empty_utxos') }) + } else if (allUnfrozenUnchecked && frozenUtxosToUpdate.length === 0) { + setAlert({ variant: 'warning', message: t('showUtxos.alert_for_unfreeze_utxos'), dismissible: true }) + } else { + setAlert(undefined) + } + }, [unFrozenUtxos, frozenUtxos, t]) + // Handler to toggle UTXO selection const handleToggle = useCallback((utxoIndex: number, type: 'frozen' | 'unfrozen') => { if (type === 'unfrozen') { @@ -185,64 +197,18 @@ const ShowUtxos = ({ walletInfo, wallet, show, onHide, index }: SignModalProps) .filter((utxo) => !utxo.checked) .map((utxo) => ({ utxo: utxo.utxo, freeze: true })) - for (const utxo of frozenUtxos) { - if (utxo.checked && utxo.locktime) { - setAlert({ - variant: 'danger', - message: `${t('showUtxos.alert_for_time_locked')} ${utxo.locktime}`, - dismissible: true, - }) - return - } - } - - if (frozenUtxosToUpdate.length >= 1) { - try { - const freezeCalls = frozenUtxosToUpdate.map((utxo) => - Api.postFreeze({ ...wallet, signal: abortCtrl.signal }, { utxo: utxo.utxo, freeze: utxo.freeze }).then( - (res) => { - if (!res.ok) { - return Api.Helper.throwError(res) - } - }, - ), - ) - - await Promise.all(freezeCalls) - } catch (err: any) { - if (!abortCtrl.signal.aborted) { - setAlert({ variant: 'danger', message: err.message, dismissible: true }) - } - return - } - } - - const uncheckedUnfrozen = unFrozenUtxos.filter((utxo) => !utxo.checked) - if (uncheckedUnfrozen.length === unFrozenUtxos.length && frozenUtxosToUpdate.length === 0) { - setAlert({ variant: 'danger', message: t('showUtxos.alert_for_unfreeze_utxos'), dismissible: true }) - return - } - try { - const unfreezeCalls = unFrozenUtxosToUpdate.map((utxo) => - Api.postFreeze({ ...wallet, signal: abortCtrl.signal }, { utxo: utxo.utxo, freeze: utxo.freeze }).then( - (res) => { - if (!res.ok) { - return Api.Helper.throwError(res) - } - }, - ), - ) - - await Promise.all(unfreezeCalls) + await Promise.all([ + ...frozenUtxosToUpdate.map((utxo) => Api.postFreeze({ ...wallet, signal: abortCtrl.signal }, utxo)), + ...unFrozenUtxosToUpdate.map((utxo) => Api.postFreeze({ ...wallet, signal: abortCtrl.signal }, utxo)), + ]) + await reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }) + onHide() } catch (err: any) { if (!abortCtrl.signal.aborted) { setAlert({ variant: 'danger', message: err.message, dismissible: true }) } } - - await reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }) - onHide() } return ( @@ -282,7 +248,7 @@ const ShowUtxos = ({ walletInfo, wallet, show, onHide, index }: SignModalProps) {t('showUtxos.back_button')} - + {t('showUtxos.next_button')} From 162d89e0406c2bbc9343bbaa161c4d55f78f1749 Mon Sep 17 00:00:00 2001 From: amitx13 Date: Mon, 3 Jun 2024 21:42:57 +0530 Subject: [PATCH 03/29] minor bug fixing --- src/components/Send/ShowUtxos.module.css | 7 +-- src/components/Send/ShowUtxos.tsx | 67 ++++++++++++++---------- src/components/jars/Jar.tsx | 8 +-- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/components/Send/ShowUtxos.module.css b/src/components/Send/ShowUtxos.module.css index 25720d45c..12fa86c08 100644 --- a/src/components/Send/ShowUtxos.module.css +++ b/src/components/Send/ShowUtxos.module.css @@ -48,20 +48,17 @@ .NextButton { width: 47%; - height: 48px; + height: 3.15rem; padding: 14px 20px 14px 20px; - margin-right: 19px; border-radius: 5px; - opacity: 0px; } .BackButton { width: 47%; - height: 48px; + height: 3.15rem; padding: 14px 20px 14px 20px; margin-right: 19px; border-radius: 5px; - opacity: 0px; } .utxoTagUnFreeze { diff --git a/src/components/Send/ShowUtxos.tsx b/src/components/Send/ShowUtxos.tsx index 8f53b79b7..b3e660808 100644 --- a/src/components/Send/ShowUtxos.tsx +++ b/src/components/Send/ShowUtxos.tsx @@ -82,40 +82,49 @@ const UtxoRow = ({ utxo, index, onToggle, walletInfo, t, isFrozen }: UtxoRowProp return ( onToggle(index, isFrozen ? 'frozen' : 'unfrozen')} className={rowClass}> - - onToggle(index, isFrozen ? 'frozen' : 'unfrozen')} - className={isFrozen ? styles.squareFrozenToggleButton : styles.squareToggleButton} - /> - - - - - {address} - - - {conf.confirmations} - - {`₿${value}`} - -
{tags[0].tag}
-
+
+ + + onToggle(index, isFrozen ? 'frozen' : 'unfrozen')} + className={isFrozen ? styles.squareFrozenToggleButton : styles.squareToggleButton} + /> + + + + + {address} + + + {conf.confirmations} + + {`₿${value}`} + +
{tags[0].tag}
+
+
+
) } const UtxoListDisplay = ({ utxos, onToggle, walletInfo, t, isFrozen }: UtxoListDisplayProps) => ( -
+
{utxos.map((utxo, index) => (
- isSelectable && onClick(index)} - className={styles.selectionCircle} - disabled={!isSelectable} - /> + {variant === 'warning' && (
From 08e1355a9bcedc4c1480ae577d902cc05b656050 Mon Sep 17 00:00:00 2001 From: barrytra Date: Fri, 7 Jun 2024 22:36:37 +0530 Subject: [PATCH 04/29] added spend file --- src/components/fb/spendFidelityBondModal.tsx | 751 +++++++++++++++++++ 1 file changed, 751 insertions(+) create mode 100644 src/components/fb/spendFidelityBondModal.tsx diff --git a/src/components/fb/spendFidelityBondModal.tsx b/src/components/fb/spendFidelityBondModal.tsx new file mode 100644 index 000000000..d9f92681e --- /dev/null +++ b/src/components/fb/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 ( + <> +