Skip to content

Commit

Permalink
feat: display fee settings on Send page (#532)
Browse files Browse the repository at this point in the history
* refactor(fees): externalize loading fees to own file

* feat(send): show estimated max. collaborator fee

* feat(send): show miner fee target

* fix(fees): allow zero for all fee config values

* feat(fees): put ≤ before max. fee estimate

* fix(i18n): lowercase for consistency

Co-authored-by: Gigi <dergigi@pm.me>
  • Loading branch information
theborakompanioni and dergigi committed Oct 5, 2022
1 parent 0757280 commit 26f911a
Show file tree
Hide file tree
Showing 7 changed files with 379 additions and 147 deletions.
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

0 comments on commit 26f911a

Please sign in to comment.