diff --git a/frontend/src/app/(dashboard)/bounties/page.tsx b/frontend/src/app/(dashboard)/bounties/page.tsx
new file mode 100644
index 0000000..317c95b
--- /dev/null
+++ b/frontend/src/app/(dashboard)/bounties/page.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import React from "react";
+import { BountyFilters } from "@/components/BountyFilters";
+import { BountyList } from "@/components/BountyList";
+import { useBountyDiscovery } from "@/hooks/useBountyDiscovery";
+
+export default function BountiesPage() {
+ const { tasks, pagination, isLoading, error, filters, setFilters, resetFilters, setPage } =
+ useBountyDiscovery();
+
+ return (
+
+
Discover Bounties
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/api/tasks/route.ts b/frontend/src/app/api/tasks/route.ts
index f0d5b09..5746c85 100644
--- a/frontend/src/app/api/tasks/route.ts
+++ b/frontend/src/app/api/tasks/route.ts
@@ -1,10 +1,64 @@
-import { createTask } from "@/lib/task-workflow";
+import { createTask, listTasks } from "@/lib/task-workflow";
import { buildNoStoreJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
+import type { TaskDifficulty, TaskSortOrder } from "@/types/task-workflow";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
+const VALID_DIFFICULTIES: TaskDifficulty[] = ["beginner", "intermediate", "advanced"];
+const VALID_SORTS: TaskSortOrder[] = ["newest", "reward_desc", "deadline_asc"];
+
+function parseOptionalNumber(value: string | null): number | undefined {
+ if (!value) return undefined;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+}
+
+export async function GET(request: Request) {
+ const { response: rateLimitResponse, headers: rateLimitHeaders } =
+ checkRateLimit(request);
+ if (rateLimitResponse) {
+ return rateLimitResponse;
+ }
+
+ const { searchParams } = new URL(request.url);
+
+ const difficultyParam = searchParams.get("difficulty");
+ const sortParam = searchParams.get("sort");
+
+ const result = listTasks({
+ search: searchParams.get("search") ?? undefined,
+ minReward: parseOptionalNumber(searchParams.get("minReward")),
+ maxReward: parseOptionalNumber(searchParams.get("maxReward")),
+ difficulty: VALID_DIFFICULTIES.includes(difficultyParam as TaskDifficulty)
+ ? (difficultyParam as TaskDifficulty)
+ : undefined,
+ technology: searchParams.get("technology") ?? undefined,
+ organization: searchParams.get("organization") ?? undefined,
+ sort: VALID_SORTS.includes(sortParam as TaskSortOrder)
+ ? (sortParam as TaskSortOrder)
+ : undefined,
+ page: parseOptionalNumber(searchParams.get("page")),
+ pageSize: parseOptionalNumber(searchParams.get("pageSize")),
+ });
+
+ return buildNoStoreJson(
+ {
+ ok: true,
+ tasks: result.tasks,
+ pagination: {
+ page: result.page,
+ pageSize: result.pageSize,
+ total: result.total,
+ totalPages: result.totalPages,
+ },
+ },
+ 200,
+ rateLimitHeaders,
+ );
+}
+
export async function POST(request: Request) {
const { response: rateLimitResponse, headers: rateLimitHeaders } =
checkRateLimit(request);
@@ -40,6 +94,10 @@ export async function POST(request: Request) {
}
const payload = body as Record;
+ const difficulty = VALID_DIFFICULTIES.includes(payload.difficulty as TaskDifficulty)
+ ? (payload.difficulty as TaskDifficulty)
+ : undefined;
+
const result = createTask({
poster: String(payload.poster ?? ""),
title: String(payload.title ?? ""),
@@ -47,6 +105,11 @@ export async function POST(request: Request) {
reward: Number(payload.reward),
deadline: Number(payload.deadline),
maxSubmissions: Number(payload.maxSubmissions),
+ difficulty,
+ technologies: Array.isArray(payload.technologies)
+ ? payload.technologies.map((tech) => String(tech))
+ : undefined,
+ organization: payload.organization ? String(payload.organization) : undefined,
});
if (!result.ok) {
diff --git a/frontend/src/components/BountyFilters.tsx b/frontend/src/components/BountyFilters.tsx
new file mode 100644
index 0000000..7d3b544
--- /dev/null
+++ b/frontend/src/components/BountyFilters.tsx
@@ -0,0 +1,178 @@
+"use client";
+
+import React from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { BountyFilters as BountyFiltersState } from "@/hooks/useBountyDiscovery";
+import type { TaskDifficulty, TaskSortOrder } from "@/types/task-workflow";
+
+const DIFFICULTY_OPTIONS: Array<{ value: TaskDifficulty; label: string }> = [
+ { value: "beginner", label: "Beginner" },
+ { value: "intermediate", label: "Intermediate" },
+ { value: "advanced", label: "Advanced" },
+];
+
+const SORT_OPTIONS: Array<{ value: TaskSortOrder; label: string }> = [
+ { value: "newest", label: "Newest" },
+ { value: "reward_desc", label: "Highest reward" },
+ { value: "deadline_asc", label: "Deadline (soonest)" },
+];
+
+/** Sentinel used by Select items that map back to "no filter" (empty string). */
+const ANY_VALUE = "any";
+
+interface BountyFiltersProps {
+ filters: BountyFiltersState;
+ onFilterChange: (partial: Partial) => void;
+ onReset: () => void;
+}
+
+export function BountyFilters({ filters, onFilterChange, onReset }: BountyFiltersProps) {
+ return (
+
+
+
Filter Bounties
+
+ {/* Keyword search */}
+
+
+ onFilterChange({ search: e.target.value })}
+ />
+
+
+
+ {/* Min Reward */}
+
+
+
+ onFilterChange({ minReward: e.target.value ? Number(e.target.value) : "" })
+ }
+ />
+
+
+ {/* Max Reward */}
+
+
+
+ onFilterChange({ maxReward: e.target.value ? Number(e.target.value) : "" })
+ }
+ />
+
+
+ {/* Difficulty */}
+
+
+
+
+
+ {/* Sort */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/BountyList.tsx b/frontend/src/components/BountyList.tsx
new file mode 100644
index 0000000..8f5c8af
--- /dev/null
+++ b/frontend/src/components/BountyList.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import React from "react";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import type { BountyPagination } from "@/hooks/useBountyDiscovery";
+import type { TaskRecord } from "@/types/task-workflow";
+
+const DIFFICULTY_LABELS: Record = {
+ beginner: "Beginner",
+ intermediate: "Intermediate",
+ advanced: "Advanced",
+};
+
+function formatReward(stroops: number): string {
+ return `${(stroops / 10_000_000).toLocaleString(undefined, {
+ maximumFractionDigits: 2,
+ })} XLM`;
+}
+
+function formatDeadline(unixSeconds: number): string {
+ return new Date(unixSeconds * 1000).toLocaleDateString();
+}
+
+interface BountyListProps {
+ tasks: TaskRecord[];
+ pagination: BountyPagination;
+ isLoading: boolean;
+ error: string | null;
+ onPageChange: (page: number) => void;
+}
+
+export function BountyList({ tasks, pagination, isLoading, error, onPageChange }: BountyListProps) {
+ if (error) {
+ return (
+
+
Couldn't load bounties
+
{error}
+
+ );
+ }
+
+ if (!isLoading && tasks.length === 0) {
+ return (
+
+
No bounties found
+
Try adjusting your filters to see more results
+
+ );
+ }
+
+ return (
+
+
+ {tasks.map((task) => (
+
+
+
+
+ {task.title}
+
+ {task.organization ? `${task.organization} • ` : ""}
+ Deadline {formatDeadline(task.deadline)}
+
+
+
{formatReward(task.reward)}
+
+
+
+ {task.description}
+
+ {DIFFICULTY_LABELS[task.difficulty]}
+ {task.technologies.map((tech) => (
+
+ {tech}
+
+ ))}
+
+
+
+ ))}
+
+
+ {pagination.totalPages > 1 && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx
index 09eec28..2b057e7 100644
--- a/frontend/src/components/Navbar.tsx
+++ b/frontend/src/components/Navbar.tsx
@@ -9,6 +9,7 @@ import { getPublicKey } from "@/hooks/stellar-wallets-kit";
const NAV_ITEMS = [
{ name: "Overview", href: "/user/overview" },
+ { name: "Bounties", href: "/bounties" },
{ name: "Groups", href: "/groups" },
{ name: "Fundraising", href: "/fundraising" },
{ name: "Transactions", href: "/user/transactions" },
diff --git a/frontend/src/hooks/useBountyDiscovery.ts b/frontend/src/hooks/useBountyDiscovery.ts
new file mode 100644
index 0000000..51d52ec
--- /dev/null
+++ b/frontend/src/hooks/useBountyDiscovery.ts
@@ -0,0 +1,165 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import type {
+ TaskDifficulty,
+ TaskRecord,
+ TaskSortOrder,
+} from "@/types/task-workflow";
+
+export interface BountyFilters {
+ search: string;
+ minReward: number | "";
+ maxReward: number | "";
+ difficulty: TaskDifficulty | "";
+ technology: string;
+ organization: string;
+ sort: TaskSortOrder;
+}
+
+export const DEFAULT_BOUNTY_FILTERS: BountyFilters = {
+ search: "",
+ minReward: "",
+ maxReward: "",
+ difficulty: "",
+ technology: "",
+ organization: "",
+ sort: "newest",
+};
+
+export interface BountyPagination {
+ page: number;
+ pageSize: number;
+ total: number;
+ totalPages: number;
+}
+
+const DEFAULT_PAGINATION: BountyPagination = {
+ page: 1,
+ pageSize: 10,
+ total: 0,
+ totalPages: 1,
+};
+
+export interface UseBountyDiscoveryReturn {
+ tasks: TaskRecord[];
+ pagination: BountyPagination;
+ isLoading: boolean;
+ error: string | null;
+ filters: BountyFilters;
+ /** Merge a partial filter update; resets pagination back to page 1. */
+ setFilters: (partial: Partial) => void;
+ resetFilters: () => void;
+ setPage: (page: number) => void;
+}
+
+const SEARCH_DEBOUNCE_MS = 300;
+
+function buildQueryString(filters: BountyFilters, page: number): string {
+ const params = new URLSearchParams();
+
+ if (filters.search.trim()) params.set("search", filters.search.trim());
+ if (filters.minReward !== "") params.set("minReward", String(filters.minReward));
+ if (filters.maxReward !== "") params.set("maxReward", String(filters.maxReward));
+ if (filters.difficulty) params.set("difficulty", filters.difficulty);
+ if (filters.technology.trim()) params.set("technology", filters.technology.trim());
+ if (filters.organization.trim()) params.set("organization", filters.organization.trim());
+ if (filters.sort) params.set("sort", filters.sort);
+ params.set("page", String(page));
+
+ return params.toString();
+}
+
+/**
+ * Drives the bounty discovery page: combinable filters, keyword search
+ * (debounced so typing doesn't trigger a request per keystroke), sorting,
+ * and pagination — all resolved server-side via GET /api/tasks.
+ */
+export function useBountyDiscovery(): UseBountyDiscoveryReturn {
+ const [filters, setFiltersState] = useState(DEFAULT_BOUNTY_FILTERS);
+ const [debouncedSearch, setDebouncedSearch] = useState(filters.search);
+ const [page, setPageState] = useState(1);
+ const [tasks, setTasks] = useState([]);
+ const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Debounce the free-text search field only; other filters (selects, number
+ // inputs) apply immediately since they don't fire on every keystroke.
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedSearch(filters.search);
+ }, SEARCH_DEBOUNCE_MS);
+ return () => clearTimeout(timer);
+ }, [filters.search]);
+
+ const effectiveFilters = useMemo(
+ () => ({ ...filters, search: debouncedSearch }),
+ [filters, debouncedSearch],
+ );
+
+ const queryString = useMemo(
+ () => buildQueryString(effectiveFilters, page),
+ [effectiveFilters, page],
+ );
+
+ useEffect(() => {
+ const controller = new AbortController();
+
+ async function run() {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/tasks?${queryString}`, {
+ signal: controller.signal,
+ });
+ const body = await response.json();
+
+ if (!response.ok || !body.ok) {
+ throw new Error(body.error ?? "Failed to load bounties.");
+ }
+
+ setTasks(body.tasks);
+ setPagination(body.pagination);
+ } catch (err) {
+ if (controller.signal.aborted) return;
+ setError(err instanceof Error ? err.message : "Failed to load bounties.");
+ } finally {
+ if (!controller.signal.aborted) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ run();
+
+ return () => controller.abort();
+ }, [queryString]);
+
+ const setFilters = (partial: Partial) => {
+ setFiltersState((prev) => ({ ...prev, ...partial }));
+ setPageState(1);
+ };
+
+ const resetFilters = () => {
+ setFiltersState(DEFAULT_BOUNTY_FILTERS);
+ setDebouncedSearch("");
+ setPageState(1);
+ };
+
+ const setPage = (nextPage: number) => {
+ setPageState(Math.max(1, Math.floor(nextPage)));
+ };
+
+ return {
+ tasks,
+ pagination,
+ isLoading,
+ error,
+ filters,
+ setFilters,
+ resetFilters,
+ setPage,
+ };
+}
diff --git a/frontend/src/lib/auth.integration.test.ts b/frontend/src/lib/auth.integration.test.ts
index a42d3b3..7b8451e 100644
--- a/frontend/src/lib/auth.integration.test.ts
+++ b/frontend/src/lib/auth.integration.test.ts
@@ -41,10 +41,12 @@ describe("auth integration flow", () => {
now += 31 * 60 * 1000;
- expect(auth.accessProtectedRoute(session.sessionId).allowed).toBe(false);
- expect(auth.accessProtectedRoute(session.sessionId).reason).toBe(
- "session-expired",
- );
+ // A single call: accessProtectedRoute deletes the session as a side
+ // effect of detecting expiry, so a second call would see it as missing
+ // rather than expired.
+ const expiredAccess = auth.accessProtectedRoute(session.sessionId);
+ expect(expiredAccess.allowed).toBe(false);
+ expect(expiredAccess.reason).toBe("session-expired");
});
it("blocks protected routes without an active session", () => {
diff --git a/frontend/src/lib/form-validation.test.ts b/frontend/src/lib/form-validation.test.ts
index 1829c78..551ba34 100644
--- a/frontend/src/lib/form-validation.test.ts
+++ b/frontend/src/lib/form-validation.test.ts
@@ -276,28 +276,28 @@ describe("validateStellarAddress", () => {
});
it("rejects address with ambiguous characters (0, O, I, L)", () => {
- // Contains '0' which is invalid in base32
- const result = validateStellarAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234560");
+ // 56 chars (correct length) but ends in '0', which is invalid in base32
+ const result = validateStellarAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV0");
expect(result.ok).toBe(false);
expect(result.error).toContain("invalid characters");
});
it("accepts a valid Stellar public key", () => {
- // This is a validly-formed Stellar test address
- const validKey = "GBDIT6QJ3HYH6C7OAVJ4XKZXONJ6PUVL2PVIOQHHGLK6M2S6TQXAAAAAAAA";
+ // This is a validly-formed Stellar test address (56 chars)
+ const validKey = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW";
const result = validateStellarAddress(validKey);
expect(result.ok).toBe(true);
});
it("accepts a standard-format G... address of 56 chars", () => {
// Generate a valid format address with only valid base32 chars
- const validKey = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+ const validKey = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW";
const result = validateStellarAddress(validKey);
expect(result.ok).toBe(true);
});
it("trims surrounding whitespace before validation", () => {
- const result = validateStellarAddress(" GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 ");
+ const result = validateStellarAddress(" GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW ");
expect(result.ok).toBe(true);
});
});
@@ -649,7 +649,7 @@ describe("validateWorkUrl", () => {
});
it("rejects URL shorter than minimum", () => {
- const result = validateWorkUrl("https://a.b");
+ const result = validateWorkUrl("ab");
expect(result.ok).toBe(false);
});
@@ -752,7 +752,7 @@ describe("validateCreateTaskForm", () => {
it("accepts valid token address", () => {
const result = validateCreateTaskForm({
...validForm,
- token: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
+ token: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW",
});
expect(result.ok).toBe(true);
});
@@ -848,8 +848,7 @@ describe("validateWorkSubmissionForm", () => {
it("accepts valid contributor address", () => {
const result = validateWorkSubmissionForm({
...validForm,
- contributorAddress:
- "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
+ contributorAddress: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW",
});
expect(result.ok).toBe(true);
});
diff --git a/frontend/src/lib/security-headers.test.ts b/frontend/src/lib/security-headers.test.ts
index 24189b6..05864e7 100644
--- a/frontend/src/lib/security-headers.test.ts
+++ b/frontend/src/lib/security-headers.test.ts
@@ -19,7 +19,7 @@ import {
getNextSecurityHeadersConfig,
getSecurityHeaderValue,
SECURITY_HEADERS,
-} from "../../../security-headers.mjs";
+} from "../../security-headers.mjs";
// ---------------------------------------------------------------------------
// 1. Next.js config wiring
diff --git a/frontend/src/lib/task-listing.test.ts b/frontend/src/lib/task-listing.test.ts
new file mode 100644
index 0000000..7532725
--- /dev/null
+++ b/frontend/src/lib/task-listing.test.ts
@@ -0,0 +1,254 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import { createTask, listTasks, resetTaskWorkflowStore } from "@/lib/task-workflow";
+
+function futureDeadline(offsetSeconds: number) {
+ return Math.floor(Date.now() / 1000) + offsetSeconds;
+}
+
+function seedTasks() {
+ createTask(
+ {
+ poster: "GPOSTER1",
+ title: "Build a Soroban escrow contract",
+ description: "Implement escrow logic in Rust for Soroban.",
+ reward: 5_000_000,
+ deadline: futureDeadline(86_400),
+ maxSubmissions: 3,
+ difficulty: "advanced",
+ technologies: ["Rust", "Soroban"],
+ organization: "Stellar Development Foundation",
+ },
+ new Date("2026-01-01T00:00:00.000Z"),
+ );
+
+ createTask(
+ {
+ poster: "GPOSTER2",
+ title: "Design a landing page",
+ description: "Create a modern marketing landing page in Figma.",
+ reward: 2_000_000,
+ deadline: futureDeadline(172_800),
+ maxSubmissions: 2,
+ difficulty: "beginner",
+ technologies: ["Figma", "CSS"],
+ organization: "Acme DAO",
+ },
+ new Date("2026-01-02T00:00:00.000Z"),
+ );
+
+ createTask(
+ {
+ poster: "GPOSTER3",
+ title: "Optimize React dashboard performance",
+ description: "Reduce re-renders in the analytics dashboard.",
+ reward: 8_000_000,
+ deadline: futureDeadline(3_600),
+ maxSubmissions: 1,
+ difficulty: "intermediate",
+ technologies: ["React", "TypeScript"],
+ organization: "Acme DAO",
+ },
+ new Date("2026-01-03T00:00:00.000Z"),
+ );
+}
+
+describe("listTasks", () => {
+ afterEach(() => {
+ resetTaskWorkflowStore();
+ });
+
+ it("defaults difficulty, technologies, and organization when omitted", () => {
+ const result = createTask({
+ poster: "GPOSTER",
+ title: "Untitled bounty",
+ description: "No extra metadata supplied.",
+ reward: 1_000_000,
+ deadline: futureDeadline(3_600),
+ maxSubmissions: 1,
+ });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.task.difficulty).toBe("intermediate");
+ expect(result.task.technologies).toEqual([]);
+ expect(result.task.organization).toBe("");
+ });
+
+ it("returns all tasks with no filters, newest first by default", () => {
+ seedTasks();
+
+ const result = listTasks();
+
+ expect(result.total).toBe(3);
+ expect(result.tasks.map((t) => t.title)).toEqual([
+ "Optimize React dashboard performance",
+ "Design a landing page",
+ "Build a Soroban escrow contract",
+ ]);
+ });
+
+ it("filters by reward range", () => {
+ seedTasks();
+
+ const result = listTasks({ minReward: 3_000_000, maxReward: 6_000_000 });
+
+ expect(result.tasks).toHaveLength(1);
+ expect(result.tasks[0].title).toBe("Build a Soroban escrow contract");
+ });
+
+ it("filters by difficulty", () => {
+ seedTasks();
+
+ const result = listTasks({ difficulty: "beginner" });
+
+ expect(result.tasks).toHaveLength(1);
+ expect(result.tasks[0].title).toBe("Design a landing page");
+ });
+
+ it("filters by technology (case-insensitive substring match)", () => {
+ seedTasks();
+
+ const result = listTasks({ technology: "rust" });
+
+ expect(result.tasks).toHaveLength(1);
+ expect(result.tasks[0].title).toBe("Build a Soroban escrow contract");
+ });
+
+ it("filters by organization (case-insensitive substring match)", () => {
+ seedTasks();
+
+ const result = listTasks({ organization: "acme" });
+
+ expect(result.tasks).toHaveLength(2);
+ expect(result.tasks.map((t) => t.title).sort()).toEqual(
+ ["Design a landing page", "Optimize React dashboard performance"].sort(),
+ );
+ });
+
+ it("searches by keyword across title and description", () => {
+ seedTasks();
+
+ const result = listTasks({ search: "dashboard" });
+
+ expect(result.tasks).toHaveLength(1);
+ expect(result.tasks[0].title).toBe("Optimize React dashboard performance");
+ });
+
+ it("combines multiple filters with AND semantics", () => {
+ seedTasks();
+
+ const result = listTasks({ organization: "acme", difficulty: "beginner" });
+
+ expect(result.tasks).toHaveLength(1);
+ expect(result.tasks[0].title).toBe("Design a landing page");
+
+ const empty = listTasks({ organization: "acme", difficulty: "advanced" });
+ expect(empty.tasks).toHaveLength(0);
+ expect(empty.total).toBe(0);
+ });
+
+ it("sorts by highest reward", () => {
+ seedTasks();
+
+ const result = listTasks({ sort: "reward_desc" });
+
+ expect(result.tasks.map((t) => t.reward)).toEqual([8_000_000, 5_000_000, 2_000_000]);
+ });
+
+ it("sorts by soonest deadline", () => {
+ seedTasks();
+
+ const result = listTasks({ sort: "deadline_asc" });
+
+ expect(result.tasks.map((t) => t.title)).toEqual([
+ "Optimize React dashboard performance",
+ "Build a Soroban escrow contract",
+ "Design a landing page",
+ ]);
+ });
+
+ it("paginates results and reports correct metadata", () => {
+ seedTasks();
+
+ const firstPage = listTasks({ pageSize: 2, page: 1 });
+ expect(firstPage.tasks).toHaveLength(2);
+ expect(firstPage.total).toBe(3);
+ expect(firstPage.totalPages).toBe(2);
+ expect(firstPage.page).toBe(1);
+
+ const secondPage = listTasks({ pageSize: 2, page: 2 });
+ expect(secondPage.tasks).toHaveLength(1);
+ expect(secondPage.page).toBe(2);
+
+ const combinedTitles = [...firstPage.tasks, ...secondPage.tasks].map((t) => t.id).sort();
+ expect(combinedTitles).toEqual(["1", "2", "3"]);
+ });
+
+ it("clamps page above the last page down to totalPages", () => {
+ seedTasks();
+
+ const result = listTasks({ pageSize: 2, page: 999 });
+
+ expect(result.page).toBe(2);
+ expect(result.tasks).toHaveLength(1);
+ });
+
+ it("clamps pageSize to the maximum allowed", () => {
+ seedTasks();
+
+ const result = listTasks({ pageSize: 10_000 });
+
+ expect(result.pageSize).toBe(50);
+ });
+
+ it("treats non-positive page/pageSize as defaults instead of throwing", () => {
+ seedTasks();
+
+ const result = listTasks({ page: 0, pageSize: -5 });
+
+ expect(result.page).toBe(1);
+ expect(result.pageSize).toBeGreaterThan(0);
+ });
+
+ it("returns an empty page (not an error) with totalPages 1 when nothing matches", () => {
+ seedTasks();
+
+ const result = listTasks({ search: "nonexistent-keyword-xyz" });
+
+ expect(result.tasks).toEqual([]);
+ expect(result.total).toBe(0);
+ expect(result.totalPages).toBe(1);
+ expect(result.page).toBe(1);
+ });
+
+ it("keeps filtering and pagination combined and efficient for larger datasets", () => {
+ for (let i = 0; i < 120; i += 1) {
+ createTask({
+ poster: `GPOSTER${i}`,
+ title: `Bounty ${i}`,
+ description: "Bulk seeded bounty for pagination testing.",
+ reward: 1_000_000 + i * 1000,
+ deadline: futureDeadline(3_600),
+ maxSubmissions: 1,
+ difficulty: i % 2 === 0 ? "beginner" : "advanced",
+ technologies: ["TypeScript"],
+ organization: "Bulk Org",
+ });
+ }
+
+ const start = Date.now();
+ const result = listTasks({
+ difficulty: "beginner",
+ technology: "typescript",
+ sort: "reward_desc",
+ page: 2,
+ pageSize: 10,
+ });
+ const elapsedMs = Date.now() - start;
+
+ expect(result.total).toBe(60);
+ expect(result.tasks).toHaveLength(10);
+ expect(elapsedMs).toBeLessThan(200);
+ });
+});
diff --git a/frontend/src/lib/task-workflow.ts b/frontend/src/lib/task-workflow.ts
index 4d95115..e1a3337 100644
--- a/frontend/src/lib/task-workflow.ts
+++ b/frontend/src/lib/task-workflow.ts
@@ -2,8 +2,11 @@ import type {
AddCommentInput,
CommentRecord,
CreateTaskInput,
+ ListTasksQuery,
+ ListTasksResult,
SubmissionRecord,
SubmitTaskInput,
+ TaskDifficulty,
TaskRecord,
TaskStatus,
} from "@/types/task-workflow";
@@ -16,6 +19,11 @@ import {
export const MIN_TASK_REWARD = 1_000_000;
export const MAX_TASK_DEADLINE_OFFSET_SECONDS = 365 * 24 * 60 * 60;
+export const DEFAULT_TASK_DIFFICULTY: TaskDifficulty = "intermediate";
+export const DEFAULT_PAGE_SIZE = 10;
+export const MAX_PAGE_SIZE = 50;
+const TASK_DIFFICULTIES: TaskDifficulty[] = ["beginner", "intermediate", "advanced"];
+
type WorkflowSuccess = { ok: true } & T;
type WorkflowFailure = {
@@ -99,6 +107,14 @@ export function createTask(
submissionCount: 0,
status: "open",
createdAt: now.toISOString(),
+ difficulty:
+ input.difficulty && TASK_DIFFICULTIES.includes(input.difficulty)
+ ? input.difficulty
+ : DEFAULT_TASK_DIFFICULTY,
+ technologies: (input.technologies ?? [])
+ .map((tech) => tech.trim())
+ .filter((tech) => tech.length > 0),
+ organization: input.organization?.trim() ?? "",
};
tasks.set(id, task);
@@ -133,6 +149,82 @@ export function getTask(taskId: string): WorkflowResult<{ task: TaskRecord }> {
return { ok: true, task: { ...task } };
}
+/**
+ * Advanced bounty discovery: filters combine with AND semantics, results
+ * are sorted, then paginated. Pure/read-only — safe to call repeatedly as
+ * filter state changes.
+ */
+export function listTasks(query: ListTasksQuery = {}): ListTasksResult {
+ const search = query.search?.trim().toLowerCase();
+ const technology = query.technology?.trim().toLowerCase();
+ const organization = query.organization?.trim().toLowerCase();
+
+ const filtered = Array.from(tasks.values()).filter((task) => {
+ if (typeof query.minReward === "number" && task.reward < query.minReward) {
+ return false;
+ }
+ if (typeof query.maxReward === "number" && task.reward > query.maxReward) {
+ return false;
+ }
+ if (query.difficulty && task.difficulty !== query.difficulty) {
+ return false;
+ }
+ if (
+ technology &&
+ !task.technologies.some((tech) => tech.toLowerCase().includes(technology))
+ ) {
+ return false;
+ }
+ if (organization && !task.organization.toLowerCase().includes(organization)) {
+ return false;
+ }
+ if (search) {
+ const haystack = `${task.title} ${task.description}`.toLowerCase();
+ if (!haystack.includes(search)) {
+ return false;
+ }
+ }
+ return true;
+ });
+
+ const sort = query.sort ?? "newest";
+ filtered.sort((a, b) => {
+ switch (sort) {
+ case "reward_desc":
+ return b.reward - a.reward;
+ case "deadline_asc":
+ return a.deadline - b.deadline;
+ case "newest":
+ default:
+ return b.createdAt.localeCompare(a.createdAt);
+ }
+ });
+
+ const total = filtered.length;
+ const pageSize = Math.min(
+ MAX_PAGE_SIZE,
+ Math.max(
+ 1,
+ Number.isFinite(query.pageSize) && query.pageSize
+ ? Math.floor(query.pageSize)
+ : DEFAULT_PAGE_SIZE,
+ ),
+ );
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ const page = Math.min(
+ totalPages,
+ Math.max(
+ 1,
+ Number.isFinite(query.page) && query.page ? Math.floor(query.page) : 1,
+ ),
+ );
+
+ const start = (page - 1) * pageSize;
+ const pageTasks = filtered.slice(start, start + pageSize).map((task) => ({ ...task }));
+
+ return { tasks: pageTasks, total, page, pageSize, totalPages };
+}
+
export function submitTaskWork(
input: SubmitTaskInput,
files: ValidatedTaskSubmissionFile[],
diff --git a/frontend/src/lib/tasks-list-api.test.ts b/frontend/src/lib/tasks-list-api.test.ts
new file mode 100644
index 0000000..0c97b8c
--- /dev/null
+++ b/frontend/src/lib/tasks-list-api.test.ts
@@ -0,0 +1,124 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import { GET as listTasksRoute, POST as createTaskRoute } from "@/app/api/tasks/route";
+import { resetTaskWorkflowStore } from "@/lib/task-workflow";
+
+function futureDeadline(offsetSeconds = 86_400) {
+ return Math.floor(Date.now() / 1000) + offsetSeconds;
+}
+
+async function seedTask(overrides: Record = {}) {
+ return createTaskRoute(
+ new Request("http://localhost/api/tasks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ poster: "GPOSTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ title: "Build a Soroban escrow contract",
+ description: "Implement escrow logic in Rust for Soroban.",
+ reward: 5_000_000,
+ deadline: futureDeadline(),
+ maxSubmissions: 3,
+ difficulty: "advanced",
+ technologies: ["Rust", "Soroban"],
+ organization: "Stellar Development Foundation",
+ ...overrides,
+ }),
+ }),
+ );
+}
+
+describe("GET /api/tasks", () => {
+ afterEach(() => {
+ resetTaskWorkflowStore();
+ });
+
+ it("creates a task with difficulty/technologies/organization via POST", async () => {
+ const response = await seedTask();
+ const body = await response.json();
+
+ expect(response.status).toBe(201);
+ expect(body.task).toMatchObject({
+ difficulty: "advanced",
+ technologies: ["Rust", "Soroban"],
+ organization: "Stellar Development Foundation",
+ });
+ });
+
+ it("lists created tasks with pagination metadata", async () => {
+ await seedTask({ title: "Task A" });
+ await seedTask({ title: "Task B" });
+
+ const response = await listTasksRoute(new Request("http://localhost/api/tasks"));
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.ok).toBe(true);
+ expect(body.tasks).toHaveLength(2);
+ expect(body.pagination).toMatchObject({ page: 1, total: 2, totalPages: 1 });
+ });
+
+ it("disables caching so listings are always fresh", async () => {
+ const response = await listTasksRoute(new Request("http://localhost/api/tasks"));
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
+ });
+
+ it("combines search, difficulty, and technology query params", async () => {
+ await seedTask({ title: "Escrow contract", difficulty: "advanced" });
+ await seedTask({
+ title: "Landing page",
+ description: "Marketing site",
+ difficulty: "beginner",
+ technologies: ["Figma"],
+ organization: "Acme DAO",
+ });
+
+ const response = await listTasksRoute(
+ new Request(
+ "http://localhost/api/tasks?search=escrow&difficulty=advanced&technology=rust",
+ ),
+ );
+ const body = await response.json();
+
+ expect(body.tasks).toHaveLength(1);
+ expect(body.tasks[0].title).toBe("Escrow contract");
+ });
+
+ it("ignores an invalid difficulty or sort value instead of erroring", async () => {
+ await seedTask();
+
+ const response = await listTasksRoute(
+ new Request("http://localhost/api/tasks?difficulty=nonsense&sort=bogus"),
+ );
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.tasks).toHaveLength(1);
+ });
+
+ it("paginates via page/pageSize query params", async () => {
+ for (let i = 0; i < 5; i += 1) {
+ await seedTask({ title: `Task ${i}` });
+ }
+
+ const response = await listTasksRoute(
+ new Request("http://localhost/api/tasks?pageSize=2&page=3"),
+ );
+ const body = await response.json();
+
+ expect(body.tasks).toHaveLength(1);
+ expect(body.pagination).toMatchObject({ page: 3, pageSize: 2, total: 5, totalPages: 3 });
+ });
+
+ it("sorts by highest reward via the sort query param", async () => {
+ await seedTask({ title: "Low", reward: 1_000_000 });
+ await seedTask({ title: "High", reward: 9_000_000 });
+
+ const response = await listTasksRoute(
+ new Request("http://localhost/api/tasks?sort=reward_desc"),
+ );
+ const body = await response.json();
+
+ expect(body.tasks.map((t: { title: string }) => t.title)).toEqual(["High", "Low"]);
+ });
+});
diff --git a/frontend/src/types/task-workflow.ts b/frontend/src/types/task-workflow.ts
index e7f76c8..3b07e17 100644
--- a/frontend/src/types/task-workflow.ts
+++ b/frontend/src/types/task-workflow.ts
@@ -2,6 +2,8 @@ export type TaskStatus = "open" | "in_progress" | "completed" | "cancelled" | "d
export type SubmissionStatus = "pending" | "approved" | "rejected";
+export type TaskDifficulty = "beginner" | "intermediate" | "advanced";
+
export interface TaskRecord {
id: string;
poster: string;
@@ -13,6 +15,11 @@ export interface TaskRecord {
submissionCount: number;
status: TaskStatus;
createdAt: string;
+ difficulty: TaskDifficulty;
+ /** Technology/skill tags, e.g. ["Rust", "Soroban"]. */
+ technologies: string[];
+ /** Name of the organization or team posting the bounty, if any. */
+ organization: string;
}
export interface SubmissionRecord {
@@ -39,6 +46,9 @@ export interface CreateTaskInput {
reward: number;
deadline: number;
maxSubmissions: number;
+ difficulty?: TaskDifficulty;
+ technologies?: string[];
+ organization?: string;
}
export interface SubmitTaskInput {
@@ -63,3 +73,30 @@ export interface AddCommentInput {
author: string;
message: string;
}
+
+export type TaskSortOrder = "newest" | "reward_desc" | "deadline_asc";
+
+export interface ListTasksQuery {
+ /** Case-insensitive substring match against title and description. */
+ search?: string;
+ minReward?: number;
+ maxReward?: number;
+ difficulty?: TaskDifficulty;
+ /** Matches tasks whose technologies list includes this value (case-insensitive). */
+ technology?: string;
+ /** Case-insensitive substring match against organization. */
+ organization?: string;
+ sort?: TaskSortOrder;
+ /** 1-based page number. Defaults to 1. */
+ page?: number;
+ /** Items per page. Defaults to 10, clamped to [1, 50]. */
+ pageSize?: number;
+}
+
+export interface ListTasksResult {
+ tasks: TaskRecord[];
+ total: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
+}