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(Send): Fee breakdown table #606

Merged
merged 31 commits into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0272924
Add `AccordionInfo` component
httpiga Jan 30, 2023
7916523
Add fee breakdown UI
httpiga Feb 4, 2023
33809b0
Wrap `loadFeeConfigValues` in hook
httpiga Feb 18, 2023
ae493fa
Wrap mining fee text function in hook
httpiga Feb 18, 2023
344e18d
Wrap estimated max collaborator fee in hook
httpiga Feb 19, 2023
4a1dcb5
Get real fee values
httpiga Feb 19, 2023
69a961e
Fix TS error "TFunction is not generic"
httpiga Feb 26, 2023
4d0327d
Improve clarity
httpiga May 27, 2023
32ba78a
Fix popover header visibility in dark mode
httpiga Jun 5, 2023
4e1f69d
Use passive voice in "cannot estimate mining fee" text
httpiga Jun 5, 2023
4a6f6fb
Fix past usage in "cannot estimate mining fee" text
httpiga Jun 5, 2023
bc8d276
Fix popover dark mode colors
httpiga Jun 12, 2023
5ce4912
Align UI to the new proposal from editwentyone
httpiga Aug 12, 2023
52b9d90
ui: de-emphasize non-effective fee card content
theborakompanioni Aug 23, 2023
476b586
chore: fix browser warning for fee breakdown subtitle
theborakompanioni Aug 23, 2023
40b0a72
chore(wording): Fee Estimation -> Maximum collaborator fee limit
theborakompanioni Aug 23, 2023
de80e6a
chore: use css class text-small for fee breakdown labels
theborakompanioni Aug 23, 2023
d11afe6
chore: use useMemo in FeeBreakdown component
theborakompanioni Aug 23, 2023
9cf74c5
feat(FeeBreakdown): open fee modal on click
theborakompanioni Aug 23, 2023
98bdcc1
ui(FeeBreakdown): add less-or-equal sign
theborakompanioni Aug 23, 2023
33c9a03
ui(FeeBreakdown): show max collaborator fee value in title
theborakompanioni Aug 23, 2023
0146241
ui(FeeBreakdown): open cj fee section by default
theborakompanioni Aug 23, 2023
054bc22
ui(FeeSettings): switch position of mining and collaborator fees
theborakompanioni Aug 24, 2023
86c9846
ui(FeeBreakdown): now works with sweep transactions
theborakompanioni Aug 24, 2023
bc901c7
ui(FeeBreakdown): market maker fees -> collaborator fees
theborakompanioni Aug 24, 2023
4dbe975
fix(actions): temporarily print linter debug messages
theborakompanioni Aug 24, 2023
0c3dc38
build(deps): update to prettier v2.8.7
theborakompanioni Aug 24, 2023
c7f9b0d
ui(FeeBreakdown): emphasize collaborator fee note
theborakompanioni Aug 24, 2023
5432b6d
fix(FeeBreakdown): improve handling of missing config values
theborakompanioni Aug 25, 2023
6f8efef
chore(FeeBreakdown): Remove unused `AccordionInfo`
httpiga Aug 28, 2023
b331c58
revert: temporarily print linter debug messages
httpiga Aug 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/components/AccordionInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { PropsWithChildren, useState } from 'react'
import { useSettings } from '../context/SettingsContext'
import * as rb from 'react-bootstrap'
import Sprite from './Sprite'

interface AccordionInfoProps {
title: string
defaultOpen?: boolean
}

const AccordionInfo = ({ title, defaultOpen = false, children }: PropsWithChildren<AccordionInfoProps>) => {
const settings = useSettings()
const [isOpen, setIsOpen] = useState(defaultOpen)

return (
<div className="mt-4">
<rb.Button
variant={settings.theme}
className="d-flex align-items-center justify-content-end bg-transparent border-0 w-100 px-0 py-2"
style={{ fontSize: '0.75rem' }}
onClick={() => setIsOpen((current) => !current)}
>
<Sprite symbol={`caret-${isOpen ? 'up' : 'down'}`} className="me-1" width="10" height="10" />
{title}
</rb.Button>
<rb.Collapse in={isOpen}>
<div>{children}</div>
</rb.Collapse>
</div>
)
}

