From 27fcddd948567985c7f092f16e995a8e2610be1e Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 10:38:54 -0700 Subject: [PATCH] feat: add LP position transfer UI --- app/governance/page.tsx | 10 +- app/pay/[id]/__tests__/PayInvoice.test.tsx | 154 +++++++++++------- app/pay/[id]/page.tsx | 73 ++++++++- src/components/LPDashboard.tsx | 107 ++++++++++-- src/components/LPPortfolio.tsx | 39 +++-- src/components/TransferPositionModal.tsx | 134 +++++++++++++++ .../__tests__/LPPortfolioTransfer.test.tsx | 42 +++++ .../__tests__/TransferPositionModal.test.tsx | 76 +++++++++ src/hooks/useTransaction.ts | 62 +++++++ src/utils/federation.ts | 9 +- src/utils/soroban.ts | 69 ++++++-- 11 files changed, 673 insertions(+), 102 deletions(-) create mode 100644 src/components/TransferPositionModal.tsx create mode 100644 src/components/__tests__/LPPortfolioTransfer.test.tsx create mode 100644 src/components/__tests__/TransferPositionModal.test.tsx create mode 100644 src/hooks/useTransaction.ts diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 87a40dd..4515726 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -23,6 +23,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 ( @@ -172,10 +173,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]); const filtered = diff --git a/app/pay/[id]/__tests__/PayInvoice.test.tsx b/app/pay/[id]/__tests__/PayInvoice.test.tsx index f4c9ee7..95bac95 100644 --- a/app/pay/[id]/__tests__/PayInvoice.test.tsx +++ b/app/pay/[id]/__tests__/PayInvoice.test.tsx @@ -1,122 +1,162 @@ -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, 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(); }); }); - it('should call markPaid when Settle button is clicked', async () => { + it("should call markPaid when Settle button is clicked", 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(); await waitFor(() => { - expect(screen.getByText('Settle Invoice Now')).toBeInTheDocument(); + expect(screen.getByText("Settle Invoice Now")).toBeInTheDocument(); }); - fireEvent.click(screen.getByText('Settle Invoice Now')); + fireEvent.click(screen.getByText("Settle Invoice Now")); await waitFor(() => { - expect(soroban.markPaid).toHaveBeenCalledWith('GPAYER', 1n); + expect(soroban.markPaid).toHaveBeenCalledWith(PAYER, 1n); 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 49d1bf2..fe8793e 100644 --- a/app/pay/[id]/page.tsx +++ b/app/pay/[id]/page.tsx @@ -1,12 +1,20 @@ "use client"; import { use, useEffect, useState, useCallback } from "react"; -import { getInvoice, markPaid, submitSignedTransaction, type Invoice } from "@/utils/soroban"; +import { + getInvoice, + markPaid, + submitSignedTransaction, + transferLpPosition, + type Invoice, +} from "@/utils/soroban"; 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 TransferPositionModal from "@/components/TransferPositionModal"; +import { useTransaction } from "@/hooks/useTransaction"; type LoadState = "loading" | "success" | "error"; @@ -18,6 +26,15 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin const [loadState, setLoadState] = useState("loading"); const [error, setError] = useState(null); const [isPaying, setIsPaying] = useState(false); + const [isTransferModalOpen, setIsTransferModalOpen] = useState(false); + const [transferError, setTransferError] = useState(null); + const transferTransaction = useTransaction({ + pendingTitle: "Transferring LP position...", + pendingMessage: "Please sign the transfer in Freighter.", + successTitle: "LP position transferred", + successMessage: "Future payouts will go to the new LP address.", + errorTitle: "Transfer failed", + }); const invoiceId = BigInt(id); @@ -35,7 +52,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 handlePay = async () => { @@ -58,19 +76,35 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin }); // Refresh invoice state - fetchInvoice(); - } catch (err: any) { + void fetchInvoice(); + } catch (err) { console.error(err); 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 (
@@ -92,6 +126,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"; return ( @@ -117,7 +152,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin )} - {address && !isPayer && !isPaid && ( + {address && !isPayer && !isCurrentLp && !isPaid && (
warning
@@ -174,6 +209,17 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin > {isPaying ? "Processing..." : "Settle Invoice Now"} + ) : isCurrentLp && invoice.status === "Funded" ? ( + ) : (
Restricted to Registered Payer @@ -187,6 +233,19 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin This is a direct settlement page. Verify all details before proceeding.

+ { + if (!transferTransaction.isPending) { + setIsTransferModalOpen(false); + setTransferError(null); + } + }} + onTransfer={handleTransferPosition} + />
); } diff --git a/src/components/LPDashboard.tsx b/src/components/LPDashboard.tsx index 881a8af..c2cb492 100644 --- a/src/components/LPDashboard.tsx +++ b/src/components/LPDashboard.tsx @@ -18,6 +18,7 @@ import { getTokenAllowance, Invoice, submitSignedTransaction, + transferLpPosition, } from "@/utils/soroban"; import { formatAddress, formatDate, formatTokenAmount, calculateYield } from "@/utils/format"; import { useWatchlist } from "@/hooks/useWatchlist"; @@ -30,10 +31,19 @@ import YieldCalculator from "./YieldCalculator"; import LastUpdated from "./LastUpdated"; import InvoiceStatusBadge from "./InvoiceStatusBadge"; import FundConfirmModal from "./FundConfirmModal"; +import TransferPositionModal from "./TransferPositionModal"; import type { DataTableColumn } from "./DataTable"; +import { useTransaction } from "@/hooks/useTransaction"; type Tab = "discovery" | "my-funded" | "watchlist"; +type WatchlistInvoice = Invoice & { watchAddedAt: number }; + +function getWatchAddedAt(invoice: Invoice | WatchlistInvoice): number { + return "watchAddedAt" in invoice && typeof invoice.watchAddedAt === "number" + ? invoice.watchAddedAt + : 0; +} @@ -56,6 +66,16 @@ export default function LPDashboard() { const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const [claimingInvoiceId, setClaimingInvoiceId] = useState(null); const [selectedInvoiceIds, setSelectedInvoiceIds] = useState([]); + const [transferInvoice, setTransferInvoice] = useState(null); + const [transferError, setTransferError] = useState(null); + const [transferredFunders, setTransferredFunders] = useState>({}); + const transferTransaction = useTransaction({ + pendingTitle: "Transferring LP position...", + pendingMessage: "Please sign the transfer in Freighter.", + successTitle: "LP position transferred", + successMessage: "Future payouts will go to the new LP address.", + errorTitle: "Transfer failed", + }); const { filters, @@ -75,8 +95,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.", + }); } }; @@ -113,7 +137,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) => { @@ -167,19 +194,50 @@ export default function LPDashboard() { } }; + const handleTransferPosition = async (invoice: Invoice, newLpAddress: string) => { + if (!address) { + await connect(); + return; + } + + setTransferError(null); + try { + await transferTransaction.runTransaction(async () => { + const tx = await transferLpPosition(address, invoice.id, newLpAddress); + return submitSignedTransaction({ tx, signTx }); + }); + setTransferredFunders((current) => ({ + ...current, + [invoice.id.toString()]: newLpAddress, + })); + setTransferInvoice(null); + } catch (error) { + setTransferError(error instanceof Error ? error.message : "Failed to transfer LP position."); + } + }; + + const invoicesWithTransferredFunders = useMemo( + () => + invoices.map((invoice) => { + const transferredFunder = transferredFunders[invoice.id.toString()]; + return transferredFunder ? { ...invoice, funder: transferredFunder } : invoice; + }), + [invoices, transferredFunders], + ); + const filteredInvoices = useMemo( () => - applyInvoiceFilters(invoices, filters, { + applyInvoiceFilters(invoicesWithTransferredFunders, filters, { resolveTokenSymbol: (invoice) => { const token = tokenMap.get(invoice.token ?? defaultToken?.contractId ?? ""); return token?.symbol ?? "USDC"; }, }), - [defaultToken?.contractId, filters, invoices, tokenMap], + [defaultToken?.contractId, filters, invoicesWithTransferredFunders, tokenMap], ); - 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"]; @@ -194,6 +252,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; @@ -218,7 +279,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) { @@ -244,7 +305,7 @@ export default function LPDashboard() { } }; - const commonColumns: DataTableColumn[] = [ + const commonColumns: DataTableColumn[] = [ { id: "id", label: "ID", @@ -304,7 +365,7 @@ export default function LPDashboard() { }, ]; - const discoveryColumns: DataTableColumn[] = [ + const discoveryColumns: DataTableColumn[] = [ ...commonColumns, { id: "risk", @@ -348,7 +409,7 @@ export default function LPDashboard() { }, ]; - const watchlistColumns: DataTableColumn[] = [ + const watchlistColumns: DataTableColumn[] = [ ...commonColumns, { id: "watchAddedAt", @@ -491,6 +552,11 @@ export default function LPDashboard() { isLoading={loading} onClaimDefault={handleClaimDefault} claimingInvoiceId={claimingInvoiceId} + onTransferPosition={(invoice) => { + setTransferError(null); + setTransferInvoice(invoice); + }} + transferringInvoiceId={transferTransaction.isPending ? transferInvoice?.id.toString() ?? null : null} tokenMap={tokenMap} defaultToken={defaultToken} /> @@ -555,7 +621,7 @@ export default function LPDashboard() { ) : ( - (activeTab === "discovery" ? discoveryInvoices : watchlistInvoices).map((invoice: any, index: number) => ( + (activeTab === "discovery" ? discoveryInvoices : watchlistInvoices).map((invoice, index) => ( {activeTab === "watchlist" && ( - {new Date(invoice.watchAddedAt).toLocaleDateString()} + {new Date(getWatchAddedAt(invoice)).toLocaleDateString()} )} {activeTab === "discovery" && ( @@ -653,6 +719,23 @@ export default function LPDashboard() { setSelectedInvoice(null); }} /> + { + if (!transferTransaction.isPending) { + setTransferInvoice(null); + setTransferError(null); + } + }} + onTransfer={(newLpAddress) => { + if (transferInvoice) { + return handleTransferPosition(transferInvoice, newLpAddress); + } + }} + /> ); } diff --git a/src/components/LPPortfolio.tsx b/src/components/LPPortfolio.tsx index 70dbc80..a99f2a9 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; } @@ -25,11 +29,13 @@ export default function LPPortfolio({ isLoading, onClaimDefault, claimingInvoiceId, + onTransferPosition, + transferringInvoiceId = null, tokenMap = new Map(), defaultToken = null, }: LPPortfolioProps) { const [showUSDEquivalent, setShowUSDEquivalent] = useState(false); - const now = Date.now(); + const now = INITIAL_NOW_MS; // Calculate per-token metrics const perTokenMetrics = useMemo( @@ -109,18 +115,31 @@ 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) return null; + if (!isClaimEligible && !isFundedPosition) return null; return ( -
- +
+ {isFundedPosition && onTransferPosition && ( + + )} + {isClaimEligible && ( + + )}
); }, 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/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 16ae25d..4a57773 100644 --- a/src/utils/soroban.ts +++ b/src/utils/soroban.ts @@ -120,6 +120,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) { @@ -477,6 +485,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). @@ -524,7 +571,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 @@ -533,18 +580,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 { @@ -588,17 +635,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 { @@ -621,11 +668,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({