From 77a2b2e7b0de55ed2322dd34387b242d3e0ea744 Mon Sep 17 00:00:00 2001
From: comwanga
Date: Wed, 17 Jun 2026 23:03:36 +0300
Subject: [PATCH 01/16] docs(spec): Lightning prize splitting via NWC design
---
...-06-17-lightning-prize-splitting-design.md | 132 ++++++++++++++++++
1 file changed, 132 insertions(+)
create mode 100644 docs/superpowers/specs/2026-06-17-lightning-prize-splitting-design.md
diff --git a/docs/superpowers/specs/2026-06-17-lightning-prize-splitting-design.md b/docs/superpowers/specs/2026-06-17-lightning-prize-splitting-design.md
new file mode 100644
index 0000000..01bf6cc
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-17-lightning-prize-splitting-design.md
@@ -0,0 +1,132 @@
+# SquadSync ⚡ Lightning Prize Splitting (NWC) Design
+
+**Date:** 2026-06-17
+**Status:** Approved
+**Branch:** `feat/lightning-payout` (off `main`)
+**Builds on:** `nostr_service` (NIP-04 `encrypt_nip04`/`decrypt_nip04`, `bech32_decode`, Schnorr
+signing, `websockets.sync.client` relay helper) — already shipped for feedback/team-notify.
+**Context:** bitcoin++ Open Source Edition hackathon (Nairobi). Adds real Bitcoin (Lightning)
+to an otherwise Nostr-only app. Targets the main competition and the Nostr/Soapbox angle.
+
+## Overview
+
+After teams are formed, an organizer marks one team as the **winner**, enters a prize amount in
+**sats**, connects a Lightning wallet via a **Nostr Wallet Connect (NWC / NIP-47)** string, and
+SquadSync splits the pot evenly and pays each winner's Lightning address **live**. The
+deterministic allocation engine is untouched — this is a bolt-on at the results stage.
+
+## Goals
+
+- One-click even split of a sats prize across a winning team's members.
+- Pay each member's Lightning address programmatically over NWC (NIP-47 `pay_invoice`).
+- Resolve each member's Lightning address from their Nostr `kind:0` profile (`lud16`), with an
+ editable fallback field captured at registration.
+- Show live per-member payment status (paid + preimage / failed + reason); retry failed items.
+- Persist a payout receipt for verifiability; surface it on the public results page.
+
+## Non-Goals
+
+- Multi-currency, fee configuration, scheduled/recurring payouts.
+- Per-member custom amounts (even split only; deterministic remainder).
+- Multiple simultaneous winning teams (one team per payout).
+- On-chain payments, swaps, or fiat off-ramp.
+- Persisting the NWC credential (it is a spend credential — used transiently, never stored).
+
+## Decisions (locked)
+
+- **Payment rail:** NWC (NIP-47). Organizer pastes a `nostr+walletconnect://…` string per payout
+ request. Backend performs `pay_invoice`, reusing existing NIP-04 + relay plumbing.
+- **Recipient address:** auto-resolve `lud16` from the member's Nostr `kind:0` profile; if absent,
+ use the optional `lightning_address` captured at registration (prefilled in the form from the
+ logged-in user's own profile, editable). Stored as a plain string; validated as `name@domain`.
+- **Split:** integer even split of `total_sats` over N members. Remainder sats assigned
+ deterministically to the first members (ordered by participant id) so the full pot is paid and
+ the result is reproducible. Each per-member amount must satisfy the recipient LNURL's
+ `minSendable`/`maxSendable` (msat) or that item fails pre-flight.
+- **Winner selection:** organizer marks a team within a published allocation as winner; stored as
+ a `team_label` snapshot on the `Payout` (not a hard FK to a regenerable team row).
+- **Security:** payout endpoints are organizer-authenticated (NIP-98, existing dependency). The
+ NWC string is accepted in the request body, used in-memory, and never logged or persisted.
+
+## Data model
+
+- `participants.lightning_address` — new nullable `String` column (Alembic migration).
+- `Payout` (`app/models/payout.py`): `id`, `event_id` (FK), `allocation_id` (FK), `team_label`,
+ `total_sats`, `status` (`pending`/`partial`/`complete`/`failed`), `created_at`.
+- `PayoutItem`: `id`, `payout_id` (FK), `participant_id` (FK), `lightning_address`, `amount_sats`,
+ `status` (`pending`/`paid`/`failed`), `bolt11` (nullable), `preimage` (nullable),
+ `error` (nullable), `created_at`.
+
+Persisting the payout gives a verifiable receipt (the "does it work" judging axis) and a public
+proof rendered on `/results/{allocation_id}`.
+
+## Backend components
+
+- **`app/services/lnurl_service.py`** — `resolve_lnurl(address) -> LnurlParams` (build
+ `https://{domain}/.well-known/lnurlp/{name}`, GET, parse `callback`/`minSendable`/`maxSendable`);
+ `request_invoice(params, amount_msat) -> bolt11` (GET `callback?amount=…`, parse `pr`). Uses
+ `httpx`. Each function raises a typed error captured per item; never aborts the batch.
+- **`app/services/nwc_service.py`** — parse the NWC URI (`wallet_pubkey`, `relay`, `secret`);
+ build + Schnorr-sign a kind `23194` request event whose content is `encrypt_nip04`-encrypted
+ `{"method":"pay_invoice","params":{"invoice":bolt11}}` to the wallet pubkey; open one
+ `websockets.sync.client` connection, publish the EVENT, `REQ` for the kind `23195` response
+ authored by the wallet, `decrypt_nip04` it, return `{preimage}` or `{error}`. Bounded timeout;
+ reuses the existing relay-connection pattern.
+- **`app/services/payout_service.py`** — `compute_split(participants, total_sats)`; orchestrate:
+ resolve address → LNURL → invoice → NWC pay, writing `PayoutItem` status transitions; roll the
+ `Payout.status` up to `complete`/`partial`/`failed`.
+
+## API (organizer-authenticated, under `/api/v1`)
+
+- `POST /allocations/{id}/payouts` — body `{ team_label, total_sats, nwc, items?: [{participant_id,
+ lightning_address}] }`. Pre-flight: compute split, resolve addresses, validate against LNURL
+ bounds; if any address missing/invalid, return `422` with per-member flags **before** spending.
+ On success, executes payments and returns the `Payout` with per-item results.
+- `POST /payouts/{id}/retry` — body `{ nwc }`; retries only `failed` items.
+- Public `GET /public/results/{allocation_id}` — extended to include a redacted payout summary
+ (amounts, masked addresses, paid/failed counts); no NWC, no preimages-as-secrets beyond display.
+
+## Frontend components
+
+- **Registration form** — add an optional "Lightning address" field; on mount, resolve the logged-in
+ user's own `kind:0` `lud16` (via existing Nostr client / extension) and prefill it, editable.
+- **Results page (organizer view)** — per team: "Mark winner & pay out" → modal:
+ total sats input, NWC string paste, computed split + resolved/missing addresses (inline edit),
+ "Send payout" → live per-member status list (✅ paid + short preimage / ❌ failed + reason),
+ "Retry failed" button.
+- **Public results** — small "⚡ Prize paid" badge + redacted payout summary when a payout exists.
+
+## Data flow
+
+1. Organizer (NIP-98) opens results → "Mark winner & pay out" on a team → enters total sats →
+ pastes NWC string.
+2. App resolves each member's address (profile-prefilled, editable) → shows split + any gaps.
+3. "Send payout" → per member: LNURL resolve → request invoice → NWC `pay_invoice` → status.
+4. `Payout`/`PayoutItem` persisted; failed items retryable; public results shows the receipt.
+
+## Error handling
+
+- **Missing/invalid address** → flagged in `422` pre-flight; organizer fills it or skips a member,
+ and the pot **re-splits** among the remaining members before any payment is sent.
+- **LNURL failure / amount below `minSendable` / above `maxSendable`** → that item `failed`,
+ others continue.
+- **NWC timeout / wallet error response** → item `failed` with the wallet's error; "Retry failed".
+- **NWC string accepted per-request**, used transiently, never logged or persisted.
+
+## Testing
+
+Backend (SQLite, matching existing suite):
+- `compute_split` — even split + deterministic remainder; re-split after a skip; N=1 edge.
+- `lnurl_service` — URL construction from `lud16`; parse `callback`/min/max; invoice extraction;
+ amount-out-of-bounds error (mocked `httpx`).
+- `nwc_service` — URI parse; request event built/signed/encrypted correctly; response decrypt to
+ `{preimage}` and to `{error}` (mocked relay socket).
+- `payout_service` / endpoint — pre-flight `422` on missing address; status roll-up
+ `complete`/`partial`/`failed`; retry touches only `failed` items; NWC never persisted.
+
+Manual stage demo on **mainnet with tiny amounts** (e.g. 21 sats/member) for maximum swag.
+
+## Scope guardrails (YAGNI)
+
+Even split only, one winning team, one click, no fee/currency config, no scheduling, NWC never
+stored. Everything else is out of scope for the hackathon build.
From b79c23a11c59c5ff6eb3c8b72e239d05f4c0971d Mon Sep 17 00:00:00 2001
From: comwanga
Date: Wed, 17 Jun 2026 23:15:19 +0300
Subject: [PATCH 02/16] docs(plan): Lightning prize splitting implementation
plan + spec reconcile
---
.../2026-06-17-lightning-prize-splitting.md | 1351 +++++++++++++++++
...-06-17-lightning-prize-splitting-design.md | 13 +-
2 files changed, 1358 insertions(+), 6 deletions(-)
create mode 100644 docs/superpowers/plans/2026-06-17-lightning-prize-splitting.md
diff --git a/docs/superpowers/plans/2026-06-17-lightning-prize-splitting.md b/docs/superpowers/plans/2026-06-17-lightning-prize-splitting.md
new file mode 100644
index 0000000..579d24c
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-17-lightning-prize-splitting.md
@@ -0,0 +1,1351 @@
+# Lightning Prize Splitting (NWC) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let an organizer mark a winning team, enter a sats prize, connect a Nostr Wallet Connect (NWC) wallet, and have SquadSync split the pot evenly and pay each winner's Lightning address live.
+
+**Architecture:** A bolt-on at the results stage. Backend does all Lightning work, reusing the existing `nostr_service` NIP-04 + relay plumbing: an LNURL-pay client resolves each winner's `lud16` to a bolt11 invoice, and an NWC (NIP-47) client pays it. A pure split function and persisted `Payout`/`PayoutItem` rows make it reproducible and verifiable. Frontend adds a Lightning-address field at registration (prefilled from the user's Nostr profile) and a payout modal on the results page.
+
+**Tech Stack:** FastAPI, SQLAlchemy 2, Alembic, Pydantic v2, `coincurve` (Schnorr), `httpx` (LNURL), `websockets` (NWC relay), Next.js 16 / React 19.
+
+**Spec:** `docs/superpowers/specs/2026-06-17-lightning-prize-splitting-design.md`
+
+---
+
+## File Structure
+
+**Backend — create:**
+- `backend/app/models/payout.py` — `Payout`, `PayoutItem` ORM models.
+- `backend/app/services/lnurl_service.py` — LNURL-pay resolve + invoice request.
+- `backend/app/services/nwc_service.py` — NIP-47 URI parse, request build/encrypt, response decode, pay.
+- `backend/app/services/payout_service.py` — `compute_split` + payout orchestration.
+- `backend/app/schemas/payout.py` — request/response schemas.
+- `backend/app/api/v1/payouts.py` — organizer endpoints.
+- `backend/alembic/versions/0007_lightning_payout.py` — migration.
+- Tests: `test_split.py`, `test_lnurl_service.py`, `test_nwc_service.py`, `test_payout_endpoint.py`.
+
+**Backend — modify:**
+- `backend/app/models/participant.py` — add `lightning_address` column.
+- `backend/app/models/__init__.py` — register `Payout`, `PayoutItem`.
+- `backend/app/schemas/participant.py` — add `lightning_address` to register + out schemas.
+- `backend/app/main.py` — include payouts router.
+- `backend/app/api/v1/public.py` — add payout summary to public results.
+
+**Frontend — modify (concrete tasks at end):**
+- Registration form — Lightning-address field, prefilled from Nostr `kind:0`.
+- Results page — "Mark winner & pay out" modal with live status.
+- Public results — "⚡ Prize paid" badge.
+
+**Note on tests vs migrations:** the test suite builds tables from models via `Base.metadata.create_all` (`tests/conftest.py`), so tests do NOT exercise the Alembic migration. The migration (Task 3) is required for dev/prod only.
+
+---
+
+## Task 1: `lightning_address` on participants
+
+**Files:**
+- Modify: `backend/app/models/participant.py:34` (after the `npub` column)
+- Modify: `backend/app/schemas/participant.py`
+- Test: `backend/tests/test_registration.py` (add one test)
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `backend/tests/test_registration.py` (routes confirmed against `tests/test_find_my_team.py`: create event → PATCH status active → register at `/api/v1/events/{slug}/register`; the register endpoint returns `ParticipantOut`):
+
+```python
+def test_register_accepts_lightning_address(client, auth_headers):
+ e = client.post("/api/v1/events", headers=auth_headers,
+ json={"title": "BTC++ Demo", "team_count": 1}).json()
+ client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"})
+ res = client.post(f"/api/v1/events/{e['registration_slug']}/register", json={
+ "name": "Ada", "email": "ada@example.com",
+ "primary_strength": "technical", "experience_level": "advanced",
+ "lightning_address": "ada@getalby.com",
+ })
+ assert res.status_code in (200, 201)
+ assert res.json()["lightning_address"] == "ada@getalby.com"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && python -m pytest tests/test_registration.py::test_register_accepts_lightning_address -v`
+Expected: FAIL — `lightning_address` rejected/ignored (422 or missing key).
+
+- [ ] **Step 3: Add the column and schema fields**
+
+In `backend/app/models/participant.py`, after the `npub` column (line 34):
+
+```python
+ lightning_address = Column(String, nullable=True)
+```
+
+In `backend/app/schemas/participant.py`, add to `ParticipantRegister` (after `npub`):
+
+```python
+ lightning_address: Optional[str] = Field(default=None, max_length=255)
+
+ @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
+```
+
+And add to `ParticipantOut`:
+
+```python
+ lightning_address: Optional[str]
+```
+
+(The registration service uses `**req.model_dump()`, so no service change is needed.)
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `cd backend && python -m pytest tests/test_registration.py -v`
+Expected: PASS (all registration tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/models/participant.py backend/app/schemas/participant.py backend/tests/test_registration.py
+git commit -m "feat(payout): capture optional lightning_address at registration"
+```
+
+---
+
+## Task 2: `Payout` and `PayoutItem` models
+
+**Files:**
+- Create: `backend/app/models/payout.py`
+- Modify: `backend/app/models/__init__.py`
+- Test: `backend/tests/test_payout_endpoint.py` (model smoke test for now)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `backend/tests/test_payout_endpoint.py`:
+
+```python
+def test_payout_models_importable_and_persist(db):
+ import uuid
+ from app.models.payout import Payout, PayoutItem
+
+ payout = Payout(event_id=uuid.uuid4(), allocation_id=uuid.uuid4(),
+ team_label="Team Satoshi", total_sats=210, status="pending")
+ db.add(payout)
+ db.flush()
+ item = PayoutItem(payout_id=payout.id, participant_id=uuid.uuid4(),
+ lightning_address="ada@getalby.com", amount_sats=105, status="pending")
+ db.add(item)
+ db.commit()
+ assert payout.id is not None and item.payout_id == payout.id
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && python -m pytest tests/test_payout_endpoint.py::test_payout_models_importable_and_persist -v`
+Expected: FAIL — `ModuleNotFoundError: app.models.payout`.
+
+- [ ] **Step 3: Create the models**
+
+Create `backend/app/models/payout.py`:
+
+```python
+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())
+```
+
+In `backend/app/models/__init__.py`, add the import and `__all__` entries:
+
+```python
+from app.models.payout import Payout, PayoutItem
+```
+
+```python
+ "UsedAuthEvent", "Feedback", "TeamNotification", "Payout", "PayoutItem",
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `cd backend && python -m pytest tests/test_payout_endpoint.py::test_payout_models_importable_and_persist -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/models/payout.py backend/app/models/__init__.py backend/tests/test_payout_endpoint.py
+git commit -m "feat(payout): add Payout and PayoutItem models"
+```
+
+---
+
+## Task 3: Alembic migration `0007_lightning_payout`
+
+**Files:**
+- Create: `backend/alembic/versions/0007_lightning_payout.py`
+
+(No unit test — tests build tables from models. Verify by running the migration against a scratch SQLite DB.)
+
+- [ ] **Step 1: Write the migration**
+
+Create `backend/alembic/versions/0007_lightning_payout.py`:
+
+```python
+"""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")
+```
+
+- [ ] **Step 2: Verify the migration applies on a scratch DB**
+
+Run:
+```bash
+cd backend && DATABASE_URL="sqlite:///./scratch_migration.db" SECRET_KEY=x python -m alembic upgrade head && rm -f scratch_migration.db
+```
+Expected: ends at `0007_lightning_payout` with no error.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/alembic/versions/0007_lightning_payout.py
+git commit -m "feat(payout): migration for lightning_address + payout tables"
+```
+
+---
+
+## Task 4: `compute_split` (pure split math)
+
+**Files:**
+- Create: `backend/app/services/payout_service.py` (split function only in this task)
+- Test: `backend/tests/test_split.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `backend/tests/test_split.py`:
+
+```python
+import pytest
+from app.services.payout_service import compute_split
+
+
+def test_even_split_no_remainder():
+ # 300 sats over 3 members -> 100 each, order preserved
+ assert compute_split(["a", "b", "c"], 300) == [("a", 100), ("b", 100), ("c", 100)]
+
+
+def test_remainder_goes_to_earliest_members():
+ # 100 sats over 3 -> 34, 33, 33 (remainder 1 to the first member)
+ assert compute_split(["a", "b", "c"], 100) == [("a", 34), ("b", 33), ("c", 33)]
+
+
+def test_single_member_gets_everything():
+ assert compute_split(["a"], 210) == [("a", 210)]
+
+
+def test_zero_members_raises():
+ with pytest.raises(ValueError):
+ compute_split([], 100)
+
+
+def test_total_below_member_count_raises():
+ # cannot give every member at least 1 sat
+ with pytest.raises(ValueError):
+ compute_split(["a", "b", "c"], 2)
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && python -m pytest tests/test_split.py -v`
+Expected: FAIL — `ModuleNotFoundError` / `compute_split` undefined.
+
+- [ ] **Step 3: Implement `compute_split`**
+
+Create `backend/app/services/payout_service.py`:
+
+```python
+"""Lightning payout: deterministic split + orchestration.
+
+`compute_split` is pure and reproducible: an integer even split with the
+remainder assigned to the earliest members (by the order they are passed in).
+The orchestration functions are added in a later task.
+"""
+from typing import Sequence, TypeVar
+
+T = TypeVar("T")
+
+
+def compute_split(recipients: Sequence[T], total_sats: int) -> list[tuple[T, int]]:
+ """Split `total_sats` evenly across `recipients`, remainder to the first members.
+
+ Raises ValueError if there are no recipients or fewer sats than recipients
+ (every member must receive at least 1 sat).
+ """
+ n = len(recipients)
+ if n == 0:
+ raise ValueError("no recipients")
+ if total_sats < n:
+ raise ValueError("total_sats must be at least one sat per recipient")
+ base, remainder = divmod(total_sats, n)
+ return [(r, base + (1 if i < remainder else 0)) for i, r in enumerate(recipients)]
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd backend && python -m pytest tests/test_split.py -v`
+Expected: PASS (5 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/payout_service.py backend/tests/test_split.py
+git commit -m "feat(payout): deterministic even-split with remainder rule"
+```
+
+---
+
+## Task 5: LNURL-pay client
+
+**Files:**
+- Create: `backend/app/services/lnurl_service.py`
+- Test: `backend/tests/test_lnurl_service.py`
+
+> Confirm `httpx` is installed (`cd backend && python -c "import httpx"`). It ships with Starlette's TestClient, so it should be present. If not, add `httpx` to `backend/requirements.txt` and `pip install httpx`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `backend/tests/test_lnurl_service.py`:
+
+```python
+import pytest
+from app.services import lnurl_service
+from app.services.lnurl_service import LnurlError, lud16_to_url
+
+
+def test_lud16_to_url():
+ assert lud16_to_url("ada@getalby.com") == "https://getalby.com/.well-known/lnurlp/ada"
+
+
+def test_lud16_to_url_rejects_malformed():
+ with pytest.raises(LnurlError):
+ lud16_to_url("not-an-address")
+
+
+def test_request_invoice_amount_below_min_raises(monkeypatch):
+ params = {"callback": "https://getalby.com/lnurlp/ada/callback",
+ "minSendable": 100_000, "maxSendable": 1_000_000} # 100..1000 sat
+
+ def fake_get(url, **kwargs):
+ raise AssertionError("callback should not be hit when amount is out of bounds")
+
+ monkeypatch.setattr(lnurl_service.httpx, "get", fake_get)
+ with pytest.raises(LnurlError):
+ lnurl_service.request_invoice(params, amount_sats=50) # 50 sat < 100 sat min
+
+
+def test_request_invoice_returns_bolt11(monkeypatch):
+ params = {"callback": "https://getalby.com/lnurlp/ada/callback",
+ "minSendable": 1_000, "maxSendable": 1_000_000}
+
+ class FakeResp:
+ def raise_for_status(self): pass
+ def json(self): return {"pr": "lnbc100n1fakeinvoice"}
+
+ captured = {}
+
+ def fake_get(url, params=None, **kwargs):
+ captured["url"] = url
+ captured["params"] = params
+ return FakeResp()
+
+ monkeypatch.setattr(lnurl_service.httpx, "get", fake_get)
+ bolt11 = lnurl_service.request_invoice(params, amount_sats=100)
+ assert bolt11 == "lnbc100n1fakeinvoice"
+ assert captured["params"]["amount"] == 100_000 # msat
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && python -m pytest tests/test_lnurl_service.py -v`
+Expected: FAIL — `ModuleNotFoundError: app.services.lnurl_service`.
+
+- [ ] **Step 3: Implement the LNURL client**
+
+Create `backend/app/services/lnurl_service.py`:
+
+```python
+"""LNURL-pay client: resolve a `name@domain` Lightning address to a bolt11 invoice.
+
+Two steps per LNURL spec (LUD-06/LUD-16):
+ 1. GET https://{domain}/.well-known/lnurlp/{name} -> {callback, minSendable, maxSendable, ...}
+ 2. GET {callback}?amount={msat} -> {pr: }
+
+All errors raise LnurlError; the caller marks that payout item failed and continues.
+"""
+import httpx
+
+_TIMEOUT = 10.0
+
+
+class LnurlError(Exception):
+ """Any failure resolving an address or fetching an invoice."""
+
+
+def lud16_to_url(address: str) -> str:
+ address = address.strip().lower()
+ if address.count("@") != 1:
+ raise LnurlError(f"malformed lightning address: {address!r}")
+ name, domain = address.split("@")
+ if not name or not domain:
+ raise LnurlError(f"malformed lightning address: {address!r}")
+ return f"https://{domain}/.well-known/lnurlp/{name}"
+
+
+def resolve_lnurl(address: str) -> dict:
+ """Return the LNURL-pay params dict for a `name@domain` address."""
+ url = lud16_to_url(address)
+ try:
+ resp = httpx.get(url, timeout=_TIMEOUT, follow_redirects=True)
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as exc: # noqa: BLE001 — normalize to LnurlError
+ raise LnurlError(f"failed to resolve {address}: {exc}") from exc
+ if "callback" not in data or "minSendable" not in data or "maxSendable" not in data:
+ raise LnurlError(f"invalid LNURL-pay response for {address}")
+ return data
+
+
+def request_invoice(params: dict, amount_sats: int) -> str:
+ """Request a bolt11 invoice for `amount_sats` from a resolved LNURL params dict."""
+ amount_msat = amount_sats * 1000
+ if amount_msat < params["minSendable"] or amount_msat > params["maxSendable"]:
+ raise LnurlError(
+ f"amount {amount_sats} sat outside payable range "
+ f"[{params['minSendable'] // 1000}, {params['maxSendable'] // 1000}] sat"
+ )
+ try:
+ resp = httpx.get(params["callback"], params={"amount": amount_msat},
+ timeout=_TIMEOUT, follow_redirects=True)
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as exc: # noqa: BLE001
+ raise LnurlError(f"invoice request failed: {exc}") from exc
+ pr = data.get("pr")
+ if not pr:
+ raise LnurlError("LNURL callback returned no invoice")
+ return pr
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd backend && python -m pytest tests/test_lnurl_service.py -v`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/lnurl_service.py backend/tests/test_lnurl_service.py
+git commit -m "feat(payout): LNURL-pay client (resolve + invoice)"
+```
+
+---
+
+## Task 6: NWC (NIP-47) client
+
+**Files:**
+- Create: `backend/app/services/nwc_service.py`
+- Test: `backend/tests/test_nwc_service.py`
+
+This task has three pure, unit-testable pieces (`parse_nwc_uri`, `build_pay_invoice_request`, `decode_response`) plus a thin relay round-trip (`pay_invoice`) that tests monkeypatch.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `backend/tests/test_nwc_service.py`:
+
+```python
+import json
+import pytest
+from coincurve import PrivateKey
+
+from app.services import nwc_service
+from app.services.nwc_service import NwcError, parse_nwc_uri, build_pay_invoice_request, decode_response
+from app.services.nostr_service import encrypt_nip04
+
+
+def _make_uri(secret_hex: str, wallet_pub_hex: str, relay="wss://relay.example") -> str:
+ return f"nostr+walletconnect://{wallet_pub_hex}?relay={relay}&secret={secret_hex}"
+
+
+def test_parse_nwc_uri():
+ secret = PrivateKey()
+ wallet = PrivateKey()
+ wallet_xonly = wallet.public_key_xonly.format().hex()
+ uri = _make_uri(secret.to_hex(), wallet_xonly)
+ parsed = parse_nwc_uri(uri)
+ assert parsed.wallet_pubkey_hex == wallet_xonly
+ assert parsed.relay == "wss://relay.example"
+ assert parsed.secret_bytes == secret.secret
+
+
+def test_parse_nwc_uri_rejects_garbage():
+ with pytest.raises(NwcError):
+ parse_nwc_uri("https://not-nwc")
+
+
+def test_build_pay_invoice_request_is_signed_and_encrypted():
+ secret = PrivateKey()
+ wallet = PrivateKey()
+ wallet_xonly = wallet.public_key_xonly.format().hex()
+ event = build_pay_invoice_request(secret.secret, bytes.fromhex(wallet_xonly), "lnbc1fake")
+ assert event["kind"] == 23194
+ assert ["p", wallet_xonly] in event["tags"]
+ # wallet decrypts the request with its privkey + our x-only pubkey
+ our_xonly = secret.public_key_xonly.format()
+ from app.services.nostr_service import decrypt_nip04
+ body = json.loads(decrypt_nip04(wallet.secret, our_xonly, event["content"]))
+ assert body["method"] == "pay_invoice"
+ assert body["params"]["invoice"] == "lnbc1fake"
+
+
+def test_decode_response_success():
+ secret = PrivateKey()
+ wallet = PrivateKey()
+ our_xonly = secret.public_key_xonly.format()
+ payload = json.dumps({"result_type": "pay_invoice", "result": {"preimage": "deadbeef"}})
+ content = encrypt_nip04(wallet.secret, our_xonly, payload)
+ result = decode_response(secret.secret, wallet.public_key_xonly.format(), content)
+ assert result == {"preimage": "deadbeef"}
+
+
+def test_decode_response_error_raises():
+ secret = PrivateKey()
+ wallet = PrivateKey()
+ our_xonly = secret.public_key_xonly.format()
+ payload = json.dumps({"error": {"code": "INSUFFICIENT_BALANCE", "message": "no funds"}})
+ content = encrypt_nip04(wallet.secret, our_xonly, payload)
+ with pytest.raises(NwcError) as exc:
+ decode_response(secret.secret, wallet.public_key_xonly.format(), content)
+ assert "no funds" in str(exc.value)
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && python -m pytest tests/test_nwc_service.py -v`
+Expected: FAIL — `ModuleNotFoundError: app.services.nwc_service`.
+
+- [ ] **Step 3: Implement the NWC client**
+
+Create `backend/app/services/nwc_service.py`:
+
+```python
+"""Nostr Wallet Connect (NIP-47) client — pay_invoice only.
+
+Reuses nostr_service's NIP-04 encryption. The NWC URI carries the client's
+secret key and the wallet service pubkey + relay:
+ nostr+walletconnect://?relay=&secret=
+
+Flow over one short-lived websocket: subscribe (REQ) for the wallet's kind-23195
+response tagged with our request id, publish the kind-23194 request, read frames
+until the response arrives or we time out, decrypt it, return the preimage.
+
+The NWC secret is a spend credential: it is used transiently and never persisted.
+"""
+import hashlib
+import json
+import logging
+import time
+from dataclasses import dataclass
+from urllib.parse import urlparse, parse_qs
+
+from coincurve import PrivateKey
+
+from app.services.nostr_service import encrypt_nip04, decrypt_nip04
+
+logger = logging.getLogger(__name__)
+
+_REQUEST_KIND = 23194
+_RESPONSE_KIND = 23195
+_TIMEOUT = 30.0
+
+
+class NwcError(Exception):
+ """Any NWC failure: bad URI, wallet error response, or relay timeout."""
+
+
+@dataclass
+class NwcConnection:
+ wallet_pubkey_hex: str # x-only hex
+ relay: str
+ secret_bytes: bytes
+
+
+def parse_nwc_uri(uri: str) -> NwcConnection:
+ uri = uri.strip()
+ if not uri.startswith("nostr+walletconnect://"):
+ raise NwcError("not a nostr+walletconnect URI")
+ parsed = urlparse(uri)
+ wallet_pubkey = parsed.netloc or parsed.path.lstrip("/")
+ qs = parse_qs(parsed.query)
+ relay = (qs.get("relay") or [None])[0]
+ secret_hex = (qs.get("secret") or [None])[0]
+ if not wallet_pubkey or not relay or not secret_hex:
+ raise NwcError("NWC URI missing wallet pubkey, relay, or secret")
+ try:
+ secret_bytes = bytes.fromhex(secret_hex)
+ except ValueError as exc:
+ raise NwcError("NWC secret is not valid hex") from exc
+ return NwcConnection(wallet_pubkey_hex=wallet_pubkey.lower(), relay=relay, secret_bytes=secret_bytes)
+
+
+def build_pay_invoice_request(secret_bytes: bytes, wallet_xonly: bytes, bolt11: str) -> dict:
+ """Build a signed, NIP-04-encrypted kind-23194 pay_invoice request event."""
+ privkey = PrivateKey(secret_bytes)
+ pubkey_hex = privkey.public_key_xonly.format().hex()
+ created_at = int(time.time())
+ body = json.dumps({"method": "pay_invoice", "params": {"invoice": bolt11}},
+ separators=(",", ":"))
+ content = encrypt_nip04(secret_bytes, wallet_xonly, body)
+ tags = [["p", wallet_xonly.hex()]]
+ serialized = json.dumps(
+ [0, pubkey_hex, created_at, _REQUEST_KIND, tags, content],
+ separators=(",", ":"), ensure_ascii=False,
+ )
+ event_id = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+ sig = privkey.sign_schnorr(bytes.fromhex(event_id)).hex()
+ return {"id": event_id, "pubkey": pubkey_hex, "created_at": created_at,
+ "kind": _REQUEST_KIND, "tags": tags, "content": content, "sig": sig}
+
+
+def decode_response(secret_bytes: bytes, wallet_xonly: bytes, content: str) -> dict:
+ """Decrypt a kind-23195 response. Return {'preimage': ...} or raise NwcError."""
+ plaintext = decrypt_nip04(secret_bytes, wallet_xonly, content)
+ data = json.loads(plaintext)
+ if data.get("error"):
+ raise NwcError(data["error"].get("message", "wallet returned an error"))
+ result = data.get("result") or {}
+ preimage = result.get("preimage")
+ if not preimage:
+ raise NwcError("wallet response had no preimage")
+ return {"preimage": preimage}
+
+
+def _round_trip(conn: NwcConnection, request_event: dict) -> str:
+ """Publish the request and read the wallet's response content. Monkeypatched in tests.
+
+ Subscribe BEFORE publishing so we cannot miss a fast response. Returns the
+ raw (still-encrypted) response event content; raises NwcError on timeout.
+ """
+ from websockets.sync.client import connect
+
+ sub_id = request_event["id"][:16]
+ req = json.dumps(["REQ", sub_id, {
+ "kinds": [_RESPONSE_KIND],
+ "authors": [conn.wallet_pubkey_hex],
+ "#e": [request_event["id"]],
+ }])
+ event_msg = json.dumps(["EVENT", request_event])
+ deadline = time.time() + _TIMEOUT
+ try:
+ with connect(conn.relay, open_timeout=10, close_timeout=5) as ws:
+ ws.send(req)
+ ws.send(event_msg)
+ while time.time() < deadline:
+ frame = json.loads(ws.recv(timeout=max(1.0, deadline - time.time())))
+ if frame[0] == "EVENT" and frame[1] == sub_id:
+ return frame[2]["content"]
+ except NwcError:
+ raise
+ except Exception as exc: # noqa: BLE001
+ raise NwcError(f"NWC relay round-trip failed: {exc}") from exc
+ raise NwcError("timed out waiting for wallet response")
+
+
+def pay_invoice(uri: str, bolt11: str) -> str:
+ """Pay `bolt11` via the wallet in `uri`. Return the payment preimage or raise NwcError."""
+ conn = parse_nwc_uri(uri)
+ wallet_xonly = bytes.fromhex(conn.wallet_pubkey_hex)
+ request_event = build_pay_invoice_request(conn.secret_bytes, wallet_xonly, bolt11)
+ content = _round_trip(conn, request_event)
+ return decode_response(conn.secret_bytes, wallet_xonly, content)["preimage"]
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd backend && python -m pytest tests/test_nwc_service.py -v`
+Expected: PASS (5 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/nwc_service.py backend/tests/test_nwc_service.py
+git commit -m "feat(payout): NWC (NIP-47) pay_invoice client"
+```
+
+---
+
+## Task 7: Payout orchestration + schemas + endpoint
+
+**Files:**
+- Modify: `backend/app/services/payout_service.py` (add orchestration)
+- Create: `backend/app/schemas/payout.py`
+- Create: `backend/app/api/v1/payouts.py`
+- Modify: `backend/app/main.py:7,33` (import + include router)
+- Test: `backend/tests/test_payout_endpoint.py` (add endpoint tests)
+
+- [ ] **Step 1: Write the failing endpoint test**
+
+Add to `backend/tests/test_payout_endpoint.py`. The helper below uses the routes confirmed in
+`tests/test_find_my_team.py` and `app/api/v1/allocation.py`: create event with `team_count=1` (so
+both registrants land on one team) → activate → register → allocate → read the single team.
+
+```python
+def _setup_team(client, auth_headers, members):
+ """members: list of (name, email, lightning_address|None).
+ Returns (event_id, allocation_id, team_id)."""
+ e = client.post("/api/v1/events", headers=auth_headers,
+ json={"title": "BTC++ Payout", "team_count": 1}).json()
+ client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"})
+ strengths = ["technical", "design", "planning", "coordination"]
+ for i, (name, email, ln) in enumerate(members):
+ body = {"name": name, "email": email,
+ "primary_strength": strengths[i % len(strengths)],
+ "experience_level": "intermediate"}
+ if ln:
+ body["lightning_address"] = ln
+ r = client.post(f"/api/v1/events/{e['registration_slug']}/register", json=body)
+ assert r.status_code in (200, 201), r.text
+ a = client.post(f"/api/v1/events/{e['id']}/allocate", headers=auth_headers).json()
+ teams = client.get(f"/api/v1/allocations/{a['id']}/teams", headers=auth_headers).json()
+ assert teams, "expected at least one team"
+ return e["id"], a["id"], teams[0]["id"]
+
+
+def test_payout_pays_team_and_records_results(client, auth_headers, monkeypatch):
+ from app.services import lnurl_service, nwc_service
+
+ _, allocation_id, team_id = _setup_team(client, auth_headers, [
+ ("Ada", "ada@t.com", "ada@getalby.com"),
+ ("Linus", "linus@t.com", "linus@getalby.com"),
+ ])
+
+ # Stub the network: every address resolves, every invoice is fake, every pay returns a preimage.
+ monkeypatch.setattr(lnurl_service, "resolve_lnurl",
+ lambda addr: {"callback": "https://x/cb", "minSendable": 1000, "maxSendable": 10_000_000})
+ monkeypatch.setattr(lnurl_service, "request_invoice", lambda params, amount_sats: f"lnbc{amount_sats}fake")
+ paid = []
+ monkeypatch.setattr(nwc_service, "pay_invoice",
+ lambda uri, bolt11: (paid.append(bolt11), "preimage_" + bolt11)[1])
+
+ res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
+ "team_id": str(team_id), "total_sats": 210,
+ "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
+ })
+
+ assert res.status_code == 201, res.text
+ body = res.json()
+ assert body["status"] == "complete"
+ assert len(body["items"]) == 2
+ assert sum(i["amount_sats"] for i in body["items"]) == 210
+ assert all(i["status"] == "paid" and i["preimage"] for i in body["items"])
+ assert len(paid) == 2
+
+
+def test_payout_422_when_member_missing_address(client, auth_headers):
+ _, allocation_id, team_id = _setup_team(client, auth_headers, [
+ ("Ada", "ada@t.com", "ada@getalby.com"),
+ ("NoAddr", "noaddr@t.com", None),
+ ])
+ res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
+ "team_id": str(team_id), "total_sats": 210,
+ "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
+ })
+ assert res.status_code == 422
+ assert "missing" in res.text.lower()
+
+
+def test_payout_address_override_fills_missing(client, auth_headers, monkeypatch):
+ from app.services import lnurl_service, nwc_service
+ _, allocation_id, team_id = _setup_team(client, auth_headers, [
+ ("Ada", "ada@t.com", "ada@getalby.com"),
+ ("NoAddr", "noaddr@t.com", None),
+ ])
+ # Find the participant id of the member with no address (organizer team view).
+ teams = client.get(f"/api/v1/allocations/{allocation_id}/teams", headers=auth_headers).json()
+ members = teams[0]["members"]
+ no_addr_id = next(m["id"] for m in members if m["name"] == "NoAddr")
+
+ monkeypatch.setattr(lnurl_service, "resolve_lnurl",
+ lambda addr: {"callback": "https://x/cb", "minSendable": 1000, "maxSendable": 10_000_000})
+ monkeypatch.setattr(lnurl_service, "request_invoice", lambda params, amount_sats: "lnbcfake")
+ monkeypatch.setattr(nwc_service, "pay_invoice", lambda uri, bolt11: "preimage")
+
+ res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
+ "team_id": str(team_id), "total_sats": 210,
+ "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
+ "addresses": {no_addr_id: "noaddr@getalby.com"},
+ })
+ assert res.status_code == 201, res.text
+ assert res.json()["status"] == "complete"
+```
+
+> `TeamOut.members` exposes member `id` and `name` (see `app/schemas/allocation.py` / `teams.py`).
+> If `team_count=1` does not yield a single team with both members in this engine, switch the helper
+> to `team_count=1` semantics it does support, or read the team that actually contains both emails.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && python -m pytest tests/test_payout_endpoint.py -v`
+Expected: FAIL — payouts route does not exist (404) / helpers undefined.
+
+- [ ] **Step 3: Add orchestration to `payout_service.py`**
+
+Append to `backend/app/services/payout_service.py`:
+
+```python
+import logging
+from uuid import UUID
+
+from sqlalchemy.orm import Session
+
+from app.models.payout import Payout, PayoutItem
+from app.models.participant import Participant
+from app.models.team import Team, TeamMember
+from app.services import lnurl_service, nwc_service
+
+logger = logging.getLogger(__name__)
+
+
+def _team_members(db: Session, team_id: UUID) -> list[Participant]:
+ """Members of a team, ordered by participant id for a reproducible split."""
+ return (
+ db.query(Participant)
+ .join(TeamMember, Participant.id == TeamMember.participant_id)
+ .filter(TeamMember.team_id == team_id)
+ .order_by(Participant.id)
+ .all()
+ )
+
+
+def preflight(
+ db: Session, team_id: UUID, total_sats: int, overrides: dict[str, str] | None = None,
+) -> list[tuple[Participant, str, int]]:
+ """Resolve each member's address + split.
+
+ `overrides` maps `str(participant_id) -> lightning_address` and lets the organizer
+ supply/correct addresses in the payout modal (the spec's editable `items`). The
+ override wins over the registration value. Raise ValueError listing any member who
+ still has no address. Returns (participant, address, amount_sats) tuples.
+ """
+ overrides = overrides or {}
+ members = _team_members(db, team_id)
+ resolved = [(m, overrides.get(str(m.id)) or m.lightning_address) for m in members]
+ missing = [m.name for m, addr in resolved if not addr]
+ if missing:
+ raise ValueError(f"missing lightning address for: {', '.join(missing)}")
+ # compute_split preserves order, so zip the amounts back onto (participant, address).
+ amounts = compute_split([m for m, _ in resolved], total_sats)
+ return [(m, addr, amount) for (m, addr), (_, amount) in zip(resolved, amounts)]
+
+
+def execute_payout(
+ db: Session, payout: Payout, splits: list[tuple[Participant, str, int]], nwc: str,
+) -> Payout:
+ """Pay each member, recording per-item status. Rolls payout.status up at the end."""
+ paid = 0
+ for participant, address, amount_sats in splits:
+ item = PayoutItem(payout_id=payout.id, participant_id=participant.id,
+ lightning_address=address, amount_sats=amount_sats, status="pending")
+ db.add(item)
+ db.flush()
+ try:
+ params = lnurl_service.resolve_lnurl(address)
+ bolt11 = lnurl_service.request_invoice(params, amount_sats)
+ item.bolt11 = bolt11
+ item.preimage = nwc_service.pay_invoice(nwc, bolt11)
+ item.status = "paid"
+ paid += 1
+ except Exception as exc: # noqa: BLE001 — record + continue
+ item.status = "failed"
+ item.error = str(exc)
+ logger.warning("payout item %s failed: %s", item.id, exc)
+ payout.status = "complete" if paid == len(splits) else ("partial" if paid else "failed")
+ db.commit()
+ db.refresh(payout)
+ return payout
+
+
+def retry_failed(db: Session, payout: Payout, nwc: str) -> Payout:
+ """Retry only the failed items of an existing payout."""
+ items = db.query(PayoutItem).filter(PayoutItem.payout_id == payout.id).all()
+ for item in items:
+ if item.status != "failed":
+ continue
+ participant = db.query(Participant).filter(Participant.id == item.participant_id).first()
+ try:
+ params = lnurl_service.resolve_lnurl(item.lightning_address)
+ bolt11 = lnurl_service.request_invoice(params, item.amount_sats)
+ item.bolt11 = bolt11
+ item.preimage = nwc_service.pay_invoice(nwc, bolt11)
+ item.status, item.error = "paid", None
+ except Exception as exc: # noqa: BLE001
+ item.error = str(exc)
+ logger.warning("payout retry item %s failed: %s", item.id, exc)
+ paid = sum(1 for i in items if i.status == "paid")
+ payout.status = "complete" if paid == len(items) else ("partial" if paid else "failed")
+ db.commit()
+ db.refresh(payout)
+ return payout
+```
+
+- [ ] **Step 4: Create the schemas**
+
+Create `backend/app/schemas/payout.py`:
+
+```python
+from typing import Literal, 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}
+```
+
+- [ ] **Step 5: Create the endpoint**
+
+Create `backend/app/api/v1/payouts.py`:
+
+```python
+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)
+```
+
+- [ ] **Step 6: Register the router**
+
+In `backend/app/main.py`, line 7 import list, add `payouts`:
+
+```python
+from app.api.v1 import auth, events, participants, allocation, teams, export, public, feedback, payouts
+```
+
+After the `teams` router line (line 30), add:
+
+```python
+app.include_router(payouts.router, prefix="/api/v1/allocations", tags=["payouts"])
+```
+
+- [ ] **Step 7: Run the endpoint tests to verify they pass**
+
+Run: `cd backend && python -m pytest tests/test_payout_endpoint.py -v`
+Expected: PASS. If `assert_allocation_organizer`'s return value or signature differs, open `app/services/event_service.py` and adjust the call to match (it is the same helper `teams.py` uses).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add backend/app/services/payout_service.py backend/app/schemas/payout.py backend/app/api/v1/payouts.py backend/app/main.py backend/tests/test_payout_endpoint.py
+git commit -m "feat(payout): payout orchestration + organizer endpoints"
+```
+
+---
+
+## Task 8: Public results payout summary
+
+**Files:**
+- Modify: `backend/app/schemas/allocation.py` (add `PublicPayoutSummary` + a field on `PublicAllocationOut`)
+- Modify: `backend/app/api/v1/public.py:29-53` (the `public_allocation` handler)
+- Test: `backend/tests/test_payout_endpoint.py` (add a public-view test)
+
+The public results route is `GET /api/v1/public/allocations/{allocation_id}` → `PublicAllocationOut`
+(`app/api/v1/public.py:29`). It is **published-only**, so the test must publish first. Because the
+route has a `response_model`, we must add a typed `payouts` field to `PublicAllocationOut` (a
+`response_model` strips keys not on the model).
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `backend/tests/test_payout_endpoint.py` (`_setup_team` returns `event_id` so we can publish):
+
+```python
+def test_public_results_include_payout_summary(client, auth_headers, monkeypatch):
+ from app.services import lnurl_service, nwc_service
+ event_id, allocation_id, team_id = _setup_team(client, auth_headers, [
+ ("Ada", "ada@t.com", "ada@getalby.com"),
+ ("Linus", "linus@t.com", "linus@getalby.com"),
+ ])
+ monkeypatch.setattr(lnurl_service, "resolve_lnurl",
+ lambda addr: {"callback": "https://x/cb", "minSendable": 1000, "maxSendable": 10_000_000})
+ monkeypatch.setattr(lnurl_service, "request_invoice", lambda params, amount_sats: "lnbcfake")
+ monkeypatch.setattr(nwc_service, "pay_invoice", lambda uri, bolt11: "preimage")
+ client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
+ "team_id": str(team_id), "total_sats": 210,
+ "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
+ })
+ # Public results are published-only.
+ client.post(f"/api/v1/events/{event_id}/allocations/{allocation_id}/publish", headers=auth_headers)
+
+ res = client.get(f"/api/v1/public/allocations/{allocation_id}")
+ assert res.status_code == 200, res.text
+ summary = res.json()["payouts"]
+ assert summary[0]["team_label"]
+ assert summary[0]["total_sats"] == 210
+ assert summary[0]["paid_count"] == 2
+ assert summary[0]["member_count"] == 2
+ # never leak the credential or invoice/preimage secrets
+ for leaked in ("nwc", "preimage", "bolt11", "lightning_address"):
+ assert leaked not in res.text.lower()
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `cd backend && python -m pytest tests/test_payout_endpoint.py::test_public_results_include_payout_summary -v`
+Expected: FAIL — no `payouts` key.
+
+- [ ] **Step 3: Add the schema field**
+
+In `backend/app/schemas/allocation.py`, before `class PublicAllocationOut` (around line 76):
+
+```python
+class PublicPayoutSummary(BaseModel):
+ team_label: str
+ total_sats: int
+ status: str
+ paid_count: int
+ member_count: int
+```
+
+And add to `PublicAllocationOut`:
+
+```python
+ payouts: list[PublicPayoutSummary] = []
+```
+
+- [ ] **Step 4: Populate it in the handler**
+
+In `backend/app/api/v1/public.py`, add the import near the top:
+
+```python
+from app.models.payout import Payout, PayoutItem
+from app.schemas.allocation import PublicPayoutSummary
+```
+
+In `public_allocation`, replace the final `return` (line 53) with:
+
+```python
+ 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)
+```
+
+The summary intentionally excludes `lightning_address`, `bolt11`, `preimage`, and any NWC data.
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `cd backend && python -m pytest tests/test_payout_endpoint.py -v`
+Expected: PASS.
+
+- [ ] **Step 6: Run the full backend suite (no regressions)**
+
+Run: `cd backend && python -m pytest -q`
+Expected: all green.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add backend/app/api/v1/public.py backend/tests/test_payout_endpoint.py
+git commit -m "feat(payout): redacted payout summary on public results"
+```
+
+---
+
+## Task 9: Frontend — Lightning address at registration (prefilled from Nostr profile)
+
+**Files:**
+- Modify: the registration form component (find with: `grep -rl "primary_strength\|strength_other" frontend/`)
+- Modify: the API/types module that defines the registration request body (find with: `grep -rln "npub" frontend/`)
+
+- [ ] **Step 1: Add the field to the registration request type/body**
+
+Wherever the registration payload type and POST body are built (the same place `npub` is sent), add an optional `lightning_address?: string` and include it in the POST body.
+
+- [ ] **Step 2: Add the input to the form**
+
+Add an optional text input labelled "Lightning address (optional)" with placeholder `you@walletofsatoshi.com`, bound to the same form state used for the other fields. Mirror the existing `npub` field's markup/validation styling exactly.
+
+- [ ] **Step 3: Prefill from the logged-in Nostr profile**
+
+If the registrant is logged in via Nostr (the app already has NIP-07 / nsec handling — find it with `grep -rln "nip07\|window.nostr\|getPublicKey" frontend/`), on form mount fetch their `kind:0` metadata from the app's configured relays, parse the JSON `content`, and if it has a `lud16` (or `lud06`), prefill the Lightning-address input (leave editable). If no profile or no `lud16`, leave the field blank. Reuse any existing relay/profile helper rather than adding a new Nostr client.
+
+- [ ] **Step 4: Manual verification**
+
+Run the frontend (`cd frontend && npm run dev`), open a registration page, confirm: (a) the field appears, (b) for a Nostr profile with a `lud16` it prefills, (c) submitting persists it (check the organizer participant view / DB).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/
+git commit -m "feat(payout): lightning address field at registration, prefilled from Nostr profile"
+```
+
+---
+
+## Task 10: Frontend — "Mark winner & pay out" modal on results
+
+**Files:**
+- Modify: the organizer results/teams page (find with: `grep -rln "fairness_score\|/teams" frontend/`)
+- Modify: the frontend API client module (where authed POSTs to `/api/v1/...` are made)
+
+- [ ] **Step 1: Add the API client calls**
+
+Add `createPayout(allocationId, { team_id, total_sats, nwc })` → `POST /api/v1/allocations/{allocationId}/payouts`, and `retryPayout(payoutId, { nwc })` → `POST /api/v1/allocations/payouts/{payoutId}/retry`. Use the existing authed-fetch helper (NIP-98 header) the other organizer calls use.
+
+- [ ] **Step 2: Add a "Mark winner & pay out" button per team**
+
+On each team card (organizer view only), add the button. Clicking opens a modal.
+
+- [ ] **Step 3: Build the modal**
+
+Fields: total prize (sats, number input) and NWC connection string (password-style input, with helper text "Paste an NWC string from Alby, Coinos, or Alby Hub"). Show the computed even split client-side (`floor(total/n)` with the first `total mod n` members +1) next to each member name, and flag any member with no Lightning address (block submit, with a note to add it at registration). A "Send payout" button calls `createPayout`.
+
+- [ ] **Step 4: Render live per-member status**
+
+On the response, list each item: ✅ green with a shortened preimage (`preimage.slice(0,12)…`) when `status === "paid"`, ❌ red with `error` when `failed`. If any failed, show a "Retry failed" button that calls `retryPayout` and re-renders. Never store or log the NWC string beyond the in-memory modal state; clear it when the modal closes.
+
+- [ ] **Step 5: Manual verification (the demo path)**
+
+With a funded NWC wallet (Alby Hub / Coinos) and two participants whose Lightning addresses you control, run a real payout of a tiny amount (e.g. 21 sats) and confirm both members receive sats and the UI shows preimages. This is the stage demo — rehearse it.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add frontend/
+git commit -m "feat(payout): organizer payout modal with live NWC payment status"
+```
+
+---
+
+## Task 11: Frontend — "⚡ Prize paid" badge on public results
+
+**Files:**
+- Modify: the public results page (find with: `grep -rln "results" frontend/app frontend/src 2>/dev/null`)
+
+- [ ] **Step 1: Render the payout summary**
+
+The public results response now includes `payouts: [{ team_label, total_sats, status, paid_count, member_count }]`. When present and non-empty, render a small "⚡ Prize paid" badge near the winning team plus a one-line summary (e.g. "210 sats → 2/2 paid"). Show nothing when the array is empty.
+
+- [ ] **Step 2: Manual verification**
+
+Open `/results/` after a payout and confirm the badge shows; open one with no payout and confirm nothing renders.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add frontend/
+git commit -m "feat(payout): prize-paid badge on public results"
+```
+
+---
+
+## Final verification
+
+- [ ] Backend suite green: `cd backend && python -m pytest -q`
+- [ ] Migration applies cleanly: `cd backend && DATABASE_URL="sqlite:///./scratch.db" SECRET_KEY=x python -m alembic upgrade head && rm -f scratch.db`
+- [ ] End-to-end rehearsal: real NWC wallet, two real Lightning addresses, 21-sat payout, preimages shown, public badge appears.
+- [ ] Confirm the NWC string never appears in DB, logs, or any API response (`grep -rin "nwc" backend/app` shows only transient request handling).
+```
diff --git a/docs/superpowers/specs/2026-06-17-lightning-prize-splitting-design.md b/docs/superpowers/specs/2026-06-17-lightning-prize-splitting-design.md
index 01bf6cc..fe27218 100644
--- a/docs/superpowers/specs/2026-06-17-lightning-prize-splitting-design.md
+++ b/docs/superpowers/specs/2026-06-17-lightning-prize-splitting-design.md
@@ -78,10 +78,11 @@ proof rendered on `/results/{allocation_id}`.
## API (organizer-authenticated, under `/api/v1`)
-- `POST /allocations/{id}/payouts` — body `{ team_label, total_sats, nwc, items?: [{participant_id,
- lightning_address}] }`. Pre-flight: compute split, resolve addresses, validate against LNURL
- bounds; if any address missing/invalid, return `422` with per-member flags **before** spending.
- On success, executes payments and returns the `Payout` with per-item results.
+- `POST /allocations/{id}/payouts` — body `{ team_id, total_sats, nwc, addresses?: {participant_id:
+ lightning_address} }`. The optional `addresses` map lets the organizer fill/correct a member's
+ Lightning address in the modal (overrides the registration value). Pre-flight: compute split,
+ resolve addresses; if any address is still missing, return `422` listing those members **before**
+ spending. On success, executes payments and returns the `Payout` with per-item results.
- `POST /payouts/{id}/retry` — body `{ nwc }`; retries only `failed` items.
- Public `GET /public/results/{allocation_id}` — extended to include a redacted payout summary
(amounts, masked addresses, paid/failed counts); no NWC, no preimages-as-secrets beyond display.
@@ -106,8 +107,8 @@ proof rendered on `/results/{allocation_id}`.
## Error handling
-- **Missing/invalid address** → flagged in `422` pre-flight; organizer fills it or skips a member,
- and the pot **re-splits** among the remaining members before any payment is sent.
+- **Missing address** → flagged in `422` pre-flight (before any spend); organizer fills it inline
+ via the `addresses` override and re-submits. (Skip-and-re-split is out of scope.)
- **LNURL failure / amount below `minSendable` / above `maxSendable`** → that item `failed`,
others continue.
- **NWC timeout / wallet error response** → item `failed` with the wallet's error; "Retry failed".
From 3765e5b783f100b2d989f6a00d779bf9314ea260 Mon Sep 17 00:00:00 2001
From: comwanga
Date: Wed, 17 Jun 2026 23:21:46 +0300
Subject: [PATCH 03/16] feat(payout): capture optional lightning_address at
registration
---
backend/app/models/participant.py | 1 +
backend/app/schemas/participant.py | 14 ++++++++++++++
backend/tests/test_registration.py | 13 +++++++++++++
3 files changed, 28 insertions(+)
diff --git a/backend/app/models/participant.py b/backend/app/models/participant.py
index bc4e2f2..09a0943 100644
--- a/backend/app/models/participant.py
+++ b/backend/app/models/participant.py
@@ -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)
diff --git a/backend/app/schemas/participant.py b/backend/app/schemas/participant.py
index f3d07b4..23ada16 100644
--- a/backend/app/schemas/participant.py
+++ b/backend/app/schemas/participant.py
@@ -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()):
@@ -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}
diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py
index 9bfacdc..0130d4b 100644
--- a/backend/tests/test_registration.py
+++ b/backend/tests/test_registration.py
@@ -147,3 +147,16 @@ def test_register_omitted_npub_stored_none(client, auth_headers):
res = _register(client, e["registration_slug"], email="omit@t.com")
assert res.status_code == 201
assert res.json()["npub"] is None
+
+
+def test_register_accepts_lightning_address(client, auth_headers):
+ e = client.post("/api/v1/events", headers=auth_headers,
+ json={"title": "BTC++ Demo", "team_count": 2}).json()
+ client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"})
+ res = client.post(f"/api/v1/events/{e['registration_slug']}/register", json={
+ "name": "Ada", "email": "ada@example.com",
+ "primary_strength": "technical", "experience_level": "advanced",
+ "lightning_address": "ada@getalby.com",
+ })
+ assert res.status_code in (200, 201)
+ assert res.json()["lightning_address"] == "ada@getalby.com"
From 9ad4817d9ae7c48740ad17a0445473a99bc261fe Mon Sep 17 00:00:00 2001
From: comwanga
Date: Wed, 17 Jun 2026 23:26:47 +0300
Subject: [PATCH 04/16] feat(payout): add Payout and PayoutItem models
---
backend/app/models/__init__.py | 3 ++-
backend/app/models/payout.py | 34 +++++++++++++++++++++++++++
backend/tests/test_payout_endpoint.py | 13 ++++++++++
3 files changed, 49 insertions(+), 1 deletion(-)
create mode 100644 backend/app/models/payout.py
create mode 100644 backend/tests/test_payout_endpoint.py
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index c8db741..cfcb868 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -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",
]
diff --git a/backend/app/models/payout.py b/backend/app/models/payout.py
new file mode 100644
index 0000000..b74fd86
--- /dev/null
+++ b/backend/app/models/payout.py
@@ -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())
diff --git a/backend/tests/test_payout_endpoint.py b/backend/tests/test_payout_endpoint.py
new file mode 100644
index 0000000..8175c03
--- /dev/null
+++ b/backend/tests/test_payout_endpoint.py
@@ -0,0 +1,13 @@
+def test_payout_models_importable_and_persist(db):
+ import uuid
+ from app.models.payout import Payout, PayoutItem
+
+ payout = Payout(event_id=uuid.uuid4(), allocation_id=uuid.uuid4(),
+ team_label="Team Satoshi", total_sats=210, status="pending")
+ db.add(payout)
+ db.flush()
+ item = PayoutItem(payout_id=payout.id, participant_id=uuid.uuid4(),
+ lightning_address="ada@getalby.com", amount_sats=105, status="pending")
+ db.add(item)
+ db.commit()
+ assert payout.id is not None and item.payout_id == payout.id
From db7e3471559539b37c958540daf24b55ef1d19aa Mon Sep 17 00:00:00 2001
From: comwanga
Date: Wed, 17 Jun 2026 23:29:12 +0300
Subject: [PATCH 05/16] feat(payout): migration for lightning_address + payout
tables
---
.../alembic/versions/0007_lightning_payout.py | 64 +++++++++++++++++++
1 file changed, 64 insertions(+)
create mode 100644 backend/alembic/versions/0007_lightning_payout.py
diff --git a/backend/alembic/versions/0007_lightning_payout.py b/backend/alembic/versions/0007_lightning_payout.py
new file mode 100644
index 0000000..233015a
--- /dev/null
+++ b/backend/alembic/versions/0007_lightning_payout.py
@@ -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")
From a8b92b421e41683c8531f8cddaf51f1f4611e27e Mon Sep 17 00:00:00 2001
From: comwanga
Date: Wed, 17 Jun 2026 23:31:30 +0300
Subject: [PATCH 06/16] feat(payout): deterministic even-split with remainder
rule
---
backend/app/services/payout_service.py | 24 +++++++++++++++++++++++
backend/tests/test_split.py | 27 ++++++++++++++++++++++++++
2 files changed, 51 insertions(+)
create mode 100644 backend/app/services/payout_service.py
create mode 100644 backend/tests/test_split.py
diff --git a/backend/app/services/payout_service.py b/backend/app/services/payout_service.py
new file mode 100644
index 0000000..967ac62
--- /dev/null
+++ b/backend/app/services/payout_service.py
@@ -0,0 +1,24 @@
+"""Lightning payout: deterministic split + orchestration.
+
+`compute_split` is pure and reproducible: an integer even split with the
+remainder assigned to the earliest members (by the order they are passed in).
+The orchestration functions are added in a later task.
+"""
+from typing import Sequence, TypeVar
+
+T = TypeVar("T")
+
+
+def compute_split(recipients: Sequence[T], total_sats: int) -> list[tuple[T, int]]:
+ """Split `total_sats` evenly across `recipients`, remainder to the first members.
+
+ Raises ValueError if there are no recipients or fewer sats than recipients
+ (every member must receive at least 1 sat).
+ """
+ n = len(recipients)
+ if n == 0:
+ raise ValueError("no recipients")
+ if total_sats < n:
+ raise ValueError("total_sats must be at least one sat per recipient")
+ base, remainder = divmod(total_sats, n)
+ return [(r, base + (1 if i < remainder else 0)) for i, r in enumerate(recipients)]
diff --git a/backend/tests/test_split.py b/backend/tests/test_split.py
new file mode 100644
index 0000000..b78f6c2
--- /dev/null
+++ b/backend/tests/test_split.py
@@ -0,0 +1,27 @@
+import pytest
+from app.services.payout_service import compute_split
+
+
+def test_even_split_no_remainder():
+ # 300 sats over 3 members -> 100 each, order preserved
+ assert compute_split(["a", "b", "c"], 300) == [("a", 100), ("b", 100), ("c", 100)]
+
+
+def test_remainder_goes_to_earliest_members():
+ # 100 sats over 3 -> 34, 33, 33 (remainder 1 to the first member)
+ assert compute_split(["a", "b", "c"], 100) == [("a", 34), ("b", 33), ("c", 33)]
+
+
+def test_single_member_gets_everything():
+ assert compute_split(["a"], 210) == [("a", 210)]
+
+
+def test_zero_members_raises():
+ with pytest.raises(ValueError):
+ compute_split([], 100)
+
+
+def test_total_below_member_count_raises():
+ # cannot give every member at least 1 sat
+ with pytest.raises(ValueError):
+ compute_split(["a", "b", "c"], 2)
From b2b4dce38beb3d892c35f786ddad29931783624e Mon Sep 17 00:00:00 2001
From: comwanga
Date: Wed, 17 Jun 2026 23:34:14 +0300
Subject: [PATCH 07/16] feat(payout): LNURL-pay client (resolve + invoice)
---
backend/app/services/lnurl_service.py | 60 +++++++++++++++++++++++++++
backend/tests/test_lnurl_service.py | 45 ++++++++++++++++++++
2 files changed, 105 insertions(+)
create mode 100644 backend/app/services/lnurl_service.py
create mode 100644 backend/tests/test_lnurl_service.py
diff --git a/backend/app/services/lnurl_service.py b/backend/app/services/lnurl_service.py
new file mode 100644
index 0000000..e82fd91
--- /dev/null
+++ b/backend/app/services/lnurl_service.py
@@ -0,0 +1,60 @@
+"""LNURL-pay client: resolve a `name@domain` Lightning address to a bolt11 invoice.
+
+Two steps per LNURL spec (LUD-06/LUD-16):
+ 1. GET https://{domain}/.well-known/lnurlp/{name} -> {callback, minSendable, maxSendable, ...}
+ 2. GET {callback}?amount={msat} -> {pr: }
+
+All errors raise LnurlError; the caller marks that payout item failed and continues.
+"""
+import httpx
+
+_TIMEOUT = 10.0
+
+
+class LnurlError(Exception):
+ """Any failure resolving an address or fetching an invoice."""
+
+
+def lud16_to_url(address: str) -> str:
+ address = address.strip().lower()
+ if address.count("@") != 1:
+ raise LnurlError(f"malformed lightning address: {address!r}")
+ name, domain = address.split("@")
+ if not name or not domain:
+ raise LnurlError(f"malformed lightning address: {address!r}")
+ return f"https://{domain}/.well-known/lnurlp/{name}"
+
+
+def resolve_lnurl(address: str) -> dict:
+ """Return the LNURL-pay params dict for a `name@domain` address."""
+ url = lud16_to_url(address)
+ try:
+ resp = httpx.get(url, timeout=_TIMEOUT, follow_redirects=True)
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as exc: # noqa: BLE001 — normalize to LnurlError
+ raise LnurlError(f"failed to resolve {address}: {exc}") from exc
+ if "callback" not in data or "minSendable" not in data or "maxSendable" not in data:
+ raise LnurlError(f"invalid LNURL-pay response for {address}")
+ return data
+
+
+def request_invoice(params: dict, amount_sats: int) -> str:
+ """Request a bolt11 invoice for `amount_sats` from a resolved LNURL params dict."""
+ amount_msat = amount_sats * 1000
+ if amount_msat < params["minSendable"] or amount_msat > params["maxSendable"]:
+ raise LnurlError(
+ f"amount {amount_sats} sat outside payable range "
+ f"[{params['minSendable'] // 1000}, {params['maxSendable'] // 1000}] sat"
+ )
+ try:
+ resp = httpx.get(params["callback"], params={"amount": amount_msat},
+ timeout=_TIMEOUT, follow_redirects=True)
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as exc: # noqa: BLE001
+ raise LnurlError(f"invoice request failed: {exc}") from exc
+ pr = data.get("pr")
+ if not pr:
+ raise LnurlError("LNURL callback returned no invoice")
+ return pr
diff --git a/backend/tests/test_lnurl_service.py b/backend/tests/test_lnurl_service.py
new file mode 100644
index 0000000..f777658
--- /dev/null
+++ b/backend/tests/test_lnurl_service.py
@@ -0,0 +1,45 @@
+import pytest
+from app.services import lnurl_service
+from app.services.lnurl_service import LnurlError, lud16_to_url
+
+
+def test_lud16_to_url():
+ assert lud16_to_url("ada@getalby.com") == "https://getalby.com/.well-known/lnurlp/ada"
+
+
+def test_lud16_to_url_rejects_malformed():
+ with pytest.raises(LnurlError):
+ lud16_to_url("not-an-address")
+
+
+def test_request_invoice_amount_below_min_raises(monkeypatch):
+ params = {"callback": "https://getalby.com/lnurlp/ada/callback",
+ "minSendable": 100_000, "maxSendable": 1_000_000} # 100..1000 sat
+
+ def fake_get(url, **kwargs):
+ raise AssertionError("callback should not be hit when amount is out of bounds")
+
+ monkeypatch.setattr(lnurl_service.httpx, "get", fake_get)
+ with pytest.raises(LnurlError):
+ lnurl_service.request_invoice(params, amount_sats=50) # 50 sat < 100 sat min
+
+
+def test_request_invoice_returns_bolt11(monkeypatch):
+ params = {"callback": "https://getalby.com/lnurlp/ada/callback",
+ "minSendable": 1_000, "maxSendable": 1_000_000}
+
+ class FakeResp:
+ def raise_for_status(self): pass
+ def json(self): return {"pr": "lnbc100n1fakeinvoice"}
+
+ captured = {}
+
+ def fake_get(url, params=None, **kwargs):
+ captured["url"] = url
+ captured["params"] = params
+ return FakeResp()
+
+ monkeypatch.setattr(lnurl_service.httpx, "get", fake_get)
+ bolt11 = lnurl_service.request_invoice(params, amount_sats=100)
+ assert bolt11 == "lnbc100n1fakeinvoice"
+ assert captured["params"]["amount"] == 100_000 # msat
From 7fdc4a8423f20510f383eb0e48a1009e40a097c4 Mon Sep 17 00:00:00 2001
From: comwanga
Date: Wed, 17 Jun 2026 23:42:07 +0300
Subject: [PATCH 08/16] feat(payout): NWC (NIP-47) pay_invoice client
---
backend/app/services/nwc_service.py | 129 ++++++++++++++++++++++++++++
backend/tests/test_nwc_service.py | 63 ++++++++++++++
2 files changed, 192 insertions(+)
create mode 100644 backend/app/services/nwc_service.py
create mode 100644 backend/tests/test_nwc_service.py
diff --git a/backend/app/services/nwc_service.py b/backend/app/services/nwc_service.py
new file mode 100644
index 0000000..a949d20
--- /dev/null
+++ b/backend/app/services/nwc_service.py
@@ -0,0 +1,129 @@
+"""Nostr Wallet Connect (NIP-47) client — pay_invoice only.
+
+Reuses nostr_service's NIP-04 encryption. The NWC URI carries the client's
+secret key and the wallet service pubkey + relay:
+ nostr+walletconnect://?relay=&secret=
+
+Flow over one short-lived websocket: subscribe (REQ) for the wallet's kind-23195
+response tagged with our request id, publish the kind-23194 request, read frames
+until the response arrives or we time out, decrypt it, return the preimage.
+
+The NWC secret is a spend credential: it is used transiently and never persisted.
+"""
+import hashlib
+import json
+import logging
+import time
+from dataclasses import dataclass
+from urllib.parse import urlparse, parse_qs
+
+from coincurve import PrivateKey
+
+from app.services.nostr_service import encrypt_nip04, decrypt_nip04
+
+logger = logging.getLogger(__name__)
+
+_REQUEST_KIND = 23194
+_RESPONSE_KIND = 23195
+_TIMEOUT = 30.0
+
+
+class NwcError(Exception):
+ """Any NWC failure: bad URI, wallet error response, or relay timeout."""
+
+
+@dataclass
+class NwcConnection:
+ wallet_pubkey_hex: str # x-only hex
+ relay: str
+ secret_bytes: bytes
+
+
+def parse_nwc_uri(uri: str) -> NwcConnection:
+ uri = uri.strip()
+ if not uri.startswith("nostr+walletconnect://"):
+ raise NwcError("not a nostr+walletconnect URI")
+ parsed = urlparse(uri)
+ wallet_pubkey = parsed.netloc or parsed.path.lstrip("/")
+ qs = parse_qs(parsed.query)
+ relay = (qs.get("relay") or [None])[0]
+ secret_hex = (qs.get("secret") or [None])[0]
+ if not wallet_pubkey or not relay or not secret_hex:
+ raise NwcError("NWC URI missing wallet pubkey, relay, or secret")
+ try:
+ secret_bytes = bytes.fromhex(secret_hex)
+ except ValueError as exc:
+ raise NwcError("NWC secret is not valid hex") from exc
+ return NwcConnection(wallet_pubkey_hex=wallet_pubkey.lower(), relay=relay, secret_bytes=secret_bytes)
+
+
+def build_pay_invoice_request(secret_bytes: bytes, wallet_xonly: bytes, bolt11: str) -> dict:
+ """Build a signed, NIP-04-encrypted kind-23194 pay_invoice request event."""
+ privkey = PrivateKey(secret_bytes)
+ pubkey_hex = privkey.public_key_xonly.format().hex()
+ created_at = int(time.time())
+ body = json.dumps({"method": "pay_invoice", "params": {"invoice": bolt11}},
+ separators=(",", ":"))
+ content = encrypt_nip04(secret_bytes, wallet_xonly, body)
+ tags = [["p", wallet_xonly.hex()]]
+ serialized = json.dumps(
+ [0, pubkey_hex, created_at, _REQUEST_KIND, tags, content],
+ separators=(",", ":"), ensure_ascii=False,
+ )
+ event_id = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+ sig = privkey.sign_schnorr(bytes.fromhex(event_id)).hex()
+ return {"id": event_id, "pubkey": pubkey_hex, "created_at": created_at,
+ "kind": _REQUEST_KIND, "tags": tags, "content": content, "sig": sig}
+
+
+def decode_response(secret_bytes: bytes, wallet_xonly: bytes, content: str) -> dict:
+ """Decrypt a kind-23195 response. Return {'preimage': ...} or raise NwcError."""
+ plaintext = decrypt_nip04(secret_bytes, wallet_xonly, content)
+ data = json.loads(plaintext)
+ if data.get("error"):
+ raise NwcError(data["error"].get("message", "wallet returned an error"))
+ result = data.get("result") or {}
+ preimage = result.get("preimage")
+ if not preimage:
+ raise NwcError("wallet response had no preimage")
+ return {"preimage": preimage}
+
+
+def _round_trip(conn: NwcConnection, request_event: dict) -> str:
+ """Publish the request and read the wallet's response content. Monkeypatched in tests.
+
+ Subscribe BEFORE publishing so we cannot miss a fast response. Returns the
+ raw (still-encrypted) response event content; raises NwcError on timeout.
+ """
+ from websockets.sync.client import connect
+
+ sub_id = request_event["id"][:16]
+ req = json.dumps(["REQ", sub_id, {
+ "kinds": [_RESPONSE_KIND],
+ "authors": [conn.wallet_pubkey_hex],
+ "#e": [request_event["id"]],
+ }])
+ event_msg = json.dumps(["EVENT", request_event])
+ deadline = time.time() + _TIMEOUT
+ try:
+ with connect(conn.relay, open_timeout=10, close_timeout=5) as ws:
+ ws.send(req)
+ ws.send(event_msg)
+ while time.time() < deadline:
+ frame = json.loads(ws.recv(timeout=max(1.0, deadline - time.time())))
+ if frame[0] == "EVENT" and frame[1] == sub_id:
+ return frame[2]["content"]
+ except NwcError:
+ raise
+ except Exception as exc: # noqa: BLE001
+ raise NwcError(f"NWC relay round-trip failed: {exc}") from exc
+ raise NwcError("timed out waiting for wallet response")
+
+
+def pay_invoice(uri: str, bolt11: str) -> str:
+ """Pay `bolt11` via the wallet in `uri`. Return the payment preimage or raise NwcError."""
+ conn = parse_nwc_uri(uri)
+ wallet_xonly = bytes.fromhex(conn.wallet_pubkey_hex)
+ request_event = build_pay_invoice_request(conn.secret_bytes, wallet_xonly, bolt11)
+ content = _round_trip(conn, request_event)
+ return decode_response(conn.secret_bytes, wallet_xonly, content)["preimage"]
diff --git a/backend/tests/test_nwc_service.py b/backend/tests/test_nwc_service.py
new file mode 100644
index 0000000..48654cd
--- /dev/null
+++ b/backend/tests/test_nwc_service.py
@@ -0,0 +1,63 @@
+import json
+import pytest
+from coincurve import PrivateKey
+
+from app.services import nwc_service
+from app.services.nwc_service import NwcError, parse_nwc_uri, build_pay_invoice_request, decode_response
+from app.services.nostr_service import encrypt_nip04
+
+
+def _make_uri(secret_hex: str, wallet_pub_hex: str, relay="wss://relay.example") -> str:
+ return f"nostr+walletconnect://{wallet_pub_hex}?relay={relay}&secret={secret_hex}"
+
+
+def test_parse_nwc_uri():
+ secret = PrivateKey()
+ wallet = PrivateKey()
+ wallet_xonly = wallet.public_key_xonly.format().hex()
+ uri = _make_uri(secret.to_hex(), wallet_xonly)
+ parsed = parse_nwc_uri(uri)
+ assert parsed.wallet_pubkey_hex == wallet_xonly
+ assert parsed.relay == "wss://relay.example"
+ assert parsed.secret_bytes == secret.secret
+
+
+def test_parse_nwc_uri_rejects_garbage():
+ with pytest.raises(NwcError):
+ parse_nwc_uri("https://not-nwc")
+
+
+def test_build_pay_invoice_request_is_signed_and_encrypted():
+ secret = PrivateKey()
+ wallet = PrivateKey()
+ wallet_xonly = wallet.public_key_xonly.format().hex()
+ event = build_pay_invoice_request(secret.secret, bytes.fromhex(wallet_xonly), "lnbc1fake")
+ assert event["kind"] == 23194
+ assert ["p", wallet_xonly] in event["tags"]
+ # wallet decrypts the request with its privkey + our x-only pubkey
+ our_xonly = secret.public_key_xonly.format()
+ from app.services.nostr_service import decrypt_nip04
+ body = json.loads(decrypt_nip04(wallet.secret, our_xonly, event["content"]))
+ assert body["method"] == "pay_invoice"
+ assert body["params"]["invoice"] == "lnbc1fake"
+
+
+def test_decode_response_success():
+ secret = PrivateKey()
+ wallet = PrivateKey()
+ our_xonly = secret.public_key_xonly.format()
+ payload = json.dumps({"result_type": "pay_invoice", "result": {"preimage": "deadbeef"}})
+ content = encrypt_nip04(wallet.secret, our_xonly, payload)
+ result = decode_response(secret.secret, wallet.public_key_xonly.format(), content)
+ assert result == {"preimage": "deadbeef"}
+
+
+def test_decode_response_error_raises():
+ secret = PrivateKey()
+ wallet = PrivateKey()
+ our_xonly = secret.public_key_xonly.format()
+ payload = json.dumps({"error": {"code": "INSUFFICIENT_BALANCE", "message": "no funds"}})
+ content = encrypt_nip04(wallet.secret, our_xonly, payload)
+ with pytest.raises(NwcError) as exc:
+ decode_response(secret.secret, wallet.public_key_xonly.format(), content)
+ assert "no funds" in str(exc.value)
From b6bfcf2e792b3463c0d651c62803a6498940af81 Mon Sep 17 00:00:00 2001
From: comwanga
Date: Wed, 17 Jun 2026 23:49:51 +0300
Subject: [PATCH 09/16] feat(payout): payout orchestration + organizer
endpoints
---
backend/app/api/v1/payouts.py | 66 ++++++++++++++++++
backend/app/main.py | 3 +-
backend/app/schemas/payout.py | 40 +++++++++++
backend/app/services/payout_service.py | 93 +++++++++++++++++++++++++-
backend/tests/test_payout_endpoint.py | 79 ++++++++++++++++++++++
5 files changed, 279 insertions(+), 2 deletions(-)
create mode 100644 backend/app/api/v1/payouts.py
create mode 100644 backend/app/schemas/payout.py
diff --git a/backend/app/api/v1/payouts.py b/backend/app/api/v1/payouts.py
new file mode 100644
index 0000000..d457b49
--- /dev/null
+++ b/backend/app/api/v1/payouts.py
@@ -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)
diff --git a/backend/app/main.py b/backend/app/main.py
index 0862afc..a1ae08a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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
@@ -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")
diff --git a/backend/app/schemas/payout.py b/backend/app/schemas/payout.py
new file mode 100644
index 0000000..0809435
--- /dev/null
+++ b/backend/app/schemas/payout.py
@@ -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}
diff --git a/backend/app/services/payout_service.py b/backend/app/services/payout_service.py
index 967ac62..025a3d2 100644
--- a/backend/app/services/payout_service.py
+++ b/backend/app/services/payout_service.py
@@ -2,9 +2,19 @@
`compute_split` is pure and reproducible: an integer even split with the
remainder assigned to the earliest members (by the order they are passed in).
-The orchestration functions are added in a later task.
"""
+import logging
from typing import Sequence, TypeVar
+from uuid import UUID
+
+from sqlalchemy.orm import Session
+
+from app.models.payout import Payout, PayoutItem
+from app.models.participant import Participant
+from app.models.team import Team, TeamMember
+from app.services import lnurl_service, nwc_service
+
+logger = logging.getLogger(__name__)
T = TypeVar("T")
@@ -22,3 +32,84 @@ def compute_split(recipients: Sequence[T], total_sats: int) -> list[tuple[T, int
raise ValueError("total_sats must be at least one sat per recipient")
base, remainder = divmod(total_sats, n)
return [(r, base + (1 if i < remainder else 0)) for i, r in enumerate(recipients)]
+
+
+def _team_members(db: Session, team_id: UUID) -> list[Participant]:
+ """Members of a team, ordered by participant id for a reproducible split."""
+ return (
+ db.query(Participant)
+ .join(TeamMember, Participant.id == TeamMember.participant_id)
+ .filter(TeamMember.team_id == team_id)
+ .order_by(Participant.id)
+ .all()
+ )
+
+
+def preflight(
+ db: Session, team_id: UUID, total_sats: int, overrides: dict[str, str] | None = None,
+) -> list[tuple[Participant, str, int]]:
+ """Resolve each member's address + split.
+
+ `overrides` maps `str(participant_id) -> lightning_address` and lets the organizer
+ supply/correct addresses in the payout modal. The override wins over the
+ registration value. Raise ValueError listing any member who still has no address.
+ Returns (participant, address, amount_sats) tuples.
+ """
+ overrides = overrides or {}
+ members = _team_members(db, team_id)
+ resolved = [(m, overrides.get(str(m.id)) or m.lightning_address) for m in members]
+ missing = [m.name for m, addr in resolved if not addr]
+ if missing:
+ raise ValueError(f"missing lightning address for: {', '.join(missing)}")
+ # compute_split preserves order, so zip the amounts back onto (participant, address).
+ amounts = compute_split([m for m, _ in resolved], total_sats)
+ return [(m, addr, amount) for (m, addr), (_, amount) in zip(resolved, amounts)]
+
+
+def execute_payout(
+ db: Session, payout: Payout, splits: list[tuple[Participant, str, int]], nwc: str,
+) -> Payout:
+ """Pay each member, recording per-item status. Rolls payout.status up at the end."""
+ paid = 0
+ for participant, address, amount_sats in splits:
+ item = PayoutItem(payout_id=payout.id, participant_id=participant.id,
+ lightning_address=address, amount_sats=amount_sats, status="pending")
+ db.add(item)
+ db.flush()
+ try:
+ params = lnurl_service.resolve_lnurl(address)
+ bolt11 = lnurl_service.request_invoice(params, amount_sats)
+ item.bolt11 = bolt11
+ item.preimage = nwc_service.pay_invoice(nwc, bolt11)
+ item.status = "paid"
+ paid += 1
+ except Exception as exc: # noqa: BLE001 — record + continue
+ item.status = "failed"
+ item.error = str(exc)
+ logger.warning("payout item %s failed: %s", item.id, exc)
+ payout.status = "complete" if paid == len(splits) else ("partial" if paid else "failed")
+ db.commit()
+ db.refresh(payout)
+ return payout
+
+
+def retry_failed(db: Session, payout: Payout, nwc: str) -> Payout:
+ """Retry only the failed items of an existing payout."""
+ items = db.query(PayoutItem).filter(PayoutItem.payout_id == payout.id).all()
+ for item in items:
+ if item.status != "failed":
+ continue
+ try:
+ params = lnurl_service.resolve_lnurl(item.lightning_address)
+ bolt11 = lnurl_service.request_invoice(params, item.amount_sats)
+ item.bolt11 = bolt11
+ item.preimage = nwc_service.pay_invoice(nwc, bolt11)
+ item.status, item.error = "paid", None
+ except Exception as exc: # noqa: BLE001
+ item.error = str(exc)
+ logger.warning("payout retry item %s failed: %s", item.id, exc)
+ paid = sum(1 for i in items if i.status == "paid")
+ payout.status = "complete" if paid == len(items) else ("partial" if paid else "failed")
+ db.commit()
+ db.refresh(payout)
+ return payout
diff --git a/backend/tests/test_payout_endpoint.py b/backend/tests/test_payout_endpoint.py
index 8175c03..e915c34 100644
--- a/backend/tests/test_payout_endpoint.py
+++ b/backend/tests/test_payout_endpoint.py
@@ -1,3 +1,82 @@
+def _setup_team(client, auth_headers, all_have_addresses):
+ """Register 4 participants, allocate into 2 teams, return the first team.
+
+ Returns (event_id, allocation_id, team_id, members) where `members` is the
+ list of member dicts (each has 'id' and 'name') on teams[0].
+ """
+ e = client.post("/api/v1/events", headers=auth_headers,
+ json={"title": "BTC++ Payout", "team_count": 2}).json()
+ client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"})
+ strengths = ["technical", "design", "planning", "coordination"]
+ for i in range(4):
+ body = {"name": f"P{i}", "email": f"p{i}@t.com",
+ "primary_strength": strengths[i], "experience_level": "intermediate"}
+ if all_have_addresses:
+ body["lightning_address"] = f"p{i}@getalby.com"
+ r = client.post(f"/api/v1/events/{e['registration_slug']}/register", json=body)
+ assert r.status_code in (200, 201), r.text
+ a = client.post(f"/api/v1/events/{e['id']}/allocate", headers=auth_headers).json()
+ teams = client.get(f"/api/v1/allocations/{a['id']}/teams", headers=auth_headers).json()
+ assert teams and teams[0]["members"], "expected a non-empty first team"
+ return e["id"], a["id"], teams[0]["id"], teams[0]["members"]
+
+
+def _stub_lightning(monkeypatch):
+ """Stub LNURL + NWC so no real network happens. Returns the list of paid bolt11s."""
+ from app.services import lnurl_service, nwc_service
+ monkeypatch.setattr(lnurl_service, "resolve_lnurl",
+ lambda addr: {"callback": "https://x/cb", "minSendable": 1000, "maxSendable": 10_000_000})
+ monkeypatch.setattr(lnurl_service, "request_invoice", lambda params, amount_sats: f"lnbc{amount_sats}fake")
+ paid = []
+ monkeypatch.setattr(nwc_service, "pay_invoice",
+ lambda uri, bolt11: (paid.append(bolt11), "preimage_" + bolt11)[1])
+ return paid
+
+
+def test_payout_pays_team_and_records_results(client, auth_headers, monkeypatch):
+ _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True)
+ paid = _stub_lightning(monkeypatch)
+
+ res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
+ "team_id": str(team_id), "total_sats": 210,
+ "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
+ })
+
+ assert res.status_code == 201, res.text
+ body = res.json()
+ assert body["status"] == "complete"
+ assert len(body["items"]) == len(members)
+ assert sum(i["amount_sats"] for i in body["items"]) == 210 # full pot paid, no sats lost
+ assert all(i["status"] == "paid" and i["preimage"] for i in body["items"])
+ assert len(paid) == len(members)
+
+
+def test_payout_422_when_member_missing_address(client, auth_headers):
+ # No participant has an address, so any team triggers the pre-flight 422.
+ _, allocation_id, team_id, _ = _setup_team(client, auth_headers, all_have_addresses=False)
+ res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
+ "team_id": str(team_id), "total_sats": 210,
+ "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
+ })
+ assert res.status_code == 422
+ assert "missing" in res.text.lower()
+
+
+def test_payout_address_override_fills_missing(client, auth_headers, monkeypatch):
+ _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=False)
+ _stub_lightning(monkeypatch)
+ # Supply an address for every member of teams[0] via the override map.
+ overrides = {m["id"]: f"{m['name']}@getalby.com" for m in members}
+
+ res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
+ "team_id": str(team_id), "total_sats": 210,
+ "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
+ "addresses": overrides,
+ })
+ assert res.status_code == 201, res.text
+ assert res.json()["status"] == "complete"
+
+
def test_payout_models_importable_and_persist(db):
import uuid
from app.models.payout import Payout, PayoutItem
From 9727630f27e82a19ed4b0cb6bb3287719dd506f9 Mon Sep 17 00:00:00 2001
From: comwanga
Date: Wed, 17 Jun 2026 23:53:46 +0300
Subject: [PATCH 10/16] feat(payout): redacted payout summary on public results
---
backend/app/api/v1/public.py | 12 ++++++++++--
backend/app/schemas/allocation.py | 9 +++++++++
backend/tests/test_payout_endpoint.py | 22 ++++++++++++++++++++++
3 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/backend/app/api/v1/public.py b/backend/app/api/v1/public.py
index 53f46fa..2aa80c6 100644
--- a/backend/app/api/v1/public.py
+++ b/backend/app/api/v1/public.py
@@ -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()
@@ -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)
diff --git a/backend/app/schemas/allocation.py b/backend/app/schemas/allocation.py
index 31e34f0..06b97ca 100644
--- a/backend/app/schemas/allocation.py
+++ b/backend/app/schemas/allocation.py
@@ -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):
diff --git a/backend/tests/test_payout_endpoint.py b/backend/tests/test_payout_endpoint.py
index e915c34..9ad5c03 100644
--- a/backend/tests/test_payout_endpoint.py
+++ b/backend/tests/test_payout_endpoint.py
@@ -77,6 +77,28 @@ def test_payout_address_override_fills_missing(client, auth_headers, monkeypatch
assert res.json()["status"] == "complete"
+def test_public_results_include_payout_summary(client, auth_headers, monkeypatch):
+ event_id, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True)
+ _stub_lightning(monkeypatch)
+ client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
+ "team_id": str(team_id), "total_sats": 210,
+ "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
+ })
+ # Public results are published-only.
+ client.post(f"/api/v1/events/{event_id}/allocations/{allocation_id}/publish", headers=auth_headers)
+
+ res = client.get(f"/api/v1/public/allocations/{allocation_id}")
+ assert res.status_code == 200, res.text
+ summary = res.json()["payouts"]
+ assert summary[0]["team_label"]
+ assert summary[0]["total_sats"] == 210
+ assert summary[0]["paid_count"] == len(members)
+ assert summary[0]["member_count"] == len(members)
+ # never leak the credential or invoice/preimage secrets
+ for leaked in ("nwc", "preimage", "bolt11", "lightning_address"):
+ assert leaked not in res.text.lower()
+
+
def test_payout_models_importable_and_persist(db):
import uuid
from app.models.payout import Payout, PayoutItem
From 884c473fb1749041f00f8d24cc403736e7399b03 Mon Sep 17 00:00:00 2001
From: comwanga
Date: Thu, 18 Jun 2026 00:01:09 +0300
Subject: [PATCH 11/16] feat(payout): lightning address field at registration,
prefilled from Nostr profile
---
.../registration/registration-form.tsx | 60 ++++++++++++++++++-
1 file changed, 58 insertions(+), 2 deletions(-)
diff --git a/frontend/components/registration/registration-form.tsx b/frontend/components/registration/registration-form.tsx
index 6bf45c1..571a254 100644
--- a/frontend/components/registration/registration-form.tsx
+++ b/frontend/components/registration/registration-form.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -27,6 +27,7 @@ const schema = z.object({
strength_other: z.string().optional(),
experience_level: z.enum(["beginner", "intermediate", "advanced"]),
npub: z.string().optional(),
+ lightning_address: z.string().optional(),
}).refine(
d => d.primary_strength !== "other" || (d.strength_other?.trim().length ?? 0) > 0,
{ message: "Please describe your strength", path: ["strength_other"] },
@@ -44,13 +45,60 @@ interface EventInfo {
export function RegistrationForm({ event, slug }: { event: EventInfo; slug: string }) {
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
- const { register, handleSubmit, control, watch, formState: { errors } } = useForm({
+ const { register, handleSubmit, control, watch, setValue, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { primary_strength: "technical", experience_level: "intermediate" },
});
const selectedStrength = watch("primary_strength");
+ useEffect(() => {
+ const prefillLightningAddress = async () => {
+ try {
+ if (typeof window === "undefined") return;
+
+ // Guard: don't overwrite a value the user already typed
+ // (read current value from the DOM; setValue hasn't run yet at mount)
+ let pk: string | null = null;
+
+ if ("nostr" in window) {
+ pk = await (window as Window & { nostr: { getPublicKey(): Promise } }).nostr.getPublicKey();
+ } else {
+ const skHex = localStorage.getItem("squadsync:nostr_sk");
+ if (!skHex) return;
+ const hexToBytes = (hex: string): Uint8Array =>
+ new Uint8Array(hex.match(/.{2}/g)!.map((b) => parseInt(b, 16)));
+ const { getPublicKey } = await import("nostr-tools");
+ pk = getPublicKey(hexToBytes(skHex));
+ }
+
+ if (!pk) return;
+
+ const { SimplePool } = await import("nostr-tools/pool");
+ const pool = new SimplePool();
+ const relays = ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band"];
+ const evt = await pool.get(relays, { kinds: [0], authors: [pk] });
+ pool.close(relays);
+
+ if (evt) {
+ const meta = JSON.parse(evt.content);
+ const ln = meta.lud16 || meta.lightning_address;
+ if (ln) {
+ // Only prefill if the field is still empty
+ const current = (document.getElementById("lightning_address") as HTMLInputElement | null)?.value;
+ if (!current) {
+ setValue("lightning_address", String(ln));
+ }
+ }
+ }
+ } catch {
+ // Best-effort: silently no-op on any failure
+ }
+ };
+
+ prefillLightningAddress();
+ }, [setValue]);
+
const onSubmit = async (data: FormData) => {
setLoading(true);
try {
@@ -102,6 +150,14 @@ export function RegistrationForm({ event, slug }: { event: EventInfo; slug: stri
+
+
+
+
+ Where to send your share if your team wins a Bitcoin prize. Auto-filled from your Nostr profile when available.
+
+
+
Date: Thu, 18 Jun 2026 00:06:14 +0300
Subject: [PATCH 12/16] feat(payout): organizer payout modal with live NWC
payment status
---
frontend/components/engine/payout-modal.tsx | 236 ++++++++++++++++++++
frontend/components/engine/results-grid.tsx | 14 +-
frontend/components/engine/team-card.tsx | 21 +-
frontend/hooks/use-allocation.ts | 32 +++
4 files changed, 298 insertions(+), 5 deletions(-)
create mode 100644 frontend/components/engine/payout-modal.tsx
diff --git a/frontend/components/engine/payout-modal.tsx b/frontend/components/engine/payout-modal.tsx
new file mode 100644
index 0000000..ca069e5
--- /dev/null
+++ b/frontend/components/engine/payout-modal.tsx
@@ -0,0 +1,236 @@
+"use client";
+
+import { useState } from "react";
+import { useSession } from "next-auth/react";
+import { toast } from "sonner";
+import { Zap, CheckCircle2, XCircle } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ApiError } from "@/lib/api";
+import {
+ createPayout,
+ retryPayout,
+ type Payout,
+ type Team,
+} from "@/hooks/use-allocation";
+
+interface PayoutModalProps {
+ team: Team;
+ allocationId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutModalProps) {
+ const { data: session } = useSession();
+ const [totalSats, setTotalSats] = useState(2100);
+ const [nwc, setNwc] = useState("");
+ const [addresses, setAddresses] = useState>({});
+ const [sending, setSending] = useState(false);
+ const [payout, setPayout] = useState(null);
+
+ const n = team.members.length;
+ const base = n > 0 ? Math.floor(totalSats / n) : 0;
+ const rem = n > 0 ? totalSats % n : 0;
+
+ const handleOpenChange = (o: boolean) => {
+ if (!o) {
+ setNwc("");
+ }
+ onOpenChange(o);
+ };
+
+ const handleSend = async () => {
+ if (!session?.accessToken) return;
+ setSending(true);
+ try {
+ const nonEmptyAddresses: Record = {};
+ for (const [id, addr] of Object.entries(addresses)) {
+ if (addr.trim()) nonEmptyAddresses[id] = addr.trim();
+ }
+ const result = await createPayout(session.accessToken, allocationId, {
+ team_id: team.id,
+ total_sats: totalSats,
+ nwc,
+ addresses: Object.keys(nonEmptyAddresses).length > 0 ? nonEmptyAddresses : undefined,
+ });
+ setPayout(result);
+ toast.success("Payout sent!");
+ } catch (err: unknown) {
+ if (err instanceof ApiError && err.status === 422) {
+ toast.error(err.message);
+ } else {
+ toast.error(err instanceof Error ? err.message : "Payout failed");
+ }
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const handleRetry = async () => {
+ if (!session?.accessToken || !payout) return;
+ setSending(true);
+ try {
+ const result = await retryPayout(session.accessToken, payout.id, nwc);
+ setPayout(result);
+ toast.success("Retry complete");
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : "Retry failed");
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const canSend = !sending && !!nwc && totalSats >= n;
+ const hasFailedItems = payout?.items.some(item => item.status === "failed");
+
+ return (
+
+ );
+}
diff --git a/frontend/components/engine/results-grid.tsx b/frontend/components/engine/results-grid.tsx
index 42fec54..a979fe0 100644
--- a/frontend/components/engine/results-grid.tsx
+++ b/frontend/components/engine/results-grid.tsx
@@ -5,9 +5,10 @@ import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { AlertTriangle, Download, Link2, CheckCircle2, RefreshCw } from "lucide-react";
import { TeamCard } from "./team-card";
+import { PayoutModal } from "./payout-modal";
import { publishAllocation, moveMember, regenerateAllocation } from "@/hooks/use-allocation";
import { Button } from "@/components/ui/button";
-import type { Allocation } from "@/hooks/use-allocation";
+import type { Allocation, Team } from "@/hooks/use-allocation";
import { normalizationNote } from "@/lib/allocation-notes";
interface ResultsGridProps {
@@ -21,6 +22,7 @@ export function ResultsGrid({ allocation, eventId, onPublished, onChanged }: Res
const { data: session } = useSession();
const [publishing, setPublishing] = useState(false);
const [working, setWorking] = useState(false); // a move or regenerate is in flight
+ const [payoutTeam, setPayoutTeam] = useState(null);
const warningEntries = Object.entries(allocation.constraint_warnings);
const note = normalizationNote(allocation.ai_normalized, allocation.auto_normalized);
const isDraft = allocation.status === "draft";
@@ -126,10 +128,20 @@ export function ResultsGrid({ allocation, eventId, onPublished, onChanged }: Res
otherTeams={isDraft ? allocation.teams.filter(t => t.id !== team.id).map(t => ({ id: t.id, name: t.name })) : undefined}
onMove={isDraft ? handleMove : undefined}
moving={working}
+ onPayout={allocation.status === "published" ? () => setPayoutTeam(team) : undefined}
/>
))}
+ {payoutTeam && (
+ { if (!o) setPayoutTeam(null); }}
+ />
+ )}
+
{isDraft && (
);
From f7ca58217e2a7354e719c1cfaaf8ff6473da32c2 Mon Sep 17 00:00:00 2001
From: comwanga
Date: Thu, 18 Jun 2026 00:14:48 +0300
Subject: [PATCH 14/16] docs(plan): refine payout test helpers for
team_count>=2 + address override
---
.../2026-06-17-lightning-prize-splitting.md | 105 ++++++++----------
1 file changed, 46 insertions(+), 59 deletions(-)
diff --git a/docs/superpowers/plans/2026-06-17-lightning-prize-splitting.md b/docs/superpowers/plans/2026-06-17-lightning-prize-splitting.md
index 579d24c..6180b59 100644
--- a/docs/superpowers/plans/2026-06-17-lightning-prize-splitting.md
+++ b/docs/superpowers/plans/2026-06-17-lightning-prize-splitting.md
@@ -54,7 +54,7 @@ Add to `backend/tests/test_registration.py` (routes confirmed against `tests/tes
```python
def test_register_accepts_lightning_address(client, auth_headers):
e = client.post("/api/v1/events", headers=auth_headers,
- json={"title": "BTC++ Demo", "team_count": 1}).json()
+ json={"title": "BTC++ Demo", "team_count": 2}).json() # team_count has ge=2
client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"})
res = client.post(f"/api/v1/events/{e['registration_slug']}/register", json={
"name": "Ada", "email": "ada@example.com",
@@ -790,47 +790,53 @@ git commit -m "feat(payout): NWC (NIP-47) pay_invoice client"
- [ ] **Step 1: Write the failing endpoint test**
-Add to `backend/tests/test_payout_endpoint.py`. The helper below uses the routes confirmed in
-`tests/test_find_my_team.py` and `app/api/v1/allocation.py`: create event with `team_count=1` (so
-both registrants land on one team) → activate → register → allocate → read the single team.
+Add to `backend/tests/test_payout_endpoint.py`. The helper uses routes confirmed in
+`tests/test_find_my_team.py` and `app/api/v1/allocation.py`. IMPORTANT constraints learned from
+Task 1: the event schema requires `team_count >= 2`, and the allocation engine assigns members
+with a random seed — so we do NOT assume *which* participants land on a team. We register 4
+participants with `team_count=2` (the engine balances to ~2 per team) and drive every assertion
+off the **actual** members of `teams[0]` returned by the API. `all_have_addresses` toggles whether
+registrations include a Lightning address.
```python
-def _setup_team(client, auth_headers, members):
- """members: list of (name, email, lightning_address|None).
- Returns (event_id, allocation_id, team_id)."""
+def _setup_team(client, auth_headers, all_have_addresses):
+ """Register 4 participants, allocate into 2 teams, return the first team.
+
+ Returns (event_id, allocation_id, team_id, members) where `members` is the
+ list of member dicts (each has 'id' and 'name') on teams[0].
+ """
e = client.post("/api/v1/events", headers=auth_headers,
- json={"title": "BTC++ Payout", "team_count": 1}).json()
+ json={"title": "BTC++ Payout", "team_count": 2}).json()
client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"})
strengths = ["technical", "design", "planning", "coordination"]
- for i, (name, email, ln) in enumerate(members):
- body = {"name": name, "email": email,
- "primary_strength": strengths[i % len(strengths)],
- "experience_level": "intermediate"}
- if ln:
- body["lightning_address"] = ln
+ for i in range(4):
+ body = {"name": f"P{i}", "email": f"p{i}@t.com",
+ "primary_strength": strengths[i], "experience_level": "intermediate"}
+ if all_have_addresses:
+ body["lightning_address"] = f"p{i}@getalby.com"
r = client.post(f"/api/v1/events/{e['registration_slug']}/register", json=body)
assert r.status_code in (200, 201), r.text
a = client.post(f"/api/v1/events/{e['id']}/allocate", headers=auth_headers).json()
teams = client.get(f"/api/v1/allocations/{a['id']}/teams", headers=auth_headers).json()
- assert teams, "expected at least one team"
- return e["id"], a["id"], teams[0]["id"]
+ assert teams and teams[0]["members"], "expected a non-empty first team"
+ return e["id"], a["id"], teams[0]["id"], teams[0]["members"]
-def test_payout_pays_team_and_records_results(client, auth_headers, monkeypatch):
+def _stub_lightning(monkeypatch):
+ """Stub LNURL + NWC so no real network happens. Returns the list of paid bolt11s."""
from app.services import lnurl_service, nwc_service
-
- _, allocation_id, team_id = _setup_team(client, auth_headers, [
- ("Ada", "ada@t.com", "ada@getalby.com"),
- ("Linus", "linus@t.com", "linus@getalby.com"),
- ])
-
- # Stub the network: every address resolves, every invoice is fake, every pay returns a preimage.
monkeypatch.setattr(lnurl_service, "resolve_lnurl",
lambda addr: {"callback": "https://x/cb", "minSendable": 1000, "maxSendable": 10_000_000})
monkeypatch.setattr(lnurl_service, "request_invoice", lambda params, amount_sats: f"lnbc{amount_sats}fake")
paid = []
monkeypatch.setattr(nwc_service, "pay_invoice",
lambda uri, bolt11: (paid.append(bolt11), "preimage_" + bolt11)[1])
+ return paid
+
+
+def test_payout_pays_team_and_records_results(client, auth_headers, monkeypatch):
+ _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True)
+ paid = _stub_lightning(monkeypatch)
res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
"team_id": str(team_id), "total_sats": 210,
@@ -840,17 +846,15 @@ def test_payout_pays_team_and_records_results(client, auth_headers, monkeypatch)
assert res.status_code == 201, res.text
body = res.json()
assert body["status"] == "complete"
- assert len(body["items"]) == 2
- assert sum(i["amount_sats"] for i in body["items"]) == 210
+ assert len(body["items"]) == len(members)
+ assert sum(i["amount_sats"] for i in body["items"]) == 210 # full pot paid, no sats lost
assert all(i["status"] == "paid" and i["preimage"] for i in body["items"])
- assert len(paid) == 2
+ assert len(paid) == len(members)
def test_payout_422_when_member_missing_address(client, auth_headers):
- _, allocation_id, team_id = _setup_team(client, auth_headers, [
- ("Ada", "ada@t.com", "ada@getalby.com"),
- ("NoAddr", "noaddr@t.com", None),
- ])
+ # No participant has an address, so any team triggers the pre-flight 422.
+ _, allocation_id, team_id, _ = _setup_team(client, auth_headers, all_have_addresses=False)
res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
"team_id": str(team_id), "total_sats": 210,
"nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
@@ -860,33 +864,21 @@ def test_payout_422_when_member_missing_address(client, auth_headers):
def test_payout_address_override_fills_missing(client, auth_headers, monkeypatch):
- from app.services import lnurl_service, nwc_service
- _, allocation_id, team_id = _setup_team(client, auth_headers, [
- ("Ada", "ada@t.com", "ada@getalby.com"),
- ("NoAddr", "noaddr@t.com", None),
- ])
- # Find the participant id of the member with no address (organizer team view).
- teams = client.get(f"/api/v1/allocations/{allocation_id}/teams", headers=auth_headers).json()
- members = teams[0]["members"]
- no_addr_id = next(m["id"] for m in members if m["name"] == "NoAddr")
-
- monkeypatch.setattr(lnurl_service, "resolve_lnurl",
- lambda addr: {"callback": "https://x/cb", "minSendable": 1000, "maxSendable": 10_000_000})
- monkeypatch.setattr(lnurl_service, "request_invoice", lambda params, amount_sats: "lnbcfake")
- monkeypatch.setattr(nwc_service, "pay_invoice", lambda uri, bolt11: "preimage")
+ _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=False)
+ _stub_lightning(monkeypatch)
+ # Supply an address for every member of teams[0] via the override map.
+ overrides = {m["id"]: f"{m['name']}@getalby.com" for m in members}
res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
"team_id": str(team_id), "total_sats": 210,
"nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
- "addresses": {no_addr_id: "noaddr@getalby.com"},
+ "addresses": overrides,
})
assert res.status_code == 201, res.text
assert res.json()["status"] == "complete"
```
> `TeamOut.members` exposes member `id` and `name` (see `app/schemas/allocation.py` / `teams.py`).
-> If `team_count=1` does not yield a single team with both members in this engine, switch the helper
-> to `team_count=1` semantics it does support, or read the team that actually contains both emails.
- [ ] **Step 2: Run tests to verify they fail**
@@ -1157,17 +1149,12 @@ route has a `response_model`, we must add a typed `payouts` field to `PublicAllo
Add to `backend/tests/test_payout_endpoint.py` (`_setup_team` returns `event_id` so we can publish):
+This test reuses `_setup_team` and `_stub_lightning` from Task 7 (same file).
+
```python
def test_public_results_include_payout_summary(client, auth_headers, monkeypatch):
- from app.services import lnurl_service, nwc_service
- event_id, allocation_id, team_id = _setup_team(client, auth_headers, [
- ("Ada", "ada@t.com", "ada@getalby.com"),
- ("Linus", "linus@t.com", "linus@getalby.com"),
- ])
- monkeypatch.setattr(lnurl_service, "resolve_lnurl",
- lambda addr: {"callback": "https://x/cb", "minSendable": 1000, "maxSendable": 10_000_000})
- monkeypatch.setattr(lnurl_service, "request_invoice", lambda params, amount_sats: "lnbcfake")
- monkeypatch.setattr(nwc_service, "pay_invoice", lambda uri, bolt11: "preimage")
+ event_id, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True)
+ _stub_lightning(monkeypatch)
client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={
"team_id": str(team_id), "total_sats": 210,
"nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00",
@@ -1180,8 +1167,8 @@ def test_public_results_include_payout_summary(client, auth_headers, monkeypatch
summary = res.json()["payouts"]
assert summary[0]["team_label"]
assert summary[0]["total_sats"] == 210
- assert summary[0]["paid_count"] == 2
- assert summary[0]["member_count"] == 2
+ assert summary[0]["paid_count"] == len(members)
+ assert summary[0]["member_count"] == len(members)
# never leak the credential or invoice/preimage secrets
for leaked in ("nwc", "preimage", "bolt11", "lightning_address"):
assert leaked not in res.text.lower()
From bf89c2358ba870876531b0123bc9d3795e340093 Mon Sep 17 00:00:00 2001
From: comwanga
Date: Thu, 18 Jun 2026 00:18:57 +0300
Subject: [PATCH 15/16] fix(payout): durable per-item commits + tolerate string
NWC error responses
---
backend/app/services/nwc_service.py | 7 +++++--
backend/app/services/payout_service.py | 5 +++++
backend/tests/test_nwc_service.py | 12 ++++++++++++
3 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/backend/app/services/nwc_service.py b/backend/app/services/nwc_service.py
index a949d20..e7055b3 100644
--- a/backend/app/services/nwc_service.py
+++ b/backend/app/services/nwc_service.py
@@ -80,8 +80,11 @@ def decode_response(secret_bytes: bytes, wallet_xonly: bytes, content: str) -> d
"""Decrypt a kind-23195 response. Return {'preimage': ...} or raise NwcError."""
plaintext = decrypt_nip04(secret_bytes, wallet_xonly, content)
data = json.loads(plaintext)
- if data.get("error"):
- raise NwcError(data["error"].get("message", "wallet returned an error"))
+ err = data.get("error")
+ if err:
+ # NIP-47 models error as {code, message}, but some wallets return a bare string.
+ msg = err.get("message") if isinstance(err, dict) else str(err)
+ raise NwcError(msg or "wallet returned an error")
result = data.get("result") or {}
preimage = result.get("preimage")
if not preimage:
diff --git a/backend/app/services/payout_service.py b/backend/app/services/payout_service.py
index 025a3d2..8f639a6 100644
--- a/backend/app/services/payout_service.py
+++ b/backend/app/services/payout_service.py
@@ -87,6 +87,10 @@ def execute_payout(
item.status = "failed"
item.error = str(exc)
logger.warning("payout item %s failed: %s", item.id, exc)
+ # Commit per item: a sent payment's preimage must be durable the moment it
+ # lands, so a later crash/commit failure can never lose a record of real money
+ # already moved (which would risk a double-pay on re-run).
+ db.commit()
payout.status = "complete" if paid == len(splits) else ("partial" if paid else "failed")
db.commit()
db.refresh(payout)
@@ -108,6 +112,7 @@ def retry_failed(db: Session, payout: Payout, nwc: str) -> Payout:
except Exception as exc: # noqa: BLE001
item.error = str(exc)
logger.warning("payout retry item %s failed: %s", item.id, exc)
+ db.commit() # durable per item (see execute_payout)
paid = sum(1 for i in items if i.status == "paid")
payout.status = "complete" if paid == len(items) else ("partial" if paid else "failed")
db.commit()
diff --git a/backend/tests/test_nwc_service.py b/backend/tests/test_nwc_service.py
index 48654cd..e344eaa 100644
--- a/backend/tests/test_nwc_service.py
+++ b/backend/tests/test_nwc_service.py
@@ -61,3 +61,15 @@ def test_decode_response_error_raises():
with pytest.raises(NwcError) as exc:
decode_response(secret.secret, wallet.public_key_xonly.format(), content)
assert "no funds" in str(exc.value)
+
+
+def test_decode_response_string_error_raises():
+ # Some wallets return `error` as a bare string instead of {code, message}.
+ secret = PrivateKey()
+ wallet = PrivateKey()
+ our_xonly = secret.public_key_xonly.format()
+ payload = json.dumps({"error": "payment failed"})
+ content = encrypt_nip04(wallet.secret, our_xonly, payload)
+ with pytest.raises(NwcError) as exc:
+ decode_response(secret.secret, wallet.public_key_xonly.format(), content)
+ assert "payment failed" in str(exc.value)
From 8f11a5601617d22a73eddd6e14c10afc9fb139d8 Mon Sep 17 00:00:00 2001
From: comwanga
Date: Thu, 18 Jun 2026 00:49:26 +0300
Subject: [PATCH 16/16] docs: document Lightning payouts, refresh guide
screenshots, prune stale specs
- README: Lightning prize payouts (NIP-47) + optional registration LN address
- guide: add 'Pay out the winning team' step (09), recapture all screenshots
- capture-guide.mjs: capture the payout modal; renumber AI step to 10
- remove 5 incremental superpowers spec/plan pairs + launch-readiness audit
- strip demo wording from the kept lightning spec/plan
- gitignore stray .venvsource/ virtualenvs
---
.gitignore | 1 +
README.md | 4 +-
...-06-13-squadsync-launch-readiness-audit.md | 467 -------
.../2026-06-16-ai-visibility-and-docs.md | 555 ---------
.../plans/2026-06-17-breadcrumb-navigation.md | 466 -------
.../plans/2026-06-17-find-my-team.md | 345 ------
.../2026-06-17-lightning-prize-splitting.md | 8 +-
.../plans/2026-06-17-nostr-feedback.md | 1080 -----------------
.../plans/2026-06-17-team-notify.md | 782 ------------
...026-06-16-ai-visibility-and-docs-design.md | 99 --
...2026-06-17-breadcrumb-navigation-design.md | 129 --
.../specs/2026-06-17-find-my-team-design.md | 83 --
...-06-17-lightning-prize-splitting-design.md | 2 +-
.../specs/2026-06-17-nostr-feedback-design.md | 98 --
.../specs/2026-06-17-team-notify-design.md | 120 --
frontend/lib/guide-steps.ts | 10 +-
frontend/public/guide/01-login.png | Bin 30460 -> 28787 bytes
frontend/public/guide/02-create-event.png | Bin 66125 -> 66034 bytes
frontend/public/guide/03-event-dashboard.png | Bin 53886 -> 53799 bytes
frontend/public/guide/04-attendees-qr.png | Bin 107012 -> 106571 bytes
frontend/public/guide/05-join-form.png | Bin 54164 -> 65546 bytes
frontend/public/guide/06-configure.png | Bin 55681 -> 55527 bytes
frontend/public/guide/07-engine-results.png | Bin 69088 -> 68979 bytes
frontend/public/guide/08-published.png | Bin 72152 -> 73910 bytes
frontend/public/guide/09-ai-category.png | Bin 107012 -> 0 bytes
frontend/public/guide/09-payout.png | Bin 0 -> 85107 bytes
frontend/public/guide/10-ai-category.png | Bin 0 -> 106547 bytes
frontend/tests/lib/guide-steps.test.ts | 2 +-
scripts/capture-guide.mjs | 15 +-
29 files changed, 30 insertions(+), 4236 deletions(-)
delete mode 100644 docs/audits/2026-06-13-squadsync-launch-readiness-audit.md
delete mode 100644 docs/superpowers/plans/2026-06-16-ai-visibility-and-docs.md
delete mode 100644 docs/superpowers/plans/2026-06-17-breadcrumb-navigation.md
delete mode 100644 docs/superpowers/plans/2026-06-17-find-my-team.md
delete mode 100644 docs/superpowers/plans/2026-06-17-nostr-feedback.md
delete mode 100644 docs/superpowers/plans/2026-06-17-team-notify.md
delete mode 100644 docs/superpowers/specs/2026-06-16-ai-visibility-and-docs-design.md
delete mode 100644 docs/superpowers/specs/2026-06-17-breadcrumb-navigation-design.md
delete mode 100644 docs/superpowers/specs/2026-06-17-find-my-team-design.md
delete mode 100644 docs/superpowers/specs/2026-06-17-nostr-feedback-design.md
delete mode 100644 docs/superpowers/specs/2026-06-17-team-notify-design.md
delete mode 100644 frontend/public/guide/09-ai-category.png
create mode 100644 frontend/public/guide/09-payout.png
create mode 100644 frontend/public/guide/10-ai-category.png
diff --git a/.gitignore b/.gitignore
index f4bdeeb..f95abd7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
__pycache__/
*.pyc
.venv/
+.venvsource/
*.egg-info/
dist/
.pytest_cache/
diff --git a/README.md b/README.md
index f5f35c7..7c0cfd1 100644
--- a/README.md
+++ b/README.md
@@ -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/` 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
@@ -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`.
diff --git a/docs/audits/2026-06-13-squadsync-launch-readiness-audit.md b/docs/audits/2026-06-13-squadsync-launch-readiness-audit.md
deleted file mode 100644
index 1a50fd4..0000000
--- a/docs/audits/2026-06-13-squadsync-launch-readiness-audit.md
+++ /dev/null
@@ -1,467 +0,0 @@
-# SquadSync Production Audit — 2026-06-13
-
-**Prepared by:** Claude Code audit pass
-**Date:** 2026-06-13
-**Branch:** `feat/ci-dark-mode-mobile`
-**Timeline:** Real users in 1–2 weeks
-**Infra baseline:** Zero — Supabase, Render, Vercel not yet provisioned
-
----
-
-## Table of Contents
-
-1. [Audit Method](#1-audit-method)
-2. [Critical Blockers](#2-critical-blockers)
-3. [Security Findings](#3-security-findings)
-4. [Deployment Readiness Gaps](#4-deployment-readiness-gaps)
-5. [Major Gaps](#5-major-gaps)
-6. [Low-Risk Cleanup](#6-low-risk-cleanup)
-7. [Verified Non-Issues](#7-verified-non-issues)
-8. [Production Implementation Plan](#8-production-implementation-plan)
-
----
-
-## 1. Audit Method
-
-Every finding is marked **VERIFIED** (direct file read or command output inspected) or **INFERRED** (conclusion drawn from multiple verified inputs — cross-checked but not directly observed).
-
-All file references include line numbers as read during this audit. Findings are ordered by deploy-blocking severity.
-
----
-
-## 2. Critical Blockers
-
-### CB-01 — Alembic migration schema mismatch
-
-**Status:** VERIFIED
-**Evidence:** `backend/alembic/versions/9898ba081ca1_initial_schema.py` lines 22–34 creates:
-
-```sql
-CREATE TABLE users (
- id UUID PRIMARY KEY,
- name VARCHAR NOT NULL,
- email VARCHAR UNIQUE NOT NULL,
- hashed_password VARCHAR,
- provider ENUM('local','google') DEFAULT 'local',
- provider_id VARCHAR,
- created_at TIMESTAMP
-);
-```
-
-Current model at `backend/app/models/user.py` lines 8–12:
-
-```python
-class User(Base):
- __tablename__ = "users"
- id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
- pubkey = Column(String(64), unique=True, nullable=False, index=True)
- created_at = Column(DateTime(timezone=True), server_default=func.now())
-```
-
-**Risk:** `alembic upgrade head` on a fresh production Postgres creates the old email/password schema. `POST /auth/nostr` then fails with `column "pubkey" does not exist` — the app is non-functional from first deploy.
-
-**Reproduction:** Spin up a blank Postgres instance, run `alembic upgrade head`, then `POST /auth/nostr` with a valid Nostr keypair — guaranteed 500.
-
-**Remediation:**
-1. Delete `backend/alembic/versions/9898ba081ca1_initial_schema.py`
-2. `alembic revision --autogenerate -m "nostr-user-schema"`
-3. Verify generated file: `pubkey VARCHAR(64) NOT NULL UNIQUE` present, no `name/email/password/provider` columns
-4. `alembic upgrade head` against SQLite — confirm clean run
-
----
-
-### CB-02 — Docker build fails on `coincurve` C extension
-
-**Status:** VERIFIED
-**Evidence:** `backend/Dockerfile` lines 1–10:
-
-```dockerfile
-FROM python:3.12-slim
-WORKDIR /app
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-COPY . .
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
-```
-
-`backend/requirements.txt` line 8: `coincurve==20.0.0`
-
-`python:3.12-slim` contains no C build toolchain. `coincurve` is a C extension wrapping `libsecp256k1` — it requires `gcc`, `libffi-dev`, `libssl-dev`, and `python3-dev` at build time. No `apt-get install` step exists anywhere in the Dockerfile.
-
-**Risk:** `docker build` exits with a pip build error. No Render deployment is possible. Nostr signature verification is unavailable without `coincurve`.
-
-**Reproduction:** `docker build -t squadsync-backend ./backend` — will fail at the pip install step.
-
-**Remediation:** Add before the `pip install` line:
-
-```dockerfile
-RUN apt-get update && apt-get install -y --no-install-recommends \
- gcc build-essential libffi-dev libssl-dev python3-dev \
- && rm -rf /var/lib/apt/lists/*
-```
-
----
-
-### CB-03 — CORS blocks all production frontend requests
-
-**Status:** VERIFIED
-**Evidence:** `backend/app/core/config.py` line 9:
-
-```python
-FRONTEND_URL: str = "http://localhost:3000"
-```
-
-`backend/app/main.py` passes this directly to:
-
-```python
-CORSMiddleware(allow_origins=[settings.FRONTEND_URL])
-```
-
-**Risk:** Every API call from the Vercel deployment (`https://*.vercel.app`) receives a `403 CORS` response. The app appears to load but all data fetching and auth fail silently in the browser.
-
-**Reproduction:** Deploy frontend to Vercel with `NEXT_PUBLIC_API_URL` pointing at the Render backend. Open devtools → Network tab → any `fetch()` to the backend → `Access-Control-Allow-Origin` header absent → browser blocks response.
-
-**Remediation:** Set the `FRONTEND_URL` environment variable in Render to the exact Vercel production URL (e.g., `https://squadsync.vercel.app`). No code change required — `config.py` reads from env.
-
----
-
-### CB-04 — `(dashboard)` route deletions not committed
-
-**Status:** VERIFIED
-**Evidence:** Session-start git status shows:
-
-```
-D frontend/app/(dashboard)/events/[eventId]/attendees/page.tsx
-D frontend/app/(dashboard)/events/[eventId]/configure/page.tsx
-D frontend/app/(dashboard)/events/[eventId]/engine/page.tsx
-D frontend/app/(dashboard)/events/[eventId]/page.tsx
-D frontend/app/(dashboard)/layout.tsx
-D frontend/app/(dashboard)/page.tsx
-```
-
-The working tree has deleted these files but the deletion is unstaged. Meanwhile, `frontend/app/dashboard/` (no parentheses) is the active route group. Both route groups are live in the git HEAD that CI checks out.
-
-**Risk:** CI checkout has both `(dashboard)/` and `dashboard/` route groups simultaneously. `npx next build` may fail with duplicate-route conflicts, or pass but deploy with the dead routes included. Either way the branch is not clean for a production cut.
-
-**Reproduction:** `git stash` (restores deleted files from HEAD) → `npx next build` → observe duplicate-route warnings or errors.
-
-**Remediation:**
-1. `git add frontend/app/\(dashboard\)/`
-2. `git commit -m "chore: remove deprecated (dashboard) route group"`
-3. Confirm `npx next build` succeeds after commit
-
----
-
-## 3. Security Findings
-
-### SF-01 — NIP-98 URL and method tags not validated server-side
-
-**Status:** VERIFIED
-**Evidence:** `backend/app/services/auth_service.py` — `verify_nostr_event()` only:
-- Recomputes the event ID hash (SHA-256 of canonical JSON)
-- Verifies the Schnorr signature with `coincurve`
-
-It never inspects `event["tags"]`. NIP-98 requires the server to validate that tags contain `["u", ]` and `["method", "POST"]`. `backend/app/api/v1/auth.py` calls `nostr_login()`, which calls `verify_nostr_event()` without passing the expected URL.
-
-**Risk:** A valid NIP-98 event signed for a different endpoint (e.g., a third-party service) could be replayed to `/auth/nostr`. The 60-second freshness window (`created_at` check in `nostr_login()`) partially mitigates replay across sessions, but does not prevent within-window cross-endpoint replay.
-
-**Reproduction:** Obtain a valid kind-27235 event signed for URL `https://other-service.com/auth`, POST it to `/auth/nostr` with a matching pubkey — it succeeds.
-
-**Remediation:** In `verify_nostr_event()`, add a parameter `expected_url: str` and validate:
-
-```python
-u_tags = [t for t in event.get("tags", []) if t[0] == "u"]
-method_tags = [t for t in event.get("tags", []) if t[0] == "method"]
-if not u_tags or u_tags[0][1] != expected_url:
- return False
-if not method_tags or method_tags[0][1] != "POST":
- return False
-```
-
-Pass `expected_url` from `nostr_login()` using the request's actual URL.
-
----
-
-### SF-02 — Nostr private key stored in plaintext localStorage
-
-**Status:** VERIFIED
-**Evidence:** `frontend/components/auth/nostr-connect.tsx` line 137:
-
-```typescript
-localStorage.setItem(SK_KEY, generated.skHex)
-```
-
-**Risk:** Any XSS vulnerability in the application exposes the user's Nostr private key directly. Unlike a password (which can be rotated server-side), a Nostr private key IS the identity — a stolen key cannot be invalidated.
-
-**Severity:** Acceptable for MVP given the scope, but must be documented as a known risk and addressed before significant user growth. Consider encrypting the stored key with a PIN, or prompting users to use a browser extension (nos2x, Alby) which never expose the private key to the page.
-
----
-
-## 4. Deployment Readiness Gaps
-
-No production infrastructure has been provisioned. These are not software defects — they are missing environment setup required before any deployment attempt.
-
-| Gap | Detail |
-|-----|--------|
-| Supabase PostgreSQL | No project created; no `DATABASE_URL` for production |
-| Render Web Service | No service configured; no backend URL exists |
-| Vercel Project | No project imported; no frontend URL exists |
-| Production secrets | `SECRET_KEY`, `NEXTAUTH_SECRET` not generated or stored |
-| CORS wiring | `FRONTEND_URL` env var needs Vercel URL — only available after first Vercel deploy |
-
-See Phase 3 of the implementation plan for provisioning steps.
-
----
-
-## 5. Major Gaps
-
-### MG-01 — CI does not run Vitest
-
-**Status:** VERIFIED
-**Evidence:** `.github/workflows/ci.yml` frontend job ends at:
-
-```yaml
-- name: Type check
- run: npx tsc --noEmit
- working-directory: frontend
-```
-
-No `npm test` step. Frontend component tests exist (`frontend/tests/components/minimal-config-test.tsx`) and would catch UI regressions, but they never run in CI.
-
-**Remediation:** Add after the type check step:
-
-```yaml
-- name: Run tests
- run: npm test
- working-directory: frontend
- env:
- NEXT_PUBLIC_API_URL: http://localhost:8000
- NEXTAUTH_SECRET: ci-secret
-```
-
-### MG-02 — CI has no Docker build verification
-
-**Status:** INFERRED from Dockerfile contents and CI yaml
-**Evidence:** CI yaml has no Docker build step. CB-02 (coincurve build failure) would go undetected in CI and only surface on first Render deploy.
-
-**Remediation:** Add a third CI job:
-
-```yaml
-docker-build:
- name: Docker Build (Backend)
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Build backend image
- run: docker build ./backend
-```
-
----
-
-## 6. Low-Risk Cleanup
-
-These files are unused and safe to delete but are not deploy blockers.
-
-| File | Status | Action |
-|------|--------|--------|
-| `frontend/components/auth/login-form.tsx` | Not imported anywhere (verified with grep) | `git rm` |
-| `frontend/components/auth/register-form.tsx` | Not imported anywhere (verified with grep) | `git rm` |
-
----
-
-## 7. Verified Non-Issues
-
-These were initially flagged as potential risks. Direct inspection confirmed they are not blockers.
-
-| Item | Finding |
-|------|---------|
-| `backend/tests/test_auth.py` | Fully updated for Nostr — uses `coincurve.PrivateKey`, tests kind/pubkey/expiry/sig. Not a blocker. |
-| `backend/tests/conftest.py` | `make_nostr_event()` and `auth_headers` fixture correctly construct NIP-98 events. Not a blocker. |
-| `nostr-tools` npm package | Present in `frontend/package.json` as `"nostr-tools": "^2.23.5"`. Not missing. |
-| Google OAuth routes | Completely removed from `backend/app/api/v1/auth.py` and `frontend/lib/auth.ts`. No dead code risk. |
-| NIP-98 freshness check | `abs(time.time() - event.get("created_at", 0)) > 60` is present and correct in `nostr_login()`. |
-
----
-
-## 8. Production Implementation Plan
-
-### Phase 1 — Launch Blockers *(fix before any deploy attempt)*
-
-**Step 1 — Regenerate Alembic migration (CB-01)**
-
-```bash
-cd backend
-rm alembic/versions/9898ba081ca1_initial_schema.py
-DATABASE_URL=sqlite:///./test.db alembic revision --autogenerate -m "nostr-user-schema"
-# Verify: generated file has pubkey VARCHAR(64) NOT NULL UNIQUE
-# Verify: NO name, email, hashed_password, provider columns
-alembic upgrade head
-```
-
-Commit: `fix: regenerate Alembic migration for Nostr user schema`
-
-**Step 2 — Add NIP-98 URL/method tag validation (SF-01)**
-
-In `backend/app/services/auth_service.py`, update `verify_nostr_event()` to accept `expected_url: str` and validate `["u", expected_url]` and `["method", "POST"]` tags. Update `nostr_login()` to pass the request URL.
-
-Run `pytest tests/test_auth.py -v` — all tests must pass.
-
-Commit: `fix: enforce NIP-98 URL and method tag validation`
-
-**Step 3 — Fix Dockerfile for coincurve build (CB-02)**
-
-Add to `backend/Dockerfile` before the `pip install` line:
-
-```dockerfile
-RUN apt-get update && apt-get install -y --no-install-recommends \
- gcc build-essential libffi-dev libssl-dev python3-dev \
- && rm -rf /var/lib/apt/lists/*
-```
-
-Verify locally: `docker build -t squadsync-backend ./backend` must succeed end-to-end.
-
-Commit: `fix: add C build deps to Dockerfile for coincurve`
-
-**Step 4 — Commit route deletions (CB-04)**
-
-```bash
-git add "frontend/app/(dashboard)/"
-git status # confirm all 6 D entries are staged
-git commit -m "chore: remove deprecated (dashboard) route group"
-npx next build # run in frontend/ — must complete clean
-```
-
-**Step 5 — Remove dead auth components**
-
-```bash
-git rm frontend/components/auth/login-form.tsx
-git rm frontend/components/auth/register-form.tsx
-git commit -m "chore: remove unused email/password auth components"
-```
-
----
-
-### Phase 2 — CI Hardening *(merge before Phase 3)*
-
-**Step 6 — Add Vitest to CI frontend job (MG-01)**
-
-In `.github/workflows/ci.yml`, add after the type check step in the `frontend` job:
-
-```yaml
-- name: Run tests
- run: npm test
- working-directory: frontend
- env:
- NEXT_PUBLIC_API_URL: http://localhost:8000
- NEXTAUTH_SECRET: ci-secret
-```
-
-**Step 7 — Add Docker build CI job (MG-02)**
-
-Add a third job to `.github/workflows/ci.yml`:
-
-```yaml
-docker-build:
- name: Docker Build (Backend)
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Build backend image
- run: docker build ./backend
-```
-
-**Step 8 — Add migration smoke test to backend CI job**
-
-In the `backend` job, add after `pytest tests/ -v`:
-
-```yaml
-- name: Verify migration
- run: alembic upgrade head
- working-directory: backend
- env:
- DATABASE_URL: sqlite:///./test.db
-```
-
-Commit Steps 6–8 together: `ci: add vitest, Docker build, and migration verification`
-
----
-
-### Phase 3 — Infrastructure Provisioning
-
-**Step 9 — Provision Supabase PostgreSQL**
-
-1. Create project at supabase.com
-2. Copy the **Direct connection** `postgresql://` URI (not the pooler URI — Alembic needs direct connections)
-3. Set locally as `DATABASE_URL` in `backend/.env`
-4. `cd backend && alembic upgrade head` — verify all tables created, especially `users(id, pubkey, created_at)`
-
-**Step 10 — Deploy to Render**
-
-1. Create new Web Service → Docker → connect GitHub repo → Root Dir: `backend/`
-2. Set environment variables:
- - `DATABASE_URL` → Supabase direct URI
- - `SECRET_KEY` → generate: `openssl rand -hex 32`
- - `FRONTEND_URL` → placeholder (`http://localhost:3000` until Step 12)
-3. Trigger first deploy → verify `/health` returns `{"status":"ok"}`
-
-**Step 11 — Deploy to Vercel**
-
-1. Import repo at vercel.com → Framework: Next.js → Root Dir: `frontend/`
-2. Set environment variables:
- - `NEXT_PUBLIC_API_URL` → Render backend URL from Step 10
- - `NEXTAUTH_SECRET` → generate: `openssl rand -hex 32`
- - `NEXTAUTH_URL` → Vercel production URL (available after first deploy)
-3. Trigger first deploy → verify login page loads
-
-**Step 12 — Wire CORS (CB-03)**
-
-In Render environment variables, update `FRONTEND_URL` to the Vercel production URL (e.g., `https://squadsync.vercel.app`). Trigger Render redeploy.
-
----
-
-### Phase 4 — Release Validation
-
-**Step 13 — End-to-end smoke test on live URLs**
-
-Golden path, in order:
-1. Open Vercel URL in a fresh browser (no cached state)
-2. Generate new Nostr identity on login page → sign in → confirm redirect to `/dashboard`
-3. Create event → verify event slug generated, event appears in list
-4. Open QR URL on a mobile device → complete participant registration form
-5. Switch to desktop → run team allocation → verify team grid renders
-6. Publish teams → export CSV → verify file downloads correctly
-7. Copy share link → open in incognito tab → verify public results page loads without auth
-
-**Step 14 — Auth replay test**
-
-1. POST to `/auth/nostr` with a valid NIP-98 event; record the exact request body
-2. Wait 61 seconds
-3. POST the same body again → expect `400 Event expired`
-4. POST with same body but wrong `u` tag URL → expect `401 Invalid event`
-
-**Step 15 — CORS verification**
-
-Open browser devtools on the Vercel URL → trigger any API call → confirm `Access-Control-Allow-Origin` header present in response and no CORS errors in console.
-
-**Step 16 — Mobile and dark mode check**
-
-1. Open dashboard on a 375px-wide viewport (iPhone SE) — nav, event list, registration form must be usable without horizontal scroll
-2. Toggle OS dark mode → verify `dark:bg-slate-950` class on dashboard background renders correctly
-
----
-
-## Risk Summary
-
-| ID | Severity | Status | Blocks Deploy |
-|----|----------|--------|---------------|
-| CB-01 | Critical | Verified | Yes |
-| CB-02 | Critical | Verified | Yes |
-| CB-03 | Critical | Verified | Yes (silently) |
-| CB-04 | High | Verified | Yes (CI) |
-| SF-01 | High | Verified | No, but ship before growth |
-| SF-02 | Medium | Verified | No (MVP acceptable) |
-| MG-01 | Medium | Verified | No |
-| MG-02 | Medium | Inferred | No |
-
-**Minimum to deploy:** CB-01, CB-02, CB-03, CB-04 resolved + all of Phase 3.
-**Minimum to ship to real users:** All of Phase 1 + Phase 3 + Phase 4 validation passed.
diff --git a/docs/superpowers/plans/2026-06-16-ai-visibility-and-docs.md b/docs/superpowers/plans/2026-06-16-ai-visibility-and-docs.md
deleted file mode 100644
index 1c14d4a..0000000
--- a/docs/superpowers/plans/2026-06-16-ai-visibility-and-docs.md
+++ /dev/null
@@ -1,555 +0,0 @@
-# Surface AI Categorization + Docs/Cleanup — Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Make the AI categorization of free-text "Other" strengths visible (counts + badges + a guide step), fix stale post-PR#11 UI, rewrite the README, and remove redundant files.
-
-**Architecture:** Backend reports per-event `ai_normalized`/`auto_normalized` counts on the allocation response; the frontend surfaces them as a results-page note and Attendees source badges, and the guide gains an explainer step with a fresh screenshot. Small contained bugfix for the stale `TeamMember` type. Docs + cleanup round it out.
-
-**Tech Stack:** FastAPI + SQLAlchemy + pytest (backend); Next.js 16 + React + Vitest (frontend); Playwright (screenshot capture).
-
-**Spec:** `docs/superpowers/specs/2026-06-16-ai-visibility-and-docs-design.md`
-**Branch:** `feat/ai-visibility-and-docs` (off `main`).
-
----
-
-## Phase 1 — Backend: normalization counts
-
-**Files:**
-- Modify: `backend/app/services/categorization_service.py`
-- Modify: `backend/app/schemas/allocation.py`
-- Modify: `backend/app/api/v1/allocation.py`
-- Test: `backend/tests/test_categorization.py` (extend), `backend/tests/test_allocation_counts.py` (new)
-
-- [ ] **Step 1: Update `normalize_pending` to return counts.** In `categorization_service.py`, change the function so it returns `{"ai": int, "fallback": int}`. Replace the final loop + `return`:
-
-```python
-def normalize_pending(db: Session, event_id: UUID) -> dict[str, int]:
- """Fill normalized_strength for un-normalized Other entries. Never raises.
-
- Returns counts of how many entries were set via AI vs deterministic fallback
- in this call.
- """
- counts = {"ai": 0, "fallback": 0}
- pending = _pending(db, event_id)
- if not pending:
- return counts
- event = db.query(Event).filter(Event.id == event_id).first()
-
- mapping: dict[str, str] = {}
- if settings.ANTHROPIC_API_KEY:
- try:
- mapping = _classify(event, pending)
- except Exception as exc: # noqa: BLE001 — AI is best-effort
- logger.warning("Categorization AI failed, using fallback: %s", exc)
- mapping = {}
-
- for p in pending:
- ai_cat = mapping.get(str(p.id))
- if ai_cat:
- p.normalized_strength = ai_cat
- p.strength_source = "ai"
- counts["ai"] += 1
- else:
- p.normalized_strength = _slug(p.strength_other or "other")
- p.strength_source = "fallback"
- counts["fallback"] += 1
- db.commit()
- return counts
-```
-
-- [ ] **Step 2: Add count fields to `AllocationOut`.** In `backend/app/schemas/allocation.py`, add two fields to `AllocationOut` (after `constraint_warnings`):
-
-```python
-class AllocationOut(BaseModel):
- id: UUID
- event_id: UUID
- snapshot_hash: str
- status: str
- constraint_warnings: dict
- ai_normalized: int = 0
- auto_normalized: int = 0
- teams: list[TeamOut] = []
-
- model_config = {"from_attributes": True}
-```
-
-- [ ] **Step 3: Populate counts in `_build_allocation_out`.** In `backend/app/api/v1/allocation.py`, inside `_build_allocation_out`, before the final `return`, count the event's participant sources and include them in the validated dict:
-
-```python
- ai_n = db.query(Participant).filter(
- Participant.event_id == allocation.event_id,
- Participant.strength_source == "ai",
- ).count()
- auto_n = db.query(Participant).filter(
- Participant.event_id == allocation.event_id,
- Participant.strength_source == "fallback",
- ).count()
- return AllocationOut.model_validate({
- "id": allocation.id,
- "event_id": allocation.event_id,
- "snapshot_hash": allocation.snapshot_hash,
- "status": allocation.status,
- "constraint_warnings": allocation.constraint_warnings or {},
- "ai_normalized": ai_n,
- "auto_normalized": auto_n,
- "teams": teams_out,
- })
-```
-
-(`Participant` is already imported in this module.)
-
-- [ ] **Step 4: Extend the categorization test.** In `backend/tests/test_categorization.py`, update the two existing assertions to also check the return value. Add to `test_ai_maps_other_to_category` after the `normalize_pending` call:
-
-```python
- with patch.object(cat, "_classify", return_value={str(p.id): "domain_expert"}):
- counts = cat.normalize_pending(db, e.id)
- assert counts == {"ai": 1, "fallback": 0}
-```
-
-And add to `test_fallback_when_no_key` after the call:
-
-```python
- counts = cat.normalize_pending(db, e.id)
- assert counts == {"ai": 0, "fallback": 1}
-```
-
-(Replace the existing bare `cat.normalize_pending(db, e.id)` lines in those two tests with the count-capturing versions above.)
-
-- [ ] **Step 5: New integration test for the response counts.** Create `backend/tests/test_allocation_counts.py` (uses the shared `client`/`auth_headers` fixtures from `conftest.py`; no Anthropic key in the test env → fallback path):
-
-```python
-def _make_active_event(client, auth_headers):
- e = client.post("/api/v1/events", headers=auth_headers, json={"title": "Counts", "team_count": 2}).json()
- client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"})
- return e
-
-
-def test_allocation_reports_auto_normalized_count(client, auth_headers):
- e = _make_active_event(client, auth_headers)
- slug = e["registration_slug"]
- # one free-text "Other" + three presets so allocation has >= 2 per team
- client.post(f"/api/v1/events/{slug}/register", json={
- "name": "Carol", "email": "carol@t.com",
- "primary_strength": "other", "strength_other": "Agronomist", "experience_level": "advanced"})
- for i, s in enumerate(["technical", "design", "planning"]):
- client.post(f"/api/v1/events/{slug}/register", json={
- "name": f"P{i}", "email": f"p{i}@t.com",
- "primary_strength": s, "experience_level": "intermediate"})
- res = client.post(f"/api/v1/events/{e['id']}/allocate", headers=auth_headers)
- assert res.status_code == 200
- body = res.json()
- # No ANTHROPIC_API_KEY in tests -> the Other entry is categorized via fallback.
- assert body["auto_normalized"] == 1
- assert body["ai_normalized"] == 0
-```
-
-- [ ] **Step 6: Run backend tests**
-
-Run: `cd backend && rm -f *.db; DATABASE_URL="sqlite:///./test_squadsync.db" SECRET_KEY=test python -m pytest tests/test_categorization.py tests/test_allocation_counts.py -q; rm -f *.db`
-Expected: all pass.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add backend/app/services/categorization_service.py backend/app/schemas/allocation.py backend/app/api/v1/allocation.py backend/tests/test_categorization.py backend/tests/test_allocation_counts.py
-git commit -m "feat(api): report ai_normalized / auto_normalized counts on allocation"
-```
-
----
-
-## Phase 2 — Frontend types + team-card bugfix
-
-**Files:**
-- Modify: `frontend/hooks/use-allocation.ts`
-- Modify: `frontend/components/engine/team-card.tsx`
-
-> Bug: after PR #11 the backend returns `normalized_strength`/`experience_level` per member, but the `TeamMember` type and `team-card` still read `role`/`skill_level` (now undefined). Fix while adding the count fields.
-
-- [ ] **Step 1: Update `use-allocation.ts` types.** Replace the `TeamMember` interface and add count fields to `Allocation`:
-
-```ts
-export interface TeamMember {
- id: string;
- name: string;
- email: string;
- normalized_strength?: string;
- experience_level: string;
- composite_score?: number;
-}
-```
-```ts
-export interface Allocation {
- id: string;
- event_id: string;
- snapshot_hash: string;
- status: string;
- constraint_warnings: Record;
- ai_normalized?: number;
- auto_normalized?: number;
- teams: Team[];
-}
-```
-
-- [ ] **Step 2: Fix `team-card.tsx`** to use the new fields. Replace the `roleColor` map, the `roleCounts` reducer, the chips block, and the member line:
-
-```tsx
-const strengthColor: Record = {
- technical: "bg-blue-100 text-blue-800",
- design: "bg-pink-100 text-pink-800",
- planning: "bg-indigo-100 text-indigo-800",
- coordination: "bg-green-100 text-green-800",
- communication: "bg-orange-100 text-orange-800",
- research: "bg-purple-100 text-purple-800",
- domain_expert: "bg-teal-100 text-teal-800",
-};
-```
-
-Reducer:
-```tsx
- const strengthCounts = team.members.reduce>((acc, m) => {
- const key = m.normalized_strength ?? "other";
- acc[key] = (acc[key] ?? 0) + 1;
- return acc;
- }, {});
-```
-
-Chips block:
-```tsx
-
- {Object.entries(strengthCounts).map(([strength, count]) => (
-
- {strength.replace("_", " ")} ×{count}
-
- ))}
-
-```
-
-Member line (inside the `details` list):
-```tsx
-
- {(m.normalized_strength ?? "—").replace("_", " ")} · {m.experience_level}
-
-```
-
-- [ ] **Step 3: Typecheck + lint**
-
-Run: `cd frontend && npx tsc --noEmit && npm run lint`
-Expected: tsc clean; 0 lint errors.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add frontend/hooks/use-allocation.ts frontend/components/engine/team-card.tsx
-git commit -m "fix(ui): team cards use normalized_strength/experience_level; add count types"
-```
-
----
-
-## Phase 3 — Attendees source badge
-
-**Files:**
-- Create: `frontend/components/attendees/source-badge.tsx`
-- Test: `frontend/tests/components/source-badge.test.tsx`
-- Modify: `frontend/components/attendees/attendees-table.tsx`
-
-- [ ] **Step 1: Write the failing test**
-
-```tsx
-// frontend/tests/components/source-badge.test.tsx
-import { render, screen } from "@testing-library/react";
-import { describe, it, expect } from "vitest";
-import { SourceBadge } from "@/components/attendees/source-badge";
-
-describe("SourceBadge", () => {
- it("labels each source", () => {
- const cases: [string, string][] = [
- ["ai", "AI"], ["fallback", "Auto"], ["manual", "Manual"], ["preset", "preset"],
- ];
- for (const [source, label] of cases) {
- const { unmount } = render();
- expect(screen.getByText(label)).toBeInTheDocument();
- unmount();
- }
- });
-});
-```
-
-- [ ] **Step 2: Run it, expect FAIL**
-
-Run: `cd frontend && npx vitest run tests/components/source-badge.test.tsx`
-Expected: FAIL — module not found.
-
-- [ ] **Step 3: Implement the badge**
-
-```tsx
-// frontend/components/attendees/source-badge.tsx
-const STYLES: Record = {
- ai: { label: "AI", cls: "bg-violet-100 text-violet-800" },
- fallback: { label: "Auto", cls: "bg-slate-100 text-slate-700" },
- manual: { label: "Manual", cls: "bg-blue-100 text-blue-800" },
-};
-
-export function SourceBadge({ source }: { source: string }) {
- const s = STYLES[source];
- if (!s) return {source};
- return {s.label};
-}
-```
-
-- [ ] **Step 4: Run it, expect PASS**
-
-Run: `cd frontend && npx vitest run tests/components/source-badge.test.tsx`
-Expected: PASS.
-
-- [ ] **Step 5: Use it in the attendees table.** In `frontend/components/attendees/attendees-table.tsx`, add the import:
-
-```tsx
-import { SourceBadge } from "@/components/attendees/source-badge";
-```
-
-and replace the Source cell (currently `{p.strength_source} | `) with:
-
-```tsx
- |
-```
-
-- [ ] **Step 6: Typecheck + lint**
-
-Run: `cd frontend && npx tsc --noEmit && npm run lint`
-Expected: clean.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add frontend/components/attendees/source-badge.tsx frontend/tests/components/source-badge.test.tsx frontend/components/attendees/attendees-table.tsx
-git commit -m "feat(ui): AI/Auto/Manual source badges in attendees table"
-```
-
----
-
-## Phase 4 — Engine surfacing (results note + run-panel copy)
-
-**Files:**
-- Create: `frontend/lib/allocation-notes.ts`
-- Test: `frontend/tests/lib/allocation-notes.test.ts`
-- Modify: `frontend/components/engine/results-grid.tsx`
-- Modify: `frontend/components/engine/run-panel.tsx`
-
-- [ ] **Step 1: Write the failing test**
-
-```ts
-// frontend/tests/lib/allocation-notes.test.ts
-import { describe, it, expect } from "vitest";
-import { normalizationNote } from "@/lib/allocation-notes";
-
-describe("normalizationNote", () => {
- it("reports AI count when present", () => {
- expect(normalizationNote(2, 0)).toMatch(/AI categorized 2/i);
- });
- it("reports fallback when no AI", () => {
- expect(normalizationNote(0, 3)).toMatch(/categorized automatically/i);
- });
- it("returns null when nothing was normalized", () => {
- expect(normalizationNote(0, 0)).toBeNull();
- });
-});
-```
-
-- [ ] **Step 2: Run it, expect FAIL**
-
-Run: `cd frontend && npx vitest run tests/lib/allocation-notes.test.ts`
-Expected: FAIL — module not found.
-
-- [ ] **Step 3: Implement the helper**
-
-```ts
-// frontend/lib/allocation-notes.ts
-// Human-readable note about how free-text "Other" strengths were categorized.
-export function normalizationNote(aiNormalized = 0, autoNormalized = 0): string | null {
- if (aiNormalized > 0) {
- const s = aiNormalized === 1 ? "" : "s";
- return `🧠 AI categorized ${aiNormalized} free-text “Other” response${s}.`;
- }
- if (autoNormalized > 0) {
- const s = autoNormalized === 1 ? "" : "s";
- return `${autoNormalized} “Other” response${s} categorized automatically — set ANTHROPIC_API_KEY for AI.`;
- }
- return null;
-}
-```
-
-- [ ] **Step 4: Run it, expect PASS**
-
-Run: `cd frontend && npx vitest run tests/lib/allocation-notes.test.ts`
-Expected: PASS (3 tests).
-
-- [ ] **Step 5: Render the note in `results-grid.tsx`.** Add the import:
-
-```tsx
-import { normalizationNote } from "@/lib/allocation-notes";
-```
-
-Just inside the returned top-level `` (before the warnings block), add:
-
-```tsx
- {normalizationNote(allocation.ai_normalized, allocation.auto_normalized) && (
-
- {normalizationNote(allocation.ai_normalized, allocation.auto_normalized)}
-
- )}
-```
-
-- [ ] **Step 6: Fix stale copy + add AI line in `run-panel.tsx`.** Replace the `PASSES` array:
-
-```tsx
-const PASSES = [
- "Pass 1 — Distributing anchors (Advanced)",
- "Pass 2 — Core balance pipeline (Intermediate)",
- "Pass 3 — Strength constraint enforcement",
- "Pass 4 — Beginner fill",
-];
-```
-
-And replace the participant-count `
` with two lines:
-
-```tsx
-
- {participantCount} participants ready for allocation.
- The engine will run 4 passes to distribute teams fairly.
-
-
- Free-text “Other” strengths are categorized by AI before allocation.
-
-```
-
-- [ ] **Step 7: Typecheck + lint + tests**
-
-Run: `cd frontend && npx tsc --noEmit && npm run lint && npm test`
-Expected: clean; all tests pass.
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add frontend/lib/allocation-notes.ts frontend/tests/lib/allocation-notes.test.ts frontend/components/engine/results-grid.tsx frontend/components/engine/run-panel.tsx
-git commit -m "feat(ui): results-page normalization note + engine copy fixes"
-```
-
----
-
-## Phase 5 — Guide step + screenshot
-
-**Files:**
-- Modify: `frontend/lib/guide-steps.ts`
-- Modify: `frontend/tests/lib/guide-steps.test.ts`
-- Modify: `scripts/capture-guide.mjs`
-- Create: `frontend/public/guide/09-ai-category.png`
-
-- [ ] **Step 1: Append the guide step.** In `frontend/lib/guide-steps.ts`, add as the LAST element of `GUIDE_STEPS`:
-
-```ts
- {
- id: "ai-categorize",
- title: "9. Behind the scenes: SquadSync sorts free-text answers",
- caption: "When someone picks “Other” and types their own strength, SquadSync categorizes it automatically before forming teams — by AI when an API key is set, deterministically otherwise. The Attendees table shows each person’s category and its source (AI / Auto / Manual), and you can override any of them.",
- image: "/guide/09-ai-category.png",
- },
-```
-
-- [ ] **Step 2: Update the count assertion.** In `frontend/tests/lib/guide-steps.test.ts`, change `expect(GUIDE_STEPS.length).toBe(8);` to `expect(GUIDE_STEPS.length).toBe(9);`.
-
-- [ ] **Step 3: Run guide-steps test**
-
-Run: `cd frontend && npx vitest run tests/lib/guide-steps.test.ts`
-Expected: PASS (the image file need not exist yet for this test).
-
-- [ ] **Step 4: Extend the capture script.** In `scripts/capture-guide.mjs`, after the `08-published` shot and before `await browser.close();`, add:
-
-```js
- // 09 attendees after allocation — shows categorized "Other" + Source badge
- await page.goto(`${BASE}/dashboard/events/${eventId}/attendees`, { waitUntil: "domcontentloaded" });
- await page.waitForSelector("text=Registration QR Code", { timeout: 45000 });
- await shot(page, "09-ai-category");
-```
-
-- [ ] **Step 5: Capture the screenshot.** (Controller runs this — needs the live stack.) Start the migrated backend on :8000 and `npm run dev` on :3000, then:
-
-Run: `node scripts/capture-guide.mjs`
-Expected: all `📸` lines including `09-ai-category.png`; confirm `frontend/public/guide/09-ai-category.png` exists. (Locally it shows the "Auto" badge since no key is set — that's expected.)
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add frontend/lib/guide-steps.ts frontend/tests/lib/guide-steps.test.ts scripts/capture-guide.mjs frontend/public/guide/09-ai-category.png
-git commit -m "feat(guide): add AI-categorization step + screenshot"
-```
-
----
-
-## Phase 6 — README rewrite
-
-**Files:**
-- Modify: `README.md` (root)
-
-- [ ] **Step 1: Rewrite `README.md`** to cover, in this order: one-line description; key features (universal Primary Strengths + free-text Other, AI-assisted **deterministic** team formation, registration QR, in-app guide, CSV/PDF export, Nostr login); architecture (Next.js frontend on Vercel, FastAPI + Postgres backend on Render, single `main` branch deploys both); local development (backend: venv, `pip install -r backend/requirements.txt`, `alembic upgrade head`, `uvicorn app.main:app`; frontend: `npm install`, `npm run dev`); environment variables (link to `DEPLOYMENT.md`, note `ANTHROPIC_API_KEY` optional → enables AI categorization, deterministic fallback otherwise); testing (`pytest`, `npm test`); and a pointer to `docs/superpowers/` for specs/plans. Keep it concise (under ~120 lines) and accurate to the current code.
-
-- [ ] **Step 2: Sanity check links/commands** — verify referenced paths exist (`backend/requirements.txt`, `DEPLOYMENT.md`, `frontend/`), and that commands match what the project actually uses (cross-check against `DEPLOYMENT.md`).
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add README.md
-git commit -m "docs: rewrite README for current product + deploy model"
-```
-
----
-
-## Phase 7 — Cleanup redundant files
-
-**Files:**
-- Delete: `screenshots/` (root, 14 PNGs)
-- Delete: `golden-path.mjs` (root)
-
-- [ ] **Step 1: Confirm nothing references them**
-
-Run: `cd "C:/Users/mwang/squadsync" && grep -rn "golden-path\|/screenshots/\|\"screenshots\b" --include=*.ts --include=*.tsx --include=*.mjs --include=*.json --include=*.yml --include=*.md . | grep -v node_modules | grep -v "docs/superpowers"`
-Expected: no functional references (matches only inside the deleted files themselves or historical docs are acceptable — if a workflow/package script references `golden-path.mjs`, stop and report).
-
-- [ ] **Step 2: Delete and commit**
-
-```bash
-git rm -r screenshots golden-path.mjs
-git commit -m "chore: remove redundant root screenshots/ and golden-path.mjs (superseded by scripts/capture-guide.mjs + frontend/public/guide)"
-```
-
----
-
-## Phase 8 — Full verification
-
-- [ ] **Step 1: Backend suite**
-
-Run: `cd backend && rm -f *.db; DATABASE_URL="sqlite:///./test_squadsync.db" SECRET_KEY=test python -m pytest -q; rm -f *.db`
-Expected: all pass.
-
-- [ ] **Step 2: Frontend gates + build**
-
-Run: `cd frontend && npx tsc --noEmit && npm run lint && npm test && NEXT_PUBLIC_API_URL=http://localhost:8000 AUTH_SECRET=build-check npm run build`
-Expected: tsc clean; lint no new errors; all tests pass; build succeeds.
-
-- [ ] **Step 3: Commit any fixes** (only if needed)
-
-```bash
-git add -A && git commit -m "chore(ai-visibility): verification fixes"
-```
-
----
-
-## Self-Review (completed by author)
-
-- **Spec coverage:** counts API (P1), use-allocation types (P2), results note (P4), attendees badges (P3), engine copy fix (P4), guide step + screenshot (P5), README (P6), cleanup (P7), testing (each phase + P8). Plus the discovered `TeamMember` bugfix (P2). ✅
-- **Type consistency:** `ai_normalized`/`auto_normalized` used identically across `AllocationOut`, `_build_allocation_out`, `Allocation` type, `normalizationNote`, and `results-grid`; `normalize_pending` returns `{"ai","fallback"}` matching the test; `SourceBadge` labels (AI/Auto/Manual/preset) match `strength_source` values (`ai`/`fallback`/`manual`/`preset`). ✅
-- **Placeholders:** none — complete code per step; the one runtime step (P5 capture) is controller-run with exact commands. ✅
-- **YAGNI:** counts derived from existing data; pure-function/presentational units chosen specifically for reliable tests (no SWR/session wrestling). ✅
diff --git a/docs/superpowers/plans/2026-06-17-breadcrumb-navigation.md b/docs/superpowers/plans/2026-06-17-breadcrumb-navigation.md
deleted file mode 100644
index 4d22fcf..0000000
--- a/docs/superpowers/plans/2026-06-17-breadcrumb-navigation.md
+++ /dev/null
@@ -1,466 +0,0 @@
-# Breadcrumb Navigation Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add breadcrumb navigation to every nested dashboard page so users can see where they are and climb back to the parent event / section.
-
-**Architecture:** A generic presentational `Breadcrumb` component (semantic `