+
{t('create_wallet.confirm_backup_title')}
{t('create_wallet.confirm_backup_subtitle')}
-
- {[...new Array(seedphrase.length)].map((_, outerIndex) => {
- if (outerIndex % 2 !== 0) return null
-
- const seedWords = seedphrase.slice(outerIndex, outerIndex + 2)
-
- return (
-
- {seedWords.map((seedWord, innerIndex) => {
- const wordIndex = outerIndex + innerIndex
- return (
-
- {
- setSeedWordConfirmations(
- seedWordConfirmations.map((confirmation, index) =>
- index === wordIndex ? isValid : confirmation
- )
- )
- }}
- />
-
- )
- })}
-
- )
- })}
-
+ setGivenWords(val)}
+ isValid={(index) => givenWords[index] === seedphrase[index]}
+ isDisabled={(index) => givenWords[index] === seedphrase[index]}
+ />
{isSeedBackupConfirmed && (
{t('create_wallet.feedback_seed_confirmed')}
)}
-
walletConfirmed()}
- disabled={!isSeedBackupConfirmed}
- >
+ onSuccess()} disabled={!isSeedBackupConfirmed}>
{t('create_wallet.confirmation_button_fund_wallet')}
@@ -254,21 +54,28 @@ const BackupConfirmation = ({ createdWallet, walletConfirmed, parentStepSetter }
variant="outline-dark"
disabled={isSeedBackupConfirmed}
className={styles.button}
- onClick={() => {
- parentStepSetter()
- }}
+ onClick={() => onCancel()}
>
- {t('create_wallet.back_button')}
+
+
+ {t('create_wallet.back_button')}
+
{showSkipButton && (
walletConfirmed()}
+ className={`${styles.button} position-relative`}
+ onClick={() => onSuccess()}
disabled={isSeedBackupConfirmed}
>
- {t('create_wallet.skip_button')}
+
+ {t('create_wallet.skip_button')}
+
+
+
+ dev
+
)}
@@ -276,76 +83,7 @@ const BackupConfirmation = ({ createdWallet, walletConfirmed, parentStepSetter }
)
}
-const WalletCreationConfirmation = ({ createdWallet, walletConfirmed }) => {
- const { t } = useTranslation()
- const [userConfirmed, setUserConfirmed] = useState(false)
- const [revealSensitiveInfo, setRevealSensitiveInfo] = useState(false)
- const [sensitiveInfoWasRevealed, setSensitiveInfoWasRevealed] = useState(false)
- const [step, setStep] = useState(0)
-
- function childStepSetter() {
- setRevealSensitiveInfo(false)
- setSensitiveInfoWasRevealed(false)
- setUserConfirmed(false)
- setStep(0)
- }
-
- return (
- <>
-
-
-
{t('create_wallet.confirmation_label_wallet_name')}
-
{walletDisplayName(createdWallet.name)}
-
-
-
-
-
-
{t('create_wallet.confirmation_label_password')}
-
- {!revealSensitiveInfo ? 'randomrandom' : createdWallet.password}
-
-
-
- {
- setRevealSensitiveInfo(isToggled)
- setSensitiveInfoWasRevealed(true)
- }}
- />
-
-
- setUserConfirmed(isToggled)}
- />
-
-
setStep(1)}
- >
- {t('create_wallet.next_button')}
-
-
- ) : (
-
@@ -394,9 +140,7 @@ export default function CreateWallet({ startWallet }) {
)}
{alert &&
{alert.message}}
- {canCreate &&
}
- {isCreated &&
}
- {!canCreate && !isCreated && (
+ {!canCreate && !isCreated ? (
Currently {{ walletName: walletDisplayName(serviceInfo?.walletName) }} is active. You need
@@ -407,6 +151,36 @@ export default function CreateWallet({ startWallet }) {
.
+ ) : (
+ <>
+
+ {canCreate && (
+
navigate(routes[parentRoute])}
+ onSubmit={createWallet}
+ submitButtonText={(isSubmitting) =>
+ t(isSubmitting ? 'create_wallet.button_creating' : 'create_wallet.button_create')
+ }
+ />
+ )}
+ {isCreated && (
+ <>
+ {!showBackupConfirmation ? (
+ t('create_wallet.next_button')}
+ onSubmit={() => setShowBackupConfirmation(true)}
+ />
+ ) : (
+ setShowBackupConfirmation(false)}
+ />
+ )}
+ >
+ )}
+ >
)}
)
diff --git a/src/components/CreateWallet.module.css b/src/components/CreateWallet.module.css
index 63b12cf1b..36257fcbb 100644
--- a/src/components/CreateWallet.module.css
+++ b/src/components/CreateWallet.module.css
@@ -8,7 +8,7 @@
width: 100%;
}
-.seedword-index-backup {
+.seedwordIndexBackup {
width: 5ch;
justify-content: right;
}
diff --git a/src/components/CreateWallet.test.jsx b/src/components/CreateWallet.test.jsx
index e2bab9f07..cd1ef526d 100644
--- a/src/components/CreateWallet.test.jsx
+++ b/src/components/CreateWallet.test.jsx
@@ -5,6 +5,7 @@ import { act } from 'react-dom/test-utils'
import { __testSetDebugFeatureEnabled } from '../constants/debugFeatures'
import * as apiMock from '../libs/JmWalletApi'
+import { DUMMY_MNEMONIC_PHRASE } from '../utils'
import CreateWallet from './CreateWallet'
@@ -92,7 +93,7 @@ describe('
{(isWaitingMakerStart || isWaitingMakerStop) && (
diff --git a/src/components/ImportWallet.tsx b/src/components/ImportWallet.tsx
new file mode 100644
index 000000000..916a0707f
--- /dev/null
+++ b/src/components/ImportWallet.tsx
@@ -0,0 +1,575 @@
+import { useState, useMemo, useCallback } from 'react'
+import * as rb from 'react-bootstrap'
+import { Formik, FormikErrors } from 'formik'
+import { Link, useNavigate } from 'react-router-dom'
+import { Trans, useTranslation } from 'react-i18next'
+import classNames from 'classnames'
+import * as Api from '../libs/JmWalletApi'
+import { useServiceInfo, useDispatchServiceInfo } from '../context/ServiceInfoContext'
+import { useRefreshConfigValues, useUpdateConfigValues } from '../context/ServiceConfigContext'
+import PageTitle from './PageTitle'
+import Sprite from './Sprite'
+import Accordion from './Accordion'
+import WalletCreationForm, { CreateWalletFormValues } from './WalletCreationForm'
+import MnemonicPhraseInput from './MnemonicPhraseInput'
+import PreventLeavingPageByMistake from './PreventLeavingPageByMistake'
+import { WalletInfo, WalletInfoSummary } from './WalletCreationConfirmation'
+import { isDevMode, isDebugFeatureEnabled } from '../constants/debugFeatures'
+import { routes, Route } from '../constants/routes'
+import { SEGWIT_ACTIVATION_BLOCK, DUMMY_MNEMONIC_PHRASE, JM_WALLET_FILE_EXTENSION, walletDisplayName } from '../utils'
+import { JM_GAPLIMIT_DEFAULT, JM_GAPLIMIT_CONFIGKEY } from '../constants/config'
+
+type ImportWalletDetailsFormValues = {
+ mnemonicPhrase: MnemonicPhrase
+ blockheight: number
+ gaplimit: number
+}
+
+const GAPLIMIT_SUGGESTIONS = {
+ normal: JM_GAPLIMIT_DEFAULT,
+ heavy: JM_GAPLIMIT_DEFAULT * 4,
+}
+
+const MIN_BLOCKHEIGHT_VALUE = 0
+const MIN_GAPLIMIT_VALUE = 1
+
+const initialImportWalletDetailsFormValues: ImportWalletDetailsFormValues = isDevMode()
+ ? {
+ mnemonicPhrase: new Array
(12).fill(''),
+ blockheight: MIN_BLOCKHEIGHT_VALUE,
+ gaplimit: GAPLIMIT_SUGGESTIONS.heavy,
+ }
+ : {
+ mnemonicPhrase: new Array(12).fill(''),
+ blockheight: SEGWIT_ACTIVATION_BLOCK,
+ gaplimit: GAPLIMIT_SUGGESTIONS.normal,
+ }
+
+interface ImportWalletDetailsFormProps {
+ initialValues?: ImportWalletDetailsFormValues
+ submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
+ onCancel: () => void
+ onSubmit: (values: ImportWalletDetailsFormValues) => Promise
+}
+
+const ImportWalletDetailsForm = ({
+ initialValues = initialImportWalletDetailsFormValues,
+ submitButtonText,
+ onCancel,
+ onSubmit,
+}: ImportWalletDetailsFormProps) => {
+ const { t, i18n } = useTranslation()
+ const [__dev_showFillerButton] = useState(isDebugFeatureEnabled('importDummyMnemonicPhrase'))
+
+ const validate = useCallback(
+ (values: ImportWalletDetailsFormValues) => {
+ const errors = {} as FormikErrors
+ const isMnemonicPhraseValid = values.mnemonicPhrase.every((it) => it.length > 0)
+ if (!isMnemonicPhraseValid) {
+ errors.mnemonicPhrase = t('import_wallet.import_details.feedback_invalid_menmonic_phrase')
+ }
+
+ if (typeof values.blockheight !== 'number' || values.blockheight < MIN_BLOCKHEIGHT_VALUE) {
+ errors.blockheight = t('import_wallet.import_details.feedback_invalid_blockheight', {
+ min: MIN_BLOCKHEIGHT_VALUE,
+ })
+ }
+ if (typeof values.gaplimit !== 'number' || values.gaplimit < MIN_GAPLIMIT_VALUE) {
+ errors.gaplimit = t('import_wallet.import_details.feedback_invalid_gaplimit', {
+ min: MIN_GAPLIMIT_VALUE,
+ })
+ }
+ return errors
+ },
+ [t]
+ )
+
+ return (
+
+ {({
+ handleSubmit,
+ handleBlur,
+ handleChange,
+ setFieldValue,
+ values,
+ touched,
+ errors,
+ isSubmitting,
+ submitCount,
+ }) => (
+
+ setFieldValue('mnemonicPhrase', val, true)}
+ isDisabled={(_) => isSubmitting}
+ />
+ {!!errors.mnemonicPhrase && (
+ <>
+
+ {errors.mnemonicPhrase}
+
+ >
+ )}
+ {__dev_showFillerButton && (
+ setFieldValue('mnemonicPhrase', DUMMY_MNEMONIC_PHRASE, true)}
+ disabled={isSubmitting}
+ >
+ Fill with dummy mnemonic phrase
+
+ dev
+
+
+ )}
+
+ {t('import_wallet.import_details.import_options')}
+
+ }
+ defaultOpen={true}
+ >
+
+ {t('import_wallet.import_details.label_blockheight')}
+
+ {t('import_wallet.import_details.description_blockheight')}
+
+
+
+
+
+
+ {errors.blockheight}
+
+
+
+ {t('import_wallet.import_details.label_gaplimit')}
+
+
+ {t('import_wallet.import_details.description_gaplimit')}
+
+
+
+
+
+
+ {errors.gaplimit}
+
+
+
+
+
+ {isSubmitting && (
+
+ )}
+ {submitButtonText(isSubmitting)}
+
+
+
+ onCancel()}>
+
+ {t('global.back')}
+
+
+
+ )}
+
+ )
+}
+
+type ImportWalletConfirmationFormValues = {
+ walletDetails: CreateWalletFormValues
+ importDetails: ImportWalletDetailsFormValues
+}
+
+interface ImportWalletConfirmationProps {
+ walletDetails: CreateWalletFormValues
+ importDetails: ImportWalletDetailsFormValues
+ submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
+ onCancel: () => void
+ onSubmit: (values: ImportWalletConfirmationFormValues) => Promise
+}
+
+const ImportWalletConfirmation = ({
+ walletDetails,
+ importDetails,
+ submitButtonText,
+ onCancel,
+ onSubmit,
+}: ImportWalletConfirmationProps) => {
+ const { t, i18n } = useTranslation()
+
+ const walletInfo = useMemo(
+ () => ({
+ walletFileName: walletDetails.walletName + JM_WALLET_FILE_EXTENSION,
+ password: walletDetails.password,
+ seedphrase: importDetails.mnemonicPhrase.join(' '),
+ }),
+ [walletDetails, importDetails]
+ )
+
+ return (
+
+ {({ handleSubmit, values, isSubmitting, submitCount }) => (
+
+
+
+
+
+
{t('import_wallet.import_details.label_blockheight')}
+
{t('import_wallet.import_details.description_blockheight')}
+
{values.importDetails.blockheight}
+
+
+
{t('import_wallet.import_details.label_gaplimit')}
+
{t('import_wallet.import_details.description_gaplimit')}
+
{values.importDetails.gaplimit}
+
+
+
+
+
+ {isSubmitting && (
+
+ )}
+ {submitButtonText(isSubmitting)}
+
+
+
+ {isSubmitting && (
+
+
{t('create_wallet.hint_duration_text')}
+
+ )}
+
+
+ onCancel()}>
+
+ {t('global.back')}
+
+
+
+ )}
+
+ )
+}
+
+enum ImportWalletSteps {
+ wallet_details,
+ import_details,
+ confirm_and_submit,
+}
+
+interface ImportWalletProps {
+ parentRoute: Route
+ startWallet: (name: Api.WalletName, token: Api.ApiToken) => void
+}
+
+export default function ImportWallet({ parentRoute, startWallet }: ImportWalletProps) {
+ const { t } = useTranslation()
+ const navigate = useNavigate()
+ const serviceInfo = useServiceInfo()
+ const dispatchServiceInfo = useDispatchServiceInfo()
+ const refreshConfigValues = useRefreshConfigValues()
+ const updateConfigValues = useUpdateConfigValues()
+
+ const [alert, setAlert] = useState()
+ const [createWalletFormValues, setCreateWalletFormValues] = useState()
+ const [importDetailsFormValues, setImportDetailsFormValues] = useState()
+ const [recoveredWallet, setRecoveredWallet] = useState<{ walletFileName: Api.WalletName; token: Api.ApiToken }>()
+
+ const isRecovered = useMemo(() => !!recoveredWallet?.walletFileName && recoveredWallet?.token, [recoveredWallet])
+ const canRecover = useMemo(
+ () => !isRecovered && !serviceInfo?.walletName && !serviceInfo?.rescanning,
+ [isRecovered, serviceInfo]
+ )
+
+ const [step, setStep] = useState(ImportWalletSteps.wallet_details)
+ const nextStep = () =>
+ setStep((old) => {
+ switch (step) {
+ case ImportWalletSteps.wallet_details:
+ return ImportWalletSteps.import_details
+ case ImportWalletSteps.import_details:
+ return ImportWalletSteps.confirm_and_submit
+ default:
+ return old
+ }
+ })
+ const previousStep = () => {
+ setAlert(undefined)
+ setStep((old) => {
+ switch (step) {
+ case ImportWalletSteps.import_details:
+ return ImportWalletSteps.wallet_details
+ case ImportWalletSteps.confirm_and_submit:
+ return ImportWalletSteps.import_details
+ default:
+ return old
+ }
+ })
+ }
+
+ const recoverWallet = useCallback(
+ async (
+ signal: AbortSignal,
+ {
+ walletname,
+ password,
+ seedphrase,
+ gaplimit,
+ blockheight,
+ }: { walletname: Api.WalletName; password: string; seedphrase: string; gaplimit: number; blockheight: number }
+ ) => {
+ setAlert(undefined)
+
+ try {
+ // Step #1: recover wallet
+ const recoverResponse = await Api.postWalletRecover({ signal }, { walletname, password, seedphrase })
+ const recoverBody = await (recoverResponse.ok ? recoverResponse.json() : Api.Helper.throwError(recoverResponse))
+
+ const { walletname: importedWalletFileName } = recoverBody
+ setRecoveredWallet({ walletFileName: importedWalletFileName, token: recoverBody.token })
+
+ // Step #2: update the gaplimit config value if necessary
+ const originalGaplimit = await refreshConfigValues({
+ signal,
+ keys: [JM_GAPLIMIT_CONFIGKEY],
+ wallet: { name: importedWalletFileName, token: recoverBody.token },
+ })
+ .then((it) => it[JM_GAPLIMIT_CONFIGKEY.section] || {})
+ .then((it) => parseInt(it[JM_GAPLIMIT_CONFIGKEY.field] || String(JM_GAPLIMIT_DEFAULT), 10))
+ .then((it) => it || JM_GAPLIMIT_DEFAULT)
+
+ const gaplimitUpdateNecessary = gaplimit !== originalGaplimit
+ if (gaplimitUpdateNecessary) {
+ console.info('Will update gaplimit from %d to %d', originalGaplimit, gaplimit)
+
+ await updateConfigValues({
+ signal,
+ updates: [
+ {
+ key: JM_GAPLIMIT_CONFIGKEY,
+ value: String(gaplimit),
+ },
+ ],
+ wallet: { name: importedWalletFileName, token: recoverBody.token },
+ })
+ }
+
+ // Step #3: lock and unlock the wallet (for new addresses to be imported)
+ const lockResponse = await Api.getWalletLock({ walletName: importedWalletFileName, token: recoverBody.token })
+ if (!lockResponse.ok) await Api.Helper.throwError(lockResponse)
+
+ const unlockResponse = await Api.postWalletUnlock({ walletName: importedWalletFileName }, { password })
+ const unlockBody = await (unlockResponse.ok ? unlockResponse.json() : Api.Helper.throwError(unlockResponse))
+
+ // Step #4: reset `gaplimit´ to previous value if necessary
+ if (gaplimitUpdateNecessary) {
+ console.info('Will reset gaplimit to previous value %d', originalGaplimit)
+ await updateConfigValues({
+ signal,
+ updates: [
+ {
+ key: JM_GAPLIMIT_CONFIGKEY,
+ value: String(originalGaplimit),
+ },
+ ],
+ wallet: { name: importedWalletFileName, token: unlockBody.token },
+ })
+ }
+
+ // Step #5: invoke rescanning the timechain
+ console.info('Will start rescanning timechain from block %d', blockheight)
+
+ const rescanResponse = await Api.getRescanBlockchain({
+ signal,
+ walletName: importedWalletFileName,
+ token: unlockBody.token,
+ blockheight,
+ })
+ if (!rescanResponse.ok) {
+ await Api.Helper.throwError(rescanResponse)
+ } else {
+ dispatchServiceInfo({
+ rescanning: true,
+ })
+ }
+
+ startWallet(importedWalletFileName, unlockBody.token)
+ navigate(routes.wallet)
+ } catch (e: any) {
+ if (signal.aborted) return
+ const message = t('import_wallet.error_importing_failed', {
+ reason: e.message || 'Unknown reason',
+ })
+ setAlert({ variant: 'danger', message })
+ }
+ },
+ [
+ setRecoveredWallet,
+ startWallet,
+ navigate,
+ setAlert,
+ refreshConfigValues,
+ updateConfigValues,
+ dispatchServiceInfo,
+ t,
+ ]
+ )
+
+ return (
+
+ <>
+ {step === ImportWalletSteps.wallet_details &&
}
+ {step === ImportWalletSteps.import_details && (
+
+ )}
+ {step === ImportWalletSteps.confirm_and_submit &&
}
+ >
+ {alert &&
{alert.message}}
+ {!canRecover && !isRecovered ? (
+ <>
+ {serviceInfo?.walletName && (
+
+
+ Currently {{ walletName: walletDisplayName(serviceInfo.walletName) }} is active. You
+ need to lock it first.
+
+ Go back
+
+ .
+
+
+ )}
+ {serviceInfo?.rescanning === true && (
+
+
+ Rescanning the timechain is currently in progress. Please wait until the process finishes and then try
+ again.
+
+ Go back
+
+ .
+
+
+ )}
+ >
+ ) : (
+ <>
+
+ {step === ImportWalletSteps.wallet_details && (
+
navigate(routes[parentRoute])}
+ onSubmit={async (values) => {
+ setCreateWalletFormValues(values)
+ nextStep()
+ }}
+ submitButtonText={(isSubmitting) =>
+ t(
+ isSubmitting
+ ? 'import_wallet.wallet_details.text_button_submitting'
+ : 'import_wallet.wallet_details.text_button_submit'
+ )
+ }
+ />
+ )}
+ {step === ImportWalletSteps.import_details && (
+
+ t(
+ isSubmitting
+ ? 'import_wallet.import_details.text_button_submitting'
+ : 'import_wallet.import_details.text_button_submit'
+ )
+ }
+ onCancel={() => previousStep()}
+ onSubmit={async (values) => {
+ setImportDetailsFormValues(values)
+ nextStep()
+ }}
+ />
+ )}
+ {step === ImportWalletSteps.confirm_and_submit && (
+
+ t(
+ isSubmitting
+ ? 'import_wallet.confirmation.text_button_submitting'
+ : 'import_wallet.confirmation.text_button_submit'
+ )
+ }
+ onCancel={() => previousStep()}
+ onSubmit={(values) => {
+ const abortCtrl = new AbortController()
+
+ return recoverWallet(abortCtrl.signal, {
+ walletname: (values.walletDetails.walletName + JM_WALLET_FILE_EXTENSION) as Api.WalletName,
+ password: values.walletDetails.password,
+ seedphrase: values.importDetails.mnemonicPhrase.join(' '),
+ gaplimit: values.importDetails.gaplimit,
+ blockheight: values.importDetails.blockheight,
+ })
+ }}
+ />
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/src/components/Jam.tsx b/src/components/Jam.tsx
index 8ac5c0b7a..f197a097f 100644
--- a/src/components/Jam.tsx
+++ b/src/components/Jam.tsx
@@ -262,7 +262,7 @@ export default function Jam({ wallet }: JamProps) {
}, [currentSchedule, lastKnownSchedule, isWaitingSchedulerStop, walletInfo])
const startSchedule = async (values: FormikValues) => {
- if (isLoading || collaborativeOperationRunning) {
+ if (isLoading || collaborativeOperationRunning || serviceInfo?.rescanning === true) {
return
}
@@ -509,7 +509,7 @@ export default function Jam({ wallet }: JamProps) {
className={styles.submit}
variant="dark"
type="submit"
- disabled={isSubmitting || !isValid}
+ disabled={isSubmitting || !isValid || serviceInfo?.rescanning === true}
>
{t('scheduler.button_start')}
diff --git a/src/components/MainWalletView.module.css b/src/components/MainWalletView.module.css
index d934284ce..4ee6a051f 100644
--- a/src/components/MainWalletView.module.css
+++ b/src/components/MainWalletView.module.css
@@ -15,6 +15,14 @@
margin-bottom: 0.45rem;
}
+:global(.jm-rescan-in-progress) .walletHeader {
+ cursor: wait;
+}
+
+:global(.jm-rescan-in-progress) .walletBody {
+ filter: blur(2px);
+}
+
.jarsPlaceholder {
width: 100%;
height: 3.5rem;
@@ -49,6 +57,10 @@
height: 2rem;
}
+.jarsDividerContainer .dividerButton:disabled {
+ cursor: not-allowed;
+}
+
.sendReceiveButton {
padding: 0.25rem;
font-weight: 500;
diff --git a/src/components/MainWalletView.tsx b/src/components/MainWalletView.tsx
index 0b1db41ad..db64ae93e 100644
--- a/src/components/MainWalletView.tsx
+++ b/src/components/MainWalletView.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from 'react'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
+import { useServiceInfo } from '../context/ServiceInfoContext'
import { useSettings, useSettingsDispatch } from '../context/SettingsContext'
import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
import { walletDisplayName } from '../utils'
@@ -15,16 +16,16 @@ import { Jars } from './Jars'
import styles from './MainWalletView.module.css'
interface WalletHeaderProps {
- name: string
+ walletName: string
balance: Api.AmountSats
unit: Unit
showBalance: boolean
}
-const WalletHeader = ({ name, balance, unit, showBalance }: WalletHeaderProps) => {
+const WalletHeader = ({ walletName, balance, unit, showBalance }: WalletHeaderProps) => {
return (
-
{walletDisplayName(name)}
+
{walletName}
{
)
}
+const WalletHeaderRescanning = ({ walletName, isLoading }: { walletName: string; isLoading: boolean }) => {
+ const { t } = useTranslation()
+ return (
+
+
{walletName}
+ {isLoading ? (
+
+
+
+ ) : (
+ {t('current_wallet.text_rescan_in_progress')}
+ )}
+
+ )
+}
+
interface MainWalletViewProps {
wallet: CurrentWallet
}
@@ -58,6 +75,7 @@ export default function MainWalletView({ wallet }: MainWalletViewProps) {
const { t } = useTranslation()
const navigate = useNavigate()
+ const serviceInfo = useServiceInfo()
const settings = useSettings()
const settingsDispatch = useSettingsDispatch()
const currentWalletInfo = useCurrentWalletInfo()
@@ -127,84 +145,92 @@ export default function MainWalletView({ wallet }: MainWalletViewProps) {
onHide={() => setIsAccountOverlayShown(false)}
/>
)}
- settingsDispatch({ showBalance: !settings.showBalance })} style={{ cursor: 'pointer' }}>
- {!currentWalletInfo || isLoading ? (
- <>
+ {serviceInfo?.rescanning === true ? (
+
+
+
+ ) : (
+ settingsDispatch({ showBalance: !settings.showBalance })} style={{ cursor: 'pointer' }}>
+ {!currentWalletInfo || isLoading ? (
- >
- ) : (
-
- )}
-
-
-
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+ {/* Always receive to first jar. */}
+
+
+
+
{t('current_wallet.button_deposit')}
+
+
+
+
+
+
+
+
{t('current_wallet.button_withdraw')}
+
+
+
+
+
+
+
-
- {/* Always receive on first mixdepth. */}
-
-
-
-
{t('current_wallet.button_deposit')}
-
-
-
-
- {/* Todo: Withdrawing needs to factor in the privacy levels as well.
- Depending on the mixdepth/account there will be different amounts available. */}
-
-
-
-
{t('current_wallet.button_withdraw')}
-
-
-
+
+
+ {!currentWalletInfo || isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+
-
-
-
-
-
-
- {!currentWalletInfo || isLoading ? (
-
-
-
- ) : (
-
- )}
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
setShowJars((current) => !current)}>
-
-
-
-
-
-
+
)
}
diff --git a/src/components/MnemonicPhraseInput.tsx b/src/components/MnemonicPhraseInput.tsx
new file mode 100644
index 000000000..7f6ff21dc
--- /dev/null
+++ b/src/components/MnemonicPhraseInput.tsx
@@ -0,0 +1,49 @@
+import MnemonicWordInput from './MnemonicWordInput'
+
+interface MnemonicPhraseInputProps {
+ columns?: number
+ mnemonicPhrase: MnemonicPhrase
+ isDisabled?: (index: number) => boolean
+ isValid?: (index: number) => boolean
+ onChange: (value: MnemonicPhrase) => void
+}
+
+export default function MnemonicPhraseInput({
+ columns = 3,
+ mnemonicPhrase,
+ isDisabled,
+ isValid,
+ onChange,
+}: MnemonicPhraseInputProps) {
+ return (
+
+ {mnemonicPhrase.map((_, outerIndex) => {
+ if (outerIndex % columns !== 0) return null
+
+ const wordGroup = mnemonicPhrase.slice(outerIndex, Math.min(outerIndex + columns, mnemonicPhrase.length))
+
+ return (
+
+ {wordGroup.map((givenWord, innerIndex) => {
+ const wordIndex = outerIndex + innerIndex
+ return (
+
+ {
+ const newPhrase = mnemonicPhrase.map((old, index) => (index === i ? value : old))
+ onChange(newPhrase)
+ }}
+ isValid={isValid ? isValid(wordIndex) : undefined}
+ disabled={isDisabled ? isDisabled(wordIndex) : undefined}
+ />
+
+ )
+ })}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/MnemonicWordInput.tsx b/src/components/MnemonicWordInput.tsx
new file mode 100644
index 000000000..8d63b6b61
--- /dev/null
+++ b/src/components/MnemonicWordInput.tsx
@@ -0,0 +1,33 @@
+import * as rb from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import styles from './CreateWallet.module.css'
+
+interface MnemonicWordInputProps {
+ index: number
+ value: string
+ setValue: (value: string, index: number) => void
+ isValid?: boolean
+ disabled?: boolean
+}
+
+const MnemonicWordInput = ({ index, value, setValue, isValid, disabled }: MnemonicWordInputProps) => {
+ const { t } = useTranslation()
+ return (
+
+ {index + 1}.
+ setValue(e.target.value.trim(), index)}
+ className={styles.input}
+ disabled={disabled}
+ isInvalid={isValid === false && value.length > 0}
+ isValid={isValid === true}
+ required
+ />
+
+ )
+}
+
+export default MnemonicWordInput
diff --git a/src/components/Navbar.module.css b/src/components/Navbar.module.css
index ad2973d45..bf17313ab 100644
--- a/src/components/Navbar.module.css
+++ b/src/components/Navbar.module.css
@@ -1,3 +1,20 @@
+:global(.jm-rescan-in-progress) :global(.center-nav-link),
+:global(.jm-rescan-in-progress) :global(.center-nav-link-divider) {
+ filter: blur(2px);
+}
+
.balancePlaceholder {
width: 7.5rem;
}
+
+.loadingIndicator {
+ display: none !important;
+}
+
+:global(.jam-reload-wallet-info-in-progress) .walletSprite {
+ display: none !important;
+}
+
+:global(.jam-reload-wallet-info-in-progress) .loadingIndicator {
+ display: inline-block !important;
+}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index 6eb6f1b15..14118e079 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'
import { Link, NavLink, To } from 'react-router-dom'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
+import classNames from 'classnames'
import Sprite from './Sprite'
import Balance from './Balance'
import { TabActivityIndicator, JoiningIndicator } from './ActivityIndicators'
@@ -24,29 +25,48 @@ const BalanceLoadingIndicator = () => {
interface WalletPreviewProps {
wallet: CurrentWallet
+ rescanInProgress: boolean
totalBalance?: AmountSats
unit: Unit
showBalance?: boolean
}
-const WalletPreview = ({ wallet, totalBalance, unit, showBalance = false }: WalletPreviewProps) => {
+const WalletPreview = ({ wallet, rescanInProgress, totalBalance, unit, showBalance = false }: WalletPreviewProps) => {
+ const { t } = useTranslation()
+
return (
-
+
+
+
+
{wallet &&
{walletDisplayName(wallet.name)}
}
- {totalBalance === undefined ? (
-
- ) : (
-
-
-
- )}
+
+ {rescanInProgress ? (
+
{t('navbar.text_rescan_in_progress')}
+ ) : (
+ <>
+ {totalBalance === undefined ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
)
@@ -56,10 +76,17 @@ interface CenterNavProps {
makerRunning: boolean
schedulerRunning: boolean
singleCoinJoinRunning: boolean
+ rescanInProgress: boolean
onClick?: () => void
}
-const CenterNav = ({ makerRunning, schedulerRunning, singleCoinJoinRunning, onClick }: CenterNavProps) => {
+const CenterNav = ({
+ makerRunning,
+ schedulerRunning,
+ singleCoinJoinRunning,
+ rescanInProgress,
+ onClick,
+}: CenterNavProps) => {
const { t } = useTranslation()
return (
@@ -69,7 +96,10 @@ const CenterNav = ({ makerRunning, schedulerRunning, singleCoinJoinRunning, onCl
to={routes.receive}
onClick={onClick}
className={({ isActive }) =>
- 'center-nav-link nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
+ classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
+ active: isActive,
+ disabled: rescanInProgress,
+ })
}
>
{t('navbar.tab_receive')}
@@ -81,7 +111,10 @@ const CenterNav = ({ makerRunning, schedulerRunning, singleCoinJoinRunning, onCl
to={routes.send}
onClick={onClick}
className={({ isActive }) =>
- 'center-nav-link nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
+ classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
+ active: isActive,
+ disabled: rescanInProgress,
+ })
}
>
@@ -96,7 +129,10 @@ const CenterNav = ({ makerRunning, schedulerRunning, singleCoinJoinRunning, onCl
to={routes.earn}
onClick={onClick}
className={({ isActive }) =>
- 'center-nav-link nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
+ classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
+ active: isActive,
+ disabled: rescanInProgress,
+ })
}
>
@@ -111,7 +147,10 @@ const CenterNav = ({ makerRunning, schedulerRunning, singleCoinJoinRunning, onCl
to={routes.jam}
onClick={onClick}
className={({ isActive }) =>
- 'center-nav-link nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
+ classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
+ active: isActive,
+ disabled: rescanInProgress,
+ })
}
>
@@ -175,7 +214,8 @@ export default function Navbar() {
const [isExpanded, setIsExpanded] = useState(false)
- const makerRunning = useMemo(() => serviceInfo?.makerRunning || false, [serviceInfo])
+ const makerRunning = useMemo(() => serviceInfo?.makerRunning === true, [serviceInfo])
+ const rescanInProgress = useMemo(() => serviceInfo?.rescanning === true, [serviceInfo])
const schedulerRunning = useMemo(
() => (serviceInfo?.coinjoinInProgress && serviceInfo?.schedule !== null) || false,
[serviceInfo]
@@ -260,6 +300,7 @@ export default function Navbar() {
>
setIsExpanded(!isExpanded)}
/>
setIsExpanded(!isExpanded)} />
@@ -291,6 +333,7 @@ export default function Navbar() {
makerRunning={makerRunning}
schedulerRunning={schedulerRunning}
singleCoinJoinRunning={singleCoinJoinRunning}
+ rescanInProgress={rescanInProgress}
/>
diff --git a/src/components/PreventLeavingPageByMistake.tsx b/src/components/PreventLeavingPageByMistake.tsx
new file mode 100644
index 000000000..0e3c693c6
--- /dev/null
+++ b/src/components/PreventLeavingPageByMistake.tsx
@@ -0,0 +1,31 @@
+import { useEffect } from 'react'
+
+const PreventLeavingPageByMistake = () => {
+ // prompt users before refreshing or closing the page when this component is present.
+ // Firefox will show: "This page is asking you to confirm that you want to leave [...]"
+ // Chrome: "Leave site? Changes you made may not be saved."
+ useEffect(() => {
+ const abortCtrl = new AbortController()
+
+ window.addEventListener(
+ 'beforeunload',
+ (event) => {
+ // cancel the event as stated by the standard.
+ event.preventDefault()
+
+ // Chrome requires returnValue to be set.
+ event.returnValue = ''
+
+ // return something to trigger a dialog
+ return ''
+ },
+ { signal: abortCtrl.signal }
+ )
+
+ return () => abortCtrl.abort()
+ }, [])
+
+ return <>>
+}
+
+export default PreventLeavingPageByMistake
diff --git a/src/components/Receive.jsx b/src/components/Receive.jsx
index 667d7420d..433b2f4b3 100644
--- a/src/components/Receive.jsx
+++ b/src/components/Receive.jsx
@@ -3,6 +3,7 @@ import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
import { useSettings } from '../context/SettingsContext'
+import { useServiceInfo } from '../context/ServiceInfoContext'
import { useCurrentWalletInfo } from '../context/WalletContext'
import * as Api from '../libs/JmWalletApi'
import { BitcoinQR } from './BitcoinQR'
@@ -18,14 +19,16 @@ export default function Receive({ wallet }) {
const { t } = useTranslation()
const location = useLocation()
const settings = useSettings()
+ const serviceInfo = useServiceInfo()
const walletInfo = useCurrentWalletInfo()
const [validated, setValidated] = useState(false)
- const [alert, setAlert] = useState(null)
+ const [alert, setAlert] = useState()
const [isLoading, setIsLoading] = useState(true)
const [address, setAddress] = useState('')
const [amount, setAmount] = useState('')
const [selectedJarIndex, setSelectedJarIndex] = useState(parseInt(location.state?.account, 10) || 0)
const [addressCount, setAddressCount] = useState(0)
+ const isFormEnabled = useMemo(() => serviceInfo && serviceInfo.rescanning !== true, [serviceInfo])
const sortedAccountBalances = useMemo(() => {
if (!walletInfo) return []
@@ -35,10 +38,15 @@ export default function Receive({ wallet }) {
}, [walletInfo])
useEffect(() => {
+ if (!isFormEnabled) {
+ setIsLoading(false)
+ return
+ }
+
const abortCtrl = new AbortController()
const { name: walletName, token } = wallet
- setAlert(null)
+ setAlert(undefined)
setIsLoading(true)
Api.getAddressNew({ walletName, mixdepth: selectedJarIndex, token, signal: abortCtrl.signal })
@@ -52,7 +60,7 @@ export default function Receive({ wallet }) {
.finally(() => !abortCtrl.signal.aborted && setIsLoading(false))
return () => abortCtrl.abort()
- }, [wallet, selectedJarIndex, addressCount, t])
+ }, [isFormEnabled, wallet, selectedJarIndex, addressCount, t])
const onSubmit = (e) => {
e.preventDefault()
@@ -62,7 +70,7 @@ export default function Receive({ wallet }) {
setValidated(true)
if (isValid) {
- setAddressCount(addressCount + 1)
+ setAddressCount((it) => it + 1)
}
}
@@ -70,7 +78,8 @@ export default function Receive({ wallet }) {
{alert &&
{alert.message}}
-
+ {serviceInfo?.rescanning === true &&
{t('app.alert_rescan_in_progress')}}
+
{!isLoading && address && }
@@ -104,8 +113,8 @@ export default function Receive({ wallet }) {
-
-
+
+
{!walletInfo || sortedAccountBalances.length === 0 ? (
@@ -119,7 +128,7 @@ export default function Receive({ wallet }) {
index={it.accountIndex}
balance={it.calculatedAvailableBalanceInSats}
frozenBalance={it.calculatedFrozenOrLockedBalanceInSats}
- isSelectable={true}
+ isSelectable={isFormEnabled}
isSelected={it.accountIndex === selectedJarIndex}
fillLevel={jarFillLevel(
it.calculatedTotalBalanceInSats,
@@ -143,7 +152,7 @@ export default function Receive({ wallet }) {
type="number"
placeholder="0"
value={amount}
- disabled={isLoading}
+ disabled={!isFormEnabled || isLoading}
onChange={(e) => setAmount(e.target.value)}
min={0}
step={1}
@@ -158,7 +167,7 @@ export default function Receive({ wallet }) {
{isLoading ? (
diff --git a/src/components/Receive.module.css b/src/components/Receive.module.css
index a15840670..844a123fc 100644
--- a/src/components/Receive.module.css
+++ b/src/components/Receive.module.css
@@ -48,6 +48,14 @@
min-height: 16.25rem;
}
+:global(.jm-rescan-in-progress) .cardContainer {
+ display: none;
+}
+
+:global(.jm-rescan-in-progress) .receiveForm {
+ filter: blur(2px);
+}
+
.receive-placeholder-qr-container {
width: 16.25rem;
}
diff --git a/src/components/RescanChain.tsx b/src/components/RescanChain.tsx
new file mode 100644
index 000000000..35f71ae5d
--- /dev/null
+++ b/src/components/RescanChain.tsx
@@ -0,0 +1,167 @@
+import { useState, useCallback } from 'react'
+import * as rb from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import classNames from 'classnames'
+import { Formik, FormikErrors } from 'formik'
+import * as Api from '../libs/JmWalletApi'
+import { useServiceInfo, useDispatchServiceInfo } from '../context/ServiceInfoContext'
+import PageTitle from './PageTitle'
+import Sprite from './Sprite'
+import { CurrentWallet } from '../context/WalletContext'
+import { SEGWIT_ACTIVATION_BLOCK } from '../utils'
+
+type RescanChainFormValues = {
+ blockheight: number
+}
+
+const initialRescanChainFormValues: RescanChainFormValues = {
+ blockheight: SEGWIT_ACTIVATION_BLOCK,
+}
+
+interface RescanChainFormProps {
+ submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
+ onSubmit: (values: RescanChainFormValues) => Promise
+ disabled?: boolean
+}
+/**
+ *
+ * @param param0
+ "rescan_chain": {
+ "error_rescanning_failed": "Error while starting the rescan process. Reason: {{ reason }}"
+ },
+ * @returns
+ */
+
+const RescanChainForm = ({ disabled, submitButtonText, onSubmit }: RescanChainFormProps) => {
+ const { t, i18n } = useTranslation()
+
+ return (
+
+
{
+ const errors = {} as FormikErrors
+ if (typeof values.blockheight !== 'number' || values.blockheight < 0) {
+ errors.blockheight = t('Please provide a valid blockheight value greater than or equal to {{ min }}.', {
+ min: 0,
+ })
+ }
+ return errors
+ }}
+ onSubmit={async (values) => !disabled && onSubmit(values)}
+ >
+ {({ handleSubmit, handleBlur, handleChange, values, touched, errors, isSubmitting }) => (
+
+
+ {t('Blockheight')}
+
+ {t('The height of the chain at which the rescan process is started.')}
+
+
+
+
+
+
+ {errors.blockheight}
+
+
+
+
+ {isSubmitting && (
+
+ )}
+ {submitButtonText(isSubmitting)}
+
+
+
+ )}
+
+
+ )
+}
+
+interface RescanChainProps {
+ wallet: CurrentWallet
+}
+
+export default function RescanChain({ wallet }: RescanChainProps) {
+ const { t } = useTranslation()
+ const serviceInfo = useServiceInfo()
+ const dispatchServiceInfo = useDispatchServiceInfo()
+
+ const [alert, setAlert] = useState()
+
+ const startChainRescan = useCallback(
+ async (signal: AbortSignal, { blockheight }: { blockheight: number }) => {
+ setAlert(undefined)
+
+ try {
+ const requestContext = { walletName: wallet.name, token: wallet.token }
+ const res = await Api.getRescanBlockchain({ signal, ...requestContext, blockheight })
+ if (!res.ok) await Api.Helper.throwError(res)
+
+ dispatchServiceInfo({
+ rescanning: true,
+ })
+ } catch (e: any) {
+ if (signal.aborted) return
+
+ const message = t('Error while starting the rescan process. Reason: {{ reason }}', {
+ reason: e.message || t('global.errors.reason_unknown'),
+ })
+ setAlert({ variant: 'danger', message })
+ }
+ },
+ [wallet, setAlert, dispatchServiceInfo, t]
+ )
+
+ return (
+
+
+ {alert &&
{alert.message}}
+
+ {serviceInfo?.rescanning === true && {t('app.alert_rescan_in_progress')}}
+ t(isSubmitting ? 'Rescan timechain' : 'Rescan timechain')}
+ onSubmit={async (values) => {
+ const abortCtrl = new AbortController()
+
+ return startChainRescan(abortCtrl.signal, {
+ blockheight: values.blockheight,
+ })
+ }}
+ />
+
+
+ )
+}
diff --git a/src/components/Send/index.tsx b/src/components/Send/index.tsx
index fabc1d659..a41c14d94 100644
--- a/src/components/Send/index.tsx
+++ b/src/components/Send/index.tsx
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { Trans, useTranslation } from 'react-i18next'
import * as rb from 'react-bootstrap'
import classNames from 'classnames'
+import * as Api from '../../libs/JmWalletApi'
import PageTitle from '../PageTitle'
import ToggleSwitch from '../ToggleSwitch'
import Sprite from '../Sprite'
@@ -22,8 +23,8 @@ import { useServiceInfo, useReloadServiceInfo } from '../../context/ServiceInfoC
import { useLoadConfigValue } from '../../context/ServiceConfigContext'
import { buildCoinjoinRequirementSummary } from '../../hooks/CoinjoinRequirements'
-import * as Api from '../../libs/JmWalletApi'
import { routes } from '../../constants/routes'
+import { JM_MINIMUM_MAKERS_DEFAULT } from '../../constants/config'
import { SATS, formatSats, isValidNumber } from '../../utils'
import {
@@ -39,8 +40,6 @@ import styles from './Send.module.css'
import FeeBreakdown from './FeeBreakdown'
const IS_COINJOIN_DEFAULT_VAL = true
-// initial value for `minimum_makers` from the default joinmarket.cfg (last check on 2022-02-20 of v0.9.5)
-const MINIMUM_MAKERS_DEFAULT_VAL = 4
const INITIAL_DESTINATION = null
const INITIAL_SOURCE_JAR_INDEX = null
@@ -70,13 +69,14 @@ export default function Send({ wallet }: SendProps) {
const reloadServiceInfo = useReloadServiceInfo()
const loadConfigValue = useLoadConfigValue()
- const isCoinjoinInProgress = useMemo(() => serviceInfo && serviceInfo.coinjoinInProgress, [serviceInfo])
- const isMakerRunning = useMemo(() => serviceInfo && serviceInfo.makerRunning, [serviceInfo])
+ const isCoinjoinInProgress = useMemo(() => serviceInfo?.coinjoinInProgress === true, [serviceInfo])
+ const isMakerRunning = useMemo(() => serviceInfo?.makerRunning === true, [serviceInfo])
+ const isRescanningInProgress = useMemo(() => serviceInfo?.rescanning === true, [serviceInfo])
const [alert, setAlert] = useState()
const [isSending, setIsSending] = useState(false)
const [isCoinjoin, setIsCoinjoin] = useState(IS_COINJOIN_DEFAULT_VAL)
- const [minNumCollaborators, setMinNumCollaborators] = useState(MINIMUM_MAKERS_DEFAULT_VAL)
+ const [minNumCollaborators, setMinNumCollaborators] = useState(JM_MINIMUM_MAKERS_DEFAULT)
const [isSweep, setIsSweep] = useState(false)
const [destinationJarPickerShown, setDestinationJarPickerShown] = useState(false)
const [destinationJar, setDestinationJar] = useState(null)
@@ -90,8 +90,8 @@ export default function Send({ wallet }: SendProps) {
const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState()
const isOperationDisabled = useMemo(
- () => isCoinjoinInProgress || isMakerRunning || waitForUtxosToBeSpent.length > 0,
- [isCoinjoinInProgress, isMakerRunning, waitForUtxosToBeSpent]
+ () => isCoinjoinInProgress || isMakerRunning || isRescanningInProgress || waitForUtxosToBeSpent.length > 0,
+ [isCoinjoinInProgress, isMakerRunning, isRescanningInProgress, waitForUtxosToBeSpent]
)
const [isInitializing, setIsInitializing] = useState(!isOperationDisabled)
const isLoading = useMemo(
diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx
index 7176d1678..1ea0243d2 100644
--- a/src/components/Settings.jsx
+++ b/src/components/Settings.jsx
@@ -16,6 +16,7 @@ import languages from '../i18n/languages'
import styles from './Settings.module.css'
import SeedModal from './settings/SeedModal'
import FeeConfigModal from './settings/FeeConfigModal'
+import { isDebugFeatureEnabled } from '../constants/debugFeatures'
export default function Settings({ wallet, stopWallet }) {
const { t, i18n } = useTranslation()
@@ -224,6 +225,19 @@ export default function Settings({ wallet, stopWallet }) {
>
)}
+
+ {isDebugFeatureEnabled('rescanChainPage') && (
+
+
+ Rescan chain
+
+ dev
+
+
+ )}
{t('settings.section_title_community')}
diff --git a/src/components/WalletCreationConfirmation.tsx b/src/components/WalletCreationConfirmation.tsx
new file mode 100644
index 000000000..55250067f
--- /dev/null
+++ b/src/components/WalletCreationConfirmation.tsx
@@ -0,0 +1,103 @@
+import { useState } from 'react'
+import * as rb from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { Formik } from 'formik'
+import Seedphrase from './Seedphrase'
+import ToggleSwitch from './ToggleSwitch'
+import { walletDisplayName } from '../utils'
+// TODO: currently reusing CreateWallet styles - move to own module.css?
+import styles from './CreateWallet.module.css'
+
+export type WalletInfo = {
+ walletFileName: string
+ password: string
+ seedphrase: string
+}
+
+interface WalletCreationInfoSummaryProps {
+ walletInfo: WalletInfo
+ revealSensitiveInfo: boolean
+}
+
+export const WalletInfoSummary = ({ walletInfo, revealSensitiveInfo }: WalletCreationInfoSummaryProps) => {
+ const { t } = useTranslation()
+ return (
+ <>
+
+
{t('create_wallet.confirmation_label_wallet_name')}
+
{walletDisplayName(walletInfo.walletFileName)}
+
+
+
+
+
+
{t('create_wallet.confirmation_label_password')}
+
+ {!revealSensitiveInfo ? 'randomrandom' : walletInfo.password}
+
+
+ >
+ )
+}
+
+interface WalletCreationConfirmationProps {
+ wallet: WalletInfo
+ submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
+ onSubmit: () => Promise
+}
+
+const WalletCreationConfirmation = ({ wallet, submitButtonText, onSubmit }: WalletCreationConfirmationProps) => {
+ const { t } = useTranslation()
+ const [userConfirmed, setUserConfirmed] = useState(false)
+ const [revealSensitiveInfo, setRevealSensitiveInfo] = useState(false)
+ const [sensitiveInfoWasRevealed, setSensitiveInfoWasRevealed] = useState(false)
+
+ return (
+ ({})}
+ onSubmit={async (_: any) => {
+ if (!sensitiveInfoWasRevealed || !userConfirmed) return
+ await onSubmit()
+ }}
+ >
+ {({ handleSubmit, isSubmitting }) => (
+
+
+
+ {
+ setRevealSensitiveInfo(isToggled)
+ setSensitiveInfoWasRevealed(true)
+ }}
+ />
+
+
+ setUserConfirmed(isToggled)}
+ />
+
+
+
+ {isSubmitting && (
+
+ )}
+ {submitButtonText(isSubmitting)}
+
+
+
+ )}
+
+ )
+}
+
+export default WalletCreationConfirmation
diff --git a/src/components/WalletCreationForm.module.css b/src/components/WalletCreationForm.module.css
new file mode 100644
index 000000000..7221cc054
--- /dev/null
+++ b/src/components/WalletCreationForm.module.css
@@ -0,0 +1,9 @@
+.input {
+ height: 3.5rem;
+ width: 100%;
+}
+
+.button {
+ height: 3rem;
+ width: 100%;
+}
diff --git a/src/components/WalletCreationForm.tsx b/src/components/WalletCreationForm.tsx
new file mode 100644
index 000000000..14dde360d
--- /dev/null
+++ b/src/components/WalletCreationForm.tsx
@@ -0,0 +1,149 @@
+import { useCallback } from 'react'
+import * as rb from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { Formik, FormikErrors } from 'formik'
+import Sprite from './Sprite'
+import { sanitizeWalletName } from '../utils'
+import styles from './WalletCreationForm.module.css'
+
+export interface CreateWalletFormValues {
+ walletName: string
+ password: string
+ passwordConfirm: string
+}
+
+const initialCreateWalletFormValues: CreateWalletFormValues = {
+ walletName: '',
+ password: '',
+ passwordConfirm: '',
+}
+
+export type WalletNameAndPassword = { name: string; password: string }
+
+interface WalletCreationFormProps {
+ initialValues?: CreateWalletFormValues
+ submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
+ onCancel: () => void
+ onSubmit: (values: CreateWalletFormValues) => Promise
+}
+
+const WalletCreationForm = ({
+ initialValues = initialCreateWalletFormValues,
+ submitButtonText,
+ onCancel,
+ onSubmit,
+}: WalletCreationFormProps) => {
+ const { t, i18n } = useTranslation()
+
+ const validate = useCallback(
+ (values: CreateWalletFormValues) => {
+ const errors = {} as FormikErrors
+ if (!values.walletName) {
+ errors.walletName = t('create_wallet.feedback_invalid_wallet_name')
+ }
+ if (!values.password) {
+ errors.password = t('create_wallet.feedback_invalid_password')
+ }
+ if (!values.passwordConfirm || values.password !== values.passwordConfirm) {
+ errors.passwordConfirm = t('create_wallet.feedback_invalid_password_confirm')
+ }
+ return errors
+ },
+ [t]
+ )
+
+ return (
+ {
+ await onSubmit({ ...values, walletName: sanitizeWalletName(values.walletName) })
+ }}
+ >
+ {({ handleSubmit, handleChange, handleBlur, values, touched, errors, isSubmitting }) => (
+
+
+ {t('create_wallet.label_wallet_name')}
+
+ {t('create_wallet.feedback_valid')}
+ {errors.walletName}
+
+
+ {t('create_wallet.label_password')}
+
+ {t('create_wallet.feedback_valid')}
+ {errors.password}
+
+
+ {t('create_wallet.label_password_confirm')}
+
+ {t('create_wallet.feedback_valid')}
+ {errors.passwordConfirm}
+
+
+ {isSubmitting ? (
+
+
+ {submitButtonText(isSubmitting)}
+
+ ) : (
+ submitButtonText(isSubmitting)
+ )}
+
+ {isSubmitting && (
+
+
{t('create_wallet.hint_duration_text')}
+
+ )}
+ onCancel()}
+ >
+
+ {t('global.cancel')}
+
+
+ )}
+
+ )
+}
+
+export default WalletCreationForm
diff --git a/src/components/Wallets.jsx b/src/components/Wallets.jsx
index 723710ba9..fead18e1d 100644
--- a/src/components/Wallets.jsx
+++ b/src/components/Wallets.jsx
@@ -232,10 +232,14 @@ export default function Wallets({ currentWallet, startWallet, stopWallet }) {
)
})
)}
-
+
0,
@@ -244,10 +248,23 @@ export default function Wallets({ currentWallet, startWallet, stopWallet }) {
data-testid="new-wallet-btn"
>
-
+
{t('wallets.button_new_wallet')}
+
+
+
+ {t('wallets.button_import_wallet')}
+
+
diff --git a/src/components/Wallets.test.jsx b/src/components/Wallets.test.jsx
index c99168432..9616a0118 100644
--- a/src/components/Wallets.test.jsx
+++ b/src/components/Wallets.test.jsx
@@ -71,7 +71,7 @@ describe('', () => {
expect(screen.getByText('wallets.button_new_wallet')).toBeInTheDocument()
})
- it('should display big call-to-action button if no wallet has been created yet', async () => {
+ it('should display big call-to-action buttons if no wallet has been created yet', async () => {
apiMock.getSession.mockResolvedValueOnce({
ok: true,
json: () =>
@@ -91,16 +91,19 @@ describe('', () => {
expect(screen.getByText('wallets.text_loading')).toBeInTheDocument()
- const callToActionButtonBefore = screen.getByTestId('new-wallet-btn')
- expect(callToActionButtonBefore.classList.contains('btn')).toBe(true)
- expect(callToActionButtonBefore.classList.contains('btn-lg')).toBe(false)
+ const newWalletButtonBefore = screen.getByTestId('new-wallet-btn')
+ expect(newWalletButtonBefore.classList.contains('btn')).toBe(true)
+ expect(newWalletButtonBefore.classList.contains('btn-lg')).toBe(false)
await waitForElementToBeRemoved(screen.getByText('wallets.text_loading'))
expect(screen.getByText('wallets.subtitle_no_wallets')).toBeInTheDocument()
- const callToActionButtonAfter = screen.getByTestId('new-wallet-btn')
- expect(callToActionButtonAfter.classList.contains('btn-lg')).toBe(true)
+ const newWalletButtonBeforeAfter = screen.getByTestId('new-wallet-btn')
+ expect(newWalletButtonBeforeAfter.classList.contains('btn-lg')).toBe(true)
+
+ const importWalletButton = screen.getByTestId('import-wallet-btn')
+ expect(importWalletButton.classList.contains('btn-lg')).toBe(true)
})
it('should display login for available wallets', async () => {
@@ -129,9 +132,13 @@ describe('', () => {
expect(screen.getByText('wallet0')).toBeInTheDocument()
expect(screen.getByText('wallet1')).toBeInTheDocument()
- const callToActionButton = screen.getByTestId('new-wallet-btn')
- expect(callToActionButton.classList.contains('btn')).toBe(true)
- expect(callToActionButton.classList.contains('btn-lg')).toBe(false)
+ const newWalletButton = screen.getByTestId('new-wallet-btn')
+ expect(newWalletButton.classList.contains('btn')).toBe(true)
+ expect(newWalletButton.classList.contains('btn-lg')).toBe(false)
+
+ const importWalletButton = screen.getByTestId('import-wallet-btn')
+ expect(importWalletButton.classList.contains('btn')).toBe(true)
+ expect(importWalletButton.classList.contains('btn-lg')).toBe(false)
})
describe(' lock/unlock flow', () => {
diff --git a/src/components/fb/utils.ts b/src/components/fb/utils.ts
index 6ed1d6c87..4e725fbc0 100644
--- a/src/components/fb/utils.ts
+++ b/src/components/fb/utils.ts
@@ -1,8 +1,6 @@
import { Lockdate } from '../../libs/JmWalletApi'
import { Utxo } from '../../context/WalletContext'
-type Milliseconds = number
-type Seconds = number
type TimeInterval = number
export type YearsRange = {
diff --git a/src/components/jars/Jar.module.css b/src/components/jars/Jar.module.css
index 9aaa9bb72..126bf799e 100644
--- a/src/components/jars/Jar.module.css
+++ b/src/components/jars/Jar.module.css
@@ -75,7 +75,7 @@
.selectableJarContainer:not(.selectable) {
color: var(--bs-gray-600);
- cursor: none;
+ cursor: not-allowed;
}
.selectableJarContainer .selectionCircle {
diff --git a/src/constants/config.ts b/src/constants/config.ts
new file mode 100644
index 000000000..632e5dde9
--- /dev/null
+++ b/src/constants/config.ts
@@ -0,0 +1,19 @@
+import { ConfigKey } from '../context/ServiceConfigContext'
+
+export const JM_GAPLIMIT_DEFAULT = 6
+
+export const JM_GAPLIMIT_CONFIGKEY: ConfigKey = {
+ section: 'POLICY',
+ field: 'gaplimit',
+}
+
+// initial value for `taker_utxo_age` from the default joinmarket.cfg (last check on 2023-08-13 of v0.9.9)
+export const JM_TAKER_UTXO_AGE_DEFAULT = 5
+
+// initial value for `minimum_makers` from the default joinmarket.cfg (last check on 2022-02-20 of v0.9.5)
+export const JM_MINIMUM_MAKERS_DEFAULT = 4
+
+// possible values for property `coinjoin_state` in websocket messages
+export const CJ_STATE_TAKER_RUNNING = 0
+export const CJ_STATE_MAKER_RUNNING = 1
+export const CJ_STATE_NONE_RUNNING = 2
diff --git a/src/constants/debugFeatures.ts b/src/constants/debugFeatures.ts
index 37db0f7e9..eb3dcc635 100644
--- a/src/constants/debugFeatures.ts
+++ b/src/constants/debugFeatures.ts
@@ -3,6 +3,8 @@ interface DebugFeatures {
allowCreatingExpiredFidelityBond: boolean
skipWalletBackupConfirmation: boolean
errorExamplePage: boolean
+ importDummyMnemonicPhrase: boolean
+ rescanChainPage: boolean
}
const devMode = process.env.NODE_ENV === 'development' && process.env.REACT_APP_JAM_DEV_MODE === 'true'
@@ -12,10 +14,14 @@ const debugFeatures: DebugFeatures = {
insecureScheduleTesting: devMode,
skipWalletBackupConfirmation: devMode,
errorExamplePage: devMode,
+ importDummyMnemonicPhrase: devMode,
+ rescanChainPage: devMode,
}
type DebugFeature = keyof DebugFeatures
+export const isDevMode = (): boolean => devMode
+
export const isDebugFeatureEnabled = (name: DebugFeature): boolean => {
return debugFeatures[name] || false
}
diff --git a/src/constants/routes.ts b/src/constants/routes.ts
index 76e48c0dc..68a202098 100644
--- a/src/constants/routes.ts
+++ b/src/constants/routes.ts
@@ -8,5 +8,9 @@ export const routes = {
settings: '/settings',
wallet: '/wallet',
createWallet: '/create-wallet',
+ importWallet: '/import-wallet',
+ rescanChain: '/rescan',
__errorExample: '/error-example',
}
+
+export type Route = keyof typeof routes
diff --git a/src/context/BalanceSummary.ts b/src/context/BalanceSummary.ts
index 5e085e2c2..7642e7d38 100644
--- a/src/context/BalanceSummary.ts
+++ b/src/context/BalanceSummary.ts
@@ -3,8 +3,6 @@ import { CombinedRawWalletData, Utxos } from '../context/WalletContext'
import * as fb from '../components/fb/utils'
import { AmountSats } from '../libs/JmWalletApi'
-type Milliseconds = number
-
type BalanceSummary = {
/**
* @description Manually calculated total balance in sats.
diff --git a/src/context/ServiceConfigContext.tsx b/src/context/ServiceConfigContext.tsx
index 8619e6c34..81dddc435 100644
--- a/src/context/ServiceConfigContext.tsx
+++ b/src/context/ServiceConfigContext.tsx
@@ -32,11 +32,13 @@ type LoadConfigValueProps = {
type RefreshConfigValuesProps = {
signal?: AbortSignal
keys: ConfigKey[]
+ wallet?: CurrentWallet
}
type UpdateConfigValuesProps = {
signal?: AbortSignal
updates: ServiceConfigUpdate[]
+ wallet?: CurrentWallet
}
const configReducer = (state: ServiceConfig, obj: ServiceConfigUpdate): ServiceConfig => {
@@ -108,12 +110,13 @@ const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => {
const serviceConfig = useRef(null)
const refreshConfigValues = useCallback(
- async ({ signal, keys }: RefreshConfigValuesProps) => {
- if (!currentWallet) {
- throw new Error('Cannot load config: Wallet not present')
+ async ({ signal, keys, wallet }: RefreshConfigValuesProps) => {
+ const activeWallet = wallet || currentWallet
+ if (!activeWallet) {
+ throw new Error('Cannot refresh config: Wallet not present')
}
- return fetchConfigValues({ signal, wallet: currentWallet, configKeys: keys })
+ return fetchConfigValues({ signal, wallet: activeWallet, configKeys: keys })
.then((updates) => updates.reduce(configReducer, serviceConfig.current || {}))
.then((result) => {
if (!signal || !signal.aborted) {
@@ -153,12 +156,13 @@ const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => {
)
const updateConfigValues = useCallback(
- async ({ signal, updates }: UpdateConfigValuesProps) => {
- if (!currentWallet) {
- throw new Error('Cannot load config: Wallet not present')
+ async ({ signal, updates, wallet }: UpdateConfigValuesProps) => {
+ const activeWallet = wallet || currentWallet
+ if (!activeWallet) {
+ throw new Error('Cannot update config: Wallet not present')
}
- return pushConfigValues({ signal, wallet: currentWallet, updates })
+ return pushConfigValues({ signal, wallet: activeWallet, updates })
.then((updates) => updates.reduce(configReducer, serviceConfig.current || {}))
.then((result) => {
if (!signal || !signal.aborted) {
diff --git a/src/context/ServiceInfoContext.tsx b/src/context/ServiceInfoContext.tsx
index f07a466d2..1659e8fe7 100644
--- a/src/context/ServiceInfoContext.tsx
+++ b/src/context/ServiceInfoContext.tsx
@@ -2,8 +2,9 @@ import React, { createContext, useCallback, useContext, useReducer, useState, us
// @ts-ignore
import { useCurrentWallet, useSetCurrentWallet } from './WalletContext'
// @ts-ignore
-import { useWebsocket, CJ_STATE_TAKER_RUNNING, CJ_STATE_MAKER_RUNNING } from './WebsocketContext'
+import { useWebsocket } from './WebsocketContext'
import { clearSession } from '../session'
+import { CJ_STATE_TAKER_RUNNING, CJ_STATE_MAKER_RUNNING } from '../constants/config'
import * as Api from '../libs/JmWalletApi'
@@ -49,25 +50,29 @@ interface JmSessionData {
schedule: Schedule | null
offer_list: Offer[] | null
nickname: string | null
+ rescanning: boolean
}
type SessionFlag = { sessionActive: boolean }
type MakerRunningFlag = { makerRunning: boolean }
type CoinjoinInProgressFlag = { coinjoinInProgress: boolean }
+type RescanBlockchainInProgressFlag = { rescanning: boolean }
type ServiceInfo = SessionFlag &
MakerRunningFlag &
- CoinjoinInProgressFlag & {
+ CoinjoinInProgressFlag &
+ RescanBlockchainInProgressFlag & {
walletName: Api.WalletName | null
schedule: Schedule | null
offers: Offer[] | null
nickname: string | null
}
-type ServiceInfoUpdate = ServiceInfo | MakerRunningFlag | CoinjoinInProgressFlag
+type ServiceInfoUpdate = ServiceInfo | MakerRunningFlag | CoinjoinInProgressFlag | RescanBlockchainInProgressFlag
interface ServiceInfoContextEntry {
serviceInfo: ServiceInfo | null
reloadServiceInfo: ({ signal }: { signal: AbortSignal }) => Promise
+ dispatchServiceInfo: React.Dispatch
connectionError?: Error
}
@@ -123,6 +128,7 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => {
coinjoin_in_process: coinjoinInProgress,
wallet_name: walletNameOrNoneString,
offer_list: offers,
+ rescanning,
schedule,
nickname,
} = data
@@ -135,6 +141,7 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => {
schedule,
offers,
nickname,
+ rescanning,
}
})
@@ -210,7 +217,7 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => {
}, [websocket, onWebsocketMessage])
return (
-
+
{children}
)
@@ -223,6 +230,7 @@ const useServiceInfo = () => {
}
return context.serviceInfo
}
+
const useReloadServiceInfo = () => {
const context = useContext(ServiceInfoContext)
if (context === undefined) {
@@ -231,6 +239,14 @@ const useReloadServiceInfo = () => {
return context.reloadServiceInfo
}
+const useDispatchServiceInfo = () => {
+ const context = useContext(ServiceInfoContext)
+ if (context === undefined) {
+ throw new Error('useDispatchServiceInfo must be used within a ServiceInfoProvider')
+ }
+ return context.dispatchServiceInfo
+}
+
const useSessionConnectionError = () => {
const context = useContext(ServiceInfoContext)
if (context === undefined) {
@@ -244,6 +260,7 @@ export {
ServiceInfoProvider,
useServiceInfo,
useReloadServiceInfo,
+ useDispatchServiceInfo,
useSessionConnectionError,
Schedule,
StateFlag,
diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx
index 4d2a7eb62..52c92ad10 100644
--- a/src/context/WalletContext.tsx
+++ b/src/context/WalletContext.tsx
@@ -115,9 +115,8 @@ interface WalletContextEntry {
setCurrentWallet: React.Dispatch>
currentWalletInfo: WalletInfo | undefined
reloadCurrentWalletInfo: {
- reloadAll: ({ signal }: { signal: AbortSignal }) => Promise
+ reloadAll: ({ signal }: { signal: AbortSignal }) => Promise
reloadUtxos: ({ signal }: { signal: AbortSignal }) => Promise
- reloadDisplay: ({ signal }: { signal: AbortSignal }) => Promise
}
}
@@ -245,9 +244,10 @@ const WalletProvider = ({ children }: PropsWithChildren) => {
)
const reloadAll = useCallback(
- async ({ signal }: { signal: AbortSignal }): Promise => {
- await Promise.all([reloadUtxos({ signal }), reloadDisplay({ signal })])
- },
+ ({ signal }: { signal: AbortSignal }): Promise =>
+ Promise.all([reloadUtxos({ signal }), reloadDisplay({ signal })])
+ .then((data) => toCombinedRawData(data[0], data[1]))
+ .then((raw) => toWalletInfo(raw)),
[reloadUtxos, reloadDisplay]
)
@@ -265,32 +265,16 @@ const WalletProvider = ({ children }: PropsWithChildren) => {
() => ({
reloadAll,
reloadUtxos,
- reloadDisplay,
}),
- [reloadAll, reloadUtxos, reloadDisplay]
+ [reloadAll, reloadUtxos]
)
useEffect(() => {
if (!currentWallet) {
setUtxoResponse(undefined)
setDisplayResponse(undefined)
- } else {
- const abortCtrl = new AbortController()
- const signal = abortCtrl.signal
-
- reloadCurrentWalletInfo
- .reloadAll({ signal })
- // If the auto-reloading on wallet change fails, the error can currently
- // only be logged and cannot be displayed to the user satisfactorily.
- // This might change in the future but is okay for now - components can
- // always trigger a reload on demand and inform the user as they see fit.
- .catch((err) => console.error(err))
-
- return () => {
- abortCtrl.abort()
- }
}
- }, [currentWallet, reloadCurrentWalletInfo])
+ }, [currentWallet])
return (
{
// path that will be proxied to the backend server
const WEBSOCKET_ENDPOINT_PATH = `${window.JM.PUBLIC_PATH}/jmws`
-// possible values for property `coinjoin_state` in websocket messages
-const CJ_STATE_TAKER_RUNNING = 0
-const CJ_STATE_MAKER_RUNNING = 1
-const CJ_STATE_NONE_RUNNING = 2
-
const NOOP = () => {}
const logToDebugConsoleInDevMode = process.env.NODE_ENV === 'development' ? console.debug : NOOP
@@ -215,12 +210,4 @@ const useWebsocketState = () => {
return context.websocketState
}
-export {
- WebsocketContext,
- WebsocketProvider,
- useWebsocket,
- useWebsocketState,
- CJ_STATE_TAKER_RUNNING,
- CJ_STATE_MAKER_RUNNING,
- CJ_STATE_NONE_RUNNING,
-}
+export { WebsocketContext, WebsocketProvider, useWebsocket, useWebsocketState }
diff --git a/src/globals.d.ts b/src/globals.d.ts
index 93f0baff2..476ca5fdf 100644
--- a/src/globals.d.ts
+++ b/src/globals.d.ts
@@ -2,4 +2,9 @@ declare type JarIndex = number
declare type Unit = 'BTC' | 'sats'
+type Milliseconds = number
+type Seconds = number
+
+declare type MnemonicPhrase = string[]
+
declare type SimpleAlert = import('react-bootstrap').AlertProps & { message: string | import('react').ReactNode }
diff --git a/src/hooks/CoinjoinRequirements.ts b/src/hooks/CoinjoinRequirements.ts
index 46606f283..f27b86d5c 100644
--- a/src/hooks/CoinjoinRequirements.ts
+++ b/src/hooks/CoinjoinRequirements.ts
@@ -1,5 +1,6 @@
import * as fb from '../components/fb/utils'
import { groupByJar, Utxos } from '../context/WalletContext'
+import { JM_TAKER_UTXO_AGE_DEFAULT } from '../constants/config'
export type CoinjoinRequirementOptions = {
minNumberOfUtxos: number // min amount of utxos available
@@ -9,7 +10,7 @@ export type CoinjoinRequirementOptions = {
export const DEFAULT_REQUIREMENT_OPTIONS: CoinjoinRequirementOptions = {
minNumberOfUtxos: 1,
- minConfirmations: 5, // default of `taker_utxo_age` in jm config
+ minConfirmations: JM_TAKER_UTXO_AGE_DEFAULT,
}
export interface CoinjoinRequirementViolation {
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 41c33c4d8..649ece11e 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -7,6 +7,7 @@
"back": "Back",
"close": "Close",
"abort": "Abort",
+ "cancel": "Cancel",
"table": {
"pagination": {
"items_per_page": {
@@ -29,10 +30,12 @@
}
},
"app": {
- "alert_no_connection": "No connection to backend: {{ connectionError }}"
+ "alert_no_connection": "No connection to backend: {{ connectionError }}",
+ "alert_rescan_in_progress": "Rescanning in progress..."
},
"navbar": {
"title": "Jam",
+ "text_rescan_in_progress": "Rescanning...",
"button_create_wallet": "Create Wallet",
"tab_send": "Send",
"tab_receive": "Receive",
@@ -91,6 +94,7 @@
"subtitle_no_wallets": "It looks like you do not have a wallet, yet.",
"text_loading": "Loading wallets",
"button_new_wallet": "Create new wallet",
+ "button_import_wallet": "Import existing wallet",
"alert_wallet_open": "There can be only one active wallet. If you want to open another wallet, please lock '{{ currentWalletName }}' first.",
"error_loading_failed": "Loading wallets failed.",
"wallet_preview": {
@@ -130,6 +134,7 @@
"feedback_invalid_password_confirm": "Given passwords do not match.",
"button_create": "Create",
"button_creating": "Creating",
+ "error_creating_failed": "Error while creating the wallet. Reason: {{ reason }}",
"alert_confirmation_failed": "Wallet confirmation failed.",
"confirmation_label_wallet_name": "Wallet Name",
"confirmation_label_password": "Password",
@@ -145,8 +150,38 @@
"placeholder_seed_word_input": "Word",
"hint_duration_text": "Please be patient, this may take a few minutes."
},
+ "import_wallet": {
+ "alert_other_wallet_unlocked": "Currently <1>{{ walletName }}1> is active. You need to lock it first. <3>Go back3>.",
+ "alert_rescan_in_progress": "Rescanning the timechain is currently in progress. Please wait until the process finishes and then try again. <1>Go back1>.",
+ "wallet_details": {
+ "title": "Import Wallet",
+ "text_button_submit": "Continue",
+ "text_button_submitting": "Continue"
+ },
+ "import_details": {
+ "title": "Please enter your mnemonic phrase!",
+ "subtitle": "Please enter your mnemonic phrase one by one in the correct order and start the rescan process.",
+ "feedback_invalid_menmonic_phrase": "Please provide a valid mnemonic phrase",
+ "import_options": "Import options",
+ "label_blockheight": "Rescan height",
+ "description_blockheight": "The blockheight at which the rescan process starts to search for your funds. The earlier the wallet has been created, the lower this value should be.",
+ "feedback_invalid_blockheight": "Please provide a valid blockheight value greater than or equal to {{ min }}.",
+ "label_gaplimit": "Address import limit",
+ "description_gaplimit": "The amount of addresses that are imported per jar. Set to the highest address index used in any of the jars. Increase this number if your wallet is heavily used.",
+ "feedback_invalid_gaplimit": "Please provide a valid gaplimit value greater than or equal to {{ min }}.",
+ "text_button_submit": "Review",
+ "text_button_submitting": "Review"
+ },
+ "confirmation": {
+ "title": "Please review your mnemonic phrase and password!",
+ "text_button_submit": "Import",
+ "text_button_submitting": "Importing..."
+ },
+ "error_importing_failed": "Error while importing the wallet. Reason: {{ reason }}"
+ },
"current_wallet": {
"text_loading": "Loading",
+ "text_rescan_in_progress": "Rescanning in progress...",
"button_deposit": "Receive",
"button_withdraw": "Send",
"error_loading_failed": "Loading wallet failed.",
@@ -217,7 +252,7 @@
"text_button_cancel": "Cancel",
"text_button_submit": "Save",
"text_button_submitting": "Saving...",
- "error_loading_fee_config_failed": "Error while loading fee config values. ",
+ "error_loading_fee_config_failed": "Error while loading fee config values.",
"error_saving_fee_config_failed": "Error while saving fee config values. Reason: {{ reason }}"
}
},
diff --git a/src/index.css b/src/index.css
index 6cb2b998d..fa4002b30 100644
--- a/src/index.css
+++ b/src/index.css
@@ -190,11 +190,11 @@ html {
html,
body,
-#root {
+#root .app {
min-height: 100vh;
}
-#root {
+#root .app {
display: flex;
flex-direction: column;
}
@@ -435,12 +435,15 @@ footer .cheatsheet-link.nav-link {
color: transparent;
text-shadow: 0 0 15px var(--bs-gray-600);
}
-
:root[data-theme='dark'] .blurred-text {
color: transparent;
text-shadow: 0 0 15px var(--bs-gray-400);
}
+.blurred {
+ filter: blur(2px);
+}
+
/* Boostrap overrides */
h2 {
@@ -559,6 +562,10 @@ h2 {
cursor: not-allowed;
}
+.cursor-wait {
+ cursor: wait;
+}
+
.accordion {
--bs-accordion-color: var(--bs-black);
--bs-accordion-active-color: var(--bs-black);
diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts
index 1c7f3fdec..aa8305bd3 100644
--- a/src/libs/JmWalletApi.ts
+++ b/src/libs/JmWalletApi.ts
@@ -37,6 +37,9 @@ export type Lockdate = `${YYYY}-${MM}`
type WithLockdate = {
lockdate: Lockdate
}
+type WithBlockheight = {
+ blockheight: number
+}
interface ApiRequestContext {
signal?: AbortSignal
@@ -55,9 +58,16 @@ interface ApiError {
type WalletType = 'sw-fb'
interface CreateWalletRequest {
- wallettype: WalletType
- walletname: WalletName
+ walletname: WalletName | string
password: string
+ wallettype?: WalletType
+}
+
+interface RecoverWalletRequest {
+ walletname: WalletName | string
+ password: string
+ seedphrase: string
+ wallettype?: WalletType
}
interface WalletUnlockRequest {
@@ -227,12 +237,23 @@ const getWalletAll = async ({ signal }: ApiRequestContext) => {
})
}
-const postWalletCreate = async (req: CreateWalletRequest) => {
+const postWalletCreate = async ({ signal }: ApiRequestContext, req: CreateWalletRequest) => {
const walletname = req.walletname.endsWith('.jmdat') ? req.walletname : `${req.walletname}.jmdat`
return await fetch(`${basePath()}/v1/wallet/create`, {
method: 'POST',
- body: JSON.stringify({ ...req, walletname, wallettype: 'sw-fb' }),
+ body: JSON.stringify({ ...req, walletname, wallettype: req.wallettype || 'sw-fb' }),
+ signal,
+ })
+}
+
+const postWalletRecover = async ({ signal }: ApiRequestContext, req: RecoverWalletRequest) => {
+ const walletname = req.walletname.endsWith('.jmdat') ? req.walletname : `${req.walletname}.jmdat`
+
+ return await fetch(`${basePath()}/v1/wallet/recover`, {
+ method: 'POST',
+ body: JSON.stringify({ ...req, walletname, wallettype: req.wallettype || 'sw-fb' }),
+ signal,
})
}
@@ -419,6 +440,21 @@ const postConfigGet = async ({ token, signal, walletName }: WalletRequestContext
})
}
+/**
+ * Use this operation on recovered wallets to re-sync the wallet
+ */
+const getRescanBlockchain = async ({
+ token,
+ signal,
+ walletName,
+ blockheight,
+}: WalletRequestContext & WithBlockheight) => {
+ return await fetch(`${basePath()}/v1/wallet/${encodeURIComponent(walletName)}/rescanblockchain/${blockheight}`, {
+ headers: { ...Helper.buildAuthHeader(token) },
+ signal,
+ })
+}
+
export class JmApiError extends Error {
public response: Response
@@ -438,6 +474,7 @@ export {
getAddressTimelockNew,
getWalletAll,
postWalletCreate,
+ postWalletRecover,
getWalletDisplay,
getWalletLock,
postWalletUnlock,
@@ -450,5 +487,6 @@ export {
postSchedulerStart,
getTakerStop,
getSchedule,
+ getRescanBlockchain,
Helper,
}
diff --git a/src/utils.ts b/src/utils.ts
index bab53b6ce..d02c7eb68 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -11,7 +11,16 @@ const SATS_FORMATTER = new Intl.NumberFormat('en-US', {
export const BTC: Unit = 'BTC'
export const SATS: Unit = 'sats'
-export const walletDisplayName = (name: string) => name.replace('.jmdat', '')
+export const JM_WALLET_FILE_EXTENSION = '.jmdat'
+
+export const DUMMY_MNEMONIC_PHRASE: MnemonicPhrase =
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'.split(' ')
+
+export const SEGWIT_ACTIVATION_BLOCK = 481_824 // https://github.com/bitcoin/bitcoin/blob/v25.0/src/kernel/chainparams.cpp#L86
+
+export const sanitizeWalletName = (name: string) => name.replace(JM_WALLET_FILE_EXTENSION, '')
+
+export const walletDisplayName = (name: string) => sanitizeWalletName(name)
export const displayDate = (string: string) => new Date(string).toLocaleString()