One Shot: Reloaded

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

Zero/Trivial-Bet Battles → Free Win/Stat Farming

Author Revealed upon completion

Root + Impact

Description

  • Expected behavior: A battle should risk a positive amount of CRED; placing a bet must enforce a minimum value so wins/stats cannot be farmed for free.

  • Issue: go_on_stage_or_battle records and accepts the defender/challenger bet without enforcing bet_amount > 0 (or a protocol-defined minimum). As a result, players can initiate and complete battles with zero or near-zero CRED at risk, accumulating wins/stats essentially for free.

// rap_battle.move — root cause highlighted
public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64
) acquires BattleArena {
let arena = borrow_global_mut<BattleArena>(@battle_addr);
if (arena.defender == @0x0) {
// @> MISSING: assert!(bet_amount > 0, E_ZERO_BET);
arena.defender = signer::address_of(player);
arena.defender_bet = bet_amount; // @> accepts 0
let first_bet = coin::withdraw<cred_token::CRED>(player, bet_amount);
coin::merge(&mut arena.prize_pool, first_bet); // @> pool may be 0
arena.defender_token_id = object::object_address(&rapper_token);
// ...
} else {
assert!(arena.defender_bet == bet_amount, E_BETS_DO_NOT_MATCH);
// @> MISSING: assert!(bet_amount > 0, E_ZERO_BET);
let chall = coin::withdraw<cred_token::CRED>(player, bet_amount);
coin::merge(&mut arena.prize_pool, chall); // @> pool may stay 0
// winner decided...
let pool = coin::extract_all(&mut arena.prize_pool); // @> 0-value payout possible
coin::deposit(winner, pool); // @> win recorded with 0 stake
// ...
}
}

Risk

Likelihood:

  • Any user can place a defender bet with bet_amount == 0 (or a dust value) and complete a full battle cycle; no precondition enforces a positive stake.

Equality check (arena.defender_bet == bet_amount) still allows 0 vs 0 (or dust vs dust) matchups.

Impact:

  • Free win/stat farming: Players inflate win counts and any stat bonuses tied to victories without economic cost.

    Economics & integrity degradation: Reputation/ladder systems and future battle odds become distorted; if stats influence outcomes or rewards, this becomes an indirect financial advantage.

Proof of Concept

// Pseudo unit test illustrating zero (or trivial) bet farming
#[test_only]
public entry fun test_zero_bet_farm(
battle_owner: &signer,
p1: &signer, p2: &signer,
rapper1: Object<Token>, rapper2: Object<Token>
) acquires BattleArena {
// Defender posts 0 CRED
rap_battle::go_on_stage_or_battle(p1, rapper1, /* bet_amount = */ 0);
// Challenger matches 0 CRED — battle resolves and records a win
rap_battle::go_on_stage_or_battle(p2, rapper2, /* bet_amount = */ 0);
// Assert: prize pool transfers 0, but winner’s Rapper gets a win
// and both NFTs return (or per current logic, may be reassigned).
// Repeat to farm multiple wins with no economic risk.
}

Note: Even if a specific coin::withdraw implementation rejects amount == 0, the lack of a minimum bet still enables near-zero (dust) farming. Enforcing a protocol minimum is required.

Recommended Mitigation

// rap_battle.move
+ const E_ZERO_BET: u64 = 1001;
+ const MIN_BET: u64 = 1; // or a higher protocol-defined minimum
public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64
) acquires BattleArena {
+ assert!(bet_amount >= MIN_BET, E_ZERO_BET);
let arena = borrow_global_mut<BattleArena>(@battle_addr);
if (arena.defender == @0x0) {
- arena.defender_bet = bet_amount;
+ // Re-enforce before recording, for defense-in-depth
+ assert!(bet_amount >= MIN_BET, E_ZERO_BET);
+ arena.defender_bet = bet_amount;
let first_bet = coin::withdraw<cred_token::CRED>(player, bet_amount);
coin::merge(&mut arena.prize_pool, first_bet);
} else {
assert!(arena.defender_bet == bet_amount, E_BETS_DO_NOT_MATCH);
+ // Re-enforce before withdraw
+ assert!(bet_amount >= MIN_BET, E_ZERO_BET);
let chall = coin::withdraw<cred_token::CRED>(player, bet_amount);
coin::merge(&mut arena.prize_pool, chall);
- let pool = coin::extract_all(&mut arena.prize_pool);
+ // Optional: ensure non-zero prize pool on payout
+ // assert!(coin::value(&arena.prize_pool) > 0, E_ZERO_POOL);
+ let pool = coin::extract_all(&mut arena.prize_pool);
coin::deposit(winner, pool);
}
}

Support

FAQs

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