From 4afde04bb59860cf9357878f8d85e2e22453ea0c Mon Sep 17 00:00:00 2001 From: Thebora Kompanioni Date: Thu, 31 Oct 2024 14:23:53 +0100 Subject: [PATCH] fix: relative fee greater than zero (#862) --- src/components/Earn.tsx | 66 +++++++++------------- src/components/ImportWallet.tsx | 2 +- src/components/Orderbook.tsx | 2 +- src/components/Send/index.tsx | 2 +- src/components/settings/FeeConfigModal.tsx | 29 ++++------ src/constants/jam.ts | 25 ++++++++ src/constants/{config.ts => jm.ts} | 8 +++ src/context/ServiceInfoContext.tsx | 2 +- src/context/WalletContext.tsx | 2 +- src/hooks/CoinjoinRequirements.ts | 2 +- src/hooks/Fees.ts | 9 +-- src/utils.test.ts | 39 +++++++++++++ src/utils.ts | 16 +++++- 13 files changed, 133 insertions(+), 71 deletions(-) create mode 100644 src/constants/jam.ts rename src/constants/{config.ts => jm.ts} (75%) diff --git a/src/components/Earn.tsx b/src/components/Earn.tsx index 5277fd23e..d2facaa78 100644 --- a/src/components/Earn.tsx +++ b/src/components/Earn.tsx @@ -6,7 +6,21 @@ import { TFunction } from 'i18next' import { useSettings } from '../context/SettingsContext' import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo, WalletInfo } from '../context/WalletContext' import { useServiceInfo, useReloadServiceInfo, Offer } from '../context/ServiceInfoContext' -import { factorToPercentage, isAbsoluteOffer, isRelativeOffer, isValidNumber, percentageToFactor } from '../utils' +import { + calcOfferMinsizeMax, + factorToPercentage, + isAbsoluteOffer, + isRelativeOffer, + isValidNumber, + percentageToFactor, +} from '../utils' +import { + OFFER_FEE_ABS_MIN, + OFFER_FEE_REL_MAX, + OFFER_FEE_REL_MIN, + OFFER_FEE_REL_STEP, + OFFER_MINSIZE_MIN, +} from '../constants/jam' import * as Api from '../libs/JmWalletApi' import * as fb from './fb/utils' import Sprite from './Sprite' @@ -23,7 +37,6 @@ import Accordion from './Accordion' import BitcoinAmountInput, { AmountValue, toAmountValue } from './BitcoinAmountInput' import { isValidAmount } from './Send/helpers' import styles from './Earn.module.css' -import { JM_DUST_THRESHOLD } from '../constants/config' // In order to prevent state mismatch, the 'maker stop' response is delayed shortly. // Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case. @@ -195,10 +208,6 @@ function CurrentOffer({ offer, nickname }: CurrentOfferProps) { ) } -const feeRelMin = 0.0 -const feeRelMax = 0.1 // 10% -const feeRelPercentageStep = 0.0001 - interface EarnFormProps { initialValues?: EarnFormValues submitButtonText: (isSubmitting: boolean) => React.ReactNode | string @@ -218,22 +227,9 @@ const EarnForm = ({ }: EarnFormProps) => { const { t } = useTranslation() - const maxAvailableBalanceInJar = useMemo(() => { - return Math.max( - 0, - Math.max( - ...Object.values(walletInfo?.balanceSummary.accountBalances || []).map( - (it) => it.calculatedAvailableBalanceInSats, - ), - ), - ) - }, [walletInfo]) - - const offerMinsizeMin = JM_DUST_THRESHOLD - const offerMinsizeMax = useMemo(() => { - return Math.max(0, maxAvailableBalanceInJar - JM_DUST_THRESHOLD) - }, [maxAvailableBalanceInJar]) + return walletInfo === undefined ? 0 : calcOfferMinsizeMax(walletInfo.balanceSummary.accountBalances) + }, [walletInfo]) const validate = (values: EarnFormValues) => { const errors = {} as FormikErrors @@ -246,16 +242,16 @@ const EarnForm = ({ } if (isRelOffer) { - if (!isValidNumber(values.feeRel) || values.feeRel < feeRelMin || values.feeRel > feeRelMax) { + if (!isValidNumber(values.feeRel) || values.feeRel < OFFER_FEE_REL_MIN || values.feeRel > OFFER_FEE_REL_MAX) { errors.feeRel = t('earn.feedback_invalid_rel_fee', { - feeRelPercentageMin: `${factorToPercentage(feeRelMin)}%`, - feeRelPercentageMax: `${factorToPercentage(feeRelMax)}%`, + feeRelPercentageMin: `${factorToPercentage(OFFER_FEE_REL_MIN)}%`, + feeRelPercentageMax: `${factorToPercentage(OFFER_FEE_REL_MAX)}%`, }) } } if (isAbsOffer) { - if (!isValidNumber(values.feeAbs?.value) || values.feeAbs!.value! < 0) { + if (!isValidNumber(values.feeAbs?.value) || values.feeAbs!.value! < OFFER_FEE_ABS_MIN) { errors.feeAbs = t('earn.feedback_invalid_abs_fee') } } @@ -264,11 +260,11 @@ const EarnForm = ({ errors.minsize = t('earn.feedback_invalid_min_amount') } else { const minsize = values.minsize?.value || 0 - if (offerMinsizeMin > offerMinsizeMax) { + if (OFFER_MINSIZE_MIN > offerMinsizeMax) { errors.minsize = t('earn.feedback_invalid_min_amount_insufficient_funds') - } else if (minsize < offerMinsizeMin || minsize > offerMinsizeMax) { + } else if (minsize < OFFER_MINSIZE_MIN || minsize > offerMinsizeMax) { errors.minsize = t('earn.feedback_invalid_min_amount_range', { - minAmountMin: offerMinsizeMin.toLocaleString(), + minAmountMin: OFFER_MINSIZE_MIN.toLocaleString(), minAmountMax: offerMinsizeMax.toLocaleString(), }) } @@ -277,15 +273,7 @@ const EarnForm = ({ } return ( - + {(props) => { const { handleSubmit, setFieldValue, handleBlur, values, touched, errors, isSubmitting } = props const minsizeField = props.getFieldProps('minsize') @@ -348,8 +336,8 @@ const EarnForm = ({ value={typeof values.feeRel === 'number' ? factorToPercentage(values.feeRel) : ''} isValid={touched.feeRel && !errors.feeRel} isInvalid={touched.feeRel && !!errors.feeRel} - min={0} - step={feeRelPercentageStep} + min={factorToPercentage(OFFER_FEE_REL_MIN)} + step={factorToPercentage(OFFER_FEE_REL_STEP)} /> {errors.feeRel} diff --git a/src/components/ImportWallet.tsx b/src/components/ImportWallet.tsx index 1d85400c4..51006d74b 100644 --- a/src/components/ImportWallet.tsx +++ b/src/components/ImportWallet.tsx @@ -23,7 +23,7 @@ import { isValidNumber, walletDisplayNameToFileName, } from '../utils' -import { JM_GAPLIMIT_DEFAULT, JM_GAPLIMIT_CONFIGKEY } from '../constants/config' +import { JM_GAPLIMIT_DEFAULT, JM_GAPLIMIT_CONFIGKEY } from '../constants/jm' type ImportWalletDetailsFormValues = { mnemonicPhrase: MnemonicPhrase diff --git a/src/components/Orderbook.tsx b/src/components/Orderbook.tsx index 8dade15e7..6aafa7713 100644 --- a/src/components/Orderbook.tsx +++ b/src/components/Orderbook.tsx @@ -17,7 +17,7 @@ import { BTC, factorToPercentage, isAbsoluteOffer, isRelativeOffer } from '../ut import { isDebugFeatureEnabled, isDevMode } from '../constants/debugFeatures' import ToggleSwitch from './ToggleSwitch' import { pseudoRandomNumber } from './Send/helpers' -import { JM_DUST_THRESHOLD } from '../constants/config' +import { JM_DUST_THRESHOLD } from '../constants/jm' import * as fb from './fb/utils' import styles from './Orderbook.module.css' diff --git a/src/components/Send/index.tsx b/src/components/Send/index.tsx index d1b45358c..3bff98cd7 100644 --- a/src/components/Send/index.tsx +++ b/src/components/Send/index.tsx @@ -18,7 +18,7 @@ import { useServiceInfo, useReloadServiceInfo } from '../../context/ServiceInfoC import { useLoadConfigValue } from '../../context/ServiceConfigContext' import { useWaitForUtxosToBeSpent } from '../../hooks/WaitForUtxosToBeSpent' import { routes } from '../../constants/routes' -import { JM_MINIMUM_MAKERS_DEFAULT } from '../../constants/config' +import { JM_MINIMUM_MAKERS_DEFAULT } from '../../constants/jm' import { initialNumCollaborators } from './helpers' const INITIAL_DESTINATION = null diff --git a/src/components/settings/FeeConfigModal.tsx b/src/components/settings/FeeConfigModal.tsx index d38604774..89f7875ef 100644 --- a/src/components/settings/FeeConfigModal.tsx +++ b/src/components/settings/FeeConfigModal.tsx @@ -5,32 +5,27 @@ import { Formik, FormikErrors, FormikProps, Field } from 'formik' import classNames from 'classnames' import Sprite from '../Sprite' import { TxFeeInputField, validateTxFee } from './TxFeeInputField' -import { FEE_CONFIG_KEYS, FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees' +import { FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees' import { useUpdateConfigValues } from '../../context/ServiceConfigContext' import { isDebugFeatureEnabled } from '../../constants/debugFeatures' +import { FEE_CONFIG_KEYS, JM_MAX_SWEEP_FEE_CHANGE_DEFAULT } from '../../constants/jm' +import { + CJ_FEE_ABS_MAX, + CJ_FEE_ABS_MIN, + CJ_FEE_REL_MAX, + CJ_FEE_REL_MIN, + MAX_SWEEP_FEE_CHANGE_MAX, + MAX_SWEEP_FEE_CHANGE_MIN, + TX_FEES_FACTOR_MAX, + TX_FEES_FACTOR_MIN, +} from '../../constants/jam' import ToggleSwitch from '../ToggleSwitch' import { isValidNumber, factorToPercentage, percentageToFactor } from '../../utils' import BitcoinAmountInput, { AmountValue, toAmountValue } from '../BitcoinAmountInput' -import { JM_MAX_SWEEP_FEE_CHANGE_DEFAULT } from '../../constants/config' import styles from './FeeConfigModal.module.css' const __dev_allowFeeValuesReset = isDebugFeatureEnabled('allowFeeValuesReset') -const TX_FEES_FACTOR_MIN = 0 // 0% -/** - * For the same reasons as stated above (comment for `TX_FEES_SATSPERKILOVBYTE_MIN`), - * the maximum randomization factor must not be too high. - * Settling on 50% as a reasonable compromise until this problem is addressed. - * Once resolved, this can be set to 100% again. - */ -const TX_FEES_FACTOR_MAX = 0.5 // 50% -const CJ_FEE_ABS_MIN = 1 -const CJ_FEE_ABS_MAX = 1_000_000 // 0.01 BTC - no enforcement by JM - this should be a "sane" max value -const CJ_FEE_REL_MIN = 0.000001 // 0.0001% -const CJ_FEE_REL_MAX = 0.05 // 5% - no enforcement by JM - this should be a "sane" max value -const MAX_SWEEP_FEE_CHANGE_MIN = 0.5 // 50% -const MAX_SWEEP_FEE_CHANGE_MAX = 1 // 100% - interface FeeConfigModalProps { show: boolean onHide: () => void diff --git a/src/constants/jam.ts b/src/constants/jam.ts new file mode 100644 index 000000000..4ccabda5c --- /dev/null +++ b/src/constants/jam.ts @@ -0,0 +1,25 @@ +import { percentageToFactor } from '../utils' +import { JM_DUST_THRESHOLD } from './jm' + +export const TX_FEES_FACTOR_MIN = 0 // 0% +/** + * For the same reasons as stated above (comment for `TX_FEES_SATSPERKILOVBYTE_MIN`), + * the maximum randomization factor must not be too high. + * Settling on 50% as a reasonable compromise until this problem is addressed. + * Once resolved, this can be set to 100% again. + */ +export const TX_FEES_FACTOR_MAX = percentageToFactor(50) // 50% +export const CJ_FEE_ABS_MIN = 1 +export const CJ_FEE_ABS_MAX = 1_000_000 // 0.01 BTC - no enforcement by JM - this should be a "sane" max value +export const CJ_FEE_REL_MIN = percentageToFactor(0.0001) +export const CJ_FEE_REL_MAX = percentageToFactor(5) // no enforcement by JM - this should be a "sane" max value +export const MAX_SWEEP_FEE_CHANGE_MIN = percentageToFactor(50) +export const MAX_SWEEP_FEE_CHANGE_MAX = percentageToFactor(100) + +export const OFFER_FEE_REL_MIN = percentageToFactor(0.0001) +export const OFFER_FEE_REL_MAX = percentageToFactor(10) +export const OFFER_FEE_REL_STEP = percentageToFactor(0.0001) + +export const OFFER_FEE_ABS_MIN = 0 + +export const OFFER_MINSIZE_MIN = JM_DUST_THRESHOLD diff --git a/src/constants/config.ts b/src/constants/jm.ts similarity index 75% rename from src/constants/config.ts rename to src/constants/jm.ts index f80c07e50..c7ab5e117 100644 --- a/src/constants/config.ts +++ b/src/constants/jm.ts @@ -26,3 +26,11 @@ export const JM_DUST_THRESHOLD = 27_300 // See: https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.11/src/jmclient/configure.py#L321 (last check on 2024-07-09 of v0.9.11) export const JM_MAX_SWEEP_FEE_CHANGE_DEFAULT = 0.8 + +export const FEE_CONFIG_KEYS: Record = { + tx_fees: { section: 'POLICY', field: 'tx_fees' }, + tx_fees_factor: { section: 'POLICY', field: 'tx_fees_factor' }, + max_cj_fee_abs: { section: 'POLICY', field: 'max_cj_fee_abs' }, + max_cj_fee_rel: { section: 'POLICY', field: 'max_cj_fee_rel' }, + max_sweep_fee_change: { section: 'POLICY', field: 'max_sweep_fee_change' }, +} diff --git a/src/context/ServiceInfoContext.tsx b/src/context/ServiceInfoContext.tsx index f6e36427d..46d92cb2f 100644 --- a/src/context/ServiceInfoContext.tsx +++ b/src/context/ServiceInfoContext.tsx @@ -11,7 +11,7 @@ import { import { useCurrentWallet, useClearCurrentWallet } from './WalletContext' import { useWebsocket } from './WebsocketContext' import { clearSession } from '../session' -import { CJ_STATE_TAKER_RUNNING, CJ_STATE_MAKER_RUNNING } from '../constants/config' +import { CJ_STATE_TAKER_RUNNING, CJ_STATE_MAKER_RUNNING } from '../constants/jm' import { noop, setIntervalDebounced, toSemVer, UNKNOWN_VERSION } from '../utils' import * as Api from '../libs/JmWalletApi' diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx index ed322f830..55b0308d3 100644 --- a/src/context/WalletContext.tsx +++ b/src/context/WalletContext.tsx @@ -3,7 +3,7 @@ import { getSession, setSession } from '../session' import * as fb from '../components/fb/utils' import * as Api from '../libs/JmWalletApi' import { WalletBalanceSummary, toBalanceSummary } from './BalanceSummary' -import { JM_API_AUTH_TOKEN_EXPIRY } from '../constants/config' +import { JM_API_AUTH_TOKEN_EXPIRY } from '../constants/jm' import { isDevMode } from '../constants/debugFeatures' import { setIntervalDebounced, walletDisplayName } from '../utils' diff --git a/src/hooks/CoinjoinRequirements.ts b/src/hooks/CoinjoinRequirements.ts index c6bab25ec..8e537f2da 100644 --- a/src/hooks/CoinjoinRequirements.ts +++ b/src/hooks/CoinjoinRequirements.ts @@ -1,6 +1,6 @@ import * as fb from '../components/fb/utils' import { groupByJar, Utxos } from '../context/WalletContext' -import { JM_TAKER_UTXO_AGE_DEFAULT } from '../constants/config' +import { JM_TAKER_UTXO_AGE_DEFAULT } from '../constants/jm' export type CoinjoinRequirementOptions = { minNumberOfUtxos: number // min amount of utxos available diff --git a/src/hooks/Fees.ts b/src/hooks/Fees.ts index fac10dbce..e29cfd2c0 100644 --- a/src/hooks/Fees.ts +++ b/src/hooks/Fees.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, useMemo } from 'react' import { useRefreshConfigValues } from '../context/ServiceConfigContext' import { AmountSats } from '../libs/JmWalletApi' import { isValidNumber } from '../utils' +import { FEE_CONFIG_KEYS } from '../constants/jm' export type TxFeeValueUnit = 'blocks' | 'sats/kilo-vbyte' export type TxFeeValue = number @@ -15,14 +16,6 @@ export const toTxFeeValueUnit = (val?: TxFeeValue): TxFeeValueUnit | undefined = return val <= 1_000 ? 'blocks' : 'sats/kilo-vbyte' } -export const FEE_CONFIG_KEYS = { - tx_fees: { section: 'POLICY', field: 'tx_fees' }, - tx_fees_factor: { section: 'POLICY', field: 'tx_fees_factor' }, - max_cj_fee_abs: { section: 'POLICY', field: 'max_cj_fee_abs' }, - max_cj_fee_rel: { section: 'POLICY', field: 'max_cj_fee_rel' }, - max_sweep_fee_change: { section: 'POLICY', field: 'max_sweep_fee_change' }, -} - export interface FeeValues { tx_fees?: TxFee tx_fees_factor?: number diff --git a/src/utils.test.ts b/src/utils.test.ts index 937199ed6..6662127df 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -7,6 +7,7 @@ import { formatSats, formatBtc, formatBtcDisplayValue, + calcOfferMinsizeMax, } from './utils' describe('shortenStringMiddle', () => { @@ -180,3 +181,41 @@ describe('formatBtcDisplayValue', () => { expect(formatBtcDisplayValue(123456789, { withSymbol: true })).toBe('₿ 1.23 456 789') }) }) + +describe('calcOfferMinsizeMax', () => { + it('should calc offer minsize based on wallet balance', () => { + expect(calcOfferMinsizeMax({})).toBe(0) + expect( + calcOfferMinsizeMax( + { + '0': { + accountIndex: 0, + calculatedTotalBalanceInSats: 21, + calculatedAvailableBalanceInSats: 21, + calculatedFrozenOrLockedBalanceInSats: 0, + }, + }, + 0, + ), + ).toBe(21) + expect( + calcOfferMinsizeMax( + { + '0': { + accountIndex: 0, + calculatedTotalBalanceInSats: 42, + calculatedAvailableBalanceInSats: 41, + calculatedFrozenOrLockedBalanceInSats: 1, + }, + '1': { + accountIndex: 1, + calculatedTotalBalanceInSats: 42_000, + calculatedAvailableBalanceInSats: 1, + calculatedFrozenOrLockedBalanceInSats: 41_999, + }, + }, + 21, + ), + ).toBe(20) + }) +}) diff --git a/src/utils.ts b/src/utils.ts index 9a2db657b..526c6c41d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,6 @@ -import { AmountSats, OfferType, WalletFileName } from './libs/JmWalletApi' +import { JM_DUST_THRESHOLD } from './constants/jm' +import type { AccountBalances } from './context/BalanceSummary' +import type { AmountSats, OfferType, WalletFileName } from './libs/JmWalletApi' const BTC_FORMATTER = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 1, @@ -135,3 +137,15 @@ export const setIntervalDebounced = ( ) })() } + +const calcMaxAvailableBalanceInJar = (accountBalances: AccountBalances) => { + return Math.max(0, Math.max(...Object.values(accountBalances || []).map((it) => it.calculatedAvailableBalanceInSats))) +} + +export const calcOfferMinsizeMax = ( + accountBalances: AccountBalances, + minBufferAmount: AmountSats = JM_DUST_THRESHOLD, +) => { + const maxAvailableBalanceInJar = calcMaxAvailableBalanceInJar(accountBalances) + return Math.max(0, maxAvailableBalanceInJar - minBufferAmount) +}