diff --git a/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondFormSchema.ts b/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondFormSchema.ts index b07a30aa..b942d85e 100644 --- a/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondFormSchema.ts +++ b/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondFormSchema.ts @@ -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().oneOf(validLockdates, requiredMessage).required(requiredMessage), source: yup .object({ - fromJar: yup - .number() - .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), diff --git a/src/components/receive/ReceiveForm.tsx b/src/components/receive/ReceiveForm.tsx index 07093085..f35200bf 100644 --- a/src/components/receive/ReceiveForm.tsx +++ b/src/components/receive/ReceiveForm.tsx @@ -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(), diff --git a/src/components/send/SendForm.schema.ts b/src/components/send/SendForm.schema.ts index d9f74ade..560e13c6 100644 --- a/src/components/send/SendForm.schema.ts +++ b/src/components/send/SendForm.schema.ts @@ -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 diff --git a/src/components/sweep/SweepFormSchema.ts b/src/components/sweep/SweepFormSchema.ts index 1b57635c..49d74009 100644 --- a/src/components/sweep/SweepFormSchema.ts +++ b/src/components/sweep/SweepFormSchema.ts @@ -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 }) } diff --git a/src/components/sweep/destinationValidation.ts b/src/components/sweep/destinationValidation.ts index f145213d..fcd27434 100644 --- a/src/components/sweep/destinationValidation.ts +++ b/src/components/sweep/destinationValidation.ts @@ -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 => { 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()) 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') } diff --git a/src/lib/formValidation.test.ts b/src/lib/formValidation.test.ts new file mode 100644 index 00000000..32f80763 --- /dev/null +++ b/src/lib/formValidation.test.ts @@ -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') + }) +}) diff --git a/src/lib/formValidation.ts b/src/lib/formValidation.ts new file mode 100644 index 00000000..12621558 --- /dev/null +++ b/src/lib/formValidation.ts @@ -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() + .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), + )