security(evm): fail closed when guard and rate-limit bridge are unset #178
Labels
No labels
agent:implement
agent:ready
backend
bug
cannot-reproduce
confirmed
desktop
docs
documentation
duplicate
enhancement
feature
frontend
good first issue
help wanted
high-risk
in-review
invalid
mobile
needs-triage
P0-critical
P1-high
P2-medium
P3-low
qa
QA
question
ready
report
responsive
security
security-escalate
smart-contract
solana
tablet
test-pass
ux
wallet-issue
wallet:keplr
wallet:metamask
wallet:station
wallet:walletconnect
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-bridge-monorepo#178
Loading…
Add table
Add a link
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?
Summary
EVM
Bridgedeposit/withdraw guard hooks andTokenRegistrywithdraw rate-limit checks skip when their wiring addresses are zero. Both pointers are zero afterinitialize. Deploy scripts do not set them. Docs still treat wiring as a manual post-deploy step (OPERATIONAL_NOTES.md§8,docs/deployment-guide.md§6.1a).This is not #176 (on-chain M-of-N operator approve). This is not #175 (Terra
WithdrawApproveignoringmin_signatures). This is not #177 (timelock / cancel-window floor). This is not #135 (Terra admin handoff). Closed #122 / #121 are deploy orchestration, not fail-closed semantics. Closed #76 is test-suite breakage after rate-limit changes. Keyword overlap on “rate limit” / “guard” is not this bug.Bundle (same ticket, do not split):
Bridge.guardBridgeandTokenRegistry.rateLimitBridgeare non-zero (or require non-zero addresses at initialize / first register).setGuardBridge(address(0))/setRateLimitBridge(address(0))to silently disable enforcement on a live registry.Founder-required contracts. No community autoland. Do not add
ready.Impact (today vs hypothetical)
Funds at risk today in source, and on any live chain whose owner skipped (or later cleared) the wiring step. Registered-token deposits and withdraw executes succeed with no
GuardBridgemodules (blacklist,TokenRateLimitmodules, account checks) and noTokenRegistrywithdraw min/max/period caps. Mainnet safety then depends on a missed owner call, not on the contract.This is not a user-facing puzzle that needs an exploit recipe. Any holder of a registered token can deposit; any submitted+approved withdraw can execute. The defect is that those paths do not require the safety stack to be attached.
Hypothetical-only if every production proxy already has both pointers set and they cannot be cleared. Source still permits the gap at init and via
address(0)setters. Docs already call an unset pointer a “critical” misconfiguration; the code still treats it as a no-op.Do not publish a mainnet “deposit while unset” sequence or live RPC probe scripts.
EVM:
_checkDepositGuard/_checkWithdrawGuardno-op on zeroBridge.initializetakes admin, operator, fee recipient, wrapped native, registries, handlers, andthisChainId. It does not take a guard.guardBridgestays defaultaddress(0).setGuardBridgeisonlyOwnerand documentsaddress(0)as “disable”.Called after fee math, before lock/burn:
depositNativeCalled after decimal normalization, before unlock/mint:
withdrawExecuteUnlockwithdrawExecuteMintImplementation:
guardBridge != address(0)→IGuardBridge.checkDeposit/checkWithdrawtokenRegistryis set andtokenRegistry.rateLimitBridge() != address(0)→checkAndUpdateDepositRateLimit/checkAndUpdateWithdrawRateLimitZero on either pointer is success, not a revert.
TokenRegistry: withdraw limit returns on zero; deposit check is a no-op even when wired
TokenRegistry.initialize(admin, chainRegistry)does not take a bridge.rateLimitBridgestaysaddress(0).setRateLimitBridgeisonlyOwnerand documentsaddress(0)as “disable”.checkAndUpdateWithdrawRateLimit:rateLimitBridge == address(0)→ return (no min/max/period)msg.sender == rateLimitBridge, then_checkAndUpdateRateLimitregisterTokenstill writes defaultrateLimitConfigsfrom supply. Those configs never run untilrateLimitBridgeis the Bridge proxy.checkAndUpdateDepositRateLimitisexternal pureand does nothing. Deposit-side registry caps are out of this ticket (do not invent them). Fail-closed here means: registered-token deposits still needguardBridge, and registered-token withdraw executes need bothguardBridgeandrateLimitBridge.Docs and deploy still encode fail-open
packages/contracts-evm/OPERATIONAL_NOTES.md§8: “Guard disabled by default”; “when disabled, all guard checks are no-ops”; production unset is “critical” but the fix is “verify after deploy.”docs/deployment-guide.md:Deploy.s.soldoes not callTokenRegistry.setRateLimitBridgeorBridge.setGuardBridge. §6.1a / §9.3 are operatorcast callchecklists.OPERATIONAL_NOTES.md§9: Guard–Bridge integration tests “not required.”GuardBridge.t.soltests the module in isolation. Happy-pathBridge.t.solfixtures can pass with both pointers still zero.Why the new implementation is needed
registerTokenalready assumes limits exist (defaults from supply). Enforcement that returns on zero contradicts that invariant.Constraints / guardrails
GuardBridgemodules,TokenRateLimit, blacklist, pause, cancelers, or(srcChain, nonce)replay. This ticket makes those controls mandatory to attach, not weaker.initializemay keep a zero default if deploy order deploysGuardBridgeafter the proxy, provided every user-facing deposit/withdraw of a registered token reverts until both pointers are non-zero. If initialize/register instead require non-zero addresses, update Foundry scripts in the same change so local and parity replays still boot.setGuardBridge(address(0))/setRateLimitBridge(address(0))must not restore fail-open on a populated registry. Revert on zero, or require pause and still revert registered-token transfers while unset. Owner pause remains the incident brake.rateLimitBridgeto enforce deposit-side registry caps (those are a documented no-op). Deposit fail-closed is the guard pointer; withdraw fail-closed is both pointers.TokenRegistryrate-limit caller check stays: only the wired Bridge may consume the 24h window. Do not opencheckAndUpdateWithdrawRateLimitto arbitrary callers.__gap/ existing proxy layout. New errors and require-nonzero checks are enough; avoid a new initializer version unless a new field is actually required.AccessManagerrole split for guard-stack admin (role2vs minter role1) stays. This ticket does not retarget #98.ready. No public mainnet unset-pointer recipe.Relevant files
packages/contracts-evm/src/Bridge.solguardBridgeunset at init;_checkDepositGuard/_checkWithdrawGuardskip on zero;setGuardBridgeallows zeropackages/contracts-evm/src/TokenRegistry.solrateLimitBridgeunset at init;checkAndUpdateWithdrawRateLimitreturns on zero;setRateLimitBridgeallows zeropackages/contracts-evm/src/GuardBridge.solguardBridge == 0packages/contracts-evm/src/TokenRateLimit.solpackages/contracts-evm/script/Deploy.s.sol(and DeployPart1 / local deploy scripts)setGuardBridge/setRateLimitBridgepackages/contracts-evm/test/Bridge.t.solpackages/contracts-evm/test/TokenRegistry.t.solrateLimitBridgeas a valid production statepackages/contracts-evm/test/GuardBridge.t.solBridge.t.solpackages/contracts-evm/OPERATIONAL_NOTES.mddocs/deployment-guide.mdRecommended direction
_checkDepositGuard/_checkWithdrawGuard, if the token is registered, revert whenguardBridge == address(0). On withdraw, also revert whenrateLimitBridge() == address(0)before calling into the registry. Dedicated errors (e.g.GuardBridgeNotSet/RateLimitBridgeNotSetused as a real revert, not a silent skip).setGuardBridge(0)andsetRateLimitBridge(address(0))revert (or only allowed while paused and transfers still revert). Update natspec; remove “address(0) to disable.”wireSafetyStackthat must run beforeunpause. Pause-at-init until wired is acceptable.setGuardBridgeandsetRateLimitBridge(bridgeProxy)in the same broadcast path as register-token. Parity replay / MegaETH scripts must not complete “success” with zeros.Acceptance criteria
initialize, with a registered token andguardBridge == 0,depositNative/ lock deposit / burn deposit revert. They succeed only aftersetGuardBridgeto a liveGuardBridge.guardBridge == 0orrateLimitBridge == 0. Both must be non-zero for execute to reach lock/mint logic.setGuardBridge(address(0))andsetRateLimitBridge(address(0))cannot return the proxy to a transferring fail-open state.checkAndUpdateDepositRateLimitmay remain a no-op; deposit safety isGuardBridge, not a new TokenRegistry deposit window, unless product explicitly adds one in this same change.OPERATIONAL_NOTES§9 does not excuse missing integration tests.Test plan (functional paths)
guardBridge == 0GuardBridgeNotSetor equivalent)setGuardBridge(gb), depositcheckDepositruns; happy path succeedsrateLimitBridge == 0guardBridgeis setsetGuardBridge(0)after T2setRateLimitBridge(0)after wiringsetRateLimitmaxPerTx, execute above maxRateLimitExceededPerTxguardBridgeandrateLimitBridgenon-zero, or the first user deposit reverts until a documented wire step that tests also runTest plan (attack, hack, and abuse)
Non-exploitative. Local Forge only. Do not use these as a mainnet recipe.
setGuardBridgeafter deploy, deposit registered tokensetRateLimitBridge, submit+approve, executecheckAndUpdateWithdrawRateLimitonce wiredRateLimitBridgeNotSet/ caller checkGuardBridge(zero modules) vs unset pointerVerification criteria
Bridge.t.sol/TokenRegistry.t.sol. Existing happy-path tests updated to wire the stack insetUp(or they now expect revert — do not leave skip-on-zero as the default fixture).forge testinpackages/contracts-evmgreen, includingBridge.inv.t.sol._checkDepositGuard/_checkWithdrawGuard/checkAndUpdateWithdrawRateLimitno longerreturn/ skip onaddress(0)for registered tokens.Out of scope
min_signatures(#175), cancel-window timelock (#177).TokenRegistrywindows (unless done as an explicit extra in this change).First-pass model recommendation
Recommendation: grok-high
Rationale: Security class plus founder-required contracts (Bridge + TokenRegistry + GuardBridge + deploy scripts). Composer is disallowed (High/security; contracts/auth/keys; fail-open → fail-closed is a protocol invariant, not a local three-file edit). A wrong exception (unset still succeeds, or
address(0)setter restores skip) leaves registered tokens uncapped. Verify with Forge revert/success fixtures, not a production deposit.