fix: reject non-zero native value in ERC20/ERC721 transfer enforcers - #195
Open
SashaMIT wants to merge 3 commits into
Open
fix: reject non-zero native value in ERC20/ERC721 transfer enforcers#195SashaMIT wants to merge 3 commits into
SashaMIT wants to merge 3 commits into
Conversation
LogicalOrWrapperEnforcer forwarded the raw delegation hash to every caveat in the selected group. Stateful enforcers key state by delegation hash (not terms), so all groups referencing the same stateful enforcer shared one state: the first-used group initialised it (period start, call counter, spent map) for every other group — invisibly to the delegator, and beyond what the header security notice covers. The forwarded hash is now namespaced by the selected group index (keccak256(delegationHash, groupIndex)); each group's budgets/periods are independent, and a shared budget remains expressible by placing caveats in a single group. Includes an isolation regression test (LimitedCallsEnforcer, limit 1 in two groups) that fails without the fix. Signed-off-by: SashaMIT <sash@ela.city> Co-authored-by: Cursor <cursoragent@cursor.com>
The afterHook assertion still read the raw delegation hash key, which was never locked, so it always passed. Point it at the namespaced key like the before-hook check. Signed-off-by: SashaMIT <sash@ela.city> Co-authored-by: Cursor <cursoragent@cursor.com>
ERC20PeriodTransferEnforcer, ERC20TransferAmountEnforcer, and
ERC721TransferEnforcer decoded executions with (target,, callData) and
discarded the native value field, while the framework executes
target.call{value: value}(callData) from the delegator's account. A
token-scoped delegation therefore imposed no constraint on the native
ETH attached to the execution - reachable with any token contract whose
pinned transfer selector is payable.
Sibling enforcers already close this: ApprovalRevocationEnforcer and
MultiTokenPeriodEnforcer both require value == 0 on token-transfer
executions. This brings the three transfer enforcers in line with that
convention; each now rejects non-zero value with '<name>:invalid-value'.
One revert test per enforcer (execution value=1 expects invalid-value);
45/45 in the three suites, full suite green except two fork tests that
require RPC env vars (pre-existing).
Signed-off-by: SashaMIT <sash@ela.city>
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 8757974. Configure here.
|
|
||
| // Validate state after first transfer | ||
| uint256 spentAfterFirst_ = erc20TransferAmountEnforcer.spentMap(address(logicalOrWrapperEnforcer), keccak256("")); | ||
| uint256 spentAfterFirst_ = erc20TransferAmountEnforcer.spentMap(address(logicalOrWrapperEnforcer), _groupDelegationHash(keccak256(""), 0)); |
There was a problem hiding this comment.
Stale unlock assertions after namespacing
Low Severity
After LogicalOrWrapperEnforcer started forwarding a group-namespaced delegation hash, the ERC721 and native balance-change success tests still build their unlock checks with the raw keccak256("") hash. That key was never locked, so assertFalse(isLocked(...)) passes without verifying the namespaced key that beforeHook actually used.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 8757974. Configure here.
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
In plain terms: when someone grants a delegation that says "you may transfer my ERC20 tokens" (or an ERC721), the enforcer checks the token contract, the method, and the amount — but it never looked at the native ETH attached to the execution. The framework executes
target.call{value: value}(callData)from the delegator's account, so a token-scoped delegation placed no constraint on the ETH riding along.All three transfer enforcers decoded the execution as
(target,, callData)— the value field was silently discarded:ERC20PeriodTransferEnforcerERC20TransferAmountEnforcerERC721TransferEnforcerFor standard tokens the pinned selectors are non-payable, so ETH can't move through the big-name tokens today — but any token contract whose transfer selector is payable (fee-splitting tokens, proxy wrappers) turns a "token-only" delegation into an unbounded native-ETH spend.
The repo already has the convention:
ApprovalRevocationEnforcerandMultiTokenPeriodEnforcerbothrequire(value_ == 0)on token-transfer executions. This PR brings the three outliers in line with the codebase's own pattern.Fix
Each enforcer now binds
value_fromdecodeSingleand rejects non-zero with<name>:invalid-value(matching the error style of the sibling checks).Test plan
value = 1expectsinvalid-valueforge teston the three suites: 45/45 passDelegationMetaSwapAdapter/VedaLendingfork tests, which needLINEA_RPC_URL/ARBITRUM_RPC_URL(pre-existing environment requirement)Made with Cursor
Made with Cursor