Skip to content

Commit

Permalink
fix: relative fee greater than zero (joinmarket-webui#862)
Browse files Browse the repository at this point in the history
  • Loading branch information
theborakompanioni authored and barrytra committed Nov 2, 2024
1 parent cb9621c commit 4afde04
Show file tree
Hide file tree
Showing 13 changed files with 133 additions and 71 deletions.
66 changes: 27 additions & 39 deletions src/components/Earn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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<EarnFormValues>
Expand All @@ -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')
}
}
Expand All @@ -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(),
})
}
Expand All @@ -277,15 +273,7 @@ const EarnForm = ({
}

return (
<Formik
initialValues={initialValues}
validate={validate}
onSubmit={onSubmit}
validateOnMount={true}
initialTouched={{
minsize: true,
}}
>
<Formik initialValues={initialValues} validate={validate} onSubmit={onSubmit}>
{(props) => {
const { handleSubmit, setFieldValue, handleBlur, values, touched, errors, isSubmitting } = props
const minsizeField = props.getFieldProps<AmountValue>('minsize')
Expand Down Expand Up @@ -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)}
/>
<rb.Form.Control.Feedback type="invalid">{errors.feeRel}</rb.Form.Control.Feedback>
</rb.InputGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ImportWallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/components/Orderbook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 1 addition & 1 deletion src/components/Send/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 12 additions & 17 deletions src/components/settings/FeeConfigModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/constants/jam.ts
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions src/constants/config.ts → src/constants/jm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ConfigKey> = {
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' },
}
2 changes: 1 addition & 1 deletion src/context/ServiceInfoContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/context/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 1 addition & 1 deletion src/hooks/CoinjoinRequirements.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 1 addition & 8 deletions src/hooks/Fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
formatSats,
formatBtc,
formatBtcDisplayValue,
calcOfferMinsizeMax,
} from './utils'

describe('shortenStringMiddle', () => {
Expand Down Expand Up @@ -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)
})
})
16 changes: 15 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
}

0 comments on commit 4afde04

Please sign in to comment.