chore(rescan): max rescan height is current blockheight (#1332)
Some checks failed
Build / build (v26.3.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled

* 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
This commit is contained in:
Thebora Kompanioni 2026-07-21 18:29:48 +02:00 committed by GitHub
parent 4f8d7f497f
commit f9523729ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 310 additions and 125 deletions

View file

@ -232,7 +232,10 @@ function App() {
/>
<Route path={routes.orderbook} element={<OrderbookPage />} />
<Route path={routes.logs} element={<LogsPage />} />
<Route path={routes.rescan} element={<RescanChainPage walletFileName={walletFileName!} />} />
<Route
path={routes.rescan}
element={<RescanChainPage walletFileName={walletFileName!} backLinkTarget="settings" />}
/>
<Route
path={routes.walletJarsDetails}
element={<WalletJarsDetailsPage walletFileName={walletFileName!} />}

View file

@ -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(<ImportDetailsForm onSubmit={vi.fn()} />)
render(<ImportDetailsForm onSubmit={vi.fn()} sessionInfo={mockedSessionInfo} />)
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(<ImportDetailsForm onSubmit={vi.fn()} />)
render(<ImportDetailsForm onSubmit={vi.fn()} sessionInfo={mockedSessionInfo} />)
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(<ImportDetailsForm onSubmit={onSubmit} />)
render(<ImportDetailsForm onSubmit={onSubmit} sessionInfo={mockedSessionInfo} />)
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(
<ImportDetailsForm
onSubmit={onSubmit}
sessionInfo={{ ...mockedSessionInfo, block_height: currentBlockHeight }}
/>,
)
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(
<ImportDetailsForm
onSubmit={onSubmit}
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700000, gaplimit: 50 }}
sessionInfo={mockedSessionInfo}
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700_000, gaplimit: 50 }}
/>,
)
fireEvent.submit(document.querySelector('form')!)
@ -120,7 +153,8 @@ describe('ImportDetailsForm', () => {
render(
<ImportDetailsForm
onSubmit={vi.fn()}
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700000, gaplimit: 9999 }}
sessionInfo={mockedSessionInfo}
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700_000, gaplimit: 9_999 }}
/>,
)
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(<ImportDetailsForm onSubmit={vi.fn()} disabled />)
render(<ImportDetailsForm onSubmit={vi.fn()} sessionInfo={mockedSessionInfo} disabled />)
const submit = document.querySelector('button[type="submit"]')
expect(submit).toBeDisabled()
await flushActUpdates()

View file

@ -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<ImportDetailsFormValues>
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 = ({
<InputGroup>
<InputGroupInput
id="blockheight"
placeholder={t('import_wallet.import_details.placeholder_blockheight')}
{...register('blockheight', {
required: true,
disabled,
})}
type="number"
min={MIN_BLOCKHEIGHT_VALUE}
max={MAX_BLOCKHEIGHT_VALUE}
step={1}
/>
<InputGroupAddon align="inline-start">
@ -292,6 +288,7 @@ export const ImportDetailsForm = ({
<InputGroup>
<InputGroupInput
id="gaplimit"
placeholder={t('import_wallet.import_details.placeholder_gaplimit')}
{...register('gaplimit', {
required: true,
disabled,

View file

@ -127,14 +127,14 @@ export const ImportStepConfirm = ({
<div className="text-muted-foreground text-xs">
{t('import_wallet.import_details.description_blockheight')}
</div>
<div className="text-xl">{importDetails.blockheight}</div>
<div className="text-xl">{importDetails.blockheight.toLocaleString()}</div>
</div>
<div>
<div>{t('import_wallet.import_details.label_gaplimit')}</div>
<div className="text-muted-foreground text-xs">
{t('import_wallet.import_details.description_gaplimit')}
</div>
<div className="text-xl">{importDetails.gaplimit}</div>
<div className="text-xl">{importDetails.gaplimit.toLocaleString()}</div>
</div>
{showGaplimitWarning && (
<Alert variant="warning">

View file

@ -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<typeof ImportDetailsForm> & {
sessionInfo: SessionResponse | undefined
onBack: () => void
}
@ -27,7 +25,7 @@ export const ImportStepImportDetails = ({ sessionInfo, onBack, ...importFormProp
/>
)}
{isRescanActive && <RescanActiveAlert linkTarget={'login'} />}
{showForm && <ImportDetailsForm {...importFormProps} />}
{showForm && <ImportDetailsForm sessionInfo={sessionInfo} {...importFormProps} />}
<Button variant="ghost" onClick={onBack}>
<ChevronLeftIcon />
{t('global.back')}

View file

@ -253,7 +253,7 @@ const ImportWalletPage = () => {
})
toast.success(t('rescan_chain.success_rescan_started'))
} catch (error: unknown) {
const reason = getErrorReason(error, 'Unknown error.')
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
console.warn('Non-critical error while fetching session after wallet import. Continuing with import...', reason)
}
@ -276,7 +276,7 @@ const ImportWalletPage = () => {
token: authState.auth.token,
})
} catch (error: unknown) {
const reason = getErrorReason(error, 'Unknown error.')
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
console.warn('Locking wallet attempt failed after import error.', reason)
}
}

View file

@ -1,8 +1,9 @@
import type { ReactNode } from 'react'
import type { ComponentProps, ReactNode } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { RescanInfo } from '@/context/JamSessionInfoContext'
import type { WalletFileName } from '@/lib/utils'
import { type RescanInfo } from '@/context/JamSessionInfoContext'
import { SEGWIT_ACTIVATION_BLOCK, type WalletFileName } from '@/lib/utils'
import type { BlockHeight } from '@/types/global'
import { RescanChainPage } from './RescanChainPage'
type MutationConfig = {
@ -11,7 +12,7 @@ type MutationConfig = {
onError: (error: unknown) => void
}
const mutateAsync = vi.fn<(blockHeight: number) => Promise<unknown>>().mockResolvedValue(undefined)
const mutateAsync = vi.fn<(blockHeight: BlockHeight) => Promise<unknown>>().mockResolvedValue(undefined)
const rescanblockchainMock = vi.fn<() => Promise<{ data: unknown }>>().mockResolvedValue({ data: 'ok' })
const navigateMock = vi.fn()
const setRescanInfo = vi.fn()
@ -19,6 +20,7 @@ const toastSuccess = vi.fn()
const toastError = vi.fn()
let mutationConfig: MutationConfig
let rescanInfo: RescanInfo
const currentBlockHeight: BlockHeight = SEGWIT_ACTIVATION_BLOCK
vi.mock('@tanstack/react-query', () => ({
useMutation: (config: MutationConfig) => {
@ -54,6 +56,7 @@ vi.mock('sonner', () => ({
vi.mock('@/context/JamSessionInfoContext', () => ({
useRescanStatus: () => ({ rescanInfo, setRescanInfo }),
useCurrentBlockHeight: () => ({ currentBlockHeight: SEGWIT_ACTIVATION_BLOCK }),
}))
vi.mock('@/hooks/useApiClient', () => ({
@ -77,9 +80,9 @@ const walletFileName = 'wallet.jmdat' as WalletFileName
// react-hook-form runs validation after mount; flushing inside async act keeps
// that deferred state update from triggering a "not wrapped in act" warning.
const renderPage = async () => {
const renderPage = async (props: Omit<ComponentProps<typeof RescanChainPage>, 'walletFileName'> = {}) => {
await act(async () => {
render(<RescanChainPage walletFileName={walletFileName} />)
render(<RescanChainPage walletFileName={walletFileName} {...props} />)
await Promise.resolve()
})
}
@ -90,11 +93,27 @@ describe('RescanChainPage', () => {
rescanInfo = { updatedAt: 0, rescanning: false, progress: undefined, progressInPercentage: undefined }
})
it('renders the form and navigates back', async () => {
it('renders the form', async () => {
await renderPage()
expect(screen.getByText('rescan_chain.title')).toBeInTheDocument()
expect(screen.getByRole('spinbutton', { name: 'rescan_chain.label_blockheight' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('rescan_chain.placeholder_blockheight')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 144 blocks (~24 hours)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 52,560 blocks (~1 year)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'From block #481,824' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'rescan_chain.text_button_submit' })).toBeInTheDocument()
})
it('renders the form and navigates back', async () => {
await renderPage({ backLinkTarget: 'settings' })
expect(screen.getByText('global.back')).toBeInTheDocument()
fireEvent.click(screen.getByTitle('global.back'))
expect(navigateMock).toHaveBeenCalled()
})
@ -113,15 +132,25 @@ describe('RescanChainPage', () => {
it('submits a valid block height through the mutation', async () => {
await renderPage()
const input = screen.getByPlaceholderText('rescan_chain.placeholder_blockheight')
fireEvent.change(input, { target: { value: String(700_000) } })
fireEvent.change(input, { target: { value: String(currentBlockHeight - 1) } })
fireEvent.submit(input.closest('form')!)
await waitFor(() => expect(mutateAsync).toHaveBeenCalledWith(700_000))
await waitFor(() => expect(mutateAsync).toHaveBeenCalledWith(currentBlockHeight - 1))
})
it('submits an invalid block height and verifies form error', async () => {
await renderPage()
const input = screen.getByPlaceholderText('rescan_chain.placeholder_blockheight')
fireEvent.change(input, { target: { value: String(currentBlockHeight + 1) } })
fireEvent.submit(input.closest('form')!)
await waitFor(() => expect(screen.getByText(/rescan_chain\.feedback_invalid_blockheight/)).toBeInTheDocument())
expect(mutateAsync).not.toHaveBeenCalled()
})
it('mutationFn calls the rescan API and returns data', async () => {
await renderPage()
await expect(mutationConfig.mutationFn(700_000)).resolves.toBe('ok')
await expect(mutationConfig.mutationFn(currentBlockHeight - 1)).resolves.toBe('ok')
expect(rescanblockchainMock).toHaveBeenCalled()
})

View file

@ -1,6 +1,8 @@
import { useMemo, useState } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import { rescanblockchain } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useMutation } from '@tanstack/react-query'
import type { TFunction } from 'i18next'
import { ArrowLeftIcon, PackageSearchIcon, RefreshCwIcon } from 'lucide-react'
import { useForm } from 'react-hook-form'
import type { SubmitHandler } from 'react-hook-form'
@ -10,45 +12,60 @@ import { toast } from 'sonner'
import * as yup from 'yup'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import PageTitle from '@/components/ui/jam/PageTitle'
import { Label } from '@/components/ui/label'
import { routes } from '@/constants/routes'
import { useRescanStatus, type RescanInfo } from '@/context/JamSessionInfoContext'
import { routes, type Route } from '@/constants/routes'
import { useCurrentBlockHeight, useRescanStatus, type RescanInfo } from '@/context/JamSessionInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import { getErrorReason } from '@/lib/errorReason'
import { SEGWIT_ACTIVATION_BLOCK } from '@/lib/utils'
import { blockHeightField, INPUT_BLOCK_HEIGHT_MIN } from '@/lib/formValidation'
import { AVERAGE_BLOCKS_PER_DAY, AVERAGE_BLOCKS_PER_YEAR, SEGWIT_ACTIVATION_BLOCK } from '@/lib/utils'
import type { WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import type { BlockHeight } from '@/types/global'
import { Field, FieldDescription, FieldLabel } from '../ui/field'
import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
const INPUT_BLOCK_HEIGHT_MIN = 0
type Inputs = {
blockHeight: number
type RescanChainFormValues = {
blockHeight: BlockHeight
}
const schema = yup
.object({
blockHeight: yup.number().integer().default(SEGWIT_ACTIVATION_BLOCK).min(INPUT_BLOCK_HEIGHT_MIN).required(),
})
.required()
const rescanFormSchema = (currentBlockHeight: BlockHeight | undefined, t: TFunction) => {
return yup
.object({
blockHeight: blockHeightField({
currentBlockHeight: currentBlockHeight,
messages: {
invalid: ({ min, max }) =>
t('rescan_chain.feedback_invalid_blockheight', { min: min.toLocaleString(), max: max.toLocaleString() }),
},
}),
})
.required()
}
interface RescanChainFormProps {
rescanInfo: RescanInfo
onSubmit: SubmitHandler<Inputs>
defaultValues?: Partial<RescanChainFormValues>
currentBlockHeight?: BlockHeight
onSubmit: SubmitHandler<RescanChainFormValues>
disabled?: boolean
}
function RescanChainForm({ rescanInfo, onSubmit, disabled }: RescanChainFormProps) {
function RescanChainForm({ rescanInfo, defaultValues, currentBlockHeight, onSubmit, disabled }: RescanChainFormProps) {
const { t } = useTranslation()
const { enabled: isDeveloperMode } = useDeveloperMode()
const schema = useMemo(() => rescanFormSchema(currentBlockHeight, t), [currentBlockHeight, t])
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
} = useForm<Inputs>({
mode: 'all',
defaultValues: {
blockHeight: SEGWIT_ACTIVATION_BLOCK,
},
setValue,
formState: { errors, isSubmitting },
} = useForm<RescanChainFormValues>({
mode: 'onSubmit',
defaultValues,
resolver: yupResolver(schema),
})
@ -58,37 +75,94 @@ function RescanChainForm({ rescanInfo, onSubmit, disabled }: RescanChainFormProp
<form onSubmit={(event) => void doOnSubmit(event)} className="space-y-4">
{/* include validation with required or other standard HTML validation rules */}
<div className="space-y-2">
<Label htmlFor="rescanHeight" className="text-sm font-medium">
{t('rescan_chain.label_blockheight')}
</Label>
<p className="text-muted-foreground text-xs">{t('rescan_chain.description_blockheight')}</p>
<div className="relative">
<div className="absolute top-1/2 left-3 -translate-y-1/2">
<PackageSearchIcon className="text-muted-foreground h-4 w-4" />
</div>
<Field data-invalid={errors.blockHeight !== undefined}>
<FieldLabel htmlFor="inputRescanBlockheight">{t('rescan_chain.label_blockheight')}</FieldLabel>
<FieldDescription className="text-xs">{t('rescan_chain.description_blockheight')}</FieldDescription>
<InputGroup className="gap-2">
<InputGroupInput
id="inputRescanBlockheight"
{...register('blockHeight', {
disabled: disabled || rescanInfo.rescanning,
})}
placeholder={t('rescan_chain.placeholder_blockheight')}
type="number"
step={1}
/>
<InputGroupAddon align="inline-start">
<PackageSearchIcon />
</InputGroupAddon>
</InputGroup>
</Field>
<Input
{...register('blockHeight', {
disabled: rescanInfo.rescanning,
})}
type="number"
step={1}
className="bg-background pl-10"
placeholder={t('rescan_chain.placeholder_blockheight')}
/>
</div>
{errors.blockHeight && (
<div className="text-destructive text-xs">
<span>{t('rescan_chain.feedback_invalid_blockheight', { min: INPUT_BLOCK_HEIGHT_MIN })}</span>
</div>
)}
{errors.blockHeight?.message && <div className="text-destructive text-xs">{errors.blockHeight.message}</div>}
</div>
<Button
type="submit"
disabled={disabled || !isValid || isSubmitting || rescanInfo.rescanning}
className="w-full"
size="xxl"
>
<div className="flex flex-wrap gap-2">
{(currentBlockHeight && currentBlockHeight >= AVERAGE_BLOCKS_PER_DAY) || isDeveloperMode ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() =>
setValue(
'blockHeight',
Math.max(INPUT_BLOCK_HEIGHT_MIN, (currentBlockHeight ?? 0) - AVERAGE_BLOCKS_PER_DAY),
{
shouldValidate: true,
},
)
}
disabled={disabled || isSubmitting || rescanInfo.rescanning}
>
{/* TODO: i18n */}
Last {AVERAGE_BLOCKS_PER_DAY.toLocaleString()} blocks (~24 hours)
</Button>
) : null}
{(currentBlockHeight && currentBlockHeight >= AVERAGE_BLOCKS_PER_YEAR) || isDeveloperMode ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() =>
setValue(
'blockHeight',
Math.max(INPUT_BLOCK_HEIGHT_MIN, (currentBlockHeight ?? 0) - AVERAGE_BLOCKS_PER_YEAR),
{
shouldValidate: true,
},
)
}
disabled={disabled || isSubmitting || rescanInfo.rescanning}
>
{/* TODO: i18n */}
Last {AVERAGE_BLOCKS_PER_YEAR.toLocaleString()} blocks (~1 year)
</Button>
) : null}
{(currentBlockHeight && currentBlockHeight >= SEGWIT_ACTIVATION_BLOCK) || isDeveloperMode ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() =>
setValue('blockHeight', SEGWIT_ACTIVATION_BLOCK, {
shouldValidate: true,
})
}
disabled={disabled || isSubmitting || rescanInfo.rescanning}
>
{/* TODO: i18n */}
From block #{SEGWIT_ACTIVATION_BLOCK.toLocaleString()}
</Button>
</TooltipTrigger>
<TooltipContent>Segwit Activation Block</TooltipContent>
</Tooltip>
) : null}
</div>
<Button type="submit" disabled={disabled || isSubmitting || rescanInfo.rescanning} className="w-full" size="xxl">
{isSubmitting || rescanInfo.rescanning
? t('rescan_chain.text_button_submitting')
: t('rescan_chain.text_button_submit')}
@ -99,12 +173,22 @@ function RescanChainForm({ rescanInfo, onSubmit, disabled }: RescanChainFormProp
interface RescanChainProps {
walletFileName: WalletFileName
backLinkTarget?: Route
}
export const RescanChainPage = ({ walletFileName }: RescanChainProps) => {
export const RescanChainPage = ({ walletFileName, backLinkTarget }: RescanChainProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const client = useApiClient()
const { currentBlockHeight } = useCurrentBlockHeight()
const [defaultValues] = useState<Partial<RescanChainFormValues>>(() => ({
blockHeight:
currentBlockHeight === undefined
? SEGWIT_ACTIVATION_BLOCK
: Math.max(INPUT_BLOCK_HEIGHT_MIN, currentBlockHeight - AVERAGE_BLOCKS_PER_DAY),
}))
const { rescanInfo, setRescanInfo } = useRescanStatus()
const rescanMutation = useMutation({
@ -139,31 +223,24 @@ export const RescanChainPage = ({ walletFileName }: RescanChainProps) => {
},
})
const handleRescan = async (blockHeight: number) => {
if (Number.isNaN(blockHeight) || blockHeight < INPUT_BLOCK_HEIGHT_MIN) {
toast.error(t('rescan_chain.feedback_invalid_blockheight', { min: INPUT_BLOCK_HEIGHT_MIN }))
return
}
await rescanMutation.mutateAsync(blockHeight)
}
const onSubmit: SubmitHandler<Inputs> = async (data) => {
return handleRescan(data.blockHeight)
const onSubmit: SubmitHandler<RescanChainFormValues> = async (data) => {
return await rescanMutation.mutateAsync(data.blockHeight)
}
return (
<div className="mx-auto max-w-4xl space-y-3 p-4">
<div className="flex min-w-0 items-start gap-2 sm:items-center sm:gap-4">
<Button
variant="ghost"
className="shrink-0"
onClick={() => void navigate(routes.settings)}
title={t('global.back')}
>
<ArrowLeftIcon />
<span className="sr-only">{t('global.back')}</span>
</Button>
{backLinkTarget ? (
<Button
variant="ghost"
className="shrink-0"
onClick={() => void navigate(routes.settings)}
title={t('global.back')}
>
<ArrowLeftIcon />
<span className="sr-only">{t('global.back')}</span>
</Button>
) : null}
<PageTitle title={t('rescan_chain.title')} subtitle={t('rescan_chain.subtitle')} />
</div>
@ -171,6 +248,8 @@ export const RescanChainPage = ({ walletFileName }: RescanChainProps) => {
<CardContent>
<RescanChainForm
rescanInfo={rescanInfo}
defaultValues={defaultValues}
currentBlockHeight={currentBlockHeight}
onSubmit={onSubmit}
disabled={rescanInfo.rescanning || rescanMutation.isPending}
/>

View file

@ -45,3 +45,8 @@ export const useRescanStatus = () => {
const { rescanInfo, setRescanInfo } = useJamSessionInfoContext()
return { rescanInfo, setRescanInfo }
}
export const useCurrentBlockHeight = () => {
const { blockHeight } = useJamSessionInfoContext()
return { currentBlockHeight: blockHeight }
}

View file

@ -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.",

View file

@ -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%);

View file

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

View file

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

View file

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