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.
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) {
arena.defender = signer::address_of(player);
arena.defender_bet = bet_amount;
let first_bet = coin::withdraw<cred_token::CRED>(player, bet_amount);
coin::merge(&mut arena.prize_pool, first_bet);
arena.defender_token_id = object::object_address(&rapper_token);
} else {
assert!(arena.defender_bet == bet_amount, E_BETS_DO_NOT_MATCH);
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);
coin::deposit(winner, pool);
}
}
Risk
Likelihood:
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
#[test_only]
public entry fun test_zero_bet_farm(
battle_owner: &signer,
p1: &signer, p2: &signer,
rapper1: Object<Token>, rapper2: Object<Token>
) acquires BattleArena {
rap_battle::go_on_stage_or_battle(p1, rapper1, 0);
rap_battle::go_on_stage_or_battle(p2, rapper2, 0);
}
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);
}
}