The upgrade target reorders storage. ThunderLoan declares s_feePrecision before s_flashLoanFee, while ThunderLoanUpgraded removes s_feePrecision and replaces it with a constant. Constants are embedded in bytecode and occupy no storage slot, so every variable declared after it shifts up by one slot.
A proxy keeps its storage across an upgrade while the implementation's variable-to-slot mapping is recompiled from the new source. After the upgrade, s_flashLoanFee therefore reads the slot that used to hold s_feePrecision (1e18), and s_currentlyFlashLoaning reads the slot that used to hold the fee.
Likelihood: High
This is not an edge case but the intended deployment path. The protocol documentation states: "We are planning to upgrade from the current ThunderLoan contract to the ThunderLoanUpgraded contract." Performing that documented upgrade triggers the bug with certainty.
Impact: High
The flash loan fee becomes 100% of the borrowed value the instant the upgrade executes, because s_flashLoanFee and FEE_PRECISION both equal 1e18, so fee = value * 1e18 / 1e18 = value. Every flash loan then demands repaying double the borrowed amount, breaking the protocol's core function for all users.
On a proxy this is unrecoverable without a further upgrade, and the corrupted s_currentlyFlashLoaning slot additionally breaks repay().
Deploy behind an ERC1967Proxy, record the fee, perform the documented upgrade, read the fee again.
Both tests pass. Result:
Never remove or reorder existing storage variables in an upgradeable contract. The proxy's storage is fixed at the layout of the first implementation, so a later implementation must preserve that layout exactly: variables may only be appended at the end, and any variable that is no longer used must keep its slot occupied by a placeholder rather than being deleted.
Replacing a storage variable with a constant is the specific trap here, because a constant looks like a declaration in the same position but consumes no slot, silently shifting everything below it.
Declare FEE_PRECISION as a constant elsewhere in the contract body (after all storage variables), and add an automated storage-layout check to CI, such as the OpenZeppelin Upgrades plugin, so a layout-incompatible upgrade cannot be deployed.
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.