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
95 changes: 52 additions & 43 deletions client/src/__tests__/campaignService.test.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,58 @@
import { describe, it, expect } from 'vitest';
import {
validateContribution,
calculateOwnershipShare,
fundCampaign,
parseContributionAmount,
} from '../lib/soroban/campaignService';

function assertEqual<T>(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);
});
});
96 changes: 56 additions & 40 deletions client/src/components/campaign/FundCampaignModal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,7 +17,6 @@ export interface FundCampaignModalProps {
campaignTitle: string;
totalTarget: number;
currentRaised: number;
walletAddress?: string;
onSuccess?: (result: FundCampaignResult, addedAmount: number) => void;
}

Expand All @@ -27,23 +27,25 @@ export const FundCampaignModal: React.FC<FundCampaignModalProps> = ({
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<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [successResult, setSuccessResult] = useState<FundCampaignResult | null>(
null,
);

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);
Expand All @@ -55,40 +57,37 @@ export const FundCampaignModal: React.FC<FundCampaignModalProps> = ({
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));
}
};

Expand Down Expand Up @@ -177,6 +176,23 @@ export const FundCampaignModal: React.FC<FundCampaignModalProps> = ({
</div>
</div>

{!wallet.isConnected && (
<div
role="status"
className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300"
>
<p>Connect your wallet to fund this campaign.</p>
<button
type="button"
onClick={() => void wallet.connect()}
disabled={wallet.isConnecting}
className="mt-2 rounded-lg bg-emerald-700 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-800 disabled:opacity-50"
>
{wallet.isConnecting ? 'Connecting...' : 'Connect wallet'}
</button>
</div>
)}

{/* Error banner */}
{error && (
<div
Expand Down Expand Up @@ -205,16 +221,16 @@ export const FundCampaignModal: React.FC<FundCampaignModalProps> = ({
</span>
<input
id="contribution-amount"
type="number"
min="1"
max={remainingTarget}
step="any"
type="text"
inputMode="numeric"
autoComplete="off"
value={amount}
onChange={(e) => {
setAmount(e.target.value);
setError(null);
}}
placeholder="e.g. 500"
disabled={isSubmitting}
aria-invalid={!!error}
aria-describedby={
error ? 'contribution-amount-error' : undefined
Expand Down Expand Up @@ -252,10 +268,10 @@ export const FundCampaignModal: React.FC<FundCampaignModalProps> = ({
</button>
<button
type="submit"
disabled={loading || remainingTarget <= 0}
disabled={!canSubmit}
className="rounded-xl bg-emerald-700 px-5 py-2.5 font-semibold text-white shadow-sm transition hover:bg-emerald-800 disabled:opacity-50"
>
{loading ? 'Confirming...' : 'Confirm Contribution'}
{isSubmitting ? 'Confirming...' : 'Confirm Contribution'}
</button>
</div>
</form>
Expand Down
Loading