Beginner FriendlyGameFi
100 EXP
View results
Submission Details
Severity: low
Valid

Permanent Fund Lock Without Emergency Recovery

Permanent Fund Lock Without Emergency Recovery

Description

  • The Pizza Drop contract allows the owner to register users for APT rewards through the register_pizza_lover function.

  • There is no mechanism to withdraw unclaimed funds from the resource account, meaning funds could become permanently locked.

public entry fun fund_pizza_drop(owner: &signer, amount: u64) acquires ModuleData, State {
let state = borrow_global_mut<State>(get_resource_address());
assert!(signer::address_of(owner) == state.owner, E_NOT_OWNER);
let resource_addr = get_resource_address();
@> coin::transfer<AptosCoin>(owner, resource_addr, amount); // Funds can be sent in
state.balance = state.balance + amount;
}
// No emergency withdrawal mechanism exists

Risk

Likelihood:

  • When eligible users lose access to their wallets, and cannot claim anymore.

  • When eligible users forget to claim.

  • When the resource account is funded with more funds than are claimed, very possible because amounts are assigned random.

  • When the owner wants to end the airdrop campaign before all funds are claimed.

  • When there are bugs in the claim function.

Impact:

  • No clean way to handle unclaimed funds or emergency situations.

  • Owner can only only withdraw funds by creating new wallets and using airdrop mechanism, but this is inefficient and complex.

  • This approach is less transparent compared to using a proper withdrawal function.

  • A withdraw function can be added later, but only if the Pizza Drop contract is deployed with upgrade capability.

Proof of Concept

Consider the following scenario:

  1. The Pizza Drop contract is funded with 1,000 APT tokens:

fund_pizza_drop(owner, 1000); // 1000 APT sent to resource account
  1. Users are registered and assigned random amounts:

register_pizza_lover(owner, @user1); // Assigned 300 APT
register_pizza_lover(owner, @user2); // Assigned 200 APT
register_pizza_lover(owner, @lost_user); // Assigned 400 APT - user lost wallet access
  1. After some time:

    • user1 claims their 300 APT successfully

    • user2 claims their 200 APT successfully

    • lost_user cannot claim their 400 APT (lost wallet access)

    • 100 APT remains unassigned

  2. Current state:

    • 500 APT locked in resource account (400 APT assigned to lost wallet + 100 APT unassigned)

    • No function exists to withdraw these funds

    • Even the contract owner cannot access these funds

    • If contract has no upgrade capability, funds are locked in the resource account

This is problematic because:

  • Lost/inaccessible wallets and unclaimed allocations are common in airdrops

  • Without a withdrawal mechanism, significant value could be locked in the resource account

Recommended Mitigation

Add emergency withdrawal with timelock protection:

+ /// Error if emergency withdrawal is not ready
+ const E_EMERGENCY_NOT_INITIATED: u64 = 6;
+ /// Error if emergency withdrawal timelock not expired
+ const E_TIMELOCK_NOT_EXPIRED: u64 = 7;
+
+ /// Time required between initiating and executing emergency withdrawal
+ const EMERGENCY_TIMELOCK: u64 = 86_400; // 1 day in seconds
+
+ struct State has key {
users_claimed_amount: Table<address, u64>,
claimed_users: Table<address, bool>,
owner: address,
balance: u64,
+ emergency_withdrawal_time: Option<u64>, // When emergency withdrawal was initiated
}
+
+ /// Start emergency withdrawal process with timelock
+ public entry fun initiate_emergency_withdrawal(
+ owner: &signer
+ ) acquires State, ModuleData {
+ let state = borrow_global_mut<State>(get_resource_address());
+ assert!(signer::address_of(owner) == state.owner, E_NOT_OWNER);
+
+ // Set withdrawal time to now
+ state.emergency_withdrawal_time = option::some(timestamp::now_seconds());
+ }
+
+ /// Execute emergency withdrawal after timelock expires
+ public entry fun execute_emergency_withdrawal(
+ owner: &signer
+ ) acquires State, ModuleData {
+ let state = borrow_global_mut<State>(get_resource_address());
+ assert!(signer::address_of(owner) == state.owner, E_NOT_OWNER);
+
+ // Check withdrawal was initiated
+ assert!(option::is_some(&state.emergency_withdrawal_time), E_EMERGENCY_NOT_INITIATED);
+ let init_time = *option::borrow(&state.emergency_withdrawal_time);
+
+ // Check timelock expired
+ let current_time = timestamp::now_seconds();
+ assert!(current_time >= init_time + EMERGENCY_TIMELOCK, E_TIMELOCK_NOT_EXPIRED);
+
+ // Withdraw all funds
+ let balance = get_actual_apt_balance();
+ transfer_from_contract(signer::address_of(owner), balance);
+ state.balance = 0;
+
+ // Reset emergency state
+ state.emergency_withdrawal_time = option::none();
+ }

This solution:

  1. Requires two-step process to prevent rugpulls or accidents

  2. Gives users 24 hours to claim before owner executes withdrawal

  3. Allows recovery of all remaining funds

  4. Maintains proper access control

This solutions prevents permanent fund locking while giving users fair warning and time to claim their rewards before emergency measures are taken.

Test for the solution, showcasing recovering funds via emergency withdrawal:

#[test(deployer = @pizza_drop, user = @0x123, framework = @0x1)]
fun test_emergency_withdrawal(deployer: &signer, user: &signer, framework: &signer) acquires State, ModuleData {
// Setup and fund contract
let (burn_cap, mint_cap) = aptos_coin::initialize_for_test(framework);
account::create_account_for_test(@pizza_drop);
init_module(deployer);
let deployer_coins = coin::mint<AptosCoin>(1000, &mint_cap);
coin::register<AptosCoin>(deployer);
coin::deposit<AptosCoin>(@pizza_drop, deployer_coins);
fund_pizza_drop(deployer, 1000);
// Register some users
register_pizza_lover(deployer, signer::address_of(user));
register_pizza_lover(deployer, @0xDEAD); // Inaccessible address
// Initiate emergency withdrawal
initiate_emergency_withdrawal(deployer);
// Wait for timelock
timestamp::fast_forward_seconds(EMERGENCY_TIMELOCK);
// User should not be able to claim before executing emergency withdrawal
assert!(!has_claimed_slice(signer::address_of(user)), 1);
claim_pizza_slice(user);
assert!(has_claimed_slice(signer::address_of(user)), 1);
let user_claimed_amount = get_claimed_amount(signer::address_of(user));
// Execute withdrawal
let initial_balance = coin::balance<AptosCoin>(signer::address_of(deployer));
execute_emergency_withdrawal(deployer);
// Verify all funds withdrawn
let final_balance = coin::balance<AptosCoin>(signer::address_of(deployer));
assert!(final_balance == initial_balance + 1000 - user_claimed_amount, 1);
assert!(get_actual_apt_balance() == 0, 2);
}
Updates

Appeal created

bube Lead Judge 10 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Lack of withdraw mechanism

Support

FAQs

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