diff --git a/app/(landing)/bounties/[id]/page.tsx b/app/(landing)/bounties/[id]/page.tsx
new file mode 100644
index 000000000..31a577238
--- /dev/null
+++ b/app/(landing)/bounties/[id]/page.tsx
@@ -0,0 +1,22 @@
+import { Metadata } from 'next';
+
+import { generatePageMetadata } from '@/lib/metadata';
+import BountyDetail from '@/components/bounties/detail/BountyDetail';
+
+export const metadata: Metadata = generatePageMetadata('bounties');
+
+interface BountyDetailPageProps {
+ params: Promise<{ id: string }>;
+}
+
+export default async function BountyDetailPageRoute({
+ params,
+}: BountyDetailPageProps) {
+ const { id } = await params;
+
+ return (
+
- Bounties Page
+
+
);
-};
-
-export default GrantPage;
+}
diff --git a/components/bounties/detail/BountyDetail.tsx b/components/bounties/detail/BountyDetail.tsx
new file mode 100644
index 000000000..4da15dd83
--- /dev/null
+++ b/components/bounties/detail/BountyDetail.tsx
@@ -0,0 +1,231 @@
+'use client';
+
+import React from 'react';
+import dynamic from 'next/dynamic';
+import Link from 'next/link';
+import {
+ ArrowLeft,
+ Award,
+ Calendar,
+ Loader2,
+ Target,
+ Users,
+} from 'lucide-react';
+
+import { Badge } from '@/components/ui/badge';
+import EmptyState from '@/components/EmptyState';
+import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema';
+import { useBounty, useMyBountyApplication } from '@/features/bounties';
+import { BountyEntryCta } from './BountyEntryCta';
+
+// Markdown renderer (matches the wizard's editor package).
+const Markdown = dynamic(
+ () => import('@uiw/react-md-editor').then(mod => mod.default.Markdown),
+ { ssr: false }
+);
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+const ordinal = (n: number): string => {
+ const s = ['th', 'st', 'nd', 'rd'];
+ const v = n % 100;
+ return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`;
+};
+
+export default function BountyDetail({ id }: { id: string }) {
+ const { data: bounty, isLoading, error } = useBounty(id);
+ const { data: myApplication } = useMyBountyApplication(id);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !bounty) {
+ return (
+
+
+
+
+ Back to bounties
+
+
+
+ );
+ }
+
+ const modeLabel =
+ bounty.entryType && bounty.claimType
+ ? computeBountyModeLabel(bounty.entryType, bounty.claimType)
+ : 'Bounty';
+ const currency = bounty.rewardCurrency;
+
+ return (
+
+
+
+ Back to bounties
+
+
+
+ {/* Main */}
+
+
+
+ {modeLabel}
+
+
+ {bounty.status.replace(/_/g, ' ')}
+
+
+
+
+ {bounty.title}
+
+
+ by {bounty.organization.name}
+
+
+ {/* Description (markdown) */}
+
+
+
+
+ {/* Prize tiers */}
+ {bounty.prizeTiers.length > 0 && (
+
+
+
+ Prize tiers
+
+
+ {bounty.prizeTiers
+ .slice()
+ .sort((a, b) => a.position - b.position)
+ .map(tier => (
+
+
+ {ordinal(tier.position)} place
+
+
+ {Number(tier.amount).toLocaleString()} {currency}
+
+
+ ))}
+
+
+ )}
+
+
+ {/* Sidebar */}
+
+
+
+ );
+}
+
+function Row({
+ label,
+ value,
+ icon,
+}: {
+ label: string;
+ value: string;
+ icon?: React.ReactNode;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+ {value}
+
+ );
+}
diff --git a/components/bounties/detail/BountyEntryCta.tsx b/components/bounties/detail/BountyEntryCta.tsx
new file mode 100644
index 000000000..688a2baeb
--- /dev/null
+++ b/components/bounties/detail/BountyEntryCta.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import { toast } from 'sonner';
+import { CheckCircle2, Lock } from 'lucide-react';
+
+import { BoundlessButton } from '@/components/buttons';
+import { Badge } from '@/components/ui/badge';
+import type { BountyPublic, MyBountyApplication } from '@/features/bounties';
+
+/** Statuses where a bounty still accepts new entries. */
+const ACCEPTING_STATUSES = new Set([
+ 'open',
+ 'open_for_applications',
+ 'upcoming',
+]);
+
+type EntryKind = 'claim' | 'join' | 'apply' | 'view';
+
+function entryKind(b: BountyPublic): { kind: EntryKind; label: string } {
+ const open = b.entryType === 'OPEN';
+ const application =
+ b.entryType === 'APPLICATION_LIGHT' || b.entryType === 'APPLICATION_FULL';
+ if (open && b.claimType === 'SINGLE_CLAIM')
+ return { kind: 'claim', label: 'Claim this bounty' };
+ if (open && b.claimType === 'COMPETITION')
+ return { kind: 'join', label: 'Join competition' };
+ if (application) return { kind: 'apply', label: 'Apply' };
+ return { kind: 'view', label: 'View' };
+}
+
+const STATUS_LABEL: Record
= {
+ SUBMITTED: 'Application submitted',
+ SHORTLISTED: 'Shortlisted',
+ SELECTED: 'Selected',
+ DECLINED: 'Not selected',
+ WITHDRAWN: 'Withdrawn',
+};
+
+const STATUS_CLASS: Record = {
+ SUBMITTED: 'border-blue-500/30 bg-blue-500/10 text-blue-400',
+ SHORTLISTED: 'border-amber-500/30 bg-amber-500/10 text-amber-400',
+ SELECTED: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
+ DECLINED: 'border-red-500/30 bg-red-500/10 text-red-400',
+ WITHDRAWN: 'border-zinc-700 bg-zinc-800/60 text-zinc-300',
+};
+
+export function BountyEntryCta({
+ bounty,
+ myApplication,
+}: {
+ bounty: BountyPublic;
+ myApplication: MyBountyApplication | null;
+}) {
+ const { label } = entryKind(bounty);
+
+ const deadlinePassed =
+ !!bounty.applicationWindowCloseAt &&
+ new Date(bounty.applicationWindowCloseAt).getTime() < Date.now();
+ const accepting = ACCEPTING_STATUSES.has(bounty.status);
+
+ // An active (non-withdrawn) application means the caller is already in.
+ const activeStatus =
+ myApplication && myApplication.applicationStatus !== 'WITHDRAWN'
+ ? myApplication.applicationStatus
+ : null;
+
+ // The real entry flow (forms / on-chain op) ships in #624; the CTA here is
+ // mode-aware and correctly gated, and hands off to that flow.
+ const onEntry = () =>
+ toast.info('The entry flow is coming in the next step (apply/join/claim).');
+
+ return (
+
+ {activeStatus ? (
+
+
+
+
+ You're in this bounty
+
+
+
+ {STATUS_LABEL[activeStatus] ?? activeStatus}
+
+
+ ) : (
+ <>
+
+ {label}
+
+
+ {!accepting ? (
+ <>
+
+ This bounty is not accepting entries.
+ >
+ ) : deadlinePassed ? (
+ <>
+
+ Applications have closed.
+ >
+ ) : myApplication?.applicationStatus === 'WITHDRAWN' ? (
+ 'You withdrew earlier — you can enter again.'
+ ) : (
+ 'Funds are held in escrow and paid on-chain to winners.'
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/components/bounties/marketplace/BountiesFiltersHeader.tsx b/components/bounties/marketplace/BountiesFiltersHeader.tsx
new file mode 100644
index 000000000..bc11f96fd
--- /dev/null
+++ b/components/bounties/marketplace/BountiesFiltersHeader.tsx
@@ -0,0 +1,106 @@
+'use client';
+
+import { Search } from 'lucide-react';
+
+import { Input } from '@/components/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+
+export const STATUS_OPTIONS = [
+ { value: 'all', label: 'All statuses' },
+ { value: 'open', label: 'Open' },
+ { value: 'in_progress', label: 'In progress' },
+ { value: 'completed', label: 'Completed' },
+ { value: 'cancelled', label: 'Cancelled' },
+] as const;
+
+export const MODE_OPTIONS = [
+ { value: 'all', label: 'All modes' },
+ { value: 'single_claim', label: 'Single claim' },
+ { value: 'competition', label: 'Competition' },
+ { value: 'application', label: 'Application' },
+] as const;
+
+export type ModeFilter = (typeof MODE_OPTIONS)[number]['value'];
+
+interface BountiesFiltersHeaderProps {
+ search: string;
+ status: string;
+ mode: ModeFilter;
+ totalCount: number;
+ onSearch: (value: string) => void;
+ onStatus: (value: string) => void;
+ onMode: (value: ModeFilter) => void;
+}
+
+export default function BountiesFiltersHeader({
+ search,
+ status,
+ mode,
+ totalCount,
+ onSearch,
+ onStatus,
+ onMode,
+}: BountiesFiltersHeaderProps) {
+ return (
+
+
+
+
+ Bounties
+
+
+ Discover open bounties and start earning.
+
+
+
+ {totalCount} bount{totalCount === 1 ? 'y' : 'ies'}
+
+
+
+
+
+
+ onSearch(e.target.value)}
+ className='focus:border-primary h-10 rounded-xl border-zinc-800/50 bg-zinc-900/30 pl-10 text-sm text-white placeholder:text-zinc-500 hover:border-zinc-700'
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/bounties/marketplace/BountiesPage.tsx b/components/bounties/marketplace/BountiesPage.tsx
new file mode 100644
index 000000000..200997da0
--- /dev/null
+++ b/components/bounties/marketplace/BountiesPage.tsx
@@ -0,0 +1,151 @@
+'use client';
+
+import React, { useEffect, useMemo, useState } from 'react';
+
+import EmptyState from '@/components/EmptyState';
+import LoadingSpinner from '@/components/LoadingSpinner';
+import { BoundlessButton } from '@/components/buttons';
+import { useInfiniteScroll } from '@/hooks/use-infinite-scroll';
+import type { BountyPublic } from '@/features/bounties';
+import { BountyCard } from './BountyCard';
+import BountiesFiltersHeader, {
+ type ModeFilter,
+} from './BountiesFiltersHeader';
+import { useInfiniteBounties } from './use-bounties-marketplace';
+
+/** Client-side mode narrowing (the public list endpoint doesn't filter on mode). */
+function matchesMode(b: BountyPublic, mode: ModeFilter): boolean {
+ switch (mode) {
+ case 'single_claim':
+ return b.claimType === 'SINGLE_CLAIM';
+ case 'competition':
+ return b.claimType === 'COMPETITION';
+ case 'application':
+ return (
+ b.entryType === 'APPLICATION_LIGHT' ||
+ b.entryType === 'APPLICATION_FULL'
+ );
+ default:
+ return true;
+ }
+}
+
+export default function BountiesPage() {
+ const [search, setSearch] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [status, setStatus] = useState('all');
+ const [mode, setMode] = useState('all');
+
+ // Debounce the search so we don't refetch on every keystroke.
+ useEffect(() => {
+ const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
+ return () => clearTimeout(t);
+ }, [search]);
+
+ const {
+ bounties,
+ totalCount,
+ loading,
+ loadingMore,
+ error,
+ hasMore,
+ loadMore,
+ } = useInfiniteBounties({
+ status: status === 'all' ? undefined : status,
+ search: debouncedSearch || undefined,
+ });
+
+ const visible = useMemo(
+ () => bounties.filter(b => matchesMode(b, mode)),
+ [bounties, mode]
+ );
+
+ const [sentinel, setSentinel] = useState(null);
+ useInfiniteScroll(sentinel, {
+ onLoadMore: loadMore,
+ hasMore: !!hasMore,
+ loading: loadingMore,
+ rootMargin: '0px 0px 1200px 0px',
+ });
+
+ const hasFilters = !!debouncedSearch || status !== 'all' || mode !== 'all';
+ const clearFilters = () => {
+ setSearch('');
+ setStatus('all');
+ setMode('all');
+ };
+
+ return (
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && error && (
+
+
+
+ )}
+
+ {!loading && !error && visible.length === 0 && (
+
+
+ {hasFilters && (
+
+
+ Clear filters
+
+
+ )}
+
+ )}
+
+ {!loading && !error && visible.length > 0 && (
+ <>
+
+ {visible.map(b => (
+
+ ))}
+
+
+
+
+ {loadingMore && (
+
+
+ Loading more...
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/components/bounties/marketplace/BountyCard.tsx b/components/bounties/marketplace/BountyCard.tsx
new file mode 100644
index 000000000..939918f53
--- /dev/null
+++ b/components/bounties/marketplace/BountyCard.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import Link from 'next/link';
+import { Calendar, Target } from 'lucide-react';
+
+import { Badge } from '@/components/ui/badge';
+import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema';
+import type { BountyPublic } from '@/features/bounties';
+
+/** Plain-language mode label (single claim / competition / application). */
+function modeLabel(b: BountyPublic): string {
+ if (b.entryType && b.claimType) {
+ return computeBountyModeLabel(b.entryType, b.claimType);
+ }
+ return 'Bounty';
+}
+
+const STATUS_CLASS: Record = {
+ open: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
+ in_progress: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
+ completed: 'border-primary/30 bg-primary/10 text-primary',
+ cancelled: 'border-zinc-700 bg-zinc-800/60 text-zinc-300',
+};
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+export function BountyCard({ bounty }: { bounty: BountyPublic }) {
+ const reward = bounty.rewardAmount > 0;
+ const statusClass =
+ STATUS_CLASS[bounty.status] ??
+ 'border-zinc-700 bg-zinc-800/60 text-zinc-300';
+
+ return (
+
+
+
+ {modeLabel(bounty)}
+
+
+ {bounty.status.replace(/_/g, ' ')}
+
+
+
+
+ {bounty.title}
+
+ {bounty.description && (
+
+ {bounty.description}
+
+ )}
+
+ {bounty.applicationWindowCloseAt && (
+
+
+ Applications close {formatDate(bounty.applicationWindowCloseAt)}
+
+ )}
+
+
+
+
+
+
+
+
+ {reward
+ ? `${bounty.rewardAmount.toLocaleString()} ${bounty.rewardCurrency}`
+ : 'No reward set'}
+
+
reward
+
+
+
+ {bounty.organization.name}
+
+
+
+ );
+}
diff --git a/components/bounties/marketplace/use-bounties-marketplace.ts b/components/bounties/marketplace/use-bounties-marketplace.ts
new file mode 100644
index 000000000..48ec43d3a
--- /dev/null
+++ b/components/bounties/marketplace/use-bounties-marketplace.ts
@@ -0,0 +1,46 @@
+'use client';
+
+import { useInfiniteQuery } from '@tanstack/react-query';
+
+import { listBounties, bountyKeys } from '@/features/bounties';
+
+const PAGE_SIZE = 12;
+
+export interface MarketplaceFilters {
+ /** Server-side: bounty lifecycle status (e.g. 'open'). */
+ status?: string;
+ /** Server-side: free-text title/description search. */
+ search?: string;
+}
+
+/**
+ * Infinite (page-accumulating) public bounty list for the marketplace. Wraps the
+ * `listBounties` client; status + search are applied server-side, pagination via
+ * `getNextPageParam` off the response total. Mode/category narrowing is done
+ * client-side in the page (the public list endpoint doesn't filter on them).
+ */
+export function useInfiniteBounties(filters: MarketplaceFilters) {
+ const query = useInfiniteQuery({
+ queryKey: bountyKeys.list({ ...filters, infinite: true }),
+ queryFn: ({ pageParam }) =>
+ listBounties({ ...filters, page: pageParam, limit: PAGE_SIZE }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) => {
+ const loaded = allPages.reduce((n, p) => n + p.bounties.length, 0);
+ return loaded < lastPage.total ? allPages.length + 1 : undefined;
+ },
+ });
+
+ const bounties = query.data?.pages.flatMap(p => p.bounties) ?? [];
+ const totalCount = query.data?.pages[0]?.total ?? 0;
+
+ return {
+ bounties,
+ totalCount,
+ loading: query.isLoading,
+ loadingMore: query.isFetchingNextPage,
+ error: query.error ? (query.error as Error).message : null,
+ hasMore: query.hasNextPage,
+ loadMore: query.fetchNextPage,
+ };
+}
diff --git a/features/bounties/api/keys.ts b/features/bounties/api/keys.ts
index c0ca5a549..192ec8e30 100644
--- a/features/bounties/api/keys.ts
+++ b/features/bounties/api/keys.ts
@@ -10,4 +10,13 @@ export const bountyKeys = {
[...bountyKeys.all, 'draft', organizationId, id] as const,
escrowOp: (scope: string, opRowId: string) =>
[...bountyKeys.all, 'escrow-op', scope, opRowId] as const,
+
+ // Builder / participant reads.
+ list: (params: Record = {}) =>
+ [...bountyKeys.all, 'list', params] as const,
+ detail: (bountyId: string) =>
+ [...bountyKeys.all, 'detail', bountyId] as const,
+ myApplication: (bountyId: string) =>
+ [...bountyKeys.all, 'my-application', bountyId] as const,
+ myActivity: () => [...bountyKeys.all, 'my-activity'] as const,
};
diff --git a/features/bounties/api/participant-client.ts b/features/bounties/api/participant-client.ts
new file mode 100644
index 000000000..0e4c08d46
--- /dev/null
+++ b/features/bounties/api/participant-client.ts
@@ -0,0 +1,120 @@
+/**
+ * Builder/participant bounty REST client: the public marketplace reads and the
+ * v2 application-record surface (off-chain proposal carrier for the application
+ * entry types), plus open-competition join/leave.
+ *
+ * Several backend handlers still return the raw entity without an `@ApiOkResponse`
+ * (so the generated schema types them as `never`). Those are cast to the
+ * hand-typed `MyBountyApplication` until boundless-nestjs #331 lands and codegen
+ * picks up the real DTO.
+ */
+import { apiClient, unwrapData } from '@/lib/api';
+
+import type {
+ BountyPublic,
+ BountyPublicList,
+ CreateBountyApplicationRequest,
+ EditBountyApplicationRequest,
+ JoinCompetitionRequest,
+ MyBountyApplication,
+} from '../types';
+
+export interface BountiesListParams {
+ organizationId?: string;
+ status?: string;
+ search?: string;
+ page?: number;
+ limit?: number;
+}
+
+// ── Public marketplace ────────────────────────────────────────────────────────
+
+/** Paginated public bounty list (the marketplace). */
+export const listBounties = async (
+ params: BountiesListParams = {}
+): Promise =>
+ unwrapData(
+ await apiClient.GET('/api/bounties', { params: { query: params } })
+ );
+
+/** A single public bounty (the detail page). */
+export const getBounty = async (bountyId: string): Promise =>
+ unwrapData(
+ await apiClient.GET('/api/bounties/{id}', {
+ params: { path: { id: bountyId } },
+ })
+ );
+
+// ── v2 application records (application entry types) ──────────────────────────
+
+/** The caller's own application for a bounty, or null when they have not applied. */
+export const getMyBountyApplication = async (
+ bountyId: string
+): Promise =>
+ (unwrapData(
+ await apiClient.GET('/api/bounties/{bountyId}/v2/applications/me', {
+ params: { path: { bountyId } },
+ })
+ ) as unknown as MyBountyApplication | null) ?? null;
+
+/** Submit an application (light / full proposal) for an application-mode bounty. */
+export const applyToBounty = async (
+ bountyId: string,
+ body: CreateBountyApplicationRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST('/api/bounties/{bountyId}/v2/applications', {
+ params: { path: { bountyId } },
+ body,
+ })
+ ) as unknown as MyBountyApplication;
+
+/** Edit a SUBMITTED application (before shortlist). */
+export const editBountyApplication = async (
+ bountyId: string,
+ appId: string,
+ body: EditBountyApplicationRequest
+): Promise =>
+ unwrapData(
+ await apiClient.PATCH('/api/bounties/{bountyId}/v2/applications/{appId}', {
+ params: { path: { bountyId, appId } },
+ body,
+ })
+ ) as unknown as MyBountyApplication;
+
+/** Withdraw a SUBMITTED application (the application record; status -> WITHDRAWN). */
+export const withdrawBountyApplication = async (
+ bountyId: string,
+ appId: string
+): Promise =>
+ unwrapData(
+ await apiClient.DELETE('/api/bounties/{bountyId}/v2/applications/{appId}', {
+ params: { path: { bountyId, appId } },
+ })
+ ) as unknown as MyBountyApplication;
+
+// ── Open competition join / leave ─────────────────────────────────────────────
+
+/** Join an open competition directly (no application step). */
+export const joinCompetition = async (
+ bountyId: string,
+ body: JoinCompetitionRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST('/api/bounties/{bountyId}/v2/competition/join', {
+ params: { path: { bountyId } },
+ body,
+ })
+ ) as unknown as MyBountyApplication;
+
+/** Leave an open competition the caller joined. */
+export const leaveCompetition = async (
+ bountyId: string,
+ body: JoinCompetitionRequest
+): Promise =>
+ unwrapData(
+ await apiClient.DELETE('/api/bounties/{bountyId}/v2/competition/join', {
+ params: { path: { bountyId } },
+ body,
+ })
+ ) as unknown as MyBountyApplication;
diff --git a/features/bounties/api/participant-escrow-client.ts b/features/bounties/api/participant-escrow-client.ts
new file mode 100644
index 000000000..44fc93061
--- /dev/null
+++ b/features/bounties/api/participant-escrow-client.ts
@@ -0,0 +1,107 @@
+/**
+ * Builder/participant bounty escrow client (boundless-events contract), on the
+ * typed openapi-fetch client. Counterpart to the organizer `escrow-client.ts`:
+ * these are the participant-scoped, no-org-prefix routes (`/api/bounties/{id}/
+ * escrow/...`) the builder lifecycle drives through the shared escrow runner.
+ *
+ * Like the organizer ops, each builds an EscrowOp:
+ * MANAGED : backend signs with the caller's platform-held wallet + submits;
+ * the op returns in PENDING_CONFIRM (the webapp only polls).
+ * EXTERNAL : backend returns `unsignedXdr`; the webapp signs with a connected
+ * wallet and POSTs to submit-signed, then polls.
+ */
+import { apiClient, unwrapData } from '@/lib/api';
+
+import type {
+ ApplyBountyRequest,
+ BountyEscrowOpResponse,
+ ContributeBountyRequest,
+ SubmitBountyRequest,
+ SubmitSignedXdrRequest,
+ WithdrawApplicationRequest,
+ WithdrawSubmissionRequest,
+} from '../types';
+
+/** Apply to a bounty on-chain (APPLY_TO_BOUNTY; charges application credits). */
+export const applyToBountyEscrow = async (
+ bountyId: string,
+ body: ApplyBountyRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST('/api/bounties/{id}/escrow/apply', {
+ params: { path: { id: bountyId } },
+ body,
+ })
+ );
+
+/** Withdraw a bounty application (refunds half the application credits). */
+export const withdrawBountyApplicationEscrow = async (
+ bountyId: string,
+ body: WithdrawApplicationRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST('/api/bounties/{id}/escrow/withdraw-application', {
+ params: { path: { id: bountyId } },
+ body,
+ })
+ );
+
+/** Anchor a work submission on-chain (SUBMIT; requires an active application). */
+export const submitBountyWorkEscrow = async (
+ bountyId: string,
+ body: SubmitBountyRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST('/api/bounties/{id}/escrow/submit', {
+ params: { path: { id: bountyId } },
+ body,
+ })
+ );
+
+/** Withdraw a submission anchor. */
+export const withdrawBountySubmissionEscrow = async (
+ bountyId: string,
+ body: WithdrawSubmissionRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST('/api/bounties/{id}/escrow/withdraw-submission', {
+ params: { path: { id: bountyId } },
+ body,
+ })
+ );
+
+/** Contribute funds to a bounty's escrow pool (ADD_FUNDS; 10 USDC minimum). */
+export const contributeToBountyEscrow = async (
+ bountyId: string,
+ body: ContributeBountyRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST('/api/bounties/{id}/escrow/contribute', {
+ params: { path: { id: bountyId } },
+ body,
+ })
+ );
+
+/** Submit a wallet-signed XDR for a participant op (EXTERNAL path). */
+export const submitSignedParticipantBounty = async (
+ bountyId: string,
+ opRowId: string,
+ body: SubmitSignedXdrRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST(
+ '/api/bounties/{id}/escrow/ops/{opRowId}/submit-signed',
+ { params: { path: { id: bountyId, opRowId } }, body }
+ )
+ );
+
+/** Poll the current state of a participant escrow op. */
+export const getParticipantBountyOp = async (
+ bountyId: string,
+ opRowId: string
+): Promise =>
+ unwrapData(
+ await apiClient.GET('/api/bounties/{id}/escrow/ops/{opRowId}', {
+ params: { path: { id: bountyId, opRowId } },
+ })
+ );
diff --git a/features/bounties/api/use-escrow.ts b/features/bounties/api/use-escrow.ts
index ad42f4c37..98067fb79 100644
--- a/features/bounties/api/use-escrow.ts
+++ b/features/bounties/api/use-escrow.ts
@@ -32,6 +32,10 @@ import {
selectBountyWinners,
submitSignedBountyEscrow,
} from './escrow-client';
+import {
+ getParticipantBountyOp,
+ submitSignedParticipantBounty,
+} from './participant-escrow-client';
import {
isTerminalEscrowStatus,
type BountyEscrowOpResponse,
@@ -41,19 +45,19 @@ import {
type SelectBountyWinnersRequest,
} from '../types';
-// ─── Scope: organizer (org + bounty) ─────────────────────────────────────────
+// ─── Scope: organizer (org + bounty) or participant (bounty-only) ─────────────
-export type EscrowOpScope = {
- kind: 'organizer';
- organizationId: string;
- bountyId: string;
-};
+export type EscrowOpScope =
+ | { kind: 'organizer'; organizationId: string; bountyId: string }
+ | { kind: 'participant'; bountyId: string };
function getOpForScope(
scope: EscrowOpScope,
opRowId: string
): Promise {
- return getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId);
+ return scope.kind === 'participant'
+ ? getParticipantBountyOp(scope.bountyId, opRowId)
+ : getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId);
}
function submitSignedForScope(
@@ -61,22 +65,23 @@ function submitSignedForScope(
opRowId: string,
signedXdr: string
): Promise {
- return submitSignedBountyEscrow(
- scope.organizationId,
- scope.bountyId,
- opRowId,
- { signedXdr }
- );
+ return scope.kind === 'participant'
+ ? submitSignedParticipantBounty(scope.bountyId, opRowId, { signedXdr })
+ : submitSignedBountyEscrow(scope.organizationId, scope.bountyId, opRowId, {
+ signedXdr,
+ });
}
function escrowOpKey(scope: EscrowOpScope, opRowId: string): QueryKey {
- return [
- 'bounty-escrow-op',
- 'organizer',
- scope.organizationId,
- scope.bountyId,
- opRowId,
- ];
+ return scope.kind === 'participant'
+ ? ['bounty-escrow-op', 'participant', scope.bountyId, opRowId]
+ : [
+ 'bounty-escrow-op',
+ 'organizer',
+ scope.organizationId,
+ scope.bountyId,
+ opRowId,
+ ];
}
// ─── 1. Polling primitive ────────────────────────────────────────────────────
diff --git a/features/bounties/api/use-participant.ts b/features/bounties/api/use-participant.ts
new file mode 100644
index 000000000..f3ebcad64
--- /dev/null
+++ b/features/bounties/api/use-participant.ts
@@ -0,0 +1,236 @@
+'use client';
+
+/**
+ * React Query hooks for the bounty builder/participant lifecycle (boundless #621).
+ *
+ * reads — useBountiesList / useBounty (public), useMyBountyApplication.
+ * applications — useApplyToBounty / useEditApplication / useWithdrawApplication
+ * / useJoinCompetition / useLeaveCompetition (v2 records).
+ * escrow ops — useSubmitBounty / useWithdrawSubmission / useContributeToBounty
+ * / useApplyToBountyEscrow / useWithdrawApplicationEscrow, each
+ * driven through the shared {@link useEscrowOpRunner} on the
+ * participant scope. Funding defaults to MANAGED (the backend
+ * signs + submits with the caller's platform-held wallet); pass
+ * a `signXdr` to take the EXTERNAL connected-wallet path.
+ *
+ * `useMyBountyActivity` (cross-bounty applications + submissions) waits on the
+ * backend participant dashboard (boundless-nestjs #332); it is intentionally not
+ * implemented here yet.
+ */
+import { useCallback } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { useEscrowOpRunner, type SignXdrFn } from './use-escrow';
+import { bountyKeys } from './keys';
+import {
+ applyToBountyEscrow,
+ contributeToBountyEscrow,
+ submitBountyWorkEscrow,
+ withdrawBountyApplicationEscrow,
+ withdrawBountySubmissionEscrow,
+} from './participant-escrow-client';
+import {
+ applyToBounty,
+ editBountyApplication,
+ getBounty,
+ getMyBountyApplication,
+ joinCompetition,
+ leaveCompetition,
+ listBounties,
+ withdrawBountyApplication,
+ type BountiesListParams,
+} from './participant-client';
+import type {
+ ApplyBountyRequest,
+ ContributeBountyRequest,
+ CreateBountyApplicationRequest,
+ EditBountyApplicationRequest,
+ FundingMode,
+ JoinCompetitionRequest,
+ SubmitBountyRequest,
+ WithdrawApplicationRequest,
+ WithdrawSubmissionRequest,
+} from '../types';
+
+// ── Reads ─────────────────────────────────────────────────────────────────────
+
+/** Paginated public marketplace list. */
+export function useBountiesList(params: BountiesListParams = {}) {
+ return useQuery({
+ queryKey: bountyKeys.list(params as Record),
+ queryFn: () => listBounties(params),
+ });
+}
+
+/** A single public bounty (detail page). */
+export function useBounty(bountyId: string | undefined) {
+ return useQuery({
+ queryKey: bountyKeys.detail(bountyId ?? ''),
+ queryFn: () => getBounty(bountyId as string),
+ enabled: !!bountyId,
+ });
+}
+
+/** The caller's own application for a bounty (drives the detail CTA). */
+export function useMyBountyApplication(bountyId: string | undefined) {
+ return useQuery({
+ queryKey: bountyKeys.myApplication(bountyId ?? ''),
+ queryFn: () => getMyBountyApplication(bountyId as string),
+ enabled: !!bountyId,
+ });
+}
+
+// ── Application records (v2) ──────────────────────────────────────────────────
+
+/** Submit an application (light/full proposal) for an application-mode bounty. */
+export function useApplyToBounty(bountyId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: CreateBountyApplicationRequest) =>
+ applyToBounty(bountyId, body),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }),
+ });
+}
+
+/** Edit a SUBMITTED application (before shortlist). */
+export function useEditApplication(bountyId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (vars: { appId: string; body: EditBountyApplicationRequest }) =>
+ editBountyApplication(bountyId, vars.appId, vars.body),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }),
+ });
+}
+
+/** Withdraw a SUBMITTED application record (status -> WITHDRAWN). */
+export function useWithdrawApplication(bountyId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (appId: string) => withdrawBountyApplication(bountyId, appId),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }),
+ });
+}
+
+/** Join an open competition directly (no application step). */
+export function useJoinCompetition(bountyId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: JoinCompetitionRequest) =>
+ joinCompetition(bountyId, body),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }),
+ });
+}
+
+/** Leave an open competition the caller joined. */
+export function useLeaveCompetition(bountyId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: JoinCompetitionRequest) =>
+ leaveCompetition(bountyId, body),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }),
+ });
+}
+
+// ── Escrow ops (participant scope, default MANAGED) ───────────────────────────
+
+/** A participant op body with funding mode optional (defaults to MANAGED). */
+type WithManagedDefault = Omit<
+ T,
+ 'fundingMode'
+> & { fundingMode?: FundingMode };
+
+const withManaged = (
+ body: WithManagedDefault
+): T => ({ fundingMode: 'MANAGED', ...body }) as T;
+
+interface ParticipantOpOptions {
+ /** Sign the unsigned XDR for the EXTERNAL path. Omit for MANAGED. */
+ signXdr?: SignXdrFn;
+}
+
+/**
+ * Build a participant escrow-op hook: a runner on the participant scope plus a
+ * `run(body)` that starts the op (funding defaults to MANAGED). Returns the full
+ * runner surface (phase/status/error/txHash) for the calling UI.
+ */
+function useParticipantOp(
+ bountyId: string,
+ start: (id: string, body: T) => ReturnType,
+ options: ParticipantOpOptions = {}
+) {
+ const runner = useEscrowOpRunner(
+ { kind: 'participant', bountyId },
+ options.signXdr ? { signXdr: options.signXdr } : undefined
+ );
+ const run = useCallback(
+ (body: WithManagedDefault) =>
+ runner.run(() => start(bountyId, withManaged(body))),
+ [runner, bountyId, start]
+ );
+ return { ...runner, run };
+}
+
+/** Anchor a work submission on-chain (SUBMIT). */
+export function useSubmitBounty(
+ bountyId: string,
+ options?: ParticipantOpOptions
+) {
+ return useParticipantOp(
+ bountyId,
+ submitBountyWorkEscrow,
+ options
+ );
+}
+
+/** Withdraw a submission anchor. */
+export function useWithdrawSubmission(
+ bountyId: string,
+ options?: ParticipantOpOptions
+) {
+ return useParticipantOp(
+ bountyId,
+ withdrawBountySubmissionEscrow,
+ options
+ );
+}
+
+/** Apply to a bounty on-chain (APPLY_TO_BOUNTY; open modes). */
+export function useApplyToBountyEscrow(
+ bountyId: string,
+ options?: ParticipantOpOptions
+) {
+ return useParticipantOp(
+ bountyId,
+ applyToBountyEscrow,
+ options
+ );
+}
+
+/** Withdraw an on-chain application (open modes). */
+export function useWithdrawApplicationEscrow(
+ bountyId: string,
+ options?: ParticipantOpOptions
+) {
+ return useParticipantOp(
+ bountyId,
+ withdrawBountyApplicationEscrow,
+ options
+ );
+}
+
+/** Contribute funds to a bounty's escrow pool (ADD_FUNDS). */
+export function useContributeToBounty(
+ bountyId: string,
+ options?: ParticipantOpOptions
+) {
+ return useParticipantOp(
+ bountyId,
+ contributeToBountyEscrow,
+ options
+ );
+}
diff --git a/features/bounties/index.ts b/features/bounties/index.ts
index ce57379e9..2273615b9 100644
--- a/features/bounties/index.ts
+++ b/features/bounties/index.ts
@@ -86,3 +86,63 @@ export type {
UseEscrowOpRunnerOptions,
EscrowOpRunner,
} from './api/use-escrow';
+
+// ── Builder / participant data layer (#621) ──────────────────────────────────
+
+// Participant types (aliased from the generated schema; MyBountyApplication is
+// hand-typed until boundless-nestjs #331 lands and codegen runs).
+export type {
+ BountyPublic,
+ BountyPublicList,
+ MyBountyApplication,
+ BountyApplicationStatus,
+ ApplyBountyRequest,
+ SubmitBountyRequest,
+ WithdrawApplicationRequest,
+ WithdrawSubmissionRequest,
+ ContributeBountyRequest,
+ CreateBountyApplicationRequest,
+ EditBountyApplicationRequest,
+ JoinCompetitionRequest,
+} from './types';
+
+// Participant REST client (public reads + v2 application records + competition).
+export {
+ listBounties,
+ getBounty,
+ getMyBountyApplication,
+ applyToBounty,
+ editBountyApplication,
+ withdrawBountyApplication,
+ joinCompetition,
+ leaveCompetition,
+} from './api/participant-client';
+export type { BountiesListParams } from './api/participant-client';
+
+// Participant escrow client (on-chain apply/submit/withdraw/contribute).
+export {
+ applyToBountyEscrow,
+ withdrawBountyApplicationEscrow,
+ submitBountyWorkEscrow,
+ withdrawBountySubmissionEscrow,
+ contributeToBountyEscrow,
+ getParticipantBountyOp,
+ submitSignedParticipantBounty,
+} from './api/participant-escrow-client';
+
+// Participant hooks (React Query).
+export {
+ useBountiesList,
+ useBounty,
+ useMyBountyApplication,
+ useApplyToBounty,
+ useEditApplication,
+ useWithdrawApplication,
+ useJoinCompetition,
+ useLeaveCompetition,
+ useSubmitBounty,
+ useWithdrawSubmission,
+ useApplyToBountyEscrow,
+ useWithdrawApplicationEscrow,
+ useContributeToBounty,
+} from './api/use-participant';
diff --git a/features/bounties/types.ts b/features/bounties/types.ts
index 132d0aa20..0727334c0 100644
--- a/features/bounties/types.ts
+++ b/features/bounties/types.ts
@@ -74,3 +74,58 @@ export const TERMINAL_ESCROW_STATUSES: readonly EscrowOpStatus[] = [
export const isTerminalEscrowStatus = (status: EscrowOpStatus): boolean =>
TERMINAL_ESCROW_STATUSES.includes(status);
+
+// ── Builder / participant ─────────────────────────────────────────────────────
+
+/** Public marketplace bounty (list row + detail share this shape). */
+export type BountyPublic = Schemas['BountyPublicDto'];
+/** Paginated marketplace list response. */
+export type BountyPublicList = Schemas['BountyPublicListDto'];
+
+// Participant escrow op request bodies (on-chain apply/submit/withdraw/contribute).
+export type ApplyBountyRequest = Schemas['ApplyBountyDto'];
+export type SubmitBountyRequest = Schemas['SubmitBountyDto'];
+export type WithdrawApplicationRequest = Schemas['WithdrawApplicationDto'];
+export type WithdrawSubmissionRequest = Schemas['WithdrawSubmissionDto'];
+export type ContributeBountyRequest = Schemas['ContributeBountyDto'];
+
+// v2 application records (off-chain proposal carrier for application modes).
+export type CreateBountyApplicationRequest =
+ Schemas['CreateBountyApplicationDto'];
+export type EditBountyApplicationRequest = Schemas['EditBountyApplicationDto'];
+export type JoinCompetitionRequest = Schemas['JoinCompetitionDto'];
+
+/**
+ * The caller's own application for a bounty (GET …/v2/applications/me).
+ *
+ * The backend `BountyApplicationResponseDto` (boundless-nestjs #331) is not yet
+ * in the generated schema, so this is hand-typed to its shape. Replace with
+ * `Schemas['BountyApplicationResponseDto']` after that lands and codegen runs.
+ */
+export type BountyApplicationStatus =
+ | 'SUBMITTED'
+ | 'SHORTLISTED'
+ | 'SELECTED'
+ | 'DECLINED'
+ | 'WITHDRAWN';
+
+export interface MyBountyApplication {
+ id: string;
+ bountyId: string;
+ applicantAddress: string;
+ status: string;
+ applicationStatus: BountyApplicationStatus | null;
+ proposalShort: string | null;
+ proposalFull: string | null;
+ portfolioLinks: string[];
+ estimatedDays: number | null;
+ qualifications: string | null;
+ videoIntroUrl: string | null;
+ declineReason: string | null;
+ shortlistedAt: string | null;
+ selectedAt: string | null;
+ declinedAt: string | null;
+ withdrawnAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/lib/metadata.ts b/lib/metadata.ts
index c38c89ff4..937dfb723 100644
--- a/lib/metadata.ts
+++ b/lib/metadata.ts
@@ -108,6 +108,14 @@ export const pageMetadata: Record = {
ogImage:
'https://res.cloudinary.com/danuy5rqb/image/upload/v1759143589/bondless-og-image_jufgnu.png',
},
+ bounties: {
+ title: 'Bounties - Boundless',
+ description:
+ 'Discover open bounties on Boundless. Pick up work, ship it, and earn on-chain rewards.',
+ keywords: ['bounties', 'rewards', 'open source', 'earn', 'contribute'],
+ ogImage:
+ 'https://res.cloudinary.com/danuy5rqb/image/upload/v1759143589/bondless-og-image_jufgnu.png',
+ },
hackathons: {
title: 'Hackathons - Boundless',
description: