Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions node/test_utxo_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,68 @@ def test_mempool_rejects_fee_exceeding_surplus(self):
ok = self.db.mempool_add(tx)
self.assertFalse(ok)

# -- bounty #2819: negative / zero value outputs -------------------------

def test_negative_value_output_rejected(self):
"""Negative value_nrtc on an output bypasses conservation law.

Attack: 100 RTC input → [+200 RTC, -100 RTC] outputs.
output_total = 200 + (-100) = 100 <= input_total = 100, PASSES.
Attacker mints 100 RTC from nothing.
"""
self._apply_coinbase('alice', 100 * UNIT)
boxes = self.db.get_unspent_for_address('alice')

ok = self.db.apply_transaction({
'tx_type': 'transfer',
'inputs': [{'box_id': boxes[0]['box_id'],
'spending_proof': 'sig'}],
'outputs': [
{'address': 'attacker', 'value_nrtc': 200 * UNIT},
{'address': 'burn', 'value_nrtc': -100 * UNIT},
],
'fee_nrtc': 0,
}, block_height=10)

self.assertFalse(ok)
# Balance must be unchanged
self.assertEqual(self.db.get_balance('alice'), 100 * UNIT)
self.assertEqual(self.db.get_balance('attacker'), 0)

def test_zero_value_output_rejected(self):
"""Zero-value outputs are meaningless dust that bloats the UTXO set."""
self._apply_coinbase('alice', 100 * UNIT)
boxes = self.db.get_unspent_for_address('alice')

ok = self.db.apply_transaction({
'tx_type': 'transfer',
'inputs': [{'box_id': boxes[0]['box_id'],
'spending_proof': 'sig'}],
'outputs': [
{'address': 'bob', 'value_nrtc': 100 * UNIT},
{'address': 'dust', 'value_nrtc': 0},
],
'fee_nrtc': 0,
}, block_height=10)

self.assertFalse(ok)

def test_float_value_nrtc_rejected(self):
"""value_nrtc must be an integer; floats cause silent truncation."""
self._apply_coinbase('alice', 100 * UNIT)
boxes = self.db.get_unspent_for_address('alice')

ok = self.db.apply_transaction({
'tx_type': 'transfer',
'inputs': [{'box_id': boxes[0]['box_id'],
'spending_proof': 'sig'}],
'outputs': [
{'address': 'bob', 'value_nrtc': 99.5 * UNIT},
],
'fee_nrtc': 0,
}, block_height=10)
self.assertFalse(ok)


class TestCoinSelect(unittest.TestCase):

Expand Down
9 changes: 9 additions & 0 deletions node/utxo_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,15 @@ def apply_transaction(self, tx: dict, block_height: int,
return False

output_total = sum(o['value_nrtc'] for o in outputs)

# Every output must carry a strictly positive value.
# Without this, a negative-value output lowers output_total,
# letting an attacker create more value than the inputs hold.
for o in outputs:
if not isinstance(o['value_nrtc'], int) or o['value_nrtc'] <= 0:
conn.execute("ROLLBACK")
return False

if fee < 0:
conn.execute("ROLLBACK")
return False
Expand Down
Loading