From 4ffdfb7af38c4a4e11a8f2083de5e33e3ef9c25a Mon Sep 17 00:00:00 2001 From: comwanga Date: Sat, 20 Jun 2026 15:49:15 +0300 Subject: [PATCH 1/2] fix(payout): verify Lightning preimage before marking an item paid A payout item was marked "paid" for any non-empty preimage the wallet returned, with no proof the sats actually moved. A buggy or hostile wallet could fake settlement. Add app/services/bolt11.py to extract an invoice's payment hash and check sha256(preimage) == payment_hash. Items whose preimage does not verify get a new terminal "unverified" status (not "failed", so retry won't re-send money that may already have left the wallet). The decoder is validated against the canonical BOLT#11 spec test vector. --- backend/app/models/payout.py | 2 +- backend/app/services/bolt11.py | 82 ++++++++++++++++++++++++++ backend/app/services/payout_service.py | 32 ++++++---- backend/tests/lightning_helpers.py | 42 +++++++++++++ backend/tests/test_bolt11.py | 46 +++++++++++++++ backend/tests/test_payout_endpoint.py | 51 ++++++++++++++-- 6 files changed, 240 insertions(+), 15 deletions(-) create mode 100644 backend/app/services/bolt11.py create mode 100644 backend/tests/lightning_helpers.py create mode 100644 backend/tests/test_bolt11.py diff --git a/backend/app/models/payout.py b/backend/app/models/payout.py index b74fd86..884aa5f 100644 --- a/backend/app/models/payout.py +++ b/backend/app/models/payout.py @@ -26,7 +26,7 @@ class PayoutItem(Base): 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 + # pending | paid | failed | unverified (wallet preimage did not match the invoice) status = Column(String, nullable=False, default="pending") bolt11 = Column(String, nullable=True) preimage = Column(String, nullable=True) diff --git a/backend/app/services/bolt11.py b/backend/app/services/bolt11.py new file mode 100644 index 0000000..4e03f1a --- /dev/null +++ b/backend/app/services/bolt11.py @@ -0,0 +1,82 @@ +"""Minimal bolt11 reader: extract the payment hash and verify a preimage. + +We never claim a Lightning payout item is "paid" on a wallet's say-so. NIP-47 +returns a payment preimage; the cryptographic proof of settlement is that +`sha256(preimage) == payment_hash` of the invoice *we* asked the wallet to pay. + +This decoder reads only the payment-hash tagged field (BOLT #11 type `p`). It +does NOT validate the bech32 checksum or the signature: we constructed the pay +request around this exact invoice string, so the property we need is solely +"does the returned preimage hash to this invoice's payment hash". +""" +import hashlib + +_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +_TIMESTAMP_GROUPS = 7 # 35-bit timestamp prefix +_SIG_GROUPS = 104 # trailing 65-byte recoverable signature (520 bits) +_CHECKSUM_GROUPS = 6 # bech32 tail +_PAYMENT_HASH_TAG = 1 # tagged field `p` + + +class Bolt11Error(Exception): + """The invoice could not be parsed far enough to read its payment hash.""" + + +def _data_groups(invoice: str) -> list[int]: + """5-bit data groups between the `1` separator and the bech32 checksum.""" + inv = invoice.strip().lower() + pos = inv.rfind("1") # data part uses no '1'; last '1' is the hrp separator + if pos < 1: + raise Bolt11Error("missing bech32 separator") + try: + groups = [_CHARSET.index(c) for c in inv[pos + 1:]] + except ValueError as exc: + raise Bolt11Error("invalid bech32 character") from exc + if len(groups) < _TIMESTAMP_GROUPS + _SIG_GROUPS + _CHECKSUM_GROUPS: + raise Bolt11Error("invoice too short") + return groups[:-_CHECKSUM_GROUPS] + + +def _groups_to_bytes(groups: list[int]) -> bytes: + acc = bits = 0 + out = bytearray() + for v in groups: + acc = (acc << 5) | v + bits += 5 + while bits >= 8: + bits -= 8 + out.append((acc >> bits) & 0xFF) + return bytes(out) + + +def payment_hash_hex(invoice: str) -> str: + """Return the 32-byte payment hash (hex) from a bolt11 invoice, or raise Bolt11Error.""" + groups = _data_groups(invoice) + tagged = groups[_TIMESTAMP_GROUPS:-_SIG_GROUPS] + i = 0 + while i + 3 <= len(tagged): + typ = tagged[i] + length = tagged[i + 1] * 32 + tagged[i + 2] + i += 3 + field = tagged[i:i + length] + i += length + if typ == _PAYMENT_HASH_TAG: + h = _groups_to_bytes(field)[:32] + if len(h) != 32: + raise Bolt11Error("payment hash field is not 32 bytes") + return h.hex() + raise Bolt11Error("no payment hash field in invoice") + + +def preimage_matches(invoice: str, preimage_hex: str) -> bool: + """True iff sha256(preimage) equals the invoice's payment hash. + + Returns False (never raises) for any malformed input — an unverifiable + preimage must read as not-proven, not as an error to be retried. + """ + try: + expected = payment_hash_hex(invoice) + preimage = bytes.fromhex(preimage_hex.strip()) + except (Bolt11Error, ValueError, AttributeError): + return False + return hashlib.sha256(preimage).hexdigest() == expected diff --git a/backend/app/services/payout_service.py b/backend/app/services/payout_service.py index 8f639a6..4a67da7 100644 --- a/backend/app/services/payout_service.py +++ b/backend/app/services/payout_service.py @@ -12,7 +12,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 lnurl_service, nwc_service +from app.services import bolt11, lnurl_service, nwc_service logger = logging.getLogger(__name__) @@ -78,11 +78,19 @@ def execute_payout( 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 + 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) @@ -105,10 +113,14 @@ def retry_failed(db: Session, payout: Payout, nwc: str) -> Payout: 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 + 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) diff --git a/backend/tests/lightning_helpers.py b/backend/tests/lightning_helpers.py new file mode 100644 index 0000000..0976822 --- /dev/null +++ b/backend/tests/lightning_helpers.py @@ -0,0 +1,42 @@ +"""Test-only helpers for building Lightning artifacts. + +`make_invoice` bech32-encodes a minimal bolt11 carrying a chosen payment hash so +tests can produce an invoice whose preimage they control. Only the payment-hash +tagged field is meaningful; the signature/checksum are zero-filled because the +production decoder (`app.services.bolt11`) reads the payment hash and nothing else. +""" +import hashlib + +_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def _bytes_to_5bit(data: bytes) -> list[int]: + acc = bits = 0 + out: list[int] = [] + for b in data: + acc = (acc << 8) | b + bits += 8 + while bits >= 5: + bits -= 5 + out.append((acc >> bits) & 0x1F) + if bits: + out.append((acc << (5 - bits)) & 0x1F) + return out + + +def make_invoice(payment_hash_hex: str) -> str: + """Return a syntactically-walkable bolt11 carrying `payment_hash_hex` (32-byte hex).""" + ph_groups = _bytes_to_5bit(bytes.fromhex(payment_hash_hex)) # 32 bytes -> 52 groups + assert len(ph_groups) == 52 + timestamp = [0] * 7 + tag = [1, len(ph_groups) // 32, len(ph_groups) % 32] # p=1, then 2-group length + signature = [0] * 104 + checksum = [0] * 6 # decoder drops these; value is irrelevant + groups = timestamp + tag + ph_groups + signature + checksum + return "lnbc1" + "".join(_CHARSET[g] for g in groups) + + +def invoice_for_preimage(preimage_hex: str) -> str: + """Build an invoice whose payment hash is sha256(preimage).""" + ph = hashlib.sha256(bytes.fromhex(preimage_hex)).hexdigest() + return make_invoice(ph) diff --git a/backend/tests/test_bolt11.py b/backend/tests/test_bolt11.py new file mode 100644 index 0000000..4f38978 --- /dev/null +++ b/backend/tests/test_bolt11.py @@ -0,0 +1,46 @@ +import hashlib + +import pytest + +from app.services import bolt11 +from tests.lightning_helpers import invoice_for_preimage, make_invoice + +# Canonical BOLT #11 test vector: "$3 for a cup of coffee". Its payment hash is +# the well-known 0001020304...0102 used throughout the spec examples. +_SPEC_INVOICE = ( + "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq" + "dq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2aw" + "hz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp" +) +_SPEC_PAYMENT_HASH = "0001020304050607080900010203040506070809000102030405060708090102" + + +def test_payment_hash_hex_matches_spec_vector(): + assert bolt11.payment_hash_hex(_SPEC_INVOICE) == _SPEC_PAYMENT_HASH + + +def test_preimage_matches_true_for_correct_preimage(): + preimage = ("11" * 32) + invoice = invoice_for_preimage(preimage) + assert bolt11.preimage_matches(invoice, preimage) is True + + +def test_preimage_matches_false_for_wrong_preimage(): + invoice = invoice_for_preimage("11" * 32) + assert bolt11.preimage_matches(invoice, "22" * 32) is False + + +def test_preimage_matches_false_for_unparseable_invoice(): + # A wallet returning a bogus invoice/preimage must never read as verified. + assert bolt11.preimage_matches("not-an-invoice", "deadbeef") is False + + +def test_preimage_matches_false_for_non_hex_preimage(): + invoice = invoice_for_preimage("11" * 32) + assert bolt11.preimage_matches(invoice, "preimage_lnbc210fake") is False + + +def test_make_invoice_roundtrips_through_decoder(): + # The helper-encoded hash is recovered by the production decoder. + h = hashlib.sha256(b"squadsync").hexdigest() + assert bolt11.payment_hash_hex(make_invoice(h)) == h diff --git a/backend/tests/test_payout_endpoint.py b/backend/tests/test_payout_endpoint.py index 9ad5c03..7816781 100644 --- a/backend/tests/test_payout_endpoint.py +++ b/backend/tests/test_payout_endpoint.py @@ -1,3 +1,6 @@ +import hashlib + + def _setup_team(client, auth_headers, all_have_addresses): """Register 4 participants, allocate into 2 teams, return the first team. @@ -22,14 +25,29 @@ def _setup_team(client, auth_headers, all_have_addresses): def _stub_lightning(monkeypatch): - """Stub LNURL + NWC so no real network happens. Returns the list of paid bolt11s.""" + """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}) - monkeypatch.setattr(lnurl_service, "request_invoice", lambda params, amount_sats: f"lnbc{amount_sats}fake") + 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 = [] - monkeypatch.setattr(nwc_service, "pay_invoice", - lambda uri, bolt11: (paid.append(bolt11), "preimage_" + bolt11)[1]) + + def _pay(uri, bolt11): + paid.append(bolt11) + return preimages[bolt11] + + monkeypatch.setattr(nwc_service, "pay_invoice", _pay) return paid @@ -51,6 +69,31 @@ def test_payout_pays_team_and_records_results(client, auth_headers, monkeypatch) 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) + + 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"] == "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"]) + + 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 f6089af1c893811b2e45b367894426ad5551dee5 Mon Sep 17 00:00:00 2001 From: comwanga Date: Sat, 20 Jun 2026 15:50:11 +0300 Subject: [PATCH 2/2] fix(payout): make payouts idempotent so a team can't be paid twice create_payout built a fresh Payout on every call, so a double-click or a client retry after a timeout paid every winner again. Add a unique (allocation_id, team_label) constraint plus an explicit 409 pre-check, with an IntegrityError catch on flush as the race backstop (fires before any sats move). Re-attempts route through the existing retry endpoint. --- .../versions/0008_payout_team_unique.py | 29 +++++++++++++++++++ backend/app/api/v1/payouts.py | 22 +++++++++++++- backend/app/models/payout.py | 8 ++++- backend/tests/test_payout_endpoint.py | 19 ++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/0008_payout_team_unique.py diff --git a/backend/alembic/versions/0008_payout_team_unique.py b/backend/alembic/versions/0008_payout_team_unique.py new file mode 100644 index 0000000..86a308c --- /dev/null +++ b/backend/alembic/versions/0008_payout_team_unique.py @@ -0,0 +1,29 @@ +"""unique payout per team per allocation + +Enforces idempotent payouts: a winning team can only be paid once. Re-attempts +go through the retry endpoint, which reuses the existing payout's items. + +Revision ID: 0008_payout_team_unique +Revises: 0007_lightning_payout +Create Date: 2026-06-20 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0008_payout_team_unique" +down_revision: Union[str, None] = "0007_lightning_payout" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("payouts") as b: + b.create_unique_constraint( + "uq_payout_allocation_team", ["allocation_id", "team_label"] + ) + + +def downgrade() -> None: + with op.batch_alter_table("payouts") as b: + b.drop_constraint("uq_payout_allocation_team", type_="unique") diff --git a/backend/app/api/v1/payouts.py b/backend/app/api/v1/payouts.py index d457b49..ccafc57 100644 --- a/backend/app/api/v1/payouts.py +++ b/backend/app/api/v1/payouts.py @@ -1,6 +1,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_db @@ -37,6 +38,16 @@ def create_payout( if not team: raise HTTPException(status_code=404, detail="Team not found in this allocation") + # 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( + Payout.allocation_id == allocation_id, Payout.team_label == team.name + ).first(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This team has already been paid; retry the existing payout instead.", + ) + # 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) @@ -46,7 +57,16 @@ def create_payout( 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() + # The unique (allocation_id, team_label) constraint is the race backstop: if a + # concurrent request inserted first, this flush raises before any sats move. + try: + db.flush() + except IntegrityError: + db.rollback() + raise HTTPException( + 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) return _payout_out(db, payout) diff --git a/backend/app/models/payout.py b/backend/app/models/payout.py index 884aa5f..95ad334 100644 --- a/backend/app/models/payout.py +++ b/backend/app/models/payout.py @@ -1,5 +1,5 @@ import uuid -from sqlalchemy import Column, String, Integer, ForeignKey, DateTime, Uuid +from sqlalchemy import Column, String, Integer, ForeignKey, DateTime, Uuid, UniqueConstraint from sqlalchemy.sql import func from app.core.database import Base @@ -7,6 +7,12 @@ class Payout(Base): __tablename__ = "payouts" + # One payout per team per allocation: the authoritative guard against paying + # a winning team twice (double-click, client retry). Re-attempts go through + # the retry endpoint, which reuses the existing payout's items. + __table_args__ = ( + UniqueConstraint("allocation_id", "team_label", name="uq_payout_allocation_team"), + ) 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) diff --git a/backend/tests/test_payout_endpoint.py b/backend/tests/test_payout_endpoint.py index 7816781..91f6beb 100644 --- a/backend/tests/test_payout_endpoint.py +++ b/backend/tests/test_payout_endpoint.py @@ -94,6 +94,25 @@ def test_payout_unverified_when_preimage_mismatch(client, auth_headers, monkeypa assert all("preimage" in (i["error"] or "").lower() for i in body["items"]) +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", + } + + 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_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)