diff --git a/client/src/App.tsx b/client/src/App.tsx index 2c64f58..62100a3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -7,6 +7,7 @@ import { AnalyticsDashboardPage } from './pages/AnalyticsDashboardPage'; import { AdminDashboardPage } from './pages/AdminDashboardPage'; import { ActivityFeedPage } from './pages/ActivityFeedPage'; import { CampaignDetailPage } from './pages/CampaignDetailPage'; +import { InvestorDashboardPage } from './pages/InvestorDashboardPage'; import './App.css'; export default function App() { @@ -53,6 +54,7 @@ export default function App() { }> } /> } /> + } /> ); diff --git a/client/src/components/investor/InvestmentCard.tsx b/client/src/components/investor/InvestmentCard.tsx index 0410188..40deeb3 100644 --- a/client/src/components/investor/InvestmentCard.tsx +++ b/client/src/components/investor/InvestmentCard.tsx @@ -11,6 +11,12 @@ const statusBadgeStyles: Record = { Active: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300', Funding: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300', + Funded: + 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300', + InProduction: 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300', + Harvested: 'bg-lime-100 text-lime-800 dark:bg-lime-950 dark:text-lime-300', + Disputed: + 'bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300', Settled: 'bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300', Resolved: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300', diff --git a/client/src/hooks/contract/queryKeys.ts b/client/src/hooks/contract/queryKeys.ts index 00fdf5e..cd176ef 100644 --- a/client/src/hooks/contract/queryKeys.ts +++ b/client/src/hooks/contract/queryKeys.ts @@ -10,4 +10,6 @@ export const contractQueryKeys = { activity: (campaignId: string) => ['activity', campaignId] as const, escrowAdmin: () => ['escrowAdmin'] as const, adminCampaignsOverview: () => ['adminCampaignsOverview'] as const, + investorPortfolio: (address: string) => + ['investorPortfolio', address] as const, }; diff --git a/client/src/hooks/contract/useEscrowMutations.test.tsx b/client/src/hooks/contract/useEscrowMutations.test.tsx new file mode 100644 index 0000000..828f5ff --- /dev/null +++ b/client/src/hooks/contract/useEscrowMutations.test.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import * as contractClient from '../../lib/soroban/contractClient'; +import { contractQueryKeys } from './queryKeys'; + +vi.mock('../../lib/soroban/contractClient', () => ({ + getEscrowClient: vi.fn(() => Promise.resolve({})), + invokeContractWrite: vi.fn(), +})); + +const WALLET = { + publicKey: 'GINVESTOR000000000000000000000000000000000000000000000B2', + isConnected: true, + isConnecting: false, + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + signTransaction: vi.fn(), +}; + +vi.mock('../../context/WalletContext', () => ({ + useWallet: () => WALLET, +})); + +const notifySuccess = vi.fn(); +const notifyError = vi.fn(); +vi.mock('./mutationToasts', () => ({ + useMutationToasts: () => ({ notifySuccess, notifyError }), +})); + +function wrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +describe('useClaimRefund / useClaimReturn', () => { + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.mocked(contractClient.invokeContractWrite).mockResolvedValue(undefined); + }); + + it('calls claim_refund on the escrow contract and invalidates live queries', async () => { + const { useClaimRefund } = await import('./useEscrowMutations'); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useClaimRefund(), { + wrapper: wrapper(queryClient), + }); + + const returned = await result.current.mutateAsync({ + campaignId: '7', + investor: WALLET.publicKey, + }); + + expect(contractClient.invokeContractWrite).toHaveBeenCalledWith( + expect.any(Promise), + 'claim_refund', + { + campaign_id: 7n, + investor: WALLET.publicKey, + }, + WALLET, + ); + expect(returned).toBeUndefined(); + expect(typeof returned).not.toBe('string'); + expect(notifySuccess).toHaveBeenCalled(); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: contractQueryKeys.campaign('7'), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: contractQueryKeys.contribution('7', WALLET.publicKey), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: contractQueryKeys.investorPortfolio(WALLET.publicKey), + }); + }); + }); + + it('calls claim_return on the escrow contract without fabricating a tx hash', async () => { + const { useClaimReturn } = await import('./useEscrowMutations'); + + const { result } = renderHook(() => useClaimReturn(), { + wrapper: wrapper(queryClient), + }); + + const returned = await result.current.mutateAsync({ + campaignId: '42', + investor: WALLET.publicKey, + }); + + expect(contractClient.invokeContractWrite).toHaveBeenCalledWith( + expect.any(Promise), + 'claim_return', + { + campaign_id: 42n, + investor: WALLET.publicKey, + }, + WALLET, + ); + expect(returned).toBeUndefined(); + expect(JSON.stringify(returned ?? null)).not.toMatch(/0x[0-9a-f]{16}/i); + expect(notifySuccess).toHaveBeenCalled(); + }); + + it('surfaces contract failures through the mutation error toast', async () => { + vi.mocked(contractClient.invokeContractWrite).mockRejectedValue( + new Error('nothing to refund'), + ); + const { useClaimRefund } = await import('./useEscrowMutations'); + + const { result } = renderHook(() => useClaimRefund(), { + wrapper: wrapper(queryClient), + }); + + await expect( + result.current.mutateAsync({ + campaignId: '7', + investor: WALLET.publicKey, + }), + ).rejects.toThrow('nothing to refund'); + + await waitFor(() => { + expect(notifyError).toHaveBeenCalled(); + }); + }); +}); diff --git a/client/src/hooks/contract/useEscrowMutations.ts b/client/src/hooks/contract/useEscrowMutations.ts index 7b9a881..92757ac 100644 --- a/client/src/hooks/contract/useEscrowMutations.ts +++ b/client/src/hooks/contract/useEscrowMutations.ts @@ -391,3 +391,91 @@ export function useMarkFailed() { onError: notifyError, }); } + +export interface ClaimRefundInput { + campaignId: string; + investor: string; +} + +export function useClaimRefund() { + const wallet = useWallet(); + const queryClient = useQueryClient(); + const { notifySuccess, notifyError } = useMutationToasts({ + success: 'Refund claimed', + error: 'Could not claim refund', + }); + + return useMutation({ + mutationFn: async (input: ClaimRefundInput) => { + return invokeContractWrite( + getEscrowClient(), + 'claim_refund', + { + campaign_id: BigInt(input.campaignId), + investor: input.investor, + }, + wallet, + ); + }, + onSuccess: (_data, input) => { + notifySuccess(`Refund claimed for campaign #${input.campaignId}.`); + queryClient.invalidateQueries({ + queryKey: contractQueryKeys.campaign(input.campaignId), + }); + queryClient.invalidateQueries({ + queryKey: contractQueryKeys.contribution( + input.campaignId, + input.investor, + ), + }); + queryClient.invalidateQueries({ + queryKey: contractQueryKeys.investorPortfolio(input.investor), + }); + }, + onError: notifyError, + }); +} + +export interface ClaimReturnInput { + campaignId: string; + investor: string; +} + +export function useClaimReturn() { + const wallet = useWallet(); + const queryClient = useQueryClient(); + const { notifySuccess, notifyError } = useMutationToasts({ + success: 'Return claimed', + error: 'Could not claim return', + }); + + return useMutation({ + mutationFn: async (input: ClaimReturnInput) => { + return invokeContractWrite( + getEscrowClient(), + 'claim_return', + { + campaign_id: BigInt(input.campaignId), + investor: input.investor, + }, + wallet, + ); + }, + onSuccess: (_data, input) => { + notifySuccess(`Return claimed for campaign #${input.campaignId}.`); + queryClient.invalidateQueries({ + queryKey: contractQueryKeys.campaign(input.campaignId), + }); + queryClient.invalidateQueries({ + queryKey: contractQueryKeys.contribution( + input.campaignId, + input.investor, + ), + }); + queryClient.invalidateQueries({ + queryKey: contractQueryKeys.investorPortfolio(input.investor), + }); + }, + onError: notifyError, + }); +} diff --git a/client/src/hooks/useInvestorPortfolio.test.tsx b/client/src/hooks/useInvestorPortfolio.test.tsx new file mode 100644 index 0000000..fbe8439 --- /dev/null +++ b/client/src/hooks/useInvestorPortfolio.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import type { Campaign } from '../lib/soroban/types'; +import * as investorService from '../lib/soroban/investorService'; + +const WALLET = 'GINVESTOR000000000000000000000000000000000000000000000B2'; + +vi.mock('../lib/soroban/config', () => ({ + RPC_URL: 'https://rpc.test', + ESCROW_CONTRACT_ID: 'CESCROW', + isEscrowConfigured: () => true, + isRegistryConfigured: () => false, +})); + +const loadRecentEscrowEvents = vi.fn(); +vi.mock('../lib/soroban/events', () => ({ + loadRecentEscrowEvents: (...args: unknown[]) => + loadRecentEscrowEvents(...args), +})); + +const getEscrowClient = vi.fn(); +const contractMethod = vi.fn(); +vi.mock('../lib/soroban/contractClient', () => ({ + getEscrowClient: () => getEscrowClient(), + contractMethod: (...args: unknown[]) => contractMethod(...args), +})); + +vi.mock('../lib/contracts/registry', () => ({ + getCampaign: vi.fn(), +})); + +function wrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +const FAILED_CAMPAIGN: Campaign = { + farmer: 'GFARMER', + target_amount: 1500n, + token_address: 'CTOKEN', + deadline: 0n, + harvest_metadata: 'fertilizer', + total_funded: 1500n, + released: 0n, + refundable: 1500n, + returnable: 0n, + status: { tag: 'Failed' }, +}; + +describe('useInvestorPortfolio', () => { + beforeEach(() => { + vi.clearAllMocks(); + loadRecentEscrowEvents.mockResolvedValue([ + { + id: 'e1', + ledger: 1, + ledgerClosedAt: '2026-01-01T00:00:00Z', + campaignId: '7', + name: 'ContribReceived', + values: [WALLET, 1_700_000_000, 1500n], + }, + ]); + getEscrowClient.mockResolvedValue({}); + contractMethod.mockImplementation((_client: unknown, method: string) => { + if (method === 'get_campaign') { + return async () => ({ result: FAILED_CAMPAIGN }); + } + if (method === 'get_contribution') { + return async () => ({ result: 1500n }); + } + throw new Error(`unexpected method ${method}`); + }); + }); + + it('builds the portfolio from escrow events and contract reads, not MOCK_INVESTMENTS', async () => { + const getPortfolioSpy = vi.spyOn(investorService, 'getInvestorPortfolio'); + const { useInvestorPortfolio } = await import('./useInvestorPortfolio'); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result } = renderHook(() => useInvestorPortfolio(WALLET), { + wrapper: wrapper(queryClient), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(loadRecentEscrowEvents).toHaveBeenCalled(); + expect(getPortfolioSpy).not.toHaveBeenCalled(); + expect(result.current.data).toEqual([ + expect.objectContaining({ + campaignId: '7', + walletAddress: WALLET, + amountContributed: 1500, + status: 'Failed', + claimed: false, + title: 'fertilizer', + }), + ]); + expect(JSON.stringify(result.current.data)).not.toMatch(/0x[0-9a-f]{16}/i); + expect(JSON.stringify(result.current.data)).not.toMatch(/camp-101/); + }); + + it('does not fetch when no wallet is connected', async () => { + const { useInvestorPortfolio } = await import('./useInvestorPortfolio'); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result } = renderHook(() => useInvestorPortfolio(null), { + wrapper: wrapper(queryClient), + }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(loadRecentEscrowEvents).not.toHaveBeenCalled(); + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/client/src/hooks/useInvestorPortfolio.ts b/client/src/hooks/useInvestorPortfolio.ts new file mode 100644 index 0000000..c69e8b5 --- /dev/null +++ b/client/src/hooks/useInvestorPortfolio.ts @@ -0,0 +1,93 @@ +import { useQuery } from '@tanstack/react-query'; +import { loadRecentEscrowEvents } from '../lib/soroban/events'; +import { contractMethod, getEscrowClient } from '../lib/soroban/contractClient'; +import { getCampaign as getRegistryCampaign } from '../lib/contracts/registry'; +import { + ESCROW_CONTRACT_ID, + RPC_URL, + isEscrowConfigured, + isRegistryConfigured, +} from '../lib/soroban/config'; +import { contractQueryKeys } from './contract/queryKeys'; +import { + aggregateInvestorEvents, + toFundedInvestment, + type FundedInvestment, +} from '../lib/soroban/investorService'; +import type { Campaign } from '../lib/soroban/types'; + +const DEFAULT_LOOKBACK_LEDGERS = 120_000; + +const LOOKBACK_LEDGERS = (() => { + const parsed = Number(import.meta.env.VITE_SOROBAN_EVENTS_LOOKBACK_LEDGERS); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_LOOKBACK_LEDGERS; +})(); + +/** + * Discovers campaigns the connected wallet funded from ProductionEscrowContract + * event history (there is no on-chain "list investments for address" getter) + * and hydrates each row from `get_campaign` / `get_contribution`. + */ +export function useInvestorPortfolio(walletAddress: string | null | undefined) { + return useQuery({ + queryKey: contractQueryKeys.investorPortfolio(walletAddress ?? ''), + enabled: Boolean(walletAddress) && isEscrowConfigured(), + queryFn: async (): Promise => { + const events = await loadRecentEscrowEvents({ + rpcUrl: RPC_URL!, + contractId: ESCROW_CONTRACT_ID!, + lookbackLedgers: LOOKBACK_LEDGERS, + }); + + const byCampaign = aggregateInvestorEvents(events, walletAddress!); + const client = await getEscrowClient(); + + const rows = await Promise.all( + Array.from(byCampaign.entries()).map( + async ([id, amounts]): Promise => { + try { + const campaignTx = await contractMethod( + client, + 'get_campaign', + )({ campaign_id: BigInt(id) }); + const contributionTx = await contractMethod( + client, + 'get_contribution', + )({ + campaign_id: BigInt(id), + investor: walletAddress!, + }); + + let title: string | undefined; + if (isRegistryConfigured()) { + try { + const info = await getRegistryCampaign(BigInt(id)); + title = info?.title; + } catch { + // Registry lookup is best-effort; harvest metadata is the fallback. + } + } + + return toFundedInvestment({ + campaignId: id, + campaign: campaignTx.result, + currentContribution: contributionTx.result, + amounts, + walletAddress: walletAddress!, + title, + }); + } catch { + return null; + } + }, + ), + ); + + return rows + .filter((row): row is FundedInvestment => row !== null) + .sort((a, b) => Number(b.campaignId) - Number(a.campaignId)); + }, + }); +} diff --git a/client/src/lib/soroban/__tests__/investorPortfolio.test.ts b/client/src/lib/soroban/__tests__/investorPortfolio.test.ts new file mode 100644 index 0000000..89846ec --- /dev/null +++ b/client/src/lib/soroban/__tests__/investorPortfolio.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import type { EscrowEvent } from '../events'; +import type { Campaign } from '../types'; +import { + aggregateInvestorEvents, + calculatePortfolioStats, + claimableForStatus, + proRataShare, + toFundedInvestment, +} from '../investorService'; + +const WALLET = 'GABCDEFINVESTOR'; +const OTHER = 'GOTHERWALLET'; + +function event( + name: string, + campaignId: string, + values: unknown[], +): EscrowEvent { + return { + id: `${name}-${campaignId}`, + ledger: 1, + ledgerClosedAt: '2026-01-01T00:00:00Z', + campaignId, + name, + values, + }; +} + +function campaign(overrides: Partial = {}): Campaign { + return { + farmer: 'GFARMER', + target_amount: 1000n, + token_address: 'CTOKEN', + deadline: 0n, + harvest_metadata: 'maize', + total_funded: 1000n, + released: 0n, + refundable: 0n, + returnable: 0n, + status: { tag: 'Funding' }, + ...overrides, + }; +} + +describe('investor portfolio mapping', () => { + it('keeps only ContribReceived / claim events for the connected wallet', () => { + const byCampaign = aggregateInvestorEvents( + [ + event('ContribReceived', '1', [WALLET, 1_700_000_000, 600n]), + event('ContribReceived', '1', [OTHER, 1_700_000_100, 400n]), + event('ContribReceived', '2', [WALLET, 1_700_000_200, 250n]), + event('RefundClaimed', '2', [WALLET, 1_700_000_300, 250n]), + event('CampaignCreated', '1', [WALLET, 1_700_000_000, 1000n]), + ], + WALLET, + ); + + expect(byCampaign.size).toBe(2); + expect(byCampaign.get('1')?.contributed).toBe(600n); + expect(byCampaign.get('2')?.contributed).toBe(250n); + expect(byCampaign.get('2')?.claimedRefund).toBe(250n); + }); + + it('computes claimable refunds and returns with on-chain integer division', () => { + expect(proRataShare(600n, 700n, 1000n)).toBe(420n); + + const failed = campaign({ + total_funded: 1000n, + refundable: 1000n, + status: { tag: 'Failed' }, + }); + expect(claimableForStatus('Failed', 400n, failed)).toBe(400n); + + const settled = campaign({ + total_funded: 1000n, + returnable: 500n, + status: { tag: 'Settled' }, + }); + expect(claimableForStatus('Settled', 400n, settled)).toBe(200n); + expect(claimableForStatus('Funding', 400n, settled)).toBe(0n); + }); + + it('marks a row claimed from claim events rather than a fake tx hash', () => { + const row = toFundedInvestment({ + campaignId: '9', + campaign: campaign({ + status: { tag: 'Failed' }, + refundable: 1500n, + total_funded: 1500n, + }), + currentContribution: 0n, + amounts: { + contributed: 1500n, + claimedRefund: 1500n, + claimedReturn: 0n, + firstFundedAt: '2026-05-20T09:15:00.000Z', + }, + walletAddress: WALLET, + title: 'Failed Fertilizer PIP', + }); + + expect(row.claimed).toBe(true); + expect(row.claimableAmount).toBe(1500); + expect(row.amountContributed).toBe(1500); + expect(row.title).toBe('Failed Fertilizer PIP'); + expect(JSON.stringify(row)).not.toMatch(/0x[0-9a-f]+/i); + }); + + it('calculates stats from the mapped portfolio, not MOCK_INVESTMENTS', () => { + const stats = calculatePortfolioStats([ + { + campaignId: '1', + title: 'A', + amountContributed: 600, + status: 'Funding', + claimableAmount: 0, + claimed: false, + walletAddress: WALLET, + fundedAt: '', + }, + { + campaignId: '2', + title: 'B', + amountContributed: 400, + status: 'Failed', + claimableAmount: 400, + claimed: true, + walletAddress: WALLET, + fundedAt: '', + }, + ]); + + expect(stats).toEqual({ + totalInvested: 1000, + totalClaimed: 400, + totalPending: 0, + }); + }); +}); diff --git a/client/src/lib/soroban/investorService.ts b/client/src/lib/soroban/investorService.ts index 1df92b2..3a231b1 100644 --- a/client/src/lib/soroban/investorService.ts +++ b/client/src/lib/soroban/investorService.ts @@ -1,5 +1,7 @@ -export type CampaignStatus = - 'Active' | 'Funding' | 'Resolved' | 'Failed' | 'Settled'; +import type { EscrowEvent } from './events'; +import type { Campaign, CampaignStatusTag } from './types'; + +export type CampaignStatus = CampaignStatusTag; export interface FundedInvestment { campaignId: string; @@ -18,6 +20,174 @@ export interface PortfolioStats { totalPending: number; } +export interface InvestorCampaignAmounts { + contributed: bigint; + claimedRefund: bigint; + claimedReturn: bigint; + firstFundedAt: string | null; +} + +export interface InvestorPortfolioSnapshot { + campaignId: string; + campaign: Campaign; + currentContribution: bigint; + amounts: InvestorCampaignAmounts; + walletAddress: string; + title?: string; +} + +function toBigInt(value: unknown): bigint { + if (typeof value === 'bigint') return value; + if (typeof value === 'number' && Number.isFinite(value)) { + return BigInt(Math.trunc(value)); + } + if (typeof value === 'string' && /^-?\d+$/.test(value)) return BigInt(value); + return 0n; +} + +function toAddress(value: unknown): string { + return typeof value === 'string' ? value : String(value ?? ''); +} + +function addressesMatch(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +function unixToIso(value: unknown): string | null { + const seconds = + typeof value === 'bigint' ? Number(value) : Number(value ?? Number.NaN); + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return new Date(seconds * 1000).toISOString(); +} + +/** Integer pro-rata share matching ProductionEscrowContract claim math. */ +export function proRataShare( + contributed: bigint, + pool: bigint, + totalFunded: bigint, +): bigint { + if (contributed <= 0n || pool <= 0n || totalFunded <= 0n) return 0n; + return (contributed * pool) / totalFunded; +} + +export function claimableForStatus( + status: CampaignStatusTag, + contributed: bigint, + campaign: Campaign, +): bigint { + if (status === 'Resolved' || status === 'Failed') { + return proRataShare( + contributed, + campaign.refundable, + campaign.total_funded, + ); + } + if (status === 'Settled') { + return proRataShare( + contributed, + campaign.returnable, + campaign.total_funded, + ); + } + return 0n; +} + +/** + * Groups escrow events for a single investor into per-campaign contribution + * and claim totals. Used by the dashboard so portfolio rows come from + * ContribReceived / RefundClaimed / ReturnClaimed logs, not MOCK_INVESTMENTS. + */ +export function aggregateInvestorEvents( + events: EscrowEvent[], + walletAddress: string, +): Map { + const byCampaign = new Map(); + + const ensure = (campaignId: string): InvestorCampaignAmounts => { + const existing = byCampaign.get(campaignId); + if (existing) return existing; + const created: InvestorCampaignAmounts = { + contributed: 0n, + claimedRefund: 0n, + claimedReturn: 0n, + firstFundedAt: null, + }; + byCampaign.set(campaignId, created); + return created; + }; + + for (const event of events) { + if (!event.campaignId) continue; + const investor = toAddress(event.values[0]); + if (!investor || !addressesMatch(investor, walletAddress)) continue; + + if (event.name === 'ContribReceived') { + const amounts = ensure(event.campaignId); + amounts.contributed += toBigInt(event.values[2]); + if (!amounts.firstFundedAt) { + amounts.firstFundedAt = unixToIso(event.values[1]); + } + } else if (event.name === 'RefundClaimed') { + ensure(event.campaignId).claimedRefund += toBigInt(event.values[2]); + } else if (event.name === 'ReturnClaimed') { + ensure(event.campaignId).claimedReturn += toBigInt(event.values[2]); + } + } + + return byCampaign; +} + +export function toFundedInvestment( + snapshot: InvestorPortfolioSnapshot, +): FundedInvestment { + const status = snapshot.campaign.status.tag; + const remaining = snapshot.currentContribution; + const originalContributed = + remaining > 0n ? remaining : snapshot.amounts.contributed; + const claimedPayout = + snapshot.amounts.claimedRefund + snapshot.amounts.claimedReturn; + const claimed = remaining <= 0n && claimedPayout > 0n; + const claimable = claimed + ? claimedPayout + : claimableForStatus(status, remaining, snapshot.campaign); + + return { + campaignId: snapshot.campaignId, + title: + snapshot.title?.trim() || + snapshot.campaign.harvest_metadata || + `Campaign #${snapshot.campaignId}`, + amountContributed: Number(originalContributed), + status, + claimableAmount: Number(claimable), + claimed, + walletAddress: snapshot.walletAddress, + fundedAt: snapshot.amounts.firstFundedAt ?? '', + }; +} + +export function calculatePortfolioStats( + investments: FundedInvestment[], +): PortfolioStats { + return investments.reduce( + (acc, inv) => { + acc.totalInvested += inv.amountContributed; + if (inv.claimed) { + acc.totalClaimed += inv.claimableAmount; + } else { + acc.totalPending += inv.claimableAmount; + } + return acc; + }, + { totalInvested: 0, totalClaimed: 0, totalPending: 0 }, + ); +} + +/** + * Fixture data used only by `src/__tests__/investorService.test.ts` (excluded + * from the vitest suite). The live dashboard reads on-chain events via + * `useInvestorPortfolio` — do not import these mocks from pages/components. + */ const MOCK_INVESTMENTS: FundedInvestment[] = [ { campaignId: 'camp-101', @@ -51,6 +221,7 @@ const MOCK_INVESTMENTS: FundedInvestment[] = [ }, ]; +/** @deprecated Test/storybook fixture. The dashboard does not call this. */ export function getInvestorPortfolio( walletAddress: string, ): FundedInvestment[] { @@ -61,23 +232,7 @@ export function getInvestorPortfolio( ); } -export function calculatePortfolioStats( - investments: FundedInvestment[], -): PortfolioStats { - return investments.reduce( - (acc, inv) => { - acc.totalInvested += inv.amountContributed; - if (inv.claimed) { - acc.totalClaimed += inv.claimableAmount; - } else { - acc.totalPending += inv.claimableAmount; - } - return acc; - }, - { totalInvested: 0, totalClaimed: 0, totalPending: 0 }, - ); -} - +/** @deprecated Test fixture. Claims go through `useClaimRefund`. */ export async function claimRefund( campaignId: string, walletAddress: string, @@ -122,6 +277,7 @@ export async function claimRefund( }; } +/** @deprecated Test fixture. Claims go through `useClaimReturn`. */ export async function claimReturn( campaignId: string, walletAddress: string, diff --git a/client/src/pages/InvestorDashboardPage.tsx b/client/src/pages/InvestorDashboardPage.tsx index 6afa2eb..f90ba22 100644 --- a/client/src/pages/InvestorDashboardPage.tsx +++ b/client/src/pages/InvestorDashboardPage.tsx @@ -1,80 +1,48 @@ -import React, { useState } from 'react'; -import { - getInvestorPortfolio, - calculatePortfolioStats, - claimRefund, - claimReturn, - type FundedInvestment, -} from '../lib/soroban/investorService'; +import { useWallet, truncateAddress } from '../context/WalletContext'; +import { useClaimRefund, useClaimReturn } from '../hooks/contract'; +import { useInvestorPortfolio } from '../hooks/useInvestorPortfolio'; +import { calculatePortfolioStats } from '../lib/soroban/investorService'; +import { isEscrowConfigured } from '../lib/soroban/config'; import { InvestorSummaryStats } from '../components/investor/InvestorSummaryStats'; import { InvestmentCard } from '../components/investor/InvestmentCard'; -import { useToast } from '../context/ToastContext'; import { DashboardRowsSkeleton } from '../components/ui/Skeleton/Skeleton'; -export const InvestorDashboardPage: React.FC = () => { - const toast = useToast(); - const [walletAddress] = useState('GDF4...M9XZ'); - const [investments, setInvestments] = useState( - null, - ); - - // Simulate the async portfolio fetch that a live RPC/indexer hook will use. - React.useEffect(() => { - const timer = window.setTimeout(() => { - setInvestments(getInvestorPortfolio(walletAddress)); - }, 0); - return () => window.clearTimeout(timer); - }, [walletAddress]); +export function InvestorDashboardPage() { + const wallet = useWallet(); + const configured = isEscrowConfigured(); + const portfolioQuery = useInvestorPortfolio(wallet.publicKey); + const claimRefund = useClaimRefund(); + const claimReturn = useClaimReturn(); - const stats = calculatePortfolioStats(investments ?? []); + const investments = portfolioQuery.data ?? []; + const stats = calculatePortfolioStats(investments); const handleClaimRefund = async (campaignId: string) => { - const res = await claimRefund(campaignId, walletAddress); - - if (!res.success) { - toast.error( - 'Could not claim refund', - res.error || 'Failed to claim refund', - ); - return; + if (!wallet.publicKey) return; + try { + await claimRefund.mutateAsync({ + campaignId, + investor: wallet.publicKey, + }); + } catch { + // useClaimRefund already toasts on error. } - - setInvestments((prev) => - (prev ?? []).map((inv) => - inv.campaignId === campaignId ? { ...inv, claimed: true } : inv, - ), - ); - toast.success( - 'Refund claimed', - `Successfully claimed refund of $${res.claimedAmount?.toLocaleString()}.`, - ); }; const handleClaimReturn = async (campaignId: string) => { - const res = await claimReturn(campaignId, walletAddress); - - if (!res.success) { - toast.error( - 'Could not claim return', - res.error || 'Failed to claim return', - ); - return; + if (!wallet.publicKey) return; + try { + await claimReturn.mutateAsync({ + campaignId, + investor: wallet.publicKey, + }); + } catch { + // useClaimReturn already toasts on error. } - - setInvestments((prev) => - (prev ?? []).map((inv) => - inv.campaignId === campaignId ? { ...inv, claimed: true } : inv, - ), - ); - toast.success( - 'Return claimed', - `Successfully claimed return payout of $${res.claimedAmount?.toLocaleString()}.`, - ); }; return (
- {/* Header */}

@@ -85,18 +53,74 @@ export const InvestorDashboardPage: React.FC = () => {

-
-
+ {wallet.isConnected && wallet.publicKey ? ( +
+
+ ) : ( +
+
+ )}
- {investments === null ? ( + {!configured && ( +
+

+ Soroban RPC not configured +

+

+ Set VITE_SOROBAN_RPC_URL and{' '} + + VITE_PRODUCTION_ESCROW_CONTRACT_ID + {' '} + to load your on-chain investments. +

+
+ )} + + {configured && !wallet.isConnected && ( +
+

+ Connect to view your investments +

+

+ Connect your Stellar wallet to see campaigns you have funded and to + claim refunds or returns on-chain. +

+ +
+ )} + + {configured && wallet.isConnected && portfolioQuery.isLoading && ( - ) : ( + )} + + {configured && wallet.isConnected && portfolioQuery.isError && ( +
+

+ Couldn't load your investments from the network. Try reloading + the page. +

+
+ )} + + {configured && wallet.isConnected && portfolioQuery.isSuccess && ( <> { )}
); -}; +} export default InvestorDashboardPage; diff --git a/client/src/pages/__tests__/InvestorDashboardPage.test.tsx b/client/src/pages/__tests__/InvestorDashboardPage.test.tsx new file mode 100644 index 0000000..546e067 --- /dev/null +++ b/client/src/pages/__tests__/InvestorDashboardPage.test.tsx @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { InvestorDashboardPage } from '../InvestorDashboardPage'; +import type { FundedInvestment } from '../../lib/soroban/investorService'; +import * as investorService from '../../lib/soroban/investorService'; + +const WALLET = 'GINVESTOR000000000000000000000000000000000000000000000B2'; + +const mockUseWallet = vi.fn(); +vi.mock('../../context/WalletContext', () => ({ + useWallet: () => mockUseWallet(), + truncateAddress: (addr: string) => + addr.length <= 12 ? addr : `${addr.slice(0, 6)}...${addr.slice(-4)}`, +})); + +const mockUseClaimRefund = vi.fn(); +const mockUseClaimReturn = vi.fn(); +vi.mock('../../hooks/contract', () => ({ + useClaimRefund: () => mockUseClaimRefund(), + useClaimReturn: () => mockUseClaimReturn(), +})); + +const mockUseInvestorPortfolio = vi.fn(); +vi.mock('../../hooks/useInvestorPortfolio', () => ({ + useInvestorPortfolio: (...args: unknown[]) => + mockUseInvestorPortfolio(...args), +})); + +const mockIsEscrowConfigured = vi.fn(); +vi.mock('../../lib/soroban/config', () => ({ + isEscrowConfigured: () => mockIsEscrowConfigured(), +})); + +const SETTLED_INVESTMENT: FundedInvestment = { + campaignId: '42', + title: 'On-chain Maize PIP', + amountContributed: 5000, + status: 'Settled', + claimableAmount: 1250, + claimed: false, + walletAddress: WALLET, + fundedAt: '2026-06-01T14:30:00.000Z', +}; + +const FAILED_INVESTMENT: FundedInvestment = { + campaignId: '7', + title: 'Failed Fertilizer PIP', + amountContributed: 1500, + status: 'Failed', + claimableAmount: 1500, + claimed: false, + walletAddress: WALLET, + fundedAt: '2026-05-20T09:15:00.000Z', +}; + +function mockWallet(publicKey: string | null) { + mockUseWallet.mockReturnValue({ + publicKey, + isConnected: publicKey !== null, + isConnecting: false, + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + signTransaction: vi.fn(), + }); +} + +function renderPage() { + return render( + + + , + ); +} + +describe('InvestorDashboardPage', () => { + const mutateRefund = vi.fn(); + const mutateReturn = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockIsEscrowConfigured.mockReturnValue(true); + mutateRefund.mockResolvedValue(undefined); + mutateReturn.mockResolvedValue(undefined); + mockUseClaimRefund.mockReturnValue({ + mutateAsync: mutateRefund, + isPending: false, + }); + mockUseClaimReturn.mockReturnValue({ + mutateAsync: mutateReturn, + isPending: false, + }); + mockUseInvestorPortfolio.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + isSuccess: false, + }); + }); + + it('asks the user to connect a wallet instead of showing a fake portfolio', () => { + mockWallet(null); + + renderPage(); + + expect( + screen.getByRole('heading', { + name: /connect to view your investments/i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /connect wallet/i }), + ).toBeInTheDocument(); + expect(screen.queryByText(/organic maize/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/GDF4\.\.\.M9XZ/)).not.toBeInTheDocument(); + expect(mockUseInvestorPortfolio).toHaveBeenCalledWith(null); + }); + + it('renders on-chain portfolio rows for the connected wallet', () => { + mockWallet(WALLET); + mockUseInvestorPortfolio.mockReturnValue({ + data: [SETTLED_INVESTMENT, FAILED_INVESTMENT], + isLoading: false, + isError: false, + isSuccess: true, + }); + + renderPage(); + + expect(mockUseInvestorPortfolio).toHaveBeenCalledWith(WALLET); + expect(screen.getByText('On-chain Maize PIP')).toBeInTheDocument(); + expect(screen.getByText('Failed Fertilizer PIP')).toBeInTheDocument(); + expect(screen.getByText(/Connected: GINVES\.\.\.00B2/)).toBeInTheDocument(); + expect(screen.queryByText(/organic maize/i)).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /claim return/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /claim refund/i }), + ).toBeInTheDocument(); + }); + + it('sends claim refund and claim return through the contract mutation hooks', async () => { + const user = userEvent.setup(); + mockWallet(WALLET); + mockUseInvestorPortfolio.mockReturnValue({ + data: [SETTLED_INVESTMENT, FAILED_INVESTMENT], + isLoading: false, + isError: false, + isSuccess: true, + }); + + const randomSpy = vi.spyOn(Math, 'random'); + const claimRefundSpy = vi.spyOn(investorService, 'claimRefund'); + const claimReturnSpy = vi.spyOn(investorService, 'claimReturn'); + + renderPage(); + + await user.click(screen.getByRole('button', { name: /claim refund/i })); + await user.click(screen.getByRole('button', { name: /claim return/i })); + + expect(mutateRefund).toHaveBeenCalledWith({ + campaignId: '7', + investor: WALLET, + }); + expect(mutateReturn).toHaveBeenCalledWith({ + campaignId: '42', + investor: WALLET, + }); + expect(claimRefundSpy).not.toHaveBeenCalled(); + expect(claimReturnSpy).not.toHaveBeenCalled(); + expect(randomSpy).not.toHaveBeenCalled(); + + randomSpy.mockRestore(); + }); + + it('shows an empty on-chain state when the connected wallet has no investments', () => { + mockWallet(WALLET); + mockUseInvestorPortfolio.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + isSuccess: true, + }); + + renderPage(); + + expect( + screen.getByRole('heading', { name: /no funded investments found/i }), + ).toBeInTheDocument(); + expect(screen.queryByText(/organic maize/i)).not.toBeInTheDocument(); + }); +});