diff --git a/src/app/[locale]/lend/LendPageClient.tsx b/src/app/[locale]/lend/LendPageClient.tsx index ab0e8ac..5048a05 100644 --- a/src/app/[locale]/lend/LendPageClient.tsx +++ b/src/app/[locale]/lend/LendPageClient.tsx @@ -1,466 +1,114 @@ -"use client"; +'use client'; -import { useMemo, useState } from "react"; -import Link from "next/link"; -import { - Activity, - ArrowDownLeft, - ArrowUpRight, - CircleDollarSign, - HandCoins, - Percent, - PiggyBank, - Wifi, - WifiOff, -} from "lucide-react"; -import { useLocale } from "next-intl"; -import { QueryErrorBoundary } from "../../components/global_ui/ErrorBoundary"; -import { QueryError } from "../../components/ui/QueryError"; -import { Skeleton } from "../../components/ui/Skeleton"; -import { YieldEarningsChart } from "../../components/charts/YieldEarningsChart"; -import { - useDepositorPortfolio, - useInvalidatePoolStats, - useLoans, - usePoolStats, - useYieldHistory, -} from "../../hooks/useApi"; -import { LoanStatusBadge } from "../../components/ui/LoanStatusBadge"; -import { DepositWithdrawSkeleton } from "../../components/skeletons/DepositWithdrawSkeleton"; -import { OperationProgress } from "../../components/ui/OperationProgress"; -import { useDepositOperation, useWithdrawalOperation } from "../../hooks/useRepaymentOperation"; -import { selectWalletAddress, useWalletStore } from "../../stores/useWalletStore"; -import { useSSE } from "../../hooks/useSSE"; -import { EmptyState } from "../../components/ui/EmptyState"; -import { Tooltip } from "../../components/ui/Tooltip"; -import { - AmountInput, - getAmountHelperText, - getAmountInputError, - getAmountInputErrorId, -} from "../../components/global_ui/AmountInput"; -import { parseAmount } from "../../utils/amount"; - -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; - -function formatCurrency(value: number) { - return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value); -} - -function formatPercent(value: number) { - return `${(value * 100).toFixed(2)}%`; -} +import { useState } from 'react'; +import { useConfirmedMutation } from '@/app/hooks/useConfirmedMutation'; +import { TransactionStatus } from '@/app/components/transaction/TransactionStatus'; +import { Button } from '@/app/components/global_ui/Button'; +import { Input } from '@/app/components/global_ui/Input'; export function LendPageClient() { - const locale = useLocale(); - const [depositAmount, setDepositAmount] = useState("100"); - const [withdrawAmount, setWithdrawAmount] = useState("50"); - const address = useWalletStore(selectWalletAddress); - - const depositOp = useDepositOperation(); - const withdrawalOp = useWithdrawalOperation(); - - const invalidatePoolStats = useInvalidatePoolStats(); - const sseUrl = address ? `${API_URL}/pool/events` : null; - const sseStatus = useSSE<{ type: string }>({ - url: sseUrl, - onMessage: (data) => { - if (data?.type === "pool_updated") { - invalidatePoolStats(); - } + const [amount, setAmount] = useState(''); + const [action, setAction] = useState<'deposit' | 'withdraw'>('deposit'); + + const depositMutation = useConfirmedMutation({ + operation: 'lend_deposit', + buildTx: async (vars: { amount: string }) => { + const res = await fetch('/api/pool/build-deposit', { + method: 'POST', + body: JSON.stringify({ amount: vars.amount }), + }); + return (await res.json()).xdr; }, - onFallbackPoll: () => invalidatePoolStats(), - }); - - const handleDeposit = async () => { - const amount = parseAmount(depositAmount); - if (!address || depositAmountError) return; - await depositOp.executeDeposit({ amount, depositorAddress: address }); - }; - - const handleWithdraw = async () => { - const amount = parseAmount(withdrawAmount); - if (!address || withdrawAmountError) return; - await withdrawalOp.executeWithdrawal({ amount, depositorAddress: address }); - }; - - const { data: poolStats, isLoading: poolLoading } = usePoolStats({ enabled: !!address }); - const { data: depositor, isLoading: depositorLoading } = useDepositorPortfolio( - address ?? undefined, - { enabled: !!address }, - ); - const { data: loans, isLoading: loansLoading } = useLoans({ enabled: !!address }); - const { data: yieldHistory, isLoading: historyLoading } = useYieldHistory(address ?? undefined, { - enabled: !!address, + signTx: async (xdr) => { + const { signedXDR } = await window.freighterApi.signTransaction(xdr, { + network: 'TESTNET', + }); + return signedXDR; + }, + submitTx: async (signedXdr) => { + const res = await fetch('/api/transactions/submit', { + method: 'POST', + body: JSON.stringify({ xdr: signedXdr }), + }); + return { hash: (await res.json()).hash }; + }, + queryKeys: ['pool-balance', 'user-deposits', 'pool-stats'], }); - const depositAmountError = getAmountInputError(depositAmount, { - asset: "USDC", - required: true, - }); - const withdrawAmountError = getAmountInputError(withdrawAmount, { - asset: "USDC", - required: true, - balance: depositor?.depositAmount, - balanceMessage: "Withdrawal amount must not exceed your deposited balance.", + const withdrawMutation = useConfirmedMutation({ + operation: 'lend_withdraw', + buildTx: async (vars: { amount: string }) => { + const res = await fetch('/api/pool/build-withdraw', { + method: 'POST', + body: JSON.stringify({ amount: vars.amount }), + }); + return (await res.json()).xdr; + }, + signTx: async (xdr) => { + const { signedXDR } = await window.freighterApi.signTransaction(xdr, { + network: 'TESTNET', + }); + return signedXDR; + }, + submitTx: async (signedXdr) => { + const res = await fetch('/api/transactions/submit', { + method: 'POST', + body: JSON.stringify({ xdr: signedXdr }), + }); + return { hash: (await res.json()).hash }; + }, + queryKeys: ['pool-balance', 'user-deposits', 'pool-stats'], }); - const depositAmountErrorId = getAmountInputErrorId("deposit-amount"); - const withdrawAmountErrorId = getAmountInputErrorId("withdraw-amount"); - const chartData = useMemo( - () => - (yieldHistory ?? []).map((entry) => ({ - date: new Date(entry.date).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }), - earnings: entry.earnings, - apy: entry.apy, - principal: entry.principal, - })), - [yieldHistory], - ); - - if (!address) { - return ( -
-

Lender Dashboard

-

- Connect your wallet to view your lending pool portfolio. -

-
- ); - } + const activeMutation = action === 'deposit' ? depositMutation : withdrawMutation; - const isLoading = poolLoading || depositorLoading || loansLoading || historyLoading; + const handleSubmit = () => { + if (!amount || parseFloat(amount) <= 0) return; + activeMutation.mutate({ amount }); + }; return ( -
-
-
-

- Lender Portal -

-

Lend

-

- Track pool performance, manage deposits, and monitor yield growth. -

-
- {address && ( -
- {sseStatus === "connected" ? ( - - ) : ( - - )} - {sseStatus === "connected" - ? "Live" - : sseStatus === "connecting" - ? "Connecting…" - : sseStatus === "polling" - ? "Reconnecting…" - : "Offline"} -
- )} -
- - -
- {[ - { - label: "Total Pool Size", - value: formatCurrency(poolStats?.totalDeposits ?? 0), - icon: CircleDollarSign, - }, - { - label: "Utilization Rate", - value: formatPercent(poolStats?.utilizationRate ?? 0), - icon: Percent, - tooltip: - "Utilization Rate: How much of the pool is currently loaned out. Higher utilization can increase yield, but may reduce instant liquidity.", - }, - { - label: "Current APY", - value: formatPercent(poolStats?.apy ?? 0), - icon: Activity, - tooltip: - "APY (Annual Percentage Yield): The estimated yearly return on deposits, including compounding. This may vary with pool utilization and repayments.", - }, - { - label: "Active Loans", - value: String(poolStats?.activeLoansCount ?? 0), - icon: HandCoins, - }, - ].map((item) => ( -
-
-
- -
-
-

- {item.label} - {"tooltip" in item && item.tooltip ? ( - - ) : null} -

- {poolLoading ? ( - - ) : ( -

- {item.value} -

- )} -
-
-
- ))} -
-
- - -
-
-

My Deposits

-
-
-

Deposited Amount

- {depositorLoading ? ( - - ) : ( -

- {formatCurrency(depositor?.depositAmount ?? 0)} -

- )} -
-
-

Share of Pool

- {depositorLoading ? ( - - ) : ( -

- {formatPercent(depositor?.sharePercent ?? 0)} -

- )} -
-
-

Estimated Earnings

- {depositorLoading ? ( - - ) : ( -

- {formatCurrency(depositor?.estimatedYield ?? 0)} -

- )} -
-
-
- {depositorLoading ? ( - - ) : ( -
-

- Deposit / Withdraw -

-
-
{ - e.preventDefault(); - handleDeposit(); - }} - > - - - {depositOp.isLoading && ( -

- Deposit in progress - please wait. -

- )} - - - -
{ - e.preventDefault(); - handleWithdraw(); - }} - > - - - {withdrawalOp.isLoading && ( -

- Withdrawal in progress - please wait. -

- )} - - -
-
- )} -
-
- - -
-

Loan Portfolio

-

- Read-only view of loans currently funded through the pool. -

- -
- {isLoading && ( - <> - - - - - )} - {!isLoading && - (loans ?? []) - .filter((loan) => loan.status === "active") - .slice(0, 8) - .map((loan) => ( -
-
-

- Loan #{loan.id} -

-

- Borrower: {loan.borrowerId} -

-
-
- {formatCurrency(loan.amount)} - {loan.interestRate.toFixed(2)}% APR - {loan.termDays} days - -
- - View - -
- ))} - - {!isLoading && - (loans ?? []).filter((loan) => loan.status === "active").length === 0 && ( - - )} -
-
-
- - -
- {isLoading ? ( -
- - -
- ) : chartData.length === 0 ? ( - - ) : ( - - )} -
-
-
+
+

Lending Pool

+ +
+ + +
+ + setAmount(e.target.value)} + disabled={activeMutation.isProcessing} + /> + + + + {activeMutation.canSubmit && ( + + )} +
); -} +} \ No newline at end of file diff --git a/src/app/components/loan-wizard/StepFinalSignature.tsx b/src/app/components/loan-wizard/StepFinalSignature.tsx index 977f34d..a32fe22 100644 --- a/src/app/components/loan-wizard/StepFinalSignature.tsx +++ b/src/app/components/loan-wizard/StepFinalSignature.tsx @@ -1,439 +1,68 @@ -"use client"; +'use client'; -import { useEffect, useRef, useState } from "react"; -import { PenLine, CircleAlert, CheckCircle2, Loader2 } from "lucide-react"; -import { Button } from "../ui/Button"; -import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card"; -import { TransactionPreviewModal } from "../transaction/TransactionPreviewModal"; -import { - TransactionStatusTracker, - type TransactionStatusState, -} from "../ui/TransactionStatusTracker"; -import { useTransactionPreview } from "../../hooks/useTransactionPreview"; -import { useCreateLoan } from "../../hooks/useApi"; -import { useContractToast } from "../../hooks/useContractToast"; -import { buildUnsignedLoanRequestXdr } from "../../utils/soroban"; -import { - mapTransactionError, - pollTransactionStatus, - type TransactionErrorDetails, -} from "../../utils/transactionErrors"; -import type { LoanWizardData } from "./LoanApplicationWizard"; - -const ANNUAL_RATE_PERCENT = 12; - -function formatMoney(value: number): string { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(value); -} - -function addDays(date: Date, days: number): Date { - const result = new Date(date); - result.setDate(result.getDate() + days); - return result; -} +import { useConfirmedMutation } from '@/app/hooks/useConfirmedMutation'; +import { TransactionStatus } from '@/app/components/transaction/TransactionStatus'; +import { Button } from '@/app/components/global_ui/Button'; interface StepFinalSignatureProps { - data: LoanWizardData; - borrowerAddress: string; - onBack: () => void; - onSuccess: (loanId: string) => void; + loanId: string; + onComplete: () => void; } -export function StepFinalSignature({ - data, - borrowerAddress, - onBack, - onSuccess, -}: StepFinalSignatureProps) { - const [unsignedXdr, setUnsignedXdr] = useState(""); - const [xdrError, setXdrError] = useState(null); - const [isBuildingXdr, setIsBuildingXdr] = useState(false); - const [trackerState, setTrackerState] = useState("idle"); - const [trackerTitle, setTrackerTitle] = useState("Ready to submit"); - const [trackerMessage, setTrackerMessage] = useState(""); - const [trackerGuidance, setTrackerGuidance] = useState(undefined); - const [trackerTxHash, setTrackerTxHash] = useState(null); - const [lastErrorDetails, setLastErrorDetails] = useState(null); - - const pollingAbortControllerRef = useRef(null); - - const txPreview = useTransactionPreview(); - const createLoan = useCreateLoan(); - const toast = useContractToast(); - - const principal = Number(data.amount || "0"); - const estimatedInterest = (principal * ANNUAL_RATE_PERCENT * data.termDays) / (365 * 100); - const totalRepayment = principal + estimatedInterest; - const dueDate = addDays(new Date(), data.termDays); - - // Pre-build the XDR so the user can see it in the summary. - // All setState calls happen inside an async IIFE to satisfy react-hooks/set-state-in-effect. - useEffect(() => { - if (!borrowerAddress || principal <= 0) return; - - let cancelled = false; - - void (async () => { - const managerContractId = process.env.NEXT_PUBLIC_MANAGER_CONTRACT_ID; - if (!managerContractId) { - if (!cancelled) setXdrError("Missing NEXT_PUBLIC_MANAGER_CONTRACT_ID configuration."); - return; - } - - if (!cancelled) { - setIsBuildingXdr(true); - setXdrError(null); - } - - try { - const xdr = await buildUnsignedLoanRequestXdr({ - borrower: borrowerAddress, - amount: principal, - term: data.termDays * 17280, - contractId: managerContractId, - }); - if (!cancelled) setUnsignedXdr(xdr); - } catch (err) { - if (!cancelled) - setXdrError(err instanceof Error ? err.message : "Failed to build unsigned XDR."); - } finally { - if (!cancelled) setIsBuildingXdr(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [borrowerAddress, principal]); - - useEffect(() => { - return () => { - pollingAbortControllerRef.current?.abort(); - pollingAbortControllerRef.current = null; - }; - }, []); - - const resetTracker = () => { - setTrackerState("idle"); - setTrackerTitle("Ready to submit"); - setTrackerMessage(""); - setTrackerGuidance(undefined); - setTrackerTxHash(null); - setLastErrorDetails(null); - }; - - const cancelTracking = () => { - pollingAbortControllerRef.current?.abort(); - pollingAbortControllerRef.current = null; - setTrackerState("cancelled"); - setTrackerTitle("Status tracking cancelled"); - setTrackerMessage("You cancelled this transaction flow."); - setTrackerGuidance("If needed, you can retry submission."); - }; - - const handleSignAndSubmit = () => { - const managerContractId = process.env.NEXT_PUBLIC_MANAGER_CONTRACT_ID; - if (!managerContractId) { - setXdrError("Missing NEXT_PUBLIC_MANAGER_CONTRACT_ID configuration."); - return; - } - - resetTracker(); - - txPreview.show( - { - operations: [ - { - type: "request_loan", - description: `Request ${formatMoney(principal)} for ${data.termDays} days`, - amount: principal.toString(), - token: data.asset, - details: { - "Credit Score": data.creditScore, - "Interest Rate (APR)": `${ANNUAL_RATE_PERCENT}%`, - "Estimated Due Date": dueDate.toLocaleDateString(), - Term: `${data.termDays} days`, - ...(unsignedXdr && { - "Unsigned XDR": `${unsignedXdr.slice(0, 16)}...${unsignedXdr.slice(-16)}`, - }), - }, - }, - ], - balanceChanges: [{ token: data.asset, change: `${principal}`, isPositive: true }], - estimatedGasFee: "0.00001", - network: "Stellar Testnet", - contractAddress: managerContractId, - }, - async () => { - let toastId: string | number | null = null; - - setTrackerState("signing"); - setTrackerTitle("Waiting for wallet signature"); - setTrackerMessage("Approve the transaction in your wallet to continue."); - - try { - setTrackerState("submitting"); - setTrackerTitle("Submitting transaction"); - setTrackerMessage("Sending your loan request to the network."); - toastId = toast.showPending("Transaction submitted"); - - const loan = await createLoan.mutateAsync({ - amount: principal, - currency: data.asset, - interestRate: ANNUAL_RATE_PERCENT, - termDays: data.termDays, - borrowerId: borrowerAddress, - }); - - if (!loan.txHash) { - setTrackerState("success"); - setTrackerTitle("Loan request submitted"); - setTrackerMessage("Your request was accepted and recorded."); - setTrackerGuidance("You can monitor approval status from your loans dashboard."); - if (toastId !== null) { - toast.showSuccess(toastId, { - successMessage: "Loan request submitted successfully", - }); - } else { - toast.success("Loan request submitted successfully"); - } - onSuccess(loan.id); - return; - } - - setTrackerTxHash(loan.txHash); - setTrackerState("polling"); - setTrackerTitle("Waiting for on-chain confirmation"); - setTrackerMessage("Tracking transaction status on Stellar testnet."); - - const controller = new AbortController(); - pollingAbortControllerRef.current = controller; - - const pollResult = await pollTransactionStatus(loan.txHash, { - signal: controller.signal, - }); - - pollingAbortControllerRef.current = null; - - if (pollResult.status === "success") { - setTrackerState("success"); - setTrackerTitle("Transaction confirmed"); - setTrackerMessage("Your loan request is confirmed on-chain."); - setTrackerGuidance("You can monitor approval status from your loans dashboard."); - if (toastId !== null) { - toast.showSuccess(toastId, { - successMessage: "Loan request confirmed on-chain", - txHash: loan.txHash, - }); - } - onSuccess(loan.id); - return; - } - - if (pollResult.status === "cancelled") { - setTrackerState("cancelled"); - setTrackerTitle("Status tracking cancelled"); - setTrackerMessage(pollResult.message); - setTrackerGuidance("You can retry tracking or submit again."); - return; - } - - const pollError = mapTransactionError( - pollResult.status === "failed" - ? "Transaction failed on-chain" - : "Network timeout while polling status", - ); - - if (toastId !== null) { - toast.showError(toastId, { - errorMessage: pollError.title, - retryAction: retrySubmission, - }); - } else { - toast.error(pollError.title, pollResult.message); - } - - setLastErrorDetails(pollError); - setTrackerState("error"); - setTrackerTitle(pollError.title); - setTrackerMessage(pollResult.message); - setTrackerGuidance(pollError.guidance); - } catch (error) { - const mapped = mapTransactionError(error); - setLastErrorDetails(mapped); - - if (mapped.cancelledByUser) { - setTrackerState("cancelled"); - } else { - setTrackerState("error"); - } - - setTrackerTitle(mapped.title); - setTrackerMessage(mapped.message); - setTrackerGuidance(mapped.guidance); - - if (toastId !== null) { - toast.showError(toastId, { - errorMessage: mapped.title, - retryAction: mapped.retryable ? retrySubmission : undefined, - }); - } else { - toast.error(mapped.title, mapped.message); - } - - throw error; - } - }, - ); - }; - - const retrySubmission = () => { - txPreview.close(); - handleSignAndSubmit(); - }; +export function StepFinalSignature({ loanId, onComplete }: StepFinalSignatureProps) { + const mutation = useConfirmedMutation({ + operation: 'loan_sign', + buildTx: async () => { + // Call backend to build loan signature transaction XDR + const res = await fetch(`/api/loans/${loanId}/build-signature`); + const data = await res.json(); + return data.xdr; + }, + signTx: async (xdr) => { + // Use Stellar Wallet Kit or Freighter + const { signedXDR } = await window.freighterApi.signTransaction(xdr, { + network: 'TESTNET', + }); + return signedXDR; + }, + submitTx: async (signedXdr) => { + const res = await fetch('/api/transactions/submit', { + method: 'POST', + body: JSON.stringify({ xdr: signedXdr }), + }); + const data = await res.json(); + return { hash: data.hash }; + }, + queryKeys: ['loan', loanId, 'loans', 'user-loans'], + onSuccess: () => { + onComplete(); + }, + }); return (
- - - - - Final Signature - -

