From a439e1d432a0acecb949e7e83383384cd598a73a Mon Sep 17 00:00:00 2001 From: comwanga Date: Sat, 20 Jun 2026 16:12:27 +0300 Subject: [PATCH 1/8] fix(nostr): validate bech32 checksum when decoding npub/nsec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled decoder dropped the 6-char checksum without verifying it, so a single mistyped character in an npub decoded to a different (wrong) 32-byte key and was silently accepted — a feedback DM or future zap would then go nowhere. Add the standard BIP-173 polymod checksum verification. This also surfaced a long-standing typo in the nsec test vector (...nlfe9 → ...nlfe5): its body always decoded correctly, so the missing checksum check hid the bad tail. --- backend/app/services/nostr_service.py | 28 ++++++++++++++++++++++----- backend/tests/test_nostr_service.py | 24 +++++++++++++++++++---- backend/tests/test_registration.py | 2 +- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/backend/app/services/nostr_service.py b/backend/app/services/nostr_service.py index be52d02..631b166 100644 --- a/backend/app/services/nostr_service.py +++ b/backend/app/services/nostr_service.py @@ -22,26 +22,44 @@ _BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +def _bech32_polymod(values: list[int]) -> int: + """BIP-173 checksum polymod over hrp-expansion + data (incl. checksum).""" + generator = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3) + chk = 1 + for value in values: + top = chk >> 25 + chk = ((chk & 0x1FFFFFF) << 5) ^ value + for i in range(5): + chk ^= generator[i] if ((top >> i) & 1) else 0 + return chk + + +def _bech32_hrp_expand(hrp: str) -> list[int]: + return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + + def bech32_decode(bech: str) -> tuple[str, bytes]: """Decode a bech32 `npub`/`nsec` to (hrp, 32-byte key). - Minimal decoder: splits on the last '1', drops the 6-char checksum, and - converts the 5-bit data groups to 8-bit bytes. Sufficient for npub/nsec. + Validates the BIP-173 checksum so a single mistyped character is rejected + rather than silently decoded to a different (wrong) key. Sufficient for + npub/nsec (bech32, not bech32m). """ bech = bech.strip().lower() pos = bech.rfind("1") - if pos < 1: + if pos < 1 or pos + 7 > len(bech): # need a non-empty hrp + 6-char checksum raise ValueError("invalid bech32 string") hrp = bech[:pos] try: data = [_BECH32_CHARSET.index(c) for c in bech[pos + 1:]] except ValueError as exc: raise ValueError("invalid bech32 character") from exc - data = data[:-6] # drop checksum + if _bech32_polymod(_bech32_hrp_expand(hrp) + data) != 1: + raise ValueError("invalid bech32 checksum") acc = 0 bits = 0 out = bytearray() - for value in data: + for value in data[:-6]: # drop the now-verified 6-char checksum acc = (acc << 5) | value bits += 5 if bits >= 8: diff --git a/backend/tests/test_nostr_service.py b/backend/tests/test_nostr_service.py index ddee094..1d0a2da 100644 --- a/backend/tests/test_nostr_service.py +++ b/backend/tests/test_nostr_service.py @@ -38,7 +38,7 @@ def test_bech32_decode_npub_vector(): def test_bech32_decode_nsec_vector(): hrp, key = nostr_service.bech32_decode( - "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe9" + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5" ) assert hrp == "nsec" assert key.hex() == "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa" @@ -95,7 +95,7 @@ def test_send_dm_returns_true_when_a_relay_accepts(monkeypatch): monkeypatch.setattr( nostr_service.settings, "SQUADSYNC_NSEC", - "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe9", + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", raising=False, ) monkeypatch.setattr(nostr_service.settings, "NOSTR_RELAYS", "wss://relay.test", raising=False) @@ -119,7 +119,7 @@ def test_send_dm_swallows_publish_errors(monkeypatch): monkeypatch.setattr( nostr_service.settings, "SQUADSYNC_NSEC", - "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe9", + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", raising=False, ) @@ -139,9 +139,25 @@ def test_validate_npub_accepts_valid(): def test_validate_npub_rejects_nsec(): with pytest.raises(ValueError): - nostr_service.validate_npub("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe9") + nostr_service.validate_npub("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5") def test_validate_npub_rejects_garbage(): with pytest.raises(ValueError): nostr_service.validate_npub("not-an-npub") + + +def test_bech32_decode_rejects_corrupted_checksum(): + # Flip one data character of a valid npub; the bech32 checksum must catch it + # instead of silently decoding to a different (wrong) 32-byte key. + with pytest.raises(ValueError): + nostr_service.bech32_decode( + "npub1q0cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6" + ) + + +def test_validate_npub_rejects_corrupted_checksum(): + with pytest.raises(ValueError): + nostr_service.validate_npub( + "npub1q0cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6" + ) diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index 0130d4b..cb85602 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -131,7 +131,7 @@ def test_register_rejects_malformed_npub(client, auth_headers): def test_register_rejects_nsec_as_npub(client, auth_headers): e = _active_event(client, auth_headers) res = _register(client, e["registration_slug"], email="nsec@t.com", - npub="nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe9") + npub="nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5") assert res.status_code == 422 From b4800ede1d91af56488a60589168cb9f831376b9 Mon Sep 17 00:00:00 2001 From: comwanga Date: Sat, 20 Jun 2026 16:16:32 +0300 Subject: [PATCH 2/8] fix(ai): batch categorization so large events don't silently fall back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single Claude call with max_tokens=1024 truncated its tool output past ~14 "Other" registrants; the partial JSON failed to parse and the whole event silently dropped to the per-slug deterministic fallback — exactly at the scale where AI normalization matters most. Process participants in capped batches with raised max_tokens, so output can't truncate and one failing batch only forces its own members to fallback. Move the static taxonomy + instructions into a cacheable system block, and let the model OMIT a participant it can't confidently place (handled by fallback / manual override) rather than forcing a guess. Prompt-building and response parsing are now pure helpers with direct unit tests. --- .../app/services/categorization_service.py | 85 ++++++++++++++----- backend/tests/test_categorization.py | 76 +++++++++++++++++ 2 files changed, 139 insertions(+), 22 deletions(-) diff --git a/backend/app/services/categorization_service.py b/backend/app/services/categorization_service.py index e3a502c..dbc1e40 100644 --- a/backend/app/services/categorization_service.py +++ b/backend/app/services/categorization_service.py @@ -17,12 +17,32 @@ logger = logging.getLogger(__name__) +# Cap participants per AI call so a batch's tool-use output cannot exceed +# _MAX_TOKENS and truncate — a truncated tool call would fail to parse and +# silently drop the whole batch to the deterministic fallback. +_BATCH_SIZE = 25 +_MAX_TOKENS = 4096 + +_SYSTEM_INSTRUCTIONS = ( + "You normalize each participant's free-text strength into exactly one of a " + "fixed set of categories so a downstream deterministic engine can compare " + "them. You never assign teams. Map each participant to the single best-fit " + "category. If a participant's text is too vague, empty, or unrelated to every " + "category, OMIT them from the assignments instead of guessing — an omitted " + "participant is handled by a deterministic fallback and an organizer can set " + "their category by hand." +) + def _slug(text: str) -> str: s = re.sub(r"[^a-z0-9]+", "_", text.strip().lower()).strip("_") return s or "other" +def _category_catalog() -> str: + return ", ".join(f"{v} ({STRENGTH_LABELS[v]})" for v in CONCRETE_STRENGTHS) + + def _pending(db: Session, event_id: UUID) -> list[Participant]: return ( db.query(Participant) @@ -36,12 +56,12 @@ def _pending(db: Session, event_id: UUID) -> list[Participant]: ) -def _classify(event: Event, participants: list[Participant]) -> dict[str, str]: - """Call Claude; return {participant_id: concrete_category}. Raises on failure.""" - import anthropic +def _build_request(event: Event, participants: list[Participant]) -> dict: + """Build the Messages API kwargs. Pure (no network) so it is unit-testable. - client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY) - categories = ", ".join(f"{v} ({STRENGTH_LABELS[v]})" for v in CONCRETE_STRENGTHS) + The static instructions + category catalog go in a cacheable system block; + the per-event context and participant list go in the user message. + """ people = "\n".join(f"- id={p.id}: {p.strength_other}" for p in participants) tool = { "name": "assign_categories", @@ -64,29 +84,47 @@ def _classify(event: Event, participants: list[Participant]) -> dict[str, str]: "required": ["assignments"], }, } - msg = client.messages.create( - model=settings.CATEGORIZATION_MODEL, - max_tokens=1024, - tools=[tool], - tool_choice={"type": "tool", "name": "assign_categories"}, - messages=[{ + return { + "model": settings.CATEGORIZATION_MODEL, + "max_tokens": _MAX_TOKENS, + "system": [{ + "type": "text", + "text": f"{_SYSTEM_INSTRUCTIONS}\n\nAvailable categories: {_category_catalog()}", + "cache_control": {"type": "ephemeral"}, + }], + "tools": [tool], + "tool_choice": {"type": "tool", "name": "assign_categories"}, + "messages": [{ "role": "user", "content": ( f"Event: {event.title}\nDescription: {event.description or '(none)'}\n\n" - f"Available categories: {categories}\n\n" - f"Map each participant's free-text strength to the best category.\n{people}" + f"Map each participant's free-text strength to the best category, " + f"omitting any that are too unclear to place.\n{people}" ), }], - ) + } + + +def _parse_assignments(content_blocks) -> dict[str, str]: + """Extract {id: category} from tool_use blocks, dropping out-of-taxonomy values.""" out: dict[str, str] = {} - for block in msg.content: + for block in content_blocks: if getattr(block, "type", None) == "tool_use": - for a in block.input["assignments"]: - if a["category"] in CONCRETE_STRENGTHS: + for a in block.input.get("assignments", []): + if a.get("category") in CONCRETE_STRENGTHS and a.get("id"): out[a["id"]] = a["category"] return out +def _classify(event: Event, participants: list[Participant]) -> dict[str, str]: + """Call Claude for one batch; return {participant_id: concrete_category}. Raises on failure.""" + import anthropic + + client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY) + msg = client.messages.create(**_build_request(event, participants)) + return _parse_assignments(msg.content) + + def normalize_pending(db: Session, event_id: UUID) -> dict[str, int]: """Fill normalized_strength for un-normalized Other entries. Never raises. @@ -101,11 +139,14 @@ def normalize_pending(db: Session, event_id: UUID) -> dict[str, int]: 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 = {} + # Batch so output can't truncate, and so one failing batch only forces its + # own members to the fallback rather than dumping everyone. + for i in range(0, len(pending), _BATCH_SIZE): + batch = pending[i:i + _BATCH_SIZE] + try: + mapping.update(_classify(event, batch)) + except Exception as exc: # noqa: BLE001 — AI is best-effort, per batch + logger.warning("Categorization AI batch failed, using fallback: %s", exc) for p in pending: ai_cat = mapping.get(str(p.id)) diff --git a/backend/tests/test_categorization.py b/backend/tests/test_categorization.py index f65ec69..3994fc0 100644 --- a/backend/tests/test_categorization.py +++ b/backend/tests/test_categorization.py @@ -2,6 +2,7 @@ import uuid from app.core.database import Base +from app.core.taxonomy import CONCRETE_STRENGTHS from app.models.event import Event from app.models.participant import Participant from app.models.user import User @@ -64,6 +65,81 @@ def test_fallback_when_no_key(db, monkeypatch): assert p.strength_source == "fallback" +def _add_other(db, event_id, n, prefix): + for i in range(n): + db.add(Participant( + event_id=event_id, name=f"{prefix}{i}", email=f"{prefix}{i}@t.com", + primary_strength="other", strength_other=f"role {i}", + experience_level="beginner", strength_source="preset", + composite_score=1.0, tech_stack=[], interests=[], + )) + db.commit() + + +def test_build_request_has_cached_system_and_raised_max_tokens(db): + e, p = _event_with_other(db) + req = cat._build_request(e, [p]) + # Static taxonomy + instructions live in a cacheable system block. + assert isinstance(req["system"], list) + assert req["system"][0]["cache_control"]["type"] == "ephemeral" + # Headroom so a full batch's tool output can't truncate (was 1024). + assert req["max_tokens"] >= 2048 + # The tool still constrains output to the concrete categories. + enum = req["tools"][0]["input_schema"]["properties"]["assignments"]["items"]["properties"]["category"]["enum"] + assert enum == list(CONCRETE_STRENGTHS) + # The participant and an abstention instruction both reach the model. + user_text = req["messages"][0]["content"] + assert str(p.id) in user_text + blob = (req["system"][0]["text"] + user_text).lower() + assert "omit" in blob + + +def test_parse_assignments_keeps_valid_drops_invalid(): + class Block: + type = "tool_use" + input = {"assignments": [ + {"id": "a", "category": "research"}, + {"id": "b", "category": "not_a_category"}, # outside the taxonomy -> dropped + ]} + assert cat._parse_assignments([Block()]) == {"a": "research"} + + +def test_normalize_batches_large_pending(db, monkeypatch): + e, _ = _event_with_other(db) # 1 existing "other" + extra = cat._BATCH_SIZE + 4 + _add_other(db, e.id, extra, "x") + monkeypatch.setattr(cat.settings, "ANTHROPIC_API_KEY", "k") + sizes = [] + + def fake_classify(event, parts): + sizes.append(len(parts)) + return {str(pp.id): "research" for pp in parts} + + monkeypatch.setattr(cat, "_classify", fake_classify) + counts = cat.normalize_pending(db, e.id) + assert counts["ai"] == extra + 1 # everyone categorized, none lost + assert len(sizes) >= 2 # split across batches + assert max(sizes) <= cat._BATCH_SIZE # no batch exceeds the cap + + +def test_normalize_isolates_batch_failure(db, monkeypatch): + e, _ = _event_with_other(db) # 1 in batch 2 + _add_other(db, e.id, cat._BATCH_SIZE, "y") # fills batch 1 + monkeypatch.setattr(cat.settings, "ANTHROPIC_API_KEY", "k") + calls = {"n": 0} + + def flaky(event, parts): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("boom") # only the first batch fails + return {str(pp.id): "research" for pp in parts} + + monkeypatch.setattr(cat, "_classify", flaky) + counts = cat.normalize_pending(db, e.id) + # A single failed batch falls back locally; the rest still get AI — never all-or-nothing. + assert counts["ai"] >= 1 and counts["fallback"] >= 1 + + def test_manual_override_not_touched(db, monkeypatch): e, p = _event_with_other(db) monkeypatch.setattr(cat.settings, "ANTHROPIC_API_KEY", "test-key") From b18c25ef7762ae656460d25baaa3345fb06a44a2 Mon Sep 17 00:00:00 2001 From: comwanga Date: Sat, 20 Jun 2026 16:21:25 +0300 Subject: [PATCH 3/8] feat(payout): add a configurable spend ceiling; drop unused requests dep total_sats was bounded only by gt=0, so one extra zero could fire off a huge payout with a single click. Add a configurable PAYOUT_MAX_SATS ceiling (default 5,000,000) enforced before a wallet is ever touched. Also remove the unused `requests` dependency (only httpx is used). --- backend/.env.example | 6 ++++++ backend/app/api/v1/payouts.py | 9 +++++++++ backend/app/core/config.py | 3 +++ backend/requirements.txt | 1 - backend/tests/test_payout_endpoint.py | 17 +++++++++++++++++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/backend/.env.example b/backend/.env.example index df356b6..f8935bc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,3 +3,9 @@ SECRET_KEY=change-me-to-a-long-random-string ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=1440 FRONTEND_URL=http://localhost:3000 + +# --- Optional --- +# Enables AI normalization of free-text "Other" strengths (deterministic fallback when unset). +# ANTHROPIC_API_KEY=sk-ant-... +# Hard ceiling (sats) on a single team payout; a safety net against a fat-finger amount. +# PAYOUT_MAX_SATS=5000000 diff --git a/backend/app/api/v1/payouts.py b/backend/app/api/v1/payouts.py index ccafc57..78c1acd 100644 --- a/backend/app/api/v1/payouts.py +++ b/backend/app/api/v1/payouts.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_db +from app.core.config import settings from app.models.allocation import Allocation from app.models.payout import Payout, PayoutItem from app.models.team import Team @@ -38,6 +39,14 @@ def create_payout( if not team: raise HTTPException(status_code=404, detail="Team not found in this allocation") + # Spend ceiling: reject an implausibly large amount before touching a wallet. + if req.total_sats > settings.PAYOUT_MAX_SATS: + raise HTTPException( + status_code=422, + detail=f"total_sats {req.total_sats} exceeds the payout ceiling " + f"of {settings.PAYOUT_MAX_SATS} sats", + ) + # Idempotency: refuse a second payout for a team that already has one, so a # double-click or a client retry after a timeout can never pay winners twice. if db.query(Payout).filter( diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4235ef3..857a915 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -20,6 +20,9 @@ def cors_origins(self) -> list[str]: # request URL FastAPI sees (http, internal host) differs from the signed URL. # When unset, the live request URL is used (correct for local/dev). PUBLIC_API_URL: str | None = None + # Hard ceiling on a single team payout (sats). A safety net against a + # fat-finger (extra zeros) — the request is refused before anything is sent. + PAYOUT_MAX_SATS: int = 5_000_000 # Optional: enables AI normalization of free-text "Other" strengths. # When unset, allocation falls back to a deterministic slug per Other entry. ANTHROPIC_API_KEY: str | None = None diff --git a/backend/requirements.txt b/backend/requirements.txt index b5974be..5273854 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,7 +8,6 @@ python-jose[cryptography]==3.3.0 coincurve==20.0.0 httpx==0.27.0 python-multipart==0.0.9 -requests==2.32.3 psycopg2-binary==2.9.9 reportlab==4.2.0 qrcode[pil]==7.4.2 diff --git a/backend/tests/test_payout_endpoint.py b/backend/tests/test_payout_endpoint.py index 91f6beb..20d09a3 100644 --- a/backend/tests/test_payout_endpoint.py +++ b/backend/tests/test_payout_endpoint.py @@ -113,6 +113,23 @@ def test_payout_idempotent_second_call_rejected(client, auth_headers, monkeypatc assert len(paid) == paid_after_first # no second round of payments +def test_payout_rejects_amount_over_ceiling(client, auth_headers, monkeypatch): + # A fat-finger (extra zeros) must be refused before any sats are sent. + from app.core.config import settings as app_settings + monkeypatch.setattr(app_settings, "PAYOUT_MAX_SATS", 500, raising=False) + _, allocation_id, team_id, _ = _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": 501, + "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00", + }) + + assert res.status_code == 422, res.text + assert "exceeds" in res.text.lower() + assert paid == [] # nothing sent + + 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) From d012a596e3108f12b6cc4c612d6fdf1591de1798 Mon Sep 17 00:00:00 2001 From: comwanga Date: Sat, 20 Jun 2026 16:32:59 +0300 Subject: [PATCH 4/8] feat(payout): allow retry with corrected addresses A payout that failed because an address was wrong had no recovery path: the unique-per-team constraint blocks a new payout, and retry reused the stored (bad) address. Let retry accept an optional address-correction map, applied to failed items before re-resolving. The modal re-enables the address fields when items can be retried and sends the corrections. --- backend/app/api/v1/payouts.py | 2 +- backend/app/schemas/payout.py | 4 +++ backend/app/services/payout_service.py | 15 ++++++++-- backend/tests/test_payout_endpoint.py | 31 +++++++++++++++++++++ frontend/components/engine/payout-modal.tsx | 9 ++++-- frontend/hooks/use-allocation.ts | 13 +++++++-- 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/backend/app/api/v1/payouts.py b/backend/app/api/v1/payouts.py index 78c1acd..78af3ee 100644 --- a/backend/app/api/v1/payouts.py +++ b/backend/app/api/v1/payouts.py @@ -91,5 +91,5 @@ def retry_payout( 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) + payout = payout_service.retry_failed(db, payout, req.nwc, req.addresses) return _payout_out(db, payout) diff --git a/backend/app/schemas/payout.py b/backend/app/schemas/payout.py index 0809435..96d5f87 100644 --- a/backend/app/schemas/payout.py +++ b/backend/app/schemas/payout.py @@ -14,6 +14,10 @@ class PayoutCreate(BaseModel): class PayoutRetry(BaseModel): nwc: str = Field(min_length=1) + # Optional per-member address corrections applied to failed items before retry: + # {str(participant_id): "name@domain"}. Lets an organizer recover a payout that + # failed because an address was wrong, without creating a new payout. + addresses: Optional[dict[str, str]] = None class PayoutItemOut(BaseModel): diff --git a/backend/app/services/payout_service.py b/backend/app/services/payout_service.py index 4a67da7..a3b4222 100644 --- a/backend/app/services/payout_service.py +++ b/backend/app/services/payout_service.py @@ -105,12 +105,23 @@ def execute_payout( return payout -def retry_failed(db: Session, payout: Payout, nwc: str) -> Payout: - """Retry only the failed items of an existing payout.""" +def retry_failed( + db: Session, payout: Payout, nwc: str, overrides: dict[str, str] | None = None, +) -> Payout: + """Retry only the failed items of an existing payout. + + `overrides` maps `str(participant_id) -> lightning_address` and corrects a bad + address before the retry, so a payout that failed purely on addresses can be + recovered without creating a new one. + """ + overrides = overrides or {} items = db.query(PayoutItem).filter(PayoutItem.payout_id == payout.id).all() for item in items: if item.status != "failed": continue + corrected = overrides.get(str(item.participant_id)) + if corrected: + item.lightning_address = corrected try: params = lnurl_service.resolve_lnurl(item.lightning_address) invoice = lnurl_service.request_invoice(params, item.amount_sats) diff --git a/backend/tests/test_payout_endpoint.py b/backend/tests/test_payout_endpoint.py index 20d09a3..e3c960c 100644 --- a/backend/tests/test_payout_endpoint.py +++ b/backend/tests/test_payout_endpoint.py @@ -130,6 +130,37 @@ def test_payout_rejects_amount_over_ceiling(client, auth_headers, monkeypatch): assert paid == [] # nothing sent +def test_retry_with_corrected_addresses_recovers(client, auth_headers, monkeypatch): + # A payout that fully failed (bad addresses) must be recoverable: retry with + # corrected addresses pays the team without needing a brand-new payout. + _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True) + from app.services import lnurl_service + + def _boom(addr): + raise lnurl_service.LnurlError("unresolvable") + + monkeypatch.setattr(lnurl_service, "resolve_lnurl", _boom) + first = 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 first.status_code == 201 + payout_id = first.json()["id"] + assert first.json()["status"] == "failed" + + # Fix the network and supply corrected addresses on retry. + _stub_lightning(monkeypatch) + corrected = {m["id"]: f"fixed-{m['name']}@getalby.com" for m in members} + res = client.post(f"/api/v1/allocations/payouts/{payout_id}/retry", headers=auth_headers, json={ + "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00", + "addresses": corrected, + }) + assert res.status_code == 200, res.text + body = res.json() + assert body["status"] == "complete" + assert all(i["lightning_address"].startswith("fixed-") for i in body["items"]) + + 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) diff --git a/frontend/components/engine/payout-modal.tsx b/frontend/components/engine/payout-modal.tsx index ca069e5..07e5045 100644 --- a/frontend/components/engine/payout-modal.tsx +++ b/frontend/components/engine/payout-modal.tsx @@ -80,7 +80,11 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo if (!session?.accessToken || !payout) return; setSending(true); try { - const result = await retryPayout(session.accessToken, payout.id, nwc); + const corrected: Record = {}; + for (const [id, addr] of Object.entries(addresses)) { + if (addr.trim()) corrected[id] = addr.trim(); + } + const result = await retryPayout(session.accessToken, payout.id, nwc, corrected); setPayout(result); toast.success("Retry complete"); } catch (err: unknown) { @@ -156,7 +160,8 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo setAddresses(prev => ({ ...prev, [member.id]: e.target.value })) } className="h-7 text-xs" - disabled={!!payout} + // Re-enable for correction when items failed and can be retried. + disabled={!!payout && !hasFailedItems} /> ); diff --git a/frontend/hooks/use-allocation.ts b/frontend/hooks/use-allocation.ts index 8234b4a..cef08e7 100644 --- a/frontend/hooks/use-allocation.ts +++ b/frontend/hooks/use-allocation.ts @@ -111,6 +111,15 @@ export async function createPayout( 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 }); +export async function retryPayout( + token: string, + payoutId: string, + nwc: string, + addresses?: Record +) { + return fetchAPI(`/api/v1/allocations/payouts/${payoutId}/retry`, { + method: "POST", + body: { nwc, ...(addresses && Object.keys(addresses).length > 0 ? { addresses } : {}) }, + token, + }); } From 91e3497d6ccd40886bfa6e2bc2690b364c9d13ce Mon Sep 17 00:00:00 2001 From: comwanga Date: Sat, 20 Jun 2026 16:35:35 +0300 Subject: [PATCH 5/8] docs(spec): self-custody (client-side NWC) payout design Architecture decision record for moving the Lightning send into the organizer's browser: server computes the split and verifies reported preimages, but the NWC spend credential never reaches the backend. Resolves the self-custody and blocking/timeout audit findings together. --- .../2026-06-20-self-custody-payouts-design.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-self-custody-payouts-design.md diff --git a/docs/superpowers/specs/2026-06-20-self-custody-payouts-design.md b/docs/superpowers/specs/2026-06-20-self-custody-payouts-design.md new file mode 100644 index 0000000..a784cf0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-self-custody-payouts-design.md @@ -0,0 +1,108 @@ +# SquadSync ⚡ Self-Custody Payouts (client-side NWC) Design + +**Date:** 2026-06-20 +**Status:** Approved +**Branch:** `fix/payout-correctness` (off `main`) +**Builds on:** the shipped Lightning payout (`payout_service`, `lnurl_service`, `nwc_service`, +`bolt11` preimage verification, idempotent per-team `Payout`). +**Context:** Audit finding — the NWC spend credential currently transits the backend, so a +compromised server could drain any connected wallet (bounded only by the wallet's NWC budget). +This is not self-custodial-grade. It also makes the payout a long, blocking HTTP request that +can time out mid-send. + +## Overview + +Move the Lightning send **into the organizer's browser**. The server computes the deterministic +split and records a payout, but the browser holds the NWC credential, resolves each recipient's +invoice, performs the NIP-47 `pay_invoice`, and reports each result back. The server **verifies** +every reported preimage against the invoice's payment hash before recording `paid`. The NWC +secret never leaves the client. + +This simultaneously resolves two audit findings: **self-custody** (server never sees the spend +key) and **blocking/timeout** (no server-side relay round-trips, so the HTTP request no longer +blocks on the network for the whole team). + +## Goals + +- The NWC spend credential is used only in the browser; the backend never receives it. +- The server remains the source of truth for the deterministic split and the payout receipt. +- Every `paid` item is backed by a server-verified preimage (`sha256(preimage) == payment_hash`). +- Per-member live status; failed/unverified items retryable from the browser. +- Backwards-compatible receipt: the public results summary is unchanged. + +## Non-Goals + +- Changing the split math, idempotency model, or the public summary shape. +- Removing `nwc_service` (kept as a reusable NIP-47 client; the browser reimplements the subset + it needs). Server-side execution is retired from the live path. +- BOLT12 / zaps / on-chain (separate, build on this contract). + +## Decisions (locked) + +- **Where the secret lives:** only in the browser, in component state, for the duration of the + payout. Never sent to the backend, never persisted, never logged. +- **Split authority:** the server computes `compute_split` and creates the `Payout` + `PayoutItem` + rows in `pending`. The browser may not invent amounts; it pays the items the server created. +- **Invoice resolution:** done in the browser (LUD-16 → LNURL-pay callback → bolt11). No secret + needed, and it keeps the server off the network during the send. +- **Proof:** the browser POSTs `{bolt11, preimage}` per item; the server recomputes the payment + hash from the bolt11 and checks the preimage (`bolt11.preimage_matches`) before marking `paid`. + A mismatch is recorded `unverified` (terminal), exactly as today. +- **Auth:** all payout endpoints stay organizer-authenticated (NIP-98). + +## API (organizer-authenticated, under `/api/v1`) + +- `POST /allocations/{id}/payouts` — body `{ team_id, total_sats, addresses? }` (**no `nwc`**). + Pre-flight (split + every member has an address, spend ceiling, idempotency) unchanged; creates + the `Payout` and `pending` `PayoutItem`s and returns them. **Does not send anything.** +- `POST /payouts/{payout_id}/items/{item_id}/result` — body `{ bolt11, preimage }`. Organizer-auth. + Verifies the preimage; sets the item `paid` or `unverified`; rolls up `Payout.status`. Idempotent + on an already-`paid` item (returns current state, never re-counts). +- `POST /payouts/{payout_id}/items/{item_id}/failed` — body `{ error }`. Marks an item `failed` + so the browser can record a send that never produced a preimage (LNURL/relay/wallet error). +- `POST /payouts/{id}/retry` — retained for re-driving `failed` items (browser re-pays, re-reports). + +The old server-side `nwc` body field and server execution are removed from `create_payout`. + +## Backend components + +- **`payout_service.create_pending(db, payout, splits)`** — write `pending` items, no network. +- **`payout_service.record_item_result(db, payout, item, bolt11, preimage)`** — verify + set + `paid`/`unverified`, roll up status. Pure of network. +- **`payout_service.record_item_failed(db, payout, item, error)`** — set `failed`, roll up. +- `execute_payout` / `retry_failed` (server-side NWC) are removed from the API path. + +## Frontend components + +- **`lib/lightning.ts`** (new) — `resolveInvoice(address, sats)`: LUD-16 fetch + callback fetch + → bolt11 (browser `fetch`). `payWithNwc(nwcUri, bolt11)`: parse URI, build + sign the kind-23194 + request (nostr-tools `finalizeEvent` + `nip04`), open a `WebSocket` to the relay, publish, await + the kind-23195 response, decrypt, return the preimage. Bounded timeout. +- **`payout-modal.tsx`** — on "Send payout": `createPayout` (no nwc) → for each returned item, + `resolveInvoice` → `payWithNwc` → POST `result` (or `failed`); update live status from the + server's verified responses. Retry re-drives `failed` items. + +## Data flow + +1. Organizer opens the modal, enters sats, pastes NWC (stays in the browser). +2. `POST /payouts` → server returns the payout with `pending` items (id, address, amount). +3. Browser loops items: resolve invoice → NWC pay → POST `{bolt11, preimage}` (or `{error}`). +4. Server verifies each preimage, records `paid`/`unverified`/`failed`, rolls up status. +5. Public results show the same redacted receipt. + +## Testing + +Backend (SQLite, matching existing suite): +- `create_payout` returns `pending` items and sends nothing (no nwc accepted). +- `result` endpoint: valid preimage → `paid` + rollup `complete`; bad preimage → `unverified`; + already-`paid` item is idempotent (no double count). +- `failed` endpoint marks the item and rolls up `partial`/`failed`. +- Auth: non-organizer is rejected on every new endpoint. + +Frontend: unit-test `resolveInvoice` URL/amount construction and the NWC request-event build +(mocked `fetch`/`WebSocket`). The relay round-trip itself is covered by manual mainnet verification. + +## Scope guardrails (YAGNI) + +Same split, same receipt, same idempotency. Only the *location* of the send and the secret moves +to the client; the server's new job is to verify proofs and keep the receipt. From ac02e76e7d1981ca1814e4e4240d36073e6191a6 Mon Sep 17 00:00:00 2001 From: comwanga Date: Sat, 20 Jun 2026 16:40:32 +0300 Subject: [PATCH 6/8] feat(payout): self-custody backend contract (verify browser-reported sends) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the server side of client-side NWC: when create_payout is called without an nwc credential, the server creates pending items and returns them for the browser to pay. Two new organizer-authenticated endpoints record each result — /items/{id}/result verifies the reported preimage against the invoice before marking paid (mismatch -> unverified), /items/{id}/failed records a no-preimage failure. Both roll the payout status up and are idempotent on already-paid items. The legacy server-side path still runs when nwc is supplied, so nothing breaks; the frontend switch and removal of the server-side credential path follow. --- backend/app/api/v1/payouts.py | 53 ++++++++++- backend/app/schemas/payout.py | 15 ++- backend/app/services/payout_service.py | 64 +++++++++++++ backend/tests/test_payout_selfcustody.py | 115 +++++++++++++++++++++++ 4 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_payout_selfcustody.py diff --git a/backend/app/api/v1/payouts.py b/backend/app/api/v1/payouts.py index 78af3ee..33b56f8 100644 --- a/backend/app/api/v1/payouts.py +++ b/backend/app/api/v1/payouts.py @@ -10,7 +10,9 @@ 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.schemas.payout import ( + PayoutCreate, PayoutRetry, PayoutOut, PayoutItemResult, PayoutItemFailed, +) from app.services.event_service import assert_allocation_organizer from app.services import payout_service @@ -76,7 +78,54 @@ def create_payout( status_code=status.HTTP_409_CONFLICT, detail="This team has already been paid; retry the existing payout instead.", ) - payout = payout_service.execute_payout(db, payout, splits, req.nwc) + if req.nwc: + # Legacy server-side path (deprecated): server holds the credential and pays. + payout = payout_service.execute_payout(db, payout, splits, req.nwc) + else: + # Self-custody path: create pending items; the browser pays and reports back. + payout = payout_service.create_pending(db, payout, splits) + return _payout_out(db, payout) + + +def _get_item(db: Session, payout_id: UUID, item_id: UUID, user_id: UUID) -> tuple[Payout, PayoutItem]: + """Load a payout + one of its items, asserting the caller is the organizer.""" + 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, user_id) + item = db.query(PayoutItem).filter( + PayoutItem.id == item_id, PayoutItem.payout_id == payout_id + ).first() + if not item: + raise HTTPException(status_code=404, detail="Payout item not found") + return payout, item + + +@router.post("/payouts/{payout_id}/items/{item_id}/result", response_model=PayoutOut) +def report_item_result( + payout_id: UUID, + item_id: UUID, + req: PayoutItemResult, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Self-custody: the browser reports a completed send; the server verifies the preimage.""" + payout, item = _get_item(db, payout_id, item_id, current_user.id) + payout = payout_service.record_item_result(db, payout, item, req.bolt11, req.preimage) + return _payout_out(db, payout) + + +@router.post("/payouts/{payout_id}/items/{item_id}/failed", response_model=PayoutOut) +def report_item_failed( + payout_id: UUID, + item_id: UUID, + req: PayoutItemFailed, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Self-custody: the browser reports a send that produced no preimage.""" + payout, item = _get_item(db, payout_id, item_id, current_user.id) + payout = payout_service.record_item_failed(db, payout, item, req.error) return _payout_out(db, payout) diff --git a/backend/app/schemas/payout.py b/backend/app/schemas/payout.py index 96d5f87..68b3df0 100644 --- a/backend/app/schemas/payout.py +++ b/backend/app/schemas/payout.py @@ -6,12 +6,25 @@ class PayoutCreate(BaseModel): team_id: UUID total_sats: int = Field(gt=0) - nwc: str = Field(min_length=1) + # Self-custody: when omitted, the server creates pending items and the browser + # performs the NIP-47 send, reporting each result back. The legacy server-side + # path (server holds the credential) runs only when `nwc` is supplied. + nwc: Optional[str] = None # 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 PayoutItemResult(BaseModel): + """A browser-performed send to report for one item.""" + bolt11: str = Field(min_length=1) + preimage: str = Field(min_length=1) + + +class PayoutItemFailed(BaseModel): + error: str = Field(min_length=1) + + class PayoutRetry(BaseModel): nwc: str = Field(min_length=1) # Optional per-member address corrections applied to failed items before retry: diff --git a/backend/app/services/payout_service.py b/backend/app/services/payout_service.py index a3b4222..9e3b676 100644 --- a/backend/app/services/payout_service.py +++ b/backend/app/services/payout_service.py @@ -66,6 +66,70 @@ def preflight( return [(m, addr, amount) for (m, addr), (_, amount) in zip(resolved, amounts)] +def _rollup_status(items: list[PayoutItem]) -> str: + """Derive a payout's status from its items. + + complete: every item paid. pending: nothing paid yet and some still pending. + partial: some paid with others still outstanding or terminally not-paid. + failed: all items resolved and none paid. + """ + n = len(items) + paid = sum(1 for i in items if i.status == "paid") + pending = any(i.status == "pending" for i in items) + if n and paid == n: + return "complete" + if pending: + return "partial" if paid else "pending" + return "partial" if paid else "failed" + + +def create_pending( + db: Session, payout: Payout, splits: list[tuple[Participant, str, int]], +) -> Payout: + """Self-custody path: persist pending items (no network). The browser pays them.""" + for participant, address, amount_sats in splits: + db.add(PayoutItem(payout_id=payout.id, participant_id=participant.id, + lightning_address=address, amount_sats=amount_sats, status="pending")) + payout.status = "pending" + db.commit() + db.refresh(payout) + return payout + + +def record_item_result( + db: Session, payout: Payout, item: PayoutItem, bolt11_str: str, preimage: str, +) -> Payout: + """Record a browser-performed send, verifying its preimage before marking paid. + + Idempotent on an already-paid item (a client retry must not re-count it). + """ + if item.status != "paid": + item.bolt11 = bolt11_str + item.preimage = preimage + if bolt11.preimage_matches(bolt11_str, preimage): + item.status, item.error = "paid", None + else: + item.status = "unverified" + item.error = "wallet returned a preimage that does not match the invoice" + items = db.query(PayoutItem).filter(PayoutItem.payout_id == payout.id).all() + payout.status = _rollup_status(items) + db.commit() + db.refresh(payout) + return payout + + +def record_item_failed(db: Session, payout: Payout, item: PayoutItem, error: str) -> Payout: + """Record that a browser send for one item failed (no preimage produced).""" + if item.status != "paid": + item.status = "failed" + item.error = error + items = db.query(PayoutItem).filter(PayoutItem.payout_id == payout.id).all() + payout.status = _rollup_status(items) + db.commit() + db.refresh(payout) + return payout + + def execute_payout( db: Session, payout: Payout, splits: list[tuple[Participant, str, int]], nwc: str, ) -> Payout: diff --git a/backend/tests/test_payout_selfcustody.py b/backend/tests/test_payout_selfcustody.py new file mode 100644 index 0000000..3b99b4d --- /dev/null +++ b/backend/tests/test_payout_selfcustody.py @@ -0,0 +1,115 @@ +"""Self-custody payout contract: the server creates pending items and verifies +reported preimages, but never receives the NWC spend credential (the browser +does the NIP-47 send). See docs/superpowers/specs/2026-06-20-self-custody-payouts-design.md. +""" +import hashlib + +from tests.test_payout_endpoint import _setup_team +from tests.lightning_helpers import invoice_for_preimage + + +def _report_result(client, headers, payout_id, item_id, preimage): + invoice = invoice_for_preimage(preimage) + return client.post( + f"/api/v1/allocations/payouts/{payout_id}/items/{item_id}/result", + headers=headers, json={"bolt11": invoice, "preimage": preimage}, + ) + + +def test_create_without_nwc_returns_pending_and_sends_nothing(client, auth_headers): + _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True) + res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={ + "team_id": str(team_id), "total_sats": 210, # no nwc -> self-custody path + }) + assert res.status_code == 201, res.text + body = res.json() + assert body["status"] == "pending" + assert len(body["items"]) == len(members) + assert all(i["status"] == "pending" and i["preimage"] is None for i in body["items"]) + assert sum(i["amount_sats"] for i in body["items"]) == 210 + + +def test_reporting_valid_preimages_marks_paid_and_completes(client, auth_headers): + _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True) + payout = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, + json={"team_id": str(team_id), "total_sats": 210}).json() + + final = None + for item in payout["items"]: + preimage = hashlib.sha256(item["id"].encode()).hexdigest() + res = _report_result(client, auth_headers, payout["id"], item["id"], preimage) + assert res.status_code == 200, res.text + final = res.json() + + assert final["status"] == "complete" + assert all(i["status"] == "paid" and i["preimage"] for i in final["items"]) + + +def test_reporting_mismatched_preimage_is_unverified(client, auth_headers): + _, allocation_id, team_id, _ = _setup_team(client, auth_headers, all_have_addresses=True) + payout = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, + json={"team_id": str(team_id), "total_sats": 210}).json() + item = payout["items"][0] + # bolt11 commits to one payment hash; report a preimage for a different one. + res = client.post( + f"/api/v1/allocations/payouts/{payout['id']}/items/{item['id']}/result", + headers=auth_headers, + json={"bolt11": invoice_for_preimage("11" * 32), "preimage": "22" * 32}, + ) + assert res.status_code == 200, res.text + reported = next(i for i in res.json()["items"] if i["id"] == item["id"]) + assert reported["status"] == "unverified" + + +def test_reporting_failure_marks_item_failed(client, auth_headers): + _, allocation_id, team_id, _ = _setup_team(client, auth_headers, all_have_addresses=True) + payout = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, + json={"team_id": str(team_id), "total_sats": 210}).json() + item = payout["items"][0] + res = client.post( + f"/api/v1/allocations/payouts/{payout['id']}/items/{item['id']}/failed", + headers=auth_headers, json={"error": "wallet declined"}, + ) + assert res.status_code == 200, res.text + reported = next(i for i in res.json()["items"] if i["id"] == item["id"]) + assert reported["status"] == "failed" + assert reported["error"] == "wallet declined" + + +def test_reporting_result_is_idempotent_on_paid_item(client, auth_headers): + _, allocation_id, team_id, _ = _setup_team(client, auth_headers, all_have_addresses=True) + payout = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, + json={"team_id": str(team_id), "total_sats": 210}).json() + item = payout["items"][0] + preimage = hashlib.sha256(item["id"].encode()).hexdigest() + first = _report_result(client, auth_headers, payout["id"], item["id"], preimage).json() + paid_preimage = next(i for i in first["items"] if i["id"] == item["id"])["preimage"] + + # A duplicate report (client retry) must not change or re-count the paid item. + second = _report_result(client, auth_headers, payout["id"], item["id"], preimage).json() + again = next(i for i in second["items"] if i["id"] == item["id"]) + assert again["status"] == "paid" + assert again["preimage"] == paid_preimage + + +def test_result_endpoint_requires_organizer(client, auth_headers, nostr_privkey): + _, allocation_id, team_id, _ = _setup_team(client, auth_headers, all_have_addresses=True) + payout = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, + json={"team_id": str(team_id), "total_sats": 210}).json() + item = payout["items"][0] + + # A different authenticated user (not the organizer) is rejected. + from tests.conftest import make_nostr_event + from coincurve import PrivateKey + other = PrivateKey() + pubkey = other.public_key.format(compressed=True)[1:].hex() + token = client.post("/auth/nostr", json={ + "pubkey": pubkey, "event": make_nostr_event(other), + }).json()["access_token"] + + res = client.post( + f"/api/v1/allocations/payouts/{payout['id']}/items/{item['id']}/result", + headers={"Authorization": f"Bearer {token}"}, + json={"bolt11": invoice_for_preimage("11" * 32), "preimage": "11" * 32}, + ) + assert res.status_code in (401, 403, 404) From 8ed134591a32c612d3ba1cb2795afbda4b4547e7 Mon Sep 17 00:00:00 2001 From: comwanga Date: Sat, 20 Jun 2026 16:47:52 +0300 Subject: [PATCH 7/8] feat(payout): drive the NWC send in the browser (self-custody) New lib/lightning.ts resolves invoices (LUD-16) and performs the NIP-47 pay_invoice over a WebSocket entirely in the browser; the NWC credential never leaves the client. The payout modal now creates a payout without an nwc, pays each item locally, and reports each result for the server to verify. Retry re-drives only the failed items (using any corrected addresses). --- frontend/components/engine/payout-modal.tsx | 57 +++++--- frontend/hooks/use-allocation.ts | 31 +++-- frontend/lib/lightning.ts | 147 ++++++++++++++++++++ frontend/tests/lib/lightning.test.ts | 63 +++++++++ 4 files changed, 272 insertions(+), 26 deletions(-) create mode 100644 frontend/lib/lightning.ts create mode 100644 frontend/tests/lib/lightning.test.ts diff --git a/frontend/components/engine/payout-modal.tsx b/frontend/components/engine/payout-modal.tsx index 07e5045..172f450 100644 --- a/frontend/components/engine/payout-modal.tsx +++ b/frontend/components/engine/payout-modal.tsx @@ -18,10 +18,13 @@ import { Label } from "@/components/ui/label"; import { ApiError } from "@/lib/api"; import { createPayout, - retryPayout, + reportPayoutItemResult, + reportPayoutItemFailed, type Payout, + type PayoutItem, type Team, } from "@/hooks/use-allocation"; +import { resolveInvoice, payWithNwc } from "@/lib/lightning"; interface PayoutModalProps { team: Team; @@ -49,24 +52,46 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo onOpenChange(o); }; + // Pay each item in the browser: resolve an invoice, send via NWC (the credential + // never leaves this client), then report the result for the server to verify. + const payItems = async (current: Payout, items: PayoutItem[]): Promise => { + const token = session!.accessToken!; + for (const item of items) { + const addr = addresses[item.participant_id]?.trim() || item.lightning_address; + try { + if (!addr) throw new Error("missing lightning address"); + const invoice = await resolveInvoice(addr, item.amount_sats); + const preimage = await payWithNwc(nwc, invoice); + current = await reportPayoutItemResult(token, current.id, item.id, invoice, preimage); + } catch (e) { + current = await reportPayoutItemFailed( + token, current.id, item.id, e instanceof Error ? e.message : String(e) + ); + } + setPayout(current); // live per-member status + } + return current; + }; + const handleSend = async () => { if (!session?.accessToken) return; setSending(true); try { - const nonEmptyAddresses: Record = {}; + const overrides: Record = {}; for (const [id, addr] of Object.entries(addresses)) { - if (addr.trim()) nonEmptyAddresses[id] = addr.trim(); + if (addr.trim()) overrides[id] = addr.trim(); } - const result = await createPayout(session.accessToken, allocationId, { + let current = await createPayout(session.accessToken, allocationId, { team_id: team.id, total_sats: totalSats, - nwc, - addresses: Object.keys(nonEmptyAddresses).length > 0 ? nonEmptyAddresses : undefined, + addresses: Object.keys(overrides).length > 0 ? overrides : undefined, }); - setPayout(result); - toast.success("Payout sent!"); + setPayout(current); + current = await payItems(current, current.items); + if (current.status === "complete") toast.success("Payout complete"); + else toast.error("Some payments need attention"); } catch (err: unknown) { - if (err instanceof ApiError && err.status === 422) { + if (err instanceof ApiError && (err.status === 422 || err.status === 409)) { toast.error(err.message); } else { toast.error(err instanceof Error ? err.message : "Payout failed"); @@ -80,13 +105,10 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo if (!session?.accessToken || !payout) return; setSending(true); try { - const corrected: Record = {}; - for (const [id, addr] of Object.entries(addresses)) { - if (addr.trim()) corrected[id] = addr.trim(); - } - const result = await retryPayout(session.accessToken, payout.id, nwc, corrected); - setPayout(result); - toast.success("Retry complete"); + const failed = payout.items.filter((i) => i.status === "failed"); + const current = await payItems(payout, failed); + if (current.status === "complete") toast.success("Retry complete"); + else toast.error("Some payments still need attention"); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : "Retry failed"); } finally { @@ -136,7 +158,8 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo disabled={!!payout} />

