From 3742afc5b1e359d33a07751954b4743fdfb322ff Mon Sep 17 00:00:00 2001 From: ArokyaMatthew Date: Sun, 5 Apr 2026 06:59:53 +0530 Subject: [PATCH] [UTXO-BUG] CRIT-1: Conservation law bypass via negative/zero-value outputs apply_transaction() never validates that output value_nrtc is positive. A negative-value output reduces output_total, allowing an attacker to create outputs exceeding input_total while the conservation check passes. Attack: 100 RTC input -> [+200 RTC, -100 RTC] outputs output_total = 200 + (-100) = 100 <= input_total = 100 -> PASSES Attacker now has 200 RTC from a 100 RTC input. Fix: validate every output has integer value_nrtc > 0 before the conservation check. Also rejects zero-value dust and float types. Tests added: - test_negative_value_output_rejected - test_zero_value_output_rejected - test_float_value_nrtc_rejected Bounty: #2819 (Critical, 200 RTC) --- node/test_utxo_db.py | 62 ++++++++++++++++++++++++++++++++++++++++++++ node/utxo_db.py | 9 +++++++ 2 files changed, 71 insertions(+) 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