Root + Impact
Description
public entry fun unstake(staker: &signer, module_owner: &signer, rapper_token: Object<Token>) acquires StakeInfo {
let staker_addr = signer::address_of(staker);
let token_id = object::object_address(&rapper_token);
assert!(exists<StakeInfo>(staker_addr), E_TOKEN_NOT_STAKED);
let stake_info = borrow_global<StakeInfo>(staker_addr);
assert!(stake_info.owner == staker_addr, E_NOT_OWNER);
let staked_duration = timestamp::now_seconds() - stake_info.start_time_seconds;
let days_staked = staked_duration / 86400;
if (days_staked > 0) {
let (wk, ha, ss, cr, wins) = one_shot::read_stats(token_id);
let final_wk = if (days_staked >= 1) { false } else { wk };
let final_ha = if (days_staked >= 2) { false } else { ha };
let final_ss = if (days_staked >= 3) { false } else { ss };
let final_cr = if (days_staked >= 4) { true } else { cr };
if (days_staked >= 1) { cred_token::mint(module_owner, staker_addr, 1); };
if (days_staked >= 2) { cred_token::mint(module_owner, staker_addr, 1); };
if (days_staked >= 3) { cred_token::mint(module_owner, staker_addr, 1); };
if (days_staked >= 4) { cred_token::mint(module_owner, staker_addr, 1); };
one_shot::write_stats(token_id, final_wk, final_ha, final_ss, final_cr, wins);
};
let StakeInfo { start_time_seconds: _, owner: _ } = move_from<StakeInfo>(staker_addr);
one_shot::transfer_record_only(token_id, @battle_addr, staker_addr);
object::transfer(module_owner, rapper_token, staker_addr);
event::emit(UnstakedEvent {
owner: staker_addr,
token_id,
staked_duration,
});
}
Risk
Likelihood:
Impact:
Proof of Concept
This test demonstrates state corruption by showing the stake record is removed but the token remains stuck in the contract during unstaking, while also verifying normal successful unstake behavior.
#[test_only]
module battle_addr::streets_tests {
use std::signer;
use aptos_framework::account;
use aptos_framework::object::Object;
use aptos_token_v2::token::Token;
use battle_addr::cred_token;
use battle_addr::one_shot;
use battle_addr::streets::{Self as streets, StakeInfo};
#[test]
#[expected_failure]
public fun test_unstake_state_corruption() {
let module_owner = account::create_account_for_test(@battle_addr);
let staker = account::create_account_for_test(@staker_addr);
cred_token::init_module(&module_owner);
one_shot::init_module(&module_owner);
one_shot::mint_rapper(&module_owner, @staker_addr);
let rapper_token =
streets::stake(&staker, rapper_token);
streets::unstake(&staker, &module_owner, rapper_token);
assert!(!exists<StakeInfo>(@staker_addr), 1);
assert!(one_shot::balance_of(@staker_addr) == 0, 2);
assert!(one_shot::balance_of(@battle_addr) == 1, 3);
}
#[test]
public fun test_normal_unstake_success() {
let module_owner = account::create_account_for_test(@battle_addr);
let staker = account::create_account_for_test(@staker_addr);
cred_token::init_module(&module_owner);
one_shot::init_module(&module_owner);
one_shot::mint_rapper(&module_owner, @staker_addr);
let rapper_token =
streets::stake(&staker, rapper_token);
streets::unstake(&staker, &module_owner, rapper_token);
assert!(!exists<StakeInfo>(@staker_addr), 4);
assert!(one_shot::balance_of(@staker_addr) == 1, 5);
}
}
Recommended Mitigation
I have moved the move_from
operation to the very end of the function, after all state changes and before event emission.
public entry fun unstake(staker: &signer, module_owner: &signer, rapper_token: Object<Token>) acquires StakeInfo {
let staker_addr = signer::address_of(staker);
let token_id = object::object_address(&rapper_token);
assert!(exists<StakeInfo>(staker_addr), E_TOKEN_NOT_STAKED);
let stake_info = borrow_global<StakeInfo>(staker_addr);
assert!(stake_info.owner == staker_addr, E_NOT_OWNER);
let staked_duration = timestamp::now_seconds() - stake_info.start_time_seconds;
let days_staked = staked_duration / 86400;
if (days_staked > 0) {
let (wk, ha, ss, cr, wins) = one_shot::read_stats(token_id);
let final_wk = if (days_staked >= 1) { false } else { wk };
let final_ha = if (days_staked >= 2) { false } else { ha };
let final_ss = if (days_staked >= 3) { false } else { ss };
let final_cr = if (days_staked >= 4) { true } else { cr };
if (days_staked >= 1) { cred_token::mint(module_owner, staker_addr, 1); };
if (days_staked >= 2) { cred_token::mint(module_owner, staker_addr, 1); };
if (days_staked >= 3) { cred_token::mint(module_owner, staker_addr, 1); };
if (days_staked >= 4) { cred_token::mint(module_owner, staker_addr, 1); };
one_shot::write_stats(token_id, final_wk, final_ha, final_ss, final_cr, wins);
};
// @> FIX: COMPLETE ALL OPERATIONS BEFORE REMOVING STAKE
one_shot::transfer_record_only(token_id, @battle_addr, staker_addr);
object::transfer(module_owner, rapper_token, staker_addr);
// @> FIX: REMOVE STAKE RECORD AFTER ALL OPERATIONS COMPLETE
let StakeInfo { start_time_seconds: _, owner: _ } = move_from<StakeInfo>(staker_addr);
event::emit(UnstakedEvent {
owner: staker_addr,
token_id,
staked_duration,
});
}