Under normal operation, a defender is expected to “take the stage” by transferring a Rapper NFT and placing a CRED bet; then a challenger with an equal bet is expected to arrive, after which a winner is determined and the prize pool is paid out. The arena state is cleared only after a completed battle.
It was observed that no path exists to cancel or exit the “on stage” state before a challenger arrives. When go_on_stage_or_battle
is called on the empty arena, the defender’s NFT is transferred into custody of @battle_addr
and the defender’s bet is withdrawn and merged into prize_pool
. No entry function is exposed to return the NFT or refund the bet without a challenger, creating a liveness risk and indefinite lock of user assets. The README also states that NFTs are custodied at @battle_addr
during battles.
Likelihood:
Protocol usage patterns where challengers do not arrive (low activity periods, UI downtime, or fragmented liquidity) will leave defenders “on stage” indefinitely because no alternative transition exists.
Any mismatch of common bet sizes in the ecosystem (e.g., most users bet different amounts) will effectively prevent matching, causing the same indefinite on-stage state.
Impact:
Liveness failure / asset lock: The defender’s NFT remains in custody of @battle_addr
, and the defender’s CRED bet remains in prize_pool
, with no protocol-native mechanism to recover them without a challenger.
Operational burden / centralization pressure: Because actual transfers out from @battle_addr
require its signer, recovery becomes a manual/centralized operation, increasing trust and support overhead.
Minimal PoC sequence:
1) Defender prepares wallet with a Rapper NFT and some CRED.
2) Defender calls rap_battle::go_on_stage_or_battle(player, rapper_token, 100)
A cancel/exit mechanism should be introduced to guarantee progress. Two complementary improvements are recommended:
Add a cancel_stage
entry function that can be called only by the current defender (and optionally only after a cooldown). This function should:
Refund the entire prize_pool
to the defender.
Return the defender’s NFT from @battle_addr
to the defender (both the internal record and the physical object transfer).
Clear the arena state.
Emit a StageCancelled
event.
Because @battle_addr
is the custodian, the function must perform the physical transfer using an authorized signer (e.g., module_owner: &signer
whose address equals @battle_addr
), consistent with how streets::unstake
handles transfers out of custody.
(Optional) Add a timeout / expiry so that, after N
seconds on stage without a match, cancel_stage
becomes callable (or can be executed permissionlessly to auto-unwind in favor of the defender).
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.