diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..efc13c9 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -31,6 +31,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) { Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" }, Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" }, Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" }, + Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" }, }; const { color, icon } = config[status]; return ( @@ -202,10 +203,13 @@ export default function GovernancePage() { }, []); useEffect(() => { - load(); + const timeout = window.setTimeout(load, 0); // Refresh every 30 s for real-time vote counts - const interval = setInterval(load, 30_000); - return () => clearInterval(interval); + const interval = window.setInterval(load, 30_000); + return () => { + window.clearTimeout(timeout); + window.clearInterval(interval); + }; }, [load]); useEffect(() => { diff --git a/app/pay/[id]/__tests__/PayInvoice.test.tsx b/app/pay/[id]/__tests__/PayInvoice.test.tsx index 9742df0..ea0e9b2 100644 --- a/app/pay/[id]/__tests__/PayInvoice.test.tsx +++ b/app/pay/[id]/__tests__/PayInvoice.test.tsx @@ -1,96 +1,106 @@ -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import PayInvoicePage from '../page'; -import * as soroban from '../../../../utils/soroban'; -import { useWallet } from '../../../../context/WalletContext'; -import { useToast } from '../../../../context/ToastContext'; +import { render, screen, waitFor, fireEvent, within } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import PayInvoicePage from "../page"; +import * as soroban from "@/utils/soroban"; +import { useWallet } from "@/context/WalletContext"; +import { useToast } from "@/context/ToastContext"; +import type { Invoice } from "@/utils/soroban"; // Mock context and utils -vi.mock('../../../../context/WalletContext', () => ({ +vi.mock("@/context/WalletContext", () => ({ useWallet: vi.fn(), })); -vi.mock('../../../../context/ToastContext', () => ({ +vi.mock("@/context/ToastContext", () => ({ useToast: vi.fn(), })); -vi.mock('../../../../utils/soroban', () => ({ +vi.mock("@/utils/soroban", () => ({ getInvoice: vi.fn(), markPaid: vi.fn(), submitSignedTransaction: vi.fn(), + transferLpPosition: vi.fn(), })); -describe('PayInvoicePage', () => { - const mockInvoice = { +const PAYER = "GCSXPYZSTPKX2GDVW6XSJDBE3PVSNSXCCLTGSPJXNF57IJU5EDU6IUDV"; +const FUNDER = "GDIEC472DEK3S5UWVKYDBXG74R53KMHGXGFIURLJUF6P6JJ352HLLJED"; +const NEW_FUNDER = "GCBXRCREFHR6YEJ4VFRGRLRACPGWSZUKRIKG3ZNGV5DGYIUEE2GTWYNO"; + +type TestParams = Promise<{ id: string }> & { _resolvedValue?: { id: string } }; + +function createParams(): TestParams { + const params = Promise.resolve({ id: "1" }) as TestParams; + params._resolvedValue = { id: "1" }; + return params; +} + +describe("PayInvoicePage", () => { + const mockInvoice: Invoice = { id: 1n, - freelancer: 'GFREELANCER', - payer: 'GPAYER', + freelancer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + payer: PAYER, amount: 1000000000n, amount_paid: 0n, due_date: 1713960000n, - status: 'Funded', + discount_rate: 500, + status: "Funded", + funder: FUNDER, }; const mockToast = { - addToast: vi.fn().mockReturnValue('toast-id'), + addToast: vi.fn().mockReturnValue("toast-id"), updateToast: vi.fn(), }; beforeEach(() => { vi.clearAllMocks(); - (useToast as any).mockReturnValue(mockToast); - (soroban.getInvoice as any).mockResolvedValue(mockInvoice); + vi.mocked(useToast).mockReturnValue(mockToast as ReturnType); + vi.mocked(soroban.getInvoice).mockResolvedValue(mockInvoice); }); - it('should render invoice summary without wallet connection', async () => { - (useWallet as any).mockReturnValue({ + it("should render invoice summary without wallet connection", async () => { + vi.mocked(useWallet).mockReturnValue({ address: null, connect: vi.fn(), - }); + } as unknown as ReturnType); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { expect(screen.getByText(/100\s+USDC/)).toBeInTheDocument(); - expect(screen.getByText('Connect Wallet and Pay')).toBeInTheDocument(); + expect(screen.getByText("Connect Wallet and Pay")).toBeInTheDocument(); }); }); - it('should show warning if connected wallet is not the payer', async () => { - (useWallet as any).mockReturnValue({ - address: 'GWRONGWALLET', + it("should show warning if connected wallet is neither payer nor LP", async () => { + vi.mocked(useWallet).mockReturnValue({ + address: NEW_FUNDER, connect: vi.fn(), - }); + } as unknown as ReturnType); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { - expect(screen.getByText('Address Mismatch')).toBeInTheDocument(); - expect(screen.getByText('Restricted to Registered Payer')).toBeInTheDocument(); + expect(screen.getByText("Address Mismatch")).toBeInTheDocument(); + expect(screen.getByText("Restricted to Registered Payer")).toBeInTheDocument(); }); }); - it('should show confirmation if invoice is already paid', async () => { - (soroban.getInvoice as any).mockResolvedValue({ + it("should show confirmation if invoice is already paid", async () => { + vi.mocked(soroban.getInvoice).mockResolvedValue({ ...mockInvoice, - status: 'Paid', + status: "Paid", }); - (useWallet as any).mockReturnValue({ - address: 'GPAYER', - }); + vi.mocked(useWallet).mockReturnValue({ + address: PAYER, + } as unknown as ReturnType); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { - expect(screen.getByText('Invoice settled')).toBeInTheDocument(); - expect(screen.getByText('Settlement Complete')).toBeInTheDocument(); + expect(screen.getByText("Invoice settled")).toBeInTheDocument(); + expect(screen.getByText("Settlement Complete")).toBeInTheDocument(); }); }); @@ -130,17 +140,17 @@ describe('PayInvoicePage', () => { it('should call markPaid with correct amount when payment is confirmed', async () => { const mockSignTx = vi.fn(); - (useWallet as any).mockReturnValue({ - address: 'GPAYER', + vi.mocked(useWallet).mockReturnValue({ + address: PAYER, signTx: mockSignTx, - }); + } as unknown as ReturnType); - (soroban.markPaid as any).mockResolvedValue('mock-tx'); - (soroban.submitSignedTransaction as any).mockResolvedValue({ txHash: 'hash123' }); + vi.mocked(soroban.markPaid).mockResolvedValue( + "mock-tx" as unknown as Awaited>, + ); + vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: "hash123" }); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); // Open modal await waitFor(() => { @@ -158,7 +168,37 @@ describe('PayInvoicePage', () => { await waitFor(() => { expect(soroban.markPaid).toHaveBeenCalledWith('GPAYER', 1n, 500000000n); // 50 USDC in stroops expect(soroban.submitSignedTransaction).toHaveBeenCalled(); - expect(mockToast.updateToast).toHaveBeenCalledWith('toast-id', expect.objectContaining({ type: 'success' })); + expect(mockToast.updateToast).toHaveBeenCalledWith("toast-id", expect.objectContaining({ type: "success" })); + }); + }); + + it("shows LP-only transfer action and updates the LP field after transfer", async () => { + const mockSignTx = vi.fn(); + vi.mocked(useWallet).mockReturnValue({ + address: FUNDER, + connect: vi.fn(), + signTx: mockSignTx, + } as unknown as ReturnType); + + vi.mocked(soroban.transferLpPosition).mockResolvedValue( + "transfer-tx" as unknown as Awaited>, + ); + vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: "transfer-hash" }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Transfer Position" })); + fireEvent.change(screen.getByLabelText("New LP address"), { + target: { value: NEW_FUNDER }, + }); + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Transfer Position" })); + + await waitFor(() => { + expect(soroban.transferLpPosition).toHaveBeenCalledWith(FUNDER, 1n, NEW_FUNDER); + expect(soroban.submitSignedTransaction).toHaveBeenCalledWith({ + tx: "transfer-tx", + signTx: mockSignTx, + }); }); }); diff --git a/app/pay/[id]/page.tsx b/app/pay/[id]/page.tsx index e93c036..075fe04 100644 --- a/app/pay/[id]/page.tsx +++ b/app/pay/[id]/page.tsx @@ -2,12 +2,18 @@ import { use, useEffect, useState, useCallback } from "react"; import Link from "next/link"; -import { getInvoice, markPaid, submitSignedTransaction, type Invoice } from "@/utils/soroban"; +import { + getInvoice, + markPaid, + submitSignedTransaction, + transferLpPosition, + type Invoice, +} from "@/utils/soroban"; import { formatAddress } from "@/utils/format"; import { formatUsdcFromStroops } from "@/utils/invoiceSubmission"; import { useWallet } from "@/context/WalletContext"; import { useToast } from "@/context/ToastContext"; -import { TESTNET_USDC_TOKEN_ID, NETWORK_NAME } from "@/constants"; +import { NETWORK_NAME } from "@/constants"; import ActivityFeed from "@/components/ActivityFeed"; import PartialPaymentModal from "@/components/PartialPaymentModal"; @@ -39,7 +45,8 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin }, [invoiceId]); useEffect(() => { - fetchInvoice(); + const timeout = window.setTimeout(fetchInvoice, 0); + return () => window.clearTimeout(timeout); }, [fetchInvoice]); const handlePaymentConfirm = async (amount: bigint) => { @@ -69,13 +76,29 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin updateToast(toastId, { type: "error", title: "Payment Failed", - message: err.message || "An unexpected error occurred during payment." + message: err instanceof Error ? err.message : "An unexpected error occurred during payment." }); } finally { setIsPaying(false); } }; + const handleTransferPosition = async (newLpAddress: string) => { + if (!address || !invoice) return; + + setTransferError(null); + try { + await transferTransaction.runTransaction(async () => { + const tx = await transferLpPosition(address, invoice.id, newLpAddress); + return submitSignedTransaction({ tx, signTx }); + }); + setInvoice({ ...invoice, funder: newLpAddress }); + setIsTransferModalOpen(false); + } catch (err) { + setTransferError(err instanceof Error ? err.message : "Failed to transfer LP position."); + } + }; + if (loadState === "loading") { return (
@@ -97,6 +120,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin } const isPayer = address === invoice.payer; + const isCurrentLp = Boolean(address && invoice.funder && address === invoice.funder); const isPaid = invoice.status === "Paid"; const isFunded = invoice.status === "Funded"; @@ -123,7 +147,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin )} - {address && !isPayer && !isPaid && ( + {address && !isPayer && !isCurrentLp && !isPaid && (
warning
diff --git a/src/components/LPDashboard.tsx b/src/components/LPDashboard.tsx index da5df7b..631e566 100644 --- a/src/components/LPDashboard.tsx +++ b/src/components/LPDashboard.tsx @@ -21,6 +21,7 @@ import { getTokenAllowance, Invoice, submitSignedTransaction, + transferLpPosition, } from "@/utils/soroban"; import { formatAddress, formatDate, formatTokenAmount, calculateYield } from "@/utils/format"; import { useWatchlist } from "@/hooks/useWatchlist"; @@ -40,6 +41,7 @@ import DynamicYieldAnalyticsChart from "./DynamicYieldAnalyticsChart"; import LPSettingsModal from "./LPSettingsModal"; import { useLPSettings } from "@/hooks/useLPSettings"; import type { DataTableColumn } from "./DataTable"; +import { useTransaction } from "@/hooks/useTransaction"; type Tab = "discovery" | "my-funded" | "watchlist" | "earnings-history"; @@ -91,8 +93,12 @@ export default function LPDashboard() { } else { addToast({ type: "success", title: "Removed from Watchlist" }); } - } catch (error: any) { - addToast({ type: "error", title: "Watchlist Error", message: error.message }); + } catch (error) { + addToast({ + type: "error", + title: "Watchlist Error", + message: error instanceof Error ? error.message : "Unable to update watchlist.", + }); } }; @@ -129,7 +135,10 @@ export default function LPDashboard() { useEffect(() => { if (!selectedInvoice || !address) return; - void refreshAllowance(selectedInvoice, address); + const timeout = window.setTimeout(() => { + void refreshAllowance(selectedInvoice, address); + }, 0); + return () => window.clearTimeout(timeout); }, [address, refreshAllowance, selectedInvoice]); const toggleInvoiceSelection = (id: string) => { @@ -188,7 +197,7 @@ export default function LPDashboard() { const filteredInvoices = useMemo( () => - applyInvoiceFilters(invoices, filters, { + applyInvoiceFilters(invoicesWithTransferredFunders, filters, { resolveTokenSymbol: (invoice) => { const token = tokenMap.get(invoice.token ?? defaultToken?.contractId ?? ""); return token?.symbol ?? "USDC"; @@ -199,7 +208,7 @@ export default function LPDashboard() { ); - const sortedInvoices = useMemo(() => [...filteredInvoices].sort((a: any, b: any) => { + const sortedInvoices = useMemo(() => [...filteredInvoices].sort((a, b) => { if (sortKey === "risk") { const ra = RISK_SORT_ORDER[payerRisks.get(a.payer) ?? "Unknown"]; const rb = RISK_SORT_ORDER[payerRisks.get(b.payer) ?? "Unknown"]; @@ -214,6 +223,9 @@ export default function LPDashboard() { } const aVal = a[sortKey]; const bVal = b[sortKey]; + if (aVal === undefined && bVal === undefined) return 0; + if (aVal === undefined) return sortOrder === "asc" ? -1 : 1; + if (bVal === undefined) return sortOrder === "asc" ? 1 : -1; if (aVal < bVal) return sortOrder === "asc" ? -1 : 1; if (aVal > bVal) return sortOrder === "asc" ? 1 : -1; return 0; @@ -262,7 +274,7 @@ export default function LPDashboard() { } }; - const handleKeyDown = (e: React.KeyboardEvent, invoice: any, index: number) => { + const handleKeyDown = (e: React.KeyboardEvent, invoice: Invoice, index: number) => { const rowElements = Array.from(e.currentTarget.parentElement?.querySelectorAll('tr[role="row"]') || []); switch (e.key) { @@ -288,7 +300,7 @@ export default function LPDashboard() { } }; - const commonColumns: DataTableColumn[] = [ + const commonColumns: DataTableColumn[] = [ { id: "id", label: "ID", @@ -354,7 +366,7 @@ export default function LPDashboard() { }, ]; - const discoveryColumns: DataTableColumn[] = [ + const discoveryColumns: DataTableColumn[] = [ ...commonColumns, { id: "risk", @@ -398,7 +410,7 @@ export default function LPDashboard() { }, ]; - const watchlistColumns: DataTableColumn[] = [ + const watchlistColumns: DataTableColumn[] = [ ...commonColumns, { id: "watchAddedAt", @@ -708,7 +720,7 @@ export default function LPDashboard() { {activeTab === "watchlist" && ( - {new Date(invoice.watchAddedAt).toLocaleDateString()} + {new Date(getWatchAddedAt(invoice)).toLocaleDateString()} )} {activeTab === "discovery" && ( diff --git a/src/components/LPPortfolio.tsx b/src/components/LPPortfolio.tsx index e6f9dc9..6d41a71 100644 --- a/src/components/LPPortfolio.tsx +++ b/src/components/LPPortfolio.tsx @@ -11,11 +11,15 @@ import LPPortfolioAllocationChart from "./LPPortfolioAllocationChart"; import WeeklyYieldChart from "./WeeklyYieldChart"; import { calculatePerTokenMetrics } from "@/utils/per-token-yield"; +const INITIAL_NOW_MS = Date.now(); + interface LPPortfolioProps { invoices: Invoice[]; isLoading: boolean; onClaimDefault: (invoice: Invoice) => Promise; claimingInvoiceId: string | null; + onTransferPosition?: (invoice: Invoice) => void; + transferringInvoiceId?: string | null; tokenMap?: Map; defaultToken?: ApprovedToken | null; onTransfer?: (invoice: Invoice) => void; @@ -26,12 +30,14 @@ export default function LPPortfolio({ isLoading, onClaimDefault, claimingInvoiceId, + onTransferPosition, + transferringInvoiceId = null, tokenMap = new Map(), defaultToken = null, onTransfer, }: LPPortfolioProps) { const [showUSDEquivalent, setShowUSDEquivalent] = useState(false); - const now = Date.now(); + const now = INITIAL_NOW_MS; // Calculate per-token metrics const perTokenMetrics = useMemo( @@ -111,6 +117,8 @@ export default function LPPortfolio({ const isPastDue = Number(inv.due_date) * 1000 < now; const isClaimEligible = inv.status === "Funded" && isPastDue; const isClaiming = claimingInvoiceId === inv.id.toString(); + const isFundedPosition = inv.status === "Funded"; + const isTransferring = transferringInvoiceId === inv.id.toString(); if (!isClaimEligible && !onTransfer) return null; diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..e52bb44 100644 --- a/src/components/TokenSelector.tsx +++ b/src/components/TokenSelector.tsx @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string { return token.name ?? token.symbol; } -function getTokenLogo(token: TokenLike): string { +function getTokenLogo(token: Pick): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: Pick): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } diff --git a/src/components/TransferPositionModal.tsx b/src/components/TransferPositionModal.tsx new file mode 100644 index 0000000..0c945f5 --- /dev/null +++ b/src/components/TransferPositionModal.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { FormEvent, useMemo, useState } from "react"; +import { formatAddress, formatUSDC } from "@/utils/format"; +import { isValidStellarAccount } from "@/utils/invoiceSubmission"; +import type { Invoice } from "@/utils/soroban"; + +interface TransferPositionModalProps { + invoice: Invoice | null; + currentLpAddress: string | null; + isTransferring: boolean; + error?: string | null; + onClose: () => void; + onTransfer: (newLpAddress: string) => Promise | void; +} + +export default function TransferPositionModal({ + invoice, + currentLpAddress, + isTransferring, + error, + onClose, + onTransfer, +}: TransferPositionModalProps) { + const [newLpAddress, setNewLpAddress] = useState(""); + const trimmedAddress = newLpAddress.trim(); + + const validationMessage = useMemo(() => { + if (!trimmedAddress) return "Enter the new LP address."; + if (!isValidStellarAccount(trimmedAddress)) return "Enter a valid Stellar G-address."; + if (currentLpAddress && trimmedAddress === currentLpAddress) { + return "New LP address must be different from the current LP."; + } + return null; + }, [currentLpAddress, trimmedAddress]); + + if (!invoice) return null; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (validationMessage) return; + await onTransfer(trimmedAddress); + }; + + return ( +
+
+
+
+

+ LP Position +

+

+ Transfer Position +

+
+ +
+ +
+
+ Invoice + #{invoice.id.toString()} +
+
+ Amount + {formatUSDC(invoice.amount)} +
+
+ Current LP + + {formatAddress(currentLpAddress ?? invoice.funder ?? "")} + +
+
+ + + setNewLpAddress(event.target.value)} + className="mt-2 w-full rounded-xl border border-outline-variant/30 bg-surface-container-low px-4 py-3 font-mono text-sm outline-none transition-colors focus:border-primary" + placeholder="G..." + aria-describedby="transfer-position-validation transfer-position-warning" + /> +

+ {trimmedAddress && validationMessage ? validationMessage : error} +

+ +
+ warning +

After transfer, all future payouts for this invoice go to the new address.

+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/__tests__/LPPortfolioTransfer.test.tsx b/src/components/__tests__/LPPortfolioTransfer.test.tsx new file mode 100644 index 0000000..0037f38 --- /dev/null +++ b/src/components/__tests__/LPPortfolioTransfer.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import LPPortfolio from "../LPPortfolio"; +import type { Invoice } from "@/utils/soroban"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + }), +})); + +const fundedInvoice: Invoice = { + id: 12n, + freelancer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + payer: "GDIEC472DEK3S5UWVKYDBXG74R53KMHGXGFIURLJUF6P6JJ352HLLJED", + amount: 500_000_000n, + due_date: 1_900_000_000n, + discount_rate: 400, + status: "Funded", + funder: "GCSXPYZSTPKX2GDVW6XSJDBE3PVSNSXCCLTGSPJXNF57IJU5EDU6IUDV", +}; + +describe("LPPortfolio transfer action", () => { + it("renders Transfer Position for funded invoices and calls the handler", async () => { + const onTransferPosition = vi.fn(); + + render( + , + ); + + const button = await screen.findByRole("button", { name: "Transfer Position" }); + fireEvent.click(button); + + await waitFor(() => expect(onTransferPosition).toHaveBeenCalledWith(fundedInvoice)); + }); +}); diff --git a/src/components/__tests__/TransferPositionModal.test.tsx b/src/components/__tests__/TransferPositionModal.test.tsx new file mode 100644 index 0000000..80deba7 --- /dev/null +++ b/src/components/__tests__/TransferPositionModal.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import TransferPositionModal from "../TransferPositionModal"; +import type { Invoice } from "@/utils/soroban"; + +const CURRENT_LP = "GCSXPYZSTPKX2GDVW6XSJDBE3PVSNSXCCLTGSPJXNF57IJU5EDU6IUDV"; +const NEW_LP = "GDIEC472DEK3S5UWVKYDBXG74R53KMHGXGFIURLJUF6P6JJ352HLLJED"; + +const invoice: Invoice = { + id: 27n, + freelancer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + payer: "GCBXRCREFHR6YEJ4VFRGRLRACPGWSZUKRIKG3ZNGV5DGYIUEE2GTWYNO", + amount: 1_000_000_000n, + due_date: 1_900_000_000n, + discount_rate: 500, + status: "Funded", + funder: CURRENT_LP, +}; + +function renderModal(onTransfer = vi.fn()) { + render( + , + ); +} + +describe("TransferPositionModal", () => { + it("shows the payout warning", () => { + renderModal(); + + expect( + screen.getByText("After transfer, all future payouts for this invoice go to the new address."), + ).toBeInTheDocument(); + }); + + it("rejects invalid Stellar addresses", () => { + renderModal(); + + fireEvent.change(screen.getByLabelText("New LP address"), { + target: { value: "not-a-stellar-address" }, + }); + + expect(screen.getByText("Enter a valid Stellar G-address.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Transfer Position" })).toBeDisabled(); + }); + + it("rejects the current LP address", () => { + renderModal(); + + fireEvent.change(screen.getByLabelText("New LP address"), { + target: { value: CURRENT_LP }, + }); + + expect( + screen.getByText("New LP address must be different from the current LP."), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Transfer Position" })).toBeDisabled(); + }); + + it("submits a valid new LP address", async () => { + const onTransfer = vi.fn().mockResolvedValue(undefined); + renderModal(onTransfer); + + fireEvent.change(screen.getByLabelText("New LP address"), { + target: { value: ` ${NEW_LP} ` }, + }); + fireEvent.click(screen.getByRole("button", { name: "Transfer Position" })); + + await waitFor(() => expect(onTransfer).toHaveBeenCalledWith(NEW_LP)); + }); +}); diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts new file mode 100644 index 0000000..9e371ca --- /dev/null +++ b/src/hooks/useTransaction.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useToast } from "@/context/ToastContext"; + +interface UseTransactionOptions { + pendingTitle: string; + pendingMessage?: string; + successTitle: string; + successMessage?: string; + errorTitle: string; +} + +interface TransactionResult { + txHash?: string; +} + +export function useTransaction({ + pendingTitle, + pendingMessage, + successTitle, + successMessage, + errorTitle, +}: UseTransactionOptions) { + const { addToast, updateToast } = useToast(); + const [isPending, setIsPending] = useState(false); + + const runTransaction = useCallback( + async (transaction: () => Promise): Promise => { + setIsPending(true); + const toastId = addToast({ + type: "pending", + title: pendingTitle, + message: pendingMessage, + }); + + try { + const result = await transaction(); + updateToast(toastId, { + type: "success", + title: successTitle, + message: successMessage, + txHash: result.txHash, + }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Transaction failed."; + updateToast(toastId, { + type: "error", + title: errorTitle, + message, + }); + throw error; + } finally { + setIsPending(false); + } + }, + [addToast, errorTitle, pendingMessage, pendingTitle, successMessage, successTitle, updateToast], + ); + + return { isPending, runTransaction }; +} diff --git a/src/utils/evidence.ts b/src/utils/evidence.ts index 55396ad..c661f8f 100644 --- a/src/utils/evidence.ts +++ b/src/utils/evidence.ts @@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise { if (typeof crypto !== "undefined" && crypto.subtle) { const encoded = new TextEncoder().encode(normalized); - const digest = await crypto.subtle.digest("SHA-256", encoded); + const digest = await crypto.subtle.digest("SHA-256", encoded.buffer as ArrayBuffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..ee89597 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -4,14 +4,19 @@ import { RPC_URL } from "@/constants"; const horizonServer = new rpc.Server(RPC_URL); const federationCache = new Map(); +interface AccountHomeDomain { + home_domain?: string; + homeDomain?: string; +} + export async function resolveFederatedAddress(address: string): Promise { if (!address) return address; const cached = federationCache.get(address); if (cached) return cached; try { - const account = await horizonServer.getAccount(address); - const homeDomain = account.home_domain ?? (account as any).homeDomain; + const account = await horizonServer.getAccount(address) as AccountHomeDomain; + const homeDomain = account.home_domain ?? account.homeDomain; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); diff --git a/src/utils/soroban.ts b/src/utils/soroban.ts index 5d90f57..70eabde 100644 --- a/src/utils/soroban.ts +++ b/src/utils/soroban.ts @@ -142,6 +142,14 @@ function extractInvoiceIdFromTransaction(result: unknown): bigint | null { return null; } +function getSimulationError(simulated: unknown, fallback = "Unable to simulate transaction."): string { + if (simulated && typeof simulated === "object" && "error" in simulated) { + const { error } = simulated as { error?: unknown }; + return typeof error === "string" ? error : fallback; + } + return fallback; +} + async function readTokenContractValue(tokenId: string, method: string): Promise { const callResult = await server.simulateTransaction(buildReadTransaction(tokenId, method, [])); if (!rpc.Api.isSimulationSuccess(callResult) || !callResult.result?.retval) { @@ -661,6 +669,45 @@ export async function claimDefault(funder: string, invoice_id: bigint) { return rpc.assembleTransaction(tx, sim).build(); } +// ─── Write: transfer LP position ───────────────────────────────────────────── + +export async function transferLpPosition( + currentFunder: string, + invoice_id: bigint, + newFunder: string +) { + const params: xdr.ScVal[] = [ + Address.fromString(currentFunder).toScVal(), + nativeToScVal(invoice_id, { type: "u64" }), + Address.fromString(newFunder).toScVal(), + ]; + const account = await server.getAccount(currentFunder); + const tx = new TransactionBuilder(account, { + fee: "10000", + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + Operation.invokeHostFunction({ + func: xdr.HostFunction.hostFunctionTypeInvokeContract( + new xdr.InvokeContractArgs({ + contractAddress: Address.fromString(CONTRACT_ID).toScAddress(), + functionName: "transfer_lp_position", + args: params, + }) + ), + auth: [], + }) + ) + .setTimeout(60 * 5) + .build(); + + const sim = await server.simulateTransaction(tx); + if (!rpc.Api.isSimulationSuccess(sim)) { + throw new Error(`Simulation failed: ${sim.error}`); + } + return rpc.assembleTransaction(tx, sim).build(); +} + // ─── Write: submit invoice (returns tx for external signing) ────────────────── // Used by the freelancer dashboard (sign via WalletContext.signTx). @@ -708,7 +755,7 @@ export async function submitInvoice( const sim = await server.simulateTransaction(tx); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } // Extract the predicted invoice ID from simulation retval @@ -717,18 +764,18 @@ export async function submitInvoice( const raw = scValToNative(sim.result!.retval); // Contract returns Result — unwrap Ok variant if (raw && typeof raw === "object" && "ok" in raw) { - invoiceId = BigInt((raw as any).ok); + invoiceId = BigInt(String((raw as { ok: unknown }).ok)); } else if (raw && typeof raw === "object" && "Ok" in raw) { - invoiceId = BigInt((raw as any).Ok); + invoiceId = BigInt(String((raw as { Ok: unknown }).Ok)); } else { - invoiceId = BigInt(raw as any); + invoiceId = BigInt(String(raw)); } - } catch (_) { + } catch { // If we can't parse it, proceed without the ID — it'll be shown after poll } const finalTx = rpc.assembleTransaction(tx, sim).build(); - return { tx: finalTx as any, invoiceId }; + return { tx: finalTx, invoiceId }; } export interface UpdateInvoiceArgs { @@ -772,17 +819,17 @@ export async function updateInvoice( const sim = await server.simulateTransaction(tx); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } const finalTx = rpc.assembleTransaction(tx, sim).build(); - return { tx: finalTx as any }; + return { tx: finalTx }; } export async function cancelInvoice( freelancer: string, invoiceId: bigint -): Promise<{ tx: any }> { +): Promise<{ tx: Transaction }> { // Use a default sequence number / account for preparing or real one if needed let account: Account; try { @@ -805,11 +852,11 @@ export async function cancelInvoice( const sim = await server.simulateTransaction(txUrl); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } const finalTx = rpc.assembleTransaction(txUrl, sim).build(); - return { tx: finalTx as any }; + return { tx: finalTx }; } export async function submitInvoiceTransaction({