Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
77a2b2e
docs(spec): Lightning prize splitting via NWC design
comwanga Jun 17, 2026
b79c23a
docs(plan): Lightning prize splitting implementation plan + spec reco…
comwanga Jun 17, 2026
3765e5b
feat(payout): capture optional lightning_address at registration
comwanga Jun 17, 2026
9ad4817
feat(payout): add Payout and PayoutItem models
comwanga Jun 17, 2026
db7e347
feat(payout): migration for lightning_address + payout tables
comwanga Jun 17, 2026
a8b92b4
feat(payout): deterministic even-split with remainder rule
comwanga Jun 17, 2026
b2b4dce
feat(payout): LNURL-pay client (resolve + invoice)
comwanga Jun 17, 2026
7fdc4a8
feat(payout): NWC (NIP-47) pay_invoice client
comwanga Jun 17, 2026
b6bfcf2
feat(payout): payout orchestration + organizer endpoints
comwanga Jun 17, 2026
9727630
feat(payout): redacted payout summary on public results
comwanga Jun 17, 2026
884c473
feat(payout): lightning address field at registration, prefilled from…
comwanga Jun 17, 2026
e994c4a
feat(payout): organizer payout modal with live NWC payment status
comwanga Jun 17, 2026
fe9c540
feat(payout): prize-paid badge on public results
comwanga Jun 17, 2026
f7ca582
docs(plan): refine payout test helpers for team_count>=2 + address ov…
comwanga Jun 17, 2026
bf89c23
fix(payout): durable per-item commits + tolerate string NWC error res…
comwanga Jun 17, 2026
4cfe2e6
Merge branch 'main' into feat/lightning-payout
comwanga Jun 17, 2026
8f11a56
docs: document Lightning payouts, refresh guide screenshots, prune st…
comwanga Jun 17, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
__pycache__/
*.pyc
.venv/
.venvsource/
*.egg-info/
dist/
.pytest_cache/
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ Balanced team formation for hackathons, workshops, and study groups. Organizers
## What it does

- **Nostr login** — NIP-07 extension, generated keypair, or existing `nsec`.
- **Events + registration** — create an event, share a QR/link, attendees register with no login.
- **Events + registration** — create an event, share a QR/link, attendees register with no login. Each attendee may add an optional **Lightning address**, auto-filled from their Nostr profile (`lud16`) when available.
- **Taxonomy** — each attendee picks a Primary Strength (Technical, Design, Planning, Coordination, Communication, Research, Domain Expert, or Other free-text) and Experience level (Beginner/Intermediate/Advanced).
- **Allocation** — one click distributes role diversity and experience evenly across teams. The engine is fully deterministic; Claude (Haiku) only normalizes free-text "Other" strengths when `ANTHROPIC_API_KEY` is set (deterministic fallback otherwise).
- **Results** — public `/results/<id>` link, "find my team" lookup, CSV/PDF export.
- **Lightning prize payouts** — an organizer marks the winning team, enters a prize in sats, and connects a wallet via **Nostr Wallet Connect (NIP-47)**. SquadSync splits the pot evenly and pays each winner's Lightning address (resolved via LNURL-pay). Members without an address are flagged before anything is sent, so a partial team is never charged; the public results page shows a redacted "prize paid" summary. The NWC credential is used per request and never stored.
- **Feedback** — Settings feedback box; stored in the DB and optionally DM'd to the owner over Nostr (NIP-04) when a bot key is configured.

## Tech stack
Expand All @@ -19,6 +20,7 @@ Balanced team formation for hackathons, workshops, and study groups. Organizers
| Backend | FastAPI, SQLAlchemy 2, Alembic, Pydantic v2 |
| Database | PostgreSQL (prod), SQLite (tests) |
| Auth / Nostr | NIP-98 auth, NIP-04 DMs, Schnorr (`coincurve`) |
| Bitcoin / Lightning | LNURL-pay, Nostr Wallet Connect (NIP-47) prize payouts |
| AI | Anthropic Claude Haiku (optional, strength normalization only) |

**Deploy:** single `main` branch → backend + Postgres on Render (`render.yaml`), frontend on Vercel (`frontend/`). Full guide in `DEPLOYMENT.md`.
Expand Down
64 changes: 64 additions & 0 deletions backend/alembic/versions/0007_lightning_payout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""lightning payout

Revision ID: 0007_lightning_payout
Revises: 0006_npub_and_team_notifications
Create Date: 2026-06-17
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

revision: str = "0007_lightning_payout"
down_revision: Union[str, None] = "0006_npub_and_team_notifications"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table("participants") as b:
b.add_column(sa.Column("lightning_address", sa.String(), nullable=True))

