One Shot: Reloaded

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

M01. Permanent Arena Lock: No Challenger or Cancellation Mechanism

Author Revealed upon completion

Root + Impact

Description

The intended behavior is that a player can take the stage as a defender by staking a CRED token bet and locking their NFT. Another player can then challenge with a matching bet and their NFT, after which the contract determines a winner and pays out the pooled bets.

The problem is that once a defender goes on stage, there is no mechanism for the defender (or anyone else) to cancel or withdraw if no challenger arrives. This allows a defender to lock the arena indefinitely or set a bet size that no one can match, blocking the arena for all other players. In the process, the defender’s bet and NFT are also locked forever.

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) {
@> arena.defender = player_addr;
@> arena.defender_bet = bet_amount;
@> arena.defender_token_id = token_id;
...
} else {
assert!(arena.defender_bet == bet_amount, E_BETS_DO_NOT_MATCH);
...
}
}

Risk

Likelihood:

  • A defender always sets arena.defender and arena.defender_bet when going on stage, with no timeout or cancel function.

  • Attackers can trivially call this once with a very high bet or simply never intend to battle, leaving the arena locked.

Impact:

  • Denial of Service: No other users can use the arena once locked.

  • Permanent loss of funds/NFT: Defender’s stake and NFT remain trapped unless a challenger with the exact bet appears — which may never happen.


Proof of Concept

// Attacker sets unrealistic bet and occupies arena forever.
rap_battle::go_on_stage_or_battle(&attacker_signer, attacker_nft, 1_000_000_000);
// Arena state:
// arena.defender == attacker_addr
// arena.defender_bet == 1_000_000_000
// arena.defender_token_id == attacker_nft.address
// There is no public function to cancel or withdraw.
// No other player can use the arena unless they match this exact bet.
// Defender’s NFT and stake remain permanently locked.

Recommended Mitigation

Add a cancellation or timeout mechanism that allows a defender to reclaim their NFT and bet if no challenger arrives:

+ public entry fun cancel_stage(player: &signer) acquires BattleArena {
+ let addr = signer::address_of(player);
+ let arena = borrow_global_mut<BattleArena>(@battle_addr);
+ assert!(arena.defender == addr, E_NOT_DEFENDER);
+
+ // Return NFT and bet
+ let pool = coin::extract_all(&mut arena.prize_pool);
+ coin::deposit(addr, pool);
+ // TODO: object::transfer stored rapper_token back to addr
+
+ // Reset arena
+ arena.defender = @0x0;
+ arena.defender_bet = 0;
+ arena.defender_token_id = @0x0;
+ }

Alternatively, enforce a time-based expiration (e.g., using timestamp::now_seconds()) so that if no challenger joins within X seconds, the defender can safely withdraw.

Support

FAQs

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