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 <[email protected]>

* 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 <[email protected]>
  • Loading branch information
theborakompanioni and openoms authored 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.
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 <></>
}
Loading

0 comments on commit 028f321

Please sign in to comment.