One Shot: Reloaded

First Flight #47
Beginner FriendlyNFT
100 EXP
Submission Details
Impact: high
Likelihood: medium

Zero amount bets allow risk free battles and arena griefing

Author Revealed upon completion

Description

Players can place bets with zero CRED amount when entering the battle arena as defenders. This completely breaks the game's economic model by allowing battles with no financial stakes, undermining the risk/reward mechanism that drives the protocol's tokenomics.

Root Cause

In rap_battle::go_on_stage_or_battle(), there is no validation that the bet_amount parameter is greater than zero. The function accepts any value including 0:

// rap_battle.move
public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64 // No validation that this > 0
) acquires BattleArena {
// ...
if (arena.defender == @0x0) {
arena.defender_bet = bet_amount; // @audit-issue: can be 0
let first_bet = coin::withdraw<CRED>(player, bet_amount); // Succeeds with 0
coin::merge(&mut arena.prize_pool, first_bet);
// ...
} else {
assert!(arena.defender_bet == bet_amount, E_BETS_DO_NOT_MATCH); // Forces challenger to also bet 0
// ...
}
}

Key issues:

  1. No minimum bet validation allows defenders to set 0 CRED bets

  2. Challengers are forced to match the defender's bet amount (including 0)

  3. coin::withdraw(player, 0) succeeds, creating empty prize pools

  4. Battles can occur with no economic stakes

Risk

Likelihood: High - Any player can easily call the function with bet_amount = 0

Impact: Medium - Breaks core game mechanics and tokenomics but no direct fund loss

Impact

Medium severity because:

  • Defeats game purpose: Battles become meaningless without financial stakes

  • Breaks tokenomics: CRED utility is undermined if battles don't require it

  • Arena griefing: Defenders can occupy the arena indefinitely with 0 bets, blocking real battles

  • Economic exploitation: Players can gain battle experience/wins without risk

Proof of Concept

This test demonstrate that a defender can griffe the battle with 0 token

#[test]
public fun test_farm_winning() {
let one_day = 86_400_000_000;
// Initialize timestamp for testing
let aptos_framework = account::create_account_for_test(@0x1);
timestamp::set_time_has_started_for_testing(&aptos_framework);
timestamp::update_global_time_for_test(one_day);
// Initialize aggregator factory and coin system
coin::create_coin_conversion_map(&aptos_framework);
let module_owner = account::create_account_for_test(@battle_addr);
let minter = account::create_account_for_test(@minter_addr);
let attacker = account::create_account_for_test(@attacker_addr);
cred_token::init_module_test(&module_owner);
rap_battle::init_module_test(&module_owner);
one_shot::mint_rapper(&module_owner, signer::address_of(&minter));
// Access the first event from the vector and get token_id using helper
let mint_events = event::emitted_events<MintRapperEvent>();
let first_event = vector::borrow(&mint_events, 0);
let token_id = one_shot::get_mint_event_token_id(first_event);
let rapper_token = object::address_to_object<Token>(
token_id
);
print(&rapper_token);
one_shot::mint_rapper(&module_owner, signer::address_of(&attacker));
// Access the first event from the vector and get token_id using helper
let second_mint_events = event::emitted_events<MintRapperEvent>();
let second_event = vector::borrow(&second_mint_events, 1);
let token_id2 = one_shot::get_mint_event_token_id(second_event);
let rapper_token2 = object::address_to_object<Token>(
token_id2
);
print(&rapper_token2);
// Now you can call go_on_stage_or_battle
rap_battle::go_on_stage_or_battle(&minter, rapper_token, 0);
rap_battle::go_on_stage_or_battle(&attacker, rapper_token2, 0);
}

Recommended Mitigation

Add a minimum bet amount validation:

+ const E_INVALID_BET_AMOUNT: u64 = 3;
+ const MINIMUM_BET: u64 = 1; // Or higher value as desired
public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64
) acquires BattleArena {
+ assert!(bet_amount >= MINIMUM_BET, E_INVALID_BET_AMOUNT);
+
let player_addr = signer::address_of(player);
let arena = borrow_global_mut<BattleArena>(@battle_addr);
// ... rest of function unchanged
}

This ensures all battles have meaningful economic stakes while preserving the existing battle mechanics.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.