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);
state.balance = state.balance + amount;
}
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:
The Pizza Drop contract is funded with 1,000 APT tokens:
fund_pizza_drop(owner, 1000);
Users are registered and assigned random amounts:
register_pizza_lover(owner, @user1);
register_pizza_lover(owner, @user2);
register_pizza_lover(owner, @lost_user);
-
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
-
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:
Requires two-step process to prevent rugpulls or accidents
Gives users 24 hours to claim before owner executes withdrawal
Allows recovery of all remaining funds
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 {
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_pizza_lover(deployer, signer::address_of(user));
register_pizza_lover(deployer, @0xDEAD);
initiate_emergency_withdrawal(deployer);
timestamp::fast_forward_seconds(EMERGENCY_TIMELOCK);
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));
let initial_balance = coin::balance<AptosCoin>(signer::address_of(deployer));
execute_emergency_withdrawal(deployer);
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);
}