security(evm): reject fee-on-transfer ERC20 deposits that deliver less than recorded #189
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#189
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.depositERC20computesnetAmount = amount - fee, optionallysafeTransferFroms the fee tofeeConfig.feeRecipient, thensafeTransferFromsnetAmounttoLockUnlock. Neither transfer measuresbalanceOfbefore/after. The deposit record,Depositevent, andxchainHashIdall use the requestednetAmount, not tokens actually received.LockUnlock.unlockalready enforces exact deltas (InvalidUnlockThis/InvalidUnlockTo).MintBurn.burn/mintdo the same. Lock deposits do not. Docs already list fee-on-transfer as unsupported (OPERATIONAL_NOTES.md§4).TokenRegistry.registerTokenis owner-only and does not probe transfer semantics.This is not #179 (native
recoverAssetvsdepositNativeliability). This is not #178 (unsetguardBridge/rateLimitBridge). This is not #100 (protocol fee vs rate-limit minimums). This is not #9 (frontend displayed amount). Keyword overlap onfee/depositERC20/netAmountis not this bug.Internal review id: EVM-M1. Still in source as of 2026-09-12.
Bundle (same ticket, do not split):
depositERC20; revert if received ≠ expected. Do not credit a reduced amount (that would change hash inputs).TokenRegistry.registerTokenfor non-standard transfer semantics (owner-funded 1-unit round-trip probe, or equivalent).MockTransferTaxToken(10% tax, currently unreferenced by any test) through deposit + registration + unlock fixtures.Founder-required contracts. No community autoland. Do not add
ready.Impact (today vs hypothetical)
Inventory / dest-credit invariant is broken in source today whenever a fee-on-transfer (or otherwise deflationary-on-transfer) ERC-20 is registered as
LockUnlock.depositERC20will record and hashnetAmountwhileLockUnlockholds less. Destination mint/unlock of the recorded amount then over-credits relative to source inventory (mint path) or cannot be backed 1:1. Other lockers of the same token are short.Registration is
onlyOwner, and §4 already says FoT is unsupported. That is an operational hope, not an invariant. Listing any tax/deflationary token (mistaken economic-token add, or a token that later turns on a fee) activates the hole with no on-chain reject.Hypothetical-only if every registered lock token is guaranteed standard
transfer/transferFromforever. Source still allows registering and depositing non-standard tokens.Do not publish a mainnet FoT deposit sequence, token-tax configuration, or live RPC probe scripts.
EVM:
depositERC20records requested net, not receiveddepositERC20(whenNotPaused,nonReentrant):destToken != 0,amount > 0.fee = calculateFee(msg.sender, amount);netAmount = amount - fee.fee > 0:safeTransferFrom(msg.sender, feeRecipient, fee)with nobalanceOf(feeRecipient)delta check.safeTransferFrom(msg.sender, address(lockUnlock), netAmount)with nobalanceOf(LockUnlock)delta check.deposits[xchainHashId].amount = netAmountand emitsDeposit(..., netAmount, ..., fee).safeTransferFromonly requires the token not to revert / return false. A token that transfersnetAmount - taxand returnstrueis treated as a full lock.Fee and net are two transfers. A tax on each transfer can short both the fee recipient and the vault. Checking only the vault still leaves fee accounting wrong; the dest-credit hole is the vault short.
EVM: unlock and mint/burn already fail closed; lock deposit does not
LockUnlock.unlock:MintBurn.burn/mintcomparebalanceOfaroundburnFrom/mint.Lock inventory is not created by
LockUnlock.lock(that path was removed). Bridge pushes tokens in. The vault never sees a lock-time delta, so unlock’s exact-delta check cannot protect deposit accounting. Unlock of a tax token typically fails on the recipient delta (InvalidUnlockTo) even when the vault debit matchesamount(tax burned from sender). That does not fix dest mint of a standard wrapped identifier against a short lock.depositERC20Mintableburns viaMintBurn.burn(delta on the user). The feesafeTransferFromon that path still has no recipient delta. Bundle a fee-recipient check there too; do not treat mintable as a separate issue.Registration does not probe
TokenRegistry.registerToken(token, tokenType)(onlyOwner): setstokenRegistered,tokenTypes, default rate limits fromtotalSupply(). Notransfer/transferFromprobe.setTokenTypelikewise.MockTransferTaxToken.solimplements 10% tax ontransfer/transferFrom(recipient gets 90%; remainder burned from sender). No test file imports it.CODE_REVIEW.mdstill lists it as covering deflationary handling and leaves “integration test for fee-on-transfer tokens through full bridge cycle” unchecked.Why the new implementation is needed
Bridge, which does not measure received amounts. Unlock/mint/burn checks are the wrong layer for deposit credits.netAmountinto the cross-chain hash while holding less is dest over-mint / inventory insolvency, not a UX warning.Constraints / guardrails
netAmount(lock) or received ≠fee(fee transfer), revert. Do not store the actual received amount. Hash /DepositRecord.amount/ dest credit must stay requested-net for standard tokens only.GuardBridge, rate limits, cancelers,(srcChain, nonce)replay, pause,nonReentrant, or fee forwarding tofeeRecipient.LockUnlockexcept viaunlock. Do not add arecoverAssetbackdoor for the shortfall.depositNativeis ETHmsg.value(already exact). Out of scope except: do not “fix” FoT by wrapping native.TokenRegistry__gap(currently[38]). If adding a probe helper contract, it is a new deploy + owner wiring, not a packed overwrite of existing slots.transferFromowner → registry, require delta == 1, return token to owner) is acceptable. Dustamount == 0probes are not sufficient (many FoT tokens skip tax at 0).ready. No public mainnet FoT recipe.Relevant files
packages/contracts-evm/src/Bridge.soldepositERC20(and mintable fee transfer) records requested amounts with no received deltapackages/contracts-evm/src/interfaces/IBridge.solNonStandardTokenTransfer)packages/contracts-evm/src/LockUnlock.solpackages/contracts-evm/src/MintBurn.solpackages/contracts-evm/src/TokenRegistry.solregisterToken/setTokenTypehave no standard-transfer probepackages/contracts-evm/src/interfaces/ITokenRegistry.solNonStandardToken)packages/contracts-evm/test/mocks/MockTransferTaxToken.solpackages/contracts-evm/test/Bridge.t.solpackages/contracts-evm/test/TokenRegistry.t.solpackages/contracts-evm/test/LockUnlock.t.solpackages/contracts-evm/OPERATIONAL_NOTES.mdpackages/contracts-evm/CODE_REVIEW.mdRecommended direction
safeTransferFromindepositERC20(fee recipient andLockUnlock) and the fee transfer indepositERC20Mintable, snapshotbalanceOf(to)before/after. Revert unlessafter - before == expected(andexpected > 0when that leg runs). Dedicated error. Do not updatenetAmountto the delta.registerToken(LockUnlock and MintBurn) runs an owner-funded 1-unit (or 1 smallest unit) round-trip probe: received must equal sent. RevertNonStandardToken(or equivalent) on mismatch, missing code, or failed return. Document that the owner must hold and approve that unit.setTokenTypedoes not need a second probe if the token is already registered, but do not skip the probe on first register for MintBurn.MockTransferTaxToken. Register +depositERC20must revert (vault and, withfeeBps > 0, fee recipient). Standard mock deposits still succeed withLockUnlockbalance +=netAmount. Unlock of FoT still reverts (existing unlock invariant). Registration of the tax mock reverts before it can be deposited.Do not implement a “credit actual received” mode in this ticket. That is a different hash/accounting design.
Acceptance criteria
depositERC20of a standard ERC-20:LockUnlock.balanceOf(token)increases bynetAmount; fee recipient increases byfee; deposit record / hash still usenetAmount.depositERC20ofMockTransferTaxToken(registered only if the probe is bypassed in a unit test, or via a test-only hook not shipped to production): the locksafeTransferFromreverts; noDepositevent; no deposit record;depositNonceunchanged.fee > 0, a token that delivers less thanfeetofeeRecipientreverts before the lock transfer; no partial lock.registerTokenofMockTransferTaxTokenreverts after the 1-unit probe. Standard ERC-20 still registers. Non-owner still cannot register.LockUnlock.unlockexact deltas unchanged.MintBurnburn/mint deltas unchanged.depositERC20Mintablefee transfer gains the same received check as lock fees.safeTransferFromofnetAmountis sufficient for FoT.readylabel. No change to dest mapping or hash field order.Test plan (functional paths)
depositERC20, fee 0depositERC20, fee > 0MockTransferTaxTokendepositERC20(if registered via test harness)registerTokentax mock with owner-funded 1 unitNonStandardToken(or equivalent)registerTokenstandard mock with 1-unit proberegisterTokenstillonlyOwnerdepositERC20Mintablestandard + fee > 0depositERC20Mintablefee transfer shortfalldepositERC20dest/chain/zero-amount revertsLockUnlock.unlockstandard tokenamount == 0must not count as “standard”Test plan (attack, hack, and abuse)
Non-exploitative. Local Forge only. Do not use these as a mainnet recipe.
transferFromto vault, fee 0truebut recipient delta 0balanceOfmid-tx (if a mock exists)amount == 0to skip taxregisterTokennonReentrant); no fee kept without lock (atomic)Verification criteria
Bridge.t.solandTokenRegistry.t.solusingMockTransferTaxToken.forge testinpackages/contracts-evmgreen.depositERC20locksafeTransferFromis preceded/followed bybalanceOfsnapshots and an equality check.MockTransferTaxTokenis imported from a test file.registerTokenpath contains a positive-amount transfer probe (or clearly named helper) that reverts on delta mismatch.Out of scope
recoverAsset(#179).First-pass model recommendation
Recommendation: grok-high
Rationale: Security class plus founder-required contracts (
Bridge.soldeposit accounting,TokenRegistryregistration probe,LockUnlock/MintBurninvariant alignment). Composer is disallowed (High/security; contracts / wallet / 2-of-3). Deposit fail-closed vs “credit actual received” is a protocol hash/custody choice; a wrong delta (checking the user instead ofLockUnlock, or probingamount == 0) leaves dest over-mint intact. Verify with Forge FoT-mock deposit/register fixtures, not a production deposit.