Skip to content
Merged
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
87 changes: 84 additions & 3 deletions src/hooks/use-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand All @@ -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(() => {
Expand All @@ -305,6 +385,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn {
goToConfirm,
goBack,
submit,
submitWithPayPassword,
reset,
canProceed,
}
Expand Down
11 changes: 10 additions & 1 deletion src/hooks/use-send.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<SubmitResult>
/** Submit transaction with pay password */
submitWithPayPassword: (password: string, payPassword: string) => Promise<SubmitResult>
/** Reset to initial state */
reset: () => void
/** Check if can proceed to confirm */
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/en/transaction.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/zh-CN/transaction.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@
"cameraPermissionRequired": "需要相机权限才能扫描",
"scanSuccess": "扫描成功",
"scanFailed": "扫描失败,请手动输入",
"explorerNotImplemented": "区块浏览器功能待实现"
"explorerNotImplemented": "区块浏览器功能待实现",
"payPasswordTitle": "需要支付密码",
"payPasswordDescription": "该地址已设置支付密码,请输入支付密码确认转账。",
"payPasswordPlaceholder": "输入支付密码",
"payPasswordError": "支付密码错误"
},
"history": {
"title": "交易记录",
Expand Down
27 changes: 25 additions & 2 deletions src/pages/send/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -73,14 +73,17 @@ 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,
fromAddress: currentChainAddress?.address,
chainConfig,
});

// Store wallet password for pay password flow
const walletPasswordRef = useRef<string>('');

useEffect(() => {
if (!defaultAsset) return;
setAsset(defaultAsset);
Expand Down Expand Up @@ -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;
Expand Down
152 changes: 152 additions & 0 deletions src/stackflow/activities/sheets/PayPasswordConfirmJob.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>) | null = null;

/**
* Set the callback for pay password verification before pushing this activity
*/
export function setPayPasswordConfirmCallback(
onVerify: (payPassword: string) => Promise<boolean>
) {
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<PayPasswordConfirmJobParams>();

const [payPassword, setPayPassword] = useState("");
const [error, setError] = useState<string>();
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 (
<BottomSheet>
<div className="bg-background rounded-t-2xl">
{/* Handle */}
<div className="flex justify-center py-3">
<div className="h-1 w-10 rounded-full bg-muted" />
</div>

{/* Title */}
<div className="border-b border-border px-4 pb-4">
<div className="flex items-center justify-center gap-2">
<Lock className="size-5 text-primary" />
<h2 className="text-center text-lg font-semibold">{displayTitle}</h2>
</div>
</div>

{/* Content */}
<form onSubmit={handleSubmit} className="space-y-6 p-4">
<p className="text-center text-muted-foreground text-sm">{displayDescription}</p>

<div className="space-y-2">
<PasswordInput
value={payPassword}
onChange={(e) => setPayPassword(e.target.value)}
placeholder={t("transaction:sendPage.payPasswordPlaceholder")}
disabled={isVerifying}
aria-describedby={error ? "pay-password-error" : undefined}
/>
{error && (
<div id="pay-password-error" className="flex items-center gap-1.5 text-sm text-destructive">
<AlertCircle className="size-4" />
<span>{error}</span>
</div>
)}
</div>

<div className="space-y-3">
<button
type="submit"
disabled={!canSubmit}
className={cn(
"w-full rounded-full py-3 font-medium text-white transition-colors",
"bg-primary hover:bg-primary/90",
"disabled:cursor-not-allowed disabled:opacity-50"
)}
>
{isVerifying ? t("security:passwordConfirm.verifying") : t("security:passwordConfirm.confirm")}
</button>

<button
type="button"
onClick={() => pop()}
disabled={isVerifying}
className="w-full py-2 text-center text-sm text-muted-foreground hover:text-foreground"
>
{t("security:passwordConfirm.cancel")}
</button>
</div>
</form>

{/* Safe area */}
<div className="h-[env(safe-area-inset-bottom)]" />
</div>
</BottomSheet>
);
}

export const PayPasswordConfirmJob: ActivityComponentType<PayPasswordConfirmJobParams> = ({ params }) => {
return (
<ActivityParamsProvider params={params}>
<PayPasswordConfirmJobContent />
</ActivityParamsProvider>
);
};
1 change: 1 addition & 0 deletions src/stackflow/activities/sheets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading