@@ -200,14 +200,14 @@ fn it_can_create_contract_from_transaction() {
200200 assert_eq ! ( contract. step_amount, 50 . try_into( ) . unwrap( ) ) ;
201201 assert_eq ! ( contract. total_amount, 100 . try_into( ) . unwrap( ) ) ;
202202
203- // Transaction 3
203+ // Transaction 3: valid 32-byte format (total_amount <= tx_value)
204204 let mut data: Vec < u8 > = Vec :: with_capacity ( Address :: SIZE + 32 ) ;
205205 let owner = Address :: from ( [ 0u8 ; 20 ] ) ;
206206 Serialize :: serialize_to_writer ( & owner, & mut data) ;
207207 Serialize :: serialize_to_writer ( & 0u64 . to_be_bytes ( ) , & mut data) ;
208208 Serialize :: serialize_to_writer ( & 100u64 . to_be_bytes ( ) , & mut data) ;
209209 Serialize :: serialize_to_writer ( & Coin :: try_from ( 50 ) . unwrap ( ) , & mut data) ;
210- Serialize :: serialize_to_writer ( & Coin :: try_from ( 150 ) . unwrap ( ) , & mut data) ;
210+ Serialize :: serialize_to_writer ( & Coin :: try_from ( 80 ) . unwrap ( ) , & mut data) ;
211211 tx. recipient_data = data;
212212 tx. recipient = tx. contract_creation_address ( ) ;
213213
@@ -232,9 +232,59 @@ fn it_can_create_contract_from_transaction() {
232232 assert_eq ! ( contract. start_time, 0 ) ;
233233 assert_eq ! ( contract. time_step, 100 ) ;
234234 assert_eq ! ( contract. step_amount, 50 . try_into( ) . unwrap( ) ) ;
235- assert_eq ! ( contract. total_amount, 150 . try_into( ) . unwrap( ) ) ;
235+ assert_eq ! ( contract. total_amount, 80 . try_into( ) . unwrap( ) ) ;
236+
237+ // Transaction 4: 32-byte format with total_amount > tx_value (rejected)
238+ let mut data: Vec < u8 > = Vec :: with_capacity ( Address :: SIZE + 32 ) ;
239+ Serialize :: serialize_to_writer ( & owner, & mut data) ;
240+ Serialize :: serialize_to_writer ( & 0u64 . to_be_bytes ( ) , & mut data) ;
241+ Serialize :: serialize_to_writer ( & 100u64 . to_be_bytes ( ) , & mut data) ;
242+ Serialize :: serialize_to_writer ( & Coin :: try_from ( 50 ) . unwrap ( ) , & mut data) ;
243+ Serialize :: serialize_to_writer ( & Coin :: try_from ( 150 ) . unwrap ( ) , & mut data) ;
244+ tx. recipient_data = data;
245+ tx. recipient = tx. contract_creation_address ( ) ;
246+
247+ let mut tx_logger = TransactionLog :: empty ( ) ;
248+ let result = accounts. test_create_new_contract :: < VestingContract > (
249+ & tx,
250+ Coin :: ZERO ,
251+ & block_state,
252+ & mut tx_logger,
253+ true ,
254+ ) ;
255+ assert_eq ! (
256+ result,
257+ Err ( AccountError :: InvalidTransaction (
258+ TransactionError :: InvalidData
259+ ) )
260+ ) ;
261+
262+ // Transaction 5: 32-byte format with step_amount > total_amount (rejected)
263+ let mut data: Vec < u8 > = Vec :: with_capacity ( Address :: SIZE + 32 ) ;
264+ Serialize :: serialize_to_writer ( & owner, & mut data) ;
265+ Serialize :: serialize_to_writer ( & 0u64 . to_be_bytes ( ) , & mut data) ;
266+ Serialize :: serialize_to_writer ( & 100u64 . to_be_bytes ( ) , & mut data) ;
267+ Serialize :: serialize_to_writer ( & Coin :: try_from ( 80 ) . unwrap ( ) , & mut data) ;
268+ Serialize :: serialize_to_writer ( & Coin :: try_from ( 50 ) . unwrap ( ) , & mut data) ;
269+ tx. recipient_data = data;
270+ tx. recipient = tx. contract_creation_address ( ) ;
236271
237- // Transaction 4: invalid data
272+ let mut tx_logger = TransactionLog :: empty ( ) ;
273+ let result = accounts. test_create_new_contract :: < VestingContract > (
274+ & tx,
275+ Coin :: ZERO ,
276+ & block_state,
277+ & mut tx_logger,
278+ true ,
279+ ) ;
280+ assert_eq ! (
281+ result,
282+ Err ( AccountError :: InvalidTransaction (
283+ TransactionError :: InvalidData
284+ ) )
285+ ) ;
286+
287+ // Transaction 6: invalid data length
238288 tx. recipient_data = Vec :: with_capacity ( Address :: SIZE + 2 ) ;
239289 Serialize :: serialize_to_writer ( & owner, & mut tx. recipient_data ) ;
240290 Serialize :: serialize_to_writer ( & 0u16 . to_be_bytes ( ) , & mut tx. recipient_data ) ;
@@ -481,6 +531,134 @@ fn reserve_release_balance_works() {
481531 assert ! ( result. is_ok( ) ) ;
482532}
483533
534+ #[ test]
535+ fn total_amount_exceeds_balance_panics_on_reserve_balance ( ) {
536+ // Attack scenario: attacker uses the 32-byte vesting creation format to set
537+ // total_amount > transaction.value. This creates a contract where min_cap() can
538+ // exceed self.balance. When an outgoing transaction triggers can_change_balance(),
539+ // the error path computes `self.balance - min_cap` which panics on underflow.
540+ //
541+ // In the mempool path (reserve_balance), this runs inside a tokio::task::spawn,
542+ // so the panic kills only the verification task — not the whole node process.
543+ // However, in the block processing path (commit_outgoing_transaction), the panic
544+ // unwinds through Blockchain::push which holds an RwLockUpgradableReadGuard.
545+ // The poisoned lock makes the blockchain permanently unusable, effectively
546+ // crashing the node.
547+
548+ let mut rng = test_rng ( true ) ;
549+ let key_owner = KeyPair :: generate ( & mut rng) ;
550+ let key_recipient = KeyPair :: generate ( & mut rng) ;
551+
552+ // Create a vesting contract where total_amount (2000) > balance (1000).
553+ // This is reachable via the 32-byte CreationTransactionData format which
554+ // deserializes total_amount from attacker-controlled recipient_data without
555+ // validating total_amount <= tx_value.
556+ let vesting_contract = VestingContract {
557+ balance : Coin :: from_u64_unchecked ( 1000 ) ,
558+ owner : Address :: from ( & key_owner. public ) ,
559+ start_time : 0 ,
560+ time_step : u64:: MAX , // Ensures 0 steps elapsed, so min_cap = total_amount
561+ step_amount : Coin :: from_u64_unchecked ( 1 ) ,
562+ total_amount : Coin :: from_u64_unchecked ( 2000 ) , // > balance!
563+ } ;
564+
565+ let accounts = TestCommitRevert :: with_initial_state ( & [
566+ (
567+ Address :: from ( & key_owner. public ) ,
568+ Account :: Basic ( BasicAccount {
569+ balance : Coin :: from_u64_unchecked ( 1000 ) ,
570+ } ) ,
571+ ) ,
572+ (
573+ Address ( [ 1u8 ; 20 ] ) ,
574+ Account :: Vesting ( vesting_contract. clone ( ) ) ,
575+ ) ,
576+ ] ) ;
577+
578+ let mut db_txn = accounts. env ( ) . write_transaction ( ) ;
579+ let sender_address = Address :: from ( & key_owner) ;
580+ let data_store = accounts. data_store ( & sender_address) ;
581+
582+ // Block time 0 with time_step=MAX means 0 steps have elapsed, so
583+ // min_cap = total_amount = 2000 > balance = 1000.
584+ let block_state = BlockState :: new ( 1 , 0 ) ;
585+
586+ let mut reserved_balance = ReservedBalance :: new ( sender_address. clone ( ) ) ;
587+
588+ // Outgoing transaction for 1 luna — the amount doesn't matter,
589+ // any outgoing tx triggers the panic because min_cap > balance.
590+ let tx = make_signed_transaction ( key_owner. clone ( ) , key_recipient. clone ( ) , 1 ) ;
591+
592+ // This panics in can_change_balance at:
593+ // balance: self.balance - min_cap → 1000 - 2000 → underflow panic
594+ //
595+ // After the fix, this should return Err(AccountError::InsufficientFunds)
596+ // without panicking.
597+ let result = vesting_contract. reserve_balance (
598+ & tx,
599+ & mut reserved_balance,
600+ & block_state,
601+ data_store. read ( & mut db_txn) ,
602+ ) ;
603+ assert ! (
604+ result. is_err( ) ,
605+ "Expected InsufficientFunds error, got: {:?}" ,
606+ result
607+ ) ;
608+ }
609+
610+ #[ test]
611+ fn total_amount_exceeds_balance_panics_on_commit_outgoing ( ) {
612+ // Same root cause as above, but triggered via commit_outgoing_transaction —
613+ // the block processing path. This is the more dangerous path because it runs
614+ // while holding the blockchain RwLock, poisoning it on panic.
615+
616+ let mut rng = test_rng ( true ) ;
617+ let key_owner = KeyPair :: generate ( & mut rng) ;
618+ let key_recipient = KeyPair :: generate ( & mut rng) ;
619+
620+ let mut vesting_contract = VestingContract {
621+ balance : Coin :: from_u64_unchecked ( 1000 ) ,
622+ owner : Address :: from ( & key_owner. public ) ,
623+ start_time : 0 ,
624+ time_step : u64:: MAX ,
625+ step_amount : Coin :: from_u64_unchecked ( 1 ) ,
626+ total_amount : Coin :: from_u64_unchecked ( 2000 ) ,
627+ } ;
628+
629+ let accounts = TestCommitRevert :: with_initial_state ( & [
630+ (
631+ Address :: from ( & key_owner. public ) ,
632+ Account :: Basic ( BasicAccount {
633+ balance : Coin :: from_u64_unchecked ( 1000 ) ,
634+ } ) ,
635+ ) ,
636+ (
637+ Address ( [ 1u8 ; 20 ] ) ,
638+ Account :: Vesting ( vesting_contract. clone ( ) ) ,
639+ ) ,
640+ ] ) ;
641+
642+ let block_state = BlockState :: new ( 1 , 0 ) ;
643+ let tx = make_signed_transaction ( key_owner. clone ( ) , key_recipient. clone ( ) , 1 ) ;
644+
645+ // This panics in can_change_balance during commit_outgoing_transaction.
646+ // After the fix, this should return Err(AccountError::InsufficientFunds).
647+ let mut tx_logger = TransactionLog :: empty ( ) ;
648+ let result = accounts. test_commit_outgoing_transaction (
649+ & mut vesting_contract,
650+ & tx,
651+ & block_state,
652+ & mut tx_logger,
653+ true ,
654+ ) ;
655+ assert ! (
656+ result. is_err( ) ,
657+ "Expected InsufficientFunds error, got: {:?}" ,
658+ result
659+ ) ;
660+ }
661+
484662#[ test]
485663fn can_reserve_balance_after_time_step ( ) {
486664 // -----------------------------------
0 commit comments