- Paste an NWC string from Alby, Coinos, or Alby Hub. Used once to send — never stored. + Paste an NWC string from Alby, Coinos, or Alby Hub. The payment is signed in your + browser — the credential never reaches our server.

diff --git a/frontend/hooks/use-allocation.ts b/frontend/hooks/use-allocation.ts index cef08e7..9203500 100644 --- a/frontend/hooks/use-allocation.ts +++ b/frontend/hooks/use-allocation.ts @@ -106,20 +106,33 @@ export interface Payout { export async function createPayout( token: string, allocationId: string, - body: { team_id: string; total_sats: number; nwc: string; addresses?: Record } + body: { team_id: string; total_sats: number; addresses?: Record } ) { + // Self-custody: no nwc is sent. The server returns pending items for the browser to pay. return fetchAPI(`/api/v1/allocations/${allocationId}/payouts`, { method: "POST", body, token }); } -export async function retryPayout( +export async function reportPayoutItemResult( token: string, payoutId: string, - nwc: string, - addresses?: Record + itemId: string, + bolt11: string, + preimage: string ) { - return fetchAPI(`/api/v1/allocations/payouts/${payoutId}/retry`, { - method: "POST", - body: { nwc, ...(addresses && Object.keys(addresses).length > 0 ? { addresses } : {}) }, - token, - }); + return fetchAPI( + `/api/v1/allocations/payouts/${payoutId}/items/${itemId}/result`, + { method: "POST", body: { bolt11, preimage }, token } + ); +} + +export async function reportPayoutItemFailed( + token: string, + payoutId: string, + itemId: string, + error: string +) { + return fetchAPI( + `/api/v1/allocations/payouts/${payoutId}/items/${itemId}/failed`, + { method: "POST", body: { error }, token } + ); } diff --git a/frontend/lib/lightning.ts b/frontend/lib/lightning.ts new file mode 100644 index 0000000..9caf3e3 --- /dev/null +++ b/frontend/lib/lightning.ts @@ -0,0 +1,147 @@ +// In-browser Lightning send (self-custody). The NWC spend credential lives only +// here in the browser and is never sent to the backend; the server only verifies +// the preimage we report. See docs/superpowers/specs/2026-06-20-self-custody-payouts-design.md. +import { finalizeEvent, nip04, type Event } from "nostr-tools"; + +export class LightningError extends Error { + constructor(message: string) { + super(message); + this.name = "LightningError"; + } +} + +function hexToBytes(hex: string): Uint8Array { + const clean = hex.trim(); + if (!/^[0-9a-fA-F]*$/.test(clean) || clean.length % 2 !== 0) { + throw new LightningError("invalid hex"); + } + return new Uint8Array(clean.match(/.{2}/g)?.map((b) => parseInt(b, 16)) ?? []); +} + +// --- LNURL-pay (LUD-16) --- + +export function lud16ToUrl(address: string): string { + const addr = address.trim().toLowerCase(); + const parts = addr.split("@"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new LightningError(`malformed lightning address: ${address}`); + } + return `https://${parts[1]}/.well-known/lnurlp/${parts[0]}`; +} + +/** Resolve a `name@domain` address to a bolt11 invoice for `sats`. */ +export async function resolveInvoice(address: string, sats: number): Promise { + const res = await fetch(lud16ToUrl(address)); + if (!res.ok) throw new LightningError(`failed to resolve ${address}`); + const params = await res.json(); + if (!params.callback || params.minSendable == null || params.maxSendable == null) { + throw new LightningError(`invalid LNURL-pay response for ${address}`); + } + const msat = sats * 1000; + if (msat < params.minSendable || msat > params.maxSendable) { + throw new LightningError( + `amount ${sats} sat outside payable range ` + + `[${params.minSendable / 1000}, ${params.maxSendable / 1000}] sat` + ); + } + const callback = new URL(params.callback); + callback.searchParams.set("amount", String(msat)); + const inv = await fetch(callback.toString()); + if (!inv.ok) throw new LightningError("invoice request failed"); + const data = await inv.json(); + if (!data.pr) throw new LightningError("LNURL callback returned no invoice"); + return data.pr as string; +} + +// --- NWC (NIP-47) pay_invoice --- + +export interface NwcConnection { + walletPubkey: string; + relay: string; + secret: string; // hex private key — stays in the browser +} + +export function parseNwcUri(uri: string): NwcConnection { + const trimmed = uri.trim(); + if (!trimmed.startsWith("nostr+walletconnect://")) { + throw new LightningError("not a nostr+walletconnect URI"); + } + const url = new URL(trimmed); + const walletPubkey = (url.host || url.pathname.replace(/^\/+/, "")).toLowerCase(); + const relay = url.searchParams.get("relay"); + const secret = url.searchParams.get("secret"); + if (!walletPubkey || !relay || !secret) { + throw new LightningError("NWC URI missing wallet pubkey, relay, or secret"); + } + return { walletPubkey, relay, secret }; +} + +/** Build the signed, NIP-04-encrypted kind-23194 pay_invoice request event. */ +export async function buildPayRequest(conn: NwcConnection, bolt11: string): Promise { + const sk = hexToBytes(conn.secret); + const body = JSON.stringify({ method: "pay_invoice", params: { invoice: bolt11 } }); + const content = await nip04.encrypt(sk, conn.walletPubkey, body); + return finalizeEvent( + { + kind: 23194, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", conn.walletPubkey]], + content, + }, + sk + ); +} + +/** Pay `bolt11` via the wallet in `uri`. Resolves to the payment preimage. */ +export async function payWithNwc(uri: string, bolt11: string, timeoutMs = 30000): Promise { + const conn = parseNwcUri(uri); + const request = await buildPayRequest(conn, bolt11); + const sk = hexToBytes(conn.secret); + const subId = request.id.slice(0, 16); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(conn.relay); + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { ws.close(); } catch { /* already closing */ } + fn(); + }; + const timer = setTimeout( + () => finish(() => reject(new LightningError("timed out waiting for wallet response"))), + timeoutMs + ); + + ws.onopen = () => { + // Subscribe BEFORE publishing so a fast response can't be missed. + ws.send(JSON.stringify(["REQ", subId, { + kinds: [23195], authors: [conn.walletPubkey], "#e": [request.id], + }])); + ws.send(JSON.stringify(["EVENT", request])); + }; + ws.onmessage = async (msg) => { + let frame: unknown; + try { + frame = JSON.parse(typeof msg.data === "string" ? msg.data : ""); + } catch { return; } + if (!Array.isArray(frame) || frame[0] !== "EVENT" || frame[1] !== subId) return; + try { + const plaintext = await nip04.decrypt(sk, conn.walletPubkey, frame[2].content); + const data = JSON.parse(plaintext); + if (data.error) { + const m = typeof data.error === "object" ? data.error.message : String(data.error); + finish(() => reject(new LightningError(m || "wallet returned an error"))); + } else if (data.result?.preimage) { + finish(() => resolve(data.result.preimage as string)); + } else { + finish(() => reject(new LightningError("wallet response had no preimage"))); + } + } catch (e) { + finish(() => reject(e instanceof Error ? e : new LightningError(String(e)))); + } + }; + ws.onerror = () => finish(() => reject(new LightningError("relay connection failed"))); + }); +} diff --git a/frontend/tests/lib/lightning.test.ts b/frontend/tests/lib/lightning.test.ts new file mode 100644 index 0000000..f296c12 --- /dev/null +++ b/frontend/tests/lib/lightning.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { lud16ToUrl, parseNwcUri, resolveInvoice, LightningError } from "@/lib/lightning"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("lud16ToUrl", () => { + it("builds the .well-known LNURL-pay URL", () => { + expect(lud16ToUrl("Ada@Getalby.com")).toBe("https://getalby.com/.well-known/lnurlp/ada"); + }); + + it("rejects a malformed address", () => { + expect(() => lud16ToUrl("not-an-address")).toThrow(LightningError); + }); +}); + +describe("parseNwcUri", () => { + it("extracts wallet pubkey, relay, and secret", () => { + const conn = parseNwcUri("nostr+walletconnect://ABCD?relay=wss://relay.example&secret=00ff"); + expect(conn.walletPubkey).toBe("abcd"); + expect(conn.relay).toBe("wss://relay.example"); + expect(conn.secret).toBe("00ff"); + }); + + it("rejects a non-NWC URI", () => { + expect(() => parseNwcUri("https://nope")).toThrow(LightningError); + }); + + it("rejects an NWC URI missing the secret", () => { + expect(() => parseNwcUri("nostr+walletconnect://abcd?relay=wss://r")).toThrow(LightningError); + }); +}); + +describe("resolveInvoice", () => { + it("requests an invoice for the amount in msat and returns the bolt11", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ callback: "https://getalby.com/cb", minSendable: 1000, maxSendable: 1_000_000_000 }), + }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ pr: "lnbc105fake" }) }); + vi.stubGlobal("fetch", fetchMock); + + const invoice = await resolveInvoice("ada@getalby.com", 105); + + expect(invoice).toBe("lnbc105fake"); + expect(fetchMock).toHaveBeenCalledTimes(2); + // amount is sent in millisats on the callback + expect(String(fetchMock.mock.calls[1][0])).toContain("amount=105000"); + }); + + it("rejects an amount outside the payable range before requesting an invoice", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ callback: "https://x/cb", minSendable: 1_000_000, maxSendable: 2_000_000 }), + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(resolveInvoice("ada@getalby.com", 1)).rejects.toThrow(LightningError); + expect(fetchMock).toHaveBeenCalledTimes(1); // never reached the callback + }); +}); From 02d088213e8232441da63dde857f8d2631c88b51 Mon Sep 17 00:00:00 2001 From: comwanga Date: Sat, 20 Jun 2026 16:55:44 +0300 Subject: [PATCH 8/8] refactor(payout): remove the server-side spend path (enforce self-custody) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create endpoint no longer accepts an nwc credential and the server-side execute/retry path is gone, so the backend has no way to spend from a wallet — self-custody is now enforced, not merely the frontend's default. Payment happens in the browser and the server only verifies reported preimages. The NIP-47/LNURL utility modules and their unit tests remain as secret-free, reusable helpers. --- backend/app/api/v1/payouts.py | 26 +--- backend/app/schemas/payout.py | 6 +- backend/app/services/payout_service.py | 80 +---------- backend/tests/test_payout_endpoint.py | 176 ++++++------------------- 4 files changed, 50 insertions(+), 238 deletions(-) diff --git a/backend/app/api/v1/payouts.py b/backend/app/api/v1/payouts.py index 33b56f8..1bdbc5c 100644 --- a/backend/app/api/v1/payouts.py +++ b/backend/app/api/v1/payouts.py @@ -11,7 +11,7 @@ from app.models.team import Team from app.models.user import User from app.schemas.payout import ( - PayoutCreate, PayoutRetry, PayoutOut, PayoutItemResult, PayoutItemFailed, + PayoutCreate, PayoutOut, PayoutItemResult, PayoutItemFailed, ) from app.services.event_service import assert_allocation_organizer from app.services import payout_service @@ -78,12 +78,9 @@ def create_payout( status_code=status.HTTP_409_CONFLICT, detail="This team has already been paid; retry the existing payout instead.", ) - if req.nwc: - # Legacy server-side path (deprecated): server holds the credential and pays. - payout = payout_service.execute_payout(db, payout, splits, req.nwc) - else: - # Self-custody path: create pending items; the browser pays and reports back. - payout = payout_service.create_pending(db, payout, splits) + # Self-custody: create pending items; the browser pays and reports each result. + # The server never receives a spend credential. + payout = payout_service.create_pending(db, payout, splits) return _payout_out(db, payout) @@ -127,18 +124,3 @@ def report_item_failed( payout, item = _get_item(db, payout_id, item_id, current_user.id) payout = payout_service.record_item_failed(db, payout, item, req.error) 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, req.addresses) - return _payout_out(db, payout) diff --git a/backend/app/schemas/payout.py b/backend/app/schemas/payout.py index 68b3df0..0eca67a 100644 --- a/backend/app/schemas/payout.py +++ b/backend/app/schemas/payout.py @@ -6,10 +6,8 @@ class PayoutCreate(BaseModel): team_id: UUID total_sats: int = Field(gt=0) - # Self-custody: when omitted, the server creates pending items and the browser - # performs the NIP-47 send, reporting each result back. The legacy server-side - # path (server holds the credential) runs only when `nwc` is supplied. - nwc: Optional[str] = None + # Self-custody: the server never receives a spend credential. It creates pending + # items and the browser performs the NIP-47 send, reporting each result back. # 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 diff --git a/backend/app/services/payout_service.py b/backend/app/services/payout_service.py index 9e3b676..947d6ff 100644 --- a/backend/app/services/payout_service.py +++ b/backend/app/services/payout_service.py @@ -3,7 +3,6 @@ `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). """ -import logging from typing import Sequence, TypeVar from uuid import UUID @@ -12,9 +11,7 @@ from app.models.payout import Payout, PayoutItem from app.models.participant import Participant from app.models.team import Team, TeamMember -from app.services import bolt11, lnurl_service, nwc_service - -logger = logging.getLogger(__name__) +from app.services import bolt11 T = TypeVar("T") @@ -130,78 +127,3 @@ def record_item_failed(db: Session, payout: Payout, item: PayoutItem, error: str return payout -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) - invoice = lnurl_service.request_invoice(params, amount_sats) - item.bolt11 = invoice - item.preimage = nwc_service.pay_invoice(nwc, invoice) - if bolt11.preimage_matches(invoice, item.preimage): - item.status = "paid" - paid += 1 - else: - # The wallet returned a preimage that does not hash to the invoice's - # payment hash — we have no proof the sats moved, so never mark it paid. - # Left as a terminal status (retry skips it) to avoid re-sending money - # that may in fact have left the wallet. - item.status = "unverified" - item.error = "wallet returned a preimage that does not match the invoice" - 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) - # 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) - return payout - - -def retry_failed( - db: Session, payout: Payout, nwc: str, overrides: dict[str, str] | None = None, -) -> Payout: - """Retry only the failed items of an existing payout. - - `overrides` maps `str(participant_id) -> lightning_address` and corrects a bad - address before the retry, so a payout that failed purely on addresses can be - recovered without creating a new one. - """ - overrides = overrides or {} - items = db.query(PayoutItem).filter(PayoutItem.payout_id == payout.id).all() - for item in items: - if item.status != "failed": - continue - corrected = overrides.get(str(item.participant_id)) - if corrected: - item.lightning_address = corrected - try: - params = lnurl_service.resolve_lnurl(item.lightning_address) - invoice = lnurl_service.request_invoice(params, item.amount_sats) - item.bolt11 = invoice - item.preimage = nwc_service.pay_invoice(nwc, invoice) - if bolt11.preimage_matches(invoice, item.preimage): - item.status, item.error = "paid", None - else: - item.status = "unverified" - item.error = "wallet returned a preimage that does not match the invoice" - 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() - db.refresh(payout) - return payout diff --git a/backend/tests/test_payout_endpoint.py b/backend/tests/test_payout_endpoint.py index e3c960c..c974142 100644 --- a/backend/tests/test_payout_endpoint.py +++ b/backend/tests/test_payout_endpoint.py @@ -1,5 +1,7 @@ import hashlib +from tests.lightning_helpers import invoice_for_preimage + def _setup_team(client, auth_headers, all_have_addresses): """Register 4 participants, allocate into 2 teams, return the first team. @@ -24,183 +26,91 @@ def _setup_team(client, auth_headers, all_have_addresses): return e["id"], a["id"], teams[0]["id"], teams[0]["members"] -def _stub_lightning(monkeypatch): - """Stub LNURL + NWC like an honest wallet: each invoice carries a payment hash - and `pay_invoice` returns the preimage that hashes to it. Returns the list of - paid bolt11s.""" - from app.services import lnurl_service, nwc_service - from tests.lightning_helpers import invoice_for_preimage - monkeypatch.setattr(lnurl_service, "resolve_lnurl", - lambda addr: {"callback": "https://x/cb", "minSendable": 1000, "maxSendable": 10_000_000}) - preimages: dict[str, str] = {} - - def _invoice(params, amount_sats): - preimage = hashlib.sha256(f"pre-{amount_sats}".encode()).hexdigest() - inv = invoice_for_preimage(preimage) - preimages[inv] = preimage - return inv - - monkeypatch.setattr(lnurl_service, "request_invoice", _invoice) - paid = [] - - def _pay(uri, bolt11): - paid.append(bolt11) - return preimages[bolt11] +def _pay_all(client, headers, payout): + """Report a valid (verifiable) preimage for every item, simulating the browser.""" + final = payout + for item in payout["items"]: + preimage = hashlib.sha256(item["id"].encode()).hexdigest() + res = client.post( + f"/api/v1/allocations/payouts/{payout['id']}/items/{item['id']}/result", + headers=headers, json={"bolt11": invoice_for_preimage(preimage), "preimage": preimage}, + ) + assert res.status_code == 200, res.text + final = res.json() + return final - monkeypatch.setattr(nwc_service, "pay_invoice", _pay) - return paid - -def test_payout_pays_team_and_records_results(client, auth_headers, monkeypatch): +def test_create_payout_returns_pending_without_a_credential(client, auth_headers): + # The server never receives a spend credential — it returns pending items. _, 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 body["status"] == "pending" + 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) == len(members) -def test_payout_unverified_when_preimage_mismatch(client, auth_headers, monkeypatch): - # A wallet that returns a preimage NOT matching the invoice must never be - # recorded as paid — there is no proof the sats moved. - _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True) - from app.services import lnurl_service, nwc_service - from tests.lightning_helpers import invoice_for_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: invoice_for_preimage("11" * 32)) - monkeypatch.setattr(nwc_service, "pay_invoice", lambda uri, bolt11: "22" * 32) - +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): + _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=False) + 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, "addresses": overrides, + }) assert res.status_code == 201, res.text body = res.json() - assert body["status"] == "failed" # nothing proven paid - assert len(body["items"]) == len(members) - assert all(i["status"] == "unverified" for i in body["items"]) - assert all("preimage" in (i["error"] or "").lower() for i in body["items"]) + assert body["status"] == "pending" + # Every item carries the organizer-supplied address. + assert {i["lightning_address"] for i in body["items"]} == set(overrides.values()) -def test_payout_idempotent_second_call_rejected(client, auth_headers, monkeypatch): - # A team must never be paid twice. A duplicate create (double-click, client - # retry after a timeout) is refused with 409 and moves no additional sats. - _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True) - paid = _stub_lightning(monkeypatch) - body = { - "team_id": str(team_id), "total_sats": 210, - "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00", - } +def test_payout_idempotent_second_call_rejected(client, auth_headers): + # A team must never get a second payout (double-click / client retry). + _, allocation_id, team_id, _ = _setup_team(client, auth_headers, all_have_addresses=True) + body = {"team_id": str(team_id), "total_sats": 210} first = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json=body) assert first.status_code == 201, first.text - paid_after_first = len(paid) - second = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json=body) assert second.status_code == 409, second.text - assert len(paid) == paid_after_first # no second round of payments def test_payout_rejects_amount_over_ceiling(client, auth_headers, monkeypatch): - # A fat-finger (extra zeros) must be refused before any sats are sent. from app.core.config import settings as app_settings monkeypatch.setattr(app_settings, "PAYOUT_MAX_SATS", 500, raising=False) _, allocation_id, team_id, _ = _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": 501, - "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00", }) - assert res.status_code == 422, res.text assert "exceeds" in res.text.lower() - assert paid == [] # nothing sent - - -def test_retry_with_corrected_addresses_recovers(client, auth_headers, monkeypatch): - # A payout that fully failed (bad addresses) must be recoverable: retry with - # corrected addresses pays the team without needing a brand-new payout. - _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True) - from app.services import lnurl_service - def _boom(addr): - raise lnurl_service.LnurlError("unresolvable") - monkeypatch.setattr(lnurl_service, "resolve_lnurl", _boom) - first = 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 first.status_code == 201 - payout_id = first.json()["id"] - assert first.json()["status"] == "failed" - - # Fix the network and supply corrected addresses on retry. - _stub_lightning(monkeypatch) - corrected = {m["id"]: f"fixed-{m['name']}@getalby.com" for m in members} - res = client.post(f"/api/v1/allocations/payouts/{payout_id}/retry", headers=auth_headers, json={ - "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00", - "addresses": corrected, - }) - assert res.status_code == 200, res.text - body = res.json() - assert body["status"] == "complete" - assert all(i["lightning_address"].startswith("fixed-") for i in body["items"]) - - -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_public_results_include_payout_summary(client, auth_headers, monkeypatch): +def test_public_results_include_payout_summary(client, auth_headers): 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. + payout = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, + json={"team_id": str(team_id), "total_sats": 210}).json() + final = _pay_all(client, auth_headers, payout) + assert final["status"] == "complete" 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)