Skip to content

Commit

Permalink
feat: import wallet (#621)
Browse files Browse the repository at this point in the history
* chore(regtest): do not build tor locally in regtest env

* feat(import): add rescanning param to session request

* feat(api): add rescanblockchain request to JmWalletApi

* feat: add import wallet button to wallets page

* refactor: externalize component PreventLeavingPageByMistake

* refactor: externalize component WalletCreationForm

* wip: use WalletCreationForm in ImportWallet

* refactor: externalize SeedWordInput and rename to MnemonicWordInput

* feat(api): add wallet recover request to JmWalletApi

* build(deps): bump caniuse-lite to v1.0.30001511

* wip(import): start wallet after successful import

* refactor: externalize component WalletCreationConfirmation

* wip(import): use WalletCreationConfirmation view in component WalletImport

* dev(regtest): add funds to dummy wallet during initialization of local setup

* wip(import): update gaplimit before rescanning chain

* wip(import): prevent import if rescan is in progress

* wip(import): ability to customize gaplimit and blockheight

* wip(import): show duration hint when importing wallet

* wip(import): hide sensitive info while importing wallet

* wip(import): reset gaplimit to original value after importing

* wip(import): add description for blockheight and gaplimit

* wip(import): add cancel button to WalletCreationForm

* refactor: externalize component MnemonicPhraseInput

* wip(import): show rescanning indicator on main wallet view

* wip(import): show rescanning indicator in navbar

* wip(import): disable sending when rescanning is in progress

* wip(import): disable navbar/earn/jam when rescanning is in progress

* Update src/i18n/locales/en/translation.json

Co-authored-by: openoms <43343391+openoms@users.noreply.github.com>

* fix(import): disable viewing jars on main wallet if rescanning is active

* wip(import): prevent creating new address when rescanning

* fix(import): reload wallet info after rescanning finishes

* refactor: move auto reloading code from WalletContext to WalletInfoAutoReload

* refactor: remove unused method reloadDisplay

* wip(import): reload recursively till balance is found

* wip(import): expand import options by default

* ui: add 'dev' badge to buttons only visible in dev env

---------

Co-authored-by: openoms <43343391+openoms@users.noreply.github.com>
  • Loading branch information
theborakompanioni and openoms committed Sep 8, 2023
1 parent 4d0479e commit 028f321
Show file tree
Hide file tree
Showing 45 changed files with 1,866 additions and 530 deletions.
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion docker/regtest/init-setup.sh
Expand Up @@ -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:-}
Expand Down
16 changes: 10 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions public/sprite.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 5 additions & 3 deletions 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<AccordionProps>) => {
const Accordion = ({ title, defaultOpen = false, disabled = false, children }: PropsWithChildren<AccordionProps>) => {
const settings = useSettings()
const [isOpen, setIsOpen] = useState(defaultOpen)

Expand All @@ -18,6 +19,7 @@ const Accordion = ({ title, defaultOpen = false, children }: PropsWithChildren<A
variant={settings.theme}
className="d-flex align-items-center bg-transparent border-0 w-100 px-0 py-2"
onClick={() => setIsOpen((current) => !current)}
disabled={disabled}
>
{title}
<Sprite symbol={`caret-${isOpen ? 'up' : 'down'}`} className="ms-1" width="20" height="20" />
Expand Down
163 changes: 157 additions & 6 deletions 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 {
Expand All @@ -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'
Expand All @@ -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'

Expand All @@ -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 })
},
Expand All @@ -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<WalletInfo>((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(
<Route
Expand Down Expand Up @@ -82,7 +117,11 @@ export default function App() {
* to the backend is down, e.g. "create-wallet" shows the seed quiz and it is important
* that it stays visible in case the backend becomes unavailable.
*/}
<Route id="create-wallet" path={routes.createWallet} element={<CreateWallet startWallet={startWallet} />} />
<Route
id="create-wallet"
path={routes.createWallet}
element={<CreateWallet parentRoute={'home'} startWallet={startWallet} />}
/>

{sessionConnectionError ? (
<Route
Expand All @@ -104,13 +143,19 @@ export default function App() {
path={routes.home}
element={<Wallets currentWallet={currentWallet} startWallet={startWallet} stopWallet={stopWallet} />}
/>
<Route
id="import-wallet"
path={routes.importWallet}
element={<ImportWallet parentRoute={'home'} startWallet={startWallet} />}
/>
{currentWallet && (
<>
<Route id="wallet" path={routes.wallet} element={<MainWalletView wallet={currentWallet} />} />
<Route id="jam" path={routes.jam} element={<Jam wallet={currentWallet} />} />
<Route id="send" path={routes.send} element={<Send wallet={currentWallet} />} />
<Route id="earn" path={routes.earn} element={<Earn wallet={currentWallet} />} />
<Route id="receive" path={routes.receive} element={<Receive wallet={currentWallet} />} />
<Route id="rescan" path={routes.rescanChain} element={<RescanChain wallet={currentWallet} />} />
<Route
id="settings"
path={routes.settings}
Expand Down Expand Up @@ -144,5 +189,111 @@ export default function App() {
)
}

return <RouterProvider router={router} />
return (
<>
<div
className={classNames('app', {
'jam-reload-wallet-info-in-progress': isReloadingWalletInfo,
'jm-coinjoin-in-progress': serviceInfo?.coinjoinInProgress === true,
'jm-rescan-in-progress': serviceInfo?.rescanning === true,
'jm-maker-running': serviceInfo?.makerRunning === true,
})}
>
<RouterProvider router={router} />
</div>
<WalletInfoAutoReload currentWallet={currentWallet} reloadWalletInfo={reloadWalletInfo} />
</>
)
}

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<WalletInfo>
}

/**
* 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 <></>
}

0 comments on commit 028f321

Please sign in to comment.