Select coins until the change output is guaranteed (B-98, BET-24)
select_utxos took the first coin covering the amount, so 10 + 1 PLM and a 10 PLM bet was refused with balance_leaves_no_change and a 10 PLM withdrawal was reduced. User txs now ask for target + DUST_LIMIT_SATS; the old outcome is kept only when no allowed combination leaves change. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
264441f5d6
commit
32df50cb75
7 files changed
+77
-34
No files matched your search
@@ -183,16 +183,3 @@ leaves the mempool gives no signal at all.
|
||||
Fix: each as described, one regression test per item; for the `closing` wait, a
|
||||
`closing_stalled` audit row every 1800s past the deadline and `closing_waiting_since`
|
||||
on `GET /rounds/current` (spec: TX-29).
|
||||
|
||||
### B-98 — Coin selection stops at the first covering UTXO, refusing a payable bet
|
||||
|
||||
Found while writing the spec on 2026-09-28 (medium). `select_utxos` returns as soon
|
||||
as the selected total covers the target. When that happens exactly (a 10 PLM coin
|
||||
for a 10 PLM bet), the change is 0: the bet is refused with
|
||||
`balance_leaves_no_change` and a withdrawal is reduced by `DUST_LIMIT_SATS`, even
|
||||
though a second coin would have left a proper change output.
|
||||
|
||||
Fix: select until the total covers `target + DUST_LIMIT_SATS` (still largest-first,
|
||||
still capped at `max_inputs`); only when every allowed coin together cannot reach it
|
||||
fall back to today's behaviour (refuse the bet, reduce the withdrawal). Spec:
|
||||
`docs/scommesse-e-prelievi.md` BET-24.
|
||||
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
||||
|
||||
## Project status
|
||||
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 698 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 702 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
|
||||
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
||||
|
||||
@@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
|
||||
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
|
||||
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
|
||||
|
||||
python -m pytest # all 698 tests
|
||||
python -m pytest # all 702 tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
|
||||
```
|
||||
|
||||
@@ -62,7 +62,7 @@ python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
698 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
702 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
|
||||
the pending-inclusive balance calculation, and the SSE push channel. No
|
||||
automated integration tests against a live Electrum connection — mainnet
|
||||
|
||||
+19
-11
@@ -127,7 +127,7 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
|
||||
|
||||
|
||||
def select_utxos(
|
||||
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS
|
||||
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS, min_change_sats: int = 0
|
||||
) -> tuple[list[Utxo], int]:
|
||||
"""Greedily select UTXOs (largest first, to minimize input count) covering
|
||||
target_sats — the amount deducted from the sender's balance. The fee is paid
|
||||
@@ -142,7 +142,13 @@ def select_utxos(
|
||||
want different ones (B-52): MAX_TX_INPUTS protects a user from a fee that would
|
||||
eat into their own bet/withdrawal, while the payout drains a pool holding one
|
||||
UTXO per bet and needs MAX_PAYOUT_TX_INPUTS to be able to pay a full round at
|
||||
all."""
|
||||
all.
|
||||
|
||||
B-98 (BET-24): with `min_change_sats` selection goes on until the total covers
|
||||
`target + min_change_sats`, so a second coin is taken when the first covers the
|
||||
target exactly. Only when every allowed coin together cannot leave that change is
|
||||
the shorter selection returned (any total >= target), and the caller then refuses
|
||||
the bet or reduces the withdrawal as before."""
|
||||
# B-75: a UTXO below the dust limit is never offered, even if one is already on
|
||||
# record: it costs more to spend than it is worth.
|
||||
ordered = sorted(
|
||||
@@ -151,16 +157,18 @@ def select_utxos(
|
||||
selected: list[Utxo] = []
|
||||
total = 0
|
||||
for utxo in ordered:
|
||||
if len(selected) == max_inputs:
|
||||
raise InsufficientFundsError(
|
||||
f"balance too fragmented: more than {max_inputs} inputs would be needed",
|
||||
code="too_many_inputs",
|
||||
max_inputs=max_inputs,
|
||||
)
|
||||
if total >= target_sats + min_change_sats or len(selected) == max_inputs:
|
||||
break
|
||||
selected.append(utxo)
|
||||
total += utxo.amount_sats
|
||||
if total >= target_sats:
|
||||
return selected, total
|
||||
if total >= target_sats:
|
||||
return selected, total
|
||||
if len(selected) == max_inputs and len(ordered) > max_inputs:
|
||||
raise InsufficientFundsError(
|
||||
f"balance too fragmented: more than {max_inputs} inputs would be needed",
|
||||
code="too_many_inputs",
|
||||
max_inputs=max_inputs,
|
||||
)
|
||||
raise InsufficientFundsError("not enough confirmed balance to cover amount")
|
||||
|
||||
|
||||
@@ -204,7 +212,7 @@ def build_signed_transaction(
|
||||
never exactly equal the bet" is a documented invariant of the PLAY phase. The
|
||||
player needs a little more than the bet amount, which is what the error says.
|
||||
"""
|
||||
selected, total_in = select_utxos(utxos, amount_sats)
|
||||
selected, total_in = select_utxos(utxos, amount_sats, min_change_sats=DUST_LIMIT_SATS)
|
||||
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
|
||||
|
||||
change = total_in - amount_sats
|
||||
|
||||
@@ -77,11 +77,7 @@ ancora `open` oltre la scadenza.
|
||||
[WDR-09] scatta solo quando nessuna combinazione di monete lascia un resto
|
||||
sufficiente.
|
||||
*Codice:* `app/wallet/psbt_builder.py:select_utxos` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-98) · *Oggi:* la selezione si ferma alla prima
|
||||
moneta che copre il costo, e il resto si misura solo su quelle monete: con
|
||||
10 + 1 PLM una scommessa da 10 PLM è rifiutata con `balance_leaves_no_change`
|
||||
e un prelievo da 10 PLM viene ridotto invece di usare la seconda moneta.
|
||||
*Test:* `tests/unit/test_psbt_builder.py`
|
||||
|
||||
- **[BET-21]** Il **costo** di una scommessa è il `bet_amount_sats`
|
||||
fotografato sul round alla sua apertura
|
||||
|
||||
+2
-2
@@ -246,7 +246,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | *da registrare* | `test_round_snapshot.py`, `test_admin.py`, `test_rounds_route.py`, `test_scheduler.py` | RND, BET |
|
||||
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP |
|
||||
| B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | *da registrare* | `test_migrations.py` | DAT |
|
||||
| B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | **aperto** | — | BET, WDR |
|
||||
| B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | *da registrare* | `test_psbt_builder.py` | BET, WDR |
|
||||
| B-99 | Un server Electrum di Bitcoin supera ogni controllo sugli header (PLM ne condivide genesis e `powLimit`): il genesis non veniva verificato | Media | *da registrare* | `test_electrum_client.py` | ELC |
|
||||
| B-100 | Il tetto del fee rate coincide con il `maxfeerate` del nodo (10 000 sat/vB): una tariffa al tetto, con vsize stimato per eccesso, verrebbe rifiutata | Bassa | *da registrare* | — (costante; copertura da `test_rbf_dust.py`) | TX |
|
||||
|
||||
@@ -254,7 +254,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
|
||||
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
|
||||
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-95, B-99, B-100.
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-95, B-98, B-99, B-100.
|
||||
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
|
||||
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (0 critici,
|
||||
3 alti, 5 medi), da implementare nell'ordine di
|
||||
|
||||
@@ -279,3 +279,55 @@ def test_dust_sized_recipient_amount_is_refused():
|
||||
change_address=change_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
|
||||
# --- B-98 (BET-24): selection leaves a change output whenever the coins allow it ---
|
||||
|
||||
|
||||
def _built(utxos, amount, reduce=False):
|
||||
signer = _key(7)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
return build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=utxos,
|
||||
to_address=script.p2wpkh(_key(8).to_public()).address(network=PLM_MAINNET),
|
||||
amount_sats=amount,
|
||||
change_address=from_script.address(network=PLM_MAINNET),
|
||||
fee_rate_sat_vb=1,
|
||||
reduce_amount_to_keep_change=reduce,
|
||||
)
|
||||
|
||||
|
||||
def test_a_bet_takes_a_second_coin_when_the_first_covers_it_exactly():
|
||||
"""BET-24: 10 + 1 PLM and a 10 PLM bet — both coins, 1 PLM change, no refusal."""
|
||||
utxos = [Utxo("aa" * 32, 0, 1_000_000_000), Utxo("bb" * 32, 0, 100_000_000)]
|
||||
built = _built(utxos, 1_000_000_000)
|
||||
assert built.change_sats == 100_000_000
|
||||
assert len(Transaction.parse(bytes.fromhex(built.raw_hex)).vin) == 2
|
||||
|
||||
|
||||
def test_a_withdrawal_uses_the_second_coin_instead_of_being_reduced():
|
||||
"""BET-24 / WDR-09: the reduction only happens when no combination leaves change."""
|
||||
utxos = [Utxo("aa" * 32, 0, 1_000_000_000), Utxo("bb" * 32, 0, 100_000_000)]
|
||||
built = _built(utxos, 1_000_000_000, reduce=True)
|
||||
assert built.change_sats == 100_000_000
|
||||
assert built.recipient_sats == 1_000_000_000 - built.fee_sats
|
||||
|
||||
|
||||
def test_with_no_coin_to_spare_the_old_outcome_stands():
|
||||
"""BET-24: a lone coin equal to the bet is still refused / the withdrawal still reduced."""
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
utxos = [Utxo("aa" * 32, 0, 1_000_000_000)]
|
||||
with pytest.raises(InsufficientFundsError) as excinfo:
|
||||
_built(utxos, 1_000_000_000)
|
||||
assert excinfo.value.code == "balance_leaves_no_change"
|
||||
assert _built(utxos, 1_000_000_000, reduce=True).change_sats == DUST_LIMIT_SATS
|
||||
|
||||
|
||||
def test_the_change_target_never_pushes_past_the_input_cap():
|
||||
"""BET-24 / BET-08: capped at MAX_TX_INPUTS, a total that covers the amount is kept."""
|
||||
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
|
||||
selected, total = select_utxos(utxos, 100_000 * MAX_TX_INPUTS, min_change_sats=294)
|
||||
assert len(selected) == MAX_TX_INPUTS and total == 100_000 * MAX_TX_INPUTS
|
||||
Reference in new issue
Block a user