diff --git a/client/src/components/campaign/OpenDisputeForm.tsx b/client/src/components/campaign/OpenDisputeForm.tsx
new file mode 100644
index 0000000..a684839
--- /dev/null
+++ b/client/src/components/campaign/OpenDisputeForm.tsx
@@ -0,0 +1,145 @@
+import { useState } from 'react';
+import type { FormEvent } from 'react';
+import { useWallet } from '../../context/WalletContext';
+import {
+ useContribution,
+ useDispute,
+ useEscrowAdmin,
+ useOpenDispute,
+} from '../../hooks/contract';
+import { toUserFacingError } from '../../lib/soroban/userFacingError';
+
+const cardClass =
+ 'rounded-campaign border border-soil-200 bg-white p-6 shadow-campaign';
+const primaryButtonClass =
+ 'inline-flex items-center justify-center rounded-lg bg-leaf-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
+const inputClass =
+ 'w-full rounded-lg border border-soil-300 px-3 py-2 text-body-sm text-soil-900 focus:border-leaf-500 focus:outline-none focus:ring-1 focus:ring-leaf-500';
+const labelClass = 'mb-1 block text-label text-soil-500';
+const errorClass = 'mt-1 text-caption text-status-failed-dark';
+const sectionTitleClass = 'text-h4 text-soil-900';
+
+function ActionError({ message }: { message: string | null }) {
+ if (!message) return null;
+ return
{message}
;
+}
+
+export interface OpenDisputeFormProps {
+ campaignId: string;
+ farmerAddress: string;
+}
+
+/**
+ * Lets a campaign's farmer, a contributing investor, or the escrow admin
+ * open a dispute. Eligibility is a UX convenience only — `open_dispute`
+ * enforces the same set on-chain via `.require_auth()`.
+ */
+export function OpenDisputeForm({
+ campaignId,
+ farmerAddress,
+}: OpenDisputeFormProps) {
+ const { publicKey } = useWallet();
+ const openDispute = useOpenDispute();
+ const { data: contribution } = useContribution(
+ campaignId,
+ publicKey ?? undefined,
+ );
+ const { data: adminAddress } = useEscrowAdmin();
+ const { data: dispute } = useDispute(campaignId);
+
+ const [reason, setReason] = useState('');
+ const [formError, setFormError] = useState(null);
+ const [success, setSuccess] = useState(false);
+
+ const hasOpenDispute = dispute?.status?.tag === 'Open';
+
+ if (!publicKey) {
+ return (
+
+
Open a dispute
+
+ Connect your wallet to open a dispute on this campaign.
+
+
+ );
+ }
+
+ const isFarmer = publicKey === farmerAddress;
+ const isContributor = (contribution ?? 0n) > 0n;
+ const isAdmin = publicKey === adminAddress;
+ const canOpenDispute = isFarmer || isContributor || isAdmin;
+
+ if (!canOpenDispute) return null;
+
+ if (hasOpenDispute) {
+ return (
+
+
Open a dispute
+
+ A dispute is already open on this campaign — an admin needs to resolve
+ it before a new one can be opened.
+
+
+ );
+ }
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ setFormError(null);
+ setSuccess(false);
+
+ if (reason.trim().length === 0) {
+ setFormError('Enter a reason for the dispute.');
+ return;
+ }
+
+ try {
+ await openDispute.mutateAsync({
+ campaignId,
+ opener: publicKey!,
+ reason: reason.trim(),
+ });
+ setSuccess(true);
+ setReason('');
+ } catch (err) {
+ setFormError(toUserFacingError(err));
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/client/src/components/campaign/__tests__/OpenDisputeForm.test.tsx b/client/src/components/campaign/__tests__/OpenDisputeForm.test.tsx
new file mode 100644
index 0000000..4c3276f
--- /dev/null
+++ b/client/src/components/campaign/__tests__/OpenDisputeForm.test.tsx
@@ -0,0 +1,159 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { OpenDisputeForm } from '../OpenDisputeForm';
+import type { Dispute } from '../../../lib/soroban/types';
+
+const FARMER = 'GFARMER0000000000000000000000000000000000000000000000000';
+const CONTRIBUTOR = 'GINVESTOR000000000000000000000000000000000000000000000';
+const ADMIN = 'GADMIN00000000000000000000000000000000000000000000000000';
+const STRANGER = 'GSTRANGER00000000000000000000000000000000000000000000000';
+
+const mockOpenDispute = vi.fn();
+let mockPublicKey: string | null = FARMER;
+let mockContribution = 0n;
+let mockAdmin: string | undefined = ADMIN;
+let mockDispute: Dispute | undefined;
+
+vi.mock('../../../context/WalletContext', () => ({
+ useWallet: () => ({ publicKey: mockPublicKey }),
+}));
+
+vi.mock('../../../hooks/contract', () => ({
+ useOpenDispute: () => ({ mutateAsync: mockOpenDispute, isPending: false }),
+ useContribution: () => ({ data: mockContribution }),
+ useEscrowAdmin: () => ({ data: mockAdmin }),
+ useDispute: () => ({ data: mockDispute }),
+}));
+
+beforeEach(() => {
+ mockOpenDispute.mockReset();
+ mockPublicKey = FARMER;
+ mockContribution = 0n;
+ mockAdmin = ADMIN;
+ mockDispute = undefined;
+});
+
+describe('OpenDisputeForm', () => {
+ it('prompts to connect a wallet when none is connected', () => {
+ mockPublicKey = null;
+ render();
+
+ expect(
+ screen.getByText(/connect your wallet to open a dispute/i),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /open dispute/i })).toBeNull();
+ });
+
+ it('renders nothing for a connected wallet that is not the farmer, a contributor, or the admin', () => {
+ mockPublicKey = STRANGER;
+ mockContribution = 0n;
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('lets the farmer open a dispute', async () => {
+ mockPublicKey = FARMER;
+ const user = userEvent.setup();
+ render();
+
+ await user.type(
+ screen.getByLabelText(/reason/i),
+ 'Harvest outcome does not match the report.',
+ );
+ await user.click(screen.getByRole('button', { name: /open dispute/i }));
+
+ expect(mockOpenDispute).toHaveBeenCalledWith({
+ campaignId: '42',
+ opener: FARMER,
+ reason: 'Harvest outcome does not match the report.',
+ });
+ expect(await screen.findByText(/dispute opened/i)).toBeInTheDocument();
+ });
+
+ it('lets a contributing investor open a dispute', async () => {
+ mockPublicKey = CONTRIBUTOR;
+ mockContribution = 500n;
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByLabelText(/reason/i), 'Funds not released.');
+ await user.click(screen.getByRole('button', { name: /open dispute/i }));
+
+ expect(mockOpenDispute).toHaveBeenCalledWith({
+ campaignId: '42',
+ opener: CONTRIBUTOR,
+ reason: 'Funds not released.',
+ });
+ });
+
+ it('lets the admin open a dispute', async () => {
+ mockPublicKey = ADMIN;
+ mockContribution = 0n;
+ const user = userEvent.setup();
+ render();
+
+ await user.type(
+ screen.getByLabelText(/reason/i),
+ 'Investigating on behalf of a reporter.',
+ );
+ await user.click(screen.getByRole('button', { name: /open dispute/i }));
+
+ expect(mockOpenDispute).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects an empty reason client-side and does not call the mutation', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: /open dispute/i }));
+
+ expect(
+ await screen.findByText(/enter a reason for the dispute/i),
+ ).toBeInTheDocument();
+ expect(mockOpenDispute).not.toHaveBeenCalled();
+ });
+
+ it('surfaces a contract-level rejection as a readable error without clearing the form', async () => {
+ mockOpenDispute.mockRejectedValueOnce(
+ new Error(
+ 'HostError: Error(Contract, #7)\nlong diagnostic dump that should be truncated',
+ ),
+ );
+ const user = userEvent.setup();
+ render();
+
+ const reasonInput = screen.getByLabelText(/reason/i);
+ await user.type(reasonInput, 'Disputing harvest outcome.');
+ await user.click(screen.getByRole('button', { name: /open dispute/i }));
+
+ expect(
+ await screen.findByText(/HostError: Error\(Contract, #7\)/),
+ ).toBeInTheDocument();
+ expect(reasonInput).toHaveValue('Disputing harvest outcome.');
+ });
+
+ it('does not render the open-dispute form when a dispute is already Open', () => {
+ mockDispute = {
+ campaign_id: 42n,
+ opener: FARMER,
+ reason: 'Harvest_delay',
+ timestamp: 0n,
+ ledger_sequence: 0,
+ status: { tag: 'Open' },
+ resolution: { tag: 'Pending' },
+ };
+ render();
+
+ expect(
+ screen.getByText(
+ /a dispute is already open on this campaign — an admin needs to resolve it/i,
+ ),
+ ).toBeInTheDocument();
+ expect(screen.queryByLabelText(/reason/i)).toBeNull();
+ expect(screen.queryByRole('button', { name: /open dispute/i })).toBeNull();
+ });
+});
diff --git a/client/src/pages/CampaignDetailPage.tsx b/client/src/pages/CampaignDetailPage.tsx
index bcc8407..d2479a5 100644
--- a/client/src/pages/CampaignDetailPage.tsx
+++ b/client/src/pages/CampaignDetailPage.tsx
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { FundCampaignModal } from '../components/campaign/FundCampaignModal';
import { StatusBadge } from '../components/campaign/StatusBadge';
import { ActivityFeed } from '../components/campaign/ActivityFeed';
+import { OpenDisputeForm } from '../components/campaign/OpenDisputeForm';
import { useCampaignLiveUpdates } from '../hooks/useCampaignLiveUpdates';
import { DetailPageSkeleton } from '../components/ui/Skeleton/Skeleton';
@@ -12,6 +13,7 @@ export interface CampaignData {
totalTarget: number;
currentRaised: number;
status: 'Active' | 'Funding' | 'Resolved' | 'Failed' | 'Settled';
+ farmer: string;
}
export const CampaignDetailPage: React.FC = () => {
@@ -28,6 +30,8 @@ export const CampaignDetailPage: React.FC = () => {
totalTarget: 50000,
currentRaised: 32500,
status: 'Funding',
+ farmer:
+ 'GDF4X5Y6Z7A8B9C0D1E2F3G4H5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3',
});
}, 0);
return () => window.clearTimeout(timer);
@@ -137,6 +141,11 @@ export const CampaignDetailPage: React.FC = () => {
refreshIntervalMs={30_000}
/>
+
+
);
};