export default AccordionInfo
51 changes: 3 additions & 48 deletions src/components/PaymentConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import { 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 { estimateMaxCollaboratorFee, FeeValues, toTxFeeValueUnit } from '../hooks/Fees'

import { isValidNumber } from '../utils'
import { FeeValues, useEstimatedMaxCollaboratorFee, useMiningFeeText } from '../hooks/Fees'
import { ConfirmModal, ConfirmModalProps } from './Modal'

import styles from './PaymentConfirmModal.module.css'
import { AmountSats } from '../libs/JmWalletApi'
import { jarInitial } from './jars/Jar'
Expand Down Expand Up @@ -44,50 +40,9 @@ export function PaymentConfirmModal({
const { t } = useTranslation()
const settings = useSettings()

const estimatedMaxCollaboratorFee = useMemo(() => {
if (!isCoinjoin || !feeConfigValues) return null
if (!isValidNumber(amount) || !isValidNumber(numCollaborators)) 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!)
const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({ amount, numCollaborators, isCoinjoin })

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])
const miningFeeText = useMiningFeeText()

return (
<ConfirmModal {...confirmModalProps}>
Expand Down
120 changes: 120 additions & 0 deletions src/components/Send/FeeBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { PropsWithChildren } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useEstimatedMaxCollaboratorFee, useFeeConfigValues, useMiningFeeText } from '../../hooks/Fees'
import Balance from '../Balance'
import * as rb from 'react-bootstrap'
import Sprite from '../Sprite'
import { useSettings } from '../../context/SettingsContext'

interface FeeBreakdownProps {
numCollaborators: number | null
amount: number | null
isCoinjoin: boolean
}

const FeeBreakdown = ({ numCollaborators, amount, isCoinjoin }: PropsWithChildren<FeeBreakdownProps>) => {
const { t } = useTranslation()
const feesConfig = useFeeConfigValues()
const maxCjRelativeFee = feesConfig?.max_cj_fee_rel
? `${feesConfig.max_cj_fee_rel * 100}%`
: t('send.fee_breakdown.not_set')

const maxEstimatedRelativeFee =
feesConfig?.max_cj_fee_rel && numCollaborators && amount
? amount * feesConfig.max_cj_fee_rel * numCollaborators >= 1
? Math.ceil(amount * feesConfig.max_cj_fee_rel) * numCollaborators
: null
: null

const maxCjAbsoluteFee = feesConfig?.max_cj_fee_abs
? feesConfig?.max_cj_fee_abs.toString()
: t('send.fee_breakdown.not_set')
const maxEstimatedAbsoluteFee =
feesConfig?.max_cj_fee_abs && numCollaborators ? feesConfig.max_cj_fee_abs * numCollaborators : null

const miningFeeText = useMiningFeeText()
const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({ amount, numCollaborators, isCoinjoin })
const settings = useSettings()

return (
<div>
{maxEstimatedAbsoluteFee && (
<div className="d-flex justify-content-between text-secondary">
<div>
<Trans
i18nKey="send.fee_breakdown.absolute_limit"
components={{
balance: <Balance convertToUnit="sats" valueString={maxCjAbsoluteFee} showBalance={true} />,
}}
values={{ num: numCollaborators }}
/>
</div>
<div>
<Balance convertToUnit="sats" valueString={maxEstimatedAbsoluteFee.toString()} showBalance={true} />
</div>
</div>
)}

<div className="d-flex justify-content-between text-secondary mb-2">
<div>
<Trans
i18nKey="send.fee_breakdown.or_relative_limit"
values={{ num: numCollaborators, percentage: maxCjRelativeFee }}
/>
</div>
<div>
{amount ? (
maxEstimatedRelativeFee ? (
<Balance convertToUnit="sats" valueString={maxEstimatedRelativeFee.toString()} showBalance={true} />
) : (
t('send.fee_breakdown.too_low')
)
) : (
'-'
)}
</div>
</div>

<div className="d-flex justify-content-between" style={{ fontWeight: 600 }}>
<div style={{ gridColumnStart: 'span 2' }}>{t('send.fee_breakdown.total_estimate')}</div>
<div>
{estimatedMaxCollaboratorFee ? (
<Balance convertToUnit="sats" valueString={`${estimatedMaxCollaboratorFee ?? ''}`} showBalance={true} />
) : (
'-'
)}
</div>
</div>
<div className="d-flex justify-content-between">
<div>
<span className="me-1">{t('send.fee_breakdown.plus_mining_fee')}</span>
<rb.OverlayTrigger
placement="right"
overlay={
<rb.Popover>
<rb.Popover.Header className={settings.theme === 'dark' ? 'text-bg-secondary' : undefined}>
{t('send.fee_breakdown.why_cant_estimate_mining_fee')}
</rb.Popover.Header>
<rb.Popover.Body>
<Trans i18nKey="send.fee_breakdown.cant_estimate_mining_fee_info" components={{ br: <br /> }} />
</rb.Popover.Body>
</rb.Popover>
}
>
<div className="d-inline-flex align-items-center h-100">
<Sprite
className="rounded-circle border border-secondary text-body ms-1"
symbol="info"
width="13"
height="13"
/>
</div>
</rb.OverlayTrigger>
</div>
<div>{miningFeeText}</div>
</div>
</div>
)
}

export default FeeBreakdown
13 changes: 13 additions & 0 deletions src/components/Send/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
} from './helpers'
import { SATS, isValidNumber } from '../../utils'
import styles from './Send.module.css'
import AccordionInfo from '../AccordionInfo'
import FeeBreakdown from './FeeBreakdown'

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)
Expand Down Expand Up @@ -832,6 +834,17 @@ export default function Send({ wallet }: SendProps) {
minNumCollaborators={minNumCollaborators}
disabled={isLoading || isOperationDisabled}
/>
<AccordionInfo title={t('send.collaborators_fee_question')}>
<div className="mb-2">
<Trans
i18nKey="send.collaborators_fee_info"
components={{
a: <Link to={routes.settings} className="text-decoration-underline text-body" />,
}}
/>
</div>
<FeeBreakdown numCollaborators={numCollaborators} amount={amount} isCoinjoin={isCoinjoin} />
</AccordionInfo>
</div>
</Accordion>
</rb.Form>
Expand Down
4 changes: 2 additions & 2 deletions src/components/jar_details/UtxoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<'translation', undefined>): Tag[] => {
const utxoTags = (utxo: Utxo, walletInfo: WalletInfo, t: TFunction): Tag[] => {
const rawStatus = walletInfo.addressSummary[utxo.address]?.status

let status: string | null = null
Expand All @@ -62,7 +62,7 @@ const utxoTags = (utxo: Utxo, walletInfo: WalletInfo, t: TFunction<'translation'
return tags
}

const utxoIcon = (utxo: Utxo, t: TFunction<'translation', undefined>) => {
const utxoIcon = (utxo: Utxo, t: TFunction) => {
if (fb.utxo.isFidelityBond(utxo)) {
return (
<>
Expand Down
91 changes: 90 additions & 1 deletion src/hooks/Fees.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRefreshConfigValues } from '../context/ServiceConfigContext'
import { AmountSats } from '../libs/JmWalletApi'
import { isValidNumber } from '../utils'
Expand Down Expand Up @@ -53,6 +54,27 @@ export const useLoadFeeConfigValues = () => {
)
}

export const useFeeConfigValues = () => {
const loadFeeConfigValues = useLoadFeeConfigValues()
const [values, setValues] = useState<FeeValues | null>()

useEffect(() => {
const abortCtrl = new AbortController()

loadFeeConfigValues(abortCtrl.signal)
.then((val) => setValues(val))
.catch((e) => {
console.log('Unable lo load fee config: ', e)
setValues(null)
})

return () => {
abortCtrl.abort()
}
}, [setValues, loadFeeConfigValues])
return values
}

interface EstimatMaxCollaboratorFeeProps {
amount: AmountSats
collaborators: number
Expand All @@ -69,3 +91,70 @@ export const estimateMaxCollaboratorFee = ({
const maxFeePerCollaborator = Math.max(Math.ceil(amount * maxFeeRel), maxFeeAbs)
return collaborators > 0 ? Math.min(maxFeePerCollaborator * collaborators, amount) : 0
}

export const useMiningFeeText = () => {
const feeConfigValues = useFeeConfigValues()
const { t } = useTranslation()

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 miningFeeText
}

interface useEstimatedMaxCollaboratorFeeArgs {
isCoinjoin: boolean
amount: number | null
numCollaborators?: number | null
}
export const useEstimatedMaxCollaboratorFee = ({
isCoinjoin,
amount,
numCollaborators,
}: useEstimatedMaxCollaboratorFeeArgs) => {
const feeConfigValues = useFeeConfigValues()

const estimatedMaxCollaboratorFee = useMemo(() => {
if (!isCoinjoin || !feeConfigValues || !amount) return null
if (!isValidNumber(amount) || !isValidNumber(numCollaborators ?? undefined)) 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])

return estimatedMaxCollaboratorFee
}
Loading