-
Notifications
You must be signed in to change notification settings - Fork 57
Implement milestone payouts #631
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,88 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Add milestones table for T3 bounties. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Revision ID: 003_milestones | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Revises: 002_disputes | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Create Date: 2026-03-22 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import Sequence, Union | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from alembic import op | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import sqlalchemy as sa | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from sqlalchemy.dialects import postgresql | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # revision identifiers, used by Alembic. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| revision: str = "003_milestones" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| down_revision: Union[str, None] = "002_disputes" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| branch_labels: Union[str, Sequence[str], None] = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| depends_on: Union[str, Sequence[str], None] = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def upgrade() -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Create the bounties_milestones table.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.create_table( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "bounties_milestones", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "id", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| postgresql.UUID(as_uuid=True), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| primary_key=True, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nullable=False, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "bounty_id", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| postgresql.UUID(as_uuid=True), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.ForeignKey("bounties.id", ondelete="CASCADE"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nullable=False, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column("milestone_number", sa.Integer(), nullable=False), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column("description", sa.String(500), nullable=False), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column("percentage", sa.Numeric(precision=5, scale=2), nullable=False), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "status", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.String(20), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nullable=False, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| server_default="pending", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column("payout_tx_hash", sa.String(100), nullable=True), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "created_at", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.DateTime(timezone=True), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nullable=True, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| server_default=sa.func.now(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.Column( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "updated_at", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sa.DateTime(timezone=True), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nullable=True, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| server_default=sa.func.now(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+49
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nullability mismatch: Migration defines Fix nullability to match ORM sa.Column(
"created_at",
sa.DateTime(timezone=True),
- nullable=True,
+ nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
- nullable=True,
+ nullable=False,
server_default=sa.func.now(),
),🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.create_index( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "ix_bounties_milestones_bounty_id", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "bounties_milestones", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ["bounty_id"], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.create_index( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "ix_bounties_milestones_status", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "bounties_milestones", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ["status"], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+63
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing unique composite index on The ORM model defines a unique index Add the missing unique index op.create_index(
"ix_bounties_milestones_status",
"bounties_milestones",
["status"],
)
+ op.create_index(
+ "ix_bmilestone_bounty_num",
+ "bounties_milestones",
+ ["bounty_id", "milestone_number"],
+ unique=True,
+ )And in downgrade: def downgrade() -> None:
"""Drop the bounties_milestones table."""
+ op.drop_index("ix_bmilestone_bounty_num", table_name="bounties_milestones")
op.drop_index("ix_bounties_milestones_status", table_name="bounties_milestones")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Add missing claim fields to bounties table | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.add_column("bounties", sa.Column("claimed_by", sa.String(length=100), nullable=True)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.add_column("bounties", sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.add_column("bounties", sa.Column("claim_deadline", sa.DateTime(timezone=True), nullable=True)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def downgrade() -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Drop the bounties_milestones table.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.drop_index("ix_bounties_milestones_status", table_name="bounties_milestones") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.drop_index("ix_bounties_milestones_bounty_id", table_name="bounties_milestones") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.drop_table("bounties_milestones") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.drop_column("bounties", "claim_deadline") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.drop_column("bounties", "claimed_at") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| op.drop_column("bounties", "claimed_by") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,13 @@ | |
| from app.services import review_service | ||
| from app.services import lifecycle_service | ||
| from app.services.bounty_search_service import BountySearchService | ||
| from app.services.milestone_service import MilestoneService | ||
| from app.models.milestone import ( | ||
| MilestoneCreate, | ||
| MilestoneResponse, | ||
| MilestoneSubmit, | ||
| MilestoneListResponse, | ||
| ) | ||
|
|
||
| async def _verify_bounty_ownership(bounty_id: str, user: UserResponse): | ||
| """Check that the authenticated user owns the bounty before modification. | ||
|
|
@@ -452,7 +459,7 @@ | |
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| from pydantic import BaseModel, Field as PydanticField | ||
|
Check failure on line 462 in backend/app/api/bounties.py
|
||
|
|
||
|
|
||
| class ApprovalRequest(BaseModel): | ||
|
|
@@ -630,13 +637,13 @@ | |
| # Lifecycle engine endpoints | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| from app.services.bounty_lifecycle_service import ( | ||
| LifecycleError, | ||
| publish_bounty as _publish_bounty, | ||
| claim_bounty as _claim_bounty, | ||
| unclaim_bounty as _unclaim_bounty, | ||
| transition_status as _transition_status, | ||
| ) | ||
|
Check failure on line 646 in backend/app/api/bounties.py
|
||
|
|
||
|
|
||
| class ClaimRequest(BaseModel): | ||
|
|
@@ -672,7 +679,7 @@ | |
| await _verify_bounty_ownership(bounty_id, user) | ||
| actor_id = user.wallet_address or str(user.id) | ||
| try: | ||
| return _publish_bounty(bounty_id, actor_id=actor_id) | ||
| return await _publish_bounty(bounty_id, actor_id=actor_id) | ||
| except LifecycleError as exc: | ||
| code = 404 if exc.code == "NOT_FOUND" else 400 | ||
| raise HTTPException(status_code=code, detail=exc.message) | ||
|
|
@@ -701,7 +708,7 @@ | |
| claimer_id = user.wallet_address or str(user.id) | ||
| duration = body.claim_duration_hours if body else 168 | ||
| try: | ||
| return _claim_bounty(bounty_id, claimer_id, claim_duration_hours=duration) | ||
| return await _claim_bounty(bounty_id, claimer_id, claim_duration_hours=duration) | ||
| except LifecycleError as exc: | ||
| code = 404 if exc.code == "NOT_FOUND" else 400 | ||
| raise HTTPException(status_code=code, detail=exc.message) | ||
|
|
@@ -724,7 +731,7 @@ | |
| ) -> BountyResponse: | ||
| actor_id = user.wallet_address or str(user.id) | ||
| try: | ||
| return _unclaim_bounty(bounty_id, actor_id=actor_id, reason="manual") | ||
| return await _unclaim_bounty(bounty_id, actor_id=actor_id, reason="manual") | ||
| except LifecycleError as exc: | ||
| code = 404 if exc.code == "NOT_FOUND" else 400 | ||
| raise HTTPException(status_code=code, detail=exc.message) | ||
|
|
@@ -760,3 +767,96 @@ | |
| code = 404 if exc.code == "NOT_FOUND" else 400 | ||
| raise HTTPException(status_code=code, detail=exc.message) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Milestone endpoints | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @router.get( | ||
| "/{bounty_id}/milestones", | ||
| response_model=MilestoneListResponse, | ||
| summary="List milestones for a bounty", | ||
| ) | ||
| async def list_milestones( | ||
| bounty_id: str, | ||
| db: AsyncSession = Depends(get_db), | ||
| ) -> MilestoneListResponse: | ||
| """Return all milestones for a specific bounty.""" | ||
| svc = MilestoneService(db) | ||
| milestones = await svc.get_milestones(bounty_id) | ||
| total_perc = sum(float(m.percentage) for m in milestones) | ||
| return MilestoneListResponse( | ||
| bounty_id=bounty_id, | ||
| milestones=milestones, | ||
| total_percentage=total_perc, | ||
| ) | ||
|
|
||
|
|
||
| @router.post( | ||
| "/{bounty_id}/milestones", | ||
| response_model=List[MilestoneResponse], | ||
|
Check failure on line 798 in backend/app/api/bounties.py
|
||
| status_code=status.HTTP_201_CREATED, | ||
| summary="Define milestones for a bounty", | ||
| ) | ||
| async def create_milestones( | ||
| bounty_id: str, | ||
| data: List[MilestoneCreate], | ||
|
Check failure on line 804 in backend/app/api/bounties.py
|
||
| db: AsyncSession = Depends(get_db), | ||
| user: UserResponse = Depends(get_current_user), | ||
| ) -> List[MilestoneResponse]: | ||
|
Check failure on line 807 in backend/app/api/bounties.py
|
||
| """Register milestone checkpoints for a T3 bounty. Total must be 100%.""" | ||
| svc = MilestoneService(db) | ||
| try: | ||
| user_id = user.wallet_address or str(user.id) | ||
| return await svc.create_milestones(bounty_id, data, user_id) | ||
| except (ValueError, UnauthorizedDisputeAccessError) as e: | ||
|
Check failure on line 813 in backend/app/api/bounties.py
|
||
| raise HTTPException(status_code=400, detail=str(e)) | ||
| except BountyNotFoundError as e: | ||
|
Check failure on line 815 in backend/app/api/bounties.py
|
||
| raise HTTPException(status_code=404, detail=str(e)) | ||
|
Comment on lines
+798
to
+816
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing import for
🐛 Proposed fix at line 8-from typing import Optional
+from typing import List, Optional🤖 Prompt for AI Agents
Comment on lines
+813
to
+816
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrong exception type caught - The milestone endpoints catch 🐛 Proposed fix - update exception handling+from app.exceptions import (
+ BountyNotFoundError,
+ UnauthorizedMilestoneAccessError,
+ MilestoneValidationError,
+ MilestoneSequenceError,
+ MilestoneNotFoundError,
+)
# In each endpoint:
- except (ValueError, UnauthorizedDisputeAccessError) as e:
+ except (ValueError, UnauthorizedMilestoneAccessError, MilestoneValidationError, MilestoneSequenceError) as e:
raise HTTPException(status_code=400, detail=str(e))
+ except MilestoneNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
except BountyNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))Also applies to: 836-839, 858-861 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| @router.post( | ||
| "/{bounty_id}/milestones/{milestone_id}/submit", | ||
| response_model=MilestoneResponse, | ||
| summary="Submit a milestone for review", | ||
| ) | ||
| async def submit_milestone( | ||
| bounty_id: str, | ||
| milestone_id: str, | ||
| data: MilestoneSubmit, | ||
| db: AsyncSession = Depends(get_db), | ||
| user: UserResponse = Depends(get_current_user), | ||
| ) -> MilestoneResponse: | ||
| """Contributor submits a completed milestone for verification.""" | ||
| svc = MilestoneService(db) | ||
| try: | ||
| user_id = user.wallet_address or str(user.id) | ||
| return await svc.submit_milestone(bounty_id, milestone_id, data, user_id) | ||
| except (ValueError, UnauthorizedDisputeAccessError) as e: | ||
|
Check failure on line 836 in backend/app/api/bounties.py
|
||
| raise HTTPException(status_code=400, detail=str(e)) | ||
| except BountyNotFoundError as e: | ||
|
Check failure on line 838 in backend/app/api/bounties.py
|
||
| raise HTTPException(status_code=404, detail=str(e)) | ||
|
|
||
|
|
||
| @router.post( | ||
| "/{bounty_id}/milestones/{milestone_id}/approve", | ||
| response_model=MilestoneResponse, | ||
| summary="Approve a milestone and release payout", | ||
| ) | ||
| async def approve_milestone( | ||
| bounty_id: str, | ||
| milestone_id: str, | ||
| db: AsyncSession = Depends(get_db), | ||
| user: UserResponse = Depends(get_current_user), | ||
| ) -> MilestoneResponse: | ||
| """Owner approves a milestone, triggering proportional token transfer.""" | ||
| svc = MilestoneService(db) | ||
| try: | ||
| user_id = user.wallet_address or str(user.id) | ||
| return await svc.approve_milestone(bounty_id, milestone_id, user_id) | ||
| except (ValueError, UnauthorizedDisputeAccessError) as e: | ||
|
Check failure on line 858 in backend/app/api/bounties.py
|
||
| raise HTTPException(status_code=400, detail=str(e)) | ||
| except BountyNotFoundError as e: | ||
| raise HTTPException(status_code=404, detail=str(e)) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| """Milestone models for T3 bounties.""" | ||
|
|
||
| import uuid | ||
| from datetime import datetime, timezone | ||
| from enum import Enum | ||
| from typing import Optional, List | ||
|
|
||
| from pydantic import BaseModel, Field, field_validator | ||
|
|
||
|
|
||
| class MilestoneStatus(str, Enum): | ||
| """Lifecycle status of a milestone.""" | ||
|
|
||
| PENDING = "pending" | ||
| SUBMITTED = "submitted" | ||
| APPROVED = "approved" | ||
|
Comment on lines
+11
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing REJECTED status for milestone lifecycle. The Consider adding a 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| class MilestoneBase(BaseModel): | ||
| """Base fields for all milestone models.""" | ||
|
|
||
| milestone_number: int = Field(..., ge=1) | ||
| description: str = Field(..., min_length=1, max_length=1000) | ||
| percentage: float = Field(..., gt=0, le=100) | ||
|
Comment on lines
+22
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial
The Either make 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| class MilestoneCreate(MilestoneBase): | ||
| """Payload for creating a milestone.""" | ||
| pass | ||
|
|
||
|
|
||
| class MilestoneSubmit(BaseModel): | ||
| """Payload for submitting a milestone.""" | ||
|
|
||
| notes: Optional[str] = Field(None, max_length=1000) | ||
|
|
||
|
|
||
| class MilestoneResponse(MilestoneBase): | ||
| """API response for a milestone.""" | ||
|
|
||
| id: uuid.UUID | ||
| bounty_id: uuid.UUID | ||
| status: MilestoneStatus | ||
| submitted_at: Optional[datetime] = None | ||
| approved_at: Optional[datetime] = None | ||
| payout_tx_hash: Optional[str] = None | ||
|
|
||
| model_config = {"from_attributes": True} | ||
|
|
||
|
|
||
| class MilestoneListResponse(BaseModel): | ||
| """List of milestones for a bounty.""" | ||
|
|
||
| bounty_id: uuid.UUID | ||
| milestones: List[MilestoneResponse] | ||
| total_percentage: float | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Minor inconsistency:
payout_tx_hashlengthMigration uses
String(100)but ORM and other tables (PayoutTable,BuybackTable) useString(128)for transaction hashes. While unlikely to cause issues (Solana tx hashes are 88 chars base58), consistency is preferred.🤖 Prompt for AI Agents