From 7c5b6bee0b8d4a2c89c483f3198bcb45016ceef7 Mon Sep 17 00:00:00 2001 From: shinzoxD Date: Mon, 24 Aug 2026 23:11:58 +0530 Subject: [PATCH] fix(client): wire FundCampaignModal to real useFundCampaign Replace mocked campaignService.fundCampaign (setTimeout + Math.random hash) with the ProductionEscrow fund_campaign mutation. Keep client-side validation, require a connected wallet, and parse amounts as bigint contract units. Success no longer invents a fake transaction hash. Closes #139 --- client/src/__tests__/campaignService.test.ts | 95 +++++----- .../components/campaign/FundCampaignModal.tsx | 96 +++++----- .../__tests__/FundCampaignModal.test.tsx | 169 ++++++++++++++++++ client/src/lib/soroban/campaignService.ts | 95 +++++----- client/vitest.config.ts | 1 - 5 files changed, 329 insertions(+), 127 deletions(-) create mode 100644 client/src/components/campaign/__tests__/FundCampaignModal.test.tsx diff --git a/client/src/__tests__/campaignService.test.ts b/client/src/__tests__/campaignService.test.ts index ca3b924..347d154 100644 --- a/client/src/__tests__/campaignService.test.ts +++ b/client/src/__tests__/campaignService.test.ts @@ -1,49 +1,58 @@ +import { describe, it, expect } from 'vitest'; import { validateContribution, calculateOwnershipShare, - fundCampaign, + parseContributionAmount, } from '../lib/soroban/campaignService'; -function assertEqual(actual: T, expected: T, message?: string) { - if (actual !== expected) { - throw new Error(message || `Expected ${expected}, got ${actual}`); - } -} - -function assertTrue(condition: boolean, message?: string) { - if (!condition) { - throw new Error(message || 'Expected true, got false'); - } -} - -// 1. Validation Tests -const validRes = validateContribution(500, 1000); -assertEqual(validRes.valid, true); - -const zeroRes = validateContribution(0, 1000); -assertEqual(zeroRes.valid, false); -assertEqual(zeroRes.error, 'Contribution amount must be greater than zero'); - -const negativeRes = validateContribution(-50, 1000); -assertEqual(negativeRes.valid, false); - -const exceedsRes = validateContribution(1500, 1000); -assertEqual(exceedsRes.valid, false); -assertTrue(!!exceedsRes.error?.includes('exceeds remaining target')); - -// 2. Ownership Share Calculation Tests -assertEqual(calculateOwnershipShare(2500, 10000), 25); -assertEqual(calculateOwnershipShare(5000, 10000), 50); -assertEqual(calculateOwnershipShare(0, 10000), 0); - -// 3. Fund Campaign Execution Tests -fundCampaign( - { campaignId: 'c1', amount: 1000, walletAddress: 'GUSER' }, - 2000, - 10000, -).then((res) => { - assertEqual(res.success, true); - assertTrue(!!res.txHash?.startsWith('0x')); - assertEqual(res.newTotalRaised, 3000); - assertEqual(res.newRemainingTarget, 7000); +describe('parseContributionAmount', () => { + it('parses whole-number strings as bigint contract units', () => { + expect(parseContributionAmount('500')).toBe(500n); + expect(parseContributionAmount(' 1000 ')).toBe(1000n); + }); + + it('rejects decimals, signs, scientific notation, and empty input', () => { + expect(parseContributionAmount('')).toBeNull(); + expect(parseContributionAmount('0.5')).toBeNull(); + expect(parseContributionAmount('-50')).toBeNull(); + expect(parseContributionAmount('1e3')).toBeNull(); + expect(parseContributionAmount('500.00')).toBeNull(); + expect(parseContributionAmount('abc')).toBeNull(); + }); +}); + +describe('validateContribution', () => { + it('accepts a positive amount within the remaining target', () => { + expect(validateContribution(500, 1000)).toEqual({ valid: true }); + expect(validateContribution(500n, 1000n)).toEqual({ valid: true }); + expect(validateContribution('500', 1000)).toEqual({ valid: true }); + }); + + it('rejects zero and negative amounts', () => { + const zeroRes = validateContribution(0, 1000); + expect(zeroRes.valid).toBe(false); + expect(zeroRes.error).toBe('Contribution amount must be greater than zero'); + + expect(validateContribution(-50, 1000).valid).toBe(false); + expect(validateContribution(0n, 1000n).valid).toBe(false); + }); + + it('rejects amounts that exceed the remaining target', () => { + const exceedsRes = validateContribution(1500, 1000); + expect(exceedsRes.valid).toBe(false); + expect(exceedsRes.error).toMatch(/exceeds remaining target/); + }); + + it('rejects non-integer amounts', () => { + expect(validateContribution(12.5, 1000).valid).toBe(false); + expect(validateContribution('12.5', 1000).valid).toBe(false); + }); +}); + +describe('calculateOwnershipShare', () => { + it('returns a percentage of the campaign target', () => { + expect(calculateOwnershipShare(2500, 10000)).toBe(25); + expect(calculateOwnershipShare(5000, 10000)).toBe(50); + expect(calculateOwnershipShare(0, 10000)).toBe(0); + }); }); diff --git a/client/src/components/campaign/FundCampaignModal.tsx b/client/src/components/campaign/FundCampaignModal.tsx index 8d9579e..bc12db8 100644 --- a/client/src/components/campaign/FundCampaignModal.tsx +++ b/client/src/components/campaign/FundCampaignModal.tsx @@ -1,10 +1,11 @@ import React, { useState } from 'react'; import { Modal } from '../ui/Modal/Modal'; -import { useToast } from '../../context/ToastContext'; +import { useWallet } from '../../context/WalletContext'; +import { useFundCampaign } from '../../hooks/contract'; import { validateContribution, calculateOwnershipShare, - fundCampaign, + parseContributionAmount, type FundCampaignResult, } from '../../lib/soroban/campaignService'; import { toUserFacingError } from '../../lib/soroban/userFacingError'; @@ -16,7 +17,6 @@ export interface FundCampaignModalProps { campaignTitle: string; totalTarget: number; currentRaised: number; - walletAddress?: string; onSuccess?: (result: FundCampaignResult, addedAmount: number) => void; } @@ -27,14 +27,13 @@ export const FundCampaignModal: React.FC = ({ campaignTitle, totalTarget, currentRaised, - walletAddress = 'GDF4...M9XZ', onSuccess, }) => { - const toast = useToast(); + const wallet = useWallet(); + const fundCampaign = useFundCampaign(); const remainingTarget = Math.max(0, totalTarget - currentRaised); const [amount, setAmount] = useState(''); - const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [successResult, setSuccessResult] = useState( null, @@ -42,8 +41,11 @@ export const FundCampaignModal: React.FC = ({ if (!isOpen) return null; - const numAmount = parseFloat(amount) || 0; + const parsedAmount = parseContributionAmount(amount); + const numAmount = parsedAmount !== null ? Number(parsedAmount) : 0; const estimatedShare = calculateOwnershipShare(numAmount, totalTarget); + const isSubmitting = fundCampaign.isPending; + const canSubmit = !isSubmitting && remainingTarget > 0 && wallet.isConnected; const handlePercentageSelect = (percentage: number) => { const calculated = Math.round((remainingTarget * percentage) / 100); @@ -55,40 +57,37 @@ export const FundCampaignModal: React.FC = ({ e.preventDefault(); setError(null); - const validation = validateContribution(numAmount, remainingTarget); - if (!validation.valid) { + const validation = validateContribution( + parsedAmount ?? amount, + remainingTarget, + ); + if (!validation.valid || parsedAmount === null) { setError(validation.error || 'Invalid contribution amount'); return; } - setLoading(true); + if (!wallet.publicKey) { + setError('Connect your wallet to continue.'); + return; + } + try { - const res = await fundCampaign( - { campaignId, amount: numAmount, walletAddress }, - currentRaised, - totalTarget, - ); + await fundCampaign.mutateAsync({ + campaignId, + investor: wallet.publicKey, + amount: parsedAmount, + }); - if (!res.success) { - const message = res.error || 'Failed to fund campaign'; - setError(message); - toast.error('Could not fund campaign', message); - } else { - setSuccessResult(res); - toast.success( - 'Contribution successful', - `You funded ${campaignTitle} with $${numAmount.toLocaleString()}.`, - ); - if (onSuccess) { - onSuccess(res, numAmount); - } - } + const addedAmount = Number(parsedAmount); + const res: FundCampaignResult = { + success: true, + newTotalRaised: currentRaised + addedAmount, + newRemainingTarget: Math.max(0, remainingTarget - addedAmount), + }; + setSuccessResult(res); + onSuccess?.(res, addedAmount); } catch (err) { - const message = toUserFacingError(err); - setError(message); - toast.error('Could not fund campaign', message); - } finally { - setLoading(false); + setError(toUserFacingError(err)); } }; @@ -177,6 +176,23 @@ export const FundCampaignModal: React.FC = ({ + {!wallet.isConnected && ( +
+

