Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: renew fidelity bond #678

Merged
merged 8 commits into from
Feb 6, 2024
48 changes: 35 additions & 13 deletions src/components/Earn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import PageTitle from './PageTitle'
import SegmentedTabs from './SegmentedTabs'
import { CreateFidelityBond } from './fb/CreateFidelityBond'
import { ExistingFidelityBond } from './fb/ExistingFidelityBond'
import { SpendFidelityBondModal } from './fb/SpendFidelityBondModal'
import { RenewFidelityBondModal, SpendFidelityBondModal } from './fb/SpendFidelityBondModal'
import { EarnReportOverlay } from './EarnReport'
import { OrderbookOverlay } from './Orderbook'
import Balance from './Balance'
Expand Down Expand Up @@ -424,6 +424,7 @@ export default function Earn({ wallet }: EarnProps) {
}, [currentWalletInfo])

const [moveToJarFidelityBondId, setMoveToJarFidelityBondId] = useState<Api.UtxoId>()
const [renewFidelityBondId, setRenewFidelityBondId] = useState<Api.UtxoId>()

const startMakerService = useCallback(
(values: EarnFormValues) => {
Expand Down Expand Up @@ -619,6 +620,20 @@ export default function Earn({ wallet }: EarnProps) {
}}
/>
)}
{currentWalletInfo && renewFidelityBondId && (
<RenewFidelityBondModal
show={true}
fidelityBondId={renewFidelityBondId}
wallet={wallet}
walletInfo={currentWalletInfo}
onClose={({ mustReload }) => {
setRenewFidelityBondId(undefined)
if (mustReload) {
reloadFidelityBonds({ delay: 0 })
}
}}
/>
)}
{fidelityBonds.map((fidelityBond, index) => {
const isExpired = !fb.utxo.isLocked(fidelityBond)
const actionsEnabled =
Expand All @@ -633,18 +648,25 @@ export default function Earn({ wallet }: EarnProps) {
return (
<ExistingFidelityBond key={index} fidelityBond={fidelityBond}>
{actionsEnabled && (
<div className="mt-4">
<div className="">
<rb.Button
variant={settings.theme === 'dark' ? 'light' : 'dark'}
className="w-50 d-flex justify-content-center align-items-center"
disabled={moveToJarFidelityBondId !== undefined}
onClick={() => setMoveToJarFidelityBondId(fidelityBond.utxo)}
>
<Sprite className="me-1 mb-1" symbol="unlock" width="24" height="24" />
{t('earn.fidelity_bond.existing.button_spend')}
</rb.Button>
</div>
<div className="mt-4 d-flex gap-2">
<rb.Button
variant={settings.theme === 'dark' ? 'light' : 'dark'}
className="w-100 d-flex justify-content-center align-items-center"
disabled={moveToJarFidelityBondId !== undefined}
onClick={() => setMoveToJarFidelityBondId(fidelityBond.utxo)}
>
<Sprite className="me-1 mb-1" symbol="unlock" width="24" height="24" />
{t('earn.fidelity_bond.existing.button_spend')}
</rb.Button>
<rb.Button
variant={settings.theme === 'dark' ? 'light' : 'dark'}
className="w-100 d-flex justify-content-center align-items-center"
disabled={renewFidelityBondId !== undefined}
onClick={() => setRenewFidelityBondId(fidelityBond.utxo)}
>
<Sprite className="me-1" symbol="refresh" width="24" height="24" />
{t('earn.fidelity_bond.existing.button_renew')}
</rb.Button>
</div>
)}
</ExistingFidelityBond>
Expand Down
16 changes: 11 additions & 5 deletions src/components/PaymentConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { useMemo } from 'react'
import { PropsWithChildren, useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import * as rb from 'react-bootstrap'
import Sprite from './Sprite'
import Balance from './Balance'
import { useSettings } from '../context/SettingsContext'
import { FeeValues, TxFee, useEstimatedMaxCollaboratorFee } from '../hooks/Fees'
import { ConfirmModal, ConfirmModalProps } from './Modal'
import styles from './PaymentConfirmModal.module.css'
import { AmountSats } from '../libs/JmWalletApi'
import { AmountSats, BitcoinAddress } from '../libs/JmWalletApi'
import { jarInitial } from './jars/Jar'
import { isValidNumber } from '../utils'
import styles from './PaymentConfirmModal.module.css'

const feeRange: (txFee: TxFee, txFeeFactor: number) => [number, number] = (txFee, txFeeFactor) => {
if (txFee.unit !== 'sats/kilo-vbyte') {
Expand Down Expand Up @@ -57,7 +57,7 @@ const useMiningFeeText = ({ tx_fees, tx_fees_factor }: Pick<FeeValues, 'tx_fees'

interface PaymentDisplayInfo {
sourceJarIndex?: JarIndex
destination: String
destination: BitcoinAddress | string
amount: AmountSats
isSweep: boolean
isCoinjoin: boolean
Expand All @@ -81,8 +81,9 @@ export function PaymentConfirmModal({
feeConfigValues,
showPrivacyInfo = true,
},
children,
...confirmModalProps
}: PaymentConfirmModalProps) {
}: PropsWithChildren<PaymentConfirmModalProps>) {
const { t } = useTranslation()
const settings = useSettings()

Expand Down Expand Up @@ -206,6 +207,11 @@ export function PaymentConfirmModal({
</rb.Col>
</rb.Row>
)}
{children && (
<rb.Row>
<rb.Col xs={12}>{children}</rb.Col>
</rb.Row>
)}
</rb.Container>
</ConfirmModal>
)
Expand Down
68 changes: 19 additions & 49 deletions src/components/Send/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ import * as rb from 'react-bootstrap'
import * as Api from '../../libs/JmWalletApi'
import PageTitle from '../PageTitle'
import Sprite from '../Sprite'
import { SendForm, SendFormValues } from './SendForm'
import { ConfirmModal } from '../Modal'
import { scrollToTop } from '../../utils'
import { PaymentConfirmModal } from '../PaymentConfirmModal'
import FeeConfigModal, { FeeConfigSectionKey } from '../settings/FeeConfigModal'
import { FeeValues, TxFee, useFeeConfigValues } from '../../hooks/Fees'
import { useReloadCurrentWalletInfo, useCurrentWalletInfo, CurrentWallet } from '../../context/WalletContext'
import { useServiceInfo, useReloadServiceInfo } from '../../context/ServiceInfoContext'
import { useLoadConfigValue } from '../../context/ServiceConfigContext'
import { useWaitForUtxosToBeSpent } from '../../hooks/WaitForUtxosToBeSpent'
import { routes } from '../../constants/routes'
import { JM_MINIMUM_MAKERS_DEFAULT } from '../../constants/config'
import { scrollToTop } from '../../utils'

import { initialNumCollaborators } from './helpers'
import { SendForm, SendFormValues } from './SendForm'

const INITIAL_DESTINATION = null
const INITIAL_SOURCE_JAR_INDEX = null
Expand Down Expand Up @@ -105,7 +107,7 @@ export default function Send({ wallet }: SendProps) {
[feeConfigValues],
)

const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([])
const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState<Api.UtxoId[]>([])
const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState<SimpleAlert>()

const isOperationDisabled = useMemo(
Expand Down Expand Up @@ -160,54 +162,22 @@ export default function Send({ wallet }: SendProps) {
[wallet, setAlert, t],
)

// This callback is responsible for updating `waitForUtxosToBeSpent` 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(
function updateWaitForUtxosToBeSpentHook() {
if (waitForUtxosToBeSpent.length === 0) return

const abortCtrl = new AbortController()

// Delaying the poll requests gives the wallet some time to synchronize
// the utxo set and reduces amount of http requests
const initialDelayInMs = 250
const timer = setTimeout(() => {
if (abortCtrl.signal.aborted) return

reloadCurrentWalletInfo
.reloadUtxos({ signal: abortCtrl.signal })
.then((res) => {
if (abortCtrl.signal.aborted) return
const outputs = res.utxos.map((it) => it.utxo)
const utxosStillPresent = waitForUtxosToBeSpent.filter((it) => outputs.includes(it))
setWaitForUtxosToBeSpent([...utxosStillPresent])
})

.catch((err) => {
if (abortCtrl.signal.aborted) return

// Stop waiting for wallet synchronization on errors, but inform
// the user that loading the wallet info failed
setWaitForUtxosToBeSpent([])

const message = t('global.errors.error_reloading_wallet_failed', {
reason: err.message || t('global.errors.reason_unknown'),
})
setAlert({ variant: 'danger', message })
})
}, initialDelayInMs)

return () => {
abortCtrl.abort()
clearTimeout(timer)
}
},
[waitForUtxosToBeSpent, reloadCurrentWalletInfo, t],
const 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)

useEffect(
function initialize() {
if (isOperationDisabled) {
Expand Down
84 changes: 58 additions & 26 deletions src/components/fb/CreateFidelityBond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Trans, useTranslation } from 'react-i18next'
import { CurrentWallet, Utxo, Utxos, WalletInfo, useReloadCurrentWalletInfo } from '../../context/WalletContext'
import Alert from '../Alert'
import Sprite from '../Sprite'
import { ConfirmModal } from '../Modal'
import {
SelectJar,
SelectUtxos,
Expand All @@ -18,9 +17,33 @@ import {
import * as fb from './utils'
import { isDebugFeatureEnabled } from '../../constants/debugFeatures'
import styles from './CreateFidelityBond.module.css'
import { PaymentConfirmModal } from '../PaymentConfirmModal'
import { useFeeConfigValues } from '../../hooks/Fees'

const TIMEOUT_RELOAD_UTXOS_AFTER_FB_CREATE_MS = 2_500

export const LockInfoAlert = ({ lockDate, className }: { lockDate: Api.Lockdate; className?: string }) => {
const { t, i18n } = useTranslation()

return (
<Alert
className={className}
variant="warning"
message={
<>
{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,
Expand All @@ -41,8 +64,9 @@ interface CreateFidelityBondProps {
}

const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDone }: CreateFidelityBondProps) => {
const { t } = useTranslation()
const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
const { t, i18n } = useTranslation()
const feeConfigValues = useFeeConfigValues()[0]

const [isExpanded, setIsExpanded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
Expand All @@ -53,17 +77,19 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
const [lockDate, setLockDate] = useState<Api.Lockdate | null>(null)
const [selectedJar, setSelectedJar] = useState<JarIndex>()
const [selectedUtxos, setSelectedUtxos] = useState<Utxos>([])
const [timelockedAddress, setTimelockedAddress] = useState(null)
const [timelockedAddress, setTimelockedAddress] = useState<Api.BitcoinAddress>()
const [utxoIdsToBeSpent, setUtxoIdsToBeSpent] = useState([])
const [createdFidelityBondUtxo, setCreatedFidelityBondUtxo] = useState<Utxo>()
const [frozenUtxos, setFrozenUtxos] = useState<Utxos>([])

const allUtxosSelected = useMemo(() => {
return (
walletInfo.balanceSummary.calculatedTotalBalanceInSats ===
selectedUtxos.map((it) => it.value).reduce((prev, curr) => prev + curr, 0)
)
}, [walletInfo, selectedUtxos])
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')) {
Expand All @@ -79,7 +105,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
setSelectedJar(undefined)
setSelectedUtxos([])
setLockDate(null)
setTimelockedAddress(null)
setTimelockedAddress(undefined)
setAlert(undefined)
setCreatedFidelityBondUtxo(undefined)
setFrozenUtxos([])
Expand Down Expand Up @@ -254,8 +280,8 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
return (
<SelectDate
description={t('earn.fidelity_bond.select_date.description')}
selectableYearsRange={yearsRange}
onDateSelected={(date) => setLockDate(date)}
yearsRange={yearsRange}
onChange={(date) => setLockDate(date)}
/>
)
case steps.selectJar:
Expand Down Expand Up @@ -307,7 +333,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
)
}

if (timelockedAddress === null) {
if (!timelockedAddress) {
return <div>{t('earn.fidelity_bond.error_loading_address')}</div>
}

Expand Down Expand Up @@ -386,7 +412,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon

return t('earn.fidelity_bond.freeze_utxos.text_primary_button')
case steps.reviewInputs:
if (timelockedAddress === null) return t('earn.fidelity_bond.review_inputs.text_primary_button_error')
if (!timelockedAddress) return t('earn.fidelity_bond.review_inputs.text_primary_button_error')

if (!onlyCjOutOrFbUtxosSelected()) {
return t('earn.fidelity_bond.review_inputs.text_primary_button_unsafe')
Expand Down Expand Up @@ -522,7 +548,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
loadTimeLockedAddress(lockDate!)
}

if (step === steps.reviewInputs && timelockedAddress === null) {
if (step === steps.reviewInputs && !timelockedAddress) {
loadTimeLockedAddress(lockDate!)
return
}
Expand Down Expand Up @@ -568,26 +594,32 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon
return (
<div className={styles.container}>
{alert && <Alert {...alert} className="mt-0" onClose={() => setAlert(undefined)} />}
{lockDate && (
<ConfirmModal
{lockDate && timelockedAddress && selectedJar !== undefined && (
<PaymentConfirmModal
isShown={showConfirmInputsModal}
size="lg"
title={t('earn.fidelity_bond.confirm_modal.title')}
onCancel={() => setShowConfirmInputsModal(false)}
onConfirm={() => {
setStep(steps.createFidelityBond)
setShowConfirmInputsModal(false)
directSweepToFidelityBond(selectedJar!, timelockedAddress!)
directSweepToFidelityBond(selectedJar, timelockedAddress)
}}
data={{
sourceJarIndex: undefined, // dont show a source jar - might be confusing in this context
destination: timelockedAddress,
amount: selectedUtxosTotalValue,
isSweep: true,
isCoinjoin: false, // not sent as collaborative transaction
numCollaborators: undefined,
feeConfigValues,
showPrivacyInfo: false,
}}
>
{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,
}),
})}
</ConfirmModal>
<LockInfoAlert className="text-start mt-4" lockDate={lockDate} />
</PaymentConfirmModal>
)}

<div className={styles.header} onClick={() => setIsExpanded(!isExpanded)}>
<div className="d-flex justify-content-between align-items-center">
<div className={styles.title}>
Expand Down