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/loud-bottles-help.md b/.changeset/loud-bottles-help.md new file mode 100644 index 000000000..a8afc1505 --- /dev/null +++ b/.changeset/loud-bottles-help.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Added async defaultValues support to useDialogFormHelpers via initKey prop. 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-e2e/src/fixtures/createWallet.ts b/apps/walletd-e2e/src/fixtures/createWallet.ts index 4cce4c82e..2c33c143f 100644 --- a/apps/walletd-e2e/src/fixtures/createWallet.ts +++ b/apps/walletd-e2e/src/fixtures/createWallet.ts @@ -13,6 +13,7 @@ export async function createWallet({ mnemonic, newWallet, responses = {}, + expects = {}, }: { page: Page mnemonic: string @@ -23,12 +24,16 @@ export async function createWallet({ fund?: WalletFundResponse addresses?: WalletAddressesResponse } + expects?: { + fundSiacoinPost?: (data: string | null) => void + } }) { const wallets = await mockApiWallets({ page, createWallet: newWallet }) await mockApiWallet({ page, wallet: newWallet, responses, + expects, }) await expect(page.getByRole('button', { name: 'Add wallet' })).toBeVisible() @@ -45,5 +50,5 @@ export async function createWallet({ page.getByText(`Wallet ${newWallet.name.slice(0, 5)}`) ).toBeVisible() await page.locator('input[name=mnemonic]').fill(mnemonic) - await page.getByRole('button', { name: 'Continue' }).click() + await page.getByRole('button', { name: 'Generate addresses' }).click() } diff --git a/apps/walletd-e2e/src/specs/seedGenerateAddresses.spec.ts b/apps/walletd-e2e/src/specs/seedGenerateAddresses.spec.ts index 30c8e8bdc..b72f8ae7e 100644 --- a/apps/walletd-e2e/src/specs/seedGenerateAddresses.spec.ts +++ b/apps/walletd-e2e/src/specs/seedGenerateAddresses.spec.ts @@ -5,6 +5,7 @@ import { navigateToWallet } from '../fixtures/navigateToWallet' import { getMockScenarioSeedWallet, mockApiDefaults, + getMockRescanResponse, } from '@siafoundation/mock-walletd' import { WalletAddressesResponse } from '@siafoundation/react-walletd' @@ -19,7 +20,53 @@ function getDefaultMockWalletResponses() { } test('generate new addresses', async ({ page }) => { - await mockApiDefaults({ page }) + const rescanResponse = getMockRescanResponse() + await mockApiDefaults({ + page, + responses: { + rescan: rescanResponse, + }, + }) + await login({ page }) + + const mocks = getMockScenarioSeedWallet() + const { newWallet, mnemonic } = mocks + await createWallet({ + page, + newWallet, + mnemonic, + responses: getDefaultMockWalletResponses(), + }) + await navigateToWallet({ page, wallet: newWallet }) + await page.getByLabel('view addresses').click() + await page.getByRole('button', { name: 'Add addresses' }).click() + await page.locator('input[name=count]').fill('5') + await page.getByRole('button', { name: 'Generate addresses' }).click() + await expect( + page.getByText('65b40f6a720352ad5b9546b9f5077209672914cc...') + ).toBeVisible() + await expect( + page.getByText('e94e8113563a549f95ff3904dccf77f1b8fbaad4...') + ).toBeVisible() + await expect( + page.getByText('cc7241334772c6d10d47882b06b21a60242a19c3...') + ).toBeVisible() + await expect( + page.getByText('170173c40ca0f39f9618da30af14c390c7ce7024...') + ).toBeVisible() + await expect( + page.getByText('90c6057cdd2463eca61f83796e83152dbba28b6c...') + ).toBeVisible() +}) + +test('generate new addresses and rescan', async ({ page }) => { + const rescanResponse = getMockRescanResponse() + await mockApiDefaults({ + page, + responses: { + rescan: rescanResponse, + }, + }) await login({ page }) const mocks = getMockScenarioSeedWallet() @@ -34,7 +81,15 @@ test('generate new addresses', async ({ page }) => { await page.getByLabel('view addresses').click() await page.getByRole('button', { name: 'Add addresses' }).click() await page.locator('input[name=count]').fill('5') - await page.getByRole('button', { name: 'Continue' }).click() + await page.getByLabel('shouldRescan').click() + await expect(page.locator('input[name=rescanStartHeight]')).toHaveValue( + '61,676' + ) + rescanResponse.index.height = 30_000 + await page + .getByRole('button', { name: 'Generate addresses and rescan' }) + .click() + await expect(page.getByText('Rescanning the blockchain')).toBeVisible() await expect( page.getByText('65b40f6a720352ad5b9546b9f5077209672914cc...') ).toBeVisible() @@ -50,4 +105,5 @@ test('generate new addresses', async ({ page }) => { await expect( page.getByText('90c6057cdd2463eca61f83796e83152dbba28b6c...') ).toBeVisible() + await expect(page.getByText('Scanning...')).toBeVisible() }) diff --git a/apps/walletd-e2e/src/specs/seedSendSiacoin.spec.ts b/apps/walletd-e2e/src/specs/seedSendSiacoin.spec.ts index 2d1245cdf..c76368933 100644 --- a/apps/walletd-e2e/src/specs/seedSendSiacoin.spec.ts +++ b/apps/walletd-e2e/src/specs/seedSendSiacoin.spec.ts @@ -28,6 +28,24 @@ test('send siacoin with a seed wallet', async ({ page }) => { newWallet, mnemonic, responses: getDefaultMockWalletResponses(mocks), + expects: { + fundSiacoinPost: (data) => + expect(data).toEqual( + JSON.stringify({ + amount: '1003930000000000000000000', + changeAddress: mocks.changeAddress, + transaction: { + minerFees: ['3930000000000000000000'], + siacoinOutputs: [ + { + value: '1000000000000000000000000', + address: mocks.receiveAddress, + }, + ], + }, + }) + ), + }, }) await navigateToWallet({ page, wallet: newWallet }) await page.getByLabel('send').click() @@ -71,6 +89,24 @@ test('errors if the input to sign is not found on the transaction', async ({ ...getDefaultMockWalletResponses(mocks), fund: mockFundInvalid, }, + expects: { + fundSiacoinPost: (data) => + expect(data).toEqual( + JSON.stringify({ + amount: '1003930000000000000000000', + changeAddress: mocks.changeAddress, + transaction: { + minerFees: ['3930000000000000000000'], + siacoinOutputs: [ + { + value: '1000000000000000000000000', + address: mocks.receiveAddress, + }, + ], + }, + }) + ), + }, }) await navigateToWallet({ page, wallet: newWallet }) await page.getByLabel('send').click() @@ -107,6 +143,24 @@ test('errors if the inputs matching utxo is not found', async ({ page }) => { ...getDefaultMockWalletResponses(mocks), outputsSiacoin: mockOutputsInvalid, }, + expects: { + fundSiacoinPost: (data) => + expect(data).toEqual( + JSON.stringify({ + amount: '1003930000000000000000000', + changeAddress: mocks.changeAddress, + transaction: { + minerFees: ['3930000000000000000000'], + siacoinOutputs: [ + { + value: '1000000000000000000000000', + address: mocks.receiveAddress, + }, + ], + }, + }) + ), + }, }) await navigateToWallet({ page, wallet: newWallet }) await page.getByLabel('send').click() @@ -144,6 +198,24 @@ test('errors if the address is missing its index', async ({ page }) => { ...getDefaultMockWalletResponses(mocks), addresses: mockAddressesInvalid, }, + expects: { + fundSiacoinPost: (data) => + expect(data).toEqual( + JSON.stringify({ + amount: '1003930000000000000000000', + changeAddress: mocks.changeAddress, + transaction: { + minerFees: ['3930000000000000000000'], + siacoinOutputs: [ + { + value: '1000000000000000000000000', + address: mocks.receiveAddress, + }, + ], + }, + }) + ), + }, }) await navigateToWallet({ page, wallet: newWallet }) await page.getByLabel('send').click() @@ -181,6 +253,24 @@ test('errors if the address is missing its public key', async ({ page }) => { ...getDefaultMockWalletResponses(mocks), addresses: mockAddressesInvalid, }, + expects: { + fundSiacoinPost: (data) => + expect(data).toEqual( + JSON.stringify({ + amount: '1003930000000000000000000', + changeAddress: mocks.changeAddress, + transaction: { + minerFees: ['3930000000000000000000'], + siacoinOutputs: [ + { + value: '1000000000000000000000000', + address: mocks.receiveAddress, + }, + ], + }, + }) + ), + }, }) await navigateToWallet({ page, wallet: newWallet }) await page.getByLabel('send').click() diff --git a/apps/walletd/components/RescanStatus.tsx b/apps/walletd/components/RescanStatus.tsx new file mode 100644 index 000000000..f84ecf5e7 --- /dev/null +++ b/apps/walletd/components/RescanStatus.tsx @@ -0,0 +1,77 @@ +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 + const showAsRescanning = syncStatus.isSynced && isScanning + + if (!showAsRescanning) { + 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/AddressUpdateDialog/index.tsx b/apps/walletd/dialogs/AddressUpdateDialog/index.tsx index 23ee47069..2258a86ba 100644 --- a/apps/walletd/dialogs/AddressUpdateDialog/index.tsx +++ b/apps/walletd/dialogs/AddressUpdateDialog/index.tsx @@ -7,18 +7,21 @@ import { truncate, WalletAddressCode, Button, + useDialogFormHelpers, } from '@siafoundation/design-system' -import { useCallback, useEffect } from 'react' +import { useCallback } from 'react' import { useForm } from 'react-hook-form' import { useWalletAddressAdd } from '@siafoundation/react-walletd' import { useDialog } from '../../contexts/dialog' import { useWalletAddresses } from '../../hooks/useWalletAddresses' -const defaultValues = { - description: '', +function getDefaultValues({ description }: { description?: string }) { + return { + description: description || '', + } } -type Values = typeof defaultValues +type Values = ReturnType function getFields(): ConfigFields { return { @@ -52,24 +55,27 @@ export function AddressUpdateDialog({ params, }: Props) { const { walletId, address: addr } = params || {} - const { openDialog, closeDialog } = useDialog() - const { dataset } = useWalletAddresses({ id: walletId }) + const { openDialog } = useDialog() + const { dataset, dataState } = useWalletAddresses({ id: walletId }) const address = dataset?.find((d) => d.id === addr) const addressAdd = useWalletAddressAdd() + const defaultValues = getDefaultValues({ + description: address?.description, + }) const form = useForm({ mode: 'all', defaultValues, }) - useEffect(() => { - form.reset( - address - ? { - description: address.description, - } - : defaultValues - ) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [params]) + + const { handleOpenChange, closeAndReset } = useDialogFormHelpers({ + form, + onOpenChange, + defaultValues, + // Resets form with latest default values after elements change and are + // all thruthy + // This is used because address data is async and can be intially undefined + initKey: [params, dataState === undefined], + }) const fields = getFields() const onSubmit = useCallback( @@ -90,11 +96,10 @@ export function AddressUpdateDialog({ body: response.error, }) } else { - closeDialog() - form.reset(defaultValues) + closeAndReset() } }, - [walletId, addr, form, addressAdd, address, closeDialog] + [walletId, addr, addressAdd, address, closeAndReset] ) return ( @@ -102,7 +107,7 @@ export function AddressUpdateDialog({ title={truncate(addr, 20)} trigger={trigger} open={open} - onOpenChange={onOpenChange} + onOpenChange={handleOpenChange} contentVariants={{ className: 'w-[400px]', }} 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..7404881d2 100644 --- a/libs/design-system/src/form/useDialogFormHelpers.ts +++ b/libs/design-system/src/form/useDialogFormHelpers.ts @@ -1,16 +1,19 @@ -import { useCallback } from 'react' +import { useCallback, useEffect, useState } from 'react' import { FieldValues, UseFormReturn } from 'react-hook-form' type Props = { form: UseFormReturn onOpenChange: (open: boolean) => void defaultValues: Values + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initKey?: any[] } export function useDialogFormHelpers({ form, onOpenChange, defaultValues, + initKey, }: Props) { const reset = useCallback(() => { form.reset(defaultValues) @@ -25,13 +28,68 @@ 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(() => { + reset() + }, 0) } }, - [closeAndReset] + [closeAndReset, reset, onOpenChange] ) + + // Use an internal key that is memoed on a shallow element comparison + // in case the passed prop is not memoed and is a new array each time + const internalInitKey = useArrayElementShallowCompare(initKey || []) + + // The init key allows the caller to trigger a delayed form reset + // once values are ready, determined by passing an array with: + // - a deep change of values + // - all truthy values + useEffect(() => { + if (!internalInitKey || !internalInitKey.length) { + return + } + + // If the key has changed and all parts of key are thruthy, + // we should reset the form + if (internalInitKey.every((k) => !!k)) { + reset() + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [internalInitKey]) + return { reset, closeAndReset, handleOpenChange, } } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function useArrayElementShallowCompare(externalVal: T): T { + const [internalVal, setInternalVal] = useState(externalVal) + + useEffect(() => { + // if externalVal is empty on mount, do nothing + if (!externalVal || !externalVal.length) { + return + } + let didChange = false + for (let i = 0; i < externalVal.length; i++) { + if (externalVal[i] !== internalVal[i]) { + didChange = true + break + } + } + if (didChange) { + setInternalVal(externalVal) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [externalVal]) + + return internalVal +} diff --git a/libs/mock-walletd/package.json b/libs/mock-walletd/package.json index 1c8bd8df3..87478b46c 100644 --- a/libs/mock-walletd/package.json +++ b/libs/mock-walletd/package.json @@ -7,8 +7,8 @@ "@siafoundation/types": "0.2.0", "@siafoundation/react-walletd": "4.0.0", "@siafoundation/units": "3.0.0", - "playwright": "^1.42.1", - "@siafoundation/mock-sia-central": "0.0.0" + "@siafoundation/mock-sia-central": "0.0.0", + "playwright": "^1.42.1" }, "types": "./src/index.d.ts" } diff --git a/libs/mock-walletd/src/index.ts b/libs/mock-walletd/src/index.ts index b52ef7174..b1d000661 100644 --- a/libs/mock-walletd/src/index.ts +++ b/libs/mock-walletd/src/index.ts @@ -4,11 +4,12 @@ export * from './mocks/consensusTipState' export * from './mocks/defaults' export * from './mocks/peers' export * from './mocks/txPoolBroadcast' +export * from './mocks/rescan' export * from './mocks/wallet' export * from './mocks/walletAddresses' export * from './mocks/walletBalance' export * from './mocks/walletEvents' -export * from './mocks/walletFund' +export * from './mocks/walletFundSiacoin' export * from './mocks/walletOutputsSiacoin' export * from './mocks/walletOutputsSiafund' export * from './mocks/walletRelease' diff --git a/libs/mock-walletd/src/mocks/consensusTip.ts b/libs/mock-walletd/src/mocks/consensusTip.ts index b829d9f17..3af9d06db 100644 --- a/libs/mock-walletd/src/mocks/consensusTip.ts +++ b/libs/mock-walletd/src/mocks/consensusTip.ts @@ -8,9 +8,9 @@ export function getMockConsensusTipResponse(): ConsensusTipResponse { } } -export async function mockApiConsensusTipState({ page }: { page: Page }) { +export async function mockApiConsensusTip({ page }: { page: Page }) { const json = getMockConsensusTipResponse() - await page.route('**/api/consensus/tipstate*', async (route) => { + await page.route('**/api/consensus/tip*', async (route) => { await route.fulfill({ json }) }) return json diff --git a/libs/mock-walletd/src/mocks/consensusTipState.ts b/libs/mock-walletd/src/mocks/consensusTipState.ts index 414267a64..d1d817d00 100644 --- a/libs/mock-walletd/src/mocks/consensusTipState.ts +++ b/libs/mock-walletd/src/mocks/consensusTipState.ts @@ -8,6 +8,8 @@ export function getMockConsensusTipStateResponse(): ConsensusState { id: 'bid:00000010d5da9002b9640d920d9eb9f7502c5c3b2a796ecf800a103920bea96f', }, prevTimestamps: [ + // This timestamp being recent is used to represent a "synced" state + new Date().toISOString(), '2024-03-25T17:33:24-04:00', '2024-03-25T17:18:32-04:00', '2024-03-25T17:07:59-04:00', @@ -56,9 +58,9 @@ export function getMockConsensusTipStateResponse(): ConsensusState { } } -export async function mockApiConsensusTip({ page }: { page: Page }) { +export async function mockApiConsensusTipState({ page }: { page: Page }) { const json = getMockConsensusTipStateResponse() - await page.route('**/api/consensus/tip*', async (route) => { + await page.route('**/api/consensus/tipstate*', async (route) => { await route.fulfill({ json }) }) return json diff --git a/libs/mock-walletd/src/mocks/defaults.ts b/libs/mock-walletd/src/mocks/defaults.ts index 62d49f9aa..3b61ef70c 100644 --- a/libs/mock-walletd/src/mocks/defaults.ts +++ b/libs/mock-walletd/src/mocks/defaults.ts @@ -1,20 +1,33 @@ import { Page } from 'playwright' import { mockApiSiaCentralExchangeRates } from '@siafoundation/mock-sia-central' import { mockApiSyncerPeers } from './peers' -import { mockApiConsensusTip } from './consensusTipState' -import { mockApiConsensusTipState } from './consensusTip' +import { mockApiConsensusTip } from './consensusTip' +import { mockApiConsensusTipState } from './consensusTipState' import { mockApiConsensusNetwork } from './consensusNetwork' import { mockApiWallets } from './wallets' import { mockApiTxPoolBroadcast } from './txPoolBroadcast' import { mockApiWallet } from './wallet' +import { mockApiRescan } from './rescan' +import { RescanResponse } from '@siafoundation/react-walletd' -export async function mockApiDefaults({ page }: { page: Page }) { +type Responses = { + rescan?: RescanResponse +} + +export async function mockApiDefaults({ + page, + responses, +}: { + page: Page + responses?: Responses +}) { await mockApiSiaCentralExchangeRates({ page }) await mockApiSyncerPeers({ page }) await mockApiConsensusTip({ page }) await mockApiConsensusTipState({ page }) await mockApiConsensusNetwork({ page }) await mockApiTxPoolBroadcast({ page }) + await mockApiRescan({ page, response: responses?.rescan }) const wallets = await mockApiWallets({ page }) for (const wallet of wallets) { await mockApiWallet({ page, wallet }) diff --git a/libs/mock-walletd/src/mocks/rescan.ts b/libs/mock-walletd/src/mocks/rescan.ts new file mode 100644 index 000000000..9965a0459 --- /dev/null +++ b/libs/mock-walletd/src/mocks/rescan.ts @@ -0,0 +1,35 @@ +import { RescanResponse } from '@siafoundation/react-walletd' +import { Page } from 'playwright' + +export function getMockRescanResponse(): RescanResponse { + return { + startIndex: { + height: 61676, + id: 'bid:00000010d5da9002b9640d920d9eb9f7502c5c3b2a796ecf800a103920bea96f', + }, + index: { + height: 61676, + id: 'bid:00000010d5da9002b9640d920d9eb9f7502c5c3b2a796ecf800a103920bea96f', + }, + startTime: new Date().toISOString(), + error: undefined, + } +} + +export async function mockApiRescan({ + page, + response, +}: { + page: Page + response?: RescanResponse +}) { + const json = response || getMockRescanResponse() + await page.route('**/api/rescan*', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill() + } else { + await route.fulfill({ json }) + } + }) + return json +} diff --git a/libs/mock-walletd/src/mocks/wallet.ts b/libs/mock-walletd/src/mocks/wallet.ts index d0105f884..c001853d3 100644 --- a/libs/mock-walletd/src/mocks/wallet.ts +++ b/libs/mock-walletd/src/mocks/wallet.ts @@ -12,13 +12,15 @@ import { mockApiWalletEvents } from './walletEvents' import { mockApiWalletTxPool } from './walletTxPool' import { mockApiWalletOutputsSiacoin } from './walletOutputsSiacoin' import { mockApiWalletOutputsSiafund } from './walletOutputsSiafund' -import { mockApiWalletFund } from './walletFund' +import { mockApiWalletFundSiacoin } from './walletFundSiacoin' import { mockApiWalletRelease } from './walletRelease' +import { mockApiWalletFundSiafund } from './walletFundSiafund' export async function mockApiWallet({ page, wallet, responses = {}, + expects = {}, }: { page: Page wallet: Wallet @@ -28,6 +30,9 @@ export async function mockApiWallet({ fund?: WalletFundResponse addresses?: WalletAddressesResponse } + expects?: { + fundSiacoinPost?: (data: string | null) => void + } }) { await mockApiWalletBalance({ page, @@ -47,10 +52,15 @@ export async function mockApiWallet({ response: responses.outputsSiacoin, }) await mockApiWalletOutputsSiafund({ page, walletId: wallet.id }) - await mockApiWalletFund({ + await mockApiWalletFundSiafund({ + page, + walletId: wallet.id, + }) + await mockApiWalletFundSiacoin({ page, walletId: wallet.id, response: responses.fund, + expectPost: expects.fundSiacoinPost, }) await mockApiWalletRelease({ page, diff --git a/libs/mock-walletd/src/mocks/walletFund.ts b/libs/mock-walletd/src/mocks/walletFundSiacoin.ts similarity index 83% rename from libs/mock-walletd/src/mocks/walletFund.ts rename to libs/mock-walletd/src/mocks/walletFundSiacoin.ts index 8117eeed5..acdd61e4c 100644 --- a/libs/mock-walletd/src/mocks/walletFund.ts +++ b/libs/mock-walletd/src/mocks/walletFundSiacoin.ts @@ -1,7 +1,7 @@ import { WalletFundResponse } from '@siafoundation/react-walletd' import { Page } from 'playwright' -export function getMockWalletFundResponse(): WalletFundResponse { +export function getMockWalletFundSiacoinResponse(): WalletFundResponse { return { transaction: { siacoinInputs: [ @@ -46,17 +46,22 @@ export function getMockWalletFundResponse(): WalletFundResponse { } } -export async function mockApiWalletFund({ +export async function mockApiWalletFundSiacoin({ page, walletId, response, + expectPost, }: { page: Page walletId: string response?: WalletFundResponse + expectPost?: (data: string | null) => void }) { - const json = response || getMockWalletFundResponse() + const json = response || getMockWalletFundSiacoinResponse() await page.route(`**/api/wallets/${walletId}/fund*`, async (route) => { + if (expectPost) { + expectPost(route.request().postData()) + } await route.fulfill({ json }) }) return json diff --git a/libs/mock-walletd/src/mocks/walletFundSiafund.ts b/libs/mock-walletd/src/mocks/walletFundSiafund.ts new file mode 100644 index 000000000..42c0c7866 --- /dev/null +++ b/libs/mock-walletd/src/mocks/walletFundSiafund.ts @@ -0,0 +1,33 @@ +import { WalletFundResponse } from '@siafoundation/react-walletd' +import { Page } from 'playwright' + +export function getMockWalletFundSiafundResponse(): WalletFundResponse { + return { + transaction: { + minerFees: ['3930000000000000000000'], + }, + toSign: [], + dependsOn: null, + } +} + +export async function mockApiWalletFundSiafund({ + page, + walletId, + response, + expectPost, +}: { + page: Page + walletId: string + response?: WalletFundResponse + expectPost?: (data: string | null) => void +}) { + const json = response || getMockWalletFundSiafundResponse() + await page.route(`**/api/wallets/${walletId}/fundsf*`, async (route) => { + if (expectPost) { + expectPost(route.request().postData()) + } + await route.fulfill({ json }) + }) + return json +} 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', }) }