One Shot: Reloaded

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

Missing Registration Enforcement → Stakers Lose All CRED Rewards

Author Revealed upon completion

Root + Impact

Missing CRED registration enforcement → Stakers who forget to register receive 0 CRED on unstake (rewards silently destroyed).

Description

  • Under normal behavior, staking for ≥ N days is expected to yield CRED rewards on unstake. The function streets::unstake computes days_staked and mints 1 CRED per day threshold (up to 4) to the staker’s address.

  • It was observed that CRED account registration is not enforced before rewards are minted. In cred_token::mint, coins are destroyed when the recipient is not registered for Coin<CRED>, causing users who skipped registration to receive 0 CRED despite qualifying stake duration.

// Root cause in the codebase with @> marks to highlight the relevant section
module battle_addr::streets {
use battle_addr::cred_token;
use aptos_framework::timestamp;
// ...
public entry fun unstake(staker: &signer, module_owner: &signer, rapper_token: Object<Token>) acquires StakeInfo {
// ... compute days_staked ...
if (days_staked >= 1) { cred_token::mint(module_owner, staker_addr, 1); } // @> rewards minted WITHOUT ensuring registration
if (days_staked >= 2) { cred_token::mint(module_owner, staker_addr, 1); } // @> repeated
if (days_staked >= 3) { cred_token::mint(module_owner, staker_addr, 1); }
if (days_staked >= 4) { cred_token::mint(module_owner, staker_addr, 1); }
// ...
}
}
module battle_addr::cred_token {
use aptos_framework::coin;
// ...
public(friend) fun mint(
module_owner: &signer,
to: address,
amount: u64
) acquires CredCapabilities {
let caps = borrow_global<CredCapabilities>(signer::address_of(module_owner));
let coins = coin::mint<CRED>(amount, &caps.mint_cap);
if (coin::is_account_registered<CRED>(to)) {
coin::deposit(to, coins);
} else {
coin::destroy_zero(coins); // @> rewards are silently destroyed when not registered
}
}
public entry fun register(account: &signer) { // @> registration exists but is not enforced by staking flow
coin::register<CRED>(account);
}
}

Risk

Likelihood:

  • New or returning users commonly omit token registration steps; the staking flow does not prompt or enforce registration before minting rewards.

  • Front‑end or wallet UX can fail to auto‑register, and no on‑chain guard exists; thus the condition occurs frequently in practice.

Impact:

  • Permanent reward loss: Stakers who qualified for rewards receive 0 CRED because rewards were destroyed, not queued or refunded.

  • Silent failure & user confusion: No event/error communicates that rewards were lost due to missing registration, degrading UX and trust.

Proof of Concept

Sequence:

1) User does NOT call cred_token::register.

2) User mints Rapper NFT, stakes it, waits >= 1 day.

3) User calls streets::unstake(...).

// Effect from code:
// - unstake calls cred_token::mint(..., staker_addr, 1..4)
// - cred_token::mint checks is_account_registered<CRED>(staker_addr) == false
// - minted coins are destroyed via coin::destroy_zero(...)
// - user's CRED balance remains 0 despite qualifying duration

Recommended Mitigation

A defensive registration gate should be implemented so rewards are never destroyed for unregistered users. Several compatible options exist:

  1. Enroll before rewarding (preferred, minimal change):

    • In streets::unstake, ensure the staker is registered before calling mint. If not, register them using the staker’s signer (available as &signer).

  2. Fail fast instead of destroying:

    • In cred_token::mint, abort with a clear error when to is not registered, preventing silent loss and signaling front‑end fixes.

  3. Escrow pending rewards (optional, UX‑friendly):

    • Accumulate rewards in a PendingRewards table keyed by address when unregistered, claimable after registration.

@@
-module battle_addr::streets {
- use battle_addr::one_shot;
- use battle_addr::cred_token;
+module battle_addr::streets {
+ use battle_addr::one_shot;
+ use battle_addr::cred_token;
+ use aptos_framework::coin::{Self as coin}; // + for registration check
+ use std::signer;
@@
public entry fun unstake(staker: &signer, module_owner: &signer, rapper_token: Object<Token>) acquires StakeInfo {
let staker_addr = signer::address_of(staker);
+ // Ensure CRED registration before any rewards are minted
+ if (!coin::is_account_registered<cred_token::CRED>(staker_addr)) {
+ // Safe to call; no-op if already registered by UI earlier
+ cred_token::register(staker);
+ }
@@
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); };
module battle_addr::cred_token {
use aptos_framework::coin;
+ const E_NOT_REGISTERED: u64 = 9001;
@@
- public(friend) fun mint(
+ public(friend) fun mint(
module_owner: &signer,
to: address,
amount: u64
) acquires CredCapabilities {
let caps = borrow_global<CredCapabilities>(signer::address_of(module_owner));
let coins = coin::mint<CRED>(amount, &caps.mint_cap);
- if (coin::is_account_registered<CRED>(to)) {
- coin::deposit(to, coins);
- } else {
- coin::destroy_zero(coins);
- };
+ // Prefer fail-fast to avoid silent reward loss:
+ assert!(coin::is_account_registered<CRED>(to), E_NOT_REGISTERED);
+ coin::deposit(to, coins);
}
}

Support

FAQs

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