Root + Impact
Description
-
Normal behavior:
The airdrop promises 100–500 APT per eligible user.
-
Issue:
The contract assigns and transfers the raw u64
amount for AptosCoin
, which is denominated in octas (1 APT = 100_000_000
octas). As a result, users actually receive 100–500 octas (≈ 0.000001–0.000005 APT), not “100–500 APT” as advertised.
#[randomness]
entry fun get_random_slice(user_addr: address) acquires ModuleData, State {
let state = borrow_global_mut<State>(get_resource_address());
let time = timestamp::now_microseconds();
let random_val = time % 401;
@> let random_amount = 100 + random_val;
table::add(&mut state.users_claimed_amount, user_addr, random_amount);
}
public entry fun claim_pizza_slice(user: &signer) acquires ModuleData, State {
...
@> let amount = *table::borrow(&state.users_claimed_amount, user_addr);
assert!(state.balance >= amount, E_INSUFFICIENT_FUND);
...
@> transfer_from_contract(user_addr, amount);
...
}
Risk
Likelihood:
Impact:
-
Trust/UX: Users expecting 100–500 APT receive ~0.000001–0.000005 APT; perceived as deceptive or broken
-
Ops/metrics: Pool spend and dashboards are off; “unused pool” and per-user payouts don’t match plan
-
Security escalation: If corrected to verdadero APT (×1e8), H2A (predictable RNG) immediately becomes financially severe
Proof of Concept
#[test(deployer = @pizza_drop, user = @0xABC, framework = @0x1)]
fun poc_unit_scale_is_octas_not_full_APT(
deployer: &signer,
user: &signer,
framework: &signer
) acquires State, ModuleData {
use aptos_framework::timestamp;
use aptos_framework::account;
use aptos_framework::aptos_coin;
use aptos_framework::coin;
timestamp::set_time_has_started_for_testing(framework);
let (burn_cap, mint_cap) = aptos_coin::initialize_for_test(framework);
account::create_account_for_test(@pizza_drop);
account::create_account_for_test(signer::address_of(user));
init_module(deployer);
timestamp::update_global_time_for_test(42);
let u = signer::address_of(user);
register_pizza_lover(deployer, u);
let assigned = get_claimed_amount(u);
assert!(assigned >= 100 && assigned <= 500, 10);
assert!(assigned < 100_000_000, 11);
coin::register<aptos_coin::AptosCoin>(deployer);
let owner_coins = coin::mint<aptos_coin::AptosCoin>(assigned, &mint_cap);
coin::deposit<aptos_coin::AptosCoin>(@pizza_drop, owner_coins);
fund_pizza_drop(deployer, assigned);
claim_pizza_slice(user);
let bal = coin::balance<aptos_coin::AptosCoin>(u);
assert!(bal == assigned, 12);
coin::destroy_burn_cap(burn_cap);
coin::destroy_mint_cap(mint_cap);
}
Recommended Mitigation
+ // Choose one of the two: (A) pay real APT; (B) clarify that amounts are OCTAS.
+ // (A) Pay 100–500 APT (scale by 1e8):
+ const OCTAS_PER_APT: u64 = 100_000_000;
+ const MIN_APT: u64 = 100;
+ const MAX_APT: u64 = 500;
+ // Use a secure RNG source (see H2A) returning a u64 'r'
+ let apt_amount: u64 = MIN_APT + (r % (MAX_APT - MIN_APT + 1));
+ let amount_octas: u64 = apt_amount * OCTAS_PER_APT; // 100..500 APT in octas
- table::add(&mut state.users_claimed_amount, user_addr, random_amount);
+ table::add(&mut state.users_claimed_amount, user_addr, amount_octas);
+ // (B) If the intent is 100–500 OCTAS, explicitly document it and update UI/spec:
+ // - Rename fields/messages to “octas”
+ // - Show amounts in APT with proper decimal conversion (divide by 1e8) to avoid confusion
Note: Fixing units to real APT should be done together with fixing predictable timestamp-based “randomness”. Otherwise the change immediately increases financial risk.