Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions backend/alembic/versions/0008_payout_team_unique.py
Original file line number Diff line number Diff line change
@@ -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")
22 changes: 21 additions & 1 deletion backend/app/api/v1/payouts.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down
10 changes: 8 additions & 2 deletions backend/app/models/payout.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
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


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)
Expand All @@ -26,7 +32,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)
Expand Down
82 changes: 82 additions & 0 deletions backend/app/services/bolt11.py
Original file line number Diff line number Diff line change
@@ -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
32 changes: 22 additions & 10 deletions backend/app/services/payout_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions backend/tests/lightning_helpers.py
Original file line number Diff line number Diff line change
@@ -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)
46 changes: 46 additions & 0 deletions backend/tests/test_bolt11.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading