Skip to content

Commit 69e4304

Browse files
committed
feat: unlock signing request approvals
Add a request-local unlock flow before signing approvals so locked password and PIN wallets prompt for credentials before resolving a signing request. Wire the shared approval helper into sign message, sign transaction, sign and send transaction, and sign in request screens while leaving connect requests unchanged.
1 parent 3dbba25 commit 69e4304

8 files changed

Lines changed: 275 additions & 8 deletions

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/feature-request/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"dependencies": {
33
"@solana/wallet-standard-features": "catalog:",
4+
"@tanstack/react-query": "catalog:",
45
"@workspace/background": "workspace:*",
56
"@workspace/ui": "workspace:*",
67
"react-router": "catalog:"
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { useMutation } from '@tanstack/react-query'
2+
import { getVaultRuntimeService } from '@workspace/background/services/vault'
3+
import { toastError } from '@workspace/ui/lib/toast-error'
4+
import type { SyntheticEvent } from 'react'
5+
import { useRef, useState } from 'react'
6+
7+
export type RequestUnlockMode = 'password' | 'pin' | 'unsecured'
8+
9+
export type RequestSignApproval = {
10+
actions: {
11+
cancelUnlock(): void
12+
changeCredential(value: string): void
13+
changeOpen(open: boolean): void
14+
submitUnlock(event: SyntheticEvent<HTMLFormElement>): Promise<void>
15+
}
16+
approve(action: () => Promise<void>): Promise<void>
17+
state: {
18+
credential: string
19+
error: string | null
20+
isApproving: boolean
21+
isBusy: boolean
22+
isChecking: boolean
23+
isOpen: boolean
24+
isUnlocking: boolean
25+
mode: RequestUnlockMode
26+
}
27+
}
28+
29+
export function useRequestSignApproval(): RequestSignApproval {
30+
const pendingApprovalRef = useRef<(() => Promise<void>) | null>(null)
31+
const [credential, setCredential] = useState('')
32+
const [isOpen, setIsOpen] = useState(false)
33+
const [mode, setMode] = useState<RequestUnlockMode>('password')
34+
const approvalActionMutation = useMutation({
35+
mutationFn: async (action: () => Promise<void>) => await runApproval(action),
36+
})
37+
const approveMutation = useMutation({
38+
mutationFn: async (action: () => Promise<void>) => {
39+
const vault = getVaultRuntimeService()
40+
const isUnlocked = await vault.isActiveWalletUnlocked()
41+
if (isUnlocked) {
42+
return { action, type: 'approve' as const }
43+
}
44+
45+
const nextMode = await vault.activeWalletProtectionMode()
46+
if (nextMode === 'unsecured') {
47+
await vault.unlockActiveWallet({ credential: '' })
48+
return { action, type: 'approve' as const }
49+
}
50+
51+
return { action, mode: nextMode, type: 'unlock' as const }
52+
},
53+
onError: (caught) => toastError(caught instanceof Error ? caught.message : `${caught}`),
54+
})
55+
const unlockMutation = useMutation({
56+
mutationFn: async (nextCredential: string) =>
57+
await getVaultRuntimeService().unlockActiveWallet({ credential: nextCredential }),
58+
})
59+
const error = unlockMutation.error
60+
? unlockMutation.error instanceof Error
61+
? unlockMutation.error.message
62+
: `${unlockMutation.error}`
63+
: null
64+
const isApproving = approvalActionMutation.isPending
65+
const isChecking = approveMutation.isPending
66+
const isUnlocking = unlockMutation.isPending
67+
const isBusy = isApproving || isChecking || isUnlocking
68+
69+
async function approve(action: () => Promise<void>) {
70+
if (isBusy) {
71+
return
72+
}
73+
74+
approvalActionMutation.reset()
75+
approveMutation.reset()
76+
unlockMutation.reset()
77+
const result = await approveMutation.mutateAsync(action).catch(() => null)
78+
if (!result) {
79+
return
80+
}
81+
82+
if (result.type === 'approve') {
83+
await approvalActionMutation.mutateAsync(result.action).catch(() => {})
84+
return
85+
}
86+
87+
pendingApprovalRef.current = result.action
88+
setCredential('')
89+
setMode(result.mode)
90+
setIsOpen(true)
91+
}
92+
93+
function changeCredential(value: string) {
94+
setCredential(value)
95+
if (!unlockMutation.isPending) {
96+
unlockMutation.reset()
97+
}
98+
}
99+
100+
function cancelUnlock() {
101+
pendingApprovalRef.current = null
102+
setCredential('')
103+
unlockMutation.reset()
104+
setIsOpen(false)
105+
}
106+
107+
function changeOpen(open: boolean) {
108+
if (!open) {
109+
cancelUnlock()
110+
}
111+
}
112+
113+
async function submitUnlock(event: SyntheticEvent<HTMLFormElement>) {
114+
event.preventDefault()
115+
if (isUnlocking) {
116+
return
117+
}
118+
119+
const action = pendingApprovalRef.current
120+
if (!action) {
121+
cancelUnlock()
122+
return
123+
}
124+
125+
try {
126+
await unlockMutation.mutateAsync(credential)
127+
} catch {
128+
return
129+
}
130+
131+
if (pendingApprovalRef.current !== action) {
132+
return
133+
}
134+
135+
cancelUnlock()
136+
await approvalActionMutation.mutateAsync(action).catch(() => {})
137+
}
138+
139+
return {
140+
actions: {
141+
cancelUnlock,
142+
changeCredential,
143+
changeOpen,
144+
submitUnlock,
145+
},
146+
approve,
147+
state: {
148+
credential,
149+
error,
150+
isApproving,
151+
isBusy,
152+
isChecking,
153+
isOpen,
154+
isUnlocking,
155+
mode,
156+
},
157+
}
158+
}
159+
160+
async function runApproval(action: () => Promise<void>) {
161+
try {
162+
await action()
163+
} catch (caught) {
164+
toastError(caught instanceof Error ? caught.message : `${caught}`)
165+
}
166+
}

packages/feature-request/src/ui/request-ui-sign-and-send-transaction.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,34 @@ import type { SolanaSignAndSendTransactionInput } from '@solana/wallet-standard-
33
import { getRequestService } from '@workspace/background/services/request'
44
import { getSignService } from '@workspace/background/services/sign'
55
import { Button } from '@workspace/ui/components/button'
6+
import { useRequestSignApproval } from '../data-access/use-request-sign-approval.tsx'
7+
import { RequestUiUnlockDialog } from './request-ui-unlock-dialog.tsx'
68

79
export interface RequestSignAndSendTransactionProps {
810
data: SolanaSignAndSendTransactionInput[]
911
}
1012

1113
export function RequestUiSignAndSendTransaction({ data }: RequestSignAndSendTransactionProps) {
14+
const approval = useRequestSignApproval()
15+
1216
return (
1317
<div className="flex flex-col gap-4 p-4">
1418
<h1 className="text-center font-bold text-2xl">Sign and Send Transaction</h1>
1519
<div className="flex flex-col gap-2">
1620
<Button
17-
onClick={async () => await getRequestService().resolve(await getSignService().signAndSendTransaction(data))}
21+
disabled={approval.state.isBusy}
22+
onClick={() =>
23+
approval.approve(
24+
async () => await getRequestService().resolve(await getSignService().signAndSendTransaction(data)),
25+
)
26+
}
1827
variant="destructive"
1928
>
20-
Approve
29+
{approval.state.isChecking ? 'Checking...' : approval.state.isApproving ? 'Approving...' : 'Approve'}
2130
</Button>
2231
<Button onClick={async () => await getRequestService().reject()}>Reject</Button>
2332
</div>
33+
<RequestUiUnlockDialog approval={approval} />
2434
</div>
2535
)
2636
}

packages/feature-request/src/ui/request-ui-sign-in.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,32 @@ import type { SolanaSignInInput } from '@solana/wallet-standard-features'
33
import { getRequestService } from '@workspace/background/services/request'
44
import { getSignService } from '@workspace/background/services/sign'
55
import { Button } from '@workspace/ui/components/button'
6+
import { useRequestSignApproval } from '../data-access/use-request-sign-approval.tsx'
7+
import { RequestUiUnlockDialog } from './request-ui-unlock-dialog.tsx'
68

79
export interface RequestSignInProps {
810
data: SolanaSignInInput[]
911
}
1012

1113
export function RequestUiSignIn({ data }: RequestSignInProps) {
14+
const approval = useRequestSignApproval()
15+
1216
return (
1317
<div className="flex flex-col gap-4 p-4">
1418
<h1 className="text-center font-bold text-2xl">Sign In</h1>
1519
<div className="flex flex-col gap-2">
1620
<Button
17-
onClick={async () => await getRequestService().resolve(await getSignService().signIn(data))}
21+
disabled={approval.state.isBusy}
22+
onClick={() =>
23+
approval.approve(async () => await getRequestService().resolve(await getSignService().signIn(data)))
24+
}
1825
variant="destructive"
1926
>
20-
Approve
27+
{approval.state.isChecking ? 'Checking...' : approval.state.isApproving ? 'Approving...' : 'Approve'}
2128
</Button>
2229
<Button onClick={async () => await getRequestService().reject()}>Reject</Button>
2330
</div>
31+
<RequestUiUnlockDialog approval={approval} />
2432
</div>
2533
)
2634
}

packages/feature-request/src/ui/request-ui-sign-message.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,32 @@ import type { SolanaSignMessageInput } from '@solana/wallet-standard-features'
33
import { getRequestService } from '@workspace/background/services/request'
44
import { getSignService } from '@workspace/background/services/sign'
55
import { Button } from '@workspace/ui/components/button'
6+
import { useRequestSignApproval } from '../data-access/use-request-sign-approval.tsx'
7+
import { RequestUiUnlockDialog } from './request-ui-unlock-dialog.tsx'
68

79
export interface RequestUiSignMessageProps {
810
data: SolanaSignMessageInput[]
911
}
1012

1113
export function RequestUiSignMessage({ data }: RequestUiSignMessageProps) {
14+
const approval = useRequestSignApproval()
15+
1216
return (
1317
<div className="flex flex-col gap-4 p-4">
1418
<h1 className="text-center font-bold text-2xl">Sign Message</h1>
1519
<div className="flex flex-col gap-2">
1620
<Button
17-
onClick={async () => await getRequestService().resolve(await getSignService().signMessage(data))}
21+
disabled={approval.state.isBusy}
22+
onClick={() =>
23+
approval.approve(async () => await getRequestService().resolve(await getSignService().signMessage(data)))
24+
}
1825
variant="destructive"
1926
>
20-
Approve
27+
{approval.state.isChecking ? 'Checking...' : approval.state.isApproving ? 'Approving...' : 'Approve'}
2128
</Button>
2229
<Button onClick={async () => await getRequestService().reject()}>Reject</Button>
2330
</div>
31+
<RequestUiUnlockDialog approval={approval} />
2432
</div>
2533
)
2634
}

packages/feature-request/src/ui/request-ui-sign-transaction.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,34 @@ import type { SolanaSignTransactionInput } from '@solana/wallet-standard-feature
33
import { getRequestService } from '@workspace/background/services/request'
44
import { getSignService } from '@workspace/background/services/sign'
55
import { Button } from '@workspace/ui/components/button'
6+
import { useRequestSignApproval } from '../data-access/use-request-sign-approval.tsx'
7+
import { RequestUiUnlockDialog } from './request-ui-unlock-dialog.tsx'
68

79
export interface RequestUiSignTransactionProps {
810
data: SolanaSignTransactionInput[]
911
}
1012

1113
export function RequestUiSignTransaction({ data }: RequestUiSignTransactionProps) {
14+
const approval = useRequestSignApproval()
15+
1216
return (
1317
<div className="flex flex-col gap-4 p-4">
1418
<h1 className="text-center font-bold text-2xl">Sign Transaction</h1>
1519
<div className="flex flex-col gap-2">
1620
<Button
17-
onClick={async () => await getRequestService().resolve(await getSignService().signTransaction(data))}
21+
disabled={approval.state.isBusy}
22+
onClick={() =>
23+
approval.approve(
24+
async () => await getRequestService().resolve(await getSignService().signTransaction(data)),
25+
)
26+
}
1827
variant="destructive"
1928
>
20-
Approve
29+
{approval.state.isChecking ? 'Checking...' : approval.state.isApproving ? 'Approving...' : 'Approve'}
2130
</Button>
2231
<Button onClick={async () => await getRequestService().reject()}>Reject</Button>
2332
</div>
33+
<RequestUiUnlockDialog approval={approval} />
2434
</div>
2535
)
2636
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Button } from '@workspace/ui/components/button'
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogDescription,
6+
DialogFooter,
7+
DialogHeader,
8+
DialogTitle,
9+
} from '@workspace/ui/components/dialog'
10+
import { Input } from '@workspace/ui/components/input'
11+
import { Label } from '@workspace/ui/components/label'
12+
import { useId } from 'react'
13+
import type { RequestSignApproval, RequestUnlockMode } from '../data-access/use-request-sign-approval.tsx'
14+
15+
export function RequestUiUnlockDialog({ approval }: { approval: RequestSignApproval }) {
16+
const credentialId = useId()
17+
const { actions, state } = approval
18+
19+
return (
20+
<Dialog onOpenChange={actions.changeOpen} open={state.isOpen}>
21+
<DialogContent>
22+
<form className="space-y-4" onSubmit={actions.submitUnlock}>
23+
<DialogHeader>
24+
<DialogTitle>Unlock wallet</DialogTitle>
25+
<DialogDescription>Unlock the active wallet to continue with this signing request.</DialogDescription>
26+
</DialogHeader>
27+
<div className="space-y-2">
28+
<Label htmlFor={credentialId}>{getCredentialLabel(state.mode)}</Label>
29+
<Input
30+
autoComplete={state.mode === 'password' ? 'current-password' : 'off'}
31+
id={credentialId}
32+
inputMode={state.mode === 'pin' ? 'numeric' : undefined}
33+
onChange={(event) => actions.changeCredential(event.target.value)}
34+
pattern={state.mode === 'pin' ? '[0-9]*' : undefined}
35+
type={state.mode === 'pin' ? 'text' : 'password'}
36+
value={state.credential}
37+
/>
38+
</div>
39+
{state.error ? <p className="text-destructive text-sm">{state.error}</p> : null}
40+
<DialogFooter>
41+
<Button disabled={state.isUnlocking} onClick={actions.cancelUnlock} type="button" variant="outline">
42+
Cancel
43+
</Button>
44+
<Button disabled={state.isUnlocking} type="submit" variant="destructive">
45+
{state.isUnlocking ? 'Unlocking...' : 'Unlock'}
46+
</Button>
47+
</DialogFooter>
48+
</form>
49+
</DialogContent>
50+
</Dialog>
51+
)
52+
}
53+
54+
function getCredentialLabel(mode: RequestUnlockMode): string {
55+
switch (mode) {
56+
case 'password':
57+
return 'Password'
58+
case 'pin':
59+
return 'PIN'
60+
case 'unsecured':
61+
return 'Wallet'
62+
}
63+
}

0 commit comments

Comments
 (0)