Skip to content

Commit af69e41

Browse files
committed
feat(settings): add vanity wallet generation
1 parent 10b7a65 commit af69e41

File tree

5 files changed

+504
-1
lines changed

5 files changed

+504
-1
lines changed
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { assertIsAddress } from '@solana/kit'
2+
import { useDbAccountCreate } from '@workspace/db-react/use-db-account-create'
3+
import { useDbWalletFindUnique } from '@workspace/db-react/use-db-wallet-find-unique'
4+
import { importKeyPairToPublicKeySecretKey } from '@workspace/keypair/import-key-pair-to-public-key-secret-key'
5+
import { Alert, AlertDescription, AlertTitle } from '@workspace/ui/components/alert'
6+
import { Button } from '@workspace/ui/components/button'
7+
import { UiCard } from '@workspace/ui/components/ui-card'
8+
import { UiError } from '@workspace/ui/components/ui-error'
9+
import { UiLoader } from '@workspace/ui/components/ui-loader'
10+
import { UiNotFound } from '@workspace/ui/components/ui-not-found'
11+
import { ellipsify } from '@workspace/ui/lib/ellipsify'
12+
import { handleCopyText } from '@workspace/ui/lib/handle-copy-text'
13+
import { toastError } from '@workspace/ui/lib/toast-error'
14+
import { toastSuccess } from '@workspace/ui/lib/toast-success'
15+
import { useEffect, useReducer, useRef, useState } from 'react'
16+
import { useNavigate, useParams } from 'react-router'
17+
18+
import {
19+
SettingsUiWalletFormGenerateVanity,
20+
type VanityWalletFormFields,
21+
} from './ui/settings-ui-wallet-form-generate-vanity.tsx'
22+
import { SettingsUiWalletItem } from './ui/settings-ui-wallet-item.tsx'
23+
24+
type VanityGeneratorState =
25+
| { attempts: number; error: string | null; result: null; status: 'idle' | 'pending' | 'error' }
26+
| { attempts: number; error: null; result: { address: string; secretKey: string }; status: 'success' }
27+
28+
type VanityGeneratorAction =
29+
| { type: 'start' }
30+
| { payload: number; type: 'progress' }
31+
| { payload: string; type: 'error' }
32+
| { payload: { address: string; attempts: number; secretKey: string }; type: 'success' }
33+
| { type: 'reset' }
34+
35+
const initialVanityGeneratorState: VanityGeneratorState = { attempts: 0, error: null, result: null, status: 'idle' }
36+
37+
function vanityGeneratorReducer(state: VanityGeneratorState, action: VanityGeneratorAction): VanityGeneratorState {
38+
switch (action.type) {
39+
case 'start':
40+
return { attempts: 0, error: null, result: null, status: 'pending' }
41+
case 'progress':
42+
return { ...state, attempts: action.payload }
43+
case 'success':
44+
return {
45+
attempts: action.payload.attempts,
46+
error: null,
47+
result: { address: action.payload.address, secretKey: action.payload.secretKey },
48+
status: 'success',
49+
}
50+
case 'error':
51+
return { attempts: 0, error: action.payload, result: null, status: 'error' }
52+
case 'reset':
53+
return initialVanityGeneratorState
54+
default:
55+
return state
56+
}
57+
}
58+
59+
function useVanityGenerator() {
60+
const workerRef = useRef<Worker | null>(null)
61+
const [state, dispatch] = useReducer(vanityGeneratorReducer, initialVanityGeneratorState)
62+
63+
useEffect(() => {
64+
return () => {
65+
workerRef.current?.terminate()
66+
workerRef.current = null
67+
}
68+
}, [])
69+
70+
const start = async (input: VanityWalletFormFields) => {
71+
const sanitizedPrefix = input.prefix?.trim().slice(0, 4) ?? ''
72+
const sanitizedSuffix = input.suffix?.trim().slice(0, 4) ?? ''
73+
const payload = {
74+
caseSensitive: input.caseSensitive ?? true,
75+
prefix: sanitizedPrefix,
76+
suffix: sanitizedSuffix,
77+
}
78+
79+
dispatch({ type: 'start' })
80+
workerRef.current?.terminate()
81+
const worker = new Worker(new URL('./workers/vanity-worker.ts', import.meta.url), { type: 'module' })
82+
workerRef.current = worker
83+
84+
worker.onmessage = (event) => {
85+
const { type, payload } = event.data ?? {}
86+
if (type === 'progress' && typeof payload === 'number') {
87+
dispatch({ payload, type: 'progress' })
88+
return
89+
}
90+
if (type === 'found' && payload) {
91+
dispatch({
92+
payload: {
93+
address: payload.address,
94+
attempts: typeof payload.attempts === 'number' ? payload.attempts : 0,
95+
secretKey: payload.secretKey,
96+
},
97+
type: 'success',
98+
})
99+
workerRef.current?.terminate()
100+
workerRef.current = null
101+
return
102+
}
103+
if (type === 'error') {
104+
dispatch({
105+
payload: typeof payload === 'string' ? payload : 'Failed to generate vanity address',
106+
type: 'error',
107+
})
108+
workerRef.current?.terminate()
109+
workerRef.current = null
110+
}
111+
}
112+
113+
worker.onerror = (event) => {
114+
dispatch({ payload: event.message ?? 'Worker error', type: 'error' })
115+
workerRef.current?.terminate()
116+
workerRef.current = null
117+
}
118+
119+
worker.postMessage(payload)
120+
}
121+
122+
const cancel = () => {
123+
workerRef.current?.terminate()
124+
workerRef.current = null
125+
dispatch({ type: 'reset' })
126+
}
127+
128+
return { cancel, start, state }
129+
}
130+
131+
export function SettingsFeatureWalletAddAccountGenerateVanity() {
132+
const navigate = useNavigate()
133+
const { walletId } = useParams() as { walletId: string }
134+
const { data: wallet, error: walletError, isError, isLoading } = useDbWalletFindUnique({ id: walletId })
135+
const createAccountMutation = useDbAccountCreate()
136+
const { cancel, start, state } = useVanityGenerator()
137+
const [showSecret, setShowSecret] = useState(false)
138+
139+
const handleGenerate = async (input: VanityWalletFormFields) => {
140+
setShowSecret(false)
141+
await start(input)
142+
}
143+
144+
const handleCancel = () => {
145+
setShowSecret(false)
146+
cancel()
147+
}
148+
149+
const isPending = state.status === 'pending'
150+
const { attempts: count, error: generationError, result } = state
151+
152+
if (isLoading) {
153+
return <UiLoader />
154+
}
155+
156+
if (isError) {
157+
return <UiError message={walletError} />
158+
}
159+
160+
if (!wallet) {
161+
return <UiNotFound />
162+
}
163+
164+
const handleCopyAndImport = async () => {
165+
if (!result) {
166+
return
167+
}
168+
169+
try {
170+
await handleCopyText(result.secretKey)
171+
const { publicKey, secretKey } = await importKeyPairToPublicKeySecretKey(result.secretKey, true)
172+
assertIsAddress(publicKey)
173+
await createAccountMutation.mutateAsync({
174+
input: {
175+
name: ellipsify(publicKey),
176+
publicKey,
177+
secretKey,
178+
type: 'Imported',
179+
walletId: wallet.id,
180+
},
181+
})
182+
toastSuccess('Vanity account copied & imported')
183+
await navigate(`/settings/wallets/${wallet.id}`)
184+
} catch (copyError) {
185+
toastError(copyError instanceof Error ? copyError.message : 'Failed to import vanity account')
186+
}
187+
}
188+
189+
return (
190+
<UiCard
191+
backButtonTo={`/settings/wallets/${wallet.id}/add`}
192+
description="Generate a vanity account for this wallet"
193+
title={<SettingsUiWalletItem item={wallet} />}
194+
>
195+
<div className="grid gap-6">
196+
<Alert>
197+
<AlertTitle>Warning</AlertTitle>
198+
<AlertDescription>
199+
Vanity searches are limited to short patterns (max 4 characters) and will stop after 20,000,000 attempts.
200+
<br />
201+
Use concise prefixes or suffixes for the fastest results.
202+
</AlertDescription>
203+
</Alert>
204+
205+
{!result ? (
206+
<div className="space-y-6">
207+
<SettingsUiWalletFormGenerateVanity disabled={isPending} submit={handleGenerate} />
208+
209+
{isPending && (
210+
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border bg-muted/50 p-6 text-center animate-in fade-in">
211+
<div className="text-3xl font-mono font-bold">{count.toLocaleString()}</div>
212+
<p className="text-sm text-muted-foreground">Wallets checked</p>
213+
<Button onClick={handleCancel} variant="destructive">
214+
Stop Generation
215+
</Button>
216+
</div>
217+
)}
218+
</div>
219+
) : (
220+
<div className="grid gap-4 p-4 border rounded-lg animate-in fade-in slide-in-from-bottom-4">
221+
<div className="grid gap-2">
222+
<h3 className="font-medium">Found Address!</h3>
223+
<div className="font-mono text-sm break-all bg-muted p-3 rounded border">{result.address}</div>
224+
{count > 0 ? (
225+
<p className="text-xs text-muted-foreground">Found after checking {count.toLocaleString()} wallets.</p>
226+
) : null}
227+
</div>
228+
229+
<div className="grid gap-2">
230+
<div className="flex items-center justify-between">
231+
<h3 className="font-medium">Secret Key JSON</h3>
232+
<Button onClick={() => setShowSecret(!showSecret)} size="sm" variant="ghost">
233+
{showSecret ? 'Hide' : 'Show'}
234+
</Button>
235+
</div>
236+
{showSecret && (
237+
<div className="font-mono text-xs break-all bg-muted p-3 rounded border text-muted-foreground">
238+
{result.secretKey}
239+
</div>
240+
)}
241+
</div>
242+
243+
<div className="flex justify-end gap-2 pt-4">
244+
<Button onClick={handleCancel} variant="outline">
245+
Try Again
246+
</Button>
247+
<Button disabled={createAccountMutation.isPending} onClick={handleCopyAndImport}>
248+
{createAccountMutation.isPending ? 'Importing...' : 'Copy & Import'}
249+
</Button>
250+
</div>
251+
</div>
252+
)}
253+
254+
{generationError && (
255+
<Alert variant="destructive">
256+
<AlertTitle>Error</AlertTitle>
257+
<AlertDescription>{generationError}</AlertDescription>
258+
</Alert>
259+
)}
260+
</div>
261+
</UiCard>
262+
)
263+
}

