add recovery phrase backup flow (#719)

Co-authored-by: Felicio <felicio@users.noreply.github.com>
This commit is contained in:
Jakub
2025-06-26 17:56:35 +09:00
committed by GitHub
co-authored by Felicio
parent 57e62774b4
commit fbec37a376
12 changed files with 389 additions and 10 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@status-im/wallet': patch
'wallet': patch
---
add wallet recovery flow
+1 -1
View File
@@ -41,7 +41,7 @@
"@tanstack/react-router": "^1.109.2",
"@trpc/client": "10.45.2",
"@trpc/server": "10.45.2",
"@trustwallet/wallet-core": "^4.2.10",
"@trustwallet/wallet-core": "^4.3.6",
"@wxt-dev/storage": "^1.1.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
@@ -0,0 +1,84 @@
'use client'
import { useState } from 'react'
import { useToast } from '@status-im/components'
import { NegativeStateIcon } from '@status-im/icons/20'
import {
RecoveryPhraseDialog,
SignTransactionDialog,
} from '@status-im/wallet/components'
import { useRecoveryPhraseBackup } from '@/hooks/use-recovery-phrase-backup'
import { apiClient } from '@/providers/api-client'
import { useWallet } from '@/providers/wallet-context'
export function RecoveryPhraseBackup() {
const { currentWallet } = useWallet()
const toast = useToast()
const [mnemonic, setMnemonic] = useState<string | null>(null)
const {
isRecoveryPhraseBackedUp,
showRecoveryDialog,
setShowRecoveryDialog,
markAsBackedUp,
} = useRecoveryPhraseBackup()
const onPasswordConfirm = async (password: string) => {
if (!currentWallet?.id) {
toast.negative('No wallet selected', { duration: 3000 })
return
}
try {
const { mnemonic } = await apiClient.wallet.get.query({
password: password,
walletId: currentWallet?.id,
})
setMnemonic(mnemonic)
setShowRecoveryDialog(true)
} catch (error) {
console.error(error)
toast.negative('Invalid password', {
duration: 3000,
})
}
}
const onRecoveryClose = () => {
setShowRecoveryDialog(false)
}
const onComplete = async () => {
await markAsBackedUp()
toast.positive(
'Your recovery phrase has been deleted from the wallet interface.',
{ duration: 3000 },
)
}
if (isRecoveryPhraseBackedUp) {
return null
}
return (
<>
<SignTransactionDialog
onConfirm={onPasswordConfirm}
description="To backup recovery phrase"
>
<button className="flex gap-1 rounded-20 border border-solid border-danger-50/20 bg-danger-50/10 px-2 py-[3px] text-13 text-danger-50">
<NegativeStateIcon className="text-danger-50/20" /> Backup recovery
phrase
</button>
</SignTransactionDialog>
<RecoveryPhraseDialog
isOpen={showRecoveryDialog}
onClose={onRecoveryClose}
onComplete={onComplete}
mnemonic={mnemonic}
/>
</>
)
}
@@ -1,6 +1,8 @@
import { Avatar } from '@status-im/components'
import { Balance, StickyHeaderContainer } from '@status-im/wallet/components'
import { RecoveryPhraseBackup } from '../components/recovery-phrase-backup'
type Props = {
list: React.ReactNode
detail?: React.ReactNode
@@ -139,6 +141,10 @@ const SplittedLayout = (props: Props) => {
</div>
<ActionButtons {...actionsButtonsData} />
<div className="my-4 flex">
<RecoveryPhraseBackup />
</div>
</div>
{list}
@@ -149,9 +155,7 @@ const SplittedLayout = (props: Props) => {
</div>
<div className="relative hidden basis-1/2 flex-col bg-white-100 2xl:flex">
<div className="relative z-20 flex h-full items-center justify-center">
{detail}
</div>
<div className="relative z-20 size-full">{detail}</div>
<div
className="absolute z-10 size-full"
+1 -1
View File
@@ -66,7 +66,7 @@ const apiRouter = router({
.mutation(async ({ input, ctx }) => {
const { walletCore, keyStore } = ctx
const wallet = walletCore.HDWallet.create(256, input.password)
const wallet = walletCore.HDWallet.create(128, input.password)
const mnemonic = wallet.mnemonic()
const name = input.name
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react'
export const useRecoveryPhraseBackup = () => {
const [isRecoveryPhraseBackedUp, setIsRecoveryPhraseBackedUp] =
useState<boolean>(true)
const [showRecoveryDialog, setShowRecoveryDialog] = useState<boolean>(false)
const markAsBackedUp = async () => {
setIsRecoveryPhraseBackedUp(true)
await chrome.storage.local.set({ recoveryPhraseBackedUp: true })
}
useEffect(() => {
async function checkSettings() {
const { recoveryPhraseBackedUp } = await chrome.storage.local.get([
'recoveryPhraseBackedUp',
])
if (recoveryPhraseBackedUp) {
setIsRecoveryPhraseBackedUp(true)
return
}
setIsRecoveryPhraseBackedUp(false)
}
checkSettings()
}, [])
return {
isRecoveryPhraseBackedUp,
markAsBackedUp,
showRecoveryDialog,
setShowRecoveryDialog,
}
}
+3
View File
@@ -24,9 +24,11 @@ export { Navbar } from './nav-bar'
export { NetworkBreakdown } from './network-breakdown'
export { NetworkExplorerLogo } from './network-explorer-logo'
export { NetworkLogo } from './network-logo'
export { PasswordInput } from './password-input'
export { PercentageChange } from './percentage-change'
export { PinExtension } from './pin-extension'
export { ReceiveCryptoDrawer } from './receive-crypto-drawer'
export { RecoveryPhraseDialog } from './recovery-phrase-dialog'
export { RecoveryPhraseTextarea } from './recovery-phrase-textarea'
export { SettingsPopover } from './settings-popover'
export {
@@ -34,6 +36,7 @@ export {
shortenAddress,
type ShortenAddressProps,
} from './shorten-address'
export { SignTransactionDialog } from './sign-transaction'
export { Slider, type SliderProps } from './slider'
export { StickyHeaderContainer } from './sticky-header-container'
export { getTabLinkClassName, TabLink } from './tab-link'
@@ -0,0 +1,69 @@
import * as Dialog from '@radix-ui/react-dialog'
import { CloseIcon } from '@status-im/icons/20'
import { RecoveryPhraseBox } from './recovery-phrase-box'
import { RecoveryPhraseConfirmation } from './recovery-phrase-confirmation'
type RecoveryPhraseDialogProps = {
isOpen: boolean
onClose: () => void
onComplete: () => void
mnemonic: string | null
}
export const RecoveryPhraseDialog = ({
isOpen,
onClose,
onComplete,
mnemonic,
}: RecoveryPhraseDialogProps) => {
if (!mnemonic) {
return null
}
return (
<Dialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-20 bg-blur-neutral-100/70 opacity-[30%] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-[0%] data-[state=open]:fade-in-[0%]" />
<Dialog.Content
data-customisation="blue"
className="fixed left-1/2 top-1/2 z-50 flex max-h-[calc(100vh-32px)] w-full max-w-[448px] -translate-x-1/2 -translate-y-1/2 flex-col overflow-y-auto rounded-20 bg-white-100 p-4 pb-0 shadow-1 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-[0%] data-[state=open]:fade-in-[0%] data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]"
>
<Dialog.Close className="absolute right-3 top-3 z-40 rounded-10 border border-neutral-30 p-1.5 transition-colors active:border-neutral-50 hover:border-neutral-40 disabled:pointer-events-none">
<span className="sr-only">Close</span>
<CloseIcon className="text-neutral-100" />
</Dialog.Close>
<div className="flex flex-col gap-4">
<Dialog.Title className="text-27 font-600">
Backup recovery phrase
</Dialog.Title>
<RecoveryPhraseBox mnemonic={mnemonic} />
<div className="flex flex-col gap-4 text-15 text-neutral-100">
<p>
<strong>What is a Recovery Phrase?</strong>
<br />A 12-word phrase that gives full access to your funds and
is the only way to recover them.
</p>
<p>
<strong>How do I save my Recovery Phrase?</strong>
<br />
Write it down and store it securely in a safe place.
</p>
<p>
<strong>Should I share my Recovery Phrase?</strong>
<br />
Never share your Secret Recovery Phrase. If someone asks for it,
they&apos;re likely trying to scam you.
</p>
</div>
<RecoveryPhraseConfirmation onConfirm={onComplete} />
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
@@ -0,0 +1,27 @@
import { Counter } from '@status-im/components'
import { cx } from 'class-variance-authority'
type Props = {
mnemonic: string
}
function RecoveryPhraseBox({ mnemonic }: Props) {
return (
<div className="mt-10 grid grid-cols-2 gap-x-4 rounded-16 bg-neutral-2.5 px-3 py-2">
{mnemonic.split(' ').map((word, i) => (
<div
key={i}
className={cx(
'flex items-center gap-2 border-dashed py-2',
i % 2 === 0 ? 'border-r border-neutral-80/10' : '',
)}
>
<Counter value={i + 1} variant="grey" />
<span className="font-mono">{word}</span>
</div>
))}
</div>
)
}
export { RecoveryPhraseBox }
@@ -0,0 +1,44 @@
'use client'
import { useState } from 'react'
import { Button, Checkbox } from '@status-im/components'
type Props = {
onConfirm: () => void
}
function RecoveryPhraseConfirmation({ onConfirm }: Props) {
const [isChecked, setIsChecked] = useState(false)
const handleConfirm = () => {
onConfirm()
}
return (
<div className="sticky bottom-0 mt-auto flex flex-col gap-4 border-t border-neutral-10 bg-white-100 py-4">
<div className="flex items-center gap-2 text-15 text-neutral-100">
<label
htmlFor="recovery-phrase-backed"
className="flex cursor-pointer select-none items-start gap-2 text-15"
>
<div className="mt-[3px]">
<Checkbox
id="recovery-phrase-backed"
checked={isChecked}
onCheckedChange={() => setIsChecked(!isChecked)}
/>
</div>
I've backed up my recovery phrase and understand that clicking Finish
Backup will completely and permanently delete it from this wallet
interface.
</label>
</div>
<Button variant="primary" disabled={!isChecked} onClick={handleConfirm}>
Finish backup
</Button>
</div>
)
}
export { RecoveryPhraseConfirmation }
@@ -0,0 +1,106 @@
'use client'
import { useState, useTransition } from 'react'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import { Button } from '@status-im/components'
import { CloseIcon, LoadingIcon } from '@status-im/icons/20'
import { FormProvider, useForm } from 'react-hook-form'
import { PasswordInput } from '../password-input'
type Props = {
onConfirm: (password: string) => void
children: React.ReactNode
description?: string
}
export function SignTransactionDialog({
children,
onConfirm,
description = 'To sign transaction',
}: Props) {
const [isOpen, setIsOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const handleConfirm = async (data: { password: string }) => {
try {
startTransition(async () => {
onConfirm(data.password)
setIsOpen(false)
})
} catch (error) {
console.error(error)
throw error
}
}
return (
<AlertDialog.Root open={isOpen} onOpenChange={setIsOpen}>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-blur-neutral-100/70 opacity-[30%] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-[0%] data-[state=open]:fade-in-[0%]" />
<AlertDialog.Trigger asChild>{children}</AlertDialog.Trigger>
<AlertDialog.Content className="fixed left-1/2 top-1/2 z-50 flex min-h-[250px] w-full max-w-[440px] flex-1 -translate-x-1/2 -translate-y-1/2 flex-col rounded-20 bg-white-100 p-4 shadow-1 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-[0%] data-[state=open]:fade-in-[0%] data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]">
<AlertDialog.Cancel className="absolute right-3 top-3 z-40 rounded-10 border border-neutral-30 p-1.5 transition-colors active:border-neutral-50 hover:border-neutral-40 disabled:pointer-events-none">
<span className="sr-only">Close</span>
<CloseIcon className="text-neutral-100" />
</AlertDialog.Cancel>
<div className="flex flex-col gap-1 pb-4">
<AlertDialog.Title className="text-27 font-600 text-neutral-100">
Enter password
</AlertDialog.Title>
<AlertDialog.Description className="text-15 text-neutral-50">
{description}
</AlertDialog.Description>
</div>
<SignTransactionForm onConfirm={handleConfirm} loading={isPending} />
</AlertDialog.Content>
</AlertDialog.Root>
)
}
type SignTransactionFormProps = {
onConfirm: (data: { password: string }) => Promise<void>
loading: boolean
}
function SignTransactionForm({ onConfirm, loading }: SignTransactionFormProps) {
const form = useForm({
mode: 'onChange',
defaultValues: {
password: '',
},
})
const handleSubmit = async (data: { password: string }) => {
await onConfirm(data)
}
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="flex flex-1 flex-col"
>
<PasswordInput
name="password"
label="Password"
placeholder="Type password"
/>
<div className="mt-auto flex flex-col">
<Button
type="submit"
variant="primary"
disabled={!form.watch('password') || loading}
>
{loading ? (
<LoadingIcon className="animate-spin text-white-100" />
) : (
'Confirm'
)}
</Button>
</div>
</form>
</FormProvider>
)
}
+5 -5
View File
@@ -886,8 +886,8 @@ importers:
specifier: 10.45.2
version: 10.45.2
'@trustwallet/wallet-core':
specifier: ^4.2.10
version: 4.3.3
specifier: ^4.3.6
version: 4.3.6
'@wxt-dev/storage':
specifier: ^1.1.0
version: 1.1.1
@@ -7764,8 +7764,8 @@ packages:
'@trpc/server@10.45.2':
resolution: {integrity: sha512-wOrSThNNE4HUnuhJG6PfDRp4L2009KDVxsd+2VYH8ro6o/7/jwYZ8Uu5j+VaW+mOmc8EHerHzGcdbGNQSAUPgg==}
'@trustwallet/wallet-core@4.3.3':
resolution: {integrity: sha512-42FXVM8NVcqvYSxs6uf1sWCd02u3aNUY26X5jwOWrGy9F1WBz89bkhbb4Kion0i3OxS8ImEHcNhsVvVt8bd2fA==}
'@trustwallet/wallet-core@4.3.6':
resolution: {integrity: sha512-X+n2CzDhIfUtnQtqqM3Su6XmBdzUijMu8uQEnA9yQWfOv7d33LfaQ9vNbzvpFk4I52K7n6AWLaylM1unuIuTJQ==}
'@tryghost/content-api@1.11.22':
resolution: {integrity: sha512-sssy/4kV8P03GWhw63HTNTZao5OMamEjI3NjzGoCRK4B5U4V9yjF/WuMDSdhMWXLtx0rIcK5uFP8tgiArM86UA==}
@@ -26930,7 +26930,7 @@ snapshots:
'@trpc/server@10.45.2': {}
'@trustwallet/wallet-core@4.3.3':
'@trustwallet/wallet-core@4.3.6':
dependencies:
protobufjs: 7.2.5