Skip to content

Commit e6e699a

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

File tree

5 files changed

+451
-0
lines changed

5 files changed

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

packages/settings/src/settings-routes.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { SettingsFeatureWalletAddAccount } from './settings-feature-wallet-add-a
77
import { SettingsFeatureWalletCreate } from './settings-feature-wallet-create.tsx'
88
import { SettingsFeatureWalletDetails } from './settings-feature-wallet-details.tsx'
99
import { SettingsFeatureWalletGenerate } from './settings-feature-wallet-generate.tsx'
10+
import { SettingsFeatureWalletGenerateVanity } from './settings-feature-wallet-generate-vanity.tsx'
1011
import { SettingsFeatureWalletImport } from './settings-feature-wallet-import.tsx'
1112
import { SettingsFeatureWalletList } from './settings-feature-wallet-list.tsx'
1213
import { SettingsFeatureWalletUpdate } from './settings-feature-wallet-update.tsx'
@@ -22,6 +23,7 @@ export default function SettingsRoutes() {
2223
{ element: <SettingsFeatureWalletList />, index: true },
2324
{ element: <SettingsFeatureWalletCreate />, path: 'create' },
2425
{ element: <SettingsFeatureWalletGenerate />, path: 'generate' },
26+
{ element: <SettingsFeatureWalletGenerateVanity />, path: 'generate-vanity' },
2527
{ element: <SettingsFeatureWalletImport />, path: 'import' },
2628
{ element: <SettingsFeatureWalletDetails />, path: ':walletId' },
2729
{ element: <SettingsFeatureWalletAddAccount />, path: ':walletId/add' },

packages/settings/src/ui/settings-ui-wallet-create-options.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export function SettingsUiWalletCreateOptions() {
1616
title="Import an existing wallet"
1717
to="/settings/wallets/import"
1818
/>
19+
<SettingsUiWalletCreateLink
20+
description="Generate a new wallet with a custom prefix or suffix"
21+
title="Generate a vanity wallet"
22+
to="/settings/wallets/generate-vanity"
23+
/>
1924
<SettingsUiWalletCreateHeader icon="hardware" label="Hardware accounts" />
2025
<SettingsUiWalletCreateComingSoon
2126
description="Unruggable is the first Solana native hardware account."
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { zodResolver } from '@hookform/resolvers/zod'
2+
import { Button } from '@workspace/ui/components/button'
3+
import {
4+
Form,
5+
FormControl,
6+
FormDescription,
7+
FormField,
8+
FormItem,
9+
FormLabel,
10+
FormMessage,
11+
} from '@workspace/ui/components/form'
12+
import { Input } from '@workspace/ui/components/input'
13+
import { Switch } from '@workspace/ui/components/switch'
14+
import { useForm } from 'react-hook-form'
15+
import { z } from 'zod'
16+
17+
const schema = z
18+
.object({
19+
caseSensitive: z.boolean().default(true),
20+
prefix: z.string().max(4, 'Keep patterns to 4 characters max').optional().default(''),
21+
suffix: z.string().max(4, 'Keep patterns to 4 characters max').optional().default(''),
22+
})
23+
.superRefine((data, ctx) => {
24+
const prefix = data.prefix?.trim() ?? ''
25+
const suffix = data.suffix?.trim() ?? ''
26+
if (!prefix && !suffix) {
27+
ctx.addIssue({
28+
code: z.ZodIssueCode.custom,
29+
message: 'Enter a prefix or suffix',
30+
path: ['prefix'],
31+
})
32+
ctx.addIssue({
33+
code: z.ZodIssueCode.custom,
34+
message: 'Enter a prefix or suffix',
35+
path: ['suffix'],
36+
})
37+
}
38+
})
39+
40+
export type VanityWalletFormFields = z.input<typeof schema>
41+
42+
export function SettingsUiWalletFormGenerateVanity({
43+
disabled,
44+
submit,
45+
}: {
46+
disabled?: boolean
47+
submit: (input: VanityWalletFormFields) => Promise<void>
48+
}) {
49+
const form = useForm<VanityWalletFormFields>({
50+
defaultValues: {
51+
caseSensitive: true,
52+
prefix: '',
53+
suffix: '',
54+
},
55+
resolver: zodResolver(schema),
56+
})
57+
58+
return (
59+
<Form {...form}>
60+
<form className="flex flex-col gap-6" onSubmit={form.handleSubmit(submit)}>
61+
<div className="grid gap-4 sm:grid-cols-2">
62+
<FormField
63+
control={form.control}
64+
name="prefix"
65+
render={({ field }) => (
66+
<FormItem>
67+
<FormLabel>Prefix</FormLabel>
68+
<FormControl>
69+
<Input
70+
{...field}
71+
disabled={disabled}
72+
maxLength={4}
73+
placeholder="Start with..."
74+
value={field.value || ''}
75+
/>
76+
</FormControl>
77+
<FormDescription>Characters the address should start with (max 4)</FormDescription>
78+
<FormMessage />
79+
</FormItem>
80+
)}
81+
/>
82+
<FormField
83+
control={form.control}
84+
name="suffix"
85+
render={({ field }) => (
86+
<FormItem>
87+
<FormLabel>Suffix</FormLabel>
88+
<FormControl>
89+
<Input
90+
{...field}
91+
disabled={disabled}
92+
maxLength={4}
93+
placeholder="End with..."
94+
value={field.value || ''}
95+
/>
96+
</FormControl>
97+
<FormDescription>Characters the address should end with (max 4)</FormDescription>
98+
<FormMessage />
99+
</FormItem>
100+
)}
101+
/>
102+
</div>
103+
104+
<FormField
105+
control={form.control}
106+
name="caseSensitive"
107+
render={({ field }) => (
108+
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
109+
<div className="space-y-0.5">
110+
<FormLabel className="text-base">Case Sensitive</FormLabel>
111+
<FormDescription>Match exact case (slower)</FormDescription>
112+
</div>
113+
<FormControl>
114+
<Switch checked={field.value ?? true} disabled={disabled} onCheckedChange={field.onChange} />
115+
</FormControl>
116+
</FormItem>
117+
)}
118+
/>
119+
120+
<div className="flex justify-end">
121+
<Button disabled={disabled} size="lg" type="submit">
122+
{disabled ? 'Generating...' : 'Find Vanity Address'}
123+
</Button>
124+
</div>
125+
</form>
126+
</Form>
127+
)
128+
}

0 commit comments

Comments
 (0)