From f9523729aeb25e6481bfb9c1649665b131c3865d Mon Sep 17 00:00:00 2001 From: Thebora Kompanioni Date: Tue, 21 Jul 2026 18:29:48 +0200 Subject: [PATCH] chore(rescan): max rescan height is current blockheight (#1332) * chore(rescan): max rescan height is current blockheight * chore: generalize block height form input * feat(rescan): quickly set start block height * chore(rescan): optionally render back button * chore: use constant for initial blockheight value calculation --- src/App.tsx | 5 +- .../import/ImportDetailsForm.test.tsx | 46 +++- src/components/import/ImportDetailsForm.tsx | 49 ++-- src/components/import/ImportStepConfirm.tsx | 4 +- .../import/ImportStepImportDetails.tsx | 4 +- src/components/import/ImportWalletPage.tsx | 4 +- .../settings/RescanChainPage.test.tsx | 49 +++- src/components/settings/RescanChainPage.tsx | 223 ++++++++++++------ src/context/JamSessionInfoContext.ts | 5 + src/i18n/locales/en/translation.json | 2 + src/index.css | 5 + src/lib/formValidation.ts | 32 ++- src/lib/utils.ts | 6 +- src/types/global.d.ts | 1 + 14 files changed, 310 insertions(+), 125 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1b15899a..e1fb0c4e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -232,7 +232,10 @@ function App() { /> } /> } /> - } /> + } + /> } diff --git a/src/components/import/ImportDetailsForm.test.tsx b/src/components/import/ImportDetailsForm.test.tsx index 57ac17b4..a55abef0 100644 --- a/src/components/import/ImportDetailsForm.test.tsx +++ b/src/components/import/ImportDetailsForm.test.tsx @@ -1,7 +1,10 @@ import type { ReactNode } from 'react' +import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { pseudoRandomInteger } from '@/lib/utils' import { flushActUpdates } from '@/test/flushActUpdates' +import type { BlockHeight } from '@/types/global' import { ImportDetailsForm } from './ImportDetailsForm' vi.mock('react-i18next', () => ({ @@ -77,9 +80,17 @@ const typeMnemonic = (value: string) => { }) } +const typeBlockheight = (value: BlockHeight) => { + fireEvent.change(screen.getByPlaceholderText('import_wallet.import_details.placeholder_blockheight'), { + target: { value }, + }) +} + +const mockedSessionInfo: SessionResponse = { session: false, maker_running: false, coinjoin_in_process: false } + describe('ImportDetailsForm', () => { it('renders the mnemonic, blockheight and gaplimit fields', async () => { - render() + render() expect(screen.getByText('import_wallet.import_details.label_mnemonic_phrase')).toBeInTheDocument() expect(document.querySelector('#blockheight')).toBeInTheDocument() expect(document.querySelector('#gaplimit')).toBeInTheDocument() @@ -87,7 +98,7 @@ describe('ImportDetailsForm', () => { }) it('shows a success alert when the mnemonic is a valid BIP-39 phrase', async () => { - render() + render() typeMnemonic(VALID_MNEMONIC) expect(screen.getByText('import_wallet.import_details.text_mnemonic_valid')).toBeInTheDocument() await flushActUpdates() @@ -95,7 +106,7 @@ describe('ImportDetailsForm', () => { it('warns and does not submit when the mnemonic is not recognized', async () => { const onSubmit = vi.fn() - render() + render() typeMnemonic('not a real seed phrase at all') fireEvent.submit(document.querySelector('form')!) await waitFor(() => @@ -104,12 +115,34 @@ describe('ImportDetailsForm', () => { expect(onSubmit).not.toHaveBeenCalled() }) + it('warns and does not submit when the blockheight is larger than current blockheight', async () => { + const onSubmit = vi.fn() + const currentBlockHeight = pseudoRandomInteger(1, Number.MAX_SAFE_INTEGER - 1) + render( + , + ) + typeBlockheight(currentBlockHeight + 1) + fireEvent.submit(document.querySelector('form')!) + await waitFor(() => + expect( + screen.getByText( + `import_wallet.import_details.feedback_invalid_blockheight {"min":"0","max":"${currentBlockHeight.toLocaleString()}"}`, + ), + ).toBeInTheDocument(), + ) + expect(onSubmit).not.toHaveBeenCalled() + }) + it('submits when all values are valid', async () => { const onSubmit = vi.fn() render( , ) fireEvent.submit(document.querySelector('form')!) @@ -120,7 +153,8 @@ describe('ImportDetailsForm', () => { render( , ) expect(screen.getByText('import_wallet.import_details.alert_high_gaplimit_value')).toBeInTheDocument() @@ -128,7 +162,7 @@ describe('ImportDetailsForm', () => { }) it('renders a disabled submit button when disabled', async () => { - render() + render() const submit = document.querySelector('button[type="submit"]') expect(submit).toBeDisabled() await flushActUpdates() diff --git a/src/components/import/ImportDetailsForm.tsx b/src/components/import/ImportDetailsForm.tsx index b08fe3fa..f34a6ab5 100644 --- a/src/components/import/ImportDetailsForm.tsx +++ b/src/components/import/ImportDetailsForm.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { yupResolver } from '@hookform/resolvers/yup' +import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import { validateMnemonic } from '@scure/bip39' import { wordlist } from '@scure/bip39/wordlists/english.js' import type { TFunction } from 'i18next' @@ -20,7 +21,9 @@ import { Spinner } from '@/components/ui/spinner' import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures' import { GAPLIMIT_WARN_THRESHOLD } from '@/constants/jam' import { JM_GAPLIMIT_DEFAULT } from '@/constants/jm' +import { blockHeightField, INPUT_BLOCK_HEIGHT_MIN } from '@/lib/formValidation' import { cn, DUMMY_SEED_PHRASE, isValidInteger, SEGWIT_ACTIVATION_BLOCK } from '@/lib/utils' +import type { BlockHeight } from '@/types/global' import { DevBadge } from '../dev/DevBadge' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' import { Alert, AlertDescription, AlertTitle } from '../ui/alert' @@ -37,17 +40,6 @@ const GAPLIMIT_SUGGESTIONS = { heavy: JM_GAPLIMIT_DEFAULT * 4, } -const MIN_BLOCKHEIGHT_VALUE = 1 -/** - * Maximum blockheight value. - * Value choosen based on estimation of blockheight in tge year 2140 (plus some buffer): - * 365 × 144 × (2140 - 2009) = 6_885_360 = ~7_000_000 - * This is necessary because javascript does not handle large values too well, - * and the `/rescanblockchain` errors. Not to mention that a value beyond the current - * height does not make any sense in the first place. - */ -const MAX_BLOCKHEIGHT_VALUE = 10_000_000 - const MIN_GAPLIMIT_VALUE = 1 /** * Maximum gaplimit value for importing an existing wallet. @@ -70,7 +62,7 @@ interface ImportDetailsFormValues { const defaultImportDetailsFormValues: ImportDetailsFormValues = isDevMode() ? { mnemonicPhrase: '', - blockheight: MIN_BLOCKHEIGHT_VALUE, + blockheight: INPUT_BLOCK_HEIGHT_MIN, gaplimit: GAPLIMIT_SUGGESTIONS.heavy, } : { @@ -93,10 +85,7 @@ const isBip39Mnemonic = (value: string) => { return validateMnemonic(normalized, wordlist) } -const importDetailsFormSchema = (t: TFunction) => { - const invalidBlockheightMessage = t('import_wallet.import_details.feedback_invalid_blockheight', { - min: MIN_BLOCKHEIGHT_VALUE.toLocaleString(), - }) +const importDetailsFormSchema = (currentBlockHeight: BlockHeight | undefined, t: TFunction) => { const invalidGaplimitMessage = t('import_wallet.import_details.feedback_invalid_gaplimit', { min: MIN_GAPLIMIT_VALUE.toLocaleString(), max: MAX_GAPLIMIT_VALUE.toLocaleString(), @@ -114,13 +103,16 @@ const importDetailsFormSchema = (t: TFunction) => { return isBip39Mnemonic(value) }, ), - blockheight: yup - .number() - .transform((value) => (isValidInteger(value) ? value : null)) - .integer(invalidBlockheightMessage) - .min(MIN_BLOCKHEIGHT_VALUE, invalidBlockheightMessage) - .max(MAX_BLOCKHEIGHT_VALUE, invalidBlockheightMessage) - .required(invalidBlockheightMessage), + blockheight: blockHeightField({ + currentBlockHeight: currentBlockHeight, + messages: { + invalid: ({ min, max }) => + t('import_wallet.import_details.feedback_invalid_blockheight', { + min: min.toLocaleString(), + max: max.toLocaleString(), + }), + }, + }), gaplimit: yup .number() .transform((value) => (isValidInteger(value) ? value : null)) @@ -134,6 +126,7 @@ const importDetailsFormSchema = (t: TFunction) => { type ImportDetailsFormProps = { className?: string + sessionInfo: SessionResponse | undefined onSubmit: SubmitHandler initialValues?: ImportDetailsFormValues disabled?: boolean @@ -142,6 +135,7 @@ type ImportDetailsFormProps = { export const ImportDetailsForm = ({ className, + sessionInfo, onSubmit, initialValues, disabled, @@ -149,7 +143,10 @@ export const ImportDetailsForm = ({ }: ImportDetailsFormProps) => { const { t } = useTranslation() - const schema = useMemo(() => importDetailsFormSchema(t), [t]) + const schema = useMemo( + () => importDetailsFormSchema(sessionInfo?.block_height ?? undefined, t), + [sessionInfo?.block_height, t], + ) const { register, @@ -265,13 +262,12 @@ export const ImportDetailsForm = ({ @@ -292,6 +288,7 @@ export const ImportDetailsForm = ({ {t('import_wallet.import_details.description_blockheight')} -
{importDetails.blockheight}
+
{importDetails.blockheight.toLocaleString()}
{t('import_wallet.import_details.label_gaplimit')}
{t('import_wallet.import_details.description_gaplimit')}
-
{importDetails.gaplimit}
+
{importDetails.gaplimit.toLocaleString()}
{showGaplimitWarning && ( diff --git a/src/components/import/ImportStepImportDetails.tsx b/src/components/import/ImportStepImportDetails.tsx index 4dbc3bf2..d4b5a0e4 100644 --- a/src/components/import/ImportStepImportDetails.tsx +++ b/src/components/import/ImportStepImportDetails.tsx @@ -1,5 +1,4 @@ import type { ComponentProps } from 'react' -import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import { ChevronLeftIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' @@ -9,7 +8,6 @@ import { ImportDetailsForm } from './ImportDetailsForm' import { RescanActiveAlert } from './RescanActiveAlert' type ImportStepImportDetailsProps = ComponentProps & { - sessionInfo: SessionResponse | undefined onBack: () => void } @@ -27,7 +25,7 @@ export const ImportStepImportDetails = ({ sessionInfo, onBack, ...importFormProp /> )} {isRescanActive && } - {showForm && } + {showForm && } + ) : null} + + {(currentBlockHeight && currentBlockHeight >= AVERAGE_BLOCKS_PER_YEAR) || isDeveloperMode ? ( + + ) : null} + + {(currentBlockHeight && currentBlockHeight >= SEGWIT_ACTIVATION_BLOCK) || isDeveloperMode ? ( + + + + + Segwit Activation Block + + ) : null} + + + {backLinkTarget ? ( + + ) : null} @@ -171,6 +248,8 @@ export const RescanChainPage = ({ walletFileName }: RescanChainProps) => { diff --git a/src/context/JamSessionInfoContext.ts b/src/context/JamSessionInfoContext.ts index 685efe3b..1e81c2b1 100644 --- a/src/context/JamSessionInfoContext.ts +++ b/src/context/JamSessionInfoContext.ts @@ -45,3 +45,8 @@ export const useRescanStatus = () => { const { rescanInfo, setRescanInfo } = useJamSessionInfoContext() return { rescanInfo, setRescanInfo } } + +export const useCurrentBlockHeight = () => { + const { blockHeight } = useJamSessionInfoContext() + return { currentBlockHeight: blockHeight } +} diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 508d52c0..d4435813 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -238,8 +238,10 @@ "import_options": "Import options", "label_blockheight": "Rescan height", "description_blockheight": "The blockheight at which the rescan process starts to search for your funds. The earlier the wallet has been created, the lower this value should be.", + "placeholder_blockheight": "Enter block height", "feedback_invalid_blockheight": "Please provide a valid value between {{ min }} and the current blockheight.", "label_gaplimit": "Address import limit", + "placeholder_gaplimit": "Enter address import limit", "description_gaplimit": "The number of addresses that are imported per jar. Set to the highest address index used in any of the jars. Increase this number if your wallet is heavily used.", "feedback_invalid_gaplimit": "Please provide a valid value between {{ min }} and {{ max }}.", "alert_high_gaplimit_value": "The given value causes many addresses to be imported, which can lead to a decline in performance and responsiveness.", diff --git a/src/index.css b/src/index.css index 570ea8cc..2270c781 100644 --- a/src/index.css +++ b/src/index.css @@ -182,6 +182,11 @@ html body[data-scroll-locked] { font-family: var(--font-sans); } +input[type='number'] { + font-family: var(--font-mono); + font-variant-numeric: slashed-zero; +} + @keyframes slide-up { from { transform: translateY(100%); diff --git a/src/lib/formValidation.ts b/src/lib/formValidation.ts index 12621558..2da65b3e 100644 --- a/src/lib/formValidation.ts +++ b/src/lib/formValidation.ts @@ -1,7 +1,8 @@ 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' +import type { BitcoinAddress, BlockHeight, JarIndex } from '@/types/global' +import { isValidInteger } from './utils' /** * Shared bitcoin-address predicates used across form schemas (send, sweep, ...). @@ -73,3 +74,32 @@ export const destinationAddressField = ({ (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) +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e6f0ffd0..45063642 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,7 +1,7 @@ import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' import { JM_WALLET_FILE_EXTENSION, type OfferType } from '@/constants/jm' -import type { Factor, Milliseconds, MnemonicPhrase } from '@/types/global' +import type { BlockHeight, Factor, Milliseconds, MnemonicPhrase } from '@/types/global' const HORIZONTAL_ELLIPSIS = '\u2026' // Horizontal Ellipsis `…` @@ -137,7 +137,9 @@ export const tryBtcToSat = (value: string): number | undefined => { return sats === 0 ? 0 : sign * sats } -export const SEGWIT_ACTIVATION_BLOCK = 481_824 // https://github.com/bitcoin/bitcoin/blob/v25.0/src/kernel/chainparams.cpp#L86 +export const SEGWIT_ACTIVATION_BLOCK: BlockHeight = 481_824 // https://github.com/bitcoin/bitcoin/blob/v25.0/src/kernel/chainparams.cpp#L86 +export const AVERAGE_BLOCKS_PER_DAY: number = Math.round((1 / 10) * 60 * 24) +export const AVERAGE_BLOCKS_PER_YEAR: number = 365 * AVERAGE_BLOCKS_PER_DAY // if applicable, the genesis date can be used as minimum `since` timestamp export const BITCOIN_GENESIS_DATE = new Date('2009-01-03T18:15:05Z') diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 8862d2ce..9206fd68 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -6,6 +6,7 @@ export type AmountSats = number export type Factor = number export type BitcoinAddress = string export type JarIndex = number +export type BlockHeight = number export type MnemonicPhrase = string[]