-
-
Notifications
You must be signed in to change notification settings - Fork 200
[UTXO-BUG] CRITICAL: Empty outputs allow fund destruction (200 RTC) #2120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Scottcjn
merged 3 commits into
Scottcjn:main
from
yuzengbaao:fix/utxo-empty-outputs-fund-destruction
Apr 6, 2026
+230
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| #!/usr/bin/env python3 | ||
| # SPDX-License-Identifier: MIT | ||
| """ | ||
| Test case for UTXO Empty Outputs Bug in apply_transaction() | ||
| Issue: #2819 - Red Team UTXO Implementation | ||
|
|
||
| This test demonstrates a CRITICAL vulnerability where empty outputs | ||
| result in complete fund destruction. | ||
| """ | ||
|
|
||
| import unittest | ||
| import tempfile | ||
| import os | ||
| import sys | ||
| import time | ||
|
|
||
| sys.path.insert(0, '/tmp/Rustchain_utxo/node') | ||
|
|
||
| from utxo_db import UtxoDB, UNIT | ||
|
|
||
|
|
||
| class TestUTXOEmptyOutputsBug(unittest.TestCase): | ||
| """ | ||
| CRITICAL: Empty outputs must be rejected to prevent fund destruction. | ||
|
|
||
| Bounty: #2819 - Red Team UTXO Implementation | ||
| Severity: CRITICAL (200 RTC) | ||
| Reporter: XiaZong (RTC0816b68b604630945c94cde35da4641a926aa4fd) | ||
|
|
||
| Vulnerability: | ||
| When outputs=[] and fee=0, the conservation check: | ||
| if inputs and (output_total + fee) > input_total: | ||
| becomes: | ||
| if inputs and (0 + 0) > input_total: | ||
| which evaluates to False, allowing the transaction to proceed. | ||
| Result: Inputs are spent, no outputs are created → funds destroyed. | ||
| """ | ||
|
|
||
| def setUp(self): | ||
| self.tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False) | ||
| self.tmp.close() | ||
| self.db = UtxoDB(self.tmp.name) | ||
| self.db.init_tables() | ||
|
|
||
| def tearDown(self): | ||
| os.unlink(self.tmp.name) | ||
|
|
||
| def test_empty_outputs_rejected(self): | ||
| """ | ||
| CRITICAL: Empty outputs must be rejected to prevent fund destruction. | ||
|
|
||
| Steps: | ||
| 1. Create UTXO with 100 RTC | ||
| 2. Try to spend with outputs=[] | ||
| 3. Verify transaction is rejected | ||
| 4. Verify balance is preserved | ||
| """ | ||
| # Step 1: Create initial UTXO with 100 RTC | ||
| ok = self.db.apply_transaction({ | ||
| 'tx_type': 'mining_reward', | ||
| 'inputs': [], | ||
| 'outputs': [{'address': 'alice', 'value_nrtc': 100 * UNIT}], | ||
| 'fee_nrtc': 0, | ||
| 'timestamp': int(time.time()), | ||
| }, block_height=1) | ||
| self.assertTrue(ok, "Coinbase should succeed") | ||
|
|
||
| # Verify Alice has 100 RTC | ||
| alice_before = self.db.get_balance('alice') | ||
| self.assertEqual(alice_before, 100 * UNIT, "Alice should have 100 RTC") | ||
|
|
||
| # Get the UTXO | ||
| boxes = self.db.get_unspent_for_address('alice') | ||
| self.assertEqual(len(boxes), 1, "Alice should have 1 UTXO") | ||
| box_id = boxes[0]['box_id'] | ||
|
|
||
| # Step 2: EXPLOIT - Try to spend with empty outputs | ||
| ok = self.db.apply_transaction({ | ||
| 'tx_type': 'transfer', | ||
| 'inputs': [{'box_id': box_id, 'spending_proof': 'sig'}], | ||
| 'outputs': [], # EMPTY - This is the vulnerability! | ||
| 'fee_nrtc': 0, | ||
| 'timestamp': int(time.time()), | ||
| }, block_height=2) | ||
|
|
||
| # Step 3: Transaction MUST be rejected | ||
| self.assertFalse(ok, | ||
| "CRITICAL: Empty outputs should be rejected to prevent fund destruction!") | ||
|
|
||
| # Step 4: Balance must be preserved | ||
| alice_after = self.db.get_balance('alice') | ||
| self.assertEqual(alice_after, 100 * UNIT, | ||
| "Balance should not change if transaction is rejected") | ||
|
|
||
| # Verify no funds were destroyed | ||
| total_supply = self.db.get_balance('alice') + \ | ||
| self.db.get_balance('bob') + \ | ||
| self.db.get_balance('charlie') | ||
| self.assertEqual(total_supply, 100 * UNIT, | ||
| "Total supply should remain 100 RTC - no funds destroyed") | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| suite = unittest.TestLoader().loadTestsFromTestCase(TestUTXOEmptyOutputsBug) | ||
| runner = unittest.TextTestRunner(verbosity=2) | ||
| result = runner.run(suite) | ||
|
|
||
| print("\n" + "=" * 70) | ||
| if result.failures: | ||
| print("⚠️ CRITICAL VULNERABILITY CONFIRMED!") | ||
| print("⚠️ Test FAILED - Empty outputs are accepted (BUG!)") | ||
| print("=" * 70) | ||
| print("\nThis means funds can be destroyed via empty outputs.") | ||
| print("Conservation law is bypassed.") | ||
| print("\nFix: Add 'if not outputs: return False' in apply_transaction()") | ||
| else: | ||
| print("✅ Test PASSED - Empty outputs are rejected (FIXED)") | ||
| print("=" * 70) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| #!/usr/bin/env python3 | ||
| # SPDX-License-Identifier: MIT | ||
| """ | ||
| Test case for UTXO Mempool Empty Outputs Bug | ||
| Issue: #2819 - Red Team UTXO Implementation | ||
|
|
||
| This test demonstrates that mempool_add() also accepts empty outputs. | ||
| """ | ||
|
|
||
| import unittest | ||
| import tempfile | ||
| import os | ||
| import sys | ||
| import time | ||
|
|
||
| sys.path.insert(0, '/tmp/Rustchain_utxo/node') | ||
|
|
||
| from utxo_db import UtxoDB, UNIT | ||
|
|
||
|
|
||
| class TestUTXOMempoolEmptyOutputsBug(unittest.TestCase): | ||
| """ | ||
| MEDIUM: mempool_add() accepts empty outputs, Bounty: #2819 - Red Team UTXO Implementation | ||
| Severity: MEDIUM (50 RTC) | ||
| Reporter: XiaZong (RTC0816b68b604630945c94cde35da4641a926aa4fd) | ||
| """ | ||
|
|
||
| def setUp(self): | ||
| self.tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False) | ||
| self.tmp.close() | ||
| self.db = UtxoDB(self.tmp.name) | ||
| self.db.init_tables() | ||
|
|
||
| def tearDown(self): | ||
| os.unlink(self.tmp.name) | ||
|
|
||
| def test_mempool_empty_outputs_rejected(self): | ||
| """ | ||
| MEDIUM: mempool should reject empty outputs. | ||
| Steps: | ||
| 1. Create UTXO with 100 RTC | ||
| 2. Try to add tx with outputs=[] to mempool | ||
| 3. Verify transaction is rejected | ||
| 4. Verify mempool is empty | ||
| """ | ||
| # Step 1: Create initial UTXO with 100 RTC | ||
| ok = self.db.apply_transaction({ | ||
| 'tx_type': 'mining_reward', | ||
| 'inputs': [], | ||
| 'outputs': [{'address': 'alice', 'value_nrtc': 100 * UNIT}], | ||
| 'fee_nrtc': 0, | ||
| 'timestamp': int(time.time()), | ||
| }, block_height=1) | ||
| self.assertTrue(ok, "Coinbase should succeed") | ||
|
|
||
| # Verify Alice has 100 RTC | ||
| alice_before = self.db.get_balance('alice') | ||
| self.assertEqual(alice_before, 100 * UNIT) | ||
|
|
||
| # Get the UTXO | ||
| boxes = self.db.get_unspent_for_address('alice') | ||
| self.assertEqual(len(boxes), 1) | ||
| box_id = boxes[0]['box_id'] | ||
|
|
||
| # Step 2: EXPLOIT - Try to add tx with empty outputs to mempool | ||
| ok = self.db.mempool_add({ | ||
| 'tx_id': 'malicious_tx_001', | ||
| 'tx_type': 'transfer', | ||
| 'inputs': [{'box_id': box_id, 'spending_proof': 'sig'}], | ||
| 'outputs': [], # EMPTY - This should be rejected! | ||
| 'fee_nrtc': 0, | ||
| }) | ||
|
|
||
| # Step 3: Transaction MUST be rejected | ||
| self.assertFalse(ok, | ||
| "MEDIUM: Empty outputs should be rejected from mempool!") | ||
|
|
||
| # Step 4: Verify mempool is empty | ||
| candidates = self.db.mempool_get_block_candidates() | ||
| self.assertEqual(len(candidates), 0, | ||
| "Mempool should be empty if transaction is rejected") | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| suite = unittest.TestLoader().loadTestsFromTestCase(TestUTXOMempoolEmptyOutputsBug) | ||
| runner = unittest.TextTestRunner(verbosity=2) | ||
| result = runner.run(suite) | ||
|
|
||
| print("\n" + "=" * 70) | ||
| if result.failures: | ||
| print("⚠️ VULNERABILITY CONFIRMED!") | ||
| print("⚠️ Test FAILED - Empty outputs accepted by mempool (BUG!)") | ||
| print("=" * 70) | ||
| print("\nThis allows DoS via mempool flooding.") | ||
| print("\nFix: Add empty outputs check in mempool_add()") | ||
| else: | ||
| print("✅ Test PASSED - Empty outputs rejected from mempool (FIXED)") | ||
| print("=" * 70) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
apply_transaction() now rejects transactions with outputs=[], but mempool_add() still admits them (it allows outputs=[] because output_total becomes 0). This reintroduces the exact mempool DoS the mempool conservation checks are trying to prevent: an attacker can submit an outputs=[] tx that will later fail apply_transaction(), but still locks the input UTXOs in utxo_mempool_inputs until expiry. Mirror this empty-outputs rejection (and ideally the same per-output value validation) in mempool_add() to keep admission criteria aligned with apply_transaction().