From 1fc21133a346217bf3dd7bdc14efa13596a7dbb0 Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Sat, 30 Mar 2024 08:47:15 -0400 Subject: [PATCH] feat: walletd resubscribe --- .changeset/dull-trees-grow.md | 5 + .changeset/mighty-days-remain.md | 5 + .changeset/plenty-ligers-pay.md | 5 + .changeset/rare-bags-live.md | 5 + .changeset/wicked-buses-tap.md | 5 + apps/walletd/components/RescanStatus.tsx | 76 +++++++++ apps/walletd/components/WalletContextMenu.tsx | 10 ++ .../walletd/components/WalletsContextMenu.tsx | 28 ++++ .../WalletsList/WalletsActionsMenu.tsx | 18 ++- apps/walletd/config/providers.tsx | 2 + apps/walletd/contexts/dialog.tsx | 52 +++++-- apps/walletd/contexts/events/index.tsx | 16 +- apps/walletd/dialogs/CalloutWarning.tsx | 25 +++ apps/walletd/dialogs/FieldRescan.tsx | 144 ++++++++++++++++++ .../dialogs/WalletAddNewDialog/index.tsx | 3 +- .../dialogs/WalletAddRecoverDialog/index.tsx | 3 +- .../dialogs/WalletAddressesAddDialog.tsx | 36 ++--- .../index.tsx | 87 +++++++---- .../index.tsx | 98 +++++++----- .../dialogs/WalletUpdateDialog/index.tsx | 28 ++-- apps/walletd/dialogs/WalletsRescanDialog.tsx | 108 +++++++++++++ libs/design-system/src/core/Dialog.tsx | 8 + libs/design-system/src/form/FieldSwitch.tsx | 7 +- .../src/form/useDialogFormHelpers.ts | 9 +- libs/react-walletd/src/api.ts | 32 +++- 25 files changed, 686 insertions(+), 129 deletions(-) create mode 100644 .changeset/dull-trees-grow.md create mode 100644 .changeset/mighty-days-remain.md create mode 100644 .changeset/plenty-ligers-pay.md create mode 100644 .changeset/rare-bags-live.md create mode 100644 .changeset/wicked-buses-tap.md create mode 100644 apps/walletd/components/RescanStatus.tsx create mode 100644 apps/walletd/components/WalletsContextMenu.tsx create mode 100644 apps/walletd/dialogs/CalloutWarning.tsx create mode 100644 apps/walletd/dialogs/FieldRescan.tsx create mode 100644 apps/walletd/dialogs/WalletsRescanDialog.tsx diff --git a/.changeset/dull-trees-grow.md b/.changeset/dull-trees-grow.md new file mode 100644 index 000000000..e962750ba --- /dev/null +++ b/.changeset/dull-trees-grow.md @@ -0,0 +1,5 @@ +--- +'walletd': minor +--- + +Address generation and addition dialogs now have an option to rescan from a specified height. Closes https://github.com/SiaFoundation/walletd/issues/96 diff --git a/.changeset/mighty-days-remain.md b/.changeset/mighty-days-remain.md new file mode 100644 index 000000000..53ead5a2b --- /dev/null +++ b/.changeset/mighty-days-remain.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': patch +--- + +Fixed an issue where Dialog and useDialogFormHelpers were not calling onOpenChange on open events. diff --git a/.changeset/plenty-ligers-pay.md b/.changeset/plenty-ligers-pay.md new file mode 100644 index 000000000..4681461d6 --- /dev/null +++ b/.changeset/plenty-ligers-pay.md @@ -0,0 +1,5 @@ +--- +'walletd': minor +--- + +There is now a dedicated rescan dialog that can be opened from the wallet list and wallet context menus. Closes https://github.com/SiaFoundation/walletd/issues/96 diff --git a/.changeset/rare-bags-live.md b/.changeset/rare-bags-live.md new file mode 100644 index 000000000..86157e2a3 --- /dev/null +++ b/.changeset/rare-bags-live.md @@ -0,0 +1,5 @@ +--- +'walletd': minor +--- + +Rescan progress and status including errors is now shown in a sticky status bar. Closes https://github.com/SiaFoundation/walletd/issues/96 diff --git a/.changeset/wicked-buses-tap.md b/.changeset/wicked-buses-tap.md new file mode 100644 index 000000000..a70eb67cf --- /dev/null +++ b/.changeset/wicked-buses-tap.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/react-walletd': minor +--- + +Added useRescanStart, useRescanStatus. diff --git a/apps/walletd/components/RescanStatus.tsx b/apps/walletd/components/RescanStatus.tsx new file mode 100644 index 000000000..84c8c0b7c --- /dev/null +++ b/apps/walletd/components/RescanStatus.tsx @@ -0,0 +1,76 @@ +import { + Panel, + ProgressBar, + Separator, + Text, +} from '@siafoundation/design-system' +import { useRescanStatus } from '@siafoundation/react-walletd' +import { useSyncStatus } from '../hooks/useSyncStatus' +import { formatRelative } from 'date-fns' +import { defaultDatasetRefreshInterval } from '../config/swr' + +export function RescanStatus() { + const syncStatus = useSyncStatus() + const rescanStatus = useRescanStatus({ + config: { + swr: { + refreshInterval: defaultDatasetRefreshInterval, + }, + }, + }) + + if (!rescanStatus.data) { + return null + } + + const isScanning = rescanStatus.data.index.height < syncStatus.nodeBlockHeight + + if (!isScanning) { + return null + } + + return ( +
+ + + Rescanning the blockchain + +
+ +
+ + {rescanStatus.data.error ? 'Stopped' : 'Scanning...'} + + + {`${rescanStatus.data.index.height.toLocaleString()} / ${syncStatus.nodeBlockHeight.toLocaleString()}`} + +
+
+ +
+ {rescanStatus.data.error && ( + + Error rescanning the blockchain + + )} +
+ + Started{' '} + {formatRelative(new Date(rescanStatus.data.startTime), new Date())} + +
+ {rescanStatus.data.error && ( +
+ + {rescanStatus.data.error} + +
+ )} + +
+ ) +} diff --git a/apps/walletd/components/WalletContextMenu.tsx b/apps/walletd/components/WalletContextMenu.tsx index f6d652373..d6a0d994f 100644 --- a/apps/walletd/components/WalletContextMenu.tsx +++ b/apps/walletd/components/WalletContextMenu.tsx @@ -9,6 +9,7 @@ import { Unlocked16, Edit16, Delete16, + Scan16, } from '@siafoundation/react-icons' import { useDialog } from '../contexts/dialog' import { WalletData } from '../contexts/wallets/types' @@ -66,6 +67,15 @@ export function WalletContextMenu({ Delete wallet + e.stopPropagation()} + onSelect={() => openDialog('walletsRescan')} + > + + + + Rescan blockchain + ) } diff --git a/apps/walletd/components/WalletsContextMenu.tsx b/apps/walletd/components/WalletsContextMenu.tsx new file mode 100644 index 000000000..803c4b562 --- /dev/null +++ b/apps/walletd/components/WalletsContextMenu.tsx @@ -0,0 +1,28 @@ +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuLeftSlot, + DropdownMenuLabel, +} from '@siafoundation/design-system' +import { Scan16 } from '@siafoundation/react-icons' +import { useDialog } from '../contexts/dialog' + +type Props = Omit, 'children'> + +export function WalletsContextMenu({ ...props }: Props) { + const { openDialog } = useDialog() + return ( + + Actions + e.stopPropagation()} + onSelect={() => openDialog('walletsRescan')} + > + + + + Rescan blockchain + + + ) +} diff --git a/apps/walletd/components/WalletsList/WalletsActionsMenu.tsx b/apps/walletd/components/WalletsList/WalletsActionsMenu.tsx index ed185ef27..8050295ea 100644 --- a/apps/walletd/components/WalletsList/WalletsActionsMenu.tsx +++ b/apps/walletd/components/WalletsList/WalletsActionsMenu.tsx @@ -1,8 +1,9 @@ import { Button } from '@siafoundation/design-system' -import { Add16, Locked16 } from '@siafoundation/react-icons' +import { Add16, Locked16, Settings16 } from '@siafoundation/react-icons' import { useWallets } from '../../contexts/wallets' import { useDialog } from '../../contexts/dialog' import { WalletsViewDropdownMenu } from './WalletsViewDropdownMenu' +import { WalletsContextMenu } from '../WalletsContextMenu' export function WalletsActionsMenu() { const { lockAllWallets, unlockedCount } = useWallets() @@ -24,6 +25,21 @@ export function WalletsActionsMenu() { Add wallet + + + + } + contentProps={{ + align: 'end', + }} + />
) } diff --git a/apps/walletd/config/providers.tsx b/apps/walletd/config/providers.tsx index 7cec3aced..4ea0eba5e 100644 --- a/apps/walletd/config/providers.tsx +++ b/apps/walletd/config/providers.tsx @@ -5,6 +5,7 @@ import { AddressesProvider } from '../contexts/addresses' import { EventsProvider } from '../contexts/events' import { LedgerProvider } from '../contexts/ledger' import { AppProvider } from '../contexts/app' +import { RescanStatus } from '../components/RescanStatus' type Props = { children: React.ReactNode @@ -21,6 +22,7 @@ export function Providers({ children }: Props) { {/* this is here so that dialogs can use all the other providers, and the other providers can trigger dialogs */} + {children} diff --git a/apps/walletd/contexts/dialog.tsx b/apps/walletd/contexts/dialog.tsx index 21c56d7f1..5c4318259 100644 --- a/apps/walletd/contexts/dialog.tsx +++ b/apps/walletd/contexts/dialog.tsx @@ -70,6 +70,10 @@ import { WalletAddressesGenerateLedgerDialog, WalletAddressesGenerateLedgerDialogParams, } from '../dialogs/WalletAddressesGenerateLedgerDialog' +import { + WalletsRescanDialogParams, + WalletsRescanDialog, +} from '../dialogs/WalletsRescanDialog' // import { CmdKDialog } from '../components/CmdKDialog' export type DialogParams = { @@ -82,6 +86,7 @@ export type DialogParams = { addressRemove?: AddressRemoveDialogParams connectPeer?: SyncerConnectPeerDialogParams confirm?: ConfirmDialogParams + walletsRescan?: WalletsRescanDialogParams walletAddType?: WalletAddTypeDialogParams walletAddNew?: WalletAddNewDialogParams walletAddRecover?: WalletAddRecoverDialogParams @@ -213,42 +218,69 @@ export function Dialogs() { (val ? openDialog(dialog) : closeDialog())} + onOpenChange={(val) => + val + ? openDialog(dialog, params['walletAddressesGenerate']) + : closeDialog() + } /> (val ? openDialog(dialog) : closeDialog())} + onOpenChange={(val) => + val + ? openDialog(dialog, params['walletLedgerAddressGenerate']) + : closeDialog() + } /> (val ? openDialog(dialog) : closeDialog())} + onOpenChange={(val) => + val ? openDialog(dialog, params['walletAddressesAdd']) : closeDialog() + } /> (val ? openDialog(dialog) : closeDialog())} + onOpenChange={(val) => + val ? openDialog(dialog, params['walletRemove']) : closeDialog() + } /> (val ? openDialog(dialog) : closeDialog())} + onOpenChange={(val) => + val ? openDialog(dialog, params['walletUpdate']) : closeDialog() + } /> (val ? openDialog(dialog) : closeDialog())} + onOpenChange={(val) => + val ? openDialog(dialog, params['walletUnlock']) : closeDialog() + } + /> + + val ? openDialog(dialog, params['walletsRescan']) : closeDialog() + } /> (val ? openDialog(dialog) : closeDialog())} + onOpenChange={(val) => + val ? openDialog(dialog, params['addressUpdate']) : closeDialog() + } /> (val ? openDialog(dialog) : closeDialog())} + onOpenChange={(val) => + val ? openDialog(dialog, params['addressRemove']) : closeDialog() + } /> (val ? openDialog(dialog) : closeDialog())} + onOpenChange={(val) => + val ? openDialog(dialog, params['connectPeer']) : closeDialog() + } /> { - _resubscribe.post({ - payload: 0, - }) - }, [_resubscribe]) - const dataset = useMemo(() => { if (!responseEvents.data || !responseTxPool.data) { return null @@ -204,7 +193,6 @@ export function useEventsMain() { removeFilter, removeLastFilter, resetFilters, - resubscribe, offset, limit, } diff --git a/apps/walletd/dialogs/CalloutWarning.tsx b/apps/walletd/dialogs/CalloutWarning.tsx new file mode 100644 index 000000000..10b1af2e2 --- /dev/null +++ b/apps/walletd/dialogs/CalloutWarning.tsx @@ -0,0 +1,25 @@ +import { Alert, Text } from '@siafoundation/design-system' +import { Warning16 } from '@carbon/icons-react' + +type Props = { + label: string + description: React.ReactNode +} + +export function CalloutWarning({ label, description }: Props) { + return ( + +
+
+ + + + {label} +
+ + {description} + +
+
+ ) +} diff --git a/apps/walletd/dialogs/FieldRescan.tsx b/apps/walletd/dialogs/FieldRescan.tsx new file mode 100644 index 000000000..93a1f6712 --- /dev/null +++ b/apps/walletd/dialogs/FieldRescan.tsx @@ -0,0 +1,144 @@ +import { + ConfigFields, + FieldNumber, + FieldSwitch, + Label, + Separator, + Text, + triggerErrorToast, + triggerSuccessToast, +} from '@siafoundation/design-system' +import { Path, UseFormReturn } from 'react-hook-form' +import BigNumber from 'bignumber.js' +import { useRescanStart } from '@siafoundation/react-walletd' +import { CalloutWarning } from './CalloutWarning' + +export function getRescanFields() { + return { + shouldRescan: { + type: 'boolean', + title: 'Enable', + validation: {}, + }, + rescanStartHeight: { + type: 'number', + decimalsLimit: 0, + title: 'Start height', + validation: {}, + }, + } as const +} + +export function getDefaultRescanValues({ + rescanStartHeight, +}: { + rescanStartHeight?: number +} = {}) { + return { + shouldRescan: false, + rescanStartHeight: new BigNumber(rescanStartHeight || 0), + } +} + +type Values = ReturnType + +type Props = { + fields: ConfigFields + form: UseFormReturn +} + +export function FieldRescan({ form, fields }: Props) { + const shouldRescan = form.watch('shouldRescan' as Path) + const showDetails = shouldRescan + return ( +
+ + +
+ +
+
+ } + /> + {showDetails && ( + } + /> + )} +
+ {showDetails && ( + + Rescan the blockchain from the specified start height to find any + missing transaction activity across all wallets. + + )} + {showDetails && ( +
+ + +
+ )} +
+ ) +} + +export function RescanCalloutWarningExpensive() { + return ( + + Only rescan the blockchain if you have added addresses with past + transactions activity. Rescanning the blockchain is a very expensive + operation and can take a long time. + + } + /> + ) +} + +export function RescanCalloutWarningStartHeight() { + return ( + + For start height, select the highest block height possible, but one + that you are sure is before the first transaction activity for the + addresses you have added. + + } + /> + ) +} + +export function useTriggerRescan() { + const rescan = useRescanStart() + return async (values: Values) => { + if (values.shouldRescan) { + const response = await rescan.post({ + payload: values.rescanStartHeight + ? values.rescanStartHeight.toNumber() + : 0, + }) + if (response.error) { + triggerErrorToast({ + title: 'Error rescanning the blockchain', + body: response.error, + }) + } else { + triggerSuccessToast({ + title: 'Rescanning the blockchain', + body: 'The blockchain is being rescanned for relevant wallet events.', + }) + } + } + } +} diff --git a/apps/walletd/dialogs/WalletAddNewDialog/index.tsx b/apps/walletd/dialogs/WalletAddNewDialog/index.tsx index 6933607c2..94f8d9777 100644 --- a/apps/walletd/dialogs/WalletAddNewDialog/index.tsx +++ b/apps/walletd/dialogs/WalletAddNewDialog/index.tsx @@ -138,7 +138,8 @@ export function WalletAddNewDialog({ trigger, open, onOpenChange }: Props) { const onSubmit = useCallback( async (values: Values) => { - const mnemonicHash = blake2bHex(values.mnemonic) + const mnemonic = values.mnemonic.trim() + const mnemonicHash = blake2bHex(mnemonic) const metadata: WalletMetadata = { type: 'seed', mnemonicHash, diff --git a/apps/walletd/dialogs/WalletAddRecoverDialog/index.tsx b/apps/walletd/dialogs/WalletAddRecoverDialog/index.tsx index bd590b573..df65572a6 100644 --- a/apps/walletd/dialogs/WalletAddRecoverDialog/index.tsx +++ b/apps/walletd/dialogs/WalletAddRecoverDialog/index.tsx @@ -95,7 +95,8 @@ export function WalletAddRecoverDialog({ trigger, open, onOpenChange }: Props) { const onSubmit = useCallback( async (values: Values) => { - const mnemonicHash = blake2bHex(values.mnemonic) + const mnemonic = values.mnemonic.trim() + const mnemonicHash = blake2bHex(mnemonic) const metadata: WalletMetadata = { type: 'seed', mnemonicHash, diff --git a/apps/walletd/dialogs/WalletAddressesAddDialog.tsx b/apps/walletd/dialogs/WalletAddressesAddDialog.tsx index e4a487e79..f05db55ff 100644 --- a/apps/walletd/dialogs/WalletAddressesAddDialog.tsx +++ b/apps/walletd/dialogs/WalletAddressesAddDialog.tsx @@ -2,7 +2,6 @@ import { ConfigFields, Dialog, - FieldSwitch, FieldTextArea, FormSubmitButton, Paragraph, @@ -12,7 +11,6 @@ import { } from '@siafoundation/design-system' import { WalletAddressMetadata, - useResubscribe, useWalletAddressAdd, } from '@siafoundation/react-walletd' import { useCallback } from 'react' @@ -20,6 +18,12 @@ import { useForm } from 'react-hook-form' import { useWallets } from '../contexts/wallets' import { isValidAddress } from '@siafoundation/units' import { uniq } from '@technically/lodash' +import { + FieldRescan, + useTriggerRescan, + getRescanFields, + getDefaultRescanValues, +} from './FieldRescan' export type WalletAddressesAddDialogParams = { walletId: string @@ -34,7 +38,7 @@ type Props = { const defaultValues = { addresses: '', - shouldResubscribe: false, + ...getDefaultRescanValues(), } type Values = typeof defaultValues @@ -84,11 +88,7 @@ function getFields(): ConfigFields { }, }, }, - shouldResubscribe: { - type: 'boolean', - title: 'Resubscribe', - validation: {}, - }, + ...getRescanFields(), } } @@ -115,7 +115,6 @@ export function WalletAddressesAddDialog({ const fields = getFields() const addressAdd = useWalletAddressAdd() - const resubscribe = useResubscribe() const addAllAddresses = useCallback( async (addresses: string) => { const addrs = formatAddresses(addresses) @@ -151,6 +150,7 @@ export function WalletAddressesAddDialog({ [walletId, addressAdd] ) + const triggerRescan = useTriggerRescan() const onSubmit = useCallback( async (values: Values) => { const result = await addAllAddresses(values.addresses) @@ -178,17 +178,14 @@ export function WalletAddressesAddDialog({ title: `Added ${result.successful} addresses`, }) } - if (values.shouldResubscribe) { - resubscribe.post({ - payload: 0, - }) - } + triggerRescan(values) closeAndReset() }, - [addAllAddresses, closeAndReset, resubscribe] + [addAllAddresses, closeAndReset, triggerRescan] ) const addressesText = form.watch('addresses') + const shouldRescan = form.watch('shouldRescan') const addressCount = formatAddresses(addressesText).length return ( @@ -203,12 +200,17 @@ export function WalletAddressesAddDialog({ onSubmit={form.handleSubmit(onSubmit)} controls={
- + {addressCount === 0 ? 'Add addresses' : addressCount === 1 ? 'Add 1 address' : `Add ${addressCount.toLocaleString()} addresses`} + {shouldRescan ? ' and rescan' : ''}
} @@ -218,7 +220,7 @@ export function WalletAddressesAddDialog({ Enter multiple addresses separated by spaces or commas. - + ) diff --git a/apps/walletd/dialogs/WalletAddressesGenerateLedgerDialog/index.tsx b/apps/walletd/dialogs/WalletAddressesGenerateLedgerDialog/index.tsx index 6cc833890..657a308d3 100644 --- a/apps/walletd/dialogs/WalletAddressesGenerateLedgerDialog/index.tsx +++ b/apps/walletd/dialogs/WalletAddressesGenerateLedgerDialog/index.tsx @@ -24,6 +24,13 @@ import { useLedger } from '../../contexts/ledger' import { LedgerAddress } from './LedgerAddress' import { useWalletAddresses } from '../../hooks/useWalletAddresses' import { getSDK } from '@siafoundation/sdk' +import { + FieldRescan, + getRescanFields, + getDefaultRescanValues, + useTriggerRescan, +} from '../FieldRescan' +import { useSyncStatus } from '../../hooks/useSyncStatus' export type WalletAddressesGenerateLedgerDialogParams = { walletId: string @@ -37,11 +44,18 @@ type Props = { onOpenChange: (val: boolean) => void } -function getDefaultValues(lastIndex: number) { +function getDefaultValues({ + nextIndex, + rescanStartHeight, +}: { + nextIndex: number + rescanStartHeight: number +}) { return { ledgerConnected: false, - index: new BigNumber(lastIndex), + index: new BigNumber(nextIndex), count: new BigNumber(1), + ...getDefaultRescanValues({ rescanStartHeight }), } } @@ -78,6 +92,7 @@ function getFields(): ConfigFields { max: 1000, }, }, + ...getRescanFields(), } } @@ -109,10 +124,14 @@ export function WalletAddressesGenerateLedgerDialog({ lastIndex, datasetCount, } = useWalletAddresses({ id: walletId }) + const syncStatus = useSyncStatus() const { dataset } = useWallets() const wallet = dataset?.find((w) => w.id === walletId) const nextIndex = lastIndex + 1 - const defaultValues = getDefaultValues(nextIndex) + const defaultValues = getDefaultValues({ + nextIndex, + rescanStartHeight: syncStatus.nodeBlockHeight, + }) const form = useForm({ mode: 'all', defaultValues, @@ -140,6 +159,7 @@ export function WalletAddressesGenerateLedgerDialog({ const formIndex = form.watch('index') const formCount = form.watch('count') + const shouldRescan = form.watch('shouldRescan') const fields = getFields() @@ -243,15 +263,16 @@ export function WalletAddressesGenerateLedgerDialog({ const saveAddresses = useCallback(async () => { const count = newGeneratedAddresses.length - function toastBatchError(count: number, i: number) { - if (count === 1) { - triggerErrorToast({ title: 'Error saving address' }) - } else { - triggerErrorToast({ - title: 'Error saving addresses', - body: i > 0 ? 'Not all addresses were saved.' : '', - }) - } + function toastBatchError(count: number, i: number, body: string) { + triggerErrorToast({ + title: 'Error generating addresses', + body: + i > 0 + ? `${ + i + 1 + }/${count} addresses were generated and saved. Batch failed on with: ${body}` + : body, + }) } for (const [ i, @@ -259,7 +280,7 @@ export function WalletAddressesGenerateLedgerDialog({ ] of newGeneratedAddresses.entries()) { const uc = getSDK().wallet.standardUnlockConditions(publicKey) if (uc.error) { - toastBatchError(count, i) + toastBatchError(count, i, uc.error) return } const metadata: WalletAddressMetadata = { @@ -278,7 +299,7 @@ export function WalletAddressesGenerateLedgerDialog({ }, }) if (response.error) { - toastBatchError(count, i) + toastBatchError(count, i, response.error) return } } @@ -299,17 +320,17 @@ export function WalletAddressesGenerateLedgerDialog({ defaultValues, }) - const onSubmit = useCallback(async () => { - if (newGeneratedAddresses.length === 0) { - triggerErrorToast({ - title: 'Generate an address', - body: 'Add and generate addresses with your Ledger device to continue.', - }) - return - } - await saveAddresses() - closeAndReset() - }, [newGeneratedAddresses, saveAddresses, closeAndReset]) + const triggerRescan = useTriggerRescan() + const onSubmit = useCallback( + async (values: Values) => { + if (newGeneratedAddresses.length > 0) { + await saveAddresses() + } + await triggerRescan(values) + closeAndReset() + }, + [newGeneratedAddresses, saveAddresses, closeAndReset, triggerRescan] + ) return ( Close - {newGeneratedAddresses.length > 0 && ( - - Save {newGeneratedAddresses.length}{' '} - {newGeneratedAddresses.length === 1 ? 'address' : 'addresses'} + {(newGeneratedAddresses.length > 0 || shouldRescan) && ( + + {newGeneratedAddresses.length > 0 + ? `Save ${newGeneratedAddresses.length} ${ + newGeneratedAddresses.length === 1 ? 'address' : 'addresses' + }${shouldRescan ? ' and rescan' : ''}` + : 'Rescan'} )} @@ -372,6 +400,7 @@ export function WalletAddressesGenerateLedgerDialog({ )} + ) diff --git a/apps/walletd/dialogs/WalletAddressesGenerateSeedDialog/index.tsx b/apps/walletd/dialogs/WalletAddressesGenerateSeedDialog/index.tsx index 1960a7f0d..56182d081 100644 --- a/apps/walletd/dialogs/WalletAddressesGenerateSeedDialog/index.tsx +++ b/apps/walletd/dialogs/WalletAddressesGenerateSeedDialog/index.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/no-unescaped-entities */ import { ConfigFields, Dialog, @@ -20,6 +19,13 @@ import { getFieldMnemonic, MnemonicFieldType } from '../../lib/fieldMnemonic' import { FieldMnemonic } from '../FieldMnemonic' import { useWalletAddresses } from '../../hooks/useWalletAddresses' import { getSDK } from '@siafoundation/sdk' +import { + FieldRescan, + getRescanFields, + getDefaultRescanValues, + useTriggerRescan, +} from '../FieldRescan' +import { useSyncStatus } from '../../hooks/useSyncStatus' export type WalletAddressesGenerateSeedDialogParams = { walletId: string @@ -32,14 +38,23 @@ type Props = { onOpenChange: (val: boolean) => void } -function getDefaultValues(lastIndex: number) { +function getDefaultValues({ + nextIndex, + currentHeight, +}: { + nextIndex: number + currentHeight: number +}) { return { mnemonic: '', - index: new BigNumber(lastIndex), + index: new BigNumber(nextIndex), count: new BigNumber(1), + ...getDefaultRescanValues({ rescanStartHeight: currentHeight }), } } +type Values = ReturnType + function getFields({ mnemonicHash, mnemonicFieldType, @@ -48,7 +63,7 @@ function getFields({ mnemonicHash?: string mnemonicFieldType: MnemonicFieldType setMnemonicFieldType: (type: MnemonicFieldType) => void -}): ConfigFields, never> { +}): ConfigFields { return { mnemonic: getFieldMnemonic({ mnemonicHash, @@ -74,6 +89,7 @@ function getFields({ max: 1000, }, }, + ...getRescanFields(), } } @@ -88,7 +104,11 @@ export function WalletAddressesGenerateSeedDialog({ const { dataset, cacheWalletMnemonic } = useWallets() const wallet = dataset?.find((w) => w.id === walletId) const nextIndex = lastIndex + 1 - const defaultValues = getDefaultValues(nextIndex) + const syncStatus = useSyncStatus() + const defaultValues = getDefaultValues({ + nextIndex, + currentHeight: syncStatus.nodeBlockHeight, + }) const [mnemonicFieldType, setMnemonicFieldType] = useState('password') const form = useForm({ @@ -113,6 +133,7 @@ export function WalletAddressesGenerateSeedDialog({ const mnemonic = form.watch('mnemonic') const index = form.watch('index') const count = form.watch('count') + const shouldRescan = form.watch('shouldRescan') const fields = getFields({ mnemonicHash: wallet?.metadata.mnemonicHash, @@ -123,29 +144,31 @@ export function WalletAddressesGenerateSeedDialog({ const addressAdd = useWalletAddressAdd() const generateAddresses = useCallback( async (mnemonic: string, index: number, count: number) => { + function toastBatchError(count: number, i: number, body: string) { + triggerErrorToast({ + title: 'Error generating addresses', + body: + i > 0 + ? `${ + i + 1 + }/${count} addresses were generated and saved. Batch failed on with: ${body}` + : body, + }) + } for (let i = index; i < index + count; i++) { const kp = getSDK().wallet.keyPairFromSeedPhrase(mnemonic, i) if (kp.error) { - triggerErrorToast({ - title: 'Error generating addresses', - body: kp.error, - }) + toastBatchError(count, i, kp.error) return } const suh = getSDK().wallet.standardUnlockHash(kp.publicKey) if (suh.error) { - triggerErrorToast({ - title: 'Error generating unlock hash', - body: suh.error, - }) + toastBatchError(count, i, suh.error) return } const uc = getSDK().wallet.standardUnlockConditions(kp.publicKey) if (uc.error) { - triggerErrorToast({ - title: 'Error generating unlock conditions', - body: uc.error, - }) + toastBatchError(count, i, uc.error) return } const metadata: WalletAddressMetadata = { @@ -164,17 +187,7 @@ export function WalletAddressesGenerateSeedDialog({ }, }) if (response.error) { - if (count === 1) { - triggerErrorToast({ - title: 'Error saving address', - body: response.error, - }) - } else { - triggerErrorToast({ - title: 'Error saving addresses', - body: i > 0 ? 'Not all addresses were saved.' : '', - }) - } + toastBatchError(count, i, response.error) return } } @@ -194,13 +207,18 @@ export function WalletAddressesGenerateSeedDialog({ [closeAndReset, addressAdd, walletId, cacheWalletMnemonic] ) - const onSubmit = useCallback(() => { - return generateAddresses( - wallet.state.mnemonic || mnemonic, - index.toNumber(), - count.toNumber() - ) - }, [generateAddresses, mnemonic, index, count, wallet]) + const triggerRescan = useTriggerRescan() + const onSubmit = useCallback( + async (values: Values) => { + await generateAddresses( + wallet.state.mnemonic || mnemonic, + index.toNumber(), + count.toNumber() + ) + triggerRescan(values) + }, + [generateAddresses, mnemonic, index, count, wallet, triggerRescan] + ) return ( - - Continue + + Generate addresses + {shouldRescan ? ' and rescan' : ''} } @@ -235,6 +258,7 @@ export function WalletAddressesGenerateSeedDialog({ + ) } diff --git a/apps/walletd/dialogs/WalletUpdateDialog/index.tsx b/apps/walletd/dialogs/WalletUpdateDialog/index.tsx index 41f8cfdf2..65749f52b 100644 --- a/apps/walletd/dialogs/WalletUpdateDialog/index.tsx +++ b/apps/walletd/dialogs/WalletUpdateDialog/index.tsx @@ -9,16 +9,26 @@ import { Label, useDialogFormHelpers, } from '@siafoundation/design-system' -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { useForm } from 'react-hook-form' import { useWalletUpdate } from '@siafoundation/react-walletd' import { useWallets } from '../../contexts/wallets' +import { WalletData } from '../../contexts/wallets/types' const defaultValues = { name: '', description: '', } +function getDefaultValues(wallet: WalletData) { + return wallet + ? { + name: wallet.name, + description: wallet.description, + } + : defaultValues +} + type Values = typeof defaultValues function getFields({ @@ -72,26 +82,16 @@ export function WalletUpdateDialog({ const { dataset } = useWallets() const wallet = dataset?.find((d) => d.id === walletId) const walletUpdate = useWalletUpdate() + const defaultValues = getDefaultValues(wallet) const form = useForm({ mode: 'all', defaultValues, }) - const { closeAndReset } = useDialogFormHelpers({ + const { handleOpenChange, closeAndReset } = useDialogFormHelpers({ form, onOpenChange, defaultValues, }) - useEffect(() => { - form.reset( - wallet - ? { - name: wallet.name, - description: wallet.description, - } - : defaultValues - ) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletId]) const walletNames = useMemo( () => @@ -133,7 +133,7 @@ export function WalletUpdateDialog({ title={`${wallet?.name}`} trigger={trigger} open={open} - onOpenChange={onOpenChange} + onOpenChange={handleOpenChange} contentVariants={{ className: 'w-[400px]', }} diff --git a/apps/walletd/dialogs/WalletsRescanDialog.tsx b/apps/walletd/dialogs/WalletsRescanDialog.tsx new file mode 100644 index 000000000..279decbcf --- /dev/null +++ b/apps/walletd/dialogs/WalletsRescanDialog.tsx @@ -0,0 +1,108 @@ +import { + ConfigFields, + Dialog, + FieldNumber, + FormSubmitButton, + Paragraph, + useDialogFormHelpers, +} from '@siafoundation/design-system' +import { useCallback } from 'react' +import { useForm } from 'react-hook-form' +import { + useTriggerRescan, + getRescanFields, + getDefaultRescanValues, + RescanCalloutWarningExpensive, + RescanCalloutWarningStartHeight, +} from './FieldRescan' +import { useSyncStatus } from '../hooks/useSyncStatus' + +export type WalletsRescanDialogParams = void + +type Props = { + params?: WalletsRescanDialogParams + trigger?: React.ReactNode + open: boolean + onOpenChange: (val: boolean) => void +} + +function getDefaultValues({ + rescanStartHeight, +}: { + rescanStartHeight: number +}) { + return { + ...getDefaultRescanValues({ rescanStartHeight }), + shouldRescan: true, + } +} + +type Values = ReturnType + +function getFields(): ConfigFields { + return { + ...getRescanFields(), + } +} + +export function WalletsRescanDialog({ trigger, open, onOpenChange }: Props) { + const syncStatus = useSyncStatus() + const defaultValues = getDefaultValues({ + rescanStartHeight: syncStatus.nodeBlockHeight, + }) + const form = useForm({ + mode: 'all', + defaultValues, + }) + + const { handleOpenChange, closeAndReset } = useDialogFormHelpers({ + form, + onOpenChange, + defaultValues, + }) + + const fields = getFields() + + const triggerRescan = useTriggerRescan() + const onSubmit = useCallback( + async (values: Values) => { + triggerRescan(values) + closeAndReset() + }, + [closeAndReset, triggerRescan] + ) + + return ( + + + Rescan + + + } + > +
+ + Rescan the blockchain from the specified start height to find any + missing transaction activity across all wallets. + +
+ +
+
+ + +
+
+
+ ) +} diff --git a/libs/design-system/src/core/Dialog.tsx b/libs/design-system/src/core/Dialog.tsx index 10ead375a..c9070500d 100644 --- a/libs/design-system/src/core/Dialog.tsx +++ b/libs/design-system/src/core/Dialog.tsx @@ -70,6 +70,14 @@ export const Dialog = React.forwardRef< onOpenChange: _onOpenChange, }) + // The dialog itself only triggers on internal open state change + useEffect(() => { + if (open) { + onOpenChange(open) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + return ( + {el} ) diff --git a/libs/design-system/src/form/useDialogFormHelpers.ts b/libs/design-system/src/form/useDialogFormHelpers.ts index ee105d483..256dbaeeb 100644 --- a/libs/design-system/src/form/useDialogFormHelpers.ts +++ b/libs/design-system/src/form/useDialogFormHelpers.ts @@ -25,9 +25,16 @@ export function useDialogFormHelpers({ (open: boolean) => { if (!open) { closeAndReset() + } else { + onOpenChange(true) + // Does not update the field valuse without setTimeout, + // likely because the actual fields are not mounted yet + setTimeout(() => { + form.reset(defaultValues) + }, 0) } }, - [closeAndReset] + [form, closeAndReset, defaultValues, onOpenChange] ) return { reset, diff --git a/libs/react-walletd/src/api.ts b/libs/react-walletd/src/api.ts index 55c915901..2c1886685 100644 --- a/libs/react-walletd/src/api.ts +++ b/libs/react-walletd/src/api.ts @@ -162,14 +162,38 @@ export function useTxPoolBroadcast( ) } -// subscribe +// rescan -export function useResubscribe( +export function useRescanStart( args?: HookArgsCallback ) { - return usePostFunc({ + return usePostFunc( + { + ...args, + route: '/rescan', + }, + async (mutate) => { + // Do not block the hook method from returning and allowing consumer to toast success etc + const func = async () => { + await delay(1_000) + await mutate((key) => key.startsWith('/rescan')) + } + func() + } + ) +} + +export type RescanResponse = { + startIndex: ChainIndex + index: ChainIndex + startTime: string + error?: string +} + +export function useRescanStatus(args?: HookArgsSwr) { + return useGetSwr({ ...args, - route: '/resubscribe', + route: '/rescan', }) }