diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/Dockerfile b/docker/regtest/dockerfile-deps/joinmarket/directory_node/Dockerfile index 8d4e61798..2ca9a4a39 100644 --- a/docker/regtest/dockerfile-deps/joinmarket/directory_node/Dockerfile +++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/Dockerfile @@ -4,8 +4,7 @@ RUN apt-get update \ && apt-get install -qq --no-install-recommends gnupg tini procps vim git iproute2 supervisor \ # joinmarket dependencies curl build-essential automake pkg-config libtool python3-dev python3-venv python3-pip python3-setuptools libltdl-dev \ - # tor dependencies - libevent-dev libssl-dev zlib1g-dev \ + tor \ && rm -rf /var/lib/apt/lists/* ENV REPO https://github.com/JoinMarket-Org/joinmarket-clientserver @@ -15,7 +14,7 @@ ENV REPO_REF master WORKDIR /src RUN git clone "$REPO" . --depth=10 --branch "$REPO_BRANCH" && git checkout "$REPO_REF" -RUN ./install.sh --docker-install --with-local-tor --disable-secp-check --without-qt +RUN ./install.sh --docker-install --disable-secp-check --without-qt ENV DATADIR /root/.joinmarket ENV CONFIG ${DATADIR}/joinmarket.cfg diff --git a/docker/regtest/init-setup.sh b/docker/regtest/init-setup.sh index 666602732..57daa804d 100755 --- a/docker/regtest/init-setup.sh +++ b/docker/regtest/init-setup.sh @@ -24,8 +24,16 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) . "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket2 --unmatured --blocks 50 . "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket3 --unmatured --blocks 50 +# fund addresses of seed 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +# this is useful if you "import an existing wallet" and verify rescanning the chain works as expected. +dummy_wallet_address1='bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk' # 1st address of jar A (m/84'/1'/0'/0/0) +dummy_wallet_address2='bcrt1qt5yxk3xzrx66q9wd5sdyynklqynqcyf7uh74j3' # 8th address of jar C (m/84'/1'/2'/0/7) +dummy_wallet_address3='bcrt1qn8804dw5fahuc5cwqteuq5j4xlhk2cnkq7a8kw' # 21st change address of jar E (m/84'/1'/4'/1/21) # make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106 -. "$script_dir/mine-block.sh" 106 &>/dev/null +. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address1" &>/dev/null +. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address2" &>/dev/null +. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address3" &>/dev/null +. "$script_dir/mine-block.sh" 100 &>/dev/null start_maker() { local base_url; base_url=${1:-} diff --git a/package-lock.json b/package-lock.json index fd3e08489..0aaf4da81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6406,9 +6406,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001431", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001431.tgz", - "integrity": "sha512-zBUoFU0ZcxpvSt9IU66dXVT/3ctO1cy4y9cscs1szkPlcWb6pasYM144GqrUygUbT+k7cmUCW61cvskjcv0enQ==", + "version": "1.0.30001511", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001511.tgz", + "integrity": "sha512-NaWPJawcoedlghN4P7bDNeADD7K+rZaY6V8ZcME7PkEZo/nfOg+lnrUgRWiKbNxcQ4/toFKSxnS4WdbyPZnKkw==", "funding": [ { "type": "opencollective", @@ -6417,6 +6417,10 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ] }, @@ -26960,9 +26964,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001431", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001431.tgz", - "integrity": "sha512-zBUoFU0ZcxpvSt9IU66dXVT/3ctO1cy4y9cscs1szkPlcWb6pasYM144GqrUygUbT+k7cmUCW61cvskjcv0enQ==" + "version": "1.0.30001511", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001511.tgz", + "integrity": "sha512-NaWPJawcoedlghN4P7bDNeADD7K+rZaY6V8ZcME7PkEZo/nfOg+lnrUgRWiKbNxcQ4/toFKSxnS4WdbyPZnKkw==" }, "case-sensitive-paths-webpack-plugin": { "version": "2.4.0", diff --git a/public/sprite.svg b/public/sprite.svg index 8cb0527e4..67f0b222f 100644 --- a/public/sprite.svg +++ b/public/sprite.svg @@ -91,6 +91,9 @@ + + + @@ -354,4 +357,8 @@ + + + + diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx index 7dbfbfd87..4da9a7327 100644 --- a/src/components/Accordion.tsx +++ b/src/components/Accordion.tsx @@ -1,14 +1,15 @@ -import { PropsWithChildren, useState } from 'react' +import React, { PropsWithChildren, useState } from 'react' import { useSettings } from '../context/SettingsContext' import * as rb from 'react-bootstrap' import Sprite from './Sprite' interface AccordionProps { - title: string + title: string | React.ReactNode defaultOpen?: boolean + disabled?: boolean } -const Accordion = ({ title, defaultOpen = false, children }: PropsWithChildren) => { +const Accordion = ({ title, defaultOpen = false, disabled = false, children }: PropsWithChildren) => { const settings = useSettings() const [isOpen, setIsOpen] = useState(defaultOpen) @@ -18,6 +19,7 @@ const Accordion = ({ title, defaultOpen = false, children }: PropsWithChildren setIsOpen((current) => !current)} + disabled={disabled} > {title} diff --git a/src/components/App.tsx b/src/components/App.tsx index d098f307e..cf5c76b8c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { @@ -9,13 +9,22 @@ import { RouterProvider, Outlet, } from 'react-router-dom' +import classNames from 'classnames' +import * as Api from '../libs/JmWalletApi' import { routes } from '../constants/routes' -import { useSessionConnectionError } from '../context/ServiceInfoContext' +import { useServiceInfo, useSessionConnectionError } from '../context/ServiceInfoContext' import { useSettings } from '../context/SettingsContext' -import { useCurrentWallet, useSetCurrentWallet } from '../context/WalletContext' +import { + WalletInfo, + CurrentWallet, + useCurrentWallet, + useSetCurrentWallet, + useReloadCurrentWalletInfo, +} from '../context/WalletContext' import { clearSession, setSession } from '../session' import { isDebugFeatureEnabled } from '../constants/debugFeatures' import CreateWallet from './CreateWallet' +import ImportWallet from './ImportWallet' import Earn from './Earn' import ErrorPage, { ErrorThrowingComponent } from './ErrorPage' import Footer from './Footer' @@ -26,6 +35,7 @@ import Navbar from './Navbar' import Onboarding from './Onboarding' import Receive from './Receive' import Send from './Send' +import RescanChain from './RescanChain' import Settings from './Settings' import Wallets from './Wallets' @@ -34,10 +44,14 @@ export default function App() { const settings = useSettings() const currentWallet = useCurrentWallet() const setCurrentWallet = useSetCurrentWallet() + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + const serviceInfo = useServiceInfo() const sessionConnectionError = useSessionConnectionError() + const [reloadingWalletInfoCounter, setReloadingWalletInfoCounter] = useState(0) + const isReloadingWalletInfo = useMemo(() => reloadingWalletInfoCounter > 0, [reloadingWalletInfoCounter]) const startWallet = useCallback( - (name, token) => { + (name: Api.WalletName, token: Api.ApiToken) => { setSession({ name, token }) setCurrentWallet({ name, token }) }, @@ -49,6 +63,27 @@ export default function App() { setCurrentWallet(null) }, [setCurrentWallet]) + const reloadWalletInfo = useCallback( + (delay: Milliseconds) => { + setReloadingWalletInfoCounter((current) => current + 1) + console.info('Reloading wallet info...') + return new Promise((resolve, reject) => + setTimeout(() => { + const abortCtrl = new AbortController() + reloadCurrentWalletInfo + .reloadAll({ signal: abortCtrl.signal }) + .then((result) => resolve(result)) + .catch((error) => reject(error)) + .finally(() => { + console.info('Finished reloading wallet info.') + setReloadingWalletInfoCounter((current) => current - 1) + }) + }, delay) + ) + }, + [reloadCurrentWalletInfo] + ) + const router = createBrowserRouter( createRoutesFromElements( } /> + } + /> {sessionConnectionError ? ( } /> + } + /> {currentWallet && ( <> } /> @@ -111,6 +155,7 @@ export default function App() { } /> } /> } /> + } /> + return ( + <> +
+ +
+ + + ) +} + +const RELOAD_WALLET_INFO_DELAY: { + AFTER_RESCAN: Milliseconds + AFTER_UNLOCK: Milliseconds +} = { + // After rescanning, it is necessary to give the JM backend some time to synchronize. + // A couple of seconds should be enough, however, this depends on the user hardware + // and the delay might need to be increased if users encounter problems, e.g. the + // balance changes again when switching views. + // As reference: 4 seconds was not enough, even on regtest. But keep in mind, this only + // takes effect after rescanning the chain, which should happen quite infrequently. + AFTER_RESCAN: 8_000, + + // No delay is needed after normal unlock of wallet + AFTER_UNLOCK: 0, +} + +const MAX_RECURSIVE_WALLET_INFO_RELOADS = 10 + +interface WalletInfoAutoReloadProps { + currentWallet: CurrentWallet | null + reloadWalletInfo: (delay: Milliseconds) => Promise +} + +/** + * A component that automatically reloads wallet information on certain state changes, + * e.g. when the wallet is unlocked or rescanning the chain finished successfully. + * + * 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. + */ +const WalletInfoAutoReload = ({ currentWallet, reloadWalletInfo }: WalletInfoAutoReloadProps) => { + const serviceInfo = useServiceInfo() + const [previousRescanning, setPreviousRescanning] = useState(serviceInfo?.rescanning || false) + const [currentRescanning, setCurrentRescanning] = useState(serviceInfo?.rescanning || false) + const rescanningFinished = useMemo( + () => previousRescanning === true && currentRescanning === false, + [previousRescanning, currentRescanning] + ) + + useEffect(() => { + setPreviousRescanning(currentRescanning) + setCurrentRescanning(serviceInfo?.rescanning || false) + }, [serviceInfo, currentRescanning]) + + useEffect( + function reloadAfterUnlock() { + if (!currentWallet) return + + reloadWalletInfo(RELOAD_WALLET_INFO_DELAY.AFTER_UNLOCK).catch((err) => console.error(err)) + }, + [currentWallet, reloadWalletInfo] + ) + + useEffect( + function reloadAfterRescan() { + if (!currentWallet || !rescanningFinished) return + + // Hacky: If the balance changes after a reload, the backend might still not have been fully synchronized - try again! + // Hint 1: Wallet might be empty after the first attempt + // Hint 2: Just because wallet balance did not change, it does not mean everything has been found. + const reloadWhileBalanceChangesRecursively = async ( + currentBalance: Api.AmountSats, + delay: Milliseconds, + maxCalls: number, + callCounter: number = 0 + ) => { + if (callCounter >= maxCalls) return + const info = await reloadWalletInfo(delay) + const newBalance = info.balanceSummary.calculatedTotalBalanceInSats + if (newBalance > currentBalance) { + await reloadWhileBalanceChangesRecursively(newBalance, delay, maxCalls, callCounter++) + } + } + + reloadWalletInfo(RELOAD_WALLET_INFO_DELAY.AFTER_RESCAN) + .then((info) => + reloadWhileBalanceChangesRecursively( + info.balanceSummary.calculatedTotalBalanceInSats, + RELOAD_WALLET_INFO_DELAY.AFTER_RESCAN, + MAX_RECURSIVE_WALLET_INFO_RELOADS + ) + ) + .catch((err) => console.error(err)) + }, + [currentWallet, rescanningFinished, reloadWalletInfo] + ) + + return <> } diff --git a/src/components/CreateWallet.jsx b/src/components/CreateWallet.jsx index 3f41c32ab..b4e2072c5 100644 --- a/src/components/CreateWallet.jsx +++ b/src/components/CreateWallet.jsx @@ -1,11 +1,13 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useCallback, useMemo } from 'react' import * as rb from 'react-bootstrap' import { Link, useNavigate } from 'react-router-dom' import { Trans, useTranslation } from 'react-i18next' -import { Formik } from 'formik' import PageTitle from './PageTitle' -import Seedphrase from './Seedphrase' -import ToggleSwitch from './ToggleSwitch' +import Sprite from './Sprite' +import WalletCreationConfirmation from './WalletCreationConfirmation' +import PreventLeavingPageByMistake from './PreventLeavingPageByMistake' +import WalletCreationForm from './WalletCreationForm' +import MnemonicPhraseInput from './MnemonicPhraseInput' import { walletDisplayName } from '../utils' import { useServiceInfo } from '../context/ServiceInfoContext' import * as Api from '../libs/JmWalletApi' @@ -13,239 +15,37 @@ import { routes } from '../constants/routes' import { isDebugFeatureEnabled } from '../constants/debugFeatures' import styles from './CreateWallet.module.css' -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 <> -} - -const WalletCreationForm = ({ createWallet }) => { +const BackupConfirmation = ({ wallet, onSuccess, onCancel }) => { const { t } = useTranslation() - const initialValues = { walletName: '', password: '', passwordConfirm: '' } - const validate = (values) => { - const errors = {} - 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 - } - - const onSubmit = useCallback( - async (values) => { - const { walletName, password } = values - await createWallet(walletName, password) - }, - [createWallet] - ) - - return ( - - {({ handleSubmit, handleChange, handleBlur, values, touched, errors, isSubmitting }) => ( - <> - {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 ? ( -
-
- ) : ( - t('create_wallet.button_create') - )} -
-
- {isSubmitting && ( -
-

{t('create_wallet.hint_duration_text')}

-
- )} - - )} -
- ) -} - -const SeedWordInput = ({ number, targetWord, isValid, setIsValid }) => { - const { t } = useTranslation() - const [enteredWord, setEnteredWord] = useState('') - - useEffect(() => { - if (!isValid && enteredWord === targetWord) { - // Only use effect when value changes from false -> true to prevent an endless re-rendering loop. - setIsValid(true) - } - }, [enteredWord, targetWord, setIsValid, isValid]) - - return ( - - {number}. - { - setEnteredWord(e.target.value) - }} - className={styles.input} - disabled={isValid} - isInvalid={!isValid && enteredWord.length > 0} - isValid={isValid} - required - /> - - ) -} - -const BackupConfirmation = ({ createdWallet, walletConfirmed, parentStepSetter }) => { - const seedphrase = createdWallet.seedphrase.split(' ') - - const { t } = useTranslation() - const [seedWordConfirmations, setSeedWordConfirmations] = useState(new Array(seedphrase.length).fill(false)) + const seedphrase = useMemo(() => wallet.seedphrase.split(' '), [wallet]) + const [givenWords, setGivenWords] = useState(new Array(seedphrase.length).fill('')) const [showSkipButton] = useState(isDebugFeatureEnabled('skipWalletBackupConfirmation')) const isSeedBackupConfirmed = useMemo( - () => seedWordConfirmations.every((wordConfirmed) => wordConfirmed), - [seedWordConfirmations] + () => givenWords.every((word, index) => word === seedphrase[index]), + [givenWords, seedphrase] ) return (
+
{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 ( - <> - - {step === 0 ? ( -
-
-
{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')} - -
- ) : ( - - )} - - ) -} - -export default function CreateWallet({ startWallet }) { +export default function CreateWallet({ parentRoute, startWallet }) { const { t } = useTranslation() const serviceInfo = useServiceInfo() const navigate = useNavigate() @@ -354,33 +92,41 @@ export default function CreateWallet({ startWallet }) { const [createdWallet, setCreatedWallet] = useState(null) const createWallet = useCallback( - async (walletName, password) => { + async ({ walletName, password }) => { setAlert(null) try { - const res = await Api.postWalletCreate({ walletname: walletName, password }) + const res = await Api.postWalletCreate({}, { walletname: walletName, password }) const body = await (res.ok ? res.json() : Api.Helper.throwError(res)) - const { seedphrase, token, walletname: createdWalletName } = body - setCreatedWallet({ name: createdWalletName, seedphrase, password, token }) + const { seedphrase, token, walletname: createdWalletFileName } = body + setCreatedWallet({ walletFileName: createdWalletFileName, seedphrase, password, token }) } catch (e) { - setAlert({ variant: 'danger', message: e.message }) + const message = t('create_wallet.error_creating_failed', { + reason: e.message || 'Unknown reason', + }) + setAlert({ variant: 'danger', message }) } }, - [setAlert, setCreatedWallet] + [setAlert, setCreatedWallet, t] ) - const walletConfirmed = () => { - if (createdWallet.name && createdWallet.token) { - startWallet(createdWallet.name, createdWallet.token) + const walletConfirmed = useCallback(() => { + if (createdWallet?.walletFileName && createdWallet?.token) { + setAlert(null) + startWallet(createdWallet.walletFileName, createdWallet.token) navigate(routes.wallet) } else { - setAlert({ variant: 'danger', message: t('alert_confirmation_failed') }) + setAlert({ variant: 'danger', message: t('create_wallet.alert_confirmation_failed') }) } - } + }, [createdWallet, startWallet, navigate, setAlert, t]) - const isCreated = createdWallet?.name && createdWallet?.seedphrase && createdWallet?.password - const canCreate = !isCreated && !serviceInfo?.walletName + const isCreated = useMemo( + () => createdWallet?.walletFileName && createdWallet?.seedphrase && createdWallet?.password, + [createdWallet] + ) + const canCreate = useMemo(() => !isCreated && !serviceInfo?.walletName, [isCreated, serviceInfo]) + const [showBackupConfirmation, setShowBackupConfirmation] = useState(false) return (
@@ -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('', () => { Promise.resolve({ walletname: `${testWalletName}.jmdat`, token: 'ANY_TOKEN', - seedphrase: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + seedphrase: DUMMY_MNEMONIC_PHRASE.join(' '), }), }) @@ -125,7 +126,7 @@ describe('', () => { Promise.resolve({ walletname: `${testWalletName}.jmdat`, token: 'ANY_TOKEN', - seedphrase: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + seedphrase: DUMMY_MNEMONIC_PHRASE.join(' '), }), }) @@ -167,7 +168,7 @@ describe('', () => { Promise.resolve({ walletname: `${testWalletName}.jmdat`, token: 'ANY_TOKEN', - seedphrase: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + seedphrase: DUMMY_MNEMONIC_PHRASE.join(' '), }), }) diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx index 3eae3f86a..a9a5a383d 100644 --- a/src/components/Earn.jsx +++ b/src/components/Earn.jsx @@ -422,6 +422,7 @@ export default function Earn({ wallet }) { serviceInfo && !serviceInfo.coinjoinInProgress && !serviceInfo.makerRunning && + !serviceInfo.rescanning && !isWaitingMakerStart && !isWaitingMakerStop && !isLoading @@ -447,6 +448,8 @@ export default function Earn({ wallet }) { })} <> {!serviceInfo?.makerRunning && + !serviceInfo?.coinjoinInProgress && + !serviceInfo?.rescanning && !isWaitingMakerStart && !isWaitingMakerStop && (!isLoading && currentWalletInfo ? ( @@ -598,7 +601,7 @@ export default function Earn({ wallet }) { isValid={touched.minsize && !errors.minsize} isInvalid={touched.minsize && errors.minsize} min={0} - step={1000} + step={1_000} /> {errors.minsize} @@ -612,7 +615,13 @@ export default function Earn({ wallet }) { variant="dark" type="submit" className={styles['earn-btn']} - disabled={isLoading || isSubmitting || isWaitingMakerStart || isWaitingMakerStop} + disabled={ + isLoading || + isSubmitting || + serviceInfo?.rescanning === true || + isWaitingMakerStart || + isWaitingMakerStop + } >
{(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 && ( +
+
+
+ +
+
+ )} +
+ ) +} + +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 && ( +
+
+ + {isSubmitting && ( +
+

{t('create_wallet.hint_duration_text')}

+
+ )} + +
+ +
+
+ )} +
+ ) +} + +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 && ( +
+
+
+ )} +
+
+ ) +} + +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 && ( +
+
+
+ )} +
+ ) +} + +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) + )} +
+ {isSubmitting && ( +
+

{t('create_wallet.hint_duration_text')}

+
+ )} + +
+ )} +
+ ) +} + +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 }} is active. You need to lock it first. <3>Go back.", + "alert_rescan_in_progress": "Rescanning the timechain is currently in progress. Please wait until the process finishes and then try again. <1>Go back.", + "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()