Connect your wallet to fund this campaign.

+ +
+ )} + {/* Error banner */} {error && (
= ({ { setAmount(e.target.value); setError(null); }} placeholder="e.g. 500" + disabled={isSubmitting} aria-invalid={!!error} aria-describedby={ error ? 'contribution-amount-error' : undefined @@ -252,10 +268,10 @@ export const FundCampaignModal: React.FC = ({
diff --git a/client/src/components/campaign/__tests__/FundCampaignModal.test.tsx b/client/src/components/campaign/__tests__/FundCampaignModal.test.tsx new file mode 100644 index 0000000..42836c6 --- /dev/null +++ b/client/src/components/campaign/__tests__/FundCampaignModal.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FundCampaignModal } from '../FundCampaignModal'; + +const mockFundCampaign = vi.fn(); +const mockUseWallet = vi.fn(); +const mockConnect = vi.fn(); + +vi.mock('../../../hooks/contract', () => ({ + useFundCampaign: () => ({ + mutateAsync: mockFundCampaign, + isPending: false, + }), +})); + +vi.mock('../../../context/WalletContext', () => ({ + useWallet: () => mockUseWallet(), +})); + +const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +function mockWallet(publicKey: string | null) { + mockUseWallet.mockReturnValue({ + publicKey, + isConnected: publicKey !== null, + isConnecting: false, + error: null, + connect: mockConnect, + disconnect: vi.fn(), + clearError: vi.fn(), + signTransaction: vi.fn(), + }); +} + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + campaignId: '101', + campaignTitle: 'Organic Maize', + totalTarget: 10000, + currentRaised: 2000, +}; + +function renderModal( + override: Partial & { + onSuccess?: ReturnType; + } = {}, +) { + const props = { ...defaultProps, ...override }; + return render(); +} + +function setAmount(value: string) { + fireEvent.change(screen.getByLabelText(/contribution amount/i), { + target: { value }, + }); +} + +describe('FundCampaignModal submit path', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFundCampaign.mockResolvedValue(undefined); + mockWallet(WALLET_ADDRESS); + }); + + it('does not call useFundCampaign when the amount is invalid', async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click( + screen.getByRole('button', { name: /confirm contribution/i }), + ); + + expect( + await screen.findByText( + /contribution amount must be a whole number greater than zero/i, + ), + ).toBeInTheDocument(); + expect(mockFundCampaign).not.toHaveBeenCalled(); + }); + + it('rejects an amount that exceeds the remaining target without submitting', async () => { + const user = userEvent.setup(); + renderModal(); + + setAmount('99999'); + await user.click( + screen.getByRole('button', { name: /confirm contribution/i }), + ); + + expect( + await screen.findByText(/exceeds remaining target/i), + ).toBeInTheDocument(); + expect(mockFundCampaign).not.toHaveBeenCalled(); + }); + + it('prompts to connect a wallet and does not submit when disconnected', async () => { + mockWallet(null); + renderModal(); + + expect( + screen.getByText(/connect your wallet to fund this campaign/i), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /confirm contribution/i }), + ).toBeDisabled(); + + setAmount('500'); + expect(mockFundCampaign).not.toHaveBeenCalled(); + }); + + it('submits a real useFundCampaign mutation with campaign id, investor, and bigint amount', async () => { + const onSuccess = vi.fn(); + const user = userEvent.setup(); + renderModal({ onSuccess }); + + setAmount('500'); + await user.click( + screen.getByRole('button', { name: /confirm contribution/i }), + ); + + expect(mockFundCampaign).toHaveBeenCalledTimes(1); + expect(mockFundCampaign).toHaveBeenCalledWith({ + campaignId: '101', + investor: WALLET_ADDRESS, + amount: 500n, + }); + + expect( + await screen.findByText(/contribution successful/i), + ).toBeInTheDocument(); + expect(screen.queryByText(/transaction hash/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/0x[0-9a-f]{16,}/i)).not.toBeInTheDocument(); + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + newTotalRaised: 2500, + newRemainingTarget: 7500, + }), + 500, + ); + expect(onSuccess.mock.calls[0][0].txHash).toBeUndefined(); + }); + + it('surfaces a contract rejection as a readable error without a fake success hash', async () => { + mockFundCampaign.mockRejectedValueOnce( + new Error('HostError: panicked at campaign not accepting contributions'), + ); + const user = userEvent.setup(); + renderModal(); + + setAmount('500'); + await user.click( + screen.getByRole('button', { name: /confirm contribution/i }), + ); + + expect( + await screen.findByText( + /this campaign is not currently accepting contributions/i, + ), + ).toBeInTheDocument(); + expect( + screen.queryByText(/contribution successful/i), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/0x[0-9a-f]{16,}/i)).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/lib/soroban/campaignService.ts b/client/src/lib/soroban/campaignService.ts index b3c4b24..3ad36d6 100644 --- a/client/src/lib/soroban/campaignService.ts +++ b/client/src/lib/soroban/campaignService.ts @@ -1,9 +1,3 @@ -export interface FundCampaignParams { - campaignId: string; - amount: number; - walletAddress: string; -} - export interface FundCampaignResult { success: boolean; txHash?: string; @@ -12,23 +6,69 @@ export interface FundCampaignResult { error?: string; } +/** + * Parses a contribution amount as a whole-number contract unit (`i128`/`bigint`). + * Rejects decimals, signs, scientific notation, and empty strings — the escrow + * `fund_campaign` amount is a raw integer, same as create-campaign / admin forms. + */ +export function parseContributionAmount(raw: string): bigint | null { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) return null; + try { + return BigInt(trimmed); + } catch { + return null; + } +} + +function toContractUnits(value: bigint | number): bigint { + if (typeof value === 'bigint') return value < 0n ? 0n : value; + if (!Number.isFinite(value) || value <= 0) return 0n; + return BigInt(Math.trunc(value)); +} + export function validateContribution( - amount: number | string, - remainingTarget: number, + amount: bigint | number | string, + remainingTarget: bigint | number, ): { valid: boolean; error?: string } { - const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount; + let parsed: bigint | null; + + if (typeof amount === 'bigint') { + parsed = amount; + } else if (typeof amount === 'string') { + parsed = parseContributionAmount(amount); + if (parsed === null) { + return { + valid: false, + error: 'Contribution amount must be a whole number greater than zero', + }; + } + } else if (!Number.isFinite(amount) || amount <= 0) { + return { + valid: false, + error: 'Contribution amount must be greater than zero', + }; + } else if (!Number.isInteger(amount)) { + return { + valid: false, + error: 'Contribution amount must be a whole number greater than zero', + }; + } else { + parsed = BigInt(amount); + } - if (isNaN(numAmount) || numAmount <= 0) { + if (parsed <= 0n) { return { valid: false, error: 'Contribution amount must be greater than zero', }; } - if (numAmount > remainingTarget) { + const remaining = toContractUnits(remainingTarget); + if (parsed > remaining) { return { valid: false, - error: `Contribution amount (${numAmount}) exceeds remaining target (${remainingTarget})`, + error: `Contribution amount (${parsed}) exceeds remaining target (${remaining})`, }; } @@ -43,34 +83,3 @@ export function calculateOwnershipShare( const share = (amount / totalTarget) * 100; return Math.min(100, Math.round(share * 100) / 100); } - -export async function fundCampaign( - params: FundCampaignParams, - currentRaised = 0, - targetAmount = 10000, -): Promise { - const remainingTarget = Math.max(0, targetAmount - currentRaised); - const validation = validateContribution(params.amount, remainingTarget); - - if (!validation.valid) { - return { success: false, error: validation.error }; - } - - // Simulate network delay / Soroban transaction submission - await new Promise((resolve) => setTimeout(resolve, 600)); - - // Generate deterministic mock txHash for successful funding transaction - const txHash = `0x${Array.from({ length: 64 }, () => - Math.floor(Math.random() * 16).toString(16), - ).join('')}`; - - const newTotalRaised = currentRaised + params.amount; - const newRemaining = Math.max(0, targetAmount - newTotalRaised); - - return { - success: true, - txHash, - newTotalRaised, - newRemainingTarget: newRemaining, - }; -} diff --git a/client/vitest.config.ts b/client/vitest.config.ts index e478d83..765a3c1 100644 --- a/client/vitest.config.ts +++ b/client/vitest.config.ts @@ -16,7 +16,6 @@ export default defineConfig({ '.git/', '.cache/', // Exclude problematic test files from other parts of the project - 'src/__tests__/campaignService.test.ts', 'src/__tests__/investorService.test.ts', 'src/App.test.ts', ],