One Shot: Reloaded

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

Flawed Unstaking logic in `battle_addr::streets` causes players to not earn CRED on staking periods greater than 4 days.

Author Revealed upon completion

Description

Affected Asset: steets.move

The Protocol documentation states that a Rapper NFT can be staked to:

  1. Remove vices

  2. Earn CRED per full day staked.

The vices to remove and how much CRED to mint is calculated when the player unstakes their NFT.

While the logic for removing vices is implemented correctly, the logic for earning CRED is incomplete.

The Issue

The logic for earning CRED ends after only 4 days - when all vices have been removed on a Rapper. This means past 4 days, any additional staking duration will not acure CRED. This breaks the protocols invariant of earning CRED per full day staked as the maximum CRED a player can earn is 4 regardless of how many days they stake past 4.

The Problematic Code

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 };
//@audit-issue: Broken logic. If staked for > 4 days, the maximum CRED is only 4 and not per full day staked.
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

The Likelihood of this issue being triggered is High when a player stakes their NFT for more than 4 days. Any additional days staked has no effect on the amount of CRED they will earn.

Impact

The impact of this issue is:

  • A partially broken protocol invariant: the protocol states that players will "earn CRED per full day staked." However, this is not entirely true as stakes past 4 days will not earn CRED.

  • A loss of value for players: Players will stake their NFT for a long duration believing they are earning CRED while only earning a maximum of 4 CRED. E.g staking for 10 days will not result in 10 CRED.

Proof of Concept

The below runnable Move test demonstrates the issue by having a player stake their Rapper NFT for 10 days. They should have received 10 CRED for each day staked but instead have only 4.

The below steps need to be carried out for the test to run successfully.

Setup

In order to run the test, the following test functions and variables needs to be added into each file.

Move.toml

#--snip---
[addresses]
battle_addr = "_"
minter_addr = "_"
# This is the official address for the Aptos Digital Asset (NFT) modules.
aptos_token_v2 = "0x4"
#For testing purposes
player_1 = "_"
player_2 = "_"
[dev-addresses]
battle_addr = "0x42"
minter_addr = "0x100"
#For testing purposes
player_1 = "0x65"
player_2 = "0x14"
#--snip---

cred_token.move

//Helper function to call `init_module` in unit tests because it does not automatically run.
#[test_only]
public fun init_contract(owner: &signer){
init_module(owner);
}
//#[test_only] //Adding a check balance function
public fun return_balance(account: address):u64{
let balance = coin::balance<CRED>(account);
balance
}

one_shot.move

#[test_only]
public fun extract_events(): vector<MintRapperEvent>{
let extract_events = event::emitted_events<MintRapperEvent>();
extract_events
}

The Test

  • Create a "poc_test.move" file and paste the below code.

  • Run test with: aptos move test --dev -f poc_cred_not_earned_properly_in_unstaking

#[test_only]
module battle_addr::poc_flawed_staking_logic{
//Aptos Modules
use std::signer;
use aptos_framework::account;
use aptos_token_v2::token::{Self as token};
use std::debug;
use std::vector;
use std::object;
use std::string;
use aptos_framework::timestamp;
use aptos_framework::genesis;
use aptos_framework::coin;
//User Modules
use battle_addr::one_shot;
use battle_addr::cred_token;
use battle_addr::streets;
const E_NO_RAPPER_NFT: u64 = 100;
#[test(aptos_framework = @0x1, module_owner = @0x561)]
public fun poc_cred_not_earned_properly_in_unstaking(aptos_framework: &signer, module_owner: &signer) {
//--Test Setup--(required for test environment)
genesis::setup();
coin::create_coin_conversion_map(aptos_framework);
timestamp::set_time_has_started_for_testing(aptos_framework);
let module_owner = account::create_account_for_test(@battle_addr);
let player_1 = account::create_account_for_test(@player_1);
let player1_address = signer::address_of(&player_1);
cred_token::init_contract(&module_owner);
//Registering Players
cred_token::register(&player_1);
//Minting Rapper
one_shot::mint_rapper(&module_owner, player1_address);
//Confirm has received Rapper NFT
let p1_balance = one_shot::balance_of(player1_address);
assert!(p1_balance == 1, E_NO_RAPPER_NFT);
//Event emission
let extracted_events = one_shot::extract_events();
let event_message = string::utf8(b"PRINTING EXTRACTED `MintRapperEvent` TO OBTAIN `token_id`");
debug::print(&event_message);
debug::print(&extracted_events); //player1 -> 0xe46a3c36283330c97668b5d4693766b8626420a5701c18eb64026075c3ec8a0a
let player1_tokenid = @0xe46a3c36283330c97668b5d4693766b8626420a5701c18eb64026075c3ec8a0a;
//Converting address to Object
let token_object = object::address_to_object<token::Token>(player1_tokenid);
streets::stake(&player_1, token_object );
//Move Time forward
let seconds_in_one_day = 86400;
let days_to_stake = 10;
timestamp::update_global_time_for_test_secs((days_to_stake * seconds_in_one_day));
let current_time = timestamp::now_seconds();
let time_message = string::utf8(b"THE CURRENT TIME (IN SECONDS)");
debug::print(&time_message);
debug::print(&current_time);
//Unstake NFT
streets::unstake(&player_1, &module_owner, token_object);
//Check CRED Balance (using test helper function)
let p1_cred_balance = cred_token::return_balance(player1_address);
let cred_balance_message = string::utf8(b"PLAYER 1 CRED BALANCE AFTER STAKING FOR 10 DAYS");
debug::print(&cred_balance_message);
debug::print(&p1_cred_balance);
assert!(p1_cred_balance != days_to_stake);
}
}

Recommended mitigation

The recommended mitigation is to mint players CRED equal to the amount of full days that the NFT has been staked for. The conditional minting logic should be removed.

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); //weak-knees, heavy arms, spaghetti_sweater, calm&ready, wins
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 };
//@audit-issue: Broken logic. If staked for > 4 days, the maximum CRED is only 4 and not per full day staked.
- 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); };
//@audit-fix: mints CRED for each full day staked.
+ let tokens_to_mint = days_staked;
+ cred_token::mint(module_owner, staker_addr, tokens_to_mint);
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,
});
}

Support

FAQs

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