From 54dd3969d954112af46d746e7fcc6e78db2ef32f Mon Sep 17 00:00:00 2001 From: Thebora Kompanioni Date: Tue, 4 Oct 2022 11:05:36 +0200 Subject: [PATCH] feat: basic fee settings (#522) * feat: add refreshConfigValues to ServiceConfigContext * chore: add api method to post config values * feat: add updateConfigValues to ServiceConfigContext * feat(fees): ability to update fee specific config vars * chore(fees): display error message on update failure * chore(fees): add block sprite and adapt description * ui(fee): wrap form sections in accordion * feat(fees): add ability to set fee randomization factor * ui(fees): use placeholder instead of loading spinner * fix(fees): proper error messages depending on fee value unit * fix(fees): dont decline zero as input value * chore(fees): wording * fix(fees): sats/vbyte mininum is exclusive * refactor: move percentage/factor functions to utils * fix(i18n): number strings * feat(fees): improve wording * fix(i18n): title case for title * feat(fees): remove superfluous label * feat(fees): improve wording * feat(fees): more realistic example * fix(fees): consistency; vbyte -> vByte * fix(fees): consistency; satoshi -> sats * feat(i18n): better verb * feat(fees): maker -> collaborator * Revert "feat(fees): remove superfluous label" This reverts commit 20937aee1d94a5781ec9a9d4729b436db81b91cf. * feat(fees): improve wording around base fee and block target * fix(fees): parse max_cj_fee_abs as integer * fix(fees): min/max value for absolute fee * fix(fees): parse block target as integer * fix(fees): allow 0% maximum relative fee * fix(fees): use 'absurd_fee_per_kb' as upper level for fee * chore(fees): lower max relative fee val from 50% to 20% * fix(fees): let users specify 1sat/vbyte as fee This is special case in that it will be translated to 1.001 sats/vbyte afterwards. This might be surprising to users but it was decided that this is better than telling them that the min is 1.001 from a UX point of view. * fix(fees): add language attribute on form element * feat(fees): improve wording & remove fee from label * feat(fees): better fee randomization wording * feat(fees): better fee label wording * feat(fees): use smaller numbers in example * feat(fees): improve wording * feat(fees): improve wording * feat(fees): improve base fee wording * chore(fees): remove dead code * ui(fees): colorize accordion header on form errors * ui(fees): add info icon to cj fee description text * fix(fees): wording * docs(fees): fix typo * chore: fix comment * review(fees): lower max valid form value for absolute fee * review(fees): lower max valid form value for relative fee Co-authored-by: Gigi <109058+dergigi@users.noreply.github.com> Co-authored-by: Gigi --- public/sprite.svg | 3 + src/components/Earn.jsx | 15 +- src/components/Settings.jsx | 16 + .../settings/FeeConfigModal.module.css | 45 ++ src/components/settings/FeeConfigModal.tsx | 562 ++++++++++++++++++ src/context/ServiceConfigContext.tsx | 125 +++- src/i18n/locales/en/translation.json | 31 +- src/index.css | 44 +- src/libs/JmWalletApi.ts | 21 +- src/utils.test.ts | 54 +- src/utils.ts | 14 + 11 files changed, 864 insertions(+), 66 deletions(-) create mode 100644 src/components/settings/FeeConfigModal.module.css create mode 100644 src/components/settings/FeeConfigModal.tsx diff --git a/public/sprite.svg b/public/sprite.svg index 389f5bdfa..f0224b60a 100644 --- a/public/sprite.svg +++ b/public/sprite.svg @@ -336,4 +336,7 @@ + + + diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx index e5002067d..d75d28334 100644 --- a/src/components/Earn.jsx +++ b/src/components/Earn.jsx @@ -15,6 +15,7 @@ import * as Api from '../libs/JmWalletApi' import styles from './Earn.module.css' import { OrderbookOverlay } from './Orderbook' import Balance from './Balance' +import { factorToPercentage, percentageToFactor } from '../utils' // 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. @@ -73,20 +74,6 @@ const initialFormValues = (settings) => ({ FORM_INPUT_DEFAULT_VALUES.minsize, }) -const percentageToFactor = (val, precision = 6) => { - // Value cannot just be divided - // e.g. ✗ 0.0027 / 100 == 0.000027000000000000002 - // but: ✓ Number((0.0027 / 100).toFixed(6)) = 0.000027 - return Number((val / 100).toFixed(precision)) -} - -const factorToPercentage = (val, precision = 6) => { - // Value cannot just be divided - // e.g. ✗ 0.000027 * 100 == 0.0026999999999999997 - // but: ✓ Number((0.000027 * 100).toFixed(6)) = 0.0027 - return Number((val * 100).toFixed(precision)) -} - const renderOrderType = (val, t) => { if (isAbsoluteOffer(val)) { return {t('earn.current.text_offer_type_absolute')} diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index cce79c109..5f00718cc 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -17,6 +17,7 @@ import { fetchFeatures } from '../libs/JamApi' import { routes } from '../constants/routes' import languages from '../i18n/languages' import styles from './Settings.module.css' +import FeeConfigModal from './settings/FeeConfigModal' function SeedModal({ show = false, onHide }) { const { t } = useTranslation() @@ -91,6 +92,7 @@ function SeedModal({ show = false, onHide }) { export default function Settings({ stopWallet }) { const [showingSeed, setShowingSeed] = useState(false) + const [showingFeeConfig, setShowingFeeConfig] = useState(false) const [lockingWallet, setLockingWallet] = useState(false) const [showConfirmLockModal, setShowConfirmLockModal] = useState(null) const [showLogsEnabled, setShowLogsEnabled] = useState(false) @@ -235,6 +237,20 @@ export default function Settings({ stopWallet }) { + {currentWallet && ( + <> + setShowingFeeConfig(true)} + > + + {t('settings.show_fee_config')} + + {showingFeeConfig && setShowingFeeConfig(false)} />} + + )} + {currentWallet && showLogsEnabled && ( <> setShowingLogs(true)}> diff --git a/src/components/settings/FeeConfigModal.module.css b/src/components/settings/FeeConfigModal.module.css new file mode 100644 index 000000000..94194bab6 --- /dev/null +++ b/src/components/settings/FeeConfigModal.module.css @@ -0,0 +1,45 @@ +.feeConfigModal .accordionLoader { + height: 3.25rem; + margin: 1px 0; +} + +.feeConfigModal form input:not([type='checkbox']) { + height: 3.5rem; +} + +.feeConfigModal form :global .form-label { + margin-bottom: 0 !important; +} +.feeConfigModal form :global .form-label ~ .form-text { + display: block; + margin-top: 0 !important; + margin-bottom: 0.5rem !important; +} + +.infoIcon { + border: 1px solid; + border-radius: 50%; +} + +.inputGroupText { + width: 5ch; + display: inline-flex; + justify-content: center; + align-items: center; + font-size: 1.2rem; +} + +.modalFooter .buttonContainer { + width: 100%; + display: flex !important; + justify-content: center !important; + gap: 1rem; + background-color: transparent !important; +} + +.modalFooter .buttonContainer :global .btn { + flex-grow: 1; + min-height: 2.8rem; + font-weight: 500; + border-color: none !important; +} diff --git a/src/components/settings/FeeConfigModal.tsx b/src/components/settings/FeeConfigModal.tsx new file mode 100644 index 000000000..b2cc0278f --- /dev/null +++ b/src/components/settings/FeeConfigModal.tsx @@ -0,0 +1,562 @@ +import { forwardRef, useRef, useCallback, useEffect, useState } from 'react' +import * as rb from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { Formik, FormikErrors } from 'formik' +import classNames from 'classnames' +import { useRefreshConfigValues, useUpdateConfigValues } from '../../context/ServiceConfigContext' +import { factorToPercentage, percentageToFactor } from '../../utils' +import Sprite from '../Sprite' +import styles from './FeeConfigModal.module.css' +import SegmentedTabs from '../SegmentedTabs' + +type SatsPerKiloVByte = number + +const TX_FEES_BLOCKS_MIN = 1 +const TX_FEES_BLOCKS_MAX = 1_000 +const TX_FEES_SATSPERKILOVBYTE_MIN: SatsPerKiloVByte = 1_000 // 1 sat/vbyte +// 350 sats/vbyte - no enforcement by JM - this should be a "sane" max value (taken default value of "absurd_fee_per_kb") +const TX_FEES_SATSPERKILOVBYTE_MAX: SatsPerKiloVByte = 350_000 +const TX_FEES_SATSPERKILOVBYTE_ADJUSTED_MIN = 1_001 // actual min of `tx_fees` if unit is sats/kilo-vbyte +const TX_FEES_FACTOR_DEFAULT_VAL = 0.2 // 20% +const TX_FEES_FACTOR_MIN = 0 // 0% +const TX_FEES_FACTOR_MAX = 1 // 100% +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 isValidNumber = (val: number | undefined) => typeof val === 'number' && !isNaN(val) + +const FEE_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' }, +} + +interface FeeConfigModalProps { + show: boolean + onHide: () => void +} + +type TxFeeValueUnit = 'blocks' | 'sats/kilo-vbyte' + +interface FeeValues { + tx_fees?: number + tx_fees_factor?: number + max_cj_fee_abs?: number + max_cj_fee_rel?: number +} + +interface FeeConfigFormProps { + initialValues: FeeValues + validate: (values: FeeValues, txFeesUnit: TxFeeValueUnit) => FormikErrors + onSubmit: (values: FeeValues, txFeesUnit: TxFeeValueUnit) => void +} + +const FeeConfigForm = forwardRef( + ({ onSubmit, validate, initialValues }: FeeConfigFormProps, ref: React.Ref) => { + const { t, i18n } = useTranslation() + + const [txFeesUnit, setTxFeesUnit] = useState( + initialValues.tx_fees && initialValues.tx_fees > 1_000 ? 'sats/kilo-vbyte' : 'blocks' + ) + + return ( + validate(values, txFeesUnit)} + onSubmit={(values) => onSubmit(values, txFeesUnit)} + > + {({ handleSubmit, setFieldValue, handleBlur, validateForm, values, touched, errors, isSubmitting }) => ( + + + + + + {t('settings.fees.title_general_fee_settings')} + + + + {t('settings.fees.label_tx_fees')} + {txFeesUnit && ( + + { + const value = tab.value as TxFeeValueUnit + setTxFeesUnit(value) + + if (values.tx_fees) { + if (value === 'sats/kilo-vbyte') { + setFieldValue('tx_fees', values.tx_fees * 1_000, false) + } else { + setFieldValue('tx_fees', Math.round(values.tx_fees / 1_000), false) + } + } + setTimeout(() => validateForm(), 4) + }} + initialValue={txFeesUnit} + disabled={isSubmitting} + /> + + )} + + {t( + txFeesUnit === 'sats/kilo-vbyte' + ? 'settings.fees.description_tx_fees_satspervbyte' + : 'settings.fees.description_tx_fees_blocks' + )} + + + + + {txFeesUnit === 'sats/kilo-vbyte' ? ( + <> + / vB + + ) : ( + + )} + + + {txFeesUnit === 'sats/kilo-vbyte' ? ( + { + const value = parseFloat(e.target.value) + setFieldValue('tx_fees', value * 1_000, true) + }} + isValid={touched.tx_fees && !errors.tx_fees} + isInvalid={touched.tx_fees && !!errors.tx_fees} + min={TX_FEES_SATSPERKILOVBYTE_MIN / 1_000} + max={TX_FEES_SATSPERKILOVBYTE_MAX / 1_000} + step={0.001} + /> + ) : ( + { + const value = parseInt(e.target.value, 10) + setFieldValue('tx_fees', value, true) + }} + isValid={touched.tx_fees && !errors.tx_fees} + isInvalid={touched.tx_fees && !!errors.tx_fees} + min={TX_FEES_BLOCKS_MIN} + max={TX_FEES_BLOCKS_MAX} + step={1} + /> + )} + {errors.tx_fees} + + + + + + {t('settings.fees.label_tx_fees_factor', { + fee: isValidNumber(values.tx_fees_factor) + ? `(${factorToPercentage(values.tx_fees_factor!)}%)` + : '', + })} + + {t('settings.fees.description_tx_fees_factor')} + + + % + + { + const value = parseFloat(e.target.value) + setFieldValue('tx_fees_factor', percentageToFactor(value), true) + }} + isValid={touched.tx_fees_factor && !errors.tx_fees_factor} + isInvalid={touched.tx_fees_factor && !!errors.tx_fees_factor} + min={factorToPercentage(TX_FEES_FACTOR_MIN)} + max={factorToPercentage(TX_FEES_FACTOR_MAX)} + step={0.01} + /> + {errors.tx_fees_factor} + + + + + + + + {t('settings.fees.title_max_cj_fee_settings')} + + + + + + {t('settings.fees.subtitle_max_cj_fee')} + + + {t('settings.fees.label_max_cj_fee_abs')} + {t('settings.fees.description_max_cj_fee_abs')} + + + + + { + const value = parseInt(e.target.value, 10) + setFieldValue('max_cj_fee_abs', value, true) + }} + isValid={touched.max_cj_fee_abs && !errors.max_cj_fee_abs} + isInvalid={touched.max_cj_fee_abs && !!errors.max_cj_fee_abs} + min={CJ_FEE_ABS_MIN} + max={CJ_FEE_ABS_MAX} + step={1} + /> + {errors.max_cj_fee_abs} + + + + + + {t('settings.fees.label_max_cj_fee_rel', { + fee: isValidNumber(values.max_cj_fee_rel) + ? `(${factorToPercentage(values.max_cj_fee_rel!)}%)` + : '', + })} + + {t('settings.fees.description_max_cj_fee_rel')} + + + % + + { + const value = parseFloat(e.target.value) + setFieldValue('max_cj_fee_rel', percentageToFactor(value), true) + }} + isValid={touched.max_cj_fee_rel && !errors.max_cj_fee_rel} + isInvalid={touched.max_cj_fee_rel && !!errors.max_cj_fee_rel} + min={factorToPercentage(CJ_FEE_REL_MIN)} + max={factorToPercentage(CJ_FEE_REL_MAX)} + step={0.0001} + /> + {errors.max_cj_fee_rel} + + + + + + + )} + + ) + } +) + +export default function FeeConfigModal({ show, onHide }: FeeConfigModalProps) { + const { t } = useTranslation() + const refreshConfigValues = useRefreshConfigValues() + const updateConfigValues = useUpdateConfigValues() + const [isLoading, setIsLoading] = useState(true) + const [isSubmitting, setIsSubmitting] = useState(false) + const [loadError, setLoadError] = useState(false) + const [saveErrorMessage, setSaveErrorMessage] = useState(undefined) + const [feeConfigValues, setFeeConfigValues] = useState(null) + const formRef = useRef(null) + + useEffect(() => { + const loadFeeValues = async (signal: AbortSignal) => { + setLoadError(false) + setIsLoading(true) + + try { + const serviceConfig = await refreshConfigValues({ + signal, + keys: Object.values(FEE_KEYS), + }) + + setIsLoading(false) + + const policy = serviceConfig['POLICY'] || {} + + const feeValues: FeeValues = { + tx_fees: parseInt(policy.tx_fees || '', 10) || undefined, + tx_fees_factor: parseFloat(policy.tx_fees_factor || `${TX_FEES_FACTOR_DEFAULT_VAL}`), + max_cj_fee_abs: parseInt(policy.max_cj_fee_abs || '', 10) || undefined, + max_cj_fee_rel: parseFloat(policy.max_cj_fee_rel || '') || undefined, + } + setFeeConfigValues(feeValues) + } catch (e) { + if (!signal.aborted) { + setIsLoading(false) + setLoadError(true) + } + } + } + + const abortCtrl = new AbortController() + if (show) { + loadFeeValues(abortCtrl.signal) + } else { + setLoadError(false) + setSaveErrorMessage(undefined) + } + + return () => { + abortCtrl.abort() + } + }, [show, refreshConfigValues]) + + const submit = async (feeValues: FeeValues, txFeesUnit: TxFeeValueUnit) => { + const allValuesPresent = Object.values(feeValues).every((it) => it !== undefined) + if (!allValuesPresent) return + + let adjustedTxFees = feeValues.tx_fees! + if (txFeesUnit === 'sats/kilo-vbyte') { + // There is one special case for value `tx_fees`: + // Users are allowed to specify the value in "sats/vbyte", but this might + // be interpreted by JM as "targeted blocks". This adaption makes sure + // that it is in fact closer to what the user actually expects, albeit it + // can be surprising that the value is slightly different as specified. + adjustedTxFees = Math.max(adjustedTxFees, TX_FEES_SATSPERKILOVBYTE_ADJUSTED_MIN) + } + + const updates = [ + { + key: FEE_KEYS.tx_fees, + value: String(adjustedTxFees), + }, + { + key: FEE_KEYS.tx_fees_factor, + value: String(feeValues.tx_fees_factor), + }, + { + key: FEE_KEYS.max_cj_fee_abs, + value: String(feeValues.max_cj_fee_abs), + }, + { + key: FEE_KEYS.max_cj_fee_rel, + value: String(feeValues.max_cj_fee_rel), + }, + ] + + setSaveErrorMessage(undefined) + setIsSubmitting(true) + try { + await updateConfigValues({ updates }) + + setIsSubmitting(false) + onHide() + } catch (err) { + setIsSubmitting(false) + setSaveErrorMessage( + t('settings.fees.error_saving_fee_config_failed', { + reason: err instanceof Error ? err.message : 'Unknown', + }) + ) + } + } + + const validate = useCallback( + (values: FeeValues, txFeesUnit: TxFeeValueUnit) => { + const errors = {} as FormikErrors + + if ( + !isValidNumber(values.tx_fees_factor) || + values.tx_fees_factor! < TX_FEES_FACTOR_MIN || + values.tx_fees_factor! > TX_FEES_FACTOR_MAX + ) { + errors.tx_fees_factor = t('settings.fees.feedback_invalid_tx_fees_factor', { + min: `${factorToPercentage(TX_FEES_FACTOR_MIN)}%`, + max: `${factorToPercentage(TX_FEES_FACTOR_MAX)}%`, + }) + } + + if (txFeesUnit === 'sats/kilo-vbyte') { + if ( + !isValidNumber(values.tx_fees) || + values.tx_fees! < TX_FEES_SATSPERKILOVBYTE_MIN || + values.tx_fees! > TX_FEES_SATSPERKILOVBYTE_MAX + ) { + errors.tx_fees = t('settings.fees.feedback_invalid_tx_fees_satspervbyte', { + min: (TX_FEES_SATSPERKILOVBYTE_MIN / 1_000).toLocaleString(undefined, { + maximumFractionDigits: Math.log10(1_000), + }), + max: (TX_FEES_SATSPERKILOVBYTE_MAX / 1_000).toLocaleString(undefined, { + maximumFractionDigits: Math.log10(1_000), + }), + }) + } + } else { + if ( + !isValidNumber(values.tx_fees) || + values.tx_fees! < TX_FEES_BLOCKS_MIN || + values.tx_fees! > TX_FEES_BLOCKS_MAX + ) { + errors.tx_fees = t('settings.fees.feedback_invalid_tx_fees_blocks', { + min: TX_FEES_BLOCKS_MIN.toLocaleString(), + max: TX_FEES_BLOCKS_MAX.toLocaleString(), + }) + } + } + + if ( + !isValidNumber(values.max_cj_fee_abs) || + values.max_cj_fee_abs! < CJ_FEE_ABS_MIN || + values.max_cj_fee_abs! > CJ_FEE_ABS_MAX + ) { + errors.max_cj_fee_abs = t('settings.fees.feedback_invalid_max_cj_fee_abs', { + min: CJ_FEE_ABS_MIN.toLocaleString(), + max: CJ_FEE_ABS_MAX.toLocaleString(), + }) + } + + if ( + !isValidNumber(values.max_cj_fee_rel) || + values.max_cj_fee_rel! <= CJ_FEE_REL_MIN || + values.max_cj_fee_rel! > CJ_FEE_REL_MAX + ) { + errors.max_cj_fee_rel = t('settings.fees.feedback_invalid_max_cj_fee_rel', { + min: `${factorToPercentage(CJ_FEE_REL_MIN)}%`, + max: `${factorToPercentage(CJ_FEE_REL_MAX)}%`, + }) + } + return errors + }, + [t] + ) + + const cancel = () => { + onHide() + } + + return ( + + + {t('settings.fees.title')} + + + <> +
{t('settings.fees.description')}
+ {loadError && ( + + {t('settings.fees.error_loading_fee_config_failed')} + + )} + {isLoading ? ( + <> + {Array(2) + .fill('') + .map((_, index) => { + return ( + + + + ) + })} + + ) : ( + <> + {feeConfigValues && ( + + )} + + )} + +
+ + {saveErrorMessage && ( + + {saveErrorMessage} + + )} +
+ + {t('settings.fees.text_button_cancel')} + + formRef.current?.requestSubmit()} + > + {isSubmitting ? ( + <> + +
+
+
+ ) +} diff --git a/src/context/ServiceConfigContext.tsx b/src/context/ServiceConfigContext.tsx index 80d5a65e2..cf0bd6496 100644 --- a/src/context/ServiceConfigContext.tsx +++ b/src/context/ServiceConfigContext.tsx @@ -8,27 +8,37 @@ interface JmConfigData { configvalue: string } -type SectionKey = string +export type SectionKey = string -interface ServiceConfig { +export interface ServiceConfig { [key: SectionKey]: Record } -interface ConfigKey { +export interface ConfigKey { section: SectionKey field: string } -interface ServiceConfigUpdate { +export interface ServiceConfigUpdate { key: ConfigKey value: string } type LoadConfigValueProps = { - signal: AbortSignal + signal?: AbortSignal key: ConfigKey } +type RefreshConfigValuesProps = { + signal?: AbortSignal + keys: ConfigKey[] +} + +type UpdateConfigValuesProps = { + signal?: AbortSignal + updates: ServiceConfigUpdate[] +} + const configReducer = (state: ServiceConfig, obj: ServiceConfigUpdate): ServiceConfig => { const data = { ...state } data[obj.key.section] = { ...data[obj.key.section], [obj.key.field]: obj.value } @@ -40,16 +50,13 @@ const fetchConfigValues = async ({ wallet, configKeys, }: { - signal: AbortSignal + signal?: AbortSignal wallet: CurrentWallet configKeys: ConfigKey[] }) => { const { name: walletName, token } = wallet const fetches: Promise[] = configKeys.map((configKey) => { - return Api.postConfigGet( - { walletName, token, signal }, - { section: configKey.section.toString(), field: configKey.field } - ) + return Api.postConfigGet({ walletName, token, signal }, { section: configKey.section, field: configKey.field }) .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res))) .then((data: JmConfigData) => { return { @@ -62,8 +69,36 @@ const fetchConfigValues = async ({ return Promise.all(fetches) } +const pushConfigValues = async ({ + signal, + wallet, + updates, +}: { + signal?: AbortSignal + wallet: CurrentWallet + updates: ServiceConfigUpdate[] +}) => { + const { name: walletName, token } = wallet + const fetches: Promise[] = updates.map((update) => { + return Api.postConfigSet( + { walletName, token, signal }, + { + section: update.key.section, + field: update.key.field, + value: update.value, + } + ) + .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res))) + .then((_) => update) + }) + + return Promise.all(fetches) +} + interface ServiceConfigContextEntry { - loadConfigValue: (props: LoadConfigValueProps) => Promise + loadConfigValueIfAbsent: (props: LoadConfigValueProps) => Promise + refreshConfigValues: (props: RefreshConfigValuesProps) => Promise + updateConfigValues: (props: UpdateConfigValuesProps) => Promise } const ServiceConfigContext = createContext(undefined) @@ -72,17 +107,16 @@ const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => { const currentWallet = useCurrentWallet() const serviceConfig = useRef(null) - const updateServiceConfig = useCallback( - async ({ signal, configKeys }: { signal: AbortSignal; configKeys: ConfigKey[] }) => { + const refreshConfigValues = useCallback( + async ({ signal, keys }: RefreshConfigValuesProps) => { if (!currentWallet) { throw new Error('Cannot load config: Wallet not present') } - const configUpdates = fetchConfigValues({ signal, wallet: currentWallet, configKeys }) - return configUpdates + return fetchConfigValues({ signal, wallet: currentWallet, configKeys: keys }) .then((updates) => updates.reduce(configReducer, serviceConfig.current || {})) .then((result) => { - if (!signal.aborted) { + if (!signal || !signal.aborted) { serviceConfig.current = result if (process.env.NODE_ENV === 'development') { console.debug('service config updated', serviceConfig.current) @@ -108,14 +142,35 @@ const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => { } } - return updateServiceConfig({ signal, configKeys: [key] }).then((conf) => { + return refreshConfigValues({ signal, keys: [key] }).then((conf) => { return { key, value: conf[key.section][key.field], } as ServiceConfigUpdate }) }, - [updateServiceConfig] + [refreshConfigValues] + ) + + const updateConfigValues = useCallback( + async ({ signal, updates }: UpdateConfigValuesProps) => { + if (!currentWallet) { + throw new Error('Cannot load config: Wallet not present') + } + + return pushConfigValues({ signal, wallet: currentWallet, updates }) + .then((updates) => updates.reduce(configReducer, serviceConfig.current || {})) + .then((result) => { + if (!signal || !signal.aborted) { + serviceConfig.current = result + if (process.env.NODE_ENV === 'development') { + console.debug('service config updated', serviceConfig.current) + } + } + return result + }) + }, + [currentWallet] ) useEffect(() => { @@ -126,7 +181,13 @@ const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => { }, [currentWallet]) return ( - + {children} ) @@ -137,7 +198,29 @@ const useLoadConfigValue = () => { if (context === undefined) { throw new Error('useLoadConfigValue must be used within a ServiceConfigProvider') } - return context.loadConfigValue + return context.loadConfigValueIfAbsent +} + +const useRefreshConfigValues = () => { + const context = useContext(ServiceConfigContext) + if (context === undefined) { + throw new Error('useRefreshConfigValues must be used within a ServiceConfigProvider') + } + return context.refreshConfigValues } -export { ServiceConfigContext, ServiceConfigProvider, useLoadConfigValue } +const useUpdateConfigValues = () => { + const context = useContext(ServiceConfigContext) + if (context === undefined) { + throw new Error('useUpdateConfigValues must be used within a ServiceConfigProvider') + } + return context.updateConfigValues +} + +export { + ServiceConfigContext, + ServiceConfigProvider, + useLoadConfigValue, + useRefreshConfigValues, + useUpdateConfigValues, +} diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 5422c46b9..7059299e9 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -154,6 +154,7 @@ "hide_seed": "Hide seed phrase", "reveal_seed": "Reveal seed phrase", "show_logs": "Show logs", + "show_fee_config": "Adjust fee limits", "button_lock_wallet": "Lock wallet", "button_locking_wallet": "Locking...", "button_switch_wallet": "Switch wallet", @@ -168,7 +169,35 @@ "jam_twitter": "Jam Twitter", "confirm_locking_modal_title": "Lock Wallet", "confirm_locking_modal_body_earn": "Earn is active. Locking the wallet will stop the service. You can close the browser window to let it run in the background.", - "confirm_locking_modal_body_jam": "A collaborative transaction is being executed. Locking the wallet will stop the service. You can close the browser window to let it run in the background." + "confirm_locking_modal_body_jam": "A collaborative transaction is being executed. Locking the wallet will stop the service. You can close the browser window to let it run in the background.", + "fees": { + "title": "Fee Limits", + "description": "Adjust mining fees and collaborator fees according to your needs. Mining fees relate to transaction priority and depend on mempool conditions. Collaborator fees relate to liquidity price and depend on market conditions. Total fees paid for each transaction depend on the amount of collaborators. Additional collaborators increase privacy, but also fees. These settings will be reset to default values when the JoinMarket service restarts, e.g. on a system reboot.", + "title_general_fee_settings": "Mining fees", + "radio_tx_fees_blocks": "Block target", + "radio_tx_fees_satspervbyte": "sats/vByte", + "label_tx_fees": "Transaction base fee", + "description_tx_fees_blocks": "When using the block target setting, Jam will use Bitcoin Core's fee estimation mechanism to set the base fee dynamically, depending on mempool activity. Default: 3.", + "description_tx_fees_satspervbyte": "Setting the base fee in sats/vByte will override the block target setting. Check your mempool to set this fee accordingly.", + "feedback_invalid_tx_fees_blocks": "Please provide a valid block target between {{ min }} and {{ max }}.", + "feedback_invalid_tx_fees_satspervbyte": "Please provide a valid transaction fee in sats/vByte between {{ min }} and {{ max }}.", + "label_tx_fees_factor": "Fee randomization", + "description_tx_fees_factor": "Random fees improve privacy. The percentage is to be understood as a +/- around the base fee. Example: If you set the base fee to 10 sats/vByte and the randomization to 30%, a value between 7 and 13 sats/vByte will be used. Default: 20%.", + "feedback_invalid_tx_fees_factor": "Please provide a valid fee randomization value between {{ min }} and {{ max }}.", + "title_max_cj_fee_settings": "Collaborator fees", + "subtitle_max_cj_fee": "Note: An offer will be rejected only if both limits are exceeded.", + "label_max_cj_fee_abs": "Absolute limit (per collaborator)", + "description_max_cj_fee_abs": "The maximum fee you are willing to pay per collaborator. Example: when set to 3,000 sats, the maximum fee in total would be 27,000 sats when using the default of 9 collaborators.", + "feedback_invalid_max_cj_fee_abs": "Please provide a valid maximum absolute fee in sats between {{ min }} and {{ max }}.", + "label_max_cj_fee_rel": "Relative limit (per collaborator)", + "description_max_cj_fee_rel": "The maximum fee you are willing to pay per collaborator, as a percentage of the transaction amount. Example: if you send 2 million sats and the maximum fee is set to 0.1%, you will at most pay 2,000 sats to a single collaborator.", + "feedback_invalid_max_cj_fee_rel": "Please provide a valid maximum relative fee between {{ min }} and {{ max }}.", + "text_button_cancel": "Cancel", + "text_button_submit": "Save", + "text_button_submitting": "Saving...", + "error_loading_fee_config_failed": "Error while loading fee config values. ", + "error_saving_fee_config_failed": "Error while saving fee config values. Reason: {{ reason }}" + } }, "logs": { "title": "Logs", diff --git a/src/index.css b/src/index.css index 6a76392b8..da637a5b1 100644 --- a/src/index.css +++ b/src/index.css @@ -535,21 +535,14 @@ h2 { cursor: not-allowed; } -/* Account Overlay */ - -.accordion-header .accordion-button:not(.collapsed) { - /* Disable coloring for expanded headers */ - color: var(--bs-black); - background-color: transparent; -} - -.accordion-header .accordion-button:not(.collapsed)::after { - /* Disable coloring for expanded headers */ - background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23212529%27%3e%3cpath fill-rule=%27evenodd%27 d=%27M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z%27/%3e%3c/svg%3e'); -} - -:root[data-theme='dark'] .accordion-header .accordion-button:not(.collapsed)::after { - background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23fff%27%3e%3cpath fill-rule=%27evenodd%27 d=%27M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z%27/%3e%3c/svg%3e'); +.accordion { + --bs-accordion-color: var(--bs-black); + --bs-accordion-active-color: var(--bs-black); + --bs-accordion-bg: transparent; + --bs-accordion-active-bg: transparent; + --bs-accordion-btn-color: var(--bs-black); + --bs-accordion-btn-icon: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23212529%27%3e%3cpath fill-rule=%27evenodd%27 d=%27M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z%27/%3e%3c/svg%3e'); + --bs-accordion-btn-active-icon: var(--bs-accordion-btn-icon); } /* Theme overrides */ @@ -563,6 +556,14 @@ h2 { --bs-black: #000; } +:root[data-theme='dark'] .accordion { + --bs-accordion-color: var(--bs-white); + --bs-accordion-active-color: var(--bs-white); + --bs-accordion-btn-color: var(--bs-white); + --bs-accordion-btn-icon: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23fff%27%3e%3cpath fill-rule=%27evenodd%27 d=%27M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z%27/%3e%3c/svg%3e'); + --bs-accordion-btn-active-icon: var(--bs-accordion-btn-icon); +} + :root[data-theme='dark'] a.nav-link.active { border-color: var(--bs-white) !important; } @@ -590,19 +591,6 @@ h2 { border-color: var(--bs-gray-dark) !important; } -:root[data-theme='dark'] .accordion { - --bs-accordion-color: var(--bs-white); - --bs-accordion-bg: transparent; -} - -:root[data-theme='dark'] .accordion-button { - color: var(--bs-accordion-color); -} - -:root[data-theme='dark'] .accordion-button.collapsed::after { - background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23fff%27%3e%3cpath fill-rule=%27evenodd%27 d=%27M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z%27/%3e%3c/svg%3e'); -} - :root[data-theme='dark'] .btn-outline-dark { color: var(--bs-white); border-color: var(--bs-gray-800); diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts index 51ec8ffef..44f6aece5 100644 --- a/src/libs/JmWalletApi.ts +++ b/src/libs/JmWalletApi.ts @@ -97,6 +97,12 @@ interface FreezeRequest { interface ConfigSetRequest { section: string field: string + value: string +} + +interface ConfigGetRequest { + section: string + field: string } interface StartSchedulerRequest { @@ -372,12 +378,24 @@ const getSchedule = async ({ token, signal, walletName }: WalletRequestContext) }) } +/** + * Change a config variable (for the duration of this backend daemon process instance). + */ +const postConfigSet = async ({ token, signal, walletName }: WalletRequestContext, req: ConfigSetRequest) => { + return await fetch(`${basePath()}/v1/wallet/${encodeURIComponent(walletName)}/configset`, { + method: 'POST', + headers: { ...Helper.buildAuthHeader(token) }, + body: JSON.stringify(req), + signal, + }) +} + /** * Get the value of a specific config setting. Note that values are always returned as string. * * @returns an object with property `configvalue` as string */ -const postConfigGet = async ({ token, signal, walletName }: WalletRequestContext, req: ConfigSetRequest) => { +const postConfigGet = async ({ token, signal, walletName }: WalletRequestContext, req: ConfigGetRequest) => { return await fetch(`${basePath()}/v1/wallet/${encodeURIComponent(walletName)}/configget`, { method: 'POST', headers: { ...Helper.buildAuthHeader(token) }, @@ -411,6 +429,7 @@ export { getWalletUtxos, getYieldgenReport, postFreeze, + postConfigSet, postConfigGet, getWalletSeed, postSchedulerStart, diff --git a/src/utils.test.ts b/src/utils.test.ts index d7276d90a..50127a81b 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,4 +1,4 @@ -import { shortenStringMiddle } from './utils' +import { shortenStringMiddle, percentageToFactor, factorToPercentage } from './utils' describe('shortenStringMiddle', () => { it('should shorten string in the middle', () => { @@ -17,3 +17,55 @@ describe('shortenStringMiddle', () => { expect(shortenStringMiddle('0123456789abcdef', 32)).toBe('0123456789abcdef') }) }) + +describe('factorToPercentage/percentageToFactor', () => { + describe('factorToPercentage', () => { + it('should turn factor to percentage', () => { + expect(factorToPercentage(NaN)).toBe(NaN) + expect(factorToPercentage(-1)).toBe(-100) + expect(factorToPercentage(0)).toBe(0) + expect(factorToPercentage(0.0027)).toBe(0.27) + expect(factorToPercentage(0.0027, 1)).toBe(0.3) + expect(factorToPercentage(0.0027, 0)).toBe(0) + expect(factorToPercentage(1 / 3, 3)).toBe(33.333) + expect(factorToPercentage(1 / 3, 8)).toBe(33.33333333) + expect(factorToPercentage(2 / 3, 10)).toBe(66.6666666667) + expect(factorToPercentage(0.7)).toBe(70) + expect(factorToPercentage(1)).toBe(100) + }) + }) + + describe('percentageToFactor', () => { + it('should turn percentage to factor', () => { + expect(percentageToFactor(NaN)).toBe(NaN) + expect(percentageToFactor(-1)).toBe(-0.01) + expect(percentageToFactor(0)).toBe(0) + expect(percentageToFactor(0.0027)).toBe(0.000027) + expect(percentageToFactor(0.0027, 5)).toBe(0.00003) + expect(percentageToFactor(0.0027, 4)).toBe(0) + expect(percentageToFactor(1 / 3, 3)).toBe(0.003) + expect(percentageToFactor(1 / 3, 8)).toBe(0.00333333) + expect(percentageToFactor(2 / 3, 10)).toBe(0.0066666667) + expect(percentageToFactor(0.7)).toBe(0.007) + expect(percentageToFactor(1)).toBe(0.01) + expect(percentageToFactor(33)).toBe(0.33) + expect(percentageToFactor(100)).toBe(1) + expect(percentageToFactor(233.7)).toBe(2.337) + }) + }) + + it('functions are inverse', () => { + const testInverse = (val: number) => percentageToFactor(factorToPercentage(val)) + + expect(testInverse(NaN)).toBe(NaN) + expect(testInverse(0)).toBe(0) + expect(testInverse(0.0027)).toBe(0.0027) + expect(testInverse(0.7)).toBe(0.7) + expect(testInverse(1)).toBe(1) + expect(testInverse(1 / 3)).toBe(0.333333) + expect(testInverse(2 / 3)).toBe(0.666667) + expect(testInverse(33)).toBe(33) + expect(testInverse(100)).toBe(100) + expect(testInverse(233.7)).toBe(233.7) + }) +}) diff --git a/src/utils.ts b/src/utils.ts index d353e530b..62fa6d671 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -46,3 +46,17 @@ export const shortenStringMiddle = (value: string, chars = 8, separator = '…') } return `${value.substring(0, prefixLength)}${separator}${value.substring(value.length - prefixLength)}` } + +export const percentageToFactor = (val: number, precision = 6) => { + // Value cannot just be divided + // e.g. ✗ 0.0027 / 100 == 0.000027000000000000002 + // but: ✓ Number((0.0027 / 100).toFixed(6)) = 0.000027 + return Number((val / 100).toFixed(precision)) +} + +export const factorToPercentage = (val: number, precision = 6) => { + // Value cannot just be multiplied + // e.g. ✗ 0.000027 * 100 == 0.0026999999999999997 + // but: ✓ Number((0.000027 * 100).toFixed(6)) = 0.0027 + return Number((val * 100).toFixed(precision)) +}