@@ -11,7 +11,6 @@ import { cn } from '@/lib/utils';
1111import { IconAlertTriangle , IconLoader2 } from '@tabler/icons-react' ;
1212import { useFlow } from '../../stackflow' ;
1313import { ActivityParamsProvider , useActivityParams } from '../../hooks' ;
14- import { setWalletLockConfirmCallback } from './WalletLockConfirmJob' ;
1514import { walletStore } from '@/stores' ;
1615import { findMiniappWalletIdByAddress , resolveMiniappChainId } from './miniapp-wallet' ;
1716import type { UnsignedTransaction } from '@/services/ecosystem' ;
@@ -20,6 +19,8 @@ import { signUnsignedTransaction } from '@/services/ecosystem/handlers';
2019import { MiniappSheetHeader } from '@/components/ecosystem' ;
2120import { ChainBadge } from '@/components/wallet/chain-icon' ;
2221import { ChainAddressDisplay } from '@/components/wallet/chain-address-display' ;
22+ import { PatternLock , patternToString } from '@/components/security/pattern-lock' ;
23+ import { WalletStorageError , WalletStorageErrorCode } from '@/services/wallet-storage/types' ;
2324
2425type MiniappSignTransactionJobParams = {
2526 /** 来源小程序名称 */
@@ -34,12 +35,32 @@ type MiniappSignTransactionJobParams = {
3435 unsignedTx : string ;
3536} ;
3637
38+ type SignStep = 'review' | 'wallet_lock' ;
39+
40+ function isWalletLockError ( error : unknown ) : boolean {
41+ if ( error instanceof WalletStorageError ) {
42+ return (
43+ error . code === WalletStorageErrorCode . DECRYPTION_FAILED || error . code === WalletStorageErrorCode . INVALID_PASSWORD
44+ ) ;
45+ }
46+
47+ if ( error instanceof Error ) {
48+ return / w r o n g p a s s w o r d | d e c r y p t m n e m o n i c | i n v a l i d w a l l e t l o c k | w a l l e t l o c k / i. test ( error . message ) ;
49+ }
50+
51+ return false ;
52+ }
53+
3754function MiniappSignTransactionJobContent ( ) {
3855 const { t } = useTranslation ( 'common' ) ;
39- const { pop, push } = useFlow ( ) ;
56+ const { pop } = useFlow ( ) ;
4057 const params = useActivityParams < MiniappSignTransactionJobParams > ( ) ;
4158 const { appName, appIcon, from, chain, unsignedTx : unsignedTxJson } = params ;
4259
60+ const [ step , setStep ] = useState < SignStep > ( 'review' ) ;
61+ const [ pattern , setPattern ] = useState < number [ ] > ( [ ] ) ;
62+ const [ patternError , setPatternError ] = useState ( false ) ;
63+ const [ errorMessage , setErrorMessage ] = useState < string | null > ( null ) ;
4364 const [ isSubmitting , setIsSubmitting ] = useState ( false ) ;
4465
4566 const unsignedTx = useMemo ( ( ) : UnsignedTransaction | null => {
@@ -56,16 +77,49 @@ function MiniappSignTransactionJobContent() {
5677 return findMiniappWalletIdByAddress ( resolvedChainId , from ) ;
5778 } , [ resolvedChainId , from ] ) ;
5879
59- const targetWallet = walletStore . state . wallets . find ( ( w ) => w . id === walletId ) ;
80+ const targetWallet = walletStore . state . wallets . find ( ( wallet ) => wallet . id === walletId ) ;
6081 const walletName = targetWallet ?. name || t ( 'unknownWallet' ) ;
6182
62- const handleConfirm = useCallback ( ( ) => {
83+ const resetWalletLockStepState = useCallback ( ( ) => {
84+ setPattern ( [ ] ) ;
85+ setPatternError ( false ) ;
86+ setErrorMessage ( null ) ;
87+ } , [ ] ) ;
88+
89+ const handleEnterWalletLockStep = useCallback ( ( ) => {
90+ if ( isSubmitting || ! unsignedTx || ! walletId ) return ;
91+ resetWalletLockStepState ( ) ;
92+ setStep ( 'wallet_lock' ) ;
93+ } , [ isSubmitting , unsignedTx , walletId , resetWalletLockStepState ] ) ;
94+
95+ const handleBackToReview = useCallback ( ( ) => {
6396 if ( isSubmitting ) return ;
64- if ( ! unsignedTx ) return ;
65- if ( ! walletId ) return ;
97+ resetWalletLockStepState ( ) ;
98+ setStep ( 'review' ) ;
99+ } , [ isSubmitting , resetWalletLockStepState ] ) ;
66100
67- setWalletLockConfirmCallback ( async ( password : string ) => {
101+ const handlePatternChange = useCallback (
102+ ( nextPattern : number [ ] ) => {
103+ if ( patternError || errorMessage ) {
104+ setPatternError ( false ) ;
105+ setErrorMessage ( null ) ;
106+ }
107+ setPattern ( nextPattern ) ;
108+ } ,
109+ [ patternError , errorMessage ] ,
110+ ) ;
111+
112+ const handlePatternComplete = useCallback (
113+ async ( nodes : number [ ] ) => {
114+ if ( nodes . length < 4 || isSubmitting || ! unsignedTx || ! walletId ) {
115+ return ;
116+ }
117+
118+ const password = patternToString ( nodes ) ;
68119 setIsSubmitting ( true ) ;
120+ setPatternError ( false ) ;
121+ setErrorMessage ( null ) ;
122+
69123 try {
70124 const signedTx = await signUnsignedTransaction ( {
71125 walletId,
@@ -84,25 +138,22 @@ function MiniappSignTransactionJobContent() {
84138 window . dispatchEvent ( event ) ;
85139
86140 pop ( ) ;
87- return true ;
88141 } catch ( error ) {
89- console . error ( "[miniapp-sign-transaction]" , error ) ;
90- throw error instanceof Error ? error : new Error ( "Sign transaction failed" ) ;
142+ console . error ( '[miniapp-sign-transaction]' , error ) ;
143+ if ( isWalletLockError ( error ) ) {
144+ setPatternError ( true ) ;
145+ setErrorMessage ( t ( 'walletLock.error' ) ) ;
146+ } else {
147+ setPatternError ( false ) ;
148+ setErrorMessage ( error instanceof Error ? error . message : t ( 'walletLock.error' ) ) ;
149+ }
150+ setPattern ( [ ] ) ;
91151 } finally {
92152 setIsSubmitting ( false ) ;
93153 }
94- } ) ;
95-
96- push ( 'WalletLockConfirmJob' , {
97- title : t ( 'signTransaction' ) ,
98- description : appName || t ( 'unknownDApp' ) ,
99- miniappName : appName ,
100- miniappIcon : appIcon ,
101- walletName,
102- walletAddress : from ,
103- walletChainId : resolvedChainId ,
104- } ) ;
105- } , [ appIcon , appName , from , isSubmitting , pop , push , resolvedChainId , t , unsignedTx , walletId , walletName ] ) ;
154+ } ,
155+ [ isSubmitting , unsignedTx , walletId , from , resolvedChainId , pop , t , tSecurity ] ,
156+ ) ;
106157
107158 const handleCancel = useCallback ( ( ) => {
108159 const event = new CustomEvent ( 'miniapp-sign-transaction-confirm' , {
@@ -130,7 +181,7 @@ function MiniappSignTransactionJobContent() {
130181
131182 < MiniappSheetHeader
132183 title = { t ( 'signTransaction' ) }
133- description = { appName || t ( 'unknownDApp' ) }
184+ description = { step === 'review' ? appName || t ( 'unknownDApp' ) : t ( 'drawPatternToConfirm ') }
134185 appName = { appName }
135186 appIcon = { appIcon }
136187 walletInfo = { {
@@ -140,67 +191,114 @@ function MiniappSignTransactionJobContent() {
140191 } }
141192 />
142193
143- < div className = "space-y-4 p-4" >
144- { ! unsignedTx && (
145- < div className = "bg-destructive/10 text-destructive rounded-xl p-3 text-sm" > { t ( 'invalidTransaction' ) } </ div >
146- ) }
194+ { step === 'review' ? (
195+ < >
196+ < div className = "space-y-4 p-4" >
197+ { ! unsignedTx && (
198+ < div className = "bg-destructive/10 text-destructive rounded-xl p-3 text-sm" >
199+ { t ( 'invalidTransaction' ) }
200+ </ div >
201+ ) }
202+
203+ { unsignedTx && ! walletId && (
204+ < div className = "rounded-xl bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200" >
205+ { t ( 'signingAddressNotFound' ) }
206+ </ div >
207+ ) }
208+
209+ < div className = "bg-muted/50 rounded-xl p-3" >
210+ < p className = "text-muted-foreground mb-1 text-xs" > { t ( 'network' ) } </ p >
211+ < ChainBadge chainId = { resolvedChainId } />
212+ </ div >
213+
214+ < div className = "bg-muted/50 rounded-xl p-3" >
215+ < p className = "text-muted-foreground mb-1 text-xs" > { t ( 'signingAddress' ) } </ p >
216+ < ChainAddressDisplay chainId = { resolvedChainId } address = { from } copyable = { false } size = "sm" />
217+ </ div >
218+
219+ < div className = "bg-muted/50 rounded-xl p-3" >
220+ < p className = "text-muted-foreground mb-1 text-xs" > { t ( 'transaction' ) } </ p >
221+ < div className = "max-h-44 overflow-y-auto" >
222+ < pre className = "font-mono text-xs break-all whitespace-pre-wrap" > { rawPreview } </ pre >
223+ </ div >
224+ </ div >
147225
148- { unsignedTx && ! walletId && (
149- < div className = "rounded-xl bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200" >
150- { t ( 'signingAddressNotFound' ) }
226+ < div className = "flex items-start gap-2 rounded-xl bg-amber-50 p-3 dark:bg-amber-950/30" >
227+ < IconAlertTriangle className = "mt-0.5 size-5 shrink-0 text-amber-600" />
228+ < p className = "text-sm text-amber-800 dark:text-amber-200" > { t ( 'signTxWarning' ) } </ p >
229+ </ div >
151230 </ div >
152- ) }
153-
154- < div className = "bg-muted/50 rounded-xl p-3" >
155- < p className = "text-muted-foreground mb-1 text-xs" > { t ( 'network' ) } </ p >
156- < ChainBadge chainId = { resolvedChainId } />
157- </ div >
158-
159- < div className = "bg-muted/50 rounded-xl p-3" >
160- < p className = "text-muted-foreground mb-1 text-xs" > { t ( 'signingAddress' ) } </ p >
161- < ChainAddressDisplay chainId = { resolvedChainId } address = { from } copyable = { false } size = "sm" />
162- </ div >
163-
164- < div className = "bg-muted/50 rounded-xl p-3" >
165- < p className = "text-muted-foreground mb-1 text-xs" > { t ( 'transaction' ) } </ p >
166- < div className = "max-h-44 overflow-y-auto" >
167- < pre className = "font-mono text-xs break-all whitespace-pre-wrap" > { rawPreview } </ pre >
231+
232+ < div className = "flex gap-3 p-4" >
233+ < button
234+ onClick = { handleCancel }
235+ disabled = { isSubmitting }
236+ className = "bg-muted hover:bg-muted/80 flex-1 rounded-xl py-3 font-medium transition-colors disabled:opacity-50"
237+ >
238+ { t ( 'cancel' ) }
239+ </ button >
240+ < button
241+ onClick = { handleEnterWalletLockStep }
242+ disabled = { isSubmitting || ! unsignedTx || ! walletId }
243+ className = { cn (
244+ 'flex-1 rounded-xl py-3 font-medium transition-colors' ,
245+ 'bg-primary text-primary-foreground hover:bg-primary/90' ,
246+ 'flex items-center justify-center gap-2 disabled:opacity-50' ,
247+ ) }
248+ >
249+ { t ( 'sign' ) }
250+ </ button >
168251 </ div >
169- </ div >
252+ </ >
253+ ) : (
254+ < >
255+ < div className = "space-y-4 p-4" >
256+ { ! walletId && (
257+ < div className = "rounded-xl bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200" >
258+ { t ( 'signingAddressNotFound' ) }
259+ </ div >
260+ ) }
170261
171- < div className = "flex items-start gap-2 rounded-xl bg-amber-50 p-3 dark:bg-amber-950/30" >
172- < IconAlertTriangle className = "mt-0.5 size-5 shrink-0 text-amber-600" />
173- < p className = "text-sm text-amber-800 dark:text-amber-200" > { t ( 'signTxWarning' ) } </ p >
174- </ div >
175- </ div >
262+ < PatternLock
263+ value = { pattern }
264+ onChange = { handlePatternChange }
265+ onComplete = { handlePatternComplete }
266+ minPoints = { 4 }
267+ disabled = { isSubmitting || ! walletId }
268+ error = { patternError }
269+ errorText = { patternError ? t ( 'walletLock.error' ) : undefined }
270+ />
176271
177- < div className = "flex gap-3 p-4" >
178- < button
179- onClick = { handleCancel }
180- disabled = { isSubmitting }
181- className = "bg-muted hover:bg-muted/80 flex-1 rounded-xl py-3 font-medium transition-colors disabled:opacity-50"
182- >
183- { t ( 'cancel' ) }
184- </ button >
185- < button
186- onClick = { handleConfirm }
187- disabled = { isSubmitting || ! unsignedTx || ! walletId }
188- className = { cn (
189- 'flex-1 rounded-xl py-3 font-medium transition-colors' ,
190- 'bg-primary text-primary-foreground hover:bg-primary/90' ,
191- 'flex items-center justify-center gap-2 disabled:opacity-50' ,
192- ) }
193- >
194- { isSubmitting ? (
195- < >
196- < IconLoader2 className = "size-4 animate-spin" />
197- { t ( 'signing' ) }
198- </ >
199- ) : (
200- t ( 'sign' )
201- ) }
202- </ button >
203- </ div >
272+ { errorMessage && ! patternError && (
273+ < div className = "bg-destructive/10 text-destructive rounded-xl p-3 text-sm" > { errorMessage } </ div >
274+ ) }
275+
276+ { isSubmitting && (
277+ < div className = "bg-muted text-muted-foreground flex items-center justify-center gap-2 rounded-xl p-3 text-sm" >
278+ < IconLoader2 className = "size-4 animate-spin" />
279+ { t ( 'signing' ) }
280+ </ div >
281+ ) }
282+ </ div >
283+
284+ < div className = "flex gap-3 p-4" >
285+ < button
286+ onClick = { handleBackToReview }
287+ disabled = { isSubmitting }
288+ className = "bg-muted hover:bg-muted/80 flex-1 rounded-xl py-3 font-medium transition-colors disabled:opacity-50"
289+ >
290+ { t ( 'back' ) }
291+ </ button >
292+ < button
293+ onClick = { handleCancel }
294+ disabled = { isSubmitting }
295+ className = "bg-muted hover:bg-muted/80 flex-1 rounded-xl py-3 font-medium transition-colors disabled:opacity-50"
296+ >
297+ { t ( 'cancel' ) }
298+ </ button >
299+ </ div >
300+ </ >
301+ ) }
204302
205303 < div className = "h-[env(safe-area-inset-bottom)]" />
206304 </ div >
0 commit comments