security(evm): stop recoverAsset from sweeping native ETH deposits #179
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#179
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
Bridge.depositNativekeeps net native ETH on the Bridge proxy. ERC-20 lock deposits go toLockUnlock.recoverAsset(address(0), amount, recipient)isonlyOwner+whenPausedand sends any requested ETH amount with no native-liability check. Pause plus owner is enough to empty user native deposits.This is not #178 (fail-closed when
guardBridge/rateLimitBridgeare unset). This is not #177 (cancel-window timelock / 15s floor). This is not #176 (on-chain M-of-N operator approve). This is not #175 (TerraWithdrawApproveignoringmin_signatures). This is not #135 (Terra wasm /config.adminhandoff). Keyword overlap ondepositNative/address(0)/ “paused owner” is not this bug.Internal review id: EVM-H4. Still in source as of 2026-09-12.
Bundle (same ticket, do not split):
feeRecipient; net deposit must not be an unbounded owner sweep).depositNativenets, minus any native-backed unlock path if one is added; accidentalreceive()/ stray ETH is excess).recoverAsset(address(0), …)to excess / stuck native only (address(this).balance - nativeLiability, or equivalent).depositNative, pause +recoverAsset(address(0), …)cannot take that user balance.Founder-required contracts. No community autoland. Do not add
ready.Impact (today vs hypothetical)
Funds at risk today in source. Native deposits are custodial ETH on the Bridge. The same
owner()that can pause can, while paused, callrecoverAsset(address(0), amount, anywhere)and take the entire native balance, including every unreclaimeddepositNativenet. Destination chains may already have minted/unlocked the wrapped equivalent against that hash. That is a custodial seize of bridged native, not a stuck-token sweeper.This is not a public user-facing puzzle and does not need an exploit recipe. It requires the Bridge owner key (and pause). It is still a live privilege path against user native deposits.
test_RecoverAsset_NativeETHcurrently encodes a full native drain (vm.deal+ recover the full balance while paused).Hypothetical-only if every production proxy has
wrappedNative == address(0)(native deposits disabled) and no ETH is held. Source still allows native deposits and unbounded native recovery wheneverwrappedNativeis set.Do not publish a mainnet pause-then-recover sequence or live RPC probe scripts.
EVM:
depositNativeretains raw ETH on BridgedepositNative(whenNotPaused,nonReentrant):wrappedNative != 0,msg.value > 0, dest/chain/token mapping.fee = calculateFee(msg.sender, msg.value);netAmount = msg.value - fee.feeConfig.feeRecipient.LockUnlock.wrappedNativeis only the cross-chain token identifier (OPERATIONAL_NOTES.md§6). Deposit record storestoken: wrappedNativeandamount: netAmount.ERC-20 lock path (
depositERC20)safeTransferFroms net tokens toLockUnlock. Native and ERC-20 lock custody are not the same vault.receive() external payable {}accepts arbitrary ETH with no accounting. That balance is mixed with deposit nets.CODE_REVIEW.mdstill claimsdepositNativewraps to WETH. That is stale; currentBridge.soldoes not wrap.EVM:
recoverAssethas no native-liability capNo check that
amount <= address(this).balance - nativeLiability. There is nonativeLiability(or equivalent) storage. For ERC-20, user lock inventory lives onLockUnlock, so Bridge-held ERC-20 is typically stray; native user inventory is the Bridge balance. The ERC-20 recovery shape is therefore not a safe template fortoken == address(0).withdrawExecuteUnlockpays ERC-20 fromLockUnlock, not ETH from Bridge. There is no native-ETH unlock that decrements a tracked liability. Destination mint/unlock of the wrapped identifier can complete while source ETH remains fully owner-recoverable.Tests lock in the sweep
packages/contracts-evm/test/Bridge.t.sol:test_RecoverAsset_NativeETH—vm.deal(bridge, 5 ether), pause, recover all 5 ETH to0xBEEF.test_RecoverAsset_RevertsIfNotPaused/RevertsIfNotOwner— access only; no liability invariant.depositNativethen pause then recover of the net amount reverts.Why the new implementation is needed
recoverAssetontoaddress(0)ignores that difference.depositNativenet” fixture.Constraints / guardrails
onlyOwner, ornonReentranton recovery. Restrict amount, do not open recovery to non-owners or while unpaused.GuardBridge, rate limits, cancelers,(srcChain, nonce)replay, or fee forwarding tofeeRecipient.depositNativealready leave the contract. Do not double-count fee ETH as liability. Liability is outstanding net native deposits (and any other native the protocol still owes), notmsg.valuegross.receive()stray ETH and explicitvm.deal/ mis-sent ETH remain recoverable as excess.nativeLiability(or two buckets: liability vs excess), consume__gapcorrectly. Do not break existing proxy layout. Prefer a new storage field + gap shrink over a packed overwrite. New initializer version only if a field must be set at init (default zero is fine).LockUnlockis an acceptable alternative if it preserves current cross-chain identifier semantics (wrappedNativeas dest token id), fee-ETH forwarding, and existing deposit hashes. Do not silently change dest-token mapping or hash inputs. If wrapping is chosen, recovery of WETH must still not pull locked inventory fromLockUnlockexcept via the normal unlock path.recoverAsset(address(0))on leftover ETH. One custody model.LockUnlockstays ERC-20. Do not invent native ETH unlock onLockUnlockunless tests and token-type routing are updated in this same change.ready. No public mainnet recover recipe.Relevant files
packages/contracts-evm/src/Bridge.soldepositNativeretains ETH;recoverAssetunbounded native send;receive()mixes stray ETH;__gappackages/contracts-evm/src/interfaces/IBridge.solAssetRecovered; no recovery-cap / liability surface on the interface todaypackages/contracts-evm/src/LockUnlock.solpackages/contracts-evm/test/Bridge.t.soltest_RecoverAsset_NativeETHencodes full drain; missing depositNative-vs-recover invariantpackages/contracts-evm/OPERATIONAL_NOTES.mdpackages/contracts-evm/CODE_REVIEW.mddocs/contracts-evm.md/docs/crosschain-flows.mdRecommended direction
nativeLiability += netAmountindepositNative. Decrement only when a future native-backed source unlock exists (none today — so liability stays until a defined release, or until dest-side mint/unlock is explicitly not a source-ETH release).recoverAsset(address(0), amount, …)reverts unlessamount <= address(this).balance - nativeLiability(andamount > 0). Dedicated error (e.g.RecoveryExceedsExcess).depositNativewraps net to WETH andLockUnlocks it like ERC-20. Bridge native balance then should be ~0 plus strayreceive().recoverAsset(address(0))only sweeps excess ETH; WETH recovery must not exceed tokens sitting on Bridge (not inLockUnlock). Update hash/token-id tests sowrappedNativeidentity is unchanged.call{value: fee}tofeeRecipientbefore liability/wrap. Never recover fee ETH that already left. Do not route fees throughLockUnlock.test_RecoverAsset_NativeETHmust not prove a full-balance sweep after a user deposit. Split: (a) stray/receive/deal excess is recoverable while paused; (b)depositNativenet is not recoverable; (c) recoveringnativeLiability + 1reverts; (d) owner/pause checks remain.OPERATIONAL_NOTES.md§6 + recovery: owner may recover excess/stuck native only. Strike wrap-to-WETH language inCODE_REVIEW.mdunless wrapping actually ships.Acceptance criteria
depositNativeofVwith feeF, Bridge ETH increases byV - F(plus any prior balance).recoverAsset(address(0), net, recipient)while paused reverts. User native remains on the contract (or inLockUnlockas WETH if wrapping is chosen).receive(), orbalance - nativeLiability) can be recovered while paused + owner, and only that excess.recoverAssetstill reverts if not paused or not owner.recipient == 0still reverts.recoverAssetstill cannot pull tokens out ofLockUnlock(only tokens actually on Bridge). Do not add a LockUnlock backdoor.depositNative; fees are not later swept as “bridged.”wrappedNativeidentifier / dest mapping behavior unchanged unless wrapping is the chosen custody model and tests are updated in this change.Test plan (functional paths)
depositNativevalueV, feeFV-F; fee recipient +=F; deposit record amountV-FrecoverAsset(0, net, to)RecoveryExceedsExcessor equivalent); Bridge still holds netreceive()(or deal) extraXafter T1, recoverXX + 1after T3recoverAssetdepositERC20into LockUnlock, recover that amount from BridgewrappedNative == 0depositNativestillWrappedNativeNotSet; no new holeTest plan (attack, hack, and abuse)
Non-exploitative. Local Forge only. Do not use these as a mainnet recipe.
address(this).balanceafter userdepositNativefeeBps0 vs nonzero)receive()mix-in then recover “all”Verification criteria
test_RecoverAsset_*with T1–T8 and A1–A5 inBridge.t.sol.forge testinpackages/contracts-evmgreen, including invariant tests ifBridge.inv.t.solcovers balances.recoverAssetnative branch no longercall{value: amount}without an excess/liability check.depositNativecontains wrap + transfer toLockUnlock; tests show WETH onLockUnlock, not raw ETH equal to net on Bridge.CODE_REVIEW.mdwrap claim either true or deleted.Out of scope
RecoverAsset/LOCKED_BALANCES(admin recover of native denom without decrementing locked balances is a sibling pattern; do not silently retarget this EVM ticket into a CosmWasm rewrite). File or extend Terra separately if product wants parity.recoverAssetin program sources searched).First-pass model recommendation
Recommendation: grok-high
Rationale: Security class plus founder-required contracts (
Bridge.solcustody +recoverAsset+ possibleLockUnlock/ WETH wrap + storage gap). Composer is disallowed (High/security; contracts / wallet / 2-of-3; native-liability vs wrap is a protocol custody change, not a local three-file edit). A wrong cap (usingbalancewithout subtracting liability, or wrapping without moving ETH) leaves user native owner-sweepable. Verify with Forge deposit-then-paused-recover fixtures, not a production recover.