diff --git a/node/test_utxo_db.py b/node/test_utxo_db.py index e7cc27b9..304bcabc 100644 --- a/node/test_utxo_db.py +++ b/node/test_utxo_db.py @@ -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): diff --git a/node/utxo_db.py b/node/utxo_db.py index 720ed543..5a4c005d 100644 --- a/node/utxo_db.py +++ b/node/utxo_db.py @@ -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