Corroborate deposit credits, not just external spends (B-59)
A candidate external spend has needed a quorum since B-29, but `value` and `height` for a *credit* came from the single active connection and went straight into utxo_events. One hostile or broken server could therefore inflate a user's displayed balance with outpoints that don't exist. It never spends anyone else's coins — a bet or withdrawal built on a phantom UTXO is refused at broadcast and rolled back — but it wedges the balance display and burns build attempts, and on a custodial platform a balance that isn't real is a support incident either way. Balances move in both directions; both directions now need the same quorum. corroborate_utxo_credit asks the other configured servers whether they report the same outpoint, for the same amount, confirmed. The height itself isn't compared: a server still catching up reports height 0 and simply doesn't agree, which is the same answer, while two honest servers can't disagree on the height of a genuinely confirmed outpoint. refresh_user gains the phase that shape already implied: find_new_credit_ candidates (new, confirmed, not already held) inside the first session, corroboration outside any session, then credit_confirmed_utxos over what survived. Only new outpoints are corroborated — re-checking what we already hold would open a connection to every other server on every refresh for an answer that can no longer change anything. A failed corroboration delays a credit, it never loses one: the next scripthash notification or DepositReconciler sweep (300s) re-offers the same outpoint, and the withholding is logged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -184,3 +184,26 @@ async def test_reinstate_reappeared_utxos_ignores_unmarked_rows(session_factory,
|
||||
async with session_factory() as session:
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
||||
assert reinstated == 0
|
||||
|
||||
|
||||
async def test_find_new_credit_candidates_skips_unconfirmed_and_already_known(session_factory, user_id): # B-59
|
||||
"""What the caller has to corroborate before crediting: only entries that would
|
||||
actually write something. Re-corroborating what we already hold would open a
|
||||
connection to every other server on every refresh, for an answer that can no
|
||||
longer change anything."""
|
||||
from app.deposits.service import find_new_credit_candidates
|
||||
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 1_000}]
|
||||
)
|
||||
|
||||
entries = [
|
||||
{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # already credited
|
||||
{"tx_hash": "bb" * 32, "tx_pos": 0, "height": 0, "value": 2_000}, # still in the mempool
|
||||
{"tx_hash": "cc" * 32, "tx_pos": 1, "height": 101, "value": 3_000}, # genuinely new
|
||||
]
|
||||
async with session_factory() as session:
|
||||
candidates = await find_new_credit_candidates(session, user_id, entries)
|
||||
|
||||
assert [(c["tx_hash"], c["tx_pos"]) for c in candidates] == [("cc" * 32, 1)]
|
||||
|
||||
Reference in New Issue
Block a user