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 (
+
+
+
+ Advanced
+
+
+ Rescan
+
+
+ }
+ />
+ {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',
})
}