packages/settings/src/settings-feature-wallet-add-account.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,21 @@ import { Button } from '@workspace/ui/components/button'
88
import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@workspace/ui/components/item'
99
import { UiCard } from '@workspace/ui/components/ui-card'
1010
import { UiError } from '@workspace/ui/components/ui-error'
11+
import { UiIcon } from '@workspace/ui/components/ui-icon'
1112
import { UiLoader } from '@workspace/ui/components/ui-loader'
1213
import { UiNotFound } from '@workspace/ui/components/ui-not-found'
1314
import { ellipsify } from '@workspace/ui/lib/ellipsify'
1415
import { toastError } from '@workspace/ui/lib/toast-error'
1516
import { toastSuccess } from '@workspace/ui/lib/toast-success'
16-
import { useParams } from 'react-router'
17+
import { useNavigate, useParams } from 'react-router'
1718

1819
import { useDeriveAndCreateAccount } from './data-access/use-derive-and-create-account.tsx'
1920
import { AccountUiIcon } from './ui/account-ui-icon.tsx'
2021
import { SettingsUiWalletItem } from './ui/settings-ui-wallet-item.tsx'
2122

2223
export function SettingsFeatureWalletAddAccount() {
2324
const { walletId } = useParams() as { walletId: string }
25+
const navigate = useNavigate()
2426
const { data: item, error, isError, isLoading } = useDbWalletFindUnique({ id: walletId })
2527
const deriveAccount = useDeriveAndCreateAccount()
2628
const createAccountMutation = useDbAccountCreate()
@@ -100,6 +102,25 @@ export function SettingsFeatureWalletAddAccount() {
100102
</ItemActions>
101103
</Item>
102104

105+
<Item variant="outline">
106+
<ItemMedia variant="icon">
107+
<UiIcon className="size-4" icon="search" />
108+
</ItemMedia>
109+
<ItemContent>
110+
<ItemTitle>Generate a vanity account</ItemTitle>
111+
<ItemDescription>Find a prefix or suffix match for this wallet</ItemDescription>
112+
</ItemContent>
113+
<ItemActions>
114+
<Button
115+
onClick={() => navigate(`/settings/wallets/${item.id}/add/generate-vanity`)}
116+
size="sm"
117+
variant="outline"
118+
>
119+
Generate
120+
</Button>
121+
</ItemActions>
122+
</Item>
123+
103124
<Item variant="outline">
104125
<ItemMedia variant="icon">
105126
<AccountUiIcon type="Imported" />

packages/settings/src/settings-routes.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { SettingsFeatureNetworkCreate } from './settings-feature-network-create.
44
import { SettingsFeatureNetworkList } from './settings-feature-network-list.tsx'
55
import { SettingsFeatureNetworkUpdate } from './settings-feature-network-update.tsx'
66
import { SettingsFeatureWalletAddAccount } from './settings-feature-wallet-add-account.tsx'
7+
import { SettingsFeatureWalletAddAccountGenerateVanity } from './settings-feature-wallet-add-account-generate-vanity.tsx'
78
import { SettingsFeatureWalletCreate } from './settings-feature-wallet-create.tsx'
89
import { SettingsFeatureWalletDetails } from './settings-feature-wallet-details.tsx'
910
import { SettingsFeatureWalletGenerate } from './settings-feature-wallet-generate.tsx'
@@ -25,6 +26,7 @@ export default function SettingsRoutes() {
2526
{ element: <SettingsFeatureWalletImport />, path: 'import' },
2627
{ element: <SettingsFeatureWalletDetails />, path: ':walletId' },
2728
{ element: <SettingsFeatureWalletAddAccount />, path: ':walletId/add' },
29+
{ element: <SettingsFeatureWalletAddAccountGenerateVanity />, path: ':walletId/add/generate-vanity' },
2830
{ element: <SettingsFeatureWalletUpdate />, path: ':walletId/edit' },
2931
],
3032
path: 'wallets',

0 commit comments

Comments
 (0)