jam/src/lib/formValidation.ts
Ash 82cc4762ff
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
fix(sweep): validate destination addresses against wallet network (#1355)
2026-07-26 21:03:31 +02:00

113 lines
4.3 KiB
TypeScript

import { getAddressInfo, Network, validate as isValidBitcoinAddress } from 'bitcoin-address-validation'
import * as yup from 'yup'
import type { AddressSummary } from '@/context/JamWalletInfoContext'
import type { BitcoinAddress, BlockHeight, JarIndex } from '@/types/global'
import { isValidInteger } from './utils'
/**
* 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)
// Legacy (base58) addresses share the same version bytes on testnet and regtest, so
// bitcoin-address-validation can't tell them apart and always labels them "testnet" -
// only bech32 addresses carry a distinct "bcrt1" prefix. Treat the two as interchangeable
// so a regtest wallet doesn't reject its own legacy-style addresses as "wrong network".
const AMBIGUOUS_TESTNET_REGTEST_NETWORKS: ReadonlySet<Network> = new Set([Network.testnet, Network.regtest])
export const isAddressOnNetwork = (value: string, network: Network): boolean => {
try {
const addressNetwork = getAddressInfo(value).network
if (addressNetwork === network) return true
return AMBIGUOUS_TESTNET_REGTEST_NETWORKS.has(addressNetwork) && AMBIGUOUS_TESTNET_REGTEST_NETWORKS.has(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),
)
export type BlockHeightMessages = {
invalid: string
}
export const INPUT_BLOCK_HEIGHT_MIN = 0
const INPUT_BLOCK_HEIGHT_MAX = Number.MAX_SAFE_INTEGER
export const blockHeightField = ({
currentBlockHeight,
messages: { invalid },
}: {
currentBlockHeight: BlockHeight | undefined
messages: {
invalid: ({ min, max }: { min: BlockHeight; max: BlockHeight }) => string
}
}) => {
const minBlockHeight = Math.min(INPUT_BLOCK_HEIGHT_MIN, currentBlockHeight || INPUT_BLOCK_HEIGHT_MIN)
const maxBlockheight = Math.max(minBlockHeight, currentBlockHeight || INPUT_BLOCK_HEIGHT_MAX)
const invalidBlockheightMessage = invalid({ min: minBlockHeight, max: maxBlockheight })
return yup
.number()
.transform((value) => (isValidInteger(value) ? value : null))
.integer(invalidBlockheightMessage)
.min(minBlockHeight, invalidBlockheightMessage)
.max(maxBlockheight, invalidBlockheightMessage)
.required(invalidBlockheightMessage)
}