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:
Parth 2026-07-06 23:44:21 +05:30 committed by GitHub
parent a486002a71
commit ff739fcc07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 223 additions and 51 deletions

View file

@ -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),

View file

@ -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(),

View file

@ -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

View file

@ -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 })
}

View file

@ -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')
}

View 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
View 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),
)