mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
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
This commit is contained in:
parent
4f8d7f497f
commit
f9523729ae
14 changed files with 310 additions and 125 deletions
|
|
@ -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!} />}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -45,3 +45,8 @@ export const useRescanStatus = () => {
|
|||
const { rescanInfo, setRescanInfo } = useJamSessionInfoContext()
|
||||
return { rescanInfo, setRescanInfo }
|
||||
}
|
||||
|
||||
export const useCurrentBlockHeight = () => {
|
||||
const { blockHeight } = useJamSessionInfoContext()
|
||||
return { currentBlockHeight: blockHeight }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
1
src/types/global.d.ts
vendored
1
src/types/global.d.ts
vendored
|
|
@ -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[]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue