# Migration Leaves Some Unused assets Locked

Airlock.migrate() computes held-asset balance as totalSupply minus numTokensToSell, ignoring the actual amount consumed at pool init, permanently locking unused approved tokens.

- Payout: Contest reward
- Severity: high
- Program: Doppler
- Credited: KupiaSec
- Disclosed: 2025-02-18
- Write-up quality: 6/10
- Original: https://cdn.cantina.xyz/reports/cantina_competition_doppler.pdf#3.1.7
- Library page: https://bugbountylibrary.com/r/cantina%3Adoppler%3A3.1.7
- Tags: domain:web3 impact:funds-loss kind:contest platform:cantina sev:high tech:evm tech:solidity technique:source-review vuln:accounting-error

**Target:** Airlock.migrate() smart contract function
**Root cause:** migrate() derives the contract's held asset balance from assetData.totalSupply - assetData.numTokensToSell, but numTokensToSell is the approved amount, not the amount actually consumed by poolInitializer.initialize(), so any unused approval is omitted from total0/total1.
**Steps:**
1. Deploy asset via create() with numTokensToSell approved to poolInitializer.
2. Have initialize() consume less than numTokensToSell, leaving a residual balance in Airlock.
3. Call migrate(asset); observe total0/total1 computed as totalSupply - numTokensToSell, excluding the residual.
4. Residual tokens remain stranded in the Airlock contract, never migrated.
**How found:** Code review of migrate() lines 241/243 comparing the balance formula against create()'s approve(numTokensToSell) and initialize() consumption, noticing the approved amount is not the used amount.
**Insight:** When a contract approves an amount to a downstream initializer, the post-call balance must be measured (balanceOf delta) rather than inferred from the approved parameter, since initializers may consume less than approved.
