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 8 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
116 changes: 116 additions & 0 deletions src/components/Send/FeeBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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'

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

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>{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
}
12 changes: 12 additions & 0 deletions src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@
"sending_options": "Sending options",
"toggle_coinjoin": "Send as collaborative transaction",
"toggle_coinjoin_subtitle": "Collaborative transactions improve the privacy of yourself and others.",
"collaborators_fee_question": "How much is the maximum collaborators fee?",
"collaborators_fee_info": "Here you can find the maximum collaborators fee you will pay (this does not include mining fees). You can change your fee limits in the <a>settings page</a>.",
"button_send": "Send",
"button_send_despite_warning": "Ignore warning & try send",
"button_send_without_improved_privacy": "Send without privacy improvement",
Expand All @@ -259,6 +261,16 @@
"sweep_amount_breakdown_frozen_balance": "Frozen or locked balance",
"sweep_amount_breakdown_estimated_amount": "Estimated amount to be sent",
"sweep_amount_breakdown_explanation": "A sweep transaction will consume all UTXOs of a mixdepth leaving no coins behind except those that have been <1>frozen</1> or <3>time-locked</3>. Onchain transaction fees and market maker fees will be deducted from the amount so as to leave zero change. The exact transaction amount can only be calculated by JoinMarket at the point when the transaction is made. Therefore the estimated amount shown might deviate from the actually sent amount. Refer to the <5>JoinMarket documentation</5> for more details.",
"fee_breakdown": {
"absolute_limit": "Absolute fee limit (<balance/>) * {{ num }} collaborators",
"or_relative_limit": "OR relative fee limit ({{ percentage }}) * {{ num }} collaborators",
"not_set": "Not set",
"too_low": "Too low",
"total_estimate": "Max collaborators fee",
"plus_mining_fee": "Plus mining fees",
"why_cant_estimate_mining_fee": "Why can't we estimate mining fees?",
"cant_estimate_mining_fee_info": "The amount of inputs and outputs is not know beforehand, so it is not possible to know the transaction size, hence the transaction fees cannot be estimated.<br>In addition to this, if you have set a \"Block target\" as mining fee (read: \"Include my tx in the next n blocks\", instead of a sats/vByte value), we cannot estimate the fee needed for your transaction since Jam does not have access to the current mempool conditions."
httpiga marked this conversation as resolved.
Show resolved Hide resolved
},
"confirm_send_modal": {
"title": "Confirm payment",
"label_amount": "Amount",
Expand Down