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
-
-
-
- )}
-
-
-
-
-
- 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 */}
-
-
-
- {[
- { 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}
-
- )}
-
-
-
-
-
-
- }
- >
- Sign & Submit
-
-
-
-
-
- {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 && (
+
+ )}
+
+ {/* 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