reconcile_once abandoned a row when the *active* server's history lacked its txid:
after 120s for `building` (which a restart onto a different server makes likely),
after 6h for `pending`, and all at once after a forward clock jump. It re-checked the
status but not the txid, so it raced the bumper, and a `failed` row was never looked at
again.
- The reconciler asks every server (ElectrumListener.get_histories, active + others) for
the history of the row's address and looks for every txid of the row. Present on any
server -> promoted (adopting the version found). Absent is only "no server of the
quorum knows it" and needs the active server plus the quorum minimum of the others to
have answered; even then nothing is released without proof:
(a) an input spent in a block by a transaction that is none of the row's versions,
agreed by a strict majority of a quorum of the other servers (confirmed_spender), or
(b) a rebroadcast of the latest raw tx to every server (rebroadcast_everywhere)
refused by all of them, explicitly, for missing/spent inputs.
Accepted or "already known" by any server means alive; a fee refusal is not proof
(`building` -> `pending` for the bumper, `pending` untouched); too few answers or
anything else leaves the row alone. failure_reason names the proof.
- The write re-reads status *and* current_txid under begin_immediate (TX-26).
- pending_transactions.failed_at (set by _abandon and _release_failed_withdrawal) starts
a 14-day watch: a failed row that a majority of the other servers (at least the quorum
minimum) list in a block becomes `confirmed`, writes pending_tx_resurrected once and
pauses the lottery with paused_reason `resurrected_tx`; linked rows are not corrected.
The confirmation is by quorum of histories for now; merkle proofs come with B-83.
- round_config.paused_reason (migration b7d2e41c9a05, with the failed_at one): `operator`
from POST /admin/pause (which accepts an optional `note` and never overwrites an
automatic reason), cleared by resume, which logs the reason it lifted; exposed on
/admin/config and GET /rounds/current. rounds/pause.py:pause_automatically is the
system's way in.
- PendingTransactionReconciler takes the listener as `network`; with none it can promote
but never abandon.
Tests changed because the spec changed: the three reconciler tests that expect an
abandonment (and the one in test_tx_versions) now supply a network that proves the
transaction is gone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The poller and the reconciler matched current_txid only. If an earlier version of
a bumped transaction confirmed it was never seen, and after 6h the row was abandoned:
a confirmed bet's participant deleted, a sent withdrawal marked failed, a paid payout
rebuilt. bump_fee also broadcast in phase 2 and persisted in phase 3 unconditionally,
so a crash or a `database is locked` in between left a transaction on the network that
the DB did not know, and an abandon or a confirmation in the meantime was overwritten.
- New table pending_transaction_txids (migration a3f5c2d9b7e1; txid unique, index on
the owning row; existing rows backfilled with current_txid + raw and the txid their
last bump replaced, raw_hex empty). Written with the row by place_bet,
request_withdrawal and _trigger_payout; removed with a bet row that a verified
refusal deletes (DAT-38).
- bump_fee in four phases: read, build and sign, commit the replacement as a candidate
version, broadcast (verdict of B-76, fanned out when accepted; an explicit refusal
raises RbfError and leaves the row), then switch the row over with
`UPDATE ... WHERE status='pending' AND current_txid=<replaced>`. Zero rows matched is
a logged conflict: nothing is overwritten and the candidate stays recorded. An
uncertain broadcast adopts the replacement. A retry rebuilds the same candidate
without tripping the unique txid.
- poll_once and reconcile_once look for every txid of a row. When the version found is
not the current one, broadcast.adopt_version makes it current (raw tx included) and
retargets spent_txid, bet_txid, withdrawals.txid and payout_txid as a bump does,
before the confirmation handler runs. _release_inputs frees inputs claimed by any
version of the row.
- RbfBumper takes the listener as `network`.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
place_bet and request_withdrawal released everything on any exception from the
broadcast, including the 15s timeout and a connection dropped after the write. The
tx may have propagated: a bet's sats landed in the pool with no participant, a
withdrawal left while its row said failed and its coins were spendable again.
- client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply
carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain
ElectrumError.
- tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused /
uncertain. A refusal is verified first: ElectrumListener.transaction_known asks
blockchain.transaction.get of the active server and every other one (10s each) —
any server returning the tx means accepted; the active server plus
MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps
the reduced rule); too few answers is uncertain. "already known" wording is only a
shortcut that skips the check. With no network to ask, a refusal is uncertain.
- place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal
releases; uncertain leaves the rows building for the reconciler, publishes SSE and
POST /bets and POST /withdrawals answer 202 with status building; "already known"
promotes a payout (payout_sent) like any accepted broadcast.
- An accepted transaction is fanned out to every other server
(ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF
replacement in bump_fee is left for B-77, which rewrites that function.
Tests changed because the spec changed: the existing "refused broadcast" tests raised
a bare RuntimeError (now an uncertain outcome) and are made explicit refusals
(rpc_error) with a network that denies the tx; direct route calls pass a Response;
FakePayoutListener gains the network surface.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An empty ELECTRUM_FALLBACK_SERVERS (the .env.example default) silently made every
corroboration pass, and with fallbacks the majority was taken over whoever
answered, so one reachable fallback decided alone. TLS certificates were never
checked.
- _corroborate_majority passes only with at least 2 answers (MIN_QUORUM_RESPONSES)
and a strict majority of the answers; no other server configured, or fewer than
2 answering, fails. ALLOW_NO_QUORUM=true keeps the old reduced rule.
- config.validate_quorum_servers, called from the lifespan after parse_endpoints,
refuses to start with fewer than 2 fallback servers unless ALLOW_NO_QUORUM=true
(which logs a warning at every start).
- Optional pinning: `host:port[:mode]:pin=<sha256>` per fallback and
ELECTRUM_CERT_PIN for the primary; ElectrumClient compares the SHA-256 of the
DER certificate after the TLS handshake and a mismatch fails like an unreachable
server. A pin on a plaintext entry is a startup error.
- .env.example now ships the four bootstrap servers and documents both new
variables; docs/setup.md, spec, storico and BUGS.md updated.
- Tests changed because the spec changed: the two "no other servers configured ->
True" corroboration tests now assert that outcome only under allow_no_quorum.
Note: a dev .env with fewer than 2 fallback servers will now refuse to start.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
All bets of all rounds paid one address (m/84'/746'/0'/1/0), so its history
grew forever, other rounds' money and stray coins mixed with a round's own, and
the payout picked from everything it held.
- hd.py: derive_pool_key/derive_pool_address(round_id) on branch 2
(m/84'/746'/0'/2/<round_id>); no argument is the reserve address 1/0.
derive_round_pool_key checks the key against the address stored on the round,
so a payout can never be signed for money its key does not control.
- rounds.pool_address (migration 477bddae1a84, existing rounds backfilled with
the reserve address since that is where their bets went; add_column, not a
batch alter, so the expression indexes stay): written in the transaction that
inserts the round, once its id exists.
- place_bet pays round.pool_address; the payout reads, signs and takes change at
the round's own address; own_address_for and bump_fee's _signing_context follow
the round (confirmation poller and reconciler pass round_id).
- Withdrawals to any round pool address, the reserve address or fee_address are
refused (withdrawal_to_platform_address, 400, all seven languages).
- GET /rounds/current and GET /admin/rounds expose pool_address.
- RND-22/DAT-11 stay "da implementare" for B-82: the payout still spends every
confirmed coin at the round's address, not only the participants' outpoints
(participants carry no bet_vout yet).
- tests/unit/conftest.py: every test now gets a throwaway master key. Opening a
round derives an address, and tests that merely opened a round were silently
reading the real key from the local .env.
- Existing tests changed because the spec changed: payout fixtures give their
round a pool_address; the reconciler's address-cache test uses two payouts of
one round (rows of different rounds no longer share an address);
own_address_for now takes the round id.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Add docs/README.md as the index of all documentation.
- Split the 52 DEC entries of decisioni-e-limiti.md into docs/adr/ADR-NN
files (same numbers), add ADR-53 (audit log), TEMPLATE.md and an ADR index
with a feature -> ADR table.
- Rename decisioni-e-limiti.md to limiti-noti.md (LIM only) and rewrite all
DEC-nn references as links to the ADRs.
- Document the docs-first / SSOT / ADR rules in processo-di-modifica.md and
map the docs in CLAUDE.md and README.md.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>