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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: display fee settings on Send page #532

Merged
merged 11 commits into from
Oct 5, 2022
4 changes: 2 additions & 2 deletions src/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React, { PropsWithChildren } from 'react'
import { ReactNode, PropsWithChildren } from 'react'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import styles from './Modal.module.css'
import Sprite from './Sprite'

interface ConfirmModalProps {
isShown: boolean
title: React.ReactNode | string
title: ReactNode | string
onCancel: () => void
onConfirm: () => void
}
Expand Down
289 changes: 204 additions & 85 deletions src/components/Send.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,18 @@ import { useReloadCurrentWalletInfo, useCurrentWallet, useCurrentWalletInfo } fr
import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
import { useLoadConfigValue } from '../context/ServiceConfigContext'
import { useSettings } from '../context/SettingsContext'
import { estimateMaxCollaboratorFee, toTxFeeValueUnit, useLoadFeeConfigValues } from '../hooks/Fees'
import { buildCoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'

import * as Api from '../libs/JmWalletApi'
import { SATS, formatBtc, formatSats } from '../utils'
import { SATS, formatBtc, formatSats, isValidNumber } from '../utils'
import { routes } from '../constants/routes'
import styles from './Send.module.css'
import { ConfirmModal } from './Modal'
import { CoinjoinPreconditionViolationAlert } from './CoinjoinPreconditionViolationAlert'
import { jarInitial, jarName } from './jars/Jar'

import styles from './Send.module.css'

const IS_COINJOIN_DEFAULT_VAL = true
// initial value for `minimum_makers` from the default joinmarket.cfg (last check on 2022-02-20 of v0.9.5)
const MINIMUM_MAKERS_DEFAULT_VAL = 4
Expand Down Expand Up @@ -52,17 +54,17 @@ const isValidAddress = (candidate) => {

const isValidAccount = (candidate) => {
const parsed = parseInt(candidate, 10)
return !isNaN(parsed) && parsed >= 0
return isValidNumber(parsed) && parsed >= 0
}

const isValidAmount = (candidate, isSweep) => {
const parsed = parseInt(candidate, 10)
return !isNaN(parsed) && (isSweep ? parsed === 0 : parsed > 0)
return isValidNumber(parsed) && (isSweep ? parsed === 0 : parsed > 0)
}

const isValidNumCollaborators = (candidate, minNumCollaborators) => {
const parsed = parseInt(candidate, 10)
return !isNaN(parsed) && parsed >= minNumCollaborators && parsed <= 99
return isValidNumber(parsed) && parsed >= minNumCollaborators && parsed <= 99
}

const CollaboratorsSelector = ({ numCollaborators, setNumCollaborators, minNumCollaborators, disabled = false }) => {
Expand Down Expand Up @@ -214,6 +216,174 @@ function SweepAccordionToggle({ eventKey }) {
)
}

function PaymentConfirmModal({
isShown,
title,
onCancel,
onConfirm,
data: { sourceJarId, destination, amount, isSweep, isCoinjoin, numCollaborators, feeConfigValues },
}) {
const { t } = useTranslation()
const settings = useSettings()

const estimatedMaxCollaboratorFee = useMemo(() => {
if (!amount || !isCoinjoin || !numCollaborators || !feeConfigValues) return null
if (!isValidNumber(feeConfigValues.max_cj_fee_abs) || !isValidNumber(feeConfigValues.max_cj_fee_rel)) return null
return estimateMaxCollaboratorFee({
amount,
collaborators: numCollaborators,
maxFeeAbs: feeConfigValues.max_cj_fee_abs,
maxFeeRel: feeConfigValues.max_cj_fee_rel,
})
}, [amount, isCoinjoin, numCollaborators, feeConfigValues])

const miningFeeText = useMemo(() => {
if (!feeConfigValues) return null
if (!isValidNumber(feeConfigValues.tx_fees) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null

const unit = toTxFeeValueUnit(feeConfigValues.tx_fees)
if (!unit) {
return null
} else if (unit === 'blocks') {
return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees })
} else {
const feeTargetInSatsPerVByte = feeConfigValues.tx_fees / 1_000
if (feeConfigValues.tx_fees_factor === 0) {
return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_exact', {
value: feeTargetInSatsPerVByte.toLocaleString(undefined, {
maximumFractionDigits: Math.log10(1_000),
}),
})
}

const minFeeSatsPerVByte = Math.max(1, feeTargetInSatsPerVByte * (1 - feeConfigValues.tx_fees_factor))
const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + feeConfigValues.tx_fees_factor)

return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_randomized', {
min: minFeeSatsPerVByte.toLocaleString(undefined, {
maximumFractionDigits: 1,
}),
max: maxFeeSatsPerVByte.toLocaleString(undefined, {
maximumFractionDigits: 1,
}),
})
}
}, [t, feeConfigValues])

