From b835a08357f30a053e6aa2943c0391aa9dc299c5 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 9 Feb 2026 02:04:35 +0800 Subject: [PATCH 1/2] fix(miniapp): use single-sheet flow for transfer and sign transaction --- .../sheets/MiniappSignTransactionJob.tsx | 257 +++++++---- .../sheets/MiniappTransferConfirmJob.tsx | 399 +++++++++++------- 2 files changed, 429 insertions(+), 227 deletions(-) diff --git a/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx b/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx index 3b3759340..e1346445a 100644 --- a/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx +++ b/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx @@ -11,7 +11,6 @@ import { cn } from '@/lib/utils'; import { IconAlertTriangle, IconLoader2 } from '@tabler/icons-react'; import { useFlow } from '../../stackflow'; import { ActivityParamsProvider, useActivityParams } from '../../hooks'; -import { setWalletLockConfirmCallback } from './WalletLockConfirmJob'; import { walletStore } from '@/stores'; import { findMiniappWalletIdByAddress, resolveMiniappChainId } from './miniapp-wallet'; import type { UnsignedTransaction } from '@/services/ecosystem'; @@ -20,6 +19,8 @@ import { signUnsignedTransaction } from '@/services/ecosystem/handlers'; import { MiniappSheetHeader } from '@/components/ecosystem'; import { ChainBadge } from '@/components/wallet/chain-icon'; import { ChainAddressDisplay } from '@/components/wallet/chain-address-display'; +import { PatternLock, patternToString } from '@/components/security/pattern-lock'; +import { WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage/types'; type MiniappSignTransactionJobParams = { /** 来源小程序名称 */ @@ -34,12 +35,33 @@ type MiniappSignTransactionJobParams = { unsignedTx: string; }; +type SignStep = 'review' | 'wallet_lock'; + +function isWalletLockError(error: unknown): boolean { + if (error instanceof WalletStorageError) { + return ( + error.code === WalletStorageErrorCode.DECRYPTION_FAILED || error.code === WalletStorageErrorCode.INVALID_PASSWORD + ); + } + + if (error instanceof Error) { + return /wrong password|decrypt mnemonic|invalid walletlock|wallet lock/i.test(error.message); + } + + return false; +} + function MiniappSignTransactionJobContent() { const { t } = useTranslation('common'); - const { pop, push } = useFlow(); + const { t: tSecurity } = useTranslation('security'); + const { pop } = useFlow(); const params = useActivityParams(); const { appName, appIcon, from, chain, unsignedTx: unsignedTxJson } = params; + const [step, setStep] = useState('review'); + const [pattern, setPattern] = useState([]); + const [patternError, setPatternError] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const unsignedTx = useMemo((): UnsignedTransaction | null => { @@ -56,16 +78,49 @@ function MiniappSignTransactionJobContent() { return findMiniappWalletIdByAddress(resolvedChainId, from); }, [resolvedChainId, from]); - const targetWallet = walletStore.state.wallets.find((w) => w.id === walletId); + const targetWallet = walletStore.state.wallets.find((wallet) => wallet.id === walletId); const walletName = targetWallet?.name || t('unknownWallet'); - const handleConfirm = useCallback(() => { + const resetWalletLockStepState = useCallback(() => { + setPattern([]); + setPatternError(false); + setErrorMessage(null); + }, []); + + const handleEnterWalletLockStep = useCallback(() => { + if (isSubmitting || !unsignedTx || !walletId) return; + resetWalletLockStepState(); + setStep('wallet_lock'); + }, [isSubmitting, unsignedTx, walletId, resetWalletLockStepState]); + + const handleBackToReview = useCallback(() => { if (isSubmitting) return; - if (!unsignedTx) return; - if (!walletId) return; + resetWalletLockStepState(); + setStep('review'); + }, [isSubmitting, resetWalletLockStepState]); - setWalletLockConfirmCallback(async (password: string) => { + const handlePatternChange = useCallback( + (nextPattern: number[]) => { + if (patternError || errorMessage) { + setPatternError(false); + setErrorMessage(null); + } + setPattern(nextPattern); + }, + [patternError, errorMessage], + ); + + const handlePatternComplete = useCallback( + async (nodes: number[]) => { + if (nodes.length < 4 || isSubmitting || !unsignedTx || !walletId) { + return; + } + + const password = patternToString(nodes); setIsSubmitting(true); + setPatternError(false); + setErrorMessage(null); + try { const signedTx = await signUnsignedTransaction({ walletId, @@ -84,25 +139,22 @@ function MiniappSignTransactionJobContent() { window.dispatchEvent(event); pop(); - return true; } catch (error) { - console.error("[miniapp-sign-transaction]", error); - throw error instanceof Error ? error : new Error("Sign transaction failed"); + console.error('[miniapp-sign-transaction]', error); + if (isWalletLockError(error)) { + setPatternError(true); + setErrorMessage(tSecurity('walletLock.error')); + } else { + setPatternError(false); + setErrorMessage(error instanceof Error ? error.message : t('unknownError')); + } + setPattern([]); } finally { setIsSubmitting(false); } - }); - - push('WalletLockConfirmJob', { - title: t('signTransaction'), - description: appName || t('unknownDApp'), - miniappName: appName, - miniappIcon: appIcon, - walletName, - walletAddress: from, - walletChainId: resolvedChainId, - }); - }, [appIcon, appName, from, isSubmitting, pop, push, resolvedChainId, t, unsignedTx, walletId, walletName]); + }, + [isSubmitting, unsignedTx, walletId, from, resolvedChainId, pop, t, tSecurity], + ); const handleCancel = useCallback(() => { const event = new CustomEvent('miniapp-sign-transaction-confirm', { @@ -130,7 +182,7 @@ function MiniappSignTransactionJobContent() { -
- {!unsignedTx && ( -
{t('invalidTransaction')}
- )} + {step === 'review' ? ( + <> +
+ {!unsignedTx && ( +
+ {t('invalidTransaction')} +
+ )} + + {unsignedTx && !walletId && ( +
+ {t('signingAddressNotFound')} +
+ )} + +
+

{t('network')}

+ +
+ +
+

{t('signingAddress')}

+ +
+ +
+

{t('transaction')}

+
+
{rawPreview}
+
+
- {unsignedTx && !walletId && ( -
- {t('signingAddressNotFound')} +
+ +

{t('signTxWarning')}

+
- )} - -
-

{t('network')}

- -
- -
-

{t('signingAddress')}

- -
- -
-

{t('transaction')}

-
-
{rawPreview}
+ +
+ +
-
+ + ) : ( + <> +
+ {!walletId && ( +
+ {t('signingAddressNotFound')} +
+ )} -
- -

{t('signTxWarning')}

-
-
+ -
- - -
+ {errorMessage && !patternError && ( +
{errorMessage}
+ )} + + {isSubmitting && ( +
+ + {t('signing')} +
+ )} +
+ +
+ + +
+ + )}
diff --git a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx index 18ffa243c..8dcb9c445 100644 --- a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx +++ b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx @@ -3,83 +3,144 @@ * 用于小程序请求发送转账时显示 */ -import { useState, useCallback, useMemo } from 'react' -import type { ActivityComponentType } from '@stackflow/react' -import { BottomSheet } from '@/components/layout/bottom-sheet' -import { useTranslation } from 'react-i18next' -import { cn } from '@/lib/utils' -import { IconArrowDown, IconAlertTriangle, IconLoader2 } from '@tabler/icons-react' -import { useFlow } from '../../stackflow' -import { ActivityParamsProvider, useActivityParams } from '../../hooks' -import { setWalletLockConfirmCallback } from './WalletLockConfirmJob' -import { walletStore } from '@/stores' -import { AddressDisplay } from '@/components/wallet/address-display' -import { AmountDisplay } from '@/components/common/amount-display' -import { MiniappSheetHeader } from '@/components/ecosystem' -import { ChainBadge } from '@/components/wallet/chain-icon' -import { getChainProvider } from '@/services/chain-adapter/providers' -import { Amount } from '@/types/amount' -import { signUnsignedTransaction } from '@/services/ecosystem/handlers' -import { chainConfigService } from '@/services/chain-config/service' -import { findMiniappWalletIdByAddress, resolveMiniappChainId } from './miniapp-wallet' +import { useState, useCallback, useMemo } from 'react'; +import type { ActivityComponentType } from '@stackflow/react'; +import { BottomSheet } from '@/components/layout/bottom-sheet'; +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; +import { IconArrowDown, IconAlertTriangle, IconLoader2 } from '@tabler/icons-react'; +import { useFlow } from '../../stackflow'; +import { ActivityParamsProvider, useActivityParams } from '../../hooks'; +import { walletStore } from '@/stores'; +import { AddressDisplay } from '@/components/wallet/address-display'; +import { AmountDisplay } from '@/components/common/amount-display'; +import { MiniappSheetHeader } from '@/components/ecosystem'; +import { ChainBadge } from '@/components/wallet/chain-icon'; +import { PatternLock, patternToString } from '@/components/security/pattern-lock'; +import { getChainProvider } from '@/services/chain-adapter/providers'; +import { Amount } from '@/types/amount'; +import { signUnsignedTransaction } from '@/services/ecosystem/handlers'; +import { chainConfigService } from '@/services/chain-config/service'; +import { WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage/types'; +import { findMiniappWalletIdByAddress, resolveMiniappChainId } from './miniapp-wallet'; type MiniappTransferConfirmJobParams = { /** 来源小程序名称 */ - appName: string + appName: string; /** 来源小程序图标 */ - appIcon?: string + appIcon?: string; /** 发送地址 */ - from: string + from: string; /** 接收地址 */ - to: string + to: string; /** 金额 */ - amount: string + amount: string; /** 链 ID */ - chain: string + chain: string; /** 代币 (可选) */ - asset?: string -} + asset?: string; +}; + +type TransferStep = 'review' | 'wallet_lock'; function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null + return typeof value === 'object' && value !== null; +} + +function isWalletLockError(error: unknown): boolean { + if (error instanceof WalletStorageError) { + return ( + error.code === WalletStorageErrorCode.DECRYPTION_FAILED || error.code === WalletStorageErrorCode.INVALID_PASSWORD + ); + } + + if (error instanceof Error) { + return /wrong password|decrypt mnemonic|invalid walletlock|wallet lock/i.test(error.message); + } + + return false; } function MiniappTransferConfirmJobContent() { - const { t } = useTranslation('common') - const { pop, push } = useFlow() - const params = useActivityParams() - const { appName, appIcon, from, to, amount, chain, asset } = params + const { t } = useTranslation('common'); + const { t: tSecurity } = useTranslation('security'); + const { pop } = useFlow(); + const params = useActivityParams(); + const { appName, appIcon, from, to, amount, chain, asset } = params; - const [isConfirming, setIsConfirming] = useState(false) + const [step, setStep] = useState('review'); + const [pattern, setPattern] = useState([]); + const [patternError, setPatternError] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [isConfirming, setIsConfirming] = useState(false); - const resolvedChainId = useMemo(() => resolveMiniappChainId(chain), [chain]) - const walletId = useMemo(() => findMiniappWalletIdByAddress(resolvedChainId, from), [resolvedChainId, from]) - const targetWallet = walletStore.state.wallets.find((wallet) => wallet.id === walletId) - const walletName = targetWallet?.name || t('unknownWallet') - const lockDescription = `${appName || t('unknownDApp')} ${t('requestsTransfer')}` + const resolvedChainId = useMemo(() => resolveMiniappChainId(chain), [chain]); + const walletId = useMemo(() => findMiniappWalletIdByAddress(resolvedChainId, from), [resolvedChainId, from]); + const targetWallet = walletStore.state.wallets.find((wallet) => wallet.id === walletId); + const walletName = targetWallet?.name || t('unknownWallet'); + const lockDescription = `${appName || t('unknownDApp')} ${t('requestsTransfer')}`; const displayAsset = useMemo(() => { - if (asset) return asset - const chainSymbol = chainConfigService.getSymbol(resolvedChainId) - return chainSymbol || resolvedChainId.toUpperCase() - }, [asset, resolvedChainId]) + if (asset) return asset; + const chainSymbol = chainConfigService.getSymbol(resolvedChainId); + return chainSymbol || resolvedChainId.toUpperCase(); + }, [asset, resolvedChainId]); + + const resetWalletLockStepState = useCallback(() => { + setPattern([]); + setPatternError(false); + setErrorMessage(null); + }, []); - const handleConfirm = useCallback(() => { - if (isConfirming || !walletId) return + const handleEnterWalletLockStep = useCallback(() => { + if (isConfirming || !walletId) return; + resetWalletLockStepState(); + setStep('wallet_lock'); + }, [isConfirming, walletId, resetWalletLockStepState]); - setWalletLockConfirmCallback(async (password: string) => { - setIsConfirming(true) + const handleBackToReview = useCallback(() => { + if (isConfirming) return; + resetWalletLockStepState(); + setStep('review'); + }, [isConfirming, resetWalletLockStepState]); + + const handlePatternChange = useCallback( + (nextPattern: number[]) => { + if (patternError || errorMessage) { + setPatternError(false); + setErrorMessage(null); + } + setPattern(nextPattern); + }, + [patternError, errorMessage], + ); + + const handlePatternComplete = useCallback( + async (nodes: number[]) => { + if (nodes.length < 4 || isConfirming || !walletId) { + return; + } + + const password = patternToString(nodes); + setIsConfirming(true); + setPatternError(false); + setErrorMessage(null); try { - const provider = getChainProvider(resolvedChainId) - if (!provider.supportsFullTransaction || !provider.buildTransaction || !provider.signTransaction || !provider.broadcastTransaction) { - throw new Error(`Chain ${resolvedChainId} does not support transaction pipeline`) + const provider = getChainProvider(resolvedChainId); + if ( + !provider.supportsFullTransaction || + !provider.buildTransaction || + !provider.signTransaction || + !provider.broadcastTransaction + ) { + throw new Error(`Chain ${resolvedChainId} does not support transaction pipeline`); } - const decimals = chainConfigService.getDecimals(resolvedChainId) - const chainSymbol = chainConfigService.getSymbol(resolvedChainId) - const symbol = (asset ?? chainSymbol) || resolvedChainId.toUpperCase() - const valueAmount = Amount.parse(amount, decimals, symbol) + const decimals = chainConfigService.getDecimals(resolvedChainId); + const chainSymbol = chainConfigService.getSymbol(resolvedChainId); + const symbol = (asset ?? chainSymbol) || resolvedChainId.toUpperCase(); + const valueAmount = Amount.parse(amount, decimals, symbol); const unsignedTx = await provider.buildTransaction({ type: 'transfer', @@ -87,7 +148,7 @@ function MiniappTransferConfirmJobContent() { to, amount: valueAmount, ...(asset ? { bioAssetType: asset } : {}), - }) + }); const signedTx = await signUnsignedTransaction({ walletId, @@ -95,10 +156,10 @@ function MiniappTransferConfirmJobContent() { from, chainId: resolvedChainId, unsignedTx, - }) + }); - const txHash = await provider.broadcastTransaction(signedTx) - const transaction = isRecord(signedTx.data) ? signedTx.data : { data: signedTx.data } + const txHash = await provider.broadcastTransaction(signedTx); + const transaction = isRecord(signedTx.data) ? signedTx.data : { data: signedTx.data }; const event = new CustomEvent('miniapp-transfer-confirm', { detail: { @@ -107,37 +168,34 @@ function MiniappTransferConfirmJobContent() { txId: txHash, transaction, }, - }) - window.dispatchEvent(event) + }); + window.dispatchEvent(event); - pop() - return true + pop(); } catch (error) { - console.error('[miniapp-transfer]', error) - throw error instanceof Error ? error : new Error('Transfer failed') + console.error('[miniapp-transfer]', error); + if (isWalletLockError(error)) { + setPatternError(true); + setErrorMessage(tSecurity('walletLock.error')); + } else { + setPatternError(false); + setErrorMessage(error instanceof Error ? error.message : t('unknownError')); + } + setPattern([]); } finally { - setIsConfirming(false) + setIsConfirming(false); } - }) - - push('WalletLockConfirmJob', { - title: t('confirmTransfer'), - description: lockDescription, - miniappName: appName, - miniappIcon: appIcon, - walletName, - walletAddress: from, - walletChainId: resolvedChainId, - }) - }, [isConfirming, walletId, resolvedChainId, from, to, amount, asset, pop, push, t, lockDescription, appName, appIcon, walletName]) + }, + [isConfirming, walletId, resolvedChainId, asset, amount, from, to, pop, t, tSecurity], + ); const handleCancel = useCallback(() => { const event = new CustomEvent('miniapp-transfer-confirm', { detail: { confirmed: false }, - }) - window.dispatchEvent(event) - pop() - }, [pop]) + }); + window.dispatchEvent(event); + pop(); + }, [pop]); return ( @@ -148,7 +206,7 @@ function MiniappTransferConfirmJobContent() { -
-
- -
- -
-
- {t('from')} - -
+ {step === 'review' ? ( + <> +
+
+ +
-
- -
+
+
+ {t('from')} + +
+ +
+ +
+ +
+ {t('to')} + +
+
+ + {!walletId && ( +
+ {t('signingAddressNotFound')} +
+ )} + +
+ {t('network')} + +
-
- {t('to')} - +
+ +

{t('transferWarning')}

+
-
- {!walletId && ( -
- {t('signingAddressNotFound')} +
+ +
- )} + + ) : ( + <> +
+ {!walletId && ( +
+ {t('signingAddressNotFound')} +
+ )} -
- {t('network')} - -
+ -
- -

{t('transferWarning')}

-
-
+ {errorMessage && !patternError && ( +
{errorMessage}
+ )} -
- - -
+ {isConfirming && ( +
+ + {t('confirming')} +
+ )} +
+ +
+ + +
+ + )}
- ) + ); } export const MiniappTransferConfirmJob: ActivityComponentType = ({ params }) => { @@ -242,5 +345,5 @@ export const MiniappTransferConfirmJob: ActivityComponentType - ) -} + ); +}; From 3c2253bc75194de517fab5313db0ea35e1c11d24 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 9 Feb 2026 02:04:47 +0800 Subject: [PATCH 2/2] release: v0.10.2 --- CHANGELOG.md | 6 ++++++ manifest.json | 4 ++-- package.json | 4 ++-- .../activities/sheets/MiniappSignTransactionJob.tsx | 7 +++---- .../activities/sheets/MiniappTransferConfirmJob.tsx | 7 +++---- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eccffe2c7..3992a239f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 更新日志 +## [0.10.2] - 2026-02-08 + +miniapp 转账/签名交易改为单弹窗多步骤流程 + + + ## [0.10.1] - 2026-02-08 Fix miniapp transfer tx object + FIFO sheets diff --git a/manifest.json b/manifest.json index c2353412f..4a387b16d 100644 --- a/manifest.json +++ b/manifest.json @@ -18,8 +18,8 @@ "author": [ "@bfmeta.info" ], - "version": "0.10.1", - "change_log": "Fix miniapp transfer tx object + FIFO sheets", + "version": "0.10.2", + "change_log": "miniapp 转账/签名交易改为单弹窗多步骤流程", "categories": [ "application", "wallet" diff --git a/package.json b/package.json index 5a3e457dd..3efe01b4a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@biochain/keyapp", "private": true, - "version": "0.10.1", + "version": "0.10.2", "type": "module", "packageManager": "pnpm@10.28.0", "scripts": { @@ -214,5 +214,5 @@ "packages/*", "miniapps/*" ], - "lastChangelogCommit": "666f7eb19a259e8677c0b04945d4d6843ca7290b" + "lastChangelogCommit": "b835a08357f30a053e6aa2943c0391aa9dc299c5" } diff --git a/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx b/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx index e1346445a..2a2b04594 100644 --- a/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx +++ b/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx @@ -53,7 +53,6 @@ function isWalletLockError(error: unknown): boolean { function MiniappSignTransactionJobContent() { const { t } = useTranslation('common'); - const { t: tSecurity } = useTranslation('security'); const { pop } = useFlow(); const params = useActivityParams(); const { appName, appIcon, from, chain, unsignedTx: unsignedTxJson } = params; @@ -143,10 +142,10 @@ function MiniappSignTransactionJobContent() { console.error('[miniapp-sign-transaction]', error); if (isWalletLockError(error)) { setPatternError(true); - setErrorMessage(tSecurity('walletLock.error')); + setErrorMessage(t('walletLock.error')); } else { setPatternError(false); - setErrorMessage(error instanceof Error ? error.message : t('unknownError')); + setErrorMessage(error instanceof Error ? error.message : t('walletLock.error')); } setPattern([]); } finally { @@ -267,7 +266,7 @@ function MiniappSignTransactionJobContent() { minPoints={4} disabled={isSubmitting || !walletId} error={patternError} - errorText={patternError ? tSecurity('walletLock.error') : undefined} + errorText={patternError ? t('walletLock.error') : undefined} /> {errorMessage && !patternError && ( diff --git a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx index 8dcb9c445..d3b7c909c 100644 --- a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx +++ b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx @@ -63,7 +63,6 @@ function isWalletLockError(error: unknown): boolean { function MiniappTransferConfirmJobContent() { const { t } = useTranslation('common'); - const { t: tSecurity } = useTranslation('security'); const { pop } = useFlow(); const params = useActivityParams(); const { appName, appIcon, from, to, amount, chain, asset } = params; @@ -176,10 +175,10 @@ function MiniappTransferConfirmJobContent() { console.error('[miniapp-transfer]', error); if (isWalletLockError(error)) { setPatternError(true); - setErrorMessage(tSecurity('walletLock.error')); + setErrorMessage(t('walletLock.error')); } else { setPatternError(false); - setErrorMessage(error instanceof Error ? error.message : t('unknownError')); + setErrorMessage(error instanceof Error ? error.message : t('walletLock.error')); } setPattern([]); } finally { @@ -300,7 +299,7 @@ function MiniappTransferConfirmJobContent() { minPoints={4} disabled={isConfirming || !walletId} error={patternError} - errorText={patternError ? tSecurity('walletLock.error') : undefined} + errorText={patternError ? t('walletLock.error') : undefined} /> {errorMessage && !patternError && (