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:
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user