- Review the full loan summary, then sign and submit your application. -

-
- - {/* Loan summary recap */} -
-
-

Loan Summary

-
-
- {[ - { label: "Asset", value: data.asset }, - { label: "Principal", value: formatMoney(principal) }, - { label: "Term", value: `${data.termDays} days` }, - { label: "APR", value: `${ANNUAL_RATE_PERCENT}%` }, - { label: "Estimated Interest", value: formatMoney(estimatedInterest) }, - { - label: "Total Repayment", - value: formatMoney(totalRepayment), - highlight: true, - }, - { - label: "Due Date", - value: dueDate.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }), - }, - { - label: "Borrower", - value: `${borrowerAddress.slice(0, 8)}…${borrowerAddress.slice(-6)}`, - }, - { label: "Credit Score", value: data.creditScore.toString() }, - { label: "Collateral", value: "RemittanceNFT (locked on approval)" }, - ].map(({ label, value, highlight }) => ( -
- {label} - - {value} - -
- ))} -
-
- - {/* XDR preview */} -
-

- Unsigned Soroban XDR -

- {isBuildingXdr && ( -
- - Building transaction... -
- )} - {xdrError && ( -
- - {xdrError} (XDR preview unavailable — you may still proceed) -
- )} - {unsignedXdr && !isBuildingXdr && ( -

- {unsignedXdr} -

- )} -
- - - -
- - -
-
-
- - {txPreview.data && ( - +
+

Final Signature Required

+

+ Please review and sign the loan agreement transaction. +

+
+ + + + {mutation.canSubmit && ( + )}
); -} +} \ No newline at end of file diff --git a/src/app/components/transaction/TransactionStatus.tsx b/src/app/components/transaction/TransactionStatus.tsx new file mode 100644 index 0000000..e74077b --- /dev/null +++ b/src/app/components/transaction/TransactionStatus.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { TransactionLifecycle } from '@/app/types/transaction'; +import { getStatusLabel, getStatusColor, getStatusIcon, formatTransactionError, getExplorerLink } from '@/app/utils/transactionFormatter'; +import { Button } from '@/app/components/global_ui/Button'; // Assuming this exists or use standard button + +interface TransactionStatusProps { + lifecycle: TransactionLifecycle; + onRetry: () => void; + onReset: () => void; + network?: 'testnet' | 'public'; +} + +export function TransactionStatus({ lifecycle, onRetry, onReset, network = 'testnet' }: TransactionStatusProps) { + const { state, error, txHash } = lifecycle; + + if (state === 'idle') return null; + + return ( +
+ {/* Status Header */} +
+ {getStatusIcon(state)} +
+

+ {getStatusLabel(state)} +

+ {txHash && state !== 'success' && ( +

+ Hash: {txHash.slice(0, 8)}...{txHash.slice(-8)} +

+ )} +
+
+ + {/* Progress Indicator */} + {state !== 'error' && state !== 'success' && ( +
+
+
+ )} + + {/* Error Display */} + {error && ( +
+

+ {formatTransactionError(error).title} +

+

+ {formatTransactionError(error).description} +

+

+ 💡 {formatTransactionError(error).action} +

+
+ )} + + {/* Success Display */} + {state === 'success' && txHash && ( +
+

+ Transaction confirmed successfully! +

+ + View on Stellar Expert → + +
+ )} + + {/* Actions */} +
+ {state === 'error' && formatTransactionError(error!).canRetry && ( + + )} + {(state === 'success' || state === 'error') && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/hooks/__tests__/useTransactionLifecycle.test.ts b/src/app/hooks/__tests__/useTransactionLifecycle.test.ts new file mode 100644 index 0000000..c5215da --- /dev/null +++ b/src/app/hooks/__tests__/useTransactionLifecycle.test.ts @@ -0,0 +1,115 @@ +import { renderHook, act } from "@testing-library/react"; +import { useTransactionLifecycle } from "../useTransactionLifecycle"; + +describe("useTransactionLifecycle", () => { + it("starts in idle state", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + expect(result.current.lifecycle.state).toBe("idle"); + expect(result.current.canSubmit).toBe(true); + expect(result.current.isProcessing).toBe(false); + }); + + it("transitions through full success flow", async () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + expect(result.current.lifecycle.state).toBe("building"); + + act(() => result.current.transition({ type: "BUILD_SUCCESS" })); + expect(result.current.lifecycle.state).toBe("awaiting-signature"); + + act(() => result.current.transition({ type: "SIGNATURE_SUCCESS" })); + expect(result.current.lifecycle.state).toBe("submitting"); + + act(() => result.current.transition({ type: "SUBMIT_SUCCESS", txHash: "abc123" })); + expect(result.current.lifecycle.state).toBe("confirming"); + expect(result.current.lifecycle.txHash).toBe("abc123"); + + act(() => result.current.transition({ type: "CONFIRM_SUCCESS" })); + expect(result.current.lifecycle.state).toBe("success"); + expect(result.current.isSuccess).toBe(true); + }); + + it("prevents double-submit via idempotency lock", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + act(() => result.current.transition({ type: "BUILD_SUCCESS" })); + act(() => result.current.transition({ type: "SIGNATURE_SUCCESS" })); + act(() => result.current.transition({ type: "SUBMIT" })); + + // Second submit should be ignored + act(() => result.current.transition({ type: "SUBMIT" })); + expect(result.current.lifecycle.state).toBe("submitting"); + }); + + it("handles errors and allows retry", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + act(() => + result.current.transition({ + type: "ERROR", + error: new Error("User declined"), + }), + ); + + expect(result.current.lifecycle.state).toBe("error"); + expect(result.current.lifecycle.error?.code).toBe("USER_REJECTED"); + expect(result.current.lifecycle.error?.retryable).toBe(true); + + act(() => result.current.transition({ type: "RETRY" })); + expect(result.current.lifecycle.state).toBe("building"); + expect(result.current.lifecycle.error).toBeNull(); + }); + + it("maps contract errors as non-retryable", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + act(() => + result.current.transition({ + type: "ERROR", + error: new Error("invoke_host_function failed"), + }), + ); + + expect(result.current.lifecycle.error?.code).toBe("CONTRACT_ERROR"); + expect(result.current.lifecycle.error?.retryable).toBe(false); + }); + + it("resets to idle", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + act(() => result.current.transition({ type: "RESET" })); + + expect(result.current.lifecycle.state).toBe("idle"); + expect(result.current.canSubmit).toBe(true); + }); +}); diff --git a/src/app/hooks/useConfirmedMutation.ts b/src/app/hooks/useConfirmedMutation.ts index e793154..f5ca507 100644 --- a/src/app/hooks/useConfirmedMutation.ts +++ b/src/app/hooks/useConfirmedMutation.ts @@ -1,90 +1,38 @@ -"use client"; +'use client'; -import { useState, useCallback } from "react"; -import type { TransactionSummaryItem } from "../components/ui/ConfirmTransactionDialog"; +import { useQueryClient } from '@tanstack/react-query'; +import { useContractMutation } from './useContractMutation'; +import { useConfirmation } from './useConfirmation'; // Existing hook -interface ConfirmedMutationOptions { - /** Build the summary rows from the mutation variables. */ - buildSummary?: (variables: TVariables) => TransactionSummaryItem[]; - /** Dialog title. */ - title?: string; - /** Dialog description / warning text. */ - description?: string; - /** Label for the confirm button. */ - confirmLabel?: string; +interface ConfirmedMutationOptions { + operation: string; + buildTx: (variables: TVariables) => Promise; + signTx: (xdr: string) => Promise; + submitTx: (signedXdr: string) => Promise<{ hash: string }>; + queryKeys: string[]; // Queries to invalidate on success + onSuccess?: (data: TData, variables: TVariables) => void; } -/** - * Wraps any async mutation with a confirmation dialog flow. - * - * Usage: - * ```tsx - * const { dialogProps, trigger, isLoading } = useConfirmedMutation( - * (vars) => approveLoanMutation.mutateAsync(vars), - * { - * title: "Approve Loan", - * buildSummary: (vars) => [ - * { label: "Loan ID", value: String(vars.loanId) }, - * { label: "Amount", value: `${vars.amount} USDC` }, - * ], - * }, - * ); - * - * // Render the dialog using dialogProps, trigger on button click: - * - * - * ``` - */ -export function useConfirmedMutation( - action: (variables: TVariables) => Promise, - options: ConfirmedMutationOptions = {}, +export function useConfirmedMutation( + options: ConfirmedMutationOptions ) { - const [isOpen, setIsOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [pendingVariables, setPendingVariables] = useState(null); - const [summary, setSummary] = useState([]); + const queryClient = useQueryClient(); + const { confirm } = useConfirmation(); // Existing confirmation hook - const trigger = useCallback( - (variables: TVariables) => { - setPendingVariables(variables); - setSummary(options.buildSummary ? options.buildSummary(variables) : []); - setIsOpen(true); + const mutation = useContractMutation({ + ...options, + confirmTx: async (hash: string) => { + // Use existing confirmation hook with unified timeout + return confirm(hash, { timeout: 30000, pollingInterval: 2000 }); }, - [options], - ); - - const handleConfirm = useCallback(async () => { - if (pendingVariables === null) return; - setIsLoading(true); - try { - await action(pendingVariables); - } finally { - setIsLoading(false); - setIsOpen(false); - setPendingVariables(null); - } - }, [action, pendingVariables]); - - const handleClose = useCallback(() => { - if (isLoading) return; // block dismiss while tx is in-flight - setIsOpen(false); - setPendingVariables(null); - }, [isLoading]); - - return { - /** Spread onto */ - dialogProps: { - isOpen, - onClose: handleClose, - onConfirm: handleConfirm, - title: options.title, - description: options.description, - confirmLabel: options.confirmLabel, - summary, - isLoading, + onSuccess: (data, variables) => { + // Invalidate relevant queries + options.queryKeys.forEach((key) => { + queryClient.invalidateQueries({ queryKey: [key] }); + }); + options.onSuccess?.(data, variables); }, - /** Call with mutation variables to open the dialog */ - trigger, - isLoading, - }; -} + }); + + return mutation; +} \ No newline at end of file diff --git a/src/app/hooks/useContractMutation.ts b/src/app/hooks/useContractMutation.ts index e7e241e..e889dc1 100644 --- a/src/app/hooks/useContractMutation.ts +++ b/src/app/hooks/useContractMutation.ts @@ -1,152 +1,87 @@ -/** - * hooks/useContractMutation.ts - * - * Wrapper hook that combines TanStack Query mutations with automatic toast notifications. - * Provides a consistent pattern for handling blockchain transactions with user feedback. - * - * Usage Example: - * ```tsx - * const createLoan = useContractMutation(useCreateLoan(), { - * pendingMessage: "Creating loan...", - * successMessage: "Loan created successfully!", - * errorMessage: "Failed to create loan", - * }); - * - * // In your component - * createLoan.mutate({ amount: 1000, ... }); - * ``` - */ +'use client'; -import { type UseMutationResult } from "@tanstack/react-query"; -import { useContractToast } from "./useContractToast"; -import { useCallback, useRef } from "react"; -import { useGamificationStore } from "../stores/useGamificationStore"; +import { useCallback } from 'react'; +import { useTransactionLifecycle } from './useTransactionLifecycle'; +import { TransactionContext } from '@/app/types/transaction'; -interface ContractMutationOptions { - /** Message shown during pending state */ - pendingMessage?: string; - /** Message shown on success */ - successMessage?: string; - /** Message shown on error */ - errorMessage?: string; - /** Stellar network for explorer links */ - network?: "testnet" | "public"; - /** Disable automatic toast notifications */ - disableToast?: boolean; - /** Gamification XP to award on success */ - gamificationXP?: number; - /** Gamification reason for XP */ - gamificationReason?: string; - /** Gamification achievement ID to unlock on success */ - gamificationAchievement?: string; +interface ContractMutationOptions { + operation: string; + buildTx: (variables: TVariables) => Promise; // Returns XDR + signTx: (xdr: string) => Promise; // Returns signed XDR + submitTx: (signedXdr: string) => Promise<{ hash: string }>; + confirmTx: (hash: string) => Promise; + onSuccess?: (data: TData, variables: TVariables) => void; + onError?: (error: Error, variables: TVariables) => void; + invalidateQueries?: string[]; } -/** - * Wraps a TanStack Query mutation with automatic toast notifications. - * Handles the full transaction lifecycle: pending → success/error. - */ -export function useContractMutation( - mutation: UseMutationResult, - options: ContractMutationOptions = {}, +export function useContractMutation( + options: ContractMutationOptions ) { - const toast = useContractToast(); - const gamificationStore = useGamificationStore(); - const toastIdRef = useRef(null); + const { lifecycle, transition, canSubmit, isProcessing } = useTransactionLifecycle(); - const { - pendingMessage = "Processing transaction...", - successMessage = "Transaction successful!", - errorMessage = "Transaction failed", - network = "testnet", - disableToast = false, - gamificationXP, - gamificationReason, - gamificationAchievement, - } = options; + const mutate = useCallback( + async (variables: TVariables) => { + if (!canSubmit) { + throw new Error('Transaction already in progress'); + } - const triggerGamification = useCallback(() => { - if (gamificationXP) { - // Small delay to let the toast appear first - setTimeout(() => { - gamificationStore.addXP(gamificationXP, gamificationReason); - if (gamificationAchievement) { - gamificationStore.unlockAchievement(gamificationAchievement); - } - }, 500); - } else if (gamificationAchievement) { - setTimeout(() => { - gamificationStore.unlockAchievement(gamificationAchievement); - }, 500); - } - }, [gamificationXP, gamificationReason, gamificationAchievement, gamificationStore]); + const context: TransactionContext = { + operation: options.operation, + metadata: variables as Record, + }; - const mutate = ( - variables: TVariables, - mutationOptions?: Parameters[1], - ) => { - if (!disableToast) { - toastIdRef.current = toast.showPending(pendingMessage); - } + try { + // 1. Build + transition({ type: 'START_BUILD', context }); + const xdr = await options.buildTx(variables); + transition({ type: 'BUILD_SUCCESS' }); - mutation.mutate(variables, { - ...mutationOptions, - onSuccess: (data, vars, onMutateResult, context) => { - if (!disableToast && toastIdRef.current !== null) { - toast.showSuccess(toastIdRef.current, { - successMessage, - txHash: data.txHash, - network, - }); - } - triggerGamification(); - mutationOptions?.onSuccess?.(data, vars, onMutateResult, context); - }, - onError: (error, vars, onMutateResult, context) => { - if (!disableToast && toastIdRef.current !== null) { - toast.showError(toastIdRef.current, { - errorMessage: error instanceof Error ? error.message : errorMessage, - }); - } - mutationOptions?.onError?.(error, vars, onMutateResult, context); - }, - }); - }; + // 2. Sign + transition({ type: 'AWAIT_SIGNATURE' }); + const signedXdr = await options.signTx(xdr); + transition({ type: 'SIGNATURE_SUCCESS' }); - const mutateAsync = async ( - variables: TVariables, - mutationOptions?: Parameters[1], - ) => { - if (!disableToast) { - toastIdRef.current = toast.showPending(pendingMessage); - } + // 3. Submit + transition({ type: 'SUBMIT' }); + const { hash } = await options.submitTx(signedXdr); + transition({ type: 'SUBMIT_SUCCESS', txHash: hash }); - try { - const data = await mutation.mutateAsync(variables, mutationOptions); + // 4. Confirm + transition({ type: 'CONFIRM' }); + const confirmed = await options.confirmTx(hash); + + if (!confirmed) { + throw new Error('Transaction confirmation timeout'); + } + + transition({ type: 'CONFIRM_SUCCESS' }); + options.onSuccess?.(hash as TData, variables); - if (!disableToast && toastIdRef.current !== null) { - toast.showSuccess(toastIdRef.current, { - successMessage, - txHash: data.txHash, - network, - }); + return hash; + } catch (error) { + transition({ type: 'ERROR', error }); + options.onError?.(error as Error, variables); + throw error; } + }, + [canSubmit, transition, options] + ); - triggerGamification(); + const retry = useCallback(() => { + transition({ type: 'RETRY' }); + }, [transition]); - return data; - } catch (error) { - if (!disableToast && toastIdRef.current !== null) { - toast.showError(toastIdRef.current, { - errorMessage: error instanceof Error ? error.message : errorMessage, - }); - } - throw error; - } - }; + const reset = useCallback(() => { + transition({ type: 'RESET' }); + }, [transition]); return { - ...mutation, mutate, - mutateAsync, + retry, + reset, + lifecycle, + isProcessing, + canSubmit, }; -} +} \ No newline at end of file diff --git a/src/app/hooks/useTransactionLifecycle.ts b/src/app/hooks/useTransactionLifecycle.ts new file mode 100644 index 0000000..684ba7b --- /dev/null +++ b/src/app/hooks/useTransactionLifecycle.ts @@ -0,0 +1,132 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { + TransactionState, + TransactionContext, + TransactionError, + TransactionLifecycle, +} from "@/app/types/transaction"; +import { mapTransactionError } from "@/app/utils/transactionErrors"; + +type StateTransition = + | { type: "START_BUILD"; context: TransactionContext } + | { type: "BUILD_SUCCESS" } + | { type: "AWAIT_SIGNATURE" } + | { type: "SIGNATURE_SUCCESS" } + | { type: "SUBMIT" } + | { type: "SUBMIT_SUCCESS"; txHash: string } + | { type: "CONFIRM" } + | { type: "CONFIRM_SUCCESS" } + | { type: "ERROR"; error: unknown } + | { type: "RESET" } + | { type: "RETRY" }; + +const initialState: TransactionLifecycle = { + state: "idle", + context: { operation: "" }, + error: null, + txHash: null, + startedAt: null, + completedAt: null, +}; + +export function useTransactionLifecycle() { + const [lifecycle, setLifecycle] = useState(initialState); + const submitLock = useRef(false); + + const transition = useCallback((action: StateTransition) => { + setLifecycle((prev) => { + // Idempotency guard: prevent double-submit + if (action.type === "SUBMIT" && submitLock.current) { + return prev; + } + + switch (action.type) { + case "START_BUILD": + submitLock.current = false; + return { + ...initialState, + state: "building", + context: action.context, + startedAt: Date.now(), + }; + + case "BUILD_SUCCESS": + if (prev.state !== "building") return prev; + return { ...prev, state: "awaiting-signature" }; + + case "AWAIT_SIGNATURE": + if (prev.state !== "building" && prev.state !== "idle") return prev; + return { ...prev, state: "awaiting-signature" }; + + case "SIGNATURE_SUCCESS": + if (prev.state !== "awaiting-signature") return prev; + return { ...prev, state: "submitting" }; + + case "SUBMIT": + if (prev.state !== "submitting" || submitLock.current) return prev; + submitLock.current = true; + return { ...prev, state: "submitting" }; + + case "SUBMIT_SUCCESS": + if (prev.state !== "submitting") return prev; + return { ...prev, state: "confirming", txHash: action.txHash }; + + case "CONFIRM": + if (prev.state !== "submitting" && prev.state !== "confirming") return prev; + return { ...prev, state: "confirming" }; + + case "CONFIRM_SUCCESS": + submitLock.current = false; + return { + ...prev, + state: "success", + completedAt: Date.now(), + }; + + case "ERROR": + submitLock.current = false; + return { + ...prev, + state: "error", + error: mapTransactionError(action.error) as TransactionError, + completedAt: Date.now(), + }; + + case "RETRY": + submitLock.current = false; + return { + ...prev, + state: "building", + error: null, + txHash: null, + completedAt: null, + }; + + case "RESET": + submitLock.current = false; + return initialState; + + default: + return prev; + } + }); + }, []); + + const canSubmit = lifecycle.state === "idle" || lifecycle.state === "error"; + const isProcessing = + lifecycle.state === "building" || + lifecycle.state === "awaiting-signature" || + lifecycle.state === "submitting" || + lifecycle.state === "confirming"; + + return { + lifecycle, + transition, + canSubmit, + isProcessing, + isSuccess: lifecycle.state === "success", + isError: lifecycle.state === "error", + }; +} diff --git a/src/app/types/transaction.ts b/src/app/types/transaction.ts new file mode 100644 index 0000000..ff60b10 --- /dev/null +++ b/src/app/types/transaction.ts @@ -0,0 +1,48 @@ +/** + * Unified transaction lifecycle states + * Used across all money flows: lend, repay, send-remittance, loan-wizard + */ +export type TransactionState = + | "idle" + | "building" + | "awaiting-signature" + | "submitting" + | "confirming" + | "success" + | "error"; + +export interface TransactionContext { + operation: string; // e.g., 'deposit', 'withdraw', 'repay', 'remit' + amount?: string; + asset?: string; + destination?: string; + metadata?: Record; +} + +export interface TransactionError { + category: TransactionErrorCategory; + title: string; + message: string; + guidance: string; + retryable: boolean; + cancelledByUser: boolean; + originalError?: unknown; +} + +export type TransactionErrorCategory = + | "wallet_rejected" + | "network_timeout" + | "insufficient_balance" + | "score_too_low" + | "onchain_failure" + | "simulation_failed" + | "unknown"; + +export interface TransactionLifecycle { + state: TransactionState; + context: TransactionContext; + error: TransactionError | null; + txHash: string | null; + startedAt: number | null; + completedAt: number | null; +} \ No newline at end of file diff --git a/src/app/utils/__tests__/pollTransactionStatus.test.ts b/src/app/utils/__tests__/pollTransactionStatus.test.ts new file mode 100644 index 0000000..104f06b --- /dev/null +++ b/src/app/utils/__tests__/pollTransactionStatus.test.ts @@ -0,0 +1,119 @@ +import { pollTransactionStatus } from "../transactionErrors"; + +// Mock fetch for tests +global.fetch = jest.fn(); + +describe("pollTransactionStatus", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("returns success when transaction is confirmed", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ successful: true }), + }); + + const result = await pollTransactionStatus("txhash123", { + horizonUrl: "https://horizon-testnet.stellar.org", + intervalMs: 100, + timeoutMs: 5000, + }); + + expect(result.status).toBe("success"); + expect(result.message).toBe("Transaction confirmed on-chain."); + }); + + it("returns failed when transaction fails on-chain", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ successful: false }), + }); + + const result = await pollTransactionStatus("txhash123", { + horizonUrl: "https://horizon-testnet.stellar.org", + intervalMs: 100, + timeoutMs: 5000, + }); + + expect(result.status).toBe("failed"); + expect(result.message).toBe("Transaction failed on-chain."); + }); + + it("returns timeout when transaction stays pending", async () => { + (fetch as jest.Mock).mockResolvedValue({ + status: 404, + ok: false, + }); + + const result = await pollTransactionStatus("txhash123", { + horizonUrl: "https://horizon-testnet.stellar.org", + intervalMs: 100, + timeoutMs: 300, + }); + + expect(result.status).toBe("timeout"); + expect(result.message).toBe("Transaction is still pending. You can retry status tracking."); + }); + + it("returns cancelled when abort signal is triggered", async () => { + jest.useFakeTimers(); + + (fetch as jest.Mock).mockResolvedValue({ + status: 404, + ok: false, + }); + + const controller = new AbortController(); + + const pollPromise = pollTransactionStatus("txhash123", { + horizonUrl: "https://horizon-testnet.stellar.org", + intervalMs: 100, + timeoutMs: 5000, + signal: controller.signal, + }); + + // Abort after a short delay + controller.abort(); + + // Advance timers so the polling sleep resolves and the abort check runs + await jest.advanceTimersByTimeAsync(100); + + const result = await pollPromise; + + expect(result.status).toBe("cancelled"); + expect(result.message).toBe("Status tracking cancelled by user."); + }); + + it("polls multiple times before success", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 404, + ok: false, + }) + .mockResolvedValueOnce({ + status: 404, + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ successful: true }), + }); + + const result = await pollTransactionStatus("txhash123", { + horizonUrl: "https://horizon-testnet.stellar.org", + intervalMs: 100, + timeoutMs: 5000, + }); + + expect(fetch).toHaveBeenCalledTimes(3); + expect(result.status).toBe("success"); + }); +}); diff --git a/src/app/utils/__tests__/transactionErrors.test.ts b/src/app/utils/__tests__/transactionErrors.test.ts new file mode 100644 index 0000000..fa2eada --- /dev/null +++ b/src/app/utils/__tests__/transactionErrors.test.ts @@ -0,0 +1,46 @@ +import { mapTransactionError, isRetryableError, getErrorDisplay } from '../transactionErrors'; + +describe('transactionErrors', () => { + it('maps user rejection', () => { + const error = mapTransactionError(new Error('User declined transaction')); + expect(error.code).toBe('USER_REJECTED'); + expect(error.retryable).toBe(true); + expect(error.actionable).toContain('approve'); + }); + + it('maps insufficient fee', () => { + const error = mapTransactionError(new Error('tx_insufficient_fee')); + expect(error.code).toBe('INSUFFICIENT_FEE'); + expect(error.actionable).toContain('XLM'); + }); + + it('maps timeout', () => { + const error = mapTransactionError(new Error('Transaction timeout')); + expect(error.code).toBe('TIMEOUT'); + }); + + it('maps sequence mismatch', () => { + const error = mapTransactionError(new Error('bad_seq')); + expect(error.code).toBe('SEQUENCE_MISMATCH'); + expect(error.actionable).toContain('refresh'); + }); + + it('maps contract error', () => { + const error = mapTransactionError(new Error('invoke_host_function error')); + expect(error.code).toBe('CONTRACT_ERROR'); + expect(error.retryable).toBe(false); + }); + + it('falls back to unknown for unmapped errors', () => { + const error = mapTransactionError(new Error('Something weird')); + expect(error.code).toBe('UNKNOWN'); + expect(error.retryable).toBe(true); + }); + + it('provides display format', () => { + const error = mapTransactionError(new Error('User declined')); + const display = getErrorDisplay(error); + expect(display.title).toBe('USER REJECTED'); + expect(display.canRetry).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/app/utils/transactionErrors.ts b/src/app/utils/transactionErrors.ts index 9ac42d4..fa32ab5 100644 --- a/src/app/utils/transactionErrors.ts +++ b/src/app/utils/transactionErrors.ts @@ -1,19 +1,10 @@ -export type TransactionErrorCategory = - | "wallet_rejected" - | "network_timeout" - | "insufficient_balance" - | "score_too_low" - | "onchain_failure" - | "simulation_failed" - | "unknown"; - -export interface TransactionErrorDetails { - category: TransactionErrorCategory; - title: string; +import { TransactionError, TransactionErrorCode } from '@/app/types/transaction'; + +interface ErrorMapping { + code: TransactionErrorCode; message: string; - guidance: string; + actionable: string; retryable: boolean; - cancelledByUser: boolean; } export interface PollTransactionOptions { @@ -52,13 +43,15 @@ export function mapTransactionError(error: unknown): TransactionErrorDetails { normalized.includes("rejected") || normalized.includes("denied") || normalized.includes("cancelled") || - normalized.includes("canceled") + normalized.includes("canceled") || + normalized.includes("declined") ) { return { category: "wallet_rejected", title: "Transaction cancelled", message: "You cancelled the signing request in your wallet.", - guidance: "No funds moved. You can review details and submit again when ready.", + guidance: + "No funds moved. You can review details and submit again when ready.", retryable: true, cancelledByUser: true, }; @@ -73,7 +66,8 @@ export function mapTransactionError(error: unknown): TransactionErrorDetails { category: "network_timeout", title: "Network issue", message: "The network request timed out or could not be completed.", - guidance: "Check connectivity and retry. If it keeps failing, try again in a few minutes.", + guidance: + "Check connectivity and retry. If it keeps failing, try again in a few minutes.", retryable: true, cancelledByUser: false, }; @@ -102,7 +96,8 @@ export function mapTransactionError(error: unknown): TransactionErrorDetails { category: "score_too_low", title: "Loan request not eligible", message: "Your credit score does not meet the minimum requirement.", - guidance: "Repay active loans on time and retry after your score improves.", + guidance: + "Repay active loans on time and retry after your score improves.", retryable: false, cancelledByUser: false, }; @@ -128,7 +123,8 @@ export function mapTransactionError(error: unknown): TransactionErrorDetails { category: "onchain_failure", title: "Transaction failed on-chain", message: "The transaction was submitted but did not succeed on-chain.", - guidance: "Check the transaction hash details and adjust inputs before retrying.", + guidance: + "Check the transaction hash details and adjust inputs before retrying.", retryable: false, cancelledByUser: false, }; @@ -138,64 +134,156 @@ export function mapTransactionError(error: unknown): TransactionErrorDetails { category: "unknown", title: "Transaction failed", message: rawMessage, - guidance: "Try again, or adjust the amount and wallet state before retrying.", + guidance: + "Try again, or adjust the amount and wallet state before retrying.", +const ERROR_MAP: Record = { + // Wallet/user errors + 'User declined': { + code: 'USER_REJECTED', + message: 'Transaction was rejected by user', + actionable: 'Please approve the transaction in your wallet to continue.', retryable: true, - cancelledByUser: false, - }; -} + }, + 'User rejected': { + code: 'USER_REJECTED', + message: 'Transaction was rejected by user', + actionable: 'Please approve the transaction in your wallet to continue.', + retryable: true, + }, + 'cancelled': { + code: 'USER_REJECTED', + message: 'Transaction was cancelled', + actionable: 'You cancelled the signing process. Click retry to try again.', + retryable: true, + }, -async function fetchTransactionStatus( - txHash: string, - horizonUrl: string, -): Promise<"pending" | "success" | "failed"> { - const response = await fetch(`${horizonUrl}/transactions/${txHash}`); + // Fee errors + 'insufficient fee': { + code: 'INSUFFICIENT_FEE', + message: 'Insufficient fee to submit transaction', + actionable: 'Your wallet balance is too low to cover the network fee. Please add more XLM and retry.', + retryable: true, + }, + 'tx_insufficient_fee': { + code: 'INSUFFICIENT_FEE', + message: 'Insufficient fee to submit transaction', + actionable: 'Your wallet balance is too low to cover the network fee. Please add more XLM and retry.', + retryable: true, + }, - if (response.status === 404) { - return "pending"; - } + // Timeout + 'timeout': { + code: 'TIMEOUT', + message: 'Transaction confirmation timed out', + actionable: 'The network is experiencing delays. Your transaction may still complete. Check your history before retrying.', + retryable: true, + }, + 'tx_too_late': { + code: 'TIMEOUT', + message: 'Transaction expired before confirmation', + actionable: 'The transaction expired. Please retry with an updated sequence number.', + retryable: true, + }, - if (!response.ok) { - throw new Error(`Unable to fetch transaction status (${response.status})`); - } + // Sequence errors + 'bad_seq': { + code: 'SEQUENCE_MISMATCH', + message: 'Transaction sequence number mismatch', + actionable: 'Your wallet sequence number is out of sync. Please refresh and retry.', + retryable: true, + }, + 'sequence': { + code: 'SEQUENCE_MISMATCH', + message: 'Transaction sequence number mismatch', + actionable: 'Your wallet sequence number is out of sync. Please refresh and retry.', + retryable: true, + }, - const payload = (await response.json()) as { successful?: boolean }; - return payload.successful ? "success" : "failed"; -} + // Contract errors + 'contract_error': { + code: 'CONTRACT_ERROR', + message: 'Smart contract execution failed', + actionable: 'The transaction failed during execution. Please verify your inputs and try again.', + retryable: false, + }, + 'invoke_host_function': { + code: 'CONTRACT_ERROR', + message: 'Smart contract execution failed', + actionable: 'The transaction failed during execution. Please verify your inputs and try again.', + retryable: false, + }, export async function pollTransactionStatus( txHash: string, { horizonUrl = process.env.NEXT_PUBLIC_HORIZON_URL ?? DEFAULT_HORIZON_URL, intervalMs = 2500, - timeoutMs = 30_000, + timeoutMs = 30000, signal, }: PollTransactionOptions = {}, ): Promise { const startedAt = Date.now(); + // Network + 'network_error': { + code: 'NETWORK_ERROR', + message: 'Network connection error', + actionable: 'Unable to connect to the Stellar network. Please check your connection and retry.', + retryable: true, + }, + 'connection': { + code: 'NETWORK_ERROR', + message: 'Network connection error', + actionable: 'Unable to connect to the Stellar network. Please check your connection and retry.', + retryable: true, + }, +}; - while (Date.now() - startedAt < timeoutMs) { - if (signal?.aborted) { +export function mapTransactionError(error: unknown): TransactionError { + const errorMessage = extractErrorMessage(error).toLowerCase(); + + // Find matching error pattern + for (const [pattern, mapping] of Object.entries(ERROR_MAP)) { + if (errorMessage.includes(pattern.toLowerCase())) { return { - status: "cancelled", - message: "Status tracking cancelled by user.", + ...mapping, + originalError: error, }; } + } - const status = await fetchTransactionStatus(txHash, horizonUrl); - - if (status === "success") { - return { status: "success", message: "Transaction confirmed on-chain." }; - } - - if (status === "failed") { - return { status: "failed", message: "Transaction failed on-chain." }; - } + // Fallback for unknown errors + return { + code: 'UNKNOWN', + message: extractErrorMessage(error) || 'An unexpected error occurred', + actionable: 'Something went wrong. Please try again or contact support if the issue persists.', + retryable: true, + originalError: error, + }; +} - await new Promise((resolve) => setTimeout(resolve, intervalMs)); +function extractErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (error && typeof error === 'object' && 'message' in error) { + return String((error as Record).message); } + return 'Unknown error'; +} +export function isRetryableError(error: TransactionError): boolean { + return error.retryable; +} + +export function getErrorDisplay(error: TransactionError): { + title: string; + description: string; + action: string; + canRetry: boolean; +} { return { - status: "timeout", - message: "Transaction is still pending. You can retry status tracking.", + title: error.code.replace(/_/g, ' '), + description: error.message, + action: error.actionable, + canRetry: error.retryable, }; -} +} \ No newline at end of file diff --git a/src/app/utils/transactionFormatter.ts b/src/app/utils/transactionFormatter.ts index 84a9898..a467995 100644 --- a/src/app/utils/transactionFormatter.ts +++ b/src/app/utils/transactionFormatter.ts @@ -1,238 +1,61 @@ -/** - * utils/transactionFormatter.ts - * - * Utility functions for formatting transaction data into human-readable descriptions. - * Converts contract calls and blockchain operations into clear, user-friendly text. - */ - -import type { - TransactionOperation, - BalanceChange, - TransactionPreviewData, -} from "../components/transaction/TransactionPreviewModal"; - -// ─── Transaction Type Formatters ───────────────────────────────────────────── - -export interface LoanRequestParams { - amount: number; - borrower: string; -} - -export interface LoanRepaymentParams { - loanId: number; - amount: number; -} - -export interface DepositParams { - amount: number; - token: string; -} - -export interface WithdrawParams { - amount: number; - token: string; -} - -/** - * Format a loan request transaction - */ -export function formatLoanRequest(params: LoanRequestParams): TransactionPreviewData { - const operations: TransactionOperation[] = [ - { - type: "Request Loan", - description: `You are requesting a loan of ${params.amount} USDC`, - amount: params.amount.toString(), - token: "USDC", - details: { - "Borrower Address": `${params.borrower.slice(0, 8)}...${params.borrower.slice(-6)}`, - "Loan Status": "Pending Approval", - }, - }, - ]; - - const balanceChanges: BalanceChange[] = [ - { - token: "USDC", - change: `${params.amount}`, - isPositive: true, - }, - ]; - - return { - operations, - balanceChanges, - estimatedGasFee: "0.00001", - network: "Stellar Testnet", - }; -} - -/** - * Format a loan repayment transaction - */ -export function formatLoanRepayment(params: LoanRepaymentParams): TransactionPreviewData { - const operations: TransactionOperation[] = [ - { - type: "Repay Loan", - description: `You are repaying ${params.amount} USDC for Loan #${params.loanId}`, - amount: params.amount.toString(), - token: "USDC", - details: { - "Loan ID": params.loanId.toString(), - "Payment Type": "Principal + Interest", - }, - }, - ]; - - const balanceChanges: BalanceChange[] = [ - { - token: "USDC", - change: `-${params.amount}`, - isPositive: false, - }, - ]; - - return { - operations, - balanceChanges, - estimatedGasFee: "0.00001", - network: "Stellar Testnet", +import { TransactionState, TransactionError } from '@/app/types/transaction'; + +export function getStatusLabel(state: TransactionState): string { + const labels: Record = { + idle: 'Ready', + building: 'Preparing transaction...', + 'awaiting-signature': 'Waiting for wallet signature...', + submitting: 'Submitting to network...', + confirming: 'Confirming on blockchain...', + success: 'Transaction complete!', + error: 'Transaction failed', }; -} - -/** - * Format a deposit transaction - */ -export function formatDeposit(params: DepositParams): TransactionPreviewData { - const operations: TransactionOperation[] = [ - { - type: "Deposit", - description: `You are depositing ${params.amount} ${params.token} into the lending pool`, - amount: params.amount.toString(), - token: params.token, - details: { - "Pool Type": "Lending Pool", - "Expected Yield": "~8-12% APY", - }, - }, - ]; - - const balanceChanges: BalanceChange[] = [ - { - token: params.token, - change: `-${params.amount}`, - isPositive: false, - }, - { - token: `LP-${params.token}`, - change: `${params.amount}`, - isPositive: true, - }, - ]; - - return { - operations, - balanceChanges, - estimatedGasFee: "0.00001", - network: "Stellar Testnet", + return labels[state]; +} + +export function getStatusColor(state: TransactionState): string { + const colors: Record = { + idle: 'text-gray-500', + building: 'text-blue-500', + 'awaiting-signature': 'text-yellow-500', + submitting: 'text-orange-500', + confirming: 'text-purple-500', + success: 'text-green-500', + error: 'text-red-500', }; -} - -/** - * Format a withdrawal transaction - */ -export function formatWithdraw(params: WithdrawParams): TransactionPreviewData { - const operations: TransactionOperation[] = [ - { - type: "Withdraw", - description: `You are withdrawing ${params.amount} ${params.token} from the lending pool`, - amount: params.amount.toString(), - token: params.token, - details: { - "Pool Type": "Lending Pool", - "Withdrawal Type": "Full Amount + Earned Interest", - }, - }, - ]; - - const balanceChanges: BalanceChange[] = [ - { - token: `LP-${params.token}`, - change: `-${params.amount}`, - isPositive: false, - }, - { - token: params.token, - change: `${params.amount}`, - isPositive: true, - }, - ]; - - return { - operations, - balanceChanges, - estimatedGasFee: "0.00001", - network: "Stellar Testnet", + return colors[state]; +} + +export function getStatusIcon(state: TransactionState): string { + const icons: Record = { + idle: '⏸️', + building: '🔧', + 'awaiting-signature': '✍️', + submitting: '📡', + confirming: '⏳', + success: '✅', + error: '❌', }; + return icons[state]; } -/** - * Format a remittance send transaction - */ -export function formatRemittanceSend(params: { - amount: number; - recipient: string; - token: string; -}): TransactionPreviewData { - const operations: TransactionOperation[] = [ - { - type: "Send Remittance", - description: `You are sending ${params.amount} ${params.token} to ${params.recipient.slice(0, 8)}...${params.recipient.slice(-6)}`, - amount: params.amount.toString(), - token: params.token, - details: { - Recipient: `${params.recipient.slice(0, 8)}...${params.recipient.slice(-6)}`, - "Transfer Type": "Cross-border Remittance", - "Credit Score Impact": "+5 points", - }, - }, - ]; - - const balanceChanges: BalanceChange[] = [ - { - token: params.token, - change: `-${params.amount}`, - isPositive: false, - }, - ]; - - return { - operations, - balanceChanges, - estimatedGasFee: "0.00001", - network: "Stellar Testnet", - }; -} - -/** - * Format a generic contract interaction - */ -export function formatGenericTransaction(params: { - contractMethod: string; +export function formatTransactionError(error: TransactionError): { + title: string; description: string; - args?: Record; -}): TransactionPreviewData { - const operations: TransactionOperation[] = [ - { - type: params.contractMethod, - description: params.description, - details: params.args, - }, - ]; - + action: string; + canRetry: boolean; +} { return { - operations, - balanceChanges: [], - estimatedGasFee: "0.00001", - network: "Stellar Testnet", + title: error.code.replace(/_/g, ' '), + description: error.message, + action: error.actionable, + canRetry: error.retryable, }; } + +export function getExplorerLink(txHash: string, network: 'testnet' | 'public' = 'testnet'): string { + const base = network === 'public' + ? 'https://stellar.expert/explorer/public' + : 'https://stellar.expert/explorer/testnet'; + return `${base}/tx/${txHash}`; +} \ No newline at end of file