return (
<ConfirmModal isShown={isShown} title={title} onCancel={onCancel} onConfirm={onConfirm}>
<rb.Container className="mt-2">
<rb.Row className="mt-2 mb-3">
<rb.Col xs={12} className="text-center">
{isCoinjoin ? (
<strong className="text-success">{t('send.confirm_send_modal.text_collaborative_tx_enabled')}</strong>
) : (
<strong className="text-danger">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</strong>
)}
</rb.Col>
</rb.Row>
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_source_jar')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start">
{t('send.confirm_send_modal.text_source_jar', { jarId: sourceJarId })}
</rb.Col>
</rb.Row>
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_recipient')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start text-break slashed-zeroes">
{destination}
</rb.Col>
</rb.Row>
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_amount')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start">
{isSweep ? (
<>
<Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
Sweep
<Balance valueString={amount} convertToUnit={settings.unit} showBalance={true} />
</Trans>
<rb.OverlayTrigger
placement="right"
overlay={
<rb.Popover>
<rb.Popover.Body>{t('send.confirm_send_modal.text_sweep_info_popover')}</rb.Popover.Body>
</rb.Popover>
}
>
<div className="d-inline-flex align-items-center">
<Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
</div>
</rb.OverlayTrigger>
</>
) : (
<Balance valueString={amount} convertToUnit={settings.unit} showBalance={true} />
)}
</rb.Col>
</rb.Row>

{miningFeeText && (
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_miner_fee')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start">
{miningFeeText}
</rb.Col>
</rb.Row>
)}
{isCoinjoin && (
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_num_collaborators')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start">
{numCollaborators}
</rb.Col>
</rb.Row>
)}
{estimatedMaxCollaboratorFee && (
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_estimated_max_collaborator_fee')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="d-inline-flex align-items-center text-start">
<div>
&le;
<Balance
valueString={`${estimatedMaxCollaboratorFee}`}
convertToUnit={settings.unit}
showBalance={true}
/>
<rb.OverlayTrigger
placement="right"
overlay={
<rb.Popover>
<rb.Popover.Body>
{t('send.confirm_send_modal.text_estimated_max_collaborator_fee_info_popover')}
</rb.Popover.Body>
</rb.Popover>
}
>
<div className="d-inline-flex align-items-center">
<Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
</div>
</rb.OverlayTrigger>
</div>
</rb.Col>
</rb.Row>
)}
</rb.Container>
</ConfirmModal>
)
}

export default function Send() {
const { t } = useTranslation()
const wallet = useCurrentWallet()
Expand All @@ -223,6 +393,7 @@ export default function Send() {
const serviceInfo = useServiceInfo()
const reloadServiceInfo = useReloadServiceInfo()
const loadConfigValue = useLoadConfigValue()
const loadFeeConfigValues = useLoadFeeConfigValues()
const settings = useSettings()
const location = useLocation()

Expand All @@ -238,6 +409,8 @@ export default function Send() {
const [destinationJar, setDestinationJar] = useState(null)
const [destinationIsReusedAddress, setDestinationIsReusedAddress] = useState(false)

const [feeConfigValues, setFeeConfigValues] = useState(null)

const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([])
const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState(null)

Expand Down Expand Up @@ -409,12 +582,24 @@ export default function Send() {
!abortCtrl.signal.aborted && setAlert({ variant: 'danger', message: err.message })
})

Promise.all([loadingServiceInfo, loadingWalletInfoAndUtxos, loadingMinimumMakerConfig]).finally(
const loadFeeValues = loadFeeConfigValues(abortCtrl.signal)
.then((data) => {
if (abortCtrl.signal.aborted) return
setFeeConfigValues(data)
})
.catch((e) => {
if (abortCtrl.signal.aborted) return
// As fee config is not essential, don't raise an error on purpose.
// Fee settings cannot be displayed, but making a payment is still possible.
setFeeConfigValues(null)
})

Promise.all([loadingServiceInfo, loadingWalletInfoAndUtxos, loadingMinimumMakerConfig, loadFeeValues]).finally(
() => !abortCtrl.signal.aborted && setIsInitializing(false)
)

return () => abortCtrl.abort()
}, [isOperationDisabled, wallet, reloadCurrentWalletInfo, reloadServiceInfo, loadConfigValue, t])
}, [isOperationDisabled, wallet, reloadCurrentWalletInfo, reloadServiceInfo, loadConfigValue, loadFeeConfigValues, t])

useEffect(() => {
if (destination !== null && walletInfo?.addressSummary[destination]) {
Expand Down Expand Up @@ -582,7 +767,7 @@ export default function Send() {
}

const amountFieldValue = () => {
if (amount === null || Number.isNaN(amount)) return ''
if (amount === null || !isValidNumber(amount)) return ''

if (isSweep) {
if (!accountBalanceOrNull) return ''
Expand Down Expand Up @@ -950,89 +1135,23 @@ export default function Send() {
{t('send.confirm_abort_modal.text_body')}
</ConfirmModal>

<ConfirmModal
<PaymentConfirmModal
isShown={showConfirmSendModal}
title={t('send.confirm_send_modal.title')}
onCancel={() => setShowConfirmSendModal(false)}
onConfirm={() => {
submitButtonRef.current?.click()
}}
>
<rb.Container className="mt-2">
<rb.Row className="mt-2 mb-3">
<rb.Col xs={12} className="text-center">
{isCoinjoin ? (
<strong className="text-success">{t('send.confirm_send_modal.text_collaborative_tx_enabled')}</strong>
) : (
<strong className="text-danger">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</strong>
)}
</rb.Col>
</rb.Row>
<rb.Row>
<rb.Col xs={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_source_jar')}</strong>
</rb.Col>
<rb.Col xs={9} className="text-start">
{t('send.confirm_send_modal.text_source_jar', { jarId: jarInitial(account) })}
</rb.Col>
</rb.Row>
<rb.Row>
<rb.Col xs={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_recipient')}</strong>
</rb.Col>
<rb.Col xs={9} className="text-start text-break slashed-zeroes">
{destination}
</rb.Col>
</rb.Row>
<rb.Row>
<rb.Col xs={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_amount')}</strong>
</rb.Col>
<rb.Col xs={9} className="text-start">
{isSweep ? (
<div className="d-flex justify-content-start align-items-center">
<Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
Sweep
<Balance
valueString={amountFieldValue().toString()}
convertToUnit={settings.unit}
showBalance={true}
/>
</Trans>
<rb.OverlayTrigger
placement="right"
overlay={
<rb.Popover>
<rb.Popover.Body>{t('send.confirm_send_modal.text_sweep_info_popover')}</rb.Popover.Body>
</rb.Popover>
}
>
<div className="d-inline-flex align-items-center">
<Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
</div>
</rb.OverlayTrigger>
</div>
) : (
<Balance
valueString={amountFieldValue().toString()}
convertToUnit={settings.unit}
showBalance={true}
/>
)}
</rb.Col>
</rb.Row>
{isCoinjoin && (
<rb.Row>
<rb.Col xs={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_num_collaborators')}</strong>
</rb.Col>
<rb.Col xs={9} className="text-start">
{numCollaborators}
</rb.Col>
</rb.Row>
)}
</rb.Container>
</ConfirmModal>
data={{
sourceJarId: jarInitial(account),
destination,
amount: amountFieldValue().toString(),
isSweep,
isCoinjoin,
numCollaborators,
feeConfigValues,
}}
/>
</div>
</>
)
Expand Down
Loading