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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -53,6 +54,7 @@ export default function App() {
<Route element={<AppLayout />}>
<Route path="/activity" element={<ActivityFeedPage />} />
<Route path="/campaigns/:id" element={<CampaignDetailPage />} />
<Route path="/dashboard/investor" element={<InvestorDashboardPage />} />
</Route>
</Routes>
);
Expand Down
6 changes: 6 additions & 0 deletions client/src/components/investor/InvestmentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ const statusBadgeStyles: Record<string, string> = {
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',
Expand Down
2 changes: 2 additions & 0 deletions client/src/hooks/contract/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
142 changes: 142 additions & 0 deletions client/src/hooks/contract/useEscrowMutations.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}

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();
});
});
});
88 changes: 88 additions & 0 deletions client/src/hooks/contract/useEscrowMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
125 changes: 125 additions & 0 deletions client/src/hooks/useInvestorPortfolio.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}

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();
});
});
Loading