Skip to content

Commit 81b4b10

Browse files
authored
FIX(#4182): UTXO Ledger - restore spent boxes on rollback + proper coin selection (#4183)
Two fixes in utxo_ledger.py: 1. **Broken rollback causes fund destruction**: When apply_transaction() fails during output creation, the except block returned False but did NOT restore the already-spent boxes. This permanently destroyed funds. Fix: restore spent_boxes to _boxes and _by_address, remove from _spent. 2. **BalanceTracker.transfer() uses all UTXOs**: The transfer method consumed every UTXO box as input, which was inefficient, privacy-leaking, and created unnecessarily large transactions. Fix: greedy coin selection (smallest-first) to minimize inputs. Note: node/utxo_db.py already has correct rollback via SQLite transactions. This fix brings the in-memory ledger to the same safety standard. Wallet: RTC9d7caca3039130d3b26d41f7343d8f4ef4592360
1 parent ad8ab92 commit 81b4b10

1 file changed

Lines changed: 33 additions & 7 deletions

File tree

rips/rustchain-core/ledger/utxo_ledger.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,19 @@ def apply_transaction(self, tx: Transaction, block_height: int) -> bool:
295295
return True
296296

297297
except Exception as e:
298-
# Rollback on failure (restore spent boxes)
299-
# In production, this would be more sophisticated
300-
print(f"Transaction failed: {e}")
298+
# Rollback on failure: restore spent boxes to UTXO set
299+
# FIX(#4182): Previously, spent boxes were not restored on failure,
300+
# causing permanent fund destruction when output creation failed.
301+
for box in spent_boxes:
302+
self._boxes[box.box_id] = box
303+
owner = self._proposition_to_address(box.proposition_bytes)
304+
if owner not in self._by_address:
305+
self._by_address[owner] = set()
306+
self._by_address[owner].add(box.box_id)
307+
# Remove from spent tracking
308+
for box in spent_boxes:
309+
self._spent.discard(box.box_id)
310+
print(f"Transaction failed (rolled back): {e}")
301311
return False
302312

303313
def _proposition_to_address(self, prop: bytes) -> str:
@@ -465,10 +475,26 @@ def transfer(
465475
if available < amount + fee:
466476
return None # Insufficient funds
467477

468-
# Select inputs (simple: use all boxes, create change)
478+
# Select inputs using greedy coin selection (smallest boxes first)
479+
# FIX(#4182): Previously used ALL boxes as inputs, which was:
480+
# - Inefficient (unnecessarily large transactions)
481+
# - Privacy-leaking (exposes all UTXOs to recipient)
482+
# Now selects minimum boxes needed to cover amount + fee
483+
sorted_boxes = sorted(boxes, key=lambda b: b.value)
484+
selected = []
485+
selected_total = 0
486+
for box in sorted_boxes:
487+
selected.append(box)
488+
selected_total += box.value
489+
if selected_total >= amount + fee:
490+
break
491+
492+
if selected_total < amount + fee:
493+
return None # Should not happen (already checked above)
494+
469495
inputs = [
470496
TransactionInput(box_id=b.box_id, spending_proof=b'\x00')
471-
for b in boxes
497+
for b in selected
472498
]
473499

474500
# Create outputs
@@ -483,8 +509,8 @@ def transfer(
483509
)
484510
]
485511

486-
# Change output
487-
change = available - amount - fee
512+
# Change output (based on selected inputs, not all boxes)
513+
change = selected_total - amount - fee
488514
if change > 0:
489515
outputs.append(Box(
490516
box_id=b'',

0 commit comments

Comments
 (0)