op.create_table(
"payouts",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("event_id", sa.Uuid(), nullable=False),
sa.Column("allocation_id", sa.Uuid(), nullable=False),
sa.Column("team_label", sa.String(), nullable=False),
sa.Column("total_sats", sa.Integer(), nullable=False),
sa.Column("status", sa.String(), nullable=False, server_default="pending"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(["event_id"], ["events.id"]),
sa.ForeignKeyConstraint(["allocation_id"], ["allocations.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_payouts_event_id", "payouts", ["event_id"])
op.create_index("ix_payouts_allocation_id", "payouts", ["allocation_id"])

op.create_table(
"payout_items",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("payout_id", sa.Uuid(), nullable=False),
sa.Column("participant_id", sa.Uuid(), nullable=False),
sa.Column("lightning_address", sa.String(), nullable=True),
sa.Column("amount_sats", sa.Integer(), nullable=False),
sa.Column("status", sa.String(), nullable=False, server_default="pending"),
sa.Column("bolt11", sa.String(), nullable=True),
sa.Column("preimage", sa.String(), nullable=True),
sa.Column("error", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(["payout_id"], ["payouts.id"]),
sa.ForeignKeyConstraint(["participant_id"], ["participants.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_payout_items_payout_id", "payout_items", ["payout_id"])


def downgrade() -> None:
op.drop_index("ix_payout_items_payout_id", table_name="payout_items")
op.drop_table("payout_items")
op.drop_index("ix_payouts_allocation_id", table_name="payouts")
op.drop_index("ix_payouts_event_id", table_name="payouts")
op.drop_table("payouts")
with op.batch_alter_table("participants") as b:
b.drop_column("lightning_address")
66 changes: 66 additions & 0 deletions backend/app/api/v1/payouts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.api.deps import get_current_user, get_db
from app.models.allocation import Allocation
from app.models.payout import Payout, PayoutItem
from app.models.team import Team
from app.models.user import User
from app.schemas.payout import PayoutCreate, PayoutRetry, PayoutOut
from app.services.event_service import assert_allocation_organizer
from app.services import payout_service

router = APIRouter()


def _payout_out(db: Session, payout: Payout) -> PayoutOut:
items = db.query(PayoutItem).filter(PayoutItem.payout_id == payout.id).all()
return PayoutOut(
id=payout.id, event_id=payout.event_id, allocation_id=payout.allocation_id,
team_label=payout.team_label, total_sats=payout.total_sats, status=payout.status,
items=items,
)


@router.post("/{allocation_id}/payouts", response_model=PayoutOut,
status_code=status.HTTP_201_CREATED)
def create_payout(
allocation_id: UUID,
req: PayoutCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
allocation: Allocation = assert_allocation_organizer(db, allocation_id, current_user.id)
team = db.query(Team).filter(Team.id == req.team_id, Team.allocation_id == allocation_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found in this allocation")

# Pre-flight: split + verify every member has an address BEFORE spending anything.
try:
splits = payout_service.preflight(db, team.id, req.total_sats, req.addresses)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))

payout = Payout(event_id=allocation.event_id, allocation_id=allocation_id,
team_label=team.name, total_sats=req.total_sats, status="pending")
db.add(payout)
db.flush()
payout = payout_service.execute_payout(db, payout, splits, req.nwc)
return _payout_out(db, payout)


@router.post("/payouts/{payout_id}/retry", response_model=PayoutOut)
def retry_payout(
payout_id: UUID,
req: PayoutRetry,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
payout = db.query(Payout).filter(Payout.id == payout_id).first()
if not payout:
raise HTTPException(status_code=404, detail="Payout not found")
assert_allocation_organizer(db, payout.allocation_id, current_user.id)
payout = payout_service.retry_failed(db, payout, req.nwc)
return _payout_out(db, payout)
12 changes: 10 additions & 2 deletions backend/app/api/v1/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from app.models.allocation import Allocation
from app.models.participant import Participant
from app.models.team import Team, TeamMember
from app.schemas.allocation import FindTeamRequest, PublicAllocationOut, PublicTeam, PublicTeamMember
from app.models.payout import Payout, PayoutItem
from app.schemas.allocation import FindTeamRequest, PublicAllocationOut, PublicPayoutSummary, PublicTeam, PublicTeamMember

router = APIRouter()

Expand Down Expand Up @@ -50,7 +51,14 @@ def public_allocation(allocation_id: UUID, db: Session = Depends(get_db)):
fairness_score=team.fairness_score,
members=[PublicTeamMember.model_validate(m) for m in members],
))
return PublicAllocationOut(id=allocation.id, status=allocation.status, teams=teams)
payouts = []
for p in db.query(Payout).filter(Payout.allocation_id == allocation.id).all():
items = db.query(PayoutItem).filter(PayoutItem.payout_id == p.id).all()
payouts.append(PublicPayoutSummary(
team_label=p.team_label, total_sats=p.total_sats, status=p.status,
paid_count=sum(1 for i in items if i.status == "paid"), member_count=len(items),
))
return PublicAllocationOut(id=allocation.id, status=allocation.status, teams=teams, payouts=payouts)


@router.post("/allocations/{allocation_id}/find-team", response_model=PublicTeam)
Expand Down
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from fastapi.middleware.cors import CORSMiddleware

from app.core.config import settings
from app.api.v1 import auth, events, participants, allocation, teams, export, public, feedback
from app.api.v1 import auth, events, participants, allocation, teams, export, public, feedback, payouts
import app.models # noqa: F401


Expand All @@ -31,6 +31,7 @@ async def lifespan(_: FastAPI):
app.include_router(export.router, prefix="/api/v1/allocations", tags=["export"])
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
app.include_router(feedback.router, prefix="/api/v1/feedback", tags=["feedback"])
app.include_router(payouts.router, prefix="/api/v1/allocations", tags=["payouts"])


@app.get("/health")
Expand Down
3 changes: 2 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
from app.models.used_event import UsedAuthEvent
from app.models.feedback import Feedback
from app.models.team_notification import TeamNotification
from app.models.payout import Payout, PayoutItem

__all__ = [
"User", "Event", "EventCoOrganizer", "Participant",
"AllocationConfig", "Allocation", "Team", "TeamMember",
"UsedAuthEvent", "Feedback", "TeamNotification",
"UsedAuthEvent", "Feedback", "TeamNotification", "Payout", "PayoutItem",
]
1 change: 1 addition & 0 deletions backend/app/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Participant(Base):
normalized_strength = Column(String, nullable=True)
strength_source = Column(String, nullable=False, default="preset")
npub = Column(String, nullable=True)
lightning_address = Column(String, nullable=True)
tech_stack = Column(JSON, nullable=False, default=list)
interests = Column(JSON, nullable=False, default=list)
composite_score = Column(Float, nullable=True)
Expand Down
34 changes: 34 additions & 0 deletions backend/app/models/payout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import uuid
from sqlalchemy import Column, String, Integer, ForeignKey, DateTime, Uuid
from sqlalchemy.sql import func

from app.core.database import Base


class Payout(Base):
__tablename__ = "payouts"

id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id = Column(Uuid(as_uuid=True), ForeignKey("events.id"), nullable=False, index=True)
allocation_id = Column(Uuid(as_uuid=True), ForeignKey("allocations.id"), nullable=False, index=True)
team_label = Column(String, nullable=False)
total_sats = Column(Integer, nullable=False)
# pending | partial | complete | failed
status = Column(String, nullable=False, default="pending")
created_at = Column(DateTime(timezone=True), server_default=func.now())


class PayoutItem(Base):
__tablename__ = "payout_items"

id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
payout_id = Column(Uuid(as_uuid=True), ForeignKey("payouts.id"), nullable=False, index=True)
participant_id = Column(Uuid(as_uuid=True), ForeignKey("participants.id"), nullable=False)
lightning_address = Column(String, nullable=True)
amount_sats = Column(Integer, nullable=False)
# pending | paid | failed
status = Column(String, nullable=False, default="pending")
bolt11 = Column(String, nullable=True)
preimage = Column(String, nullable=True)
error = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
9 changes: 9 additions & 0 deletions backend/app/schemas/allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,19 @@ class PublicTeam(BaseModel):
members: list[PublicTeamMember] = []


class PublicPayoutSummary(BaseModel):
team_label: str
total_sats: int
status: str
paid_count: int
member_count: int


class PublicAllocationOut(BaseModel):
id: UUID
status: str
teams: list[PublicTeam] = []
payouts: list[PublicPayoutSummary] = []


class FindTeamRequest(BaseModel):
Expand Down
14 changes: 14 additions & 0 deletions backend/app/schemas/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,22 @@ class ParticipantRegister(BaseModel):
strength_other: Optional[str] = Field(default=None, max_length=120)
experience_level: ExperienceLevel
npub: Optional[str] = None
lightning_address: Optional[str] = Field(default=None, max_length=255)
tech_stack: list[str] = []
interests: list[str] = []

@field_validator("lightning_address", mode="before")
@classmethod
def _normalize_lightning_address(cls, v):
if v is None:
return None
v = str(v).strip().lower()
if not v:
return None
if v.count("@") != 1 or not all(v.split("@")):
raise ValueError("Lightning address must look like name@domain")
return v

@model_validator(mode="after")
def _require_other_text(self):
if self.primary_strength == "other" and not (self.strength_other and self.strength_other.strip()):
Expand Down Expand Up @@ -50,6 +63,7 @@ class ParticipantOut(BaseModel):
strength_source: str
experience_level: str
npub: Optional[str]
lightning_address: Optional[str]
composite_score: Optional[float]

model_config = {"from_attributes": True}
Expand Down
40 changes: 40 additions & 0 deletions backend/app/schemas/payout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field


class PayoutCreate(BaseModel):
team_id: UUID
total_sats: int = Field(gt=0)
nwc: str = Field(min_length=1)
# Optional per-member address overrides: {str(participant_id): "name@domain"}.
# Lets the organizer fill/correct a missing address in the payout modal.
addresses: Optional[dict[str, str]] = None


class PayoutRetry(BaseModel):
nwc: str = Field(min_length=1)


class PayoutItemOut(BaseModel):
id: UUID
participant_id: UUID
lightning_address: Optional[str]
amount_sats: int
status: str
preimage: Optional[str]
error: Optional[str]

model_config = {"from_attributes": True}


class PayoutOut(BaseModel):
id: UUID
event_id: UUID
allocation_id: UUID
team_label: str
total_sats: int
status: str
items: list[PayoutItemOut]

model_config = {"from_attributes": True}
Loading
Loading