Confirm transactions by merkle proof and reinstate coins only on a quorum (B-83, TX-06, TX-22, TX-23, ELC-25, ELC-26, DEP-02, DEP-08, AUD-18, ADR-07)
poll_once marked a transaction confirmed on one server's `height > 0`, and reinstate_reappeared_utxos restored a coin written off as spent outside the platform on the active server's word alone, so a hostile active server could report a never-relayed payout as confirmed (winner unpaid, round closed) or put a spent coin back into a balance. - electrum/merkle.py: pure merkle-branch verification against a block header. - ElectrumListener.confirmation_proven(txid, height): the *other* servers are asked for blockchain.transaction.get_merkle; a strict majority of at least the quorum minimum must return a proof that recomputes to the merkle root of that block's header (block height and position checked too). The header is the tip the listener holds, or for an older block the one a majority of the quorum agrees on and that meets its own target; a height above our tip cannot be proven. Quorum rules are _corroborate_majority's. - poll_once takes the listener as `network`: the active server's history only nominates a candidate (txid, height); the row confirms only when the proof holds, and stays pending otherwise. With no network nothing confirms. ConfirmationPoller wires it. - The reconciler's late-confirmation check of failed rows (B-78) now puts each claimed (txid, height) to the same proof instead of counting histories. - refresh_user splits reinstatement like a credit: find_reinstate_candidates (read-only; same outpoint, recorded amount, confirmed) -> corroborate_utxo_reinstatement (same quorum as a credit) -> reinstate_reappeared_utxos writes only what was corroborated, rechecking the mark under the lock. Nothing is committed in the read phase any more. - Broadcast fan-out, the third part of the finding, shipped with B-76. Tests changed because the spec changed: every poll_once call now passes a network that proves (FakeNetwork) — the confirmation tests are otherwise unchanged; the failed-row "quorum does not confirm" test now asserts the claim goes to the proof. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a4b160e240
commit
a3c51719bd
23 files changed
+745
-119
No files matched your search
@@ -10,6 +10,7 @@ from app.db.base import Base
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, User
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.tx.confirmation import poll_once
|
||||
from tests.unit.fake_network import FakeNetwork
|
||||
from app.wallet.hd import derive_pool_address, derive_user_address
|
||||
|
||||
|
||||
@@ -82,7 +83,7 @@ async def test_bet_confirmation_marks_participant_confirmed(session_factory):
|
||||
await session.commit()
|
||||
|
||||
client = FakeClient({"tx1": 1})
|
||||
confirmed = await poll_once(session_factory, client)
|
||||
confirmed = await poll_once(session_factory, client, FakeNetwork())
|
||||
assert confirmed == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
@@ -109,7 +110,7 @@ async def test_unconfirmed_tx_is_left_pending(session_factory):
|
||||
await session.commit()
|
||||
|
||||
client = FakeClient({"tx2": 0})
|
||||
confirmed = await poll_once(session_factory, client)
|
||||
confirmed = await poll_once(session_factory, client, FakeNetwork())
|
||||
assert confirmed == 0
|
||||
|
||||
async with session_factory() as session:
|
||||
@@ -128,7 +129,7 @@ async def test_payout_confirmation_closes_round(session_factory):
|
||||
await session.commit()
|
||||
|
||||
client = FakeClient({"tx3": 2})
|
||||
confirmed = await poll_once(session_factory, client)
|
||||
confirmed = await poll_once(session_factory, client, FakeNetwork())
|
||||
assert confirmed == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
@@ -181,7 +182,7 @@ async def test_one_unresolvable_candidate_does_not_block_the_others(session_fact
|
||||
|
||||
exploding_scripthash = address_to_scripthash(derive_user_address(1))
|
||||
confirmed = await poll_once(
|
||||
session_factory, ExplodingClient({"good": 1}, exploding_scripthash=exploding_scripthash)
|
||||
session_factory, ExplodingClient({"good": 1}, exploding_scripthash=exploding_scripthash), FakeNetwork()
|
||||
)
|
||||
assert confirmed == 1 # the healthy one still got processed
|
||||
|
||||
@@ -213,7 +214,7 @@ async def test_bet_confirms_after_an_rbf_bump_changed_the_txid(session_factory):
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert await poll_once(session_factory, FakeClient({"bumped-txid": 1})) == 1
|
||||
assert await poll_once(session_factory, FakeClient({"bumped-txid": 1}), FakeNetwork()) == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
participant = (await session.scalars(select(RoundParticipant).where(RoundParticipant.round_id == 11))).one()
|
||||
@@ -231,7 +232,7 @@ async def test_payout_confirms_after_an_rbf_bump_changed_the_txid(session_factor
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert await poll_once(session_factory, FakeClient({"bumped-payout": 1})) == 1
|
||||
assert await poll_once(session_factory, FakeClient({"bumped-payout": 1}), FakeNetwork()) == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(Round, 12)).status == "closed"
|
||||
@@ -264,6 +265,6 @@ async def test_poll_once_caches_history_per_scripthash(session_factory):
|
||||
call_count["n"] += 1
|
||||
return [{"tx_hash": "tx-a", "height": 0}, {"tx_hash": "tx-b", "height": 0}]
|
||||
|
||||
await poll_once(session_factory, CountingClient())
|
||||
await poll_once(session_factory, CountingClient(), FakeNetwork())
|
||||
|
||||
assert call_count["n"] == 1
|
||||
Reference in new issue
Block a user