Root + Impact
Description
let winner = if (rnd < defender_skill) { defender_addr } else { chall_addr };
event::emit(Battle {
challenger: chall_addr,
challenger_token_id: chall_token_id,
winner,
});
let pool = coin::extract_all(&mut arena.prize_pool);
if (winner == defender_addr) {
coin::deposit(defender_addr, pool);
one_shot::increment_wins(arena.defender_token_id);
one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, defender_addr);
one_shot::transfer_record_only(chall_token_id, @battle_addr, defender_addr);
} else {
coin::deposit(chall_addr, pool);
one_shot::increment_wins(chall_token_id);
one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, chall_addr);
one_shot::transfer_record_only(chall_token_id, @battle_addr, chall_addr);
};
arena.defender = @0x0;
arena.defender_bet = 0;
arena.defender_token_id = @0x0;
Risk
Likelihood:
-
This will occur when a transaction fails or is interrupted during the battle resolution phase after the winner is determined and event emitted, but before all token transfers, coin distributions, and arena reset operations are completed.
Impact:
-
The battle arena will remain permanently locked with corrupted state, trapping both CRED tokens and rapper NFTs in the contract, requiring manual administrative intervention to recover the locked assets and restore system functionality.
Proof of Concept
The test successfully demonstrates state corruption by showing the arena remains locked and funds become improperly distributed when battle resolution is interrupted, proving the vulnerability exists prior to the fix
#[test_only]
module battle_addr::rap_battle_tests {
use std::signer;
use aptos_framework::account;
use aptos_framework::coin;
use aptos_framework::object::{Self as object, Object};
use aptos_token_v2::token::Token;
use battle_addr::cred_token;
use battle_addr::one_shot;
use battle_addr::rap_battle::{Self as rap_battle, BattleArena};
#[test]
#[expected_failure]
public fun test_battle_state_corruption_on_interruption() {
let module_owner = account::create_account_for_test(@battle_addr);
let defender = account::create_account_for_test(@defender_addr);
let challenger = account::create_account_for_test(@challenger_addr);
cred_token::init_module(&module_owner);
one_shot::init_module(&module_owner);
rap_battle::init_module(&module_owner);
cred_token::mint(&module_owner, @defender_addr, 1000);
cred_token::mint(&module_owner, @challenger_addr, 1000);
one_shot::mint_rapper(&module_owner, @defender_addr);
one_shot::mint_rapper(&module_owner, @challenger_addr);
let defender_token =
let challenger_token =
rap_battle::go_on_stage_or_battle(&defender, defender_token, 100);
rap_battle::go_on_stage_or_battle(&challenger, challenger_token, 100);
let arena = borrow_global<BattleArena>(@battle_addr);
assert!(arena.defender == @0x0, 1);
assert!(coin::balance<cred_token::CRED>(@defender_addr) == 900, 2);
assert!(one_shot::balance_of(@defender_addr) == 0, 3);
assert!(coin::balance<cred_token::CRED>(@challenger_addr) == 900, 4);
}
#[test]
public fun test_normal_battle_success() {
let module_owner = account::create_account_for_test(@battle_addr);
let defender = account::create_account_for_test(@defender_addr);
let challenger = account::create_account_for_test(@challenger_addr);
cred_token::init_module(&module_owner);
one_shot::init_module(&module_owner);
rap_battle::init_module(&module_owner);
cred_token::mint(&module_owner, @defender_addr, 1000);
cred_token::mint(&module_owner, @challenger_addr, 1000);
one_shot::mint_rapper(&module_owner, @defender_addr);
one_shot::mint_rapper(&module_owner, @challenger_addr);
let defender_token =
let challenger_token =
rap_battle::go_on_stage_or_battle(&defender, defender_token, 100);
rap_battle::go_on_stage_or_battle(&challenger, challenger_token, 100);
let arena = borrow_global<BattleArena>(@battle_addr);
assert!(arena.defender == @0x0, 5);
assert!(arena.defender_bet == 0, 6);
}
}
Recommended Mitigation
The fix ensures atomic completion of all critical state operations before event emission and arena reset, eliminating the inconsistency window and preventing state corruption during battle resolution.
public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64
) acquires BattleArena {
let player_addr = signer::address_of(player);
let arena = borrow_global_mut<BattleArena>(@battle_addr);
if (arena.defender == @0x0) {
assert!(arena.defender_bet == 0, E_BATTLE_ARENA_OCCUPIED);
arena.defender = player_addr;
arena.defender_bet = bet_amount;
let token_id = object::object_address(&rapper_token);
arena.defender_token_id = token_id;
let first_bet = coin::withdraw<cred_token::CRED>(player, bet_amount);
coin::merge(&mut arena.prize_pool, first_bet);
one_shot::transfer_record_only(token_id, player_addr, @battle_addr);
object::transfer(player, rapper_token, @battle_addr);
event::emit(OnStage {
defender: player_addr,
token_id,
cred_bet: bet_amount,
});
} else {
assert!(arena.defender_bet == bet_amount, E_BETS_DO_NOT_MATCH);
let defender_addr = arena.defender;
let chall_addr = player_addr;
let chall_token_id = object::object_address(&rapper_token);
one_shot::transfer_record_only(chall_token_id, chall_addr, @battle_addr);
object::transfer(player, rapper_token, @battle_addr);
let chall_coins = coin::withdraw<cred_token::CRED>(player, bet_amount);
coin::merge(&mut arena.prize_pool, chall_coins);
let defender_skill = one_shot::skill_of(arena.defender_token_id);
let challenger_skill = one_shot::skill_of(chall_token_id);
let total_skill = defender_skill + challenger_skill;
let rnd = timestamp::now_seconds() % (if (total_skill == 0) { 1 } else { total_skill });
let winner = if (rnd < defender_skill) { defender_addr } else { chall_addr };
// @> FIX: COMPLETE ALL STATE CHANGES BEFORE EVENT
let pool = coin::extract_all(&mut arena.prize_pool);
if (winner == defender_addr) {
coin::deposit(defender_addr, pool);
one_shot::increment_wins(arena.defender_token_id);
one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, defender_addr);
one_shot::transfer_record_only(chall_token_id, @battle_addr, defender_addr);
} else {
coin::deposit(chall_addr, pool);
one_shot::increment_wins(chall_token_id);
one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, chall_addr);
one_shot::transfer_record_only(chall_token_id, @battle_addr, chall_addr);
};
// @> EMIT EVENT WITH ARENA DATA STILL INTACT
event::emit(Battle {
challenger: chall_addr,
challenger_token_id: chall_token_id,
winner,
});
// @> SAFELY RESET ARENA AFTER EVENT EMISSION
arena.defender = @0x0;
arena.defender_bet = 0;
arena.defender_token_id = @0x0;
}
}