mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-17 13:07:34 +02:00
chore(fee-dialog): add nested TxFeeForm
This commit is contained in:
parent
98920cf279
commit
efe4b2e5e7
13 changed files with 194 additions and 417 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">/ 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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">/ 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">/ vB</span>
|
||||
</InputGroupAddon>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
52
src/components/settings/MiningFeesForm.schema.ts
Normal file
52
src/components/settings/MiningFeesForm.schema.ts
Normal 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()
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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">/ 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue