mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-18 13:09:10 +02:00
refactor(validation): share form field validators for source jar and bitcoin address (#1315)
* refactor(validation): add shared form field validators * refactor(send): use shared source jar and destination address validators * refactor(receive): use shared source jar validator and align amount bound * refactor(earn): use shared source jar validator for fidelity bonds * refactor(sweep): reuse shared bitcoin address predicates * fix(validation): guard chained destination address tests and trim input
This commit is contained in:
parent
a486002a71
commit
ff739fcc07
7 changed files with 223 additions and 51 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import type { TFunction } from 'i18next'
|
||||
import * as yup from 'yup'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
import { sourceJarField } from '@/lib/formValidation'
|
||||
import type { JarIndex } from '@/types/global'
|
||||
|
||||
export type SourceValue = {
|
||||
|
|
@ -40,15 +41,7 @@ export const createFidelityBondFormSchema = (
|
|||
lockdate: yup.string<fb.Lockdate>().oneOf(validLockdates, requiredMessage).required(requiredMessage),
|
||||
source: yup
|
||||
.object({
|
||||
fromJar: yup
|
||||
.number<JarIndex>()
|
||||
.integer(invalidSourceJarFeedbackMessage)
|
||||
.required(invalidSourceJarFeedbackMessage)
|
||||
.test(
|
||||
'valid-source-jar-index-test',
|
||||
invalidSourceJarFeedbackMessage,
|
||||
(value) => typeof value === 'number' && jarIndexes.includes(value),
|
||||
),
|
||||
fromJar: sourceJarField(invalidSourceJarFeedbackMessage, (jarIndex) => jarIndexes.includes(jarIndex)),
|
||||
})
|
||||
.required(),
|
||||
utxoIds: yup.array().of(yup.string().required(requiredMessage)).min(1, requiredMessage).required(requiredMessage),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import { useTranslation } from 'react-i18next'
|
|||
import * as yup from 'yup'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { SelectableJar } from '@/components/ui/jam/SelectableJar'
|
||||
import { TOTAL_COIN_SUPPLY } from '@/constants/jam'
|
||||
import { useWalletBalanceSummary, type Jar } from '@/context/JamWalletInfoContext'
|
||||
import { sourceJarField } from '@/lib/formValidation'
|
||||
import { cn, isValidInteger } from '@/lib/utils'
|
||||
import { DevBadge } from '../dev/DevBadge'
|
||||
import { Field, FieldLabel } from '../ui/field'
|
||||
|
|
@ -34,20 +36,16 @@ const receiveFormSchema = (jars: Jar[], t: TFunction) => {
|
|||
.object({
|
||||
source: yup
|
||||
.object({
|
||||
fromJar: yup
|
||||
.number()
|
||||
.integer(invalidSourceJarFeedbackMessage)
|
||||
.required(invalidSourceJarFeedbackMessage)
|
||||
.test('valid-source-jar-index-test', invalidSourceJarFeedbackMessage, (value) =>
|
||||
jars.some((it) => it.jarIndex === value),
|
||||
),
|
||||
fromJar: sourceJarField(invalidSourceJarFeedbackMessage, (jarIndex) =>
|
||||
jars.some((it) => it.jarIndex === jarIndex),
|
||||
),
|
||||
})
|
||||
.required(),
|
||||
amount: yup
|
||||
.number()
|
||||
.integer(t('receive.feedback_invalid_amount'))
|
||||
.min(1, t('receive.feedback_invalid_amount'))
|
||||
.max(21_000_000 * 100_000_000, t('receive.feedback_invalid_amount'))
|
||||
.max(TOTAL_COIN_SUPPLY, t('receive.feedback_invalid_amount'))
|
||||
.transform((value) => (isValidInteger(value) ? value : null))
|
||||
.nullable()
|
||||
.optional(),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { getAddressInfo, validate as isValidBitcoinAddress, Network } from 'bitcoin-address-validation'
|
||||
import { Network } from 'bitcoin-address-validation'
|
||||
import type { TFunction } from 'i18next'
|
||||
import * as yup from 'yup'
|
||||
import { MAX_NUM_COLLABORATORS, TOTAL_COIN_SUPPLY } from '@/constants/jam'
|
||||
import type { AddressSummary, Jar } from '@/context/JamWalletInfoContext'
|
||||
import { TX_FEE_UNITS } from '@/lib/feeConfig'
|
||||
import type { JamFeeConfigValues } from '@/lib/feeConfig'
|
||||
import { destinationAddressField, sourceJarField } from '@/lib/formValidation'
|
||||
import { isValidInteger, pseudoRandomInteger } from '@/lib/utils'
|
||||
import type { AmountSats } from '@/types/global'
|
||||
import { toTxFeeFormDefaultValues, createTxFeeFormSchema } from './TxFeeForm.schema'
|
||||
|
|
@ -63,37 +64,25 @@ export const createSendFormSchema = (
|
|||
.object({
|
||||
source: yup
|
||||
.object({
|
||||
fromJar: yup
|
||||
.number()
|
||||
.integer(t('send.feedback_invalid_source_jar'))
|
||||
.required(t('send.feedback_invalid_source_jar'))
|
||||
.test(
|
||||
'valid-source-jar-index-test',
|
||||
t('send.feedback_invalid_source_jar'),
|
||||
(value) =>
|
||||
(jars.find((it) => it.jarIndex === value)?.balanceSummary.calculatedAvailableBalanceInSats || 0) > 0,
|
||||
),
|
||||
fromJar: sourceJarField(
|
||||
t('send.feedback_invalid_source_jar'),
|
||||
(jarIndex) =>
|
||||
(jars.find((it) => it.jarIndex === jarIndex)?.balanceSummary.calculatedAvailableBalanceInSats || 0) > 0,
|
||||
),
|
||||
})
|
||||
.required(),
|
||||
destination: yup
|
||||
.object({
|
||||
fromJar: yup.number().optional(),
|
||||
address: yup
|
||||
.string()
|
||||
.required(t('send.feedback_invalid_destination_address'))
|
||||
.test('valid-address-test', t('send.feedback_invalid_destination_address'), (value) => {
|
||||
return isValidBitcoinAddress(value)
|
||||
})
|
||||
.test('network-mismatch-test', t('send.feedback_destination_network_mismatch'), (value) => {
|
||||
try {
|
||||
return getAddressInfo(value).network === network
|
||||
} catch (_ignoredOnPurpose) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.test('reused-address-test', t('send.feedback_reused_address'), (value) => {
|
||||
return addressSummary[value]?.used !== true
|
||||
}),
|
||||
address: destinationAddressField({
|
||||
network,
|
||||
addressSummary,
|
||||
messages: {
|
||||
invalid: t('send.feedback_invalid_destination_address'),
|
||||
networkMismatch: t('send.feedback_destination_network_mismatch'),
|
||||
reused: t('send.feedback_reused_address'),
|
||||
},
|
||||
}),
|
||||
})
|
||||
.required(),
|
||||
amount: yup
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { validate as isValidBitcoinAddress } from 'bitcoin-address-validation'
|
||||
import type { TFunction } from 'i18next'
|
||||
import * as yup from 'yup'
|
||||
import type { AddressSummary } from '@/context/JamWalletInfoContext'
|
||||
import { isValidAddress } from '@/lib/formValidation'
|
||||
import { buildDestinationErrors, normalizeDestinationAddresses } from './destinationValidation'
|
||||
|
||||
export type SweepFormValues = {
|
||||
|
|
@ -65,7 +65,7 @@ export const sweepFormSchema = (t: TFunction<'translation', undefined>): yup.Obj
|
|||
)
|
||||
.defined()
|
||||
.test('valid-sweep-destination', invalidDestinationAddressMessage, function (value) {
|
||||
if (typeof value !== 'string' || value.trim() === '' || !isValidBitcoinAddress(value)) {
|
||||
if (!isValidAddress(value)) {
|
||||
return this.createError({ message: invalidDestinationAddressMessage })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { validate as isValidBitcoinAddress } from 'bitcoin-address-validation'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { AddressSummary } from '@/context/JamWalletInfoContext'
|
||||
import { isReusedAddress, isValidAddress } from '@/lib/formValidation'
|
||||
|
||||
const normalizeAddress = (value: string): string => value.trim()
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ export const buildDestinationErrors = (
|
|||
): Array<string | undefined> => {
|
||||
const normalizedAddresses = addresses.map((address) => normalizeAddress(address))
|
||||
const counts = normalizedAddresses.reduce((acc, address) => {
|
||||
if (address === '' || !isValidBitcoinAddress(address)) {
|
||||
if (!isValidAddress(address)) {
|
||||
return acc
|
||||
}
|
||||
|
||||
|
|
@ -20,14 +20,13 @@ export const buildDestinationErrors = (
|
|||
}, new Map<string, number>())
|
||||
|
||||
return normalizedAddresses.map((address) => {
|
||||
if (address === '' || !isValidBitcoinAddress(address)) {
|
||||
if (!isValidAddress(address)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isReusedAddress = addressSummary[address]?.used === true
|
||||
const isDuplicateAddress = (counts.get(address) ?? 0) > 1
|
||||
|
||||
if (isReusedAddress || isDuplicateAddress) {
|
||||
if (isReusedAddress(address, addressSummary) || isDuplicateAddress) {
|
||||
return t('scheduler.feedback_reused_destination_address')
|
||||
}
|
||||
|
||||
|
|
|
|||
118
src/lib/formValidation.test.ts
Normal file
118
src/lib/formValidation.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { Network } from 'bitcoin-address-validation'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AddressSummary } from '@/context/JamWalletInfoContext'
|
||||
import {
|
||||
destinationAddressField,
|
||||
isAddressOnNetwork,
|
||||
isReusedAddress,
|
||||
isValidAddress,
|
||||
sourceJarField,
|
||||
} from './formValidation'
|
||||
|
||||
const mainnetAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT'
|
||||
const testnetAddress = 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn'
|
||||
|
||||
const addressSummary = {
|
||||
[mainnetAddress]: { address: mainnetAddress, used: false },
|
||||
} as unknown as AddressSummary
|
||||
|
||||
describe('isValidAddress', () => {
|
||||
it('accepts a valid address and rejects everything else', () => {
|
||||
expect(isValidAddress(mainnetAddress)).toBe(true)
|
||||
expect(isValidAddress('not-an-address')).toBe(false)
|
||||
expect(isValidAddress('')).toBe(false)
|
||||
expect(isValidAddress(undefined)).toBe(false)
|
||||
expect(isValidAddress(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAddressOnNetwork', () => {
|
||||
it('matches the address network', () => {
|
||||
expect(isAddressOnNetwork(mainnetAddress, Network.mainnet)).toBe(true)
|
||||
expect(isAddressOnNetwork(mainnetAddress, Network.testnet)).toBe(false)
|
||||
expect(isAddressOnNetwork(testnetAddress, Network.testnet)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for unparseable input instead of throwing', () => {
|
||||
expect(isAddressOnNetwork('not-an-address', Network.mainnet)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isReusedAddress', () => {
|
||||
it('is true only when the address is marked as used', () => {
|
||||
const usedSummary = {
|
||||
[mainnetAddress]: { address: mainnetAddress, used: true },
|
||||
} as unknown as AddressSummary
|
||||
|
||||
expect(isReusedAddress(mainnetAddress, usedSummary)).toBe(true)
|
||||
expect(isReusedAddress(mainnetAddress, addressSummary)).toBe(false)
|
||||
expect(isReusedAddress('unknown-address', addressSummary)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sourceJarField', () => {
|
||||
const schema = sourceJarField('invalid jar', (jarIndex) => [0, 1].includes(jarIndex))
|
||||
|
||||
it('accepts a selectable integer jar index', () => {
|
||||
expect(schema.isValidSync(0)).toBe(true)
|
||||
expect(schema.isValidSync(1)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-selectable, non-integer and missing values', () => {
|
||||
expect(schema.isValidSync(2)).toBe(false)
|
||||
expect(schema.isValidSync(1.5)).toBe(false)
|
||||
expect(schema.isValidSync(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('destinationAddressField', () => {
|
||||
const schema = destinationAddressField({
|
||||
network: Network.mainnet,
|
||||
addressSummary: {
|
||||
[mainnetAddress]: { address: mainnetAddress, used: false },
|
||||
[testnetAddress]: { address: testnetAddress, used: true },
|
||||
} as unknown as AddressSummary,
|
||||
messages: {
|
||||
invalid: 'invalid',
|
||||
networkMismatch: 'network-mismatch',
|
||||
reused: 'reused',
|
||||
},
|
||||
})
|
||||
|
||||
it('accepts a valid, on-network, unused address', () => {
|
||||
expect(schema.isValidSync(mainnetAddress)).toBe(true)
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace before validating', () => {
|
||||
expect(schema.isValidSync(` ${mainnetAddress} `)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an invalid address with the invalid message', () => {
|
||||
expect(() => schema.validateSync('not-an-address')).toThrow('invalid')
|
||||
})
|
||||
|
||||
it('reports only the invalid error for an invalid address (no cascading network/reuse errors)', () => {
|
||||
expect.assertions(1)
|
||||
try {
|
||||
schema.validateSync('not-an-address', { abortEarly: false })
|
||||
} catch (error) {
|
||||
expect((error as { errors: string[] }).errors).toEqual(['invalid'])
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an address from the wrong network', () => {
|
||||
expect(() => schema.validateSync(testnetAddress)).toThrow('network-mismatch')
|
||||
})
|
||||
|
||||
it('rejects a reused address', () => {
|
||||
const reusedSchema = destinationAddressField({
|
||||
network: Network.mainnet,
|
||||
addressSummary: {
|
||||
[mainnetAddress]: { address: mainnetAddress, used: true },
|
||||
} as unknown as AddressSummary,
|
||||
messages: { invalid: 'invalid', networkMismatch: 'network-mismatch', reused: 'reused' },
|
||||
})
|
||||
|
||||
expect(() => reusedSchema.validateSync(mainnetAddress)).toThrow('reused')
|
||||
})
|
||||
})
|
||||
75
src/lib/formValidation.ts
Normal file
75
src/lib/formValidation.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { getAddressInfo, validate as isValidBitcoinAddress, type Network } from 'bitcoin-address-validation'
|
||||
import * as yup from 'yup'
|
||||
import type { AddressSummary } from '@/context/JamWalletInfoContext'
|
||||
import type { BitcoinAddress, JarIndex } from '@/types/global'
|
||||
|
||||
/**
|
||||
* Shared bitcoin-address predicates used across form schemas (send, sweep, ...).
|
||||
* Keeping these in one place ensures every form validates addresses the same way.
|
||||
*/
|
||||
export const isValidAddress = (value: unknown): value is BitcoinAddress =>
|
||||
typeof value === 'string' && isValidBitcoinAddress(value)
|
||||
|
||||
export const isAddressOnNetwork = (value: string, network: Network): boolean => {
|
||||
try {
|
||||
return getAddressInfo(value).network === network
|
||||
} catch (_ignoredOnPurpose) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const isReusedAddress = (value: string, addressSummary: AddressSummary): boolean =>
|
||||
addressSummary[value]?.used === true
|
||||
|
||||
/**
|
||||
* Shared "source jar" (`fromJar`) field validator.
|
||||
*
|
||||
* Callers provide the feedback message and the predicate that decides whether a jar index is
|
||||
* selectable, e.g. "the jar exists" (receive / fidelity bond) or "the jar has spendable balance"
|
||||
* (send). This keeps the yup rules for the common `fromJar` field aligned across forms.
|
||||
*/
|
||||
export const sourceJarField = (message: string, isSelectableJar: (jarIndex: JarIndex) => boolean) =>
|
||||
yup
|
||||
.number<JarIndex>()
|
||||
.integer(message)
|
||||
.required(message)
|
||||
.test('valid-source-jar-index-test', message, (value) => typeof value === 'number' && isSelectableJar(value))
|
||||
|
||||
export type DestinationAddressMessages = {
|
||||
invalid: string
|
||||
networkMismatch: string
|
||||
reused: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared destination bitcoin-address field validator: valid address, matching network and not
|
||||
* previously used. Forms that validate a single destination address (e.g. send) can reuse this
|
||||
* instead of re-declaring the same chain of `.test(...)` rules.
|
||||
*/
|
||||
export const destinationAddressField = ({
|
||||
network,
|
||||
addressSummary,
|
||||
messages,
|
||||
}: {
|
||||
network: Network
|
||||
addressSummary: AddressSummary
|
||||
messages: DestinationAddressMessages
|
||||
}) =>
|
||||
yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(messages.invalid)
|
||||
.test('valid-address-test', messages.invalid, (value) => isValidAddress(value))
|
||||
// The network and reuse checks only run once the address itself is valid, so an invalid
|
||||
// address surfaces a single, correct error instead of also reporting a network mismatch.
|
||||
.test(
|
||||
'network-mismatch-test',
|
||||
messages.networkMismatch,
|
||||
(value) => !isValidAddress(value) || isAddressOnNetwork(value, network),
|
||||
)
|
||||
.test(
|
||||
'reused-address-test',
|
||||
messages.reused,
|
||||
(value) =>
|
||||
!isValidAddress(value) || !isAddressOnNetwork(value, network) || !isReusedAddress(value, addressSummary),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue