diff --git a/src/hooks/use-send.ts b/src/hooks/use-send.ts index ad36eb8e5..bcb4aaabd 100644 --- a/src/hooks/use-send.ts +++ b/src/hooks/use-send.ts @@ -253,18 +253,85 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { } if (result.status === 'password_required') { - // Pay password is required - for now treat as error, UI should prompt for pay password + // Pay password is required - return status so UI can prompt for pay password + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })) + return { status: 'pay_password_required' as const, secondPublicKey: result.secondPublicKey } + } + + if (result.status === 'error') { setState((prev) => ({ ...prev, step: 'result', isSubmitting: false, resultStatus: 'failed', txHash: null, - errorMessage: '此地址已设置支付密码,需要输入支付密码才能转账', + errorMessage: result.message, })) return { status: 'error' as const } } + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'success', + txHash: result.txHash, + errorMessage: null, + })) + + return { status: 'ok' as const } + }, [chainConfig, fromAddress, state.amount, state.asset, state.toAddress, useMock, validateAddress, validateAmount, walletId]) + + // Submit with pay password (for addresses with secondPublicKey) + const submitWithPayPassword = useCallback(async (password: string, payPassword: string) => { + if (!chainConfig || !walletId || !fromAddress) { + return { status: 'error' as const } + } + + setState((prev) => ({ + ...prev, + step: 'sending', + isSubmitting: true, + resultStatus: null, + txHash: null, + errorMessage: null, + })) + + if (!state.amount) { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: '无效的金额', + })) + return { status: 'error' as const } + } + + const result = await submitBioforestTransfer({ + chainConfig, + walletId, + password, + fromAddress, + toAddress: state.toAddress, + amount: state.amount, + payPassword, + }) + + if (result.status === 'password') { + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })) + return { status: 'password' as const } + } + if (result.status === 'error') { setState((prev) => ({ ...prev, @@ -277,6 +344,19 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { return { status: 'error' as const } } + // password_required should not happen when payPassword is provided + if (result.status === 'password_required') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: '支付密码验证失败', + })) + return { status: 'error' as const } + } + setState((prev) => ({ ...prev, step: 'result', @@ -287,7 +367,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { })) return { status: 'ok' as const } - }, [chainConfig, fromAddress, state.amount, state.asset, state.toAddress, useMock, validateAddress, validateAmount, walletId]) + }, [chainConfig, fromAddress, state.amount, state.toAddress, walletId]) // Reset to initial state const reset = useCallback(() => { @@ -305,6 +385,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { goToConfirm, goBack, submit, + submitWithPayPassword, reset, canProceed, } diff --git a/src/hooks/use-send.types.ts b/src/hooks/use-send.types.ts index a9abc6d30..9d8620d9a 100644 --- a/src/hooks/use-send.types.ts +++ b/src/hooks/use-send.types.ts @@ -51,6 +51,13 @@ export interface UseSendOptions { chainConfig?: ChainConfig | null | undefined } +/** Submit result type */ +export type SubmitResult = + | { status: 'ok' } + | { status: 'password' } + | { status: 'pay_password_required'; secondPublicKey: string } + | { status: 'error' } + export interface UseSendReturn { /** Current state */ state: SendState @@ -65,7 +72,9 @@ export interface UseSendReturn { /** Go back to input */ goBack: () => void /** Submit transaction */ - submit: (password: string) => Promise<{ status: 'ok' | 'password' | 'error' }> + submit: (password: string) => Promise + /** Submit transaction with pay password */ + submitWithPayPassword: (password: string, payPassword: string) => Promise /** Reset to initial state */ reset: () => void /** Check if can proceed to confirm */ diff --git a/src/i18n/locales/en/transaction.json b/src/i18n/locales/en/transaction.json index ec83cc828..302f38544 100644 --- a/src/i18n/locales/en/transaction.json +++ b/src/i18n/locales/en/transaction.json @@ -53,7 +53,11 @@ "cameraPermissionRequired": "Camera permission required for scanning", "scanSuccess": "Scan successful", "scanFailed": "Scan failed, please enter manually", - "explorerNotImplemented": "Block explorer feature coming soon" + "explorerNotImplemented": "Block explorer feature coming soon", + "payPasswordTitle": "Pay Password Required", + "payPasswordDescription": "This address has a pay password set. Please enter your pay password to confirm the transfer.", + "payPasswordPlaceholder": "Enter pay password", + "payPasswordError": "Incorrect pay password" }, "aboutFee": "About Fee", "amount": "Amount", diff --git a/src/i18n/locales/zh-CN/transaction.json b/src/i18n/locales/zh-CN/transaction.json index 2cac71782..cbc95a625 100644 --- a/src/i18n/locales/zh-CN/transaction.json +++ b/src/i18n/locales/zh-CN/transaction.json @@ -53,7 +53,11 @@ "cameraPermissionRequired": "需要相机权限才能扫描", "scanSuccess": "扫描成功", "scanFailed": "扫描失败,请手动输入", - "explorerNotImplemented": "区块浏览器功能待实现" + "explorerNotImplemented": "区块浏览器功能待实现", + "payPasswordTitle": "需要支付密码", + "payPasswordDescription": "该地址已设置支付密码,请输入支付密码确认转账。", + "payPasswordPlaceholder": "输入支付密码", + "payPasswordError": "支付密码错误" }, "history": { "title": "交易记录", diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index 1cc3b2300..871dbb5f0 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigation, useActivityParams, useFlow } from '@/stackflow'; -import { setPasswordConfirmCallback, setTransferConfirmCallback } from '@/stackflow/activities/sheets'; +import { setPasswordConfirmCallback, setPayPasswordConfirmCallback, setTransferConfirmCallback } from '@/stackflow/activities/sheets'; import { PageHeader } from '@/components/layout/page-header'; import { AddressInput } from '@/components/transfer/address-input'; import { AmountInput } from '@/components/transfer/amount-input'; @@ -73,7 +73,7 @@ export function SendPage() { }; }, [chainConfig, currentChainAddress?.tokens]); - const { state, setToAddress, setAmount, setAsset, goToConfirm, submit, reset, canProceed } = useSend({ + const { state, setToAddress, setAmount, setAsset, goToConfirm, submit, submitWithPayPassword, reset, canProceed } = useSend({ initialAsset: defaultAsset ?? undefined, useMock: false, walletId: currentWallet?.id, @@ -81,6 +81,9 @@ export function SendPage() { chainConfig, }); + // Store wallet password for pay password flow + const walletPasswordRef = useRef(''); + useEffect(() => { if (!defaultAsset) return; setAsset(defaultAsset); @@ -138,10 +141,30 @@ export function SendPage() { await haptics.impact('medium'); setPasswordConfirmCallback(async (password: string) => { + // Store password for potential pay password flow + walletPasswordRef.current = password; + const result = await submit(password); if (result.status === 'password') { return false; } + + // Handle pay password required + if (result.status === 'pay_password_required') { + // Show pay password dialog + setPayPasswordConfirmCallback(async (payPassword: string) => { + const payResult = await submitWithPayPassword(walletPasswordRef.current, payPassword); + if (payResult.status === 'ok') { + isPasswordSheetOpen.current = false; + return true; + } + return false; + }); + + push('PayPasswordConfirmJob', {}); + return true; // Close wallet password dialog + } + isPasswordSheetOpen.current = false; // submit() sets state.step to 'result', Jobs will pop themselves return true; diff --git a/src/stackflow/activities/sheets/PayPasswordConfirmJob.tsx b/src/stackflow/activities/sheets/PayPasswordConfirmJob.tsx new file mode 100644 index 000000000..dc84c45f2 --- /dev/null +++ b/src/stackflow/activities/sheets/PayPasswordConfirmJob.tsx @@ -0,0 +1,152 @@ +import { useState, useCallback, useEffect } 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 { PasswordInput } from "@/components/security/password-input"; +import { IconAlertCircle as AlertCircle, IconLock as Lock } from "@tabler/icons-react"; +import { useFlow } from "../../stackflow"; +import { ActivityParamsProvider, useActivityParams } from "../../hooks"; + +// Global callback store for pay password verification +let pendingCallback: ((payPassword: string) => Promise) | null = null; + +/** + * Set the callback for pay password verification before pushing this activity + */ +export function setPayPasswordConfirmCallback( + onVerify: (payPassword: string) => Promise +) { + pendingCallback = onVerify; +} + +/** + * Clear the callback (called on unmount) + */ +function clearPayPasswordConfirmCallback() { + pendingCallback = null; +} + +type PayPasswordConfirmJobParams = { + title?: string; + description?: string; +}; + +function PayPasswordConfirmJobContent() { + const { t } = useTranslation(["security", "transaction"]); + const { pop } = useFlow(); + const { title, description } = useActivityParams(); + + const [payPassword, setPayPassword] = useState(""); + const [error, setError] = useState(); + const [isVerifying, setIsVerifying] = useState(false); + + const displayTitle = title ?? t("transaction:sendPage.payPasswordTitle"); + const displayDescription = description ?? t("transaction:sendPage.payPasswordDescription"); + + useEffect(() => { + return () => { + clearPayPasswordConfirmCallback(); + }; + }, []); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!payPassword.trim() || !pendingCallback) return; + + setIsVerifying(true); + setError(undefined); + + try { + const success = await pendingCallback(payPassword); + if (success) { + pop(); + } else { + setError(t("transaction:sendPage.payPasswordError")); + } + } catch { + setError(t("transaction:sendPage.payPasswordError")); + } finally { + setIsVerifying(false); + } + }, + [payPassword, pop, t] + ); + + const canSubmit = payPassword.trim().length > 0 && !isVerifying; + + return ( + +
+ {/* Handle */} +
+
+
+ + {/* Title */} +
+
+ +

{displayTitle}

+
+
+ + {/* Content */} +
+

{displayDescription}

+ +
+ setPayPassword(e.target.value)} + placeholder={t("transaction:sendPage.payPasswordPlaceholder")} + disabled={isVerifying} + aria-describedby={error ? "pay-password-error" : undefined} + /> + {error && ( +
+ + {error} +
+ )} +
+ +
+ + + +
+
+ + {/* Safe area */} +
+
+ + ); +} + +export const PayPasswordConfirmJob: ActivityComponentType = ({ params }) => { + return ( + + + + ); +}; diff --git a/src/stackflow/activities/sheets/index.ts b/src/stackflow/activities/sheets/index.ts index 2d8a0f44b..6f3d8b1c1 100644 --- a/src/stackflow/activities/sheets/index.ts +++ b/src/stackflow/activities/sheets/index.ts @@ -2,6 +2,7 @@ export { ChainSelectorJob } from "./ChainSelectorJob"; export { WalletRenameJob } from "./WalletRenameJob"; export { WalletDeleteJob } from "./WalletDeleteJob"; export { PasswordConfirmJob, setPasswordConfirmCallback } from "./PasswordConfirmJob"; +export { PayPasswordConfirmJob, setPayPasswordConfirmCallback } from "./PayPasswordConfirmJob"; export { MnemonicOptionsJob, setMnemonicOptionsCallback } from "./MnemonicOptionsJob"; export { ContactEditJob } from "./ContactEditJob"; export { WalletAddJob } from "./WalletAddJob"; diff --git a/src/stackflow/stackflow.ts b/src/stackflow/stackflow.ts index 897accfe5..b1d03e45d 100644 --- a/src/stackflow/stackflow.ts +++ b/src/stackflow/stackflow.ts @@ -28,7 +28,7 @@ import { AddressBookActivity } from "./activities/AddressBookActivity"; import { NotificationsActivity } from "./activities/NotificationsActivity"; import { StakingActivity } from "./activities/StakingActivity"; import { WelcomeActivity } from "./activities/WelcomeActivity"; -import { ChainSelectorJob, WalletRenameJob, WalletDeleteJob, PasswordConfirmJob, MnemonicOptionsJob, ContactEditJob, WalletAddJob, SecurityWarningJob, TransferConfirmJob } from "./activities/sheets"; +import { ChainSelectorJob, WalletRenameJob, WalletDeleteJob, PasswordConfirmJob, PayPasswordConfirmJob, MnemonicOptionsJob, ContactEditJob, WalletAddJob, SecurityWarningJob, TransferConfirmJob } from "./activities/sheets"; export const { Stack, useFlow, useStepFlow, activities } = stackflow({ transitionDuration: 350, @@ -68,6 +68,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({ WalletRenameJob: "/job/wallet-rename/:walletId", WalletDeleteJob: "/job/wallet-delete/:walletId", PasswordConfirmJob: "/job/password-confirm", + PayPasswordConfirmJob: "/job/pay-password-confirm", MnemonicOptionsJob: "/job/mnemonic-options", ContactEditJob: "/job/contact-edit", WalletAddJob: "/job/wallet-add", @@ -108,6 +109,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({ WalletRenameJob, WalletDeleteJob, PasswordConfirmJob, + PayPasswordConfirmJob, MnemonicOptionsJob, ContactEditJob, WalletAddJob,