Implement full MVP: auth, HD wallet, Electrum client, deposits, bets, round/draw engine, payout, withdrawals, RBF, admin+audit

All 10 build-order stages complete and unit-tested (49 tests). Verified live on
mainnet: registration/address derivation, deposit crediting, a real 10 PLM bet
(broadcast + confirmed + change credited). A full round close->draw->payout
cycle was triggered live and was in progress at commit time. Withdrawal and RBF
bump are unit-tested but not yet exercised against a live broadcast. Known gaps
(scheduler doesn't resume mid-flight rounds after restart, payout has no retry,
no deployment setup, etc.) are documented in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:52:20 +02:00
co-authored by Claude Sonnet 5
parent bae48c46dc
commit df72367f02
76 changed files with 3506 additions and 1 deletions
View File
View File
View File
+64
View File
@@ -0,0 +1,64 @@
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
from sqlalchemy.ext.asyncio import create_async_engine
from app.db import base as db_base
db_base.engine = create_async_engine(settings.database_url)
from sqlalchemy.ext.asyncio import async_sessionmaker
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from app.api.routes.admin import router as admin_router
from fastapi import FastAPI
app = FastAPI()
app.include_router(admin_router)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
async def test_admin_requires_token(client):
resp = await client.get("/admin/config")
assert resp.status_code == 403
async def test_admin_rejects_wrong_token(client):
resp = await client.get("/admin/config", headers={"X-Admin-Token": "wrong"})
assert resp.status_code == 403
async def test_admin_reads_and_updates_config(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/config", headers=headers)
assert resp.status_code == 200
assert resp.json()["fee_address"] == ""
resp = await client.put(
"/admin/config", headers=headers, json={"fee_address": "plm1qfeeaddress", "bet_amount_sats": 500_000_000}
)
assert resp.status_code == 200
body = resp.json()
assert body["fee_address"] == "plm1qfeeaddress"
assert body["bet_amount_sats"] == 500_000_000
resp = await client.get("/admin/config", headers=headers)
assert resp.json()["fee_address"] == "plm1qfeeaddress"
+106
View File
@@ -0,0 +1,106 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import BetError, place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent
from app.wallet.hd import derive_user_address
class FakeElectrumClient:
def __init__(self):
self.broadcasted: list[str] = []
async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex)
return "fake-network-txid"
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(settings, "xprv_encryption_key", __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode())
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
async def _make_funded_user(session_factory, index: int, funded_sats: int) -> int:
async with session_factory() as session:
address = derive_user_address(index)
user = User(username=f"user{index}", password_hash="x", derivation_index=index, address=address)
session.add(user)
await session.commit()
session.add(
UtxoEvent(
user_id=user.id,
txid=f"{index:02x}" * 32,
vout=0,
amount_sats=funded_sats,
confirmed_height=100,
)
)
await session.commit()
return user.id
async def test_place_bet_broadcasts_and_records_participant(session_factory):
user_id = await _make_funded_user(session_factory, 0, 1_500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, client, user)
assert client.broadcasted # a raw tx was broadcast
assert participant.status == "broadcast"
assert participant.bet_txid
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid == participant.bet_txid
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.kind == "bet"
audit_events = (await session.scalars(select(AuditLog))).all()
assert any(e.event_type == "bet_placed" for e in audit_events)
assert pending.current_txid == participant.bet_txid
async def test_place_bet_rejects_insufficient_balance(session_factory):
user_id = await _make_funded_user(session_factory, 1, 1_000_000) # below bet_amount_sats
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="insufficient balance"):
await place_bet(session, client, user)
async def test_place_bet_rejects_second_bet_same_round(session_factory):
user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
await place_bet(session, client, user)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="already"):
await place_bet(session, client, user)
async with session_factory() as session:
participants = (await session.scalars(select(RoundParticipant))).all()
assert len(participants) == 1
+181
View File
@@ -0,0 +1,181 @@
from datetime import datetime, timedelta, timezone
import pytest
from embit import script
from embit.bip32 import HDKey
from embit.transaction import Transaction
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import PendingTransaction, User
from app.tx.broadcast import RbfError, bump_fee, should_bump
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import Utxo, build_signed_transaction
def _key(seed_byte: int) -> HDKey:
root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"])
return root.derive("m/84h/746h/0h/0/0")
def test_should_bump_false_before_timeout():
pending = PendingTransaction(
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
broadcast_at=datetime.now(timezone.utc),
)
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
def test_should_bump_true_after_timeout():
pending = PendingTransaction(
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is True
def test_should_bump_false_when_not_pending():
pending = PendingTransaction(
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="confirmed",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
class FakeClient:
def __init__(self, prevout_values: dict[str, int]):
self._prevout_values = prevout_values
self.broadcasted: list[str] = []
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
return {"vout": {0: {"value": self._prevout_values[txid] / 100_000_000}}}
async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex)
return "network-txid"
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(
settings,
"xprv_encryption_key",
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
)
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
my_address = derive_user_address(0)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(99).to_public()).address(network=PLM_MAINNET)
utxo_amount = 150_000_000
utxo_txid = "11" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
async with session_factory() as session:
user = User(username="alice", password_hash="x", derivation_index=0, address=my_address)
session.add(user)
await session.commit()
pending = PendingTransaction(
kind="bet",
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=1,
raw_tx_hex=built.raw_hex,
status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
session.add(pending)
await session.commit()
pending_id = pending.id
client = FakeClient({utxo_txid: utxo_amount})
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
new_txid = await bump_fee(session, client, row)
assert client.broadcasted
assert new_txid != built.txid
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
old_change = next(o.value for o in old_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
new_change = next(o.value for o in new_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
assert new_change < old_change # fee bump came out of the change output
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
assert row.current_txid == new_txid
assert row.fee_rate_sat_vb == 2
assert row.attempt_count == 2
async def test_bump_fee_raises_when_no_change_output(session_factory):
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
my_address = derive_user_address(0)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
utxo_amount = 10_000_000 # exact amount, no change output
utxo_txid = "22" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
async with session_factory() as session:
user = User(username="bob", password_hash="x", derivation_index=0, address=my_address)
session.add(user)
await session.commit()
pending = PendingTransaction(
kind="bet",
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=1,
raw_tx_hex=built.raw_hex,
status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
session.add(pending)
await session.commit()
pending_id = pending.id
client = FakeClient({utxo_txid: utxo_amount})
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
with pytest.raises(RbfError):
await bump_fee(session, client, row)
+82
View File
@@ -0,0 +1,82 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.bets.confirmation # noqa: F401 (registers the "bet" handler)
import app.rounds.confirmation # noqa: F401 (registers the "payout" handler)
from app.db.base import Base
from app.db.models import PendingTransaction, Round, RoundParticipant
from app.tx.confirmation import poll_once
class FakeClient:
def __init__(self, confirmations_by_txid: dict[str, int]):
self._confirmations = confirmations_by_txid
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
return {"confirmations": self._confirmations.get(txid, 0)}
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
async def test_bet_confirmation_marks_participant_confirmed(session_factory):
async with session_factory() as session:
session.add(Round(id=1, status="open"))
session.add(
RoundParticipant(
round_id=1, user_id=1, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast"
)
)
session.add(
PendingTransaction(kind="bet", round_id=1, user_id=1, current_txid="tx1", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending")
)
await session.commit()
client = FakeClient({"tx1": 1})
confirmed = await poll_once(session_factory, client)
assert confirmed == 1
async with session_factory() as session:
participant = (await session.scalars(select(RoundParticipant))).one()
assert participant.status == "confirmed"
assert participant.confirmed_at is not None
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.status == "confirmed"
async def test_unconfirmed_tx_is_left_pending(session_factory):
async with session_factory() as session:
session.add(Round(id=2, status="open"))
session.add(RoundParticipant(round_id=2, user_id=1, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast"))
session.add(PendingTransaction(kind="bet", round_id=2, user_id=1, current_txid="tx2", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"))
await session.commit()
client = FakeClient({"tx2": 0})
confirmed = await poll_once(session_factory, client)
assert confirmed == 0
async with session_factory() as session:
participant = (await session.scalars(select(RoundParticipant))).one()
assert participant.status == "broadcast"
async def test_payout_confirmation_closes_round(session_factory):
async with session_factory() as session:
session.add(Round(id=3, status="paying_out", payout_txid="tx3"))
session.add(PendingTransaction(kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"))
await session.commit()
client = FakeClient({"tx3": 2})
confirmed = await poll_once(session_factory, client)
assert confirmed == 1
async with session_factory() as session:
round_ = await session.get(Round, 3)
assert round_.status == "closed"
+54
View File
@@ -0,0 +1,54 @@
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import User
from app.deposits.service import credit_confirmed_utxos
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
@pytest.fixture
async def user_id(session_factory):
async with session_factory() as session:
user = User(username="alice", password_hash="x", derivation_index=0, address="plm1qxxx")
session.add(user)
await session.commit()
return user.id
async def test_credits_confirmed_utxo_and_updates_balance(session_factory, user_id):
entries = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 10_000_000}]
async with session_factory() as session:
credited = await credit_confirmed_utxos(session, user_id, entries)
assert credited == 1
user = await session.get(User, user_id)
assert user.cached_balance_sats == 10_000_000
async def test_unconfirmed_entry_is_ignored(session_factory, user_id):
entries = [{"tx_hash": "bb" * 32, "tx_pos": 0, "height": 0, "value": 5_000_000}]
async with session_factory() as session:
credited = await credit_confirmed_utxos(session, user_id, entries)
assert credited == 0
user = await session.get(User, user_id)
assert user.cached_balance_sats == 0
async def test_idempotent_on_repeated_notification(session_factory, user_id):
entries = [{"tx_hash": "cc" * 32, "tx_pos": 0, "height": 100, "value": 7_000_000}]
async with session_factory() as session:
first = await credit_confirmed_utxos(session, user_id, entries)
async with session_factory() as session:
second = await credit_confirmed_utxos(session, user_id, entries)
user = await session.get(User, user_id)
assert first == 1
assert second == 0
assert user.cached_balance_sats == 7_000_000
+32
View File
@@ -0,0 +1,32 @@
import pytest
from app.rounds.draw import draw_winner, header_hex_to_block_hash
def test_header_hex_to_block_hash_matches_known_mainnet_block():
# Real PLM mainnet block 477486: header from blockchain.block.header, hash
# cross-checked against the blockhash reported by blockchain.transaction.get
# for a tx confirmed in that block.
header_hex = (
"0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000"
"009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875"
"e6a47a61b1ad8012582"
)
known_block_hash = "00000000000008788b55ade13b74d54ceffda9e54315b802411be1ca65064e86"
assert header_hex_to_block_hash(header_hex) == known_block_hash
def test_draw_winner_is_deterministic_and_within_range():
participants = ["addrA", "addrB", "addrC"]
block_hash = "00" * 31 + "05" # seed = 5, index = 5 % 3 = 2
assert draw_winner(participants, block_hash) == "addrC"
def test_draw_winner_single_participant_always_wins():
block_hash = "ff" * 32
assert draw_winner(["only"], block_hash) == "only"
def test_draw_winner_raises_on_empty_participants():
with pytest.raises(ValueError):
draw_winner([], "00" * 32)
+77
View File
@@ -0,0 +1,77 @@
import asyncio
import json
from app.electrum.client import ElectrumClient, ElectrumError
class FakeWriter:
def __init__(self):
self.written = b""
def write(self, data: bytes) -> None:
self.written += data
async def drain(self) -> None:
pass
def close(self) -> None:
pass
async def wait_closed(self) -> None:
pass
async def _client_with_fake_transport() -> tuple[ElectrumClient, asyncio.StreamReader, FakeWriter]:
client = ElectrumClient("localhost", 1234)
reader = asyncio.StreamReader()
writer = FakeWriter()
client._reader = reader
client._writer = writer
client._read_task = asyncio.create_task(client._read_loop())
return client, reader, writer
async def test_request_resolves_on_matching_response():
client, reader, writer = await _client_with_fake_transport()
task = asyncio.create_task(client.request("blockchain.headers.subscribe"))
await asyncio.sleep(0) # let request() write the payload
sent = json.loads(writer.written.decode())
assert sent["method"] == "blockchain.headers.subscribe"
reader.feed_data((json.dumps({"id": sent["id"], "result": {"height": 100}}) + "\n").encode())
result = await task
assert result == {"height": 100}
client._read_task.cancel()
async def test_error_response_raises_electrum_error():
client, reader, writer = await _client_with_fake_transport()
task = asyncio.create_task(client.request("blockchain.transaction.broadcast", ["deadbeef"]))
await asyncio.sleep(0)
sent = json.loads(writer.written.decode())
reader.feed_data((json.dumps({"id": sent["id"], "error": "bad tx"}) + "\n").encode())
try:
await task
assert False, "expected ElectrumError"
except ElectrumError:
pass
client._read_task.cancel()
async def test_notification_delivered_to_subscription_queue():
client, reader, writer = await _client_with_fake_transport()
queue = client.notifications("blockchain.scripthash.subscribe")
push = {"method": "blockchain.scripthash.subscribe", "params": ["abcd", "newstatus"]}
reader.feed_data((json.dumps(push) + "\n").encode())
await asyncio.sleep(0)
params = await asyncio.wait_for(queue.get(), timeout=1)
assert params == ["abcd", "newstatus"]
client._read_task.cancel()
+21
View File
@@ -0,0 +1,21 @@
from embit.bip32 import HDKey
from app.wallet.plm_network import PLM_MAINNET
def test_p2wpkh_address_uses_plm_hrp():
from embit import script
root = HDKey.from_seed(b"\x01" * 32, version=PLM_MAINNET["xprv"])
child = root.derive("m/84h/746h/0h/0/0")
address = script.p2wpkh(child.to_public()).address(network=PLM_MAINNET)
assert address.startswith("plm1q")
def test_derivation_is_deterministic():
root = HDKey.from_seed(b"\x02" * 32, version=PLM_MAINNET["xprv"])
a = root.derive("m/84h/746h/0h/0/5").sec()
b = root.derive("m/84h/746h/0h/0/5").sec()
c = root.derive("m/84h/746h/0h/0/6").sec()
assert a == b
assert a != c
+100
View File
@@ -0,0 +1,100 @@
import pytest
from embit import script
from embit.bip32 import HDKey
from embit.transaction import Transaction
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction, estimate_vsize
def _key(seed_byte: int) -> HDKey:
root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"])
return root.derive("m/84h/746h/0h/0/0")
def test_payout_deducts_fee_only_from_winner_share():
pool_key = _key(10)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(11).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(12).to_public()).address(network=PLM_MAINNET)
pool_amount = 10_000_000_000 # 100 PLM pot
winner_share = pool_amount * 70 // 100
commission = pool_amount - winner_share
utxos = [Utxo("aa" * 32, 0, pool_amount)]
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)
fee = estimate_vsize(1, 3)
assert built.fee_sats == fee
assert built.winner_sats == winner_share - fee
assert built.commission_sats == commission # untouched by the fee
assert built.change_sats == pool_amount - (winner_share + commission)
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 2 # no change needed: winner_share + commission == pool_amount exactly
amounts = sorted(o.value for o in parsed.vout)
assert amounts == sorted([built.winner_sats, built.commission_sats])
def test_payout_adds_change_output_when_pool_utxos_exceed_target():
pool_key = _key(20)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(21).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(22).to_public()).address(network=PLM_MAINNET)
winner_share = 700_000_000
commission = 300_000_000
utxos = [Utxo("bb" * 32, 0, 2_000_000_000)] # more than winner_share+commission
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)
assert built.change_sats == 2_000_000_000 - (winner_share + commission)
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 3
def test_payout_raises_when_winner_share_too_small():
pool_key = _key(30)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(31).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(32).to_public()).address(network=PLM_MAINNET)
winner_share = 100 # smaller than the ~172 sat fee at 1 sat/vB for 1-in-3-out
commission = 50
assert winner_share < estimate_vsize(1, 3)
utxos = [Utxo("cc" * 32, 0, winner_share + commission)]
with pytest.raises(InsufficientFundsError):
build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)
+113
View File
@@ -0,0 +1,113 @@
import pytest
from embit import script
from embit.bip32 import HDKey
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import (
InsufficientFundsError,
Utxo,
build_signed_transaction,
estimate_vsize,
select_utxos,
)
def _key(seed_byte: int) -> HDKey:
root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"])
return root.derive("m/84h/746h/0h/0/0")
def test_estimate_vsize_grows_with_inputs_and_outputs():
assert estimate_vsize(1, 2) < estimate_vsize(2, 2)
assert estimate_vsize(1, 1) < estimate_vsize(1, 2)
def test_select_utxos_picks_largest_first():
utxos = [Utxo("a" * 64, 0, 5_000_000), Utxo("b" * 64, 0, 20_000_000), Utxo("c" * 64, 0, 1_000_000)]
selected, total = select_utxos(utxos, target_sats=10_000_000)
assert selected == [utxos[1]] # the 20M UTXO alone covers 10M
assert total == 20_000_000
def test_select_utxos_raises_when_insufficient():
utxos = [Utxo("a" * 64, 0, 1_000_000)]
with pytest.raises(InsufficientFundsError):
select_utxos(utxos, target_sats=10_000_000)
def test_build_signed_transaction_deducts_fee_from_amount_not_change():
signer = _key(1)
from_script = script.p2wpkh(signer.to_public())
my_address = from_script.address(network=PLM_MAINNET)
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
utxos = [Utxo("11" * 32, 0, 150_000_000)]
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
fee = estimate_vsize(1, 2)
assert built.fee_sats == fee
assert built.recipient_sats == 10_000_000 - fee
# change reflects the full amount_sats deducted from the sender, fee comes out
# of what the recipient gets, not out of the sender's remaining balance
assert built.change_sats == 150_000_000 - 10_000_000
assert built.spent_utxos == utxos
assert len(built.txid) == 64
from embit.transaction import Transaction
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vin[0].witness.items) == 2
assert len(parsed.vout) == 2
def test_build_signed_transaction_omits_change_output_when_exact_amount():
signer = _key(3)
from_script = script.p2wpkh(signer.to_public())
my_address = from_script.address(network=PLM_MAINNET)
to_address = script.p2wpkh(_key(4).to_public()).address(network=PLM_MAINNET)
utxos = [Utxo("22" * 32, 0, 10_000_000)] # exactly amount_sats, zero change
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
assert built.change_sats == 0
from embit.transaction import Transaction
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 1
def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
signer = _key(5)
from_script = script.p2wpkh(signer.to_public())
my_address = from_script.address(network=PLM_MAINNET)
to_address = script.p2wpkh(_key(6).to_public()).address(network=PLM_MAINNET)
small_amount = 100 # smaller than the ~141 sat fee at 1 sat/vB for 1-in-2-out
assert small_amount < estimate_vsize(1, 2)
utxos = [Utxo("33" * 32, 0, small_amount)]
with pytest.raises(InsufficientFundsError):
build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
to_address=to_address,
amount_sats=small_amount,
change_address=my_address,
fee_rate_sat_vb=1,
)
+58
View File
@@ -0,0 +1,58 @@
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import Round
from app.rounds.service import get_active_round, open_new_round_if_needed
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
async def test_opens_a_round_when_none_exists(session_factory):
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
assert round_.status == "open"
async def test_reuses_existing_open_round(session_factory):
async with session_factory() as session:
first = await open_new_round_if_needed(session)
await session.commit()
first_id = first.id
async with session_factory() as session:
second = await open_new_round_if_needed(session)
assert second.id == first_id
@pytest.mark.parametrize("status", ["closing", "drawing", "paying_out"])
async def test_does_not_open_new_round_while_previous_is_in_progress(session_factory, status):
async with session_factory() as session:
session.add(Round(status=status))
await session.commit()
async with session_factory() as session:
active = await get_active_round(session)
assert active is not None
assert active.status == status
# open_new_round_if_needed must return the in-progress round, not open a new one
returned = await open_new_round_if_needed(session)
assert returned.status == status
async def test_opens_new_round_after_previous_is_closed(session_factory):
async with session_factory() as session:
session.add(Round(status="closed"))
await session.commit()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
assert round_.status == "open"
+55
View File
@@ -0,0 +1,55 @@
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import Round
from app.rounds.scheduler import RoundScheduler
class FakeListener:
client = object() # truthy sentinel; _tick only checks "is not None"
tip_height = 100
tip_header_hex = "00"
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
async def test_tick_survives_sqlite_naive_datetime_roundtrip(session_factory, monkeypatch):
"""Regression test: SQLite drops tzinfo on round-trip, so opened_at comes back
naive even though it was written as an aware UTC datetime. A prior bug compared
it directly against datetime.now(timezone.utc) and crashed with
"can't compare offset-naive and offset-aware datetimes" on every tick once a
round existed — this must not happen."""
monkeypatch.setattr(settings, "round_duration_seconds", 3600) # not due yet
async with session_factory() as session:
session.add(Round(status="open", opened_at=datetime.now(timezone.utc)))
await session.commit()
scheduler = RoundScheduler(session_factory, FakeListener())
await scheduler._tick() # must not raise
async def test_tick_closes_round_with_no_participants_once_due(session_factory, monkeypatch):
monkeypatch.setattr(settings, "round_duration_seconds", 1)
past = datetime.now(timezone.utc) - timedelta(seconds=10)
async with session_factory() as session:
session.add(Round(status="open", opened_at=past))
await session.commit()
scheduler = RoundScheduler(session_factory, FakeListener())
await scheduler._tick()
async with session_factory() as session:
round_ = (await session.scalars(select(Round))).one()
assert round_.status == "closed"
+9
View File
@@ -0,0 +1,9 @@
from app.electrum.scripthash import address_to_scripthash
def test_matches_known_mainnet_vector():
# Ground truth: scriptPubKey "0014a195473740aea3b4df1690fbdcb51243fe4e7a20" of a real
# confirmed mainnet output to this address (txid 1ebd0219...b0d48a, vout 1), cross-checked
# against the Electrum server's own listunspent for this scripthash.
address = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
assert address_to_scripthash(address) == "5b7744e34b1d6ee3ae6eef7e1ce82aa4c28ff13f10d4aa905640c722c9249111"
+13
View File
@@ -0,0 +1,13 @@
from app.auth import security
def test_password_hash_roundtrip():
hashed = security.hash_password("s3cret!")
assert security.verify_password("s3cret!", hashed)
assert not security.verify_password("wrong", hashed)
def test_jwt_roundtrip(monkeypatch):
monkeypatch.setattr(security.settings, "jwt_secret", "test-secret")
token = security.create_access_token(user_id=42)
assert security.decode_access_token(token) == 42
+98
View File
@@ -0,0 +1,98 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import PendingTransaction, User, UtxoEvent
from app.wallet.hd import derive_user_address
from app.withdrawals.service import WithdrawalError, request_withdrawal
class FakeElectrumClient:
def __init__(self):
self.broadcasted: list[str] = []
async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex)
return "fake-network-txid"
EXTERNAL_ADDRESS = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx"
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(
settings,
"xprv_encryption_key",
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
)
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
async def _make_funded_user(session_factory, index: int, funded_sats: int) -> int:
async with session_factory() as session:
address = derive_user_address(index)
user = User(username=f"user{index}", password_hash="x", derivation_index=index, address=address)
session.add(user)
await session.commit()
session.add(
UtxoEvent(user_id=user.id, txid=f"{index:02x}" * 32, vout=0, amount_sats=funded_sats, confirmed_height=100)
)
await session.commit()
return user.id
async def test_withdrawal_broadcasts_and_updates_balance(session_factory):
user_id = await _make_funded_user(session_factory, 0, 500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
withdrawal = await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, 100_000_000)
assert client.broadcasted
assert withdrawal.status == "broadcast"
assert withdrawal.amount_sent_sats < 100_000_000 # fee deducted from the amount
async with session_factory() as session:
user = await session.get(User, user_id)
# The spent UTXO is gone immediately; the change output isn't credited
# until it's independently observed as confirmed on-chain (same as bets) —
# so the cached balance is transiently 0 until then, not the pre-fee delta.
assert user.cached_balance_sats == 0
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.kind == "withdrawal"
assert pending.withdrawal_id == withdrawal.id
async def test_withdrawal_rejects_amount_below_minimum(session_factory):
user_id = await _make_funded_user(session_factory, 1, 500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="minimum"):
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, settings.min_amount_sats - 1)
async def test_withdrawal_rejects_insufficient_balance(session_factory):
user_id = await _make_funded_user(session_factory, 2, 1_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="insufficient balance"):
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, settings.min_amount_sats)