diff --git a/backend/alembic/versions/005_add_bounty_boosts.py b/backend/alembic/versions/005_add_bounty_boosts.py new file mode 100644 index 00000000..da2240e1 --- /dev/null +++ b/backend/alembic/versions/005_add_bounty_boosts.py @@ -0,0 +1,62 @@ +"""Add bounty_boosts table for community reward boosting. + +Revision ID: 005_bounty_boosts +Revises: 004_contributor_webhooks +Create Date: 2026-03-23 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "005_bounty_boosts" +down_revision: Union[str, None] = "004_contributor_webhooks" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "bounty_boosts", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "bounty_id", + sa.String(36), + sa.ForeignKey("bounties.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("booster_wallet", sa.String(64), nullable=False), + sa.Column("amount", sa.Numeric(precision=20, scale=6), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("tx_hash", sa.String(128), unique=True, nullable=True), + sa.Column("refund_tx_hash", sa.String(128), unique=True, nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("NOW()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("NOW()"), + ), + ) + op.create_index("ix_bounty_boosts_bounty_id", "bounty_boosts", ["bounty_id"]) + op.create_index("ix_bounty_boosts_booster_wallet", "bounty_boosts", ["booster_wallet"]) + op.create_index("ix_bounty_boosts_created_at", "bounty_boosts", ["created_at"]) + op.create_index( + "ix_bounty_boosts_bounty_status", + "bounty_boosts", + ["bounty_id", "status"], + ) + + +def downgrade() -> None: + op.drop_index("ix_bounty_boosts_bounty_status", table_name="bounty_boosts") + op.drop_index("ix_bounty_boosts_created_at", table_name="bounty_boosts") + op.drop_index("ix_bounty_boosts_booster_wallet", table_name="bounty_boosts") + op.drop_index("ix_bounty_boosts_bounty_id", table_name="bounty_boosts") + op.drop_table("bounty_boosts") diff --git a/backend/app/api/bounties.py b/backend/app/api/bounties.py index 803fba1e..19a46eff 100644 --- a/backend/app/api/bounties.py +++ b/backend/app/api/bounties.py @@ -12,7 +12,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db +from app.exceptions import BoostBelowMinimumError, BoostInvalidBountyError +from app.models.boost import ( + BoostRequest, + BoostResponse, + BoostListResponse, + BoostSummary, + BoostLeaderboardResponse, +) from app.models.errors import ErrorResponse +from app.services import boost_service from app.services.bounty_lifecycle_service import ( LifecycleError, publish_bounty as _publish_bounty, @@ -855,3 +864,62 @@ async def transition_bounty( except LifecycleError as exc: code = 404 if exc.code == "NOT_FOUND" else 400 raise HTTPException(status_code=code, detail=exc.message) + + +# --------------------------------------------------------------------------- +# Boost endpoints +# --------------------------------------------------------------------------- + + +@router.post( + "/{bounty_id}/boost", + response_model=BoostResponse, + status_code=status.HTTP_201_CREATED, + summary="Boost a bounty reward", + responses={ + 400: {"model": ErrorResponse, "description": "Below minimum or invalid bounty"}, + 404: {"model": ErrorResponse, "description": "Bounty not found"}, + }, +) +async def boost_bounty(bounty_id: str, body: BoostRequest): + """Add $FNDRY to a bounty's prize pool. + + The boosted amount goes into escrow alongside the original reward. + Minimum contribution is 1,000 $FNDRY. The bounty must be OPEN or + IN_PROGRESS. A Telegram notification is sent to the bounty owner. + """ + try: + return await boost_service.create_boost( + bounty_id=bounty_id, + booster_wallet=body.booster_wallet, + amount=body.amount, + tx_hash=body.tx_hash, + ) + except BoostBelowMinimumError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except BoostInvalidBountyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + +@router.get( + "/{bounty_id}/boosts", + response_model=BoostListResponse, + summary="List boosts for a bounty", +) +async def list_bounty_boosts( + bounty_id: str, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), +): + """Return paginated boost history for a bounty (newest first).""" + return await boost_service.get_boosts(bounty_id, skip=skip, limit=limit) + + +@router.get( + "/{bounty_id}/boost-leaderboard", + response_model=BoostLeaderboardResponse, + summary="Top boosters for a bounty", +) +async def bounty_boost_leaderboard(bounty_id: str): + """Return the top 20 boosters ranked by total $FNDRY contributed.""" + return await boost_service.get_boost_leaderboard(bounty_id) diff --git a/backend/app/database.py b/backend/app/database.py index f2dd1672..f4290e0c 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -175,6 +175,7 @@ async def init_db() -> None: from app.models.review import AIReviewScoreDB # noqa: F401 from app.models.lifecycle import BountyLifecycleLogDB # noqa: F401 from app.models.escrow import EscrowTable, EscrowLedgerTable # noqa: F401 + from app.models.boost import BountyBoostTable # noqa: F401 # NOTE: create_all is idempotent (skips existing tables). For # production schema changes use ``alembic upgrade head`` instead. diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py index 21e71723..97778277 100644 --- a/backend/app/exceptions.py +++ b/backend/app/exceptions.py @@ -134,3 +134,24 @@ def __init__(self, message: str, tx_hash: str | None = None) -> None: class EscrowDoubleSpendError(EscrowError): """Raised when a funding transaction could not be confirmed on-chain.""" + + +# --------------------------------------------------------------------------- +# Boost exceptions +# --------------------------------------------------------------------------- + + +class BoostError(Exception): + """Base class for all bounty-boost errors.""" + + +class BoostBelowMinimumError(BoostError): + """Raised when a boost amount is below the 1,000 $FNDRY minimum.""" + + +class BoostInvalidBountyError(BoostError): + """Raised when the target bounty does not exist or is not boostable.""" + + +class BoostNotFoundError(BoostError): + """Raised when a boost ID does not exist.""" diff --git a/backend/app/models/boost.py b/backend/app/models/boost.py new file mode 100644 index 00000000..cca4345d --- /dev/null +++ b/backend/app/models/boost.py @@ -0,0 +1,147 @@ +"""ORM model and Pydantic schemas for bounty reward boosts. + +Community members can boost a bounty's prize pool by contributing +$FNDRY. All boost amounts go into escrow alongside the original reward +and are refunded if the bounty expires without completion. + +Boost lifecycle:: + + PENDING → CONFIRMED (on-chain tx verified) + | + +→ REFUNDED (bounty expired / cancelled) +""" + +from __future__ import annotations + +import re +import uuid +from datetime import datetime, timezone +from enum import Enum +from typing import Optional + +import sqlalchemy as sa +from sqlalchemy import Column, DateTime, Index, String +from pydantic import BaseModel, Field, field_validator + +from app.database import Base + +_BASE58_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$") +MINIMUM_BOOST_AMOUNT = 1_000.0 + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +# --------------------------------------------------------------------------- +# Boost status enum +# --------------------------------------------------------------------------- + + +class BoostStatus(str, Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + REFUNDED = "refunded" + + +# --------------------------------------------------------------------------- +# ORM model +# --------------------------------------------------------------------------- + + +class BountyBoostTable(Base): + """One row per community boost contribution to a bounty's prize pool.""" + + __tablename__ = "bounty_boosts" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + bounty_id = Column( + String(36), + sa.ForeignKey("bounties.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + booster_wallet = Column(String(64), nullable=False, index=True) + amount = Column(sa.Numeric(precision=20, scale=6), nullable=False) + status = Column(String(20), nullable=False, server_default=BoostStatus.PENDING.value) + tx_hash = Column(String(128), unique=True, nullable=True, index=True) + refund_tx_hash = Column(String(128), unique=True, nullable=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=_now, index=True) + updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now) + + __table_args__ = ( + Index("ix_bounty_boosts_bounty_status", "bounty_id", "status"), + ) + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class BoostRequest(BaseModel): + """POST /bounties/{id}/boost request body.""" + + booster_wallet: str = Field(..., min_length=32, max_length=44) + amount: float = Field(..., gt=0) + tx_hash: Optional[str] = Field(None, description="On-chain SPL transfer signature") + + @field_validator("booster_wallet") + @classmethod + def validate_wallet(cls, v: str) -> str: + if not _BASE58_RE.match(v): + raise ValueError("booster_wallet must be a valid Solana base-58 address") + return v + + @field_validator("amount") + @classmethod + def validate_min_amount(cls, v: float) -> float: + if v < MINIMUM_BOOST_AMOUNT: + raise ValueError(f"Minimum boost is {MINIMUM_BOOST_AMOUNT:,.0f} $FNDRY") + return v + + +class BoostResponse(BaseModel): + """Single boost entry.""" + + id: str + bounty_id: str + booster_wallet: str + amount: float + status: BoostStatus + tx_hash: Optional[str] = None + refund_tx_hash: Optional[str] = None + created_at: datetime + + +class BoostListResponse(BaseModel): + """Paginated list of boosts for a bounty.""" + + boosts: list[BoostResponse] + total: int + total_boosted: float + + +class BoostSummary(BaseModel): + """Reward summary shown on bounty detail: original + boosted amounts.""" + + original_amount: float + total_boosted: float + total_amount: float + boost_count: int + + +class BoosterLeaderboardEntry(BaseModel): + """One entry in the per-bounty booster leaderboard.""" + + rank: int + booster_wallet: str + total_boosted: float + boost_count: int + + +class BoostLeaderboardResponse(BaseModel): + """Top boosters for a single bounty.""" + + leaderboard: list[BoosterLeaderboardEntry] + total_boosted: float diff --git a/backend/app/services/boost_service.py b/backend/app/services/boost_service.py new file mode 100644 index 00000000..e3138e3f --- /dev/null +++ b/backend/app/services/boost_service.py @@ -0,0 +1,325 @@ +"""Bounty boost service — community reward pool contributions. + +Community members can add $FNDRY to any open bounty's prize pool. +Each boost is recorded in ``bounty_boosts`` and accumulated in the +escrow PDA alongside the original reward. Boosts are refunded when +a bounty expires or is cancelled without a winner. + +Minimum boost: 1,000 $FNDRY (enforced at model and service level). +""" + +from __future__ import annotations + +import logging +import os +from datetime import datetime, timezone + +import httpx +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.audit import audit_event +from app.database import get_db_session +from app.exceptions import ( + BoostBelowMinimumError, + BoostInvalidBountyError, + BoostNotFoundError, +) +from app.models.boost import ( + MINIMUM_BOOST_AMOUNT, + BountyBoostTable, + BoostLeaderboardResponse, + BoostListResponse, + BoostResponse, + BoostStatus, + BoostSummary, + BoosterLeaderboardEntry, +) +from app.models.bounty_table import BountyTable + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Telegram notification (best-effort, non-blocking) +# --------------------------------------------------------------------------- + +_TELEGRAM_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +_TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") + + +async def _send_telegram(message: str) -> None: + """Fire-and-forget Telegram notification. Logs on failure but never raises.""" + if not _TELEGRAM_TOKEN or not _TELEGRAM_CHAT_ID: + logger.debug("Telegram not configured — skipping notification") + return + url = f"https://api.telegram.org/bot{_TELEGRAM_TOKEN}/sendMessage" + try: + async with httpx.AsyncClient(timeout=5) as client: + await client.post(url, json={"chat_id": _TELEGRAM_CHAT_ID, "text": message, "parse_mode": "HTML"}) + except Exception as exc: + logger.warning("Telegram notification failed: %s", exc) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _row_to_response(row: BountyBoostTable) -> BoostResponse: + return BoostResponse( + id=str(row.id), + bounty_id=str(row.bounty_id), + booster_wallet=row.booster_wallet, + amount=float(row.amount), + status=BoostStatus(row.status), + tx_hash=row.tx_hash, + refund_tx_hash=row.refund_tx_hash, + created_at=row.created_at, + ) + + +async def _get_bounty(db: AsyncSession, bounty_id: str) -> BountyTable | None: + result = await db.execute(select(BountyTable).where(BountyTable.id == bounty_id)) + return result.scalar_one_or_none() + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def create_boost( + bounty_id: str, + booster_wallet: str, + amount: float, + tx_hash: str | None = None, +) -> BoostResponse: + """Record a community boost contribution to a bounty's prize pool. + + Validates that: + - The bounty exists and is in a boostable state (OPEN or IN_PROGRESS). + - The amount meets the 1,000 $FNDRY minimum. + + On success, sends a Telegram notification to the bounty owner and + emits an audit event. + + Args: + bounty_id: UUID of the target bounty. + booster_wallet: Solana base-58 wallet address of the booster. + amount: $FNDRY amount to add to the prize pool. + tx_hash: Optional on-chain SPL transfer signature; if provided the + boost is immediately set to CONFIRMED, otherwise PENDING. + + Returns: + The created :class:`BoostResponse`. + + Raises: + BoostInvalidBountyError: If the bounty does not exist or is not open. + BoostBelowMinimumError: If amount < 1,000 $FNDRY. + """ + if amount < MINIMUM_BOOST_AMOUNT: + raise BoostBelowMinimumError( + f"Boost amount {amount:,.0f} is below the 1,000 $FNDRY minimum" + ) + + async with get_db_session() as db: + bounty = await _get_bounty(db, bounty_id) + if bounty is None: + raise BoostInvalidBountyError(f"Bounty '{bounty_id}' not found") + + boostable_statuses = {"open", "in_progress", "OPEN", "IN_PROGRESS"} + if str(bounty.status).lower() not in {"open", "in_progress"}: + raise BoostInvalidBountyError( + f"Bounty '{bounty_id}' has status '{bounty.status}' and cannot be boosted" + ) + + status = BoostStatus.CONFIRMED if tx_hash else BoostStatus.PENDING + boost = BountyBoostTable( + bounty_id=bounty_id, + booster_wallet=booster_wallet, + amount=amount, + status=status.value, + tx_hash=tx_hash, + ) + db.add(boost) + await db.commit() + await db.refresh(boost) + + response = _row_to_response(boost) + bounty_title = bounty.title + + audit_event( + "bounty_boosted", + bounty_id=bounty_id, + booster_wallet=booster_wallet, + amount=amount, + status=status.value, + tx_hash=tx_hash, + ) + + # Telegram notification — best effort + await _send_telegram( + f"🚀 Bounty Boosted!\n" + f"Bounty: {bounty_title}\n" + f"Booster: {booster_wallet[:8]}…{booster_wallet[-4:]}\n" + f"Amount: +{amount:,.0f} $FNDRY" + ) + + return response + + +async def get_boosts(bounty_id: str, skip: int = 0, limit: int = 50) -> BoostListResponse: + """Return paginated boost history for a bounty (newest first). + + Args: + bounty_id: UUID of the target bounty. + skip: Pagination offset. + limit: Maximum rows to return (capped at 100). + + Returns: + A :class:`BoostListResponse` with confirmed boosts and aggregate total. + """ + limit = min(limit, 100) + async with get_db_session() as db: + # Total confirmed count + sum + agg_result = await db.execute( + select(func.count(BountyBoostTable.id), func.coalesce(func.sum(BountyBoostTable.amount), 0)) + .where( + BountyBoostTable.bounty_id == bounty_id, + BountyBoostTable.status == BoostStatus.CONFIRMED.value, + ) + ) + total, total_boosted = agg_result.one() + + rows_result = await db.execute( + select(BountyBoostTable) + .where(BountyBoostTable.bounty_id == bounty_id) + .order_by(BountyBoostTable.created_at.desc()) + .offset(skip) + .limit(limit) + ) + rows = rows_result.scalars().all() + + return BoostListResponse( + boosts=[_row_to_response(r) for r in rows], + total=total, + total_boosted=float(total_boosted), + ) + + +async def get_boost_summary(bounty_id: str, original_amount: float) -> BoostSummary: + """Return a reward summary showing original + boosted amounts. + + Args: + bounty_id: UUID of the target bounty. + original_amount: The bounty's base reward_amount. + + Returns: + A :class:`BoostSummary` with breakdown and totals. + """ + async with get_db_session() as db: + result = await db.execute( + select(func.count(BountyBoostTable.id), func.coalesce(func.sum(BountyBoostTable.amount), 0)) + .where( + BountyBoostTable.bounty_id == bounty_id, + BountyBoostTable.status == BoostStatus.CONFIRMED.value, + ) + ) + count, total_boosted = result.one() + + total_boosted = float(total_boosted) + return BoostSummary( + original_amount=original_amount, + total_boosted=total_boosted, + total_amount=original_amount + total_boosted, + boost_count=count, + ) + + +async def get_boost_leaderboard(bounty_id: str) -> BoostLeaderboardResponse: + """Return top boosters for a bounty, ranked by total confirmed contributions. + + Args: + bounty_id: UUID of the target bounty. + + Returns: + A :class:`BoostLeaderboardResponse` with ranked entries. + """ + async with get_db_session() as db: + rows_result = await db.execute( + select( + BountyBoostTable.booster_wallet, + func.sum(BountyBoostTable.amount).label("total_boosted"), + func.count(BountyBoostTable.id).label("boost_count"), + ) + .where( + BountyBoostTable.bounty_id == bounty_id, + BountyBoostTable.status == BoostStatus.CONFIRMED.value, + ) + .group_by(BountyBoostTable.booster_wallet) + .order_by(func.sum(BountyBoostTable.amount).desc()) + .limit(20) + ) + rows = rows_result.all() + + # Total boosted (across all confirmed boosts for this bounty) + total_result = await db.execute( + select(func.coalesce(func.sum(BountyBoostTable.amount), 0)) + .where( + BountyBoostTable.bounty_id == bounty_id, + BountyBoostTable.status == BoostStatus.CONFIRMED.value, + ) + ) + total_boosted = float(total_result.scalar_one()) + + leaderboard = [ + BoosterLeaderboardEntry( + rank=i + 1, + booster_wallet=row.booster_wallet, + total_boosted=float(row.total_boosted), + boost_count=row.boost_count, + ) + for i, row in enumerate(rows) + ] + + return BoostLeaderboardResponse(leaderboard=leaderboard, total_boosted=total_boosted) + + +async def refund_bounty_boosts(bounty_id: str) -> int: + """Mark all confirmed boosts for a bounty as REFUNDED. + + Called when a bounty expires or is cancelled without a winner. + In production this would also trigger on-chain SPL refund transfers + back to each booster wallet. + + Args: + bounty_id: UUID of the bounty whose boosts should be refunded. + + Returns: + Number of boosts refunded. + """ + async with get_db_session() as db: + result = await db.execute( + select(BountyBoostTable).where( + BountyBoostTable.bounty_id == bounty_id, + BountyBoostTable.status == BoostStatus.CONFIRMED.value, + ) + ) + boosts = result.scalars().all() + + refunded = 0 + for boost in boosts: + boost.status = BoostStatus.REFUNDED.value + boost.updated_at = datetime.now(timezone.utc) + refunded += 1 + + if refunded > 0: + await db.commit() + + audit_event( + "bounty_boosts_refunded", + bounty_id=bounty_id, + count=refunded, + ) + logger.info("Refunded %d boosts for bounty %s", refunded, bounty_id) + return refunded diff --git a/backend/tests/test_boost.py b/backend/tests/test_boost.py new file mode 100644 index 00000000..fbb2f6f9 --- /dev/null +++ b/backend/tests/test_boost.py @@ -0,0 +1,529 @@ +"""Tests for the bounty boost feature. + +Covers: + - BoostRequest Pydantic validation (min amount, wallet format) + - boost_service.create_boost — happy path, below-minimum, invalid bounty, closed bounty + - boost_service.get_boosts — pagination, total_boosted only counts confirmed + - boost_service.get_boost_leaderboard — ranking by wallet total + - boost_service.get_boost_summary — correct totals + - boost_service.refund_bounty_boosts — marks confirmed boosts as REFUNDED + - API endpoints: POST /bounties/{id}/boost, GET boosts, GET boost-leaderboard +""" + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-ci") + +import asyncio +import uuid +from datetime import datetime, timezone +from typing import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.bounties import router as bounties_router +from app.database import Base +from app.exceptions import BoostBelowMinimumError, BoostInvalidBountyError +from app.models.boost import BoostRequest, BoostStatus, BountyBoostTable, MINIMUM_BOOST_AMOUNT +from app.models.bounty_table import BountyTable +from app.services import boost_service + +# --------------------------------------------------------------------------- +# SQLite in-memory test DB +# --------------------------------------------------------------------------- + +TEST_DB_URL = "sqlite+aiosqlite:///:memory:" + +_engine = create_async_engine( + TEST_DB_URL, + poolclass=StaticPool, + connect_args={"check_same_thread": False}, +) +_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False) + + +def run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +@pytest.fixture(scope="module", autouse=True) +def create_tables(): + """Create all tables once for the module.""" + async def _create(): + async with _engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + run(_create()) + yield + async def _drop(): + async with _engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + run(_drop()) + + +@pytest.fixture(autouse=True) +def clean_tables(): + """Truncate boost + bounty tables between tests.""" + async def _clean(): + async with _session_factory() as db: + await db.execute(BountyBoostTable.__table__.delete()) + await db.execute(BountyTable.__table__.delete()) + await db.commit() + run(_clean()) + + +# --------------------------------------------------------------------------- +# Patch get_db_session to use the test DB +# --------------------------------------------------------------------------- + +from contextlib import asynccontextmanager + +@asynccontextmanager +async def _test_db_session(): + async with _session_factory() as session: + try: + yield session + except Exception: + await session.rollback() + raise + + +@pytest.fixture(autouse=True) +def patch_db(monkeypatch): + monkeypatch.setattr(boost_service, "get_db_session", _test_db_session) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +VALID_WALLET = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" +WALLET_B = "7nkFRQMdByBmgZFdGtJv6F5EZqnc9tJo9XsEoQFaJLqV" +WALLET_C = "9wFFyRfZBsuAha4YcuxcXLKwMxJR43S7fPfQLZacgYmW" + + +def make_bounty_id() -> str: + return str(uuid.uuid4()) + + +def insert_bounty(bounty_id: str, status: str = "open", reward: float = 5000.0) -> None: + """Insert a minimal BountyTable row directly into the test DB.""" + async def _insert(): + async with _session_factory() as db: + row = BountyTable( + id=bounty_id, + title="Test Bounty", + description="desc", + tier=2, + reward_amount=reward, + status=status, + created_by="system", + ) + db.add(row) + await db.commit() + run(_insert()) + + +def insert_boost( + bounty_id: str, + wallet: str = VALID_WALLET, + amount: float = 2000.0, + status: str = BoostStatus.CONFIRMED.value, +) -> str: + """Insert a BountyBoostTable row directly and return its id.""" + bid = str(uuid.uuid4()) + async def _insert(): + async with _session_factory() as db: + row = BountyBoostTable( + id=bid, + bounty_id=bounty_id, + booster_wallet=wallet, + amount=amount, + status=status, + ) + db.add(row) + await db.commit() + run(_insert()) + return bid + + +# --------------------------------------------------------------------------- +# Pydantic schema validation +# --------------------------------------------------------------------------- + +class TestBoostRequestSchema: + def test_valid_request(self): + req = BoostRequest(booster_wallet=VALID_WALLET, amount=5000.0) + assert req.amount == 5000.0 + assert req.tx_hash is None + + def test_below_minimum_raises(self): + with pytest.raises(Exception): + BoostRequest(booster_wallet=VALID_WALLET, amount=999.0) + + def test_exactly_minimum_is_valid(self): + req = BoostRequest(booster_wallet=VALID_WALLET, amount=MINIMUM_BOOST_AMOUNT) + assert req.amount == MINIMUM_BOOST_AMOUNT + + def test_invalid_wallet_raises(self): + with pytest.raises(Exception): + BoostRequest(booster_wallet="not-a-wallet", amount=5000.0) + + def test_with_tx_hash(self): + req = BoostRequest(booster_wallet=VALID_WALLET, amount=1000.0, tx_hash="abc123") + assert req.tx_hash == "abc123" + + +# --------------------------------------------------------------------------- +# boost_service.create_boost +# --------------------------------------------------------------------------- + +class TestCreateBoost: + def test_creates_confirmed_boost_with_tx_hash(self): + bid = make_bounty_id() + insert_bounty(bid) + with patch.object(boost_service, "_send_telegram", new=AsyncMock()): + result = run(boost_service.create_boost(bid, VALID_WALLET, 2000.0, tx_hash="txABC")) + assert result.status == BoostStatus.CONFIRMED + assert result.tx_hash == "txABC" + assert result.bounty_id == bid + assert result.amount == 2000.0 + + def test_creates_pending_boost_without_tx_hash(self): + bid = make_bounty_id() + insert_bounty(bid) + with patch.object(boost_service, "_send_telegram", new=AsyncMock()): + result = run(boost_service.create_boost(bid, VALID_WALLET, 1500.0)) + assert result.status == BoostStatus.PENDING + assert result.tx_hash is None + + def test_raises_below_minimum(self): + bid = make_bounty_id() + insert_bounty(bid) + with pytest.raises(BoostBelowMinimumError): + run(boost_service.create_boost(bid, VALID_WALLET, 500.0)) + + def test_raises_for_nonexistent_bounty(self): + with pytest.raises(BoostInvalidBountyError): + run(boost_service.create_boost("no-such-bounty", VALID_WALLET, 2000.0)) + + def test_raises_for_cancelled_bounty(self): + bid = make_bounty_id() + insert_bounty(bid, status="cancelled") + with pytest.raises(BoostInvalidBountyError): + run(boost_service.create_boost(bid, VALID_WALLET, 2000.0)) + + def test_raises_for_paid_bounty(self): + bid = make_bounty_id() + insert_bounty(bid, status="paid") + with pytest.raises(BoostInvalidBountyError): + run(boost_service.create_boost(bid, VALID_WALLET, 2000.0)) + + def test_in_progress_bounty_is_boostable(self): + bid = make_bounty_id() + insert_bounty(bid, status="in_progress") + with patch.object(boost_service, "_send_telegram", new=AsyncMock()): + result = run(boost_service.create_boost(bid, VALID_WALLET, 1000.0)) + assert result.status == BoostStatus.PENDING + + def test_telegram_notification_sent(self): + bid = make_bounty_id() + insert_bounty(bid) + mock_tg = AsyncMock() + with patch.object(boost_service, "_send_telegram", new=mock_tg): + run(boost_service.create_boost(bid, VALID_WALLET, 1000.0)) + mock_tg.assert_awaited_once() + call_arg: str = mock_tg.call_args[0][0] + assert "Bounty Boosted" in call_arg or "boosted" in call_arg.lower() + + +# --------------------------------------------------------------------------- +# boost_service.get_boosts +# --------------------------------------------------------------------------- + +class TestGetBoosts: + def test_returns_empty_for_unknown_bounty(self): + result = run(boost_service.get_boosts("no-bounty")) + assert result.boosts == [] + assert result.total == 0 + assert result.total_boosted == 0.0 + + def test_returns_all_boosts_for_bounty(self): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 1000.0) + insert_boost(bid, WALLET_B, 3000.0) + result = run(boost_service.get_boosts(bid)) + assert len(result.boosts) == 2 + + def test_total_boosted_only_counts_confirmed(self): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 2000.0, status=BoostStatus.CONFIRMED.value) + insert_boost(bid, WALLET_B, 5000.0, status=BoostStatus.PENDING.value) + insert_boost(bid, WALLET_C, 1000.0, status=BoostStatus.REFUNDED.value) + result = run(boost_service.get_boosts(bid)) + assert result.total_boosted == 2000.0 + assert result.total == 1 # only confirmed counts in total + + def test_pagination_limit(self): + bid = make_bounty_id() + insert_bounty(bid) + for _ in range(5): + insert_boost(bid, VALID_WALLET, 1000.0) + result = run(boost_service.get_boosts(bid, skip=0, limit=2)) + assert len(result.boosts) == 2 + + def test_pagination_skip(self): + bid = make_bounty_id() + insert_bounty(bid) + for _ in range(4): + insert_boost(bid, VALID_WALLET, 1000.0) + result = run(boost_service.get_boosts(bid, skip=3, limit=10)) + assert len(result.boosts) == 1 + + +# --------------------------------------------------------------------------- +# boost_service.get_boost_leaderboard +# --------------------------------------------------------------------------- + +class TestGetBoostLeaderboard: + def test_empty_leaderboard_for_unknown_bounty(self): + result = run(boost_service.get_boost_leaderboard("no-bounty")) + assert result.leaderboard == [] + assert result.total_boosted == 0.0 + + def test_single_booster_ranked_first(self): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 5000.0) + result = run(boost_service.get_boost_leaderboard(bid)) + assert len(result.leaderboard) == 1 + assert result.leaderboard[0].rank == 1 + assert result.leaderboard[0].booster_wallet == VALID_WALLET + assert result.leaderboard[0].total_boosted == 5000.0 + + def test_multiple_boosters_sorted_by_total(self): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 1000.0) + insert_boost(bid, WALLET_B, 8000.0) + insert_boost(bid, WALLET_C, 3000.0) + result = run(boost_service.get_boost_leaderboard(bid)) + wallets = [e.booster_wallet for e in result.leaderboard] + assert wallets[0] == WALLET_B + assert wallets[1] == WALLET_C + assert wallets[2] == VALID_WALLET + + def test_same_wallet_boosts_aggregated(self): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 2000.0) + insert_boost(bid, VALID_WALLET, 3000.0) + result = run(boost_service.get_boost_leaderboard(bid)) + assert len(result.leaderboard) == 1 + assert result.leaderboard[0].total_boosted == 5000.0 + assert result.leaderboard[0].boost_count == 2 + + def test_pending_and_refunded_excluded_from_leaderboard(self): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 9000.0, status=BoostStatus.PENDING.value) + insert_boost(bid, WALLET_B, 5000.0, status=BoostStatus.REFUNDED.value) + insert_boost(bid, WALLET_C, 1000.0, status=BoostStatus.CONFIRMED.value) + result = run(boost_service.get_boost_leaderboard(bid)) + assert len(result.leaderboard) == 1 + assert result.leaderboard[0].booster_wallet == WALLET_C + + +# --------------------------------------------------------------------------- +# boost_service.get_boost_summary +# --------------------------------------------------------------------------- + +class TestGetBoostSummary: + def test_summary_no_boosts(self): + bid = make_bounty_id() + insert_bounty(bid, reward=5000.0) + result = run(boost_service.get_boost_summary(bid, original_amount=5000.0)) + assert result.original_amount == 5000.0 + assert result.total_boosted == 0.0 + assert result.total_amount == 5000.0 + assert result.boost_count == 0 + + def test_summary_with_confirmed_boosts(self): + bid = make_bounty_id() + insert_bounty(bid, reward=5000.0) + insert_boost(bid, VALID_WALLET, 2000.0) + insert_boost(bid, WALLET_B, 3000.0) + result = run(boost_service.get_boost_summary(bid, original_amount=5000.0)) + assert result.total_boosted == 5000.0 + assert result.total_amount == 10_000.0 + assert result.boost_count == 2 + + def test_summary_ignores_pending_and_refunded(self): + bid = make_bounty_id() + insert_bounty(bid, reward=1000.0) + insert_boost(bid, VALID_WALLET, 5000.0, status=BoostStatus.PENDING.value) + result = run(boost_service.get_boost_summary(bid, original_amount=1000.0)) + assert result.total_boosted == 0.0 + assert result.total_amount == 1000.0 + + +# --------------------------------------------------------------------------- +# boost_service.refund_bounty_boosts +# --------------------------------------------------------------------------- + +class TestRefundBountyBoosts: + def test_refunds_all_confirmed_boosts(self): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 2000.0, status=BoostStatus.CONFIRMED.value) + insert_boost(bid, WALLET_B, 3000.0, status=BoostStatus.CONFIRMED.value) + count = run(boost_service.refund_bounty_boosts(bid)) + assert count == 2 + # Verify they are now REFUNDED + summary = run(boost_service.get_boost_summary(bid, original_amount=0.0)) + assert summary.total_boosted == 0.0 + + def test_does_not_refund_pending_boosts(self): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 2000.0, status=BoostStatus.PENDING.value) + count = run(boost_service.refund_bounty_boosts(bid)) + assert count == 0 + + def test_does_not_double_refund_already_refunded(self): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 2000.0, status=BoostStatus.REFUNDED.value) + count = run(boost_service.refund_bounty_boosts(bid)) + assert count == 0 + + def test_returns_zero_for_bounty_with_no_boosts(self): + bid = make_bounty_id() + insert_bounty(bid) + count = run(boost_service.refund_bounty_boosts(bid)) + assert count == 0 + + def test_mixed_statuses_only_refunds_confirmed(self): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 2000.0, status=BoostStatus.CONFIRMED.value) + insert_boost(bid, WALLET_B, 1000.0, status=BoostStatus.PENDING.value) + insert_boost(bid, WALLET_C, 3000.0, status=BoostStatus.REFUNDED.value) + count = run(boost_service.refund_bounty_boosts(bid)) + assert count == 1 + + +# --------------------------------------------------------------------------- +# API endpoints (via FastAPI TestClient + HTTP) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def api_client(): + """Create a TestClient with only the bounties router.""" + from unittest.mock import patch as _patch, AsyncMock as _AsyncMock + + app = FastAPI() + app.include_router(bounties_router, prefix="/api") + + with _patch.object(boost_service, "get_db_session", _test_db_session): + yield TestClient(app, raise_server_exceptions=False) + + +class TestBoostAPI: + def test_post_boost_returns_201(self, api_client): + bid = make_bounty_id() + insert_bounty(bid) + with patch.object(boost_service, "_send_telegram", new=AsyncMock()): + resp = api_client.post( + f"/api/bounties/{bid}/boost", + json={"booster_wallet": VALID_WALLET, "amount": 2000.0}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["bounty_id"] == bid + assert body["amount"] == 2000.0 + assert body["status"] == "pending" + + def test_post_boost_confirmed_with_tx_hash(self, api_client): + bid = make_bounty_id() + insert_bounty(bid) + with patch.object(boost_service, "_send_telegram", new=AsyncMock()): + resp = api_client.post( + f"/api/bounties/{bid}/boost", + json={"booster_wallet": VALID_WALLET, "amount": 1000.0, "tx_hash": "txXYZ"}, + ) + assert resp.status_code == 201 + assert resp.json()["status"] == "confirmed" + + def test_post_boost_below_minimum_returns_422(self, api_client): + bid = make_bounty_id() + insert_bounty(bid) + resp = api_client.post( + f"/api/bounties/{bid}/boost", + json={"booster_wallet": VALID_WALLET, "amount": 500.0}, + ) + assert resp.status_code == 422 # Pydantic validation + + def test_post_boost_invalid_bounty_returns_404(self, api_client): + with patch.object(boost_service, "_send_telegram", new=AsyncMock()): + resp = api_client.post( + "/api/bounties/no-such-bounty/boost", + json={"booster_wallet": VALID_WALLET, "amount": 1000.0}, + ) + assert resp.status_code == 404 + + def test_post_boost_invalid_wallet_returns_422(self, api_client): + bid = make_bounty_id() + insert_bounty(bid) + resp = api_client.post( + f"/api/bounties/{bid}/boost", + json={"booster_wallet": "bad-wallet", "amount": 1000.0}, + ) + assert resp.status_code == 422 + + def test_get_boosts_returns_200(self, api_client): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 1000.0) + resp = api_client.get(f"/api/bounties/{bid}/boosts") + assert resp.status_code == 200 + body = resp.json() + assert "boosts" in body + assert "total" in body + assert "total_boosted" in body + + def test_get_boosts_empty_for_unknown_bounty(self, api_client): + resp = api_client.get("/api/bounties/unknown-id/boosts") + assert resp.status_code == 200 + assert resp.json()["boosts"] == [] + + def test_get_boost_leaderboard_returns_200(self, api_client): + bid = make_bounty_id() + insert_bounty(bid) + insert_boost(bid, VALID_WALLET, 5000.0) + resp = api_client.get(f"/api/bounties/{bid}/boost-leaderboard") + assert resp.status_code == 200 + body = resp.json() + assert "leaderboard" in body + assert "total_boosted" in body + + def test_get_boost_leaderboard_empty(self, api_client): + resp = api_client.get("/api/bounties/no-boosts-here/boost-leaderboard") + assert resp.status_code == 200 + assert resp.json()["leaderboard"] == [] + + def test_get_boosts_pagination(self, api_client): + bid = make_bounty_id() + insert_bounty(bid) + for _ in range(6): + insert_boost(bid, VALID_WALLET, 1000.0) + resp = api_client.get(f"/api/bounties/{bid}/boosts?limit=3") + assert resp.status_code == 200 + assert len(resp.json()["boosts"]) == 3 diff --git a/frontend/src/__tests__/boost.test.tsx b/frontend/src/__tests__/boost.test.tsx new file mode 100644 index 00000000..59dc51ec --- /dev/null +++ b/frontend/src/__tests__/boost.test.tsx @@ -0,0 +1,411 @@ +/** + * Boost feature test suite. + * + * Covers: + * - BoostSummaryCard — prize pool display, loading skeleton, original + boosted split + * - BoostForm — renders only for open/in_progress, input validation, + * submit triggers mutation, error/success states + * - BoostLeaderboard — ranked entries, medals, empty state, loading + * - BoostHistory — boost list, status colours, empty state, loading + * - BoostPanel (integration) — assembles all sub-sections, no form when no wallet + * - useBoost hook — API success, submit flow, below-minimum guard + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, act } from '@testing-library/react'; +import React from 'react'; + +import { BoostPanel } from '../components/bounties/BoostPanel'; +import { useBoost } from '../hooks/useBoost'; +import type { BoostListResponse, BoostLeaderboardResponse } from '../types/boost'; + +// ── Global fetch mock ───────────────────────────────────────────────────────── + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); +beforeEach(() => mockFetch.mockReset()); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function okJson(data: unknown): Response { + return { + ok: true, status: 200, statusText: 'OK', + json: () => Promise.resolve(data), + headers: new Headers(), redirected: false, type: 'basic' as ResponseType, url: '', + clone: function () { return this; }, body: null, bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + text: () => Promise.resolve(JSON.stringify(data)), + bytes: () => Promise.resolve(new Uint8Array()), + } as Response; +} + +function errResponse(status: number): Response { + return { + ok: false, status, statusText: 'Error', + json: () => Promise.resolve({ detail: 'Error' }), + headers: new Headers(), redirected: false, type: 'basic' as ResponseType, url: '', + clone: function () { return this; }, body: null, bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + text: () => Promise.resolve('{}'), + bytes: () => Promise.resolve(new Uint8Array()), + } as Response; +} + +function makeQC() { + return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0 } } }); +} + +function renderWith(element: React.ReactElement) { + return render( + + {element} + , + ); +} + +function wrapHook(fn: () => T) { + const qc = makeQC(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return renderHook(fn, { wrapper }); +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const EMPTY_BOOSTS: BoostListResponse = { boosts: [], total: 0, total_boosted: 0 }; +const EMPTY_LB: BoostLeaderboardResponse = { leaderboard: [], total_boosted: 0 }; + +const WALLET_A = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; +const WALLET_B = '7nkFRQMdByBmgZFdGtJv6F5EZqnc9tJo9XsEoQFaJLqV'; + +const BOOST_LIST: BoostListResponse = { + boosts: [ + { + id: 'b1', bounty_id: 'bounty-1', booster_wallet: WALLET_A, + amount: 5000, status: 'confirmed', tx_hash: null, refund_tx_hash: null, + created_at: '2024-03-01T00:00:00Z', + }, + { + id: 'b2', bounty_id: 'bounty-1', booster_wallet: WALLET_B, + amount: 2000, status: 'pending', tx_hash: null, refund_tx_hash: null, + created_at: '2024-03-02T00:00:00Z', + }, + ], + total: 1, + total_boosted: 5000, +}; + +const LEADERBOARD: BoostLeaderboardResponse = { + leaderboard: [ + { rank: 1, booster_wallet: WALLET_A, total_boosted: 5000, boost_count: 1 }, + { rank: 2, booster_wallet: WALLET_B, total_boosted: 2000, boost_count: 1 }, + ], + total_boosted: 7000, +}; + +// ── BoostPanel — loading state ──────────────────────────────────────────────── + +describe('BoostPanel loading state', () => { + it('shows loading skeletons while fetching', () => { + mockFetch.mockReturnValue(new Promise(() => {})); + renderWith( + , + ); + expect(screen.getByLabelText(/loading reward summary/i)).toBeInTheDocument(); + }); +}); + +// ── BoostPanel — success state ──────────────────────────────────────────────── + +describe('BoostPanel success state', () => { + beforeEach(() => { + // First call = boosts, second = leaderboard + mockFetch + .mockResolvedValueOnce(okJson(BOOST_LIST)) + .mockResolvedValueOnce(okJson(LEADERBOARD)); + }); + + it('renders the panel container', async () => { + renderWith( + , + ); + expect(screen.getByTestId('boost-panel')).toBeInTheDocument(); + }); + + it('shows total prize pool in summary card', async () => { + renderWith( + , + ); + await waitFor(() => + expect(screen.getByTestId('boost-summary')).toBeInTheDocument(), + ); + expect(screen.getByText(/total prize pool/i)).toBeInTheDocument(); + // 5000 original + 5000 boosted = 10000 + expect(screen.getByText('10,000')).toBeInTheDocument(); + }); + + it('shows original and boosted amounts separately', async () => { + renderWith( + , + ); + await waitFor(() => + expect(screen.getByTestId('boost-summary')).toBeInTheDocument(), + ); + expect(screen.getByText(/5,000/)).toBeInTheDocument(); // original + expect(screen.getByText(/\+5,000/)).toBeInTheDocument(); // boosted + }); + + it('renders boost form for open bounty with wallet', async () => { + renderWith( + , + ); + await waitFor(() => + expect(screen.getByTestId('boost-form')).toBeInTheDocument(), + ); + }); + + it('renders leaderboard section', async () => { + renderWith( + , + ); + await waitFor(() => + expect(screen.getByTestId('boost-leaderboard')).toBeInTheDocument(), + ); + }); + + it('renders history section', async () => { + renderWith( + , + ); + await waitFor(() => + expect(screen.getByTestId('boost-history')).toBeInTheDocument(), + ); + }); +}); + +// ── BoostPanel — no wallet ──────────────────────────────────────────────────── + +describe('BoostPanel without wallet address', () => { + beforeEach(() => { + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(EMPTY_LB)); + }); + + it('does not render boost form when no wallet is connected', async () => { + renderWith( + , + ); + await waitFor(() => + expect(screen.queryByTestId('boost-form')).not.toBeInTheDocument(), + ); + }); +}); + +// ── BoostPanel — closed bounty ──────────────────────────────────────────────── + +describe('BoostPanel for closed bounty', () => { + beforeEach(() => { + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(EMPTY_LB)); + }); + + it('does not show boost form when bounty is paid', async () => { + renderWith( + , + ); + await waitFor(() => + expect(screen.queryByTestId('boost-form')).not.toBeInTheDocument(), + ); + }); + + it('does not show boost form when bounty is cancelled', async () => { + renderWith( + , + ); + await waitFor(() => + expect(screen.queryByTestId('boost-form')).not.toBeInTheDocument(), + ); + }); +}); + +// ── Boost form interaction ──────────────────────────────────────────────────── + +describe('Boost form interaction', () => { + it('input accepts amount and submit button triggers', async () => { + const user = userEvent.setup(); + // boosts + leaderboard fetches + POST boost + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(EMPTY_LB)) + .mockResolvedValueOnce(okJson({ id: 'new', bounty_id: 'b1', booster_wallet: WALLET_A, amount: 2000, status: 'pending', tx_hash: null, refund_tx_hash: null, created_at: '2024-03-01T00:00:00Z' })); + + renderWith( + , + ); + await waitFor(() => expect(screen.getByTestId('boost-form')).toBeInTheDocument()); + await user.type(screen.getByTestId('boost-amount-input'), '2000'); + await user.click(screen.getByTestId('boost-submit-btn')); + // Submitting state or success — no crash + expect(screen.getByTestId('boost-submit-btn')).toBeInTheDocument(); + }); + + it('submit button is disabled when amount field is empty', async () => { + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(EMPTY_LB)); + renderWith( + , + ); + await waitFor(() => expect(screen.getByTestId('boost-submit-btn')).toBeDisabled()); + }); +}); + +// ── Boost leaderboard ───────────────────────────────────────────────────────── + +describe('Boost leaderboard entries', () => { + it('shows "no boosts yet" when leaderboard is empty', async () => { + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(EMPTY_LB)); + renderWith( + , + ); + await waitFor(() => expect(screen.getByText(/no boosts yet.*be the first/i)).toBeInTheDocument()); + }); + + it('renders medal for rank 1', async () => { + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(LEADERBOARD)); + renderWith( + , + ); + await waitFor(() => + expect(screen.getByText('🥇')).toBeInTheDocument(), + ); + expect(screen.getByText('🥈')).toBeInTheDocument(); + }); + + it('shows abbreviated wallet addresses', async () => { + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(LEADERBOARD)); + renderWith( + , + ); + await waitFor(() => + expect(screen.getByLabelText(/rank 1/i)).toBeInTheDocument(), + ); + }); +}); + +// ── Boost history ───────────────────────────────────────────────────────────── + +describe('Boost history section', () => { + it('shows "no boosts yet" when history is empty', async () => { + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(EMPTY_LB)); + renderWith( + , + ); + await waitFor(() => + expect(screen.getAllByText(/no boosts yet/i).length).toBeGreaterThanOrEqual(1), + ); + }); + + it('renders boost list items with amount and status', async () => { + mockFetch + .mockResolvedValueOnce(okJson(BOOST_LIST)) + .mockResolvedValueOnce(okJson(LEADERBOARD)); + renderWith( + , + ); + await waitFor(() => + expect(screen.getByRole('list', { name: /boost contributions/i })).toBeInTheDocument(), + ); + expect(screen.getByText('+5,000')).toBeInTheDocument(); + expect(screen.getByText('+2,000')).toBeInTheDocument(); + }); +}); + +// ── useBoost hook ───────────────────────────────────────────────────────────── + +describe('useBoost hook', () => { + it('returns empty data initially before API responds', () => { + mockFetch.mockReturnValue(new Promise(() => {})); + const { result } = wrapHook(() => useBoost('b1', 5000)); + expect(result.current.boosts).toEqual([]); + expect(result.current.leaderboard).toEqual([]); + expect(result.current.loading).toBe(true); + }); + + it('returns boosts and leaderboard after successful fetch', async () => { + mockFetch + .mockResolvedValueOnce(okJson(BOOST_LIST)) + .mockResolvedValueOnce(okJson(LEADERBOARD)); + const { result } = wrapHook(() => useBoost('b1', 5000)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.boosts).toHaveLength(2); + expect(result.current.leaderboard).toHaveLength(2); + }); + + it('computes summary correctly from boost data', async () => { + mockFetch + .mockResolvedValueOnce(okJson(BOOST_LIST)) // total_boosted = 5000 + .mockResolvedValueOnce(okJson(LEADERBOARD)); + const { result } = wrapHook(() => useBoost('b1', 3000)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.summary.original_amount).toBe(3000); + expect(result.current.summary.total_boosted).toBe(5000); + expect(result.current.summary.total_amount).toBe(8000); + }); + + it('submitBoost sets error when amount is below minimum', () => { + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(EMPTY_LB)); + const { result } = wrapHook(() => useBoost('b1', 5000)); + act(() => { + result.current.submitBoost(WALLET_A, 500); + }); + expect(result.current.submitError).toMatch(/minimum/i); + }); + + it('MIN_BOOST is exposed as 1000', () => { + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(EMPTY_LB)); + const { result } = wrapHook(() => useBoost('b1', 5000)); + expect(result.current.MIN_BOOST).toBe(1000); + }); + + it('submitBoost calls API with correct payload', async () => { + mockFetch + .mockResolvedValueOnce(okJson(EMPTY_BOOSTS)) + .mockResolvedValueOnce(okJson(EMPTY_LB)) + .mockResolvedValueOnce(okJson({ id: 'new', bounty_id: 'b1', booster_wallet: WALLET_A, amount: 2000, status: 'pending', tx_hash: null, refund_tx_hash: null, created_at: '2024-03-01T00:00:00Z' })); + + const { result } = wrapHook(() => useBoost('b1', 5000)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { result.current.submitBoost(WALLET_A, 2000); }); + + await waitFor(() => { + // The third call should be the POST + const calls = mockFetch.mock.calls; + const postCall = calls.find(c => String(c[0]).includes('/boost') && c[1]?.method === 'POST'); + expect(postCall).toBeDefined(); + }); + }); +}); diff --git a/frontend/src/components/BountyDetailPage.tsx b/frontend/src/components/BountyDetailPage.tsx index 4baeb6f1..5e101521 100644 --- a/frontend/src/components/BountyDetailPage.tsx +++ b/frontend/src/components/BountyDetailPage.tsx @@ -10,6 +10,7 @@ import SubmissionForm from './bounties/SubmissionForm'; import CreatorApprovalPanel from './bounties/CreatorApprovalPanel'; import LifecycleTimeline from './bounties/LifecycleTimeline'; import { BountyTags } from './bounties/BountyTags'; +import { BoostPanel } from './bounties/BoostPanel'; interface BountyDetail { id: string; @@ -156,10 +157,10 @@ export const BountyDetailPage: React.FC<{ bounty: BountyDetail }> = ({ bounty })
-
+
Reward: - {rewardAmount.toLocaleString()} FNDRY + {rewardAmount.toLocaleString()} $FNDRY
@@ -349,6 +350,14 @@ export const BountyDetailPage: React.FC<{ bounty: BountyDetail }> = ({ bounty })
+ {/* Boost Panel — community reward contributions */} + + {/* Escrow Status */} ; +} + +// ── Boost summary card ──────────────────────────────────────────────────────── + +function BoostSummaryCard({ + originalAmount, + totalBoosted, + totalAmount, + boostCount, + loading, +}: { + originalAmount: number; + totalBoosted: number; + totalAmount: number; + boostCount: number; + loading: boolean; +}) { + if (loading) { + return ( +
+ + +
+ + +
+
+ ); + } + + const boosted = totalBoosted > 0; + + return ( +
+

+ Total Prize Pool +

+

+ {totalAmount.toLocaleString()}{' '} + $FNDRY +

+ +
+ + {originalAmount.toLocaleString()} + {' '}original + + {boosted && ( + + +{totalBoosted.toLocaleString()} + {' '}boosted{boostCount > 1 && ` (${boostCount}×)`} + + )} +
+
+ ); +} + +// ── Boost form ──────────────────────────────────────────────────────────────── + +function BoostForm({ + bountyStatus, + minBoost, + submitting, + submitError, + submitSuccess, + onSubmit, +}: { + bountyStatus: string; + minBoost: number; + submitting: boolean; + submitError: string | null; + submitSuccess: boolean; + onSubmit: (amount: number) => void; +}) { + const [amount, setAmount] = useState(''); + const canBoost = ['open', 'in_progress'].includes(bountyStatus); + + if (!canBoost) return null; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const parsed = parseFloat(amount.replace(/,/g, '')); + if (!isNaN(parsed)) onSubmit(parsed); + } + + return ( +
+

Boost Reward

+

+ Add $FNDRY to the prize pool. Minimum {minBoost.toLocaleString()} $FNDRY. + Refunded if the bounty expires without a winner. +

+ +
+
+ + setAmount(e.target.value)} + placeholder={`Min ${minBoost.toLocaleString()}`} + className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 + text-sm text-gray-100 placeholder-gray-600 focus:outline-none + focus:ring-1 focus:ring-solana-purple/60" + disabled={submitting} + aria-label="Boost amount" + data-testid="boost-amount-input" + /> + +
+ + {submitError && ( +

+ {submitError} +

+ )} + {submitSuccess && ( +

+ ✓ Boost submitted successfully! +

+ )} +
+
+ ); +} + +// ── Booster leaderboard ─────────────────────────────────────────────────────── + +function BoostLeaderboardSection({ + entries, + loading, +}: { + entries: BoosterLeaderboardEntry[]; + loading: boolean; +}) { + return ( +
+

+ Top Boosters +

+ + {loading ? ( +
    + {Array.from({ length: 3 }, (_, i) => ( +
  • + + + +
  • + ))} +
+ ) : entries.length === 0 ? ( +

No boosts yet — be the first!

+ ) : ( +
    + {entries.map(entry => { + const medal = + entry.rank === 1 ? '🥇' : entry.rank === 2 ? '🥈' : entry.rank === 3 ? '🥉' : null; + return ( +
  1. + + {medal ?? `#${entry.rank}`} + + + {entry.booster_wallet.slice(0, 6)}…{entry.booster_wallet.slice(-4)} + + + {entry.total_boosted.toLocaleString()} + +
  2. + ); + })} +
+ )} +
+ ); +} + +// ── Boost history ───────────────────────────────────────────────────────────── + +function BoostHistorySection({ + boosts, + loading, +}: { + boosts: Boost[]; + loading: boolean; +}) { + const STATUS_COLOR: Record = { + confirmed: 'text-solana-green', + pending: 'text-amber-400', + refunded: 'text-gray-500 line-through', + }; + + return ( +
+

+ Boost History +

+ + {loading ? ( +
    + {Array.from({ length: 4 }, (_, i) => ( +
  • + + + +
  • + ))} +
+ ) : boosts.length === 0 ? ( +

No boosts yet

+ ) : ( +
    + {boosts.map(boost => ( +
  • + + {boost.booster_wallet.slice(0, 6)}…{boost.booster_wallet.slice(-4)} + + + +{boost.amount.toLocaleString()} + + + {boost.status} + +
  • + ))} +
+ )} +
+ ); +} + +// ── Main BoostPanel ─────────────────────────────────────────────────────────── + +export interface BoostPanelProps { + bountyId: string; + bountyStatus: string; + originalAmount: number; + /** Wallet address of the current user (for submitting boosts). */ + walletAddress?: string; + className?: string; +} + +export function BoostPanel({ + bountyId, + bountyStatus, + originalAmount, + walletAddress = '', + className = '', +}: BoostPanelProps) { + const { + boosts, + leaderboard, + summary, + loading, + submitting, + submitError, + submitSuccess, + submitBoost, + MIN_BOOST, + } = useBoost(bountyId, originalAmount); + + function handleSubmit(amount: number) { + if (!walletAddress) return; + submitBoost(walletAddress, amount); + } + + return ( +
+ {/* Prize pool summary */} + + + {/* Boost form (only for open/in_progress bounties with connected wallet) */} + {walletAddress && ( + + )} + + {/* Leaderboard */} + + + {/* History */} + +
+ ); +} + +export default BoostPanel; diff --git a/frontend/src/hooks/useBoost.ts b/frontend/src/hooks/useBoost.ts new file mode 100644 index 00000000..5e3af393 --- /dev/null +++ b/frontend/src/hooks/useBoost.ts @@ -0,0 +1,127 @@ +/** + * useBoost — data fetching and mutation for bounty reward boosts. + * + * Provides: + * - boost history list + * - booster leaderboard + * - boost summary (original + total) + * - submit boost mutation + * + * @module hooks/useBoost + */ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '../services/apiClient'; +import type { + Boost, + BoostListResponse, + BoostLeaderboardResponse, + BoostSummary, + BoostRequest, +} from '../types/boost'; + +const MIN_BOOST = 1_000; + +// --------------------------------------------------------------------------- +// Fetchers +// --------------------------------------------------------------------------- + +async function fetchBoosts(bountyId: string): Promise { + return apiClient(`/api/bounties/${bountyId}/boosts`, { + params: { limit: '50' }, + retries: 1, + }); +} + +async function fetchLeaderboard(bountyId: string): Promise { + return apiClient( + `/api/bounties/${bountyId}/boost-leaderboard`, + { retries: 1 }, + ); +} + +async function postBoost(bountyId: string, body: BoostRequest): Promise { + return apiClient(`/api/bounties/${bountyId}/boost`, { + method: 'POST', + body: JSON.stringify(body), + retries: 0, + }); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useBoost(bountyId: string, originalAmount: number) { + const qc = useQueryClient(); + const [submitError, setSubmitError] = useState(null); + const [submitSuccess, setSubmitSuccess] = useState(false); + + // Boost history + const { + data: boostData, + isLoading: boostsLoading, + } = useQuery({ + queryKey: ['boosts', bountyId], + queryFn: () => fetchBoosts(bountyId), + staleTime: 30_000, + enabled: Boolean(bountyId), + }); + + // Leaderboard + const { + data: leaderboardData, + isLoading: leaderboardLoading, + } = useQuery({ + queryKey: ['boost-leaderboard', bountyId], + queryFn: () => fetchLeaderboard(bountyId), + staleTime: 30_000, + enabled: Boolean(bountyId), + }); + + // Derived boost summary + const totalBoosted = boostData?.total_boosted ?? 0; + const summary: BoostSummary = { + original_amount: originalAmount, + total_boosted: totalBoosted, + total_amount: originalAmount + totalBoosted, + boost_count: boostData?.total ?? 0, + }; + + // Submit mutation + const mutation = useMutation({ + mutationFn: (body: BoostRequest) => postBoost(bountyId, body), + onSuccess: () => { + setSubmitError(null); + setSubmitSuccess(true); + qc.invalidateQueries({ queryKey: ['boosts', bountyId] }); + qc.invalidateQueries({ queryKey: ['boost-leaderboard', bountyId] }); + }, + onError: (err: Error) => { + setSubmitError(err.message ?? 'Boost failed'); + setSubmitSuccess(false); + }, + }); + + function submitBoost(wallet: string, amount: number, txHash?: string) { + if (amount < MIN_BOOST) { + setSubmitError(`Minimum boost is ${MIN_BOOST.toLocaleString()} $FNDRY`); + return; + } + setSubmitError(null); + setSubmitSuccess(false); + mutation.mutate({ booster_wallet: wallet, amount, tx_hash: txHash }); + } + + return { + boosts: boostData?.boosts ?? [], + leaderboard: leaderboardData?.leaderboard ?? [], + summary, + loading: boostsLoading || leaderboardLoading, + submitting: mutation.isPending, + submitError, + submitSuccess, + submitBoost, + MIN_BOOST, + }; +} diff --git a/frontend/src/types/boost.ts b/frontend/src/types/boost.ts new file mode 100644 index 00000000..77118485 --- /dev/null +++ b/frontend/src/types/boost.ts @@ -0,0 +1,48 @@ +/** + * Bounty boost domain types. + * @module types/boost + */ + +export type BoostStatus = 'pending' | 'confirmed' | 'refunded'; + +export interface Boost { + id: string; + bounty_id: string; + booster_wallet: string; + amount: number; + status: BoostStatus; + tx_hash: string | null; + refund_tx_hash: string | null; + created_at: string; // ISO datetime +} + +export interface BoostListResponse { + boosts: Boost[]; + total: number; + total_boosted: number; +} + +export interface BoostSummary { + original_amount: number; + total_boosted: number; + total_amount: number; + boost_count: number; +} + +export interface BoosterLeaderboardEntry { + rank: number; + booster_wallet: string; + total_boosted: number; + boost_count: number; +} + +export interface BoostLeaderboardResponse { + leaderboard: BoosterLeaderboardEntry[]; + total_boosted: number; +} + +export interface BoostRequest { + booster_wallet: string; + amount: number; + tx_hash?: string; +}