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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
648 changes: 441 additions & 207 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@tailwindcss/postcss": "^4.0.0",
"@tanstack/react-query": "^5.99.2",
"axios": "^1.15.0",
"bignumber.js": "^11.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
Expand Down
28 changes: 12 additions & 16 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import "./globals.css";
import React from "react";
import { Inter } from "next/font/google";
import { Toaster } from "../components/ui/Toaster";

import ToasterProvider from "../components/general/ToasterProvider";
import { SlippageProvider } from "../contexts/SlippageContext";
import { NetworkCongestionProvider } from "../contexts/NetworkCongestionContext";
import { BackendHealthProvider } from "../contexts/BackendHealthContext";
Expand Down Expand Up @@ -31,21 +32,16 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en" className={inter.variable}>
<body className="font-sans antialiased">
<ErrorBoundary>
<SettingsProvider>
<NetworkGuard>
<NetworkCongestionProvider>
<SlippageProvider>
<NetworkMismatchWarning />
<Toaster />
<NetworkCongestionBanner />
<QueryProvider>
<PageTransition>{children}</PageTransition>
</QueryProvider>
<Footer />
</SlippageProvider>
</NetworkCongestionProvider>
</NetworkGuard>
</SettingsProvider>
<NetworkCongestionProvider>
<SlippageProvider>
<ToasterProvider />
<NetworkCongestionBanner />
<PageTransition>
{children}
</PageTransition>
<Footer />
</SlippageProvider>
</NetworkCongestionProvider>
</ErrorBoundary>
</body>
</html>
Expand Down
38 changes: 21 additions & 17 deletions src/app/marketplace/inv-123/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import FractionalPurchaseModal, {
import DynamicRiskAssessmentChart from "../../../components/DynamicRiskAssessmentChart";
import RepayInvoiceButton from "../../../components/RepayInvoiceButton";
import { useTokenStore } from "../../../stores/tokenStore";
import { useInvoice } from "../../../hooks/useInvoice";
import { ArrowLeft, ExternalLink, TrendingUp } from "lucide-react";
import { useTxWithToast } from "../../../hooks/useTxWithToast";
import { ArrowLeft, ExternalLink, Shield, TrendingUp } from "lucide-react";
import Link from "next/link";
import Icon from "../../../components/ui/Icon";

Expand Down Expand Up @@ -46,17 +46,9 @@ export default function InvoiceDetailPage() {
const [showBuyModal, setShowBuyModal] = useState(false);
const [usdcBalance, setUsdcBalance] = useState("0");

// Issue #189: Fetch on-chain invoice data via the useInvoice hook.
const { invoice, loading, error } = useInvoice("INV-00123");
const { executeTx } = useTxWithToast();

// Determine if the connected wallet is the original issuer (shows Repay CTA).
const isIssuer = invoice?.issuer === publicKey;

// Calculate total due: principal + 8.5% APY interest (simplified).
const principal = invoice ? Number(invoice.amount) / 10_000_000 : INVOICE_DATA.faceValue;
const totalDue = principal * (1 + INVOICE_DATA.apy / 100);

// Fetch live USDC balance from Stellar network whenever wallet connects.
// Fetch live USDC balance from Stellar network whenever wallet connects
useEffect(() => {
if (!isConnected || !publicKey) {
setUsdcBalance("0");
Expand All @@ -65,9 +57,12 @@ export default function InvoiceDetailPage() {

const fetchBalance = async () => {
try {
const accountResponse = await server.accounts().accountId(publicKey).call();
const accountResponse = await server
.accounts()
.accountId(publicKey)
.call();
const usdcEntry = accountResponse.balances.find(
(b: any) =>
(b: { asset_code?: string; asset_issuer?: string }) =>
b.asset_code === USDC_CODE && b.asset_issuer === USDC_ISSUER
);
setUsdcBalance(usdcEntry ? usdcEntry.balance : "0");
Expand All @@ -79,12 +74,21 @@ export default function InvoiceDetailPage() {
fetchBalance();
}, [isConnected, publicKey]);

// Wrapping the Soroban call with executeTx ensures any Freighter error
// (user rejection, network failure, contract error) fires the correct toast.
const handleBuyFraction = async (
amountStroops: string,
invoiceId: string
) => {
console.log("buy_fraction called:", { invoiceId, amountStroops });
await new Promise((r) => setTimeout(r, 1500));
): Promise<void> => {
await executeTx(async () => {
// TODO: Replace the lines below with your real Soroban client call, e.g.:
// await sorobanClient.buy_fraction({
// invoice_id: invoiceId,
// amount: BigInt(amountStroops),
// });
console.log("buy_fraction called:", { invoiceId, amountStroops });
await new Promise((r) => setTimeout(r, 1500));
});
};

return (
Expand Down
45 changes: 29 additions & 16 deletions src/components/FractionalPurchaseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React, { useState } from "react";
import BigNumber from "bignumber.js";
import { X, Wallet, TrendingUp, Zap } from "lucide-react";
import Button from "./ui/Button";
import Icon from "./ui/Icon";
import { useTxWithToast } from "../hooks/useTxWithToast";

BigNumber.config({ EXPONENTIAL_AT: 1e9, DECIMAL_PLACES: 7 });

Expand Down Expand Up @@ -57,45 +57,56 @@ export default function FractionalPurchaseModal({
}: FractionalPurchaseModalProps) {
const [amount, setAmount] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);

const { executeTx } = useTxWithToast();

const days = daysToMaturity(invoice.dueDate);
const balance = new BigNumber(walletBalance || "0");
const entered = new BigNumber(amount || "0");
const isValid = entered.isGreaterThan(0) && entered.isLessThanOrEqualTo(balance);
const isValid =
entered.isGreaterThan(0) && entered.isLessThanOrEqualTo(balance);
const { yieldAmt, total } = calcExpectedReturn(amount, invoice.apy, days);

const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (/^\d*\.?\d{0,7}$/.test(val) || val === "") {
setAmount(val);
setError(null);
setValidationError(null);
}
};

const handleMax = () => {
setAmount(balance.toFixed(7));
setError(null);
setValidationError(null);
};

const handleSubmit = async () => {
// Validation errors stay inline — these are user input issues, not tx errors
if (!isValid) {
setError(
setValidationError(
entered.isGreaterThan(balance)
? "Amount exceeds wallet balance."
: "Enter a valid amount greater than 0."
);
return;
}

setIsSubmitting(true);
setError(null);
try {
await onBuyFraction(toStroops(amount), invoice.id);
setValidationError(null);

// executeTx wraps the call in try/catch and fires the correct toast:
// - "User Rejected Signature" → warning toast
// - Network / Contract error → error toast
// Returns null if the transaction failed (toast already shown).
const result = await executeTx(() =>
onBuyFraction(toStroops(amount), invoice.id)
);

setIsSubmitting(false);

if (result !== null) {
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Transaction failed. Try again.");
} finally {
setIsSubmitting(false);
}
};

Expand Down Expand Up @@ -129,7 +140,9 @@ export default function FractionalPurchaseModal({
</div>
<div className="bg-slate-700/50 rounded-xl p-3 text-center">
<p className="text-xs text-slate-400 mb-1">APY</p>
<p className="text-emerald-400 font-semibold text-sm">{invoice.apy}%</p>
<p className="text-emerald-400 font-semibold text-sm">
{invoice.apy}%
</p>
</div>
<div className="bg-slate-700/50 rounded-xl p-3 text-center">
<p className="text-xs text-slate-400 mb-1">Days Left</p>
Expand Down Expand Up @@ -175,8 +188,8 @@ export default function FractionalPurchaseModal({
{invoice.currency}
</span>
</div>
{error && (
<p className="mt-2 text-sm text-red-400">{error}</p>
{validationError && (
<p className="mt-2 text-sm text-red-400">{validationError}</p>
)}
<p className="mt-1.5 text-xs text-slate-500">
Up to 7 decimal places (Stellar precision)
Expand Down
Empty file.
59 changes: 59 additions & 0 deletions src/hooks/useTxWithToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

/**
* useTxWithToast.ts
* Wraps Freighter transaction calls in try/catch and fires the
* correct toast on failure using react-hot-toast.
*
* Issue #181 - BETAIL-BOYS/TradeFlow-Web
* Place at: src/hooks/useTxWithToast.ts
*/

import { useCallback } from "react";
import toast from "react-hot-toast";
import { parseFreighterError, ERROR_TYPE } from "../lib/freighterErrors";

interface UseTxWithToastReturn {
executeTx: <T>(fn: () => Promise<T>) => Promise<T | null>;
showTxError: (error: unknown) => void;
}

export function useTxWithToast(): UseTxWithToastReturn {
const showTxError = useCallback((error: unknown) => {
const { type, message } = parseFreighterError(error);

if (type === ERROR_TYPE.USER_REJECTED) {
toast(message, {
icon: "⚠️",
style: {
background: "#FEF9C3",
color: "#854D0E",
border: "1px solid #FDE047",
},
});
} else {
toast.error(message, {
style: {
background: "#FEE2E2",
color: "#991B1B",
border: "1px solid #FCA5A5",
},
});
}
}, []);

const executeTx = useCallback(
async <T>(fn: () => Promise<T>): Promise<T | null> => {
try {
const result = await fn();
return result;
} catch (error: unknown) {
showTxError(error);
return null;
}
},
[showTxError]
);

return { executeTx, showTxError };
}
110 changes: 110 additions & 0 deletions src/lib/freighterErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* freighterErrors.ts
* Parses Freighter / Soroban errors into human-readable UI strings.
* Issue #181 - BETAIL-BOYS/TradeFlow-Web
*/

export const ERROR_TYPE = {
USER_REJECTED: "USER_REJECTED",
NETWORK_ERROR: "NETWORK_ERROR",
CONTRACT_ERROR: "CONTRACT_ERROR",
UNKNOWN: "UNKNOWN",
} as const;

export type ErrorType = (typeof ERROR_TYPE)[keyof typeof ERROR_TYPE];

export interface ParsedTxError {
type: ErrorType;
message: string;
}

const USER_REJECTED_PATTERNS: string[] = [
"user rejected",
"user declined",
"rejected by user",
"transaction was rejected",
"signing rejected",
"cancelled",
"canceled",
];

const SOROBAN_CODE_MAP: Record<number, string> = {
1: "The contract rejected this operation.",
2: "Wasm execution ran out of resources.",
3: "Contract storage limit exceeded.",
4: "Contract call stack overflow.",
10: "Transaction sequence number is out of order.",
11: "Insufficient account balance to cover fees.",
12: "Transaction fee is too low.",
13: "Transaction has already been processed.",
20: "Network request timed out. Please try again.",
21: "RPC node is temporarily unavailable.",
22: "Simulation failed before submission.",
23: "Transaction was dropped from the mempool.",
};

function extractMessage(error: unknown): string {
if (typeof error === "string") return error;
if (error instanceof Error) return error.message ?? "";
if (error !== null && typeof error === "object") {
const e = error as Record<string, unknown>;
return String(e["message"] ?? e["error"] ?? e["detail"] ?? JSON.stringify(error));
}
return String(error ?? "");
}

export function parseFreighterError(error: unknown): ParsedTxError {
const raw = extractMessage(error);
const lower = raw.toLowerCase();

const isRejected = USER_REJECTED_PATTERNS.some((p) => lower.includes(p));
if (isRejected) {
return {
type: ERROR_TYPE.USER_REJECTED,
message: "You cancelled the transaction. No funds were moved.",
};
}

const codeMatch = raw.match(/\b(\d+)\b/);
if (codeMatch) {
const code = parseInt(codeMatch[1], 10);
const mapped = SOROBAN_CODE_MAP[code];
if (mapped) {
return {
type: code >= 20 ? ERROR_TYPE.NETWORK_ERROR : ERROR_TYPE.CONTRACT_ERROR,
message: mapped,
};
}
}

if (
lower.includes("network") ||
lower.includes("timeout") ||
lower.includes("connection") ||
lower.includes("rpc") ||
lower.includes("fetch")
) {
return {
type: ERROR_TYPE.NETWORK_ERROR,
message: "A network error occurred. Check your connection and try again.",
};
}

if (
lower.includes("contract") ||
lower.includes("invoke") ||
lower.includes("wasm") ||
lower.includes("scval") ||
lower.includes("simulation")
) {
return {
type: ERROR_TYPE.CONTRACT_ERROR,
message: "The contract rejected this transaction. Please try again.",
};
}

return {
type: ERROR_TYPE.UNKNOWN,
message: "Something went wrong. Please try again.",
};
}
Loading