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
44 changes: 44 additions & 0 deletions node/test_utxo_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
15 changes: 14 additions & 1 deletion node/utxo_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
Loading