Skip to content

fix: reject non-zero native value in ERC20/ERC721 transfer enforcers - #195

Open
SashaMIT wants to merge 3 commits into
MetaMask:mainfrom
SashaMIT:fix/transfer-enforcers-zero-value
Open

fix: reject non-zero native value in ERC20/ERC721 transfer enforcers#195
SashaMIT wants to merge 3 commits into
MetaMask:mainfrom
SashaMIT:fix/transfer-enforcers-zero-value

Conversation

@SashaMIT

@SashaMIT SashaMIT commented Aug 5, 2026

Copy link
Copy Markdown

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:

  • ERC20PeriodTransferEnforcer
  • ERC20TransferAmountEnforcer
  • ERC721TransferEnforcer

For 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: ApprovalRevocationEnforcer and MultiTokenPeriodEnforcer both require(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_ from decodeSingle and rejects non-zero with <name>:invalid-value (matching the error style of the sibling checks).

Test plan

  • One revert test per enforcer: execution with value = 1 expects invalid-value
  • forge test on the three suites: 45/45 pass
  • Full suite green except DelegationMetaSwapAdapter/VedaLending fork tests, which need LINEA_RPC_URL/ARBITRUM_RPC_URL (pre-existing environment requirement)

Made with Cursor

Made with Cursor

SashaMIT and others added 3 commits August 6, 2026 02:25
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>
@SashaMIT
SashaMIT requested a review from a team as a code owner August 5, 2026 21:10

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8757974. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant