[Audit L-03] Reject zero admin in AccessControl initializers (deployment footgun) #120
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:open_issues
agents
agent:security_audit
agent:verify
area
timecurve
arena
arena-v2
audit
block:feature_review
block:security
bug
contracts
design
devops
docs
documentation
enhancement
epic
feature
frontend
indexer
low priority
megaeth
operations
ops
performance
QA
qa checklist
referrals
removal
security
smart-contracts
tech-debt
testing
time-arena
timecurve
visual-qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
code/yieldomega#120
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Source
Tracked from internal smart contract audit:
audits/audit_smartcontract_1777813071.md(L-03: Several AccessControl deployments do not reject zero admin addresses).Problem statement
Contracts that use OpenZeppelin AccessControl (or enumerable upgradeable variants) assign
DEFAULT_ADMIN_ROLEvia_grantRolein constructors /initializerflows. Unlike Ownable-style contracts that commonly rejectaddress(0)in their initializers, these paths currently allowadmin == address(0).Granting
DEFAULT_ADMIN_ROLEtoaddress(0)does not create a usable admin: OpenZeppelin’s role model expects a living account or contract to hold that role so it can grant/revoke other roles and (for UUPS targets) authorize upgrades. A mistaken deployment wiring zero as admin yields a governance-dead deployment: withdrawals, governor updates, NFT admin actions, treasury parameter roles, and upgrade authorization may become permanently unreachable except via an emergency upgrade path that itself requires admin—i.e., a brick risk.This is low severity from an attacker’s perspective: it is an operator/deployer footgun, not an unprivileged fund-stealing path. Impact is misconfiguration leading to irreversible inability to administer the system.
In-scope deployments (today)
FeeSink.__FeeSink_init(admin)grantsDEFAULT_ADMIN_ROLE+WITHDRAWER_ROLEtoadminCL8YProtocolTreasury.initialize,DoubLPIncentives.initialize,EcosystemTreasury.initializedelegate to__FeeSink_initinitialize(admin)grantsDEFAULT_ADMIN_ROLEinitialize(admin, ...)grantsDEFAULT_ADMIN_ROLE+GOVERNOR_ROLEinitialize(..., admin)grantsDEFAULT_ADMIN_ROLE,PARAMS_ROLE,PAUSER_ROLEconstructor(admin)grantsDEFAULT_ADMIN_ROLEonlyconstructor(..., admin)grantsDEFAULT_ADMIN_ROLE+MINTER_ROLERecommended fix
Add an explicit invariant at the earliest common point:
contracts/src/sinks/FeeSink.sol, inside__FeeSink_init, addrequire(admin != address(0), "<consistent suffix>");immediately before any
_grantRolecalls (coversCL8YProtocolTreasury,DoubLPIncentives,EcosystemTreasuryin one place).Add the same guard to each non-inherited initializer/constructor that grants AccessControl roles:
contracts/src/sinks/PodiumPool.sol→initialize(address admin)contracts/src/FeeRouter.sol→initialize(address admin, ...)contracts/src/RabbitTreasury.sol→initialize(..., address admin)(alongside existingrequirevalidations)contracts/src/tokens/Doubloon.sol→constructor(address admin)contracts/src/LeprechaunNFT.sol→constructor(..., address admin)Revert string policy: Prefer a short, grep-friendly prefix per contract (e.g.
FeeSink: zero admin,PodiumPool: zero admin, …) to match existing patterns likeFeeSink: zero addressonwithdraw’sto.No behavior change for valid admins: Existing tests and deployments with non-zero admin must remain green.
Optional hardening (out of narrow L-03 scope but aligned): audit other
initialize/constructorflows that_grantRolewith a caller-supplied address for the same class of mistake.Files expected to change (implementation)
Solidity (required)
contracts/src/sinks/FeeSink.solcontracts/src/sinks/PodiumPool.solcontracts/src/FeeRouter.solcontracts/src/RabbitTreasury.solcontracts/src/tokens/Doubloon.solcontracts/src/LeprechaunNFT.solTests (recommended)
Pick one coherent approach:
contracts/test/AccessControlZeroAdmin.t.solthat deploys minimal instances (directnewwhere non-proxy, proxy +initializevia Foundry patterns matching existing tests) and assertsvm.expectRevertfor each contract/surface; orcontracts/test/FeeSinks.t.sol,contracts/test/FeeRouter.t.sol,contracts/test/RabbitTreasury.t.sol,contracts/test/LeprechaunNFT.t.sol, plus one assertion that a FeeSink derivative wired throughUUPSDeployLibrejects zero admin.Regression coverage matrix (each row must revert with zero admin):
EcosystemTreasury/DoubLPIncentives/CL8YProtocolTreasuryproxyinitialize(address(0))PodiumPoolproxyinitialize(address(0))FeeRouterproxyinitialize(address(0), sinks, weights)(minimal valid sinks/weights or expect revert before sink validation depending on ordering—assert ordering documents which require fires first)RabbitTreasuryproxy fullinitializewithadmin = address(0)but otherwise valid paramsnew Doubloon(address(0))new LeprechaunNFT(..., address(0))Deployment scripts (verification only)
Production paths like
contracts/script/DeployDev.s.solalready use a non-zerodeployer; no functional change strictly required unless you add defensiverequirethere for clarity. Optionally addassert/comments incontracts/script/UUPSDeployLib.solmirroring onchain checks for faster failure during dry-run.Documentation (recommended alignment)
Update or add bullets so operators know zero-admin is forbidden at the EVM boundary:
docs/onchain/fee-routing-and-governance.md(FeeRouter admin / governor semantics)docs/testing/strategy.mdordocs/testing/invariants-and-business-logic.md— add a named invariant / test-map row for “AccessControl deployments rejectadmin == address(0)”contracts/PARAMETERS.mdor ops deployment checklist if admin wiring is documented thereAcceptance criteria
admin == address(0)at constructor orinitializer, before any_grantRolethat assignsDEFAULT_ADMIN_ROLE(and before co-granted roles in the same block:WITHDRAWER_ROLE,GOVERNOR_ROLE,PARAMS_ROLE,PAUSER_ROLE,MINTER_ROLEas applicable).FOUNDRY_PROFILE=ci(or repo-default CI recipe)forge testpasses with new regression tests included.DevStackIntegration,TimeCurve*,FeeRouter*, etc.) remain unchanged except where they intentionally tested zero-admin (unlikely).audits/audit_smartcontract_1777813071.mdor a follow-up changelog note references this issue (maintainer preference).Verification checklist — PR review (code)
.solfile and confirmrequire(admin != address(0), ...)(or equivalent custom error if the repo adopts Solidity custom errors elsewhere for these modules) appears above_grantRolefor the admin parameter used in initialization.FeeSink.__FeeSink_initgates all three sink derivatives without duplicating checks in subclasses (unless a subclass introduces a separate admin-like parameter—none today).FeeRouter.initializeorder: sink validation versus admin check documented; reject tests match actual revert (admin vs sinks)._grantRole(DEFAULT_ADMIN_ROLEincontracts/srcand confirm no initializer path skips the guard.cd contracts && FOUNDRY_PROFILE=ci forge test -vv(or the exact CI command from
audits/audit_smartcontract_1777813071.md§ Executive Summary).Verification checklist — post-merge / release
address(0)as admin; scan environment-driven deploy configs.openzeppelin-upgrades/unsafeAllowmanifests), confirm no unintentional bytecode metadata-only churn beyond the initializer guard.Rationale summary
Owning OpenZeppelin contracts often embed zero-address checks by convention;
AccessControl._grantRoledoes not treataddress(0)as special for admin assignment. Explicit validation aligns deployment safety with the rest of the codebase’s initializer style (many functions alreadyrequirenon-zero economic addresses).Shell
mentioned in commit
deeb851d20mentioned in commit
717c7fd6a1mentioned in commit
ebbf58ed93verified on commit
deeb851(merged via717c7fd). new test filecontracts/test/AccessControlZeroAdmin.t.solcovers all 8 affected contracts from your L-03 list:test_RabbitTreasury_zeroAdmin_revertstest_FeeRouter_zeroAdmin_reverts_before_sink_validation— bonus: confirms zero-admin rejection fires before sink validation, so deployer doesn't waste gas / get a misleading sink reverttest_PodiumPool_zeroAdmin_revertstest_Doubloon_zeroAdmin_revertstest_LeprechaunNFT_zeroAdmin_revertstest_CL8YProtocolTreasury_zeroAdmin_revertstest_DoubLPIncentives_zeroAdmin_revertstest_EcosystemTreasury_zeroAdmin_reverts(FeeSink derivative)deployment footgun closed — accidentally passing
address(0)as initial admin now reverts at constructor/initializer time on every AccessControl contract in the system. all 8 pass clean.mentioned in commit
a073b2c6a1Follow-up (merged to
main): derived-layer docs for GitLab #120On-chain
INV-AC-ZERO-ADMIN-120was already verified elsewhere; this pass documents indexer + SPA boundaries so third-party agents do not expect logs or UI signals for a reverted zero-admin deploy.What changed
INV-INDEXER-120-DEPLOY(no Postgres/API row for deploy-time revert) andINV-FRONTEND-120-DEPLOY(no bootstrapDEFAULT_ADMIN_ROLEprobe).docs/testing/invariants-and-business-logic.md§120,indexer/README.md,docs/indexer/design.md,docs/frontend/wallet-connection.md,docs/onchain/fee-routing-and-governance.md(anchor#deployer-evm-boundary-gitlab-120),docs/testing/manual-qa-checklists.md(#120),docs/testing/strategy.md,skills/README.md,.cursor/skills/yieldomega-guardrails/SKILL.md.Verification checklist for @brouie
INV-*-120-DEPLOY.initializeremits nothing to decode.cd contracts && FOUNDRY_PROFILE=ci forge test --match-path test/AccessControlZeroAdmin.t.sol -vv.skills/README.mdstill points contributors at the fuller anchor graph.Issue left open for your sign-off. Commit on
main:a073b2c.@PlasticDigits — verification PASS on
a073b2cderived-layer docs at HEAD435a446. walked your checklist:docs/testing/invariants-and-business-logic.mdL117 indexer row + L118 frontend row): both cite #120 and link toINV-INDEXER-120-DEPLOY/INV-FRONTEND-120-DEPLOYrespectively. fullINV-INDEXER-120-DEPLOYdefinition at L796 with cross-links toAccessControlZeroAdmin.t.sol, indexer README, and indexer design doc.INV-FRONTEND-120-DEPLOYdefinition at L800 with cross-links towallet-connection.mdand the manual QA checklist.indexer/README.mdL46,docs/indexer/design.mdL11): both anchor#accesscontrol-zero-admin-gitlab-120. README correctly frames the operational safety as Forge coverage + successful bytecode ("not missing rows in Postgres"). design doc explicitly states the indexer's job starts at successful bytecode atADDRESS_REGISTRYaddresses — no decode of failed initializer reverts.docs/frontend/wallet-connection.mdL19 + L21): correctly states the SPA does NOT add a runtime probe forDEFAULT_ADMIN_ROLEand trustsVITE_*registry addresses against already-live bytecode. miswired env surfaces as RPC reverts / empty reads, not a dedicated badge.docs/onchain/fee-routing-and-governance.mdL65 + L67):#deployer-evm-boundary-gitlab-120anchor present, lists all 8 contracts (FeeRouter, PodiumPool, FeeSink + 3 derivatives, RabbitTreasury, Doubloon, LeprechaunNFT) and cross-references both derived invariants.docs/testing/manual-qa-checklists.mdL595-604): bothINV-INDEXER-120-DEPLOYandINV-FRONTEND-120-DEPLOYrows present, doc-map links resolve.Forge regression confirmed:
forge test --match-path test/AccessControlZeroAdmin.t.sol: 8/8 PASS —test_RabbitTreasury_zeroAdmin_reverts,test_FeeRouter_zeroAdmin_reverts_before_sink_validation(sink-validation ordering check still green),test_PodiumPool_zeroAdmin_reverts,test_Doubloon_zeroAdmin_reverts,test_LeprechaunNFT_zeroAdmin_reverts,test_CL8YProtocolTreasury_zeroAdmin_reverts,test_DoubLPIncentives_zeroAdmin_reverts,test_EcosystemTreasury_zeroAdmin_reverts.derived-layer docs read as a clean boundary statement — third-party agents will not expect Postgres rows or UI signals for a reverted zero-admin deploy. matches the actual semantics: the EVM boundary catches it, observability layers stay quiet by design.
ready for close on your end.
mentioned in commit
617a90de47mentioned in issue #207