Deployment leaves the shared FeeCurve owned by the deployer key
The in-scope DeployCofferFactory script broadcasts new CofferFactory(feeRecipient) from the deployer key and performs no ownership handover.
Description
The in-scope DeployCofferFactory script broadcasts new CofferFactory(feeRecipient) from the deployer key and performs no ownership handover. The CofferFactory constructor creates the shared FeeCurve with msg.sender as owner, so the documented deployment leaves that role on the deployer EOA with no pending transfer.
The FeeCurve owner can replace the fee recipient, and claim() sends every pooled, unclaimed fee to the recipient configured at claim time. Until a manual two-step handover finishes, compromise or misuse of the deployer key can redirect fees collected across all Coffers.
This is an operational deployment defect rather than an unprivileged protocol attack, so Informational severity is appropriate before mainnet deployment.
Recommendations
Accept a PROTOCOL_ADMIN Safe address in the deployment flow, require it to contain code, and call FeeCurve.transferOwnership(PROTOCOL_ADMIN) in the broadcast. Complete acceptOwnership() from the Safe before the first bond purchase and make the launch check fail unless owner() equals the intended Safe and pendingOwner() is zero.
A constructor parameter for the initial FeeCurve owner is cleaner if the factory bytecode can still change, because the deployer key would never hold the role.
Proof of Concept
The shipped deployment script leaves the shared FeeCurve owned by the broadcasting key and does not start an ownership transfer:
function test_deployScriptLeavesFeeCurveWithDeployerEoa() public {address safeRecipient = makeAddr("protocolSafe");vm.setEnv("FEE_RECIPIENT", vm.toString(safeRecipient));CofferFactory factory = new DeployCofferFactory().run();FeeCurve feeCurve = FeeCurve(factory.I_FEE_CURVE_ADDRESS());address deployer = tx.origin;assertEq(feeCurve.owner(), deployer);assertEq(deployer.code.length, 0);assertEq(feeCurve.pendingOwner(), address(0));vm.deal(address(this), 5 ether);feeCurve.collectFee{value: 5 ether}();address replacement = makeAddr("replacement");vm.prank(deployer);feeCurve.setFeeRecipient(replacement);feeCurve.claim();assertEq(replacement.balance, 5 ether);assertEq(safeRecipient.balance, 0);}
Run from coffer-smart-contracts at commit 793f6a7:
forge buildforge test --match-test test_deployScriptLeavesFeeCurveWithDeployerEoa -vv
Observed result:
[PASS] test_deployScriptLeavesFeeCurveWithDeployerEoa()FeeCurve owner after DeployCofferFactory: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38
Mitigation
Fixed. Mainnet FeeCurve ownership was transferred to the protocol admin Safe and accepted before any fee was pooled or bond was sold. On-chain verification found owner() equal to 0xb5B0A1de34EBd56F5b807ec289E4094066b91dB5 and pendingOwner() equal to zero. Commit e8967f7 adds PROTOCOL_ADMIN code validation and initiates the two-step handover in the published deployment flow. The client stated that the Safe will move from 1-of-1 to 2-of-3 before the first bond purchase.
Report provenance
Source: the corrected final report dated 22 October 2026. Final status and residual conditions are preserved in this write-up. Read the full report.