chore(fee-dialog): add nested TxFeeForm

This commit is contained in:
theborakompanioni 2026-04-23 21:39:34 +02:00 committed by Thebora Kompanioni
parent 98920cf279
commit efe4b2e5e7
13 changed files with 194 additions and 417 deletions

View file

@ -161,7 +161,7 @@ export const createSendFormSchema = (
}),
})
// eslint-disable-next-line unicorn/prefer-spread -- false positive
.concat(createTxFeeFormSchema(t))
.concat(createTxFeeFormSchema({ t }))
.required()
.test('address-not-from-source-jar-test', function (root) {
// Note: `fromJar` might still be `undefined` at this point

View file

@ -155,12 +155,19 @@ export function SendForm({
SendFormValues
>({
mode: 'onSubmit',
disabled,
defaultValues,
// force type (see https://github.com/react-hook-form/resolvers/issues/807)
resolver: yupResolver(schema) as Resolver<SendFormValues, unknown, SendFormValues>,
})
const { errors, isSubmitting, isValid } = formState
const { errors, isValid, isSubmitting } = useMemo(
() => ({
errors: formState.errors,
isValid: formState.isValid,
isSubmitting: formState.isSubmitting,
}),
[formState.errors, formState.isValid, formState.isSubmitting],
)
const values = useWatch({ control })
const sourceJarIndex = useWatch({ control, name: 'source.fromJar' })
@ -598,75 +605,7 @@ export function SendForm({
)}
</div>
)}
<TxFeeForm />
{/*<div className="space-y-4">
<Tabs value={txFeeUnitWatch}>
<TabsContent value={txFeeUnit.BLOCKS}>
<div className="space-y-2">
<Field data-invalid={errors.txFeeInBlocks !== undefined}>
<FieldLabel htmlFor="txFeeInBlocks">{t('send.label_tx_fees')}</FieldLabel>
<FieldDescription>{t('settings.fees.description_tx_fees_blocks')}</FieldDescription>
<InputGroup>
<InputGroupInput
id="txFeeInBlocks"
{...register('txFeeInBlocks', {
disabled,
})}
type="number"
min={MIN_TX_FEE_IN_BLOCKS}
max={MAX_TX_FEE_IN_BLOCKS}
step={1}
/>
<InputGroupAddon align="inline-start">
<BlocksIcon />
</InputGroupAddon>
</InputGroup>
</Field>
{errors.txFeeInBlocks?.message && (
<div className="text-destructive text-xs">{errors.txFeeInBlocks.message}</div>
)}
</div>
</TabsContent>
<TabsContent value={txFeeUnit.SATS_PER_KILO_VBYTE}>
<div className="space-y-2">
<Field data-invalid={errors.txFeeInSatsPerVbyte !== undefined}>
<FieldLabel htmlFor="txFeeInSatsPerVbyte">{t('send.label_tx_fees')}</FieldLabel>
<FieldDescription>{t('settings.fees.description_tx_fees_satspervbyte')}</FieldDescription>
<InputGroup>
<InputGroupInput
id="txFeeInSatsPerVbyte"
{...register('txFeeInSatsPerVbyte', {
disabled,
})}
type="number"
step={1}
/>
<InputGroupAddon align="inline-start">
<CurrencySymbol currency="sats" />
<span className="text-xs text-nowrap">/&nbsp;vB</span>
</InputGroupAddon>
</InputGroup>
</Field>
{errors.txFeeInSatsPerVbyte?.message && (
<div className="text-destructive text-xs">{errors.txFeeInSatsPerVbyte.message}</div>
)}
</div>
</TabsContent>
</Tabs>
<TxFeeUnitInput
disabled={disabled}
defaultValue={FORM_INPUT_DEFAULT_VALUES.txFeeUnit}
onValueChange={(value) => {
setValue('txFeeUnit', value as TxFeeUnit, {
shouldValidate: true, // trigger validation
shouldTouch: true, // update touched fields form state
shouldDirty: true, // update dirty and dirty fields form state
})
}}
/>
</div>*/}
</AccordionContent>
</AccordionItem>
</Accordion>

View file

@ -29,7 +29,11 @@ export interface TxFeeFormValues {
}
}
export function createTxFeeFormSchema(t: TFunction): yup.ObjectSchema<TxFeeFormValues> {
export function createTxFeeFormSchema({
t,
}: {
t: TFunction<'translation', undefined>
}): yup.ObjectSchema<TxFeeFormValues> {
const feedbackInvalidTxFeesBlocks = t('settings.fees.feedback_invalid_tx_fees_blocks', {
min: MIN_TX_FEE_IN_BLOCKS,
max: MAX_TX_FEE_IN_BLOCKS,

View file

@ -44,10 +44,7 @@ const TxFeeUnitInput = (props: React.ComponentProps<typeof RadioGroup>) => {
className="order-1 size-5 cursor-pointer after:absolute after:inset-0 [&_svg]:size-3"
/>
<div className="grid grow justify-items-center gap-2">
<span>
<CurrencySymbol currency="sats" />
<span className="text-xs text-nowrap">/&nbsp;vB</span>
</span>
<CurrencySymbol currency="sats" className="size-[1.75em]" />
<Label htmlFor={`${id}-satsperkvb`} className="justify-center">
{t('settings.fees.radio_tx_fees_satspervbyte')}
</Label>
@ -87,6 +84,7 @@ export function TxFeeForm({ className }: TxFeeFormProps) {
id="txFeeInBlocks"
{...register('txFee.txFeeInBlocks', {
disabled,
valueAsNumber: true,
})}
type="number"
min={MIN_TX_FEE_IN_BLOCKS}
@ -113,13 +111,14 @@ export function TxFeeForm({ className }: TxFeeFormProps) {
id="txFeeInSatsPerVbyte"
{...register('txFee.txFeeInSatsPerVbyte', {
disabled,
valueAsNumber: true,
})}
type="number"
min={MIN_TX_FEE_IN_SATS_PER_VBYTE}
max={MAX_TX_FEE_IN_SATS_PER_VBYTE}
step={1}
/>
<InputGroupAddon align="inline-start">
<InputGroupAddon align="inline-start" className="mr-1 gap-0.5">
<CurrencySymbol currency="sats" />
<span className="text-xs text-nowrap">/&nbsp;vB</span>
</InputGroupAddon>

View file

@ -9,16 +9,11 @@ export type CollaboratorFeesFormValues = {
maxCjFeeRelInPercent?: number
}
export function collaboratorFeesFormSchema(enableFormValidation: boolean, t: TFunction<'translation', undefined>) {
if (!enableFormValidation) {
return yup
.object({
maxCjFeeAbs: yup.string().default(''),
maxCjFeeRelInPercent: yup.string().default(''),
})
.required()
}
export function createCollaboratorFeesFormSchema({
t,
}: {
t: TFunction<'translation', undefined>
}): yup.ObjectSchema<CollaboratorFeesFormValues> {
const maxCjFeeAbsoluteMessage = t('settings.fees.feedback_invalid_max_cj_fee_abs', {
min: formatSats(CJ_FEE_ABS_MIN),
max: formatSats(CJ_FEE_ABS_MAX),

View file

@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
import { Field, FieldDescription, FieldLabel } from '../ui/field'
import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'
import { SatSymbol } from '../ui/jam/CurrencySymbol'
import type { CollaboratorFeesFormValues } from './CollaboratorFeesFormSchema'
import type { CollaboratorFeesFormValues } from './CollaboratorFeesForm.schema'
const FieldPrefixSatSymbol = (
<SatSymbol

View file

@ -28,12 +28,12 @@ import { cn, factorToPercentage, percentageToFactor } from '@/lib/utils'
import type { WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import type { WithRequiredProperty } from '@/types/global'
import { toTxFee } from '../send/feeEstimate'
import { toTxFeeFormDefaultValues } from '../send/TxFeeForm.schema'
import { Spinner } from '../ui/spinner'
import { CollaboratorFeesForm } from './CollaboratorFeesForm'
import { collaboratorFeesFormSchema, type CollaboratorFeesFormValues } from './CollaboratorFeesFormSchema'
import { createCollaboratorFeesFormSchema, type CollaboratorFeesFormValues } from './CollaboratorFeesForm.schema'
import { MiningFeesForm } from './MiningFeesForm'
import { miningFeesFormSchema, type MiningFeesFormValues } from './MiningFeesFormSchema'
import { createMiningFeesFormSchema, type MiningFeesFormValues } from './MiningFeesForm.schema'
type FeeLimitDialogProps = WithRequiredProperty<
Omit<ComponentProps<typeof Dialog>, 'children'>,
@ -57,29 +57,24 @@ export const FeeLimitDialog = ({ open, onOpenChange, walletFileName, ...dialogPr
const client = useApiClient()
const miningFeeFormSchema = useMemo(() => {
return miningFeesFormSchema(enableFormValidation, t)
}, [enableFormValidation, t])
const miningFeeFormSchema = useMemo(() => createMiningFeesFormSchema({ t }), [t])
const miningFeeFormInitialValues: MiningFeesFormValues = useMemo(() => {
const txFee = toTxFee(feeConfigValues)
const miningFeeFormDefaultValues: MiningFeesFormValues = useMemo(() => {
const txFeesFactor = Number.parseFloat(feeConfigValues.tx_fees_factor || '')
const maxSweepChangeFactor = Number.parseFloat(feeConfigValues.max_sweep_fee_change || '')
return {
feeType: txFee.unit,
txFeesBlocks: txFee.unit === txFeeUnit.BLOCKS ? txFee.value : undefined,
txFeesSatsPerVbyte: txFee.unit === txFeeUnit.SATS_PER_KILO_VBYTE ? txFee.value / 1_000 : undefined,
txFeesFactorInPercent: Number.isFinite(txFeesFactor) ? factorToPercentage(txFeesFactor) : undefined,
maxSweepFeeChangeInPercent: Number.isFinite(maxSweepChangeFactor)
? factorToPercentage(maxSweepChangeFactor)
: undefined,
...toTxFeeFormDefaultValues(feeConfigValues),
}
}, [feeConfigValues])
const miningFeesForm = useForm<MiningFeesFormValues, unknown, MiningFeesFormValues>({
mode: 'onChange',
disabled: isSubmitting || isLoadingConfig,
values: miningFeeFormInitialValues,
defaultValues: miningFeeFormDefaultValues,
resolver: yupResolver(miningFeeFormSchema as yup.AnyObjectSchema) as Resolver<
MiningFeesFormValues,
unknown,
@ -87,11 +82,9 @@ export const FeeLimitDialog = ({ open, onOpenChange, walletFileName, ...dialogPr
>,
})
const collaboratorFormSchema = useMemo(() => {
return collaboratorFeesFormSchema(enableFormValidation, t)
}, [enableFormValidation, t])
const collaboratorFormSchema = useMemo(() => createCollaboratorFeesFormSchema({ t }), [t])
const collaboratorFeesFormInitialValues: CollaboratorFeesFormValues = useMemo(() => {
const collaboratorFeesFormDefaultValues: CollaboratorFeesFormValues = useMemo(() => {
const maxCjFeeAbsolute = Number.parseInt(feeConfigValues?.max_cj_fee_abs || '', 10)
const maxCjFeeRelative = Number.parseFloat(feeConfigValues?.max_cj_fee_rel || '')
return {
@ -103,7 +96,7 @@ export const FeeLimitDialog = ({ open, onOpenChange, walletFileName, ...dialogPr
const collaboratorFeesForm = useForm<CollaboratorFeesFormValues, unknown, CollaboratorFeesFormValues>({
mode: 'onChange',
disabled: isSubmitting || isLoadingConfig,
values: collaboratorFeesFormInitialValues,
defaultValues: collaboratorFeesFormDefaultValues,
resolver: yupResolver(collaboratorFormSchema as yup.AnyObjectSchema) as Resolver<
CollaboratorFeesFormValues,
unknown,
@ -117,12 +110,14 @@ export const FeeLimitDialog = ({ open, onOpenChange, walletFileName, ...dialogPr
setIsSubmitting(true)
try {
// Trigger validation on both forms before submission
const collaboratorValid = await collaboratorFeesForm.trigger()
const miningValid = await miningFeesForm.trigger()
if (enableFormValidation) {
// Trigger validation on both nested forms before submission
const collaboratorValid = await collaboratorFeesForm.trigger()
const miningValid = await miningFeesForm.trigger()
if (!collaboratorValid || !miningValid) {
return
if (!collaboratorValid || !miningValid) {
return
}
}
const collaboratorData = collaboratorFeesForm.getValues()
@ -136,12 +131,14 @@ export const FeeLimitDialog = ({ open, onOpenChange, walletFileName, ...dialogPr
collaboratorData.maxCjFeeRelInPercent !== undefined && Number.isFinite(collaboratorData.maxCjFeeRelInPercent)
? String(percentageToFactor(collaboratorData.maxCjFeeRelInPercent))
: ''
const txFeesBlocksValue = Number.isSafeInteger(miningData.txFeesBlocks) ? String(miningData.txFeesBlocks) : ''
const txFeesBlocksValue = Number.isSafeInteger(miningData.txFee.txFeeInBlocks)
? String(miningData.txFee.txFeeInBlocks)
: ''
const txFeesSatsPerKvByteValue =
miningData.txFeesSatsPerVbyte !== undefined && Number.isFinite(miningData.txFeesSatsPerVbyte)
? String(Math.round(miningData.txFeesSatsPerVbyte * 1_000))
miningData.txFee.txFeeInSatsPerVbyte !== undefined && Number.isFinite(miningData.txFee.txFeeInSatsPerVbyte)
? String(Math.ceil(miningData.txFee.txFeeInSatsPerVbyte * 1_000))
: ''
const txFeesValue = miningData.feeType === txFeeUnit.BLOCKS ? txFeesBlocksValue : txFeesSatsPerKvByteValue
const txFeesValue = miningData.txFee.txFeeUnit === txFeeUnit.BLOCKS ? txFeesBlocksValue : txFeesSatsPerKvByteValue
const txFeesFactorValue =
miningData.txFeesFactorInPercent !== undefined && Number.isFinite(miningData.txFeesFactorInPercent)
? String(percentageToFactor(miningData.txFeesFactorInPercent))
@ -211,7 +208,7 @@ export const FeeLimitDialog = ({ open, onOpenChange, walletFileName, ...dialogPr
</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex-1 space-y-4">
<div className="space-y-4">
{isDeveloperMode && (
<>
<div className="flex items-center gap-3">

View file

@ -0,0 +1,52 @@
import type { TFunction } from 'i18next'
import * as yup from 'yup'
import {
TX_FEES_FACTOR_MIN,
TX_FEES_FACTOR_MAX,
MAX_SWEEP_FEE_CHANGE_MIN,
MAX_SWEEP_FEE_CHANGE_MAX,
} from '@/constants/jam'
import { factorToPercentage } from '@/lib/utils'
import { createTxFeeFormSchema, type TxFeeFormValues } from '../send/TxFeeForm.schema'
export type MiningFeesFormValues = {
txFeesFactorInPercent?: number
maxSweepFeeChangeInPercent?: number
txFee: TxFeeFormValues['txFee']
}
export function createMiningFeesFormSchema({
t,
}: {
t: TFunction<'translation', undefined>
}): yup.ObjectSchema<MiningFeesFormValues> {
const txFeesFactorMessage = t('settings.fees.feedback_invalid_tx_fees_factor', {
min: factorToPercentage(TX_FEES_FACTOR_MIN).toLocaleString(),
max: factorToPercentage(TX_FEES_FACTOR_MAX).toLocaleString(),
})
const maxSweepFeeChangeMessage = t('settings.fees.feedback_invalid_max_sweep_fee_change', {
min: factorToPercentage(MAX_SWEEP_FEE_CHANGE_MIN).toLocaleString(),
max: factorToPercentage(MAX_SWEEP_FEE_CHANGE_MAX).toLocaleString(),
})
return (
yup
.object({
txFeesFactorInPercent: yup
.number()
.transform((value) => (Number.isFinite(value) ? Number(value) : null))
.min(factorToPercentage(TX_FEES_FACTOR_MIN), txFeesFactorMessage)
.max(factorToPercentage(TX_FEES_FACTOR_MAX), txFeesFactorMessage)
.required(txFeesFactorMessage),
maxSweepFeeChangeInPercent: yup
.number()
.transform((value) => (Number.isFinite(value) ? Number(value) : null))
.min(factorToPercentage(MAX_SWEEP_FEE_CHANGE_MIN), maxSweepFeeChangeMessage)
.max(factorToPercentage(MAX_SWEEP_FEE_CHANGE_MAX), maxSweepFeeChangeMessage)
.required(maxSweepFeeChangeMessage),
})
// eslint-disable-next-line unicorn/prefer-spread -- false positive
.concat(createTxFeeFormSchema({ t }))
.required()
)
}

View file

@ -1,13 +1,13 @@
import { useMemo } from 'react'
import { PercentIcon } from 'lucide-react'
import { useWatch, type UseFormReturn } from 'react-hook-form'
import { FormProvider, type UseFormReturn } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Label } from '@/components/ui/label'
import { JM_MAX_SWEEP_FEE_CHANGE_DEFAULT, txFeeUnit, type TxFeeUnit } from '@/constants/jm'
import { JM_MAX_SWEEP_FEE_CHANGE_DEFAULT, JM_TX_FEES_FACTOR_DEFAULT } from '@/constants/jm'
import { cn, factorToPercentage } from '@/lib/utils'
import { TxFeeForm } from '../send/TxFeeForm'
import { Field, FieldDescription, FieldLabel } from '../ui/field'
import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'
import type { MiningFeesFormValues } from './MiningFeesFormSchema'
import { TxFeeInputField } from './TxFeeInputField'
import type { MiningFeesFormValues } from './MiningFeesForm.schema'
interface MiningFeesFormProps {
className?: string
@ -16,130 +16,92 @@ interface MiningFeesFormProps {
export const MiningFeesForm = ({
className,
form: {
register,
control,
setValue,
formState: { errors, disabled },
},
form: { register, control, setValue, formState, ...formMethods },
}: MiningFeesFormProps) => {
const { t } = useTranslation()
const feeType = useWatch({ control, name: 'feeType' })
const txFeesBlocks = useWatch({ control, name: 'txFeesBlocks' })
const txFeesSatsPerVbyte = useWatch({ control, name: 'txFeesSatsPerVbyte' })
const handleTxFeeUnitChange = (newUnit: TxFeeUnit) => {
if (newUnit !== feeType) {
const currentValue = feeType === txFeeUnit.BLOCKS ? txFeesBlocks : txFeesSatsPerVbyte
if (currentValue) {
const numberValue = Number(currentValue)
if (!Number.isNaN(numberValue)) {
if (newUnit === txFeeUnit.SATS_PER_KILO_VBYTE && feeType === txFeeUnit.BLOCKS) {
setValue('txFeesSatsPerVbyte', Math.round(numberValue * 1_000) / 1_000, {
shouldDirty: true,
shouldValidate: true,
})
} else if (newUnit === txFeeUnit.BLOCKS && feeType === txFeeUnit.SATS_PER_KILO_VBYTE) {
const converted = Math.round(numberValue * 1_000)
setValue('txFeesBlocks', Math.round(converted / 1_000), {
shouldDirty: true,
shouldValidate: true,
})
}
}
}
}
setValue('feeType', newUnit, { shouldDirty: true, shouldValidate: true })
}
const handleTxFeeValueChange = (value: string) => {
const fieldName = feeType === txFeeUnit.BLOCKS ? 'txFeesBlocks' : 'txFeesSatsPerVbyte'
setValue(fieldName, Number(value), { shouldDirty: true, shouldValidate: true })
}
const txFeesError = feeType === txFeeUnit.BLOCKS ? errors.txFeesBlocks?.message : errors.txFeesSatsPerVbyte?.message
const { errors, disabled } = useMemo(
() => ({
errors: formState.errors,
disabled: formState.disabled,
}),
[formState.errors, formState.disabled],
)
return (
<div className={cn('flex flex-col gap-4', className)}>
<p className="text-muted-foreground text-sm">{t('settings.fees.description_general_fee_settings')}</p>
<FormProvider control={control} register={register} formState={formState} setValue={setValue} {...formMethods}>
<div className={cn('flex flex-col gap-4', className)}>
<p className="text-muted-foreground text-sm">{t('settings.fees.description_general_fee_settings')}</p>
<div className="space-y-2">
<Label>{t('settings.fees.label_tx_fees')}</Label>
<TxFeeInputField
value={String(feeType === txFeeUnit.BLOCKS ? txFeesBlocks : txFeesSatsPerVbyte)}
unit={feeType}
onUnitChange={handleTxFeeUnitChange}
onValueChange={handleTxFeeValueChange}
error={txFeesError}
disabled={disabled}
/>
</div>
<TxFeeForm />
<div className="space-y-2">
<Field data-invalid={errors.txFeesFactorInPercent !== undefined}>
<FieldLabel htmlFor="mining-fees-tx-fees-factor">{t('settings.fees.label_tx_fees_factor')}</FieldLabel>
<FieldDescription className="text-xs">
{t('settings.fees.description_tx_fees_factor_^0.9.10')}
</FieldDescription>
<InputGroup>
<InputGroupInput
id="mining-fees-tx-fees-factor"
{...register('txFeesFactorInPercent', {
disabled,
valueAsNumber: true,
<div className="space-y-2">
<Field data-invalid={errors.txFeesFactorInPercent !== undefined}>
<FieldLabel htmlFor="mining-fees-tx-fees-factor">
{t('settings.fees.label_tx_fees_factor', {
// TODO: i18n - change variable name.. `defaultValue` is used by the library!
defaultValue: `${factorToPercentage(JM_TX_FEES_FACTOR_DEFAULT)}%`,
})}
type="number"
inputMode="decimal"
min="0"
max="100"
step="1"
/>
<InputGroupAddon align="inline-start">
<PercentIcon />
</InputGroupAddon>
</InputGroup>
</Field>
{errors.txFeesFactorInPercent?.message && (
<div className="text-destructive text-xs">{errors.txFeesFactorInPercent.message}</div>
)}
</div>
<div className="space-y-2">
<Field data-invalid={errors.maxSweepFeeChangeInPercent !== undefined}>
<FieldLabel htmlFor="mining-fees-sweep-fee-change">
{t('settings.fees.label_max_sweep_fee_change')}
</FieldLabel>
<FieldDescription className="text-xs">
{t('settings.fees.description_max_sweep_fee_change', {
// TODO: i18n - change variable name.. `defaultValue` is used by the library!
defaultValue: `${factorToPercentage(JM_MAX_SWEEP_FEE_CHANGE_DEFAULT)}%`,
})}
</FieldDescription>
<InputGroup>
<InputGroupInput
id="mining-fees-sweep-fee-change"
{...register('maxSweepFeeChangeInPercent', {
disabled,
valueAsNumber: true,
})}
type="number"
inputMode="decimal"
min="0"
max="100"
step="1"
/>
<InputGroupAddon align="inline-start">
<PercentIcon />
</InputGroupAddon>
</InputGroup>
{errors.maxSweepFeeChangeInPercent?.message && (
<div className="text-destructive text-xs">{errors.maxSweepFeeChangeInPercent.message}</div>
</FieldLabel>
<FieldDescription className="text-xs">
{t('settings.fees.description_tx_fees_factor_^0.9.10')}
</FieldDescription>
<InputGroup>
<InputGroupInput
id="mining-fees-tx-fees-factor"
{...register('txFeesFactorInPercent', {
disabled,
valueAsNumber: true,
})}
type="number"
inputMode="decimal"
min="0"
max="100"
step="1"
/>
<InputGroupAddon align="inline-start">
<PercentIcon />
</InputGroupAddon>
</InputGroup>
</Field>
{errors.txFeesFactorInPercent?.message && (
<div className="text-destructive text-xs">{errors.txFeesFactorInPercent.message}</div>
)}
</Field>
</div>
<div className="space-y-2">
<Field data-invalid={errors.maxSweepFeeChangeInPercent !== undefined}>
<FieldLabel htmlFor="mining-fees-sweep-fee-change">
{t('settings.fees.label_max_sweep_fee_change')}
</FieldLabel>
<FieldDescription className="text-xs">
{t('settings.fees.description_max_sweep_fee_change', {
// TODO: i18n - change variable name.. `defaultValue` is used by the library!
defaultValue: `${factorToPercentage(JM_MAX_SWEEP_FEE_CHANGE_DEFAULT)}%`,
})}
</FieldDescription>
<InputGroup>
<InputGroupInput
id="mining-fees-sweep-fee-change"
{...register('maxSweepFeeChangeInPercent', {
disabled,
valueAsNumber: true,
})}
type="number"
inputMode="decimal"
min="0"
max="100"
step="1"
/>
<InputGroupAddon align="inline-start">
<PercentIcon />
</InputGroupAddon>
</InputGroup>
{errors.maxSweepFeeChangeInPercent?.message && (
<div className="text-destructive text-xs">{errors.maxSweepFeeChangeInPercent.message}</div>
)}
</Field>
</div>
</div>
</div>
</FormProvider>
)
}

View file

@ -1,88 +0,0 @@
import type { TFunction } from 'i18next'
import * as yup from 'yup'
import {
TX_FEES_FACTOR_MIN,
TX_FEES_FACTOR_MAX,
MAX_SWEEP_FEE_CHANGE_MIN,
MAX_SWEEP_FEE_CHANGE_MAX,
} from '@/constants/jam'
import { txFeeUnit, type TxFeeUnit } from '@/constants/jm'
import { factorToPercentage } from '@/lib/utils'
export type MiningFeesFormValues = {
feeType: TxFeeUnit
txFeesBlocks?: number
txFeesSatsPerVbyte?: number
txFeesFactorInPercent?: number
maxSweepFeeChangeInPercent?: number
}
export function miningFeesFormSchema(enableFormValidation: boolean, t: TFunction<'translation', undefined>) {
if (!enableFormValidation) {
return yup
.object({
feeType: yup.string<TxFeeUnit>().oneOf([txFeeUnit.BLOCKS, txFeeUnit.SATS_PER_KILO_VBYTE]).required(),
txFeesBlocks: yup.string().default(''),
txFeesSatsPerVbyte: yup.string().default(''),
txFeesFactorInPercent: yup.string().default(''),
maxSweepFeeChange: yup.string().default(''),
})
.required()
}
const txFeesBlocksMessage = t('settings.fees.feedback_invalid_tx_fees_blocks', {
min: Number(1).toLocaleString(),
max: Number(1_000).toLocaleString(),
})
const txFeesSatsMessage = t('settings.fees.feedback_invalid_tx_fees_satspervbyte', {
min: Number(1.001).toLocaleString(),
max: Number(350).toLocaleString(),
})
const txFeesFactorMessage = t('settings.fees.feedback_invalid_tx_fees_factor', {
min: factorToPercentage(TX_FEES_FACTOR_MIN).toLocaleString(),
max: factorToPercentage(TX_FEES_FACTOR_MAX).toLocaleString(),
})
const maxSweepFeeChangeMessage = t('settings.fees.feedback_invalid_max_sweep_fee_change', {
min: factorToPercentage(MAX_SWEEP_FEE_CHANGE_MIN).toLocaleString(),
max: factorToPercentage(MAX_SWEEP_FEE_CHANGE_MAX).toLocaleString(),
})
return yup
.object({
feeType: yup.string<TxFeeUnit>().oneOf([txFeeUnit.BLOCKS, txFeeUnit.SATS_PER_KILO_VBYTE]).required(),
txFeesBlocks: yup.number().when('feeType', {
is: txFeeUnit.BLOCKS,
then: (schema) =>
schema
.integer(txFeesBlocksMessage)
.transform((value) => (Number.isSafeInteger(value) ? Number(value) : null))
.min(1, txFeesBlocksMessage)
.max(1_000, txFeesBlocksMessage)
.required(txFeesBlocksMessage),
otherwise: (schema) => schema.nullable().optional(),
}),
txFeesSatsPerVbyte: yup.number().when('feeType', {
is: txFeeUnit.SATS_PER_KILO_VBYTE,
then: (schema) =>
schema
.transform((value) => (Number.isFinite(value) ? Number(value) : null))
.min(1.001, txFeesSatsMessage)
.max(350, txFeesSatsMessage)
.required(txFeesSatsMessage),
otherwise: (schema) => schema.nullable().optional(),
}),
txFeesFactorInPercent: yup
.number()
.transform((value) => (Number.isFinite(value) ? Number(value) : null))
.min(factorToPercentage(TX_FEES_FACTOR_MIN), txFeesFactorMessage)
.max(factorToPercentage(TX_FEES_FACTOR_MAX), txFeesFactorMessage)
.required(txFeesFactorMessage),
maxSweepFeeChangeInPercent: yup
.number()
.transform((value) => (Number.isFinite(value) ? Number(value) : null))
.min(factorToPercentage(MAX_SWEEP_FEE_CHANGE_MIN), maxSweepFeeChangeMessage)
.max(factorToPercentage(MAX_SWEEP_FEE_CHANGE_MAX), maxSweepFeeChangeMessage)
.required(maxSweepFeeChangeMessage),
})
.required()
}

View file

@ -1,90 +0,0 @@
import { useMemo } from 'react'
import { cx } from 'class-variance-authority'
import { BlocksIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol'
import { txFeeUnit, type TxFeeUnit } from '@/constants/jm'
export interface TxFeeInputFieldProps {
value: string
unit: TxFeeUnit
onUnitChange: (unit: TxFeeUnit) => void
onValueChange: (value: string) => void
error?: string
disabled?: boolean
}
export const TxFeeInputField = ({
value,
unit,
onUnitChange,
onValueChange,
error,
disabled,
}: TxFeeInputFieldProps) => {
const { t } = useTranslation()
const unitTabs = useMemo(
() => [
{ label: t('settings.fees.radio_tx_fees_blocks'), value: txFeeUnit.BLOCKS },
{ label: t('settings.fees.radio_tx_fees_satspervbyte'), value: txFeeUnit.SATS_PER_KILO_VBYTE },
],
[t],
)
const handleUnitChange = (newUnit: TxFeeUnit) => {
if (newUnit !== unit) {
onUnitChange(newUnit)
}
}
return (
<div>
<div className="mb-2 flex gap-4">
{unitTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => handleUnitChange(tab.value)}
className={cx('rounded-md px-4 py-2 text-sm font-medium transition-colors', {
'bg-primary text-primary-foreground': unit === tab.value,
'bg-muted text-muted-foreground hover:bg-muted/80': unit !== tab.value,
})}
disabled={disabled}
>
{tab.label}
</button>
))}
</div>
<p className="text-muted-foreground mb-1 text-sm">
{unit === txFeeUnit.BLOCKS
? t('settings.fees.description_tx_fees_blocks')
: t('settings.fees.description_tx_fees_satspervbyte')}
</p>
<div className="flex h-12 items-center">
<div className="bg-muted flex h-full items-center rounded-l-md border border-r-0 px-3 py-2">
{unit === txFeeUnit.BLOCKS ? (
<BlocksIcon className="h4 w-4" />
) : (
<>
<CurrencySymbol currency="sats" />
<span className="text-xs text-nowrap">/&nbsp;vB</span>
</>
)}
</div>
<Input
type="number"
inputMode={unit === txFeeUnit.BLOCKS ? 'numeric' : 'decimal'}
min="0"
step="any"
value={value}
onChange={(event) => onValueChange(event.target.value)}
placeholder={unit === txFeeUnit.BLOCKS ? '3' : '1.0'}
className="h-full rounded-l-none"
disabled={disabled}
/>
</div>
{error && <div className="text-destructive mt-1 text-xs">{error}</div>}
</div>
)
}

View file

@ -20,13 +20,13 @@ export const TX_FEES_FACTOR_MIN = 0 // 0%
* 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 TX_FEES_FACTOR_MAX = percentageToFactor(50) // TODO: since JM 0.9.11, this is only applied to the upside, so it can also be 110%, why limit it to 50%?
export const CJ_FEE_ABS_MIN: AmountSats = 1
export const CJ_FEE_ABS_MAX: AmountSats = 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 MAX_SWEEP_FEE_CHANGE_MIN = percentageToFactor(50) // no enforcement by JM - should be a "sane" min vaue (too low and users might run into problems on sweeps)
export const MAX_SWEEP_FEE_CHANGE_MAX = percentageToFactor(100) // TODO: this can also be 200%, why limit it to 100%?
export const OFFER_FEE_REL_MIN = percentageToFactor(0.0001)
export const OFFER_FEE_REL_MAX = percentageToFactor(10)

View file

@ -19,6 +19,7 @@ export const JM_API_AUTH_TOKEN_EXPIRY: Milliseconds = Math.max(
),
)
// initial value for `max_sweep_fee_change` from the default joinmarket.cfg (last check on 2026-04-22 of v0.9.12)
export const JM_MAX_SWEEP_FEE_CHANGE_DEFAULT = 0.8
export const JM_DUST_THRESHOLD: AmountSats = 27_300
@ -26,6 +27,12 @@ export const JM_DUST_THRESHOLD: AmountSats = 27_300
// initial value for `minimum_makers` from the default joinmarket.cfg (last check on 2022-02-20 of v0.9.5)
export const JM_MINIMUM_MAKERS_DEFAULT = 4
// initial value for `tx_fees` from the default joinmarket.cfg (last check on 2026-04-22 of v0.9.12)
export const JM_TX_FEES_DEFAULT = 3
// initial value for `tx_fees_factor` from the default joinmarket.cfg (last check on 2026-04-22 of v0.9.12)
export const JM_TX_FEES_FACTOR_DEFAULT = 0.2
export const JM_GAPLIMIT_DEFAULT = 6
export const JM_GAPLIMIT_CONFIGKEY: ConfigKey = {