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 ( + + + + + + Pay out {team.name} + + + Send a Lightning payout to all members of this team via NWC. + + + +
+ {/* Total sats input */} +
+ + setTotalSats(Math.max(0, parseInt(e.target.value, 10) || 0))} + disabled={!!payout} + /> +
+ + {/* NWC input */} +
+ + setNwc(e.target.value)} + placeholder="nostr+walletconnect://..." + disabled={!!payout} + /> +

+ Paste an NWC string from Alby, Coinos, or Alby Hub. Used once to send — never stored. +

+
+ + {/* Split preview + address overrides */} +
+

Split preview

+
+ {team.members.map((member, i) => { + const memberSats = base + (i < rem ? 1 : 0); + return ( +
+
+ {member.name} + {memberSats} sats +
+ + setAddresses(prev => ({ ...prev, [member.id]: e.target.value })) + } + className="h-7 text-xs" + disabled={!!payout} + /> +
+ ); + })} +
+
+ + {/* Results */} + {payout && ( +
+
+

Payout results

+ + {payout.status} + +
+
+ {payout.items.map(item => { + const member = team.members.find(m => m.id === item.participant_id); + return ( +
+ {item.status === "paid" ? ( + + ) : ( + + )} +
+ {member?.name ?? item.participant_id} + {item.status === "paid" ? ( + + {item.amount_sats} sats + {item.preimage && ( + + · {item.preimage.slice(0, 12)}… + + )} + + ) : ( + {item.error} + )} +
+
+ ); + })} +
+
+ )} +
+ + + {!payout && ( + + )} + {payout && hasFailedItems && ( + + )} + + +
+
+ ); +} 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 && ( + )}
diff --git a/frontend/hooks/use-allocation.ts b/frontend/hooks/use-allocation.ts index 9f9d76f..8234b4a 100644 --- a/frontend/hooks/use-allocation.ts +++ b/frontend/hooks/use-allocation.ts @@ -82,3 +82,35 @@ export async function regenerateAllocation(token: string, eventId: string) { // The allocate endpoint reseeds and replaces the draft, so this yields a new draft. return runAllocation(token, eventId); } + +export interface PayoutItem { + id: string; + participant_id: string; + lightning_address: string | null; + amount_sats: number; + status: string; + preimage: string | null; + error: string | null; +} + +export interface Payout { + id: string; + event_id: string; + allocation_id: string; + team_label: string; + total_sats: number; + status: string; + items: PayoutItem[]; +} + +export async function createPayout( + token: string, + allocationId: string, + body: { team_id: string; total_sats: number; nwc: string; addresses?: Record } +) { + return fetchAPI(`/api/v1/allocations/${allocationId}/payouts`, { method: "POST", body, token }); +} + +export async function retryPayout(token: string, payoutId: string, nwc: string) { + return fetchAPI(`/api/v1/allocations/payouts/${payoutId}/retry`, { method: "POST", body: { nwc }, token }); +} From fe9c54054479570f2650deda826f165461c88553 Mon Sep 17 00:00:00 2001 From: comwanga Date: Thu, 18 Jun 2026 00:12:24 +0300 Subject: [PATCH 13/16] feat(payout): prize-paid badge on public results --- frontend/app/results/[allocationId]/page.tsx | 83 +++++++++++++------- 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/frontend/app/results/[allocationId]/page.tsx b/frontend/app/results/[allocationId]/page.tsx index 6289908..feb353a 100644 --- a/frontend/app/results/[allocationId]/page.tsx +++ b/frontend/app/results/[allocationId]/page.tsx @@ -1,6 +1,6 @@ import { fetchAPI } from "@/lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Star } from "lucide-react"; +import { Star, Zap } from "lucide-react"; import { Logo } from "@/components/brand/logo"; import { FindMyTeam } from "@/components/results/find-my-team"; @@ -11,7 +11,8 @@ interface Team { fairness_score?: number; members: TeamMember[]; } -interface AllocationResult { id: string; status: string; teams: Team[]; } +interface PayoutSummary { team_label: string; total_sats: number; status: string; paid_count: number; member_count: number; } +interface AllocationResult { id: string; status: string; teams: Team[]; payouts?: PayoutSummary[]; } async function getAllocation(id: string): Promise { try { @@ -41,33 +42,57 @@ export default async function ResultsPage({ params }: { params: Promise<{ alloca

Team Allocation Results

-
- {result.teams.map(team => ( - - -
- {team.name} - {team.fairness_score && ( - - - {team.fairness_score.toFixed(0)}% - - )} -
-
- -
    - {team.members.map(m => ( -
  • - {m.name} - {(m.normalized_strength ?? "").replaceAll("_", " ")} -
  • - ))} -
-
-
- ))} -
+ {result.payouts && result.payouts.length > 0 && ( +
+ {result.payouts.map((p, i) => ( +
+ + ⚡ Prize paid — {p.team_label} + {p.total_sats.toLocaleString()} sats → {p.paid_count}/{p.member_count} paid +
+ ))} +
+ )} + {(() => { + const paidTeamLabels = new Set((result.payouts ?? []).map(p => p.team_label)); + return ( +
+ {result.teams.map(team => ( + + +
+ {team.name} +
+ {paidTeamLabels.has(team.name) && ( + + + Paid + + )} + {team.fairness_score && ( + + + {team.fairness_score.toFixed(0)}% + + )} +
+
+
+ +
    + {team.members.map(m => ( +
  • + {m.name} + {(m.normalized_strength ?? "").replaceAll("_", " ")} +
  • + ))} +
+
+
+ ))} +
+ ); + })()} ); 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 `