Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions backend/alembic/versions/005_add_bounty_boosts.py
Original file line number Diff line number Diff line change
@@ -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")
68 changes: 68 additions & 0 deletions backend/app/api/bounties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Check failure on line 20 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F401)

app/api/bounties.py:20:5: F401 `app.models.boost.BoostSummary` imported but unused help: Remove unused import: `app.models.boost.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,
Expand Down Expand Up @@ -855,3 +864,62 @@
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)
1 change: 1 addition & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions backend/app/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
147 changes: 147 additions & 0 deletions backend/app/models/boost.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading