diff --git a/node/test_utxo_db.py b/node/test_utxo_db.py index d413cde4..5df0d65b 100644 --- a/node/test_utxo_db.py +++ b/node/test_utxo_db.py @@ -293,6 +293,50 @@ def test_proposition_roundtrip(self): recovered = proposition_to_address(prop) self.assertEqual(recovered, addr) + # -- bounty #2819: empty-input minting vulnerability --------------------- + + def test_empty_inputs_rejected_for_transfer(self): + """A normal transfer with empty inputs must be rejected. + This prevents minting funds from nothing (bounty #2819).""" + ok = self.db.apply_transaction({ + 'tx_type': 'transfer', + 'inputs': [], + 'outputs': [{'address': 'attacker', 'value_nrtc': 1_000_000 * UNIT}], + 'fee_nrtc': 0, + 'timestamp': int(time.time()), + }, block_height=10) + self.assertFalse(ok) + self.assertEqual(self.db.get_balance('attacker'), 0) + + def test_empty_inputs_rejected_for_unknown_tx_type(self): + """Any non-minting tx_type with empty inputs must be rejected.""" + ok = self.db.apply_transaction({ + 'tx_type': 'some_random_type', + 'inputs': [], + 'outputs': [{'address': 'attacker', 'value_nrtc': 500 * UNIT}], + 'fee_nrtc': 0, + 'timestamp': int(time.time()), + }, block_height=10) + self.assertFalse(ok) + + def test_mining_reward_empty_inputs_allowed(self): + """Legitimate mining_reward transactions MUST still work with empty inputs.""" + ok = self._apply_coinbase('alice', 100 * UNIT) + self.assertTrue(ok) + self.assertEqual(self.db.get_balance('alice'), 100 * UNIT) + + def test_mempool_empty_inputs_rejected_for_transfer(self): + """Mempool must also reject non-minting txs with empty inputs.""" + tx = { + 'tx_id': 'ffff' * 16, + 'tx_type': 'transfer', + 'inputs': [], + 'outputs': [{'address': 'attacker', 'value_nrtc': 999 * UNIT}], + 'fee_nrtc': 0, + } + ok = self.db.mempool_add(tx) + self.assertFalse(ok) + class TestCoinSelect(unittest.TestCase): diff --git a/node/utxo_db.py b/node/utxo_db.py index e809e8b6..5acf23a6 100644 --- a/node/utxo_db.py +++ b/node/utxo_db.py @@ -332,7 +332,14 @@ def apply_transaction(self, tx: dict, block_height: int, return False input_total += row['value_nrtc'] - # -- conservation check (skip for coinbase) ---------------------- + # -- conservation check ------------------------------------------ + # Only authorized minting transaction types may have empty inputs. + # All other transactions must consume at least one input box. + MINTING_TX_TYPES = {'mining_reward'} + if not inputs and tx_type not in MINTING_TX_TYPES: + conn.execute("ROLLBACK") + return False + output_total = sum(o['value_nrtc'] for o in outputs) if inputs and (output_total + fee) > input_total: conn.execute("ROLLBACK") @@ -534,8 +541,14 @@ def mempool_add(self, tx: dict) -> bool: tx_id = tx.get('tx_id', '') inputs = tx.get('inputs', []) + tx_type = tx.get('tx_type', 'transfer') now = int(time.time()) + # Only authorized minting transaction types may have empty inputs. + MINTING_TX_TYPES = {'mining_reward'} + if not inputs and tx_type not in MINTING_TX_TYPES: + return False + conn.execute("BEGIN IMMEDIATE") # Check for double-spend in mempool