Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# 更新日志

## [0.10.2] - 2026-02-08

miniapp 转账/签名交易改为单弹窗多步骤流程

<!-- last-commit: b835a08357f30a053e6aa2943c0391aa9dc299c5 -->

## [0.10.1] - 2026-02-08

Fix miniapp transfer tx object + FIFO sheets
Expand Down
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -214,5 +214,5 @@
"packages/*",
"miniapps/*"
],
"lastChangelogCommit": "666f7eb19a259e8677c0b04945d4d6843ca7290b"
"lastChangelogCommit": "b835a08357f30a053e6aa2943c0391aa9dc299c5"
}
257 changes: 178 additions & 79 deletions src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = {
/** 来源小程序名称 */
Expand All @@ -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<MiniappSignTransactionJobParams>();
const { appName, appIcon, from, chain, unsignedTx: unsignedTxJson } = params;

const [step, setStep] = useState<SignStep>('review');
const [pattern, setPattern] = useState<number[]>([]);
const [patternError, setPatternError] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);

const unsignedTx = useMemo((): UnsignedTransaction | null => {
Expand All @@ -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,
Expand All @@ -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', {
Expand Down Expand Up @@ -130,7 +182,7 @@ function MiniappSignTransactionJobContent() {

<MiniappSheetHeader
title={t('signTransaction')}
description={appName || t('unknownDApp')}
description={step === 'review' ? appName || t('unknownDApp') : t('drawPatternToConfirm')}
appName={appName}
appIcon={appIcon}
walletInfo={{
Expand All @@ -140,67 +192,114 @@ function MiniappSignTransactionJobContent() {
}}
/>

<div className="space-y-4 p-4">
{!unsignedTx && (
<div className="bg-destructive/10 text-destructive rounded-xl p-3 text-sm">{t('invalidTransaction')}</div>
)}
{step === 'review' ? (
<>
<div className="space-y-4 p-4">
{!unsignedTx && (
<div className="bg-destructive/10 text-destructive rounded-xl p-3 text-sm">
{t('invalidTransaction')}
</div>
)}

{unsignedTx && !walletId && (
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
{t('signingAddressNotFound')}
</div>
)}

<div className="bg-muted/50 rounded-xl p-3">
<p className="text-muted-foreground mb-1 text-xs">{t('network')}</p>
<ChainBadge chainId={resolvedChainId} />
</div>

<div className="bg-muted/50 rounded-xl p-3">
<p className="text-muted-foreground mb-1 text-xs">{t('signingAddress')}</p>
<ChainAddressDisplay chainId={resolvedChainId} address={from} copyable={false} size="sm" />
</div>

<div className="bg-muted/50 rounded-xl p-3">
<p className="text-muted-foreground mb-1 text-xs">{t('transaction')}</p>
<div className="max-h-44 overflow-y-auto">
<pre className="font-mono text-xs break-all whitespace-pre-wrap">{rawPreview}</pre>
</div>
</div>

{unsignedTx && !walletId && (
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
{t('signingAddressNotFound')}
<div className="flex items-start gap-2 rounded-xl bg-amber-50 p-3 dark:bg-amber-950/30">
<IconAlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
<p className="text-sm text-amber-800 dark:text-amber-200">{t('signTxWarning')}</p>
</div>
</div>
)}

<div className="bg-muted/50 rounded-xl p-3">
<p className="text-muted-foreground mb-1 text-xs">{t('network')}</p>
<ChainBadge chainId={resolvedChainId} />
</div>

<div className="bg-muted/50 rounded-xl p-3">
<p className="text-muted-foreground mb-1 text-xs">{t('signingAddress')}</p>
<ChainAddressDisplay chainId={resolvedChainId} address={from} copyable={false} size="sm" />
</div>

<div className="bg-muted/50 rounded-xl p-3">
<p className="text-muted-foreground mb-1 text-xs">{t('transaction')}</p>
<div className="max-h-44 overflow-y-auto">
<pre className="font-mono text-xs break-all whitespace-pre-wrap">{rawPreview}</pre>

<div className="flex gap-3 p-4">
<button
onClick={handleCancel}
disabled={isSubmitting}
className="bg-muted hover:bg-muted/80 flex-1 rounded-xl py-3 font-medium transition-colors disabled:opacity-50"
>
{t('cancel')}
</button>
<button
onClick={handleEnterWalletLockStep}
disabled={isSubmitting || !unsignedTx || !walletId}
className={cn(
'flex-1 rounded-xl py-3 font-medium transition-colors',
'bg-primary text-primary-foreground hover:bg-primary/90',
'flex items-center justify-center gap-2 disabled:opacity-50',
)}
>
{t('sign')}
</button>
</div>
</div>
</>
) : (
<>
<div className="space-y-4 p-4">
{!walletId && (
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
{t('signingAddressNotFound')}
</div>
)}

<div className="flex items-start gap-2 rounded-xl bg-amber-50 p-3 dark:bg-amber-950/30">
<IconAlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
<p className="text-sm text-amber-800 dark:text-amber-200">{t('signTxWarning')}</p>
</div>
</div>
<PatternLock
value={pattern}
onChange={handlePatternChange}
onComplete={handlePatternComplete}
minPoints={4}
disabled={isSubmitting || !walletId}
error={patternError}
errorText={patternError ? tSecurity('walletLock.error') : undefined}
/>

<div className="flex gap-3 p-4">
<button
onClick={handleCancel}
disabled={isSubmitting}
className="bg-muted hover:bg-muted/80 flex-1 rounded-xl py-3 font-medium transition-colors disabled:opacity-50"
>
{t('cancel')}
</button>
<button
onClick={handleConfirm}
disabled={isSubmitting || !unsignedTx || !walletId}
className={cn(
'flex-1 rounded-xl py-3 font-medium transition-colors',
'bg-primary text-primary-foreground hover:bg-primary/90',
'flex items-center justify-center gap-2 disabled:opacity-50',
)}
>
{isSubmitting ? (
<>
<IconLoader2 className="size-4 animate-spin" />
{t('signing')}
</>
) : (
t('sign')
)}
</button>
</div>
{errorMessage && !patternError && (
<div className="bg-destructive/10 text-destructive rounded-xl p-3 text-sm">{errorMessage}</div>
)}

{isSubmitting && (
<div className="bg-muted text-muted-foreground flex items-center justify-center gap-2 rounded-xl p-3 text-sm">
<IconLoader2 className="size-4 animate-spin" />
{t('signing')}
</div>
)}
</div>

<div className="flex gap-3 p-4">
<button
onClick={handleBackToReview}
disabled={isSubmitting}
className="bg-muted hover:bg-muted/80 flex-1 rounded-xl py-3 font-medium transition-colors disabled:opacity-50"
>
{t('back')}
</button>
<button
onClick={handleCancel}
disabled={isSubmitting}
className="bg-muted hover:bg-muted/80 flex-1 rounded-xl py-3 font-medium transition-colors disabled:opacity-50"
>
{t('cancel')}
</button>
</div>
</>
)}

<div className="h-[env(safe-area-inset-bottom)]" />
</div>
Expand Down
Loading
Loading