From c853a29fd5c43b9c39ebdf4bda9b7da1171099c8 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Fri, 8 May 2026 00:47:02 -0400 Subject: [PATCH 1/4] implement ERC-8255 --- README.md | 2 +- docs/tokens/erc20.md | 95 +++++++++++- src/tokens/ERC20.sol | 212 ++++++++++++++++++++------ test/ERC20.t.sol | 86 ++++++++++- test/SafeTransferLib.t.sol | 16 +- test/ext/zksync/SafeTransferLib.t.sol | 16 +- 6 files changed, 371 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 60e81e7624..297a95c72e 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ auth ├─ TimedRoles — "Timed multiroles authorization mixin" tokens ├─ ERC1155 — "Simple ERC1155 implementation" -├─ ERC20 — "Simple ERC20 + EIP-2612 implementation" +├─ ERC20 — "Simple ERC20 + EIP-2612 + ERC-8255 implementation" ├─ ERC20Votes — "ERC20 with votes based on ERC5805 and ERC6372" ├─ ERC2981 — "Simple ERC2981 NFT Royalty Standard implementation" ├─ ERC4626 — "Simple ERC4626 tokenized Vault implementation" diff --git a/docs/tokens/erc20.md b/docs/tokens/erc20.md index e1d8c81ddf..4a27b888a7 100644 --- a/docs/tokens/erc20.md +++ b/docs/tokens/erc20.md @@ -1,6 +1,6 @@ # ERC20 -Simple ERC20 + EIP-2612 implementation. +Simple ERC20 + EIP-2612 + ERC-8255 implementation. Note: @@ -47,6 +47,22 @@ error AllowanceUnderflow() The allowance has underflowed. +### ApprovalDurationTooLong() + +```solidity +error ApprovalDurationTooLong() +``` + +The approval duration is greater than `maxApprovalDuration()`. + +### ApprovalExpirationOverflow() + +```solidity +error ApprovalExpirationOverflow() +``` + +The approval expiration timestamp has overflowed. + ### InsufficientBalance() ```solidity @@ -107,6 +123,16 @@ event Approval( Emitted when `amount` tokens is approved by `owner` to be used by `spender`. +### ApprovalExpiration(address,address,uint64) + +```solidity +event ApprovalExpiration( + address indexed owner, address indexed spender, uint64 expiration +) +``` + +Emitted when an approval expiration is set. + ## Constants ### _PERMIT2 @@ -122,6 +148,14 @@ Enabled by default. To disable, override `_givePermit2InfiniteAllowance()`. [Github](https://github.com/Uniswap/permit2) [Etherscan](https://etherscan.io/address/0x000000000022D473030F116dDEE9F6B43aC78BA3) +### _MAX_APPROVAL_DURATION + +```solidity +uint32 internal constant _MAX_APPROVAL_DURATION = 1 days +``` + +The default maximum approval duration, in seconds. + ## ERC20 ### totalSupply() @@ -156,6 +190,27 @@ function allowance(address owner, address spender) Returns the amount of tokens that `spender` can spend on behalf of `owner`. +### allowanceAndExpiration(address,address) + +```solidity +function allowanceAndExpiration(address owner, address spender) + public + view + virtual + returns (uint64 expiration, uint256 amount) +``` + +Returns the stored allowance expiration and amount for `spender` over `owner`. +The amount is returned even if the approval has expired. + +### maxApprovalDuration() + +```solidity +function maxApprovalDuration() public pure virtual returns (uint32) +``` + +Returns the maximum approval duration, in seconds. + ### approve(address,uint256) ```solidity @@ -167,7 +222,21 @@ function approve(address spender, uint256 amount) Sets `amount` as the allowance of `spender` over the caller's tokens. -Emits a `Approval` event. +Emits `Approval` and `ApprovalExpiration` events. + +### approveForDuration(address,uint256,uint32) + +```solidity +function approveForDuration(address spender, uint256 amount, uint32 duration) + public + virtual + returns (bool) +``` + +Sets `amount` as the allowance of `spender` over the caller's tokens +for `duration` seconds. `duration` must not exceed `maxApprovalDuration()`. + +Emits `Approval` and `ApprovalExpiration` events. ### transfer(address,uint256) @@ -266,7 +335,7 @@ function permit( Sets `value` as the allowance of `spender` over the tokens of `owner`, authorized by a signed approval by `owner`. -Emits a `Approval` event. +Emits `Approval` and `ApprovalExpiration` events. ### DOMAIN_SEPARATOR() @@ -334,7 +403,23 @@ function _approve(address owner, address spender, uint256 amount) Sets `amount` as the allowance of `spender` over the tokens of `owner`. -Emits a `Approval` event. +Emits `Approval` and `ApprovalExpiration` events. + +### _approve(address,address,uint256,uint32) + +```solidity +function _approve( + address owner, + address spender, + uint256 amount, + uint32 duration +) internal virtual +``` + +Sets `amount` as the allowance of `spender` over the tokens of `owner` +for `duration` seconds. + +Emits `Approval` and `ApprovalExpiration` events. ## Hooks To Override @@ -375,4 +460,4 @@ function _givePermit2InfiniteAllowance() Returns whether to fix the Permit2 contract's allowance at infinity. This value should be kept constant after contract initialization, or else the actual allowance values may not match with the `Approval` events. -For best performance, return a compile-time constant for zero-cost abstraction. \ No newline at end of file +For best performance, return a compile-time constant for zero-cost abstraction. diff --git a/src/tokens/ERC20.sol b/src/tokens/ERC20.sol index 8f4534f53a..c09972d193 100644 --- a/src/tokens/ERC20.sol +++ b/src/tokens/ERC20.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.4; -/// @notice Simple ERC20 + EIP-2612 implementation. +/// @notice Simple ERC20 + EIP-2612 + ERC-8255 implementation. /// @author Solady (https://github.com/vectorized/solady/blob/main/src/tokens/ERC20.sol) /// @author Modified from Solmate (https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC20.sol) /// @author Modified from OpenZeppelin (https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol) @@ -32,6 +32,12 @@ abstract contract ERC20 { /// @dev The allowance has underflowed. error AllowanceUnderflow(); + /// @dev The approval duration is greater than `maxApprovalDuration()`. + error ApprovalDurationTooLong(); + + /// @dev The approval expiration timestamp has overflowed. + error ApprovalExpirationOverflow(); + /// @dev Insufficient balance. error InsufficientBalance(); @@ -57,6 +63,9 @@ abstract contract ERC20 { /// @dev Emitted when `amount` tokens is approved by `owner` to be used by `spender`. event Approval(address indexed owner, address indexed spender, uint256 amount); + /// @dev Emitted when an approval expiration is set. + event ApprovalExpiration(address indexed owner, address indexed spender, uint64 expiration); + /// @dev `keccak256(bytes("Transfer(address,address,uint256)"))`. uint256 private constant _TRANSFER_EVENT_SIGNATURE = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef; @@ -65,6 +74,10 @@ abstract contract ERC20 { uint256 private constant _APPROVAL_EVENT_SIGNATURE = 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925; + /// @dev `keccak256(bytes("ApprovalExpiration(address,address,uint64)"))`. + uint256 private constant _APPROVAL_EXPIRATION_EVENT_SIGNATURE = + 0x9054e94048932437d646ffcd10359273e99618e4eecefff84e546606dd9ff6bf; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* STORAGE */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -124,6 +137,13 @@ abstract contract ERC20 { /// [Etherscan](https://etherscan.io/address/0x000000000022D473030F116dDEE9F6B43aC78BA3) address internal constant _PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + /// @dev The default maximum approval duration, in seconds. + uint32 internal constant _MAX_APPROVAL_DURATION = 1 days; + + /// @dev The mask for extracting the allowance amount from a packed ERC-8255 allowance. + uint256 private constant _ALLOWANCE_VALUE_MASK = + 0xffffffffffffffffffffffffffffffffffffffffffffffff; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* ERC20 METADATA */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -176,35 +196,62 @@ abstract contract ERC20 { mstore(0x20, spender) mstore(0x0c, _ALLOWANCE_SLOT_SEED) mstore(0x00, owner) - result := sload(keccak256(0x0c, 0x34)) + let packed := sload(keccak256(0x0c, 0x34)) + result := and(packed, _ALLOWANCE_VALUE_MASK) + if result { + if lt(shr(192, packed), timestamp()) { result := 0 } + if eq(result, _ALLOWANCE_VALUE_MASK) { result := not(0) } + } } } - /// @dev Sets `amount` as the allowance of `spender` over the caller's tokens. - /// - /// Emits a {Approval} event. - function approve(address spender, uint256 amount) public virtual returns (bool) { + /// @dev Returns the stored allowance expiration and amount for `spender` over `owner`. + /// The amount is returned even if the approval has expired. + function allowanceAndExpiration(address owner, address spender) + public + view + virtual + returns (uint64 expiration, uint256 amount) + { if (_givePermit2InfiniteAllowance()) { - /// @solidity memory-safe-assembly - assembly { - // If `spender == _PERMIT2 && amount != type(uint256).max`. - if iszero(or(xor(shr(96, shl(96, spender)), _PERMIT2), iszero(not(amount)))) { - mstore(0x00, 0x3f68539a) // `Permit2AllowanceIsFixedAtInfinity()`. - revert(0x1c, 0x04) - } - } + if (spender == _PERMIT2) return (type(uint64).max, type(uint256).max); } /// @solidity memory-safe-assembly assembly { - // Compute the allowance slot and store the amount. mstore(0x20, spender) mstore(0x0c, _ALLOWANCE_SLOT_SEED) - mstore(0x00, caller()) - sstore(keccak256(0x0c, 0x34), amount) - // Emit the {Approval} event. - mstore(0x00, amount) - log3(0x00, 0x20, _APPROVAL_EVENT_SIGNATURE, caller(), shr(96, mload(0x2c))) + mstore(0x00, owner) + let packed := sload(keccak256(0x0c, 0x34)) + expiration := shr(192, packed) + amount := and(packed, _ALLOWANCE_VALUE_MASK) + if iszero(amount) { expiration := 0 } + if eq(amount, _ALLOWANCE_VALUE_MASK) { amount := not(0) } } + } + + /// @dev Returns the maximum approval duration, in seconds. + function maxApprovalDuration() public pure virtual returns (uint32) { + return _MAX_APPROVAL_DURATION; + } + + /// @dev Sets `amount` as the allowance of `spender` over the caller's tokens. + /// + /// Emits {Approval} and {ApprovalExpiration} events. + function approve(address spender, uint256 amount) public virtual returns (bool) { + _approve(msg.sender, spender, amount); + return true; + } + + /// @dev Sets `amount` as the allowance of `spender` over the caller's tokens + /// for `duration` seconds. `duration` must not exceed `maxApprovalDuration()`. + /// + /// Emits {Approval} and {ApprovalExpiration} events. + function approveForDuration(address spender, uint256 amount, uint32 duration) + public + virtual + returns (bool) + { + _approve(msg.sender, spender, amount, duration); return true; } @@ -266,16 +313,32 @@ abstract contract ERC20 { mstore(0x20, caller()) mstore(0x0c, or(from_, _ALLOWANCE_SLOT_SEED)) let allowanceSlot := keccak256(0x0c, 0x34) - let allowance_ := sload(allowanceSlot) - // If the allowance is not the maximum uint256 value. - if not(allowance_) { + let packedAllowance := sload(allowanceSlot) + let allowance_ := and(packedAllowance, _ALLOWANCE_VALUE_MASK) + let expiration := shr(192, packedAllowance) + // If the allowance is not the maximum uint256 value sentinel. + if iszero(eq(allowance_, _ALLOWANCE_VALUE_MASK)) { // Revert if the amount to be transferred exceeds the allowance. - if gt(amount, allowance_) { + if and( + iszero(iszero(amount)), + or(gt(amount, allowance_), lt(expiration, timestamp())) + ) { mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. revert(0x1c, 0x04) } // Subtract and store the updated allowance. - sstore(allowanceSlot, sub(allowance_, amount)) + allowance_ := sub(allowance_, amount) + sstore( + allowanceSlot, + mul(iszero(iszero(allowance_)), or(shl(192, expiration), allowance_)) + ) + } + // If the allowance is the maximum uint256 value sentinel. + if eq(allowance_, _ALLOWANCE_VALUE_MASK) { + if and(iszero(iszero(amount)), lt(expiration, timestamp())) { + mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. + revert(0x1c, 0x04) + } } } // Compute the balance slot and load its value. @@ -308,16 +371,32 @@ abstract contract ERC20 { mstore(0x20, caller()) mstore(0x0c, or(from_, _ALLOWANCE_SLOT_SEED)) let allowanceSlot := keccak256(0x0c, 0x34) - let allowance_ := sload(allowanceSlot) - // If the allowance is not the maximum uint256 value. - if not(allowance_) { + let packedAllowance := sload(allowanceSlot) + let allowance_ := and(packedAllowance, _ALLOWANCE_VALUE_MASK) + let expiration := shr(192, packedAllowance) + // If the allowance is not the maximum uint256 value sentinel. + if iszero(eq(allowance_, _ALLOWANCE_VALUE_MASK)) { // Revert if the amount to be transferred exceeds the allowance. - if gt(amount, allowance_) { + if and( + iszero(iszero(amount)), + or(gt(amount, allowance_), lt(expiration, timestamp())) + ) { mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. revert(0x1c, 0x04) } // Subtract and store the updated allowance. - sstore(allowanceSlot, sub(allowance_, amount)) + allowance_ := sub(allowance_, amount) + sstore( + allowanceSlot, + mul(iszero(iszero(allowance_)), or(shl(192, expiration), allowance_)) + ) + } + // If the allowance is the maximum uint256 value sentinel. + if eq(allowance_, _ALLOWANCE_VALUE_MASK) { + if and(iszero(iszero(amount)), lt(expiration, timestamp())) { + mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. + revert(0x1c, 0x04) + } } // Compute the balance slot and load its value. mstore(0x0c, or(from_, _BALANCE_SLOT_SEED)) @@ -385,7 +464,7 @@ abstract contract ERC20 { /// @dev Sets `value` as the allowance of `spender` over the tokens of `owner`, /// authorized by a signed approval by `owner`. /// - /// Emits a {Approval} event. + /// Emits {Approval} and {ApprovalExpiration} events. function permit( address owner, address spender, @@ -457,15 +536,10 @@ abstract contract ERC20 { } // Increment and store the updated nonce. sstore(nonceSlot, add(nonceValue, t)) // `t` is 1 if ecrecover succeeds. - // Compute the allowance slot and store the value. - // The `owner` is already at slot 0x20. - mstore(0x40, or(shl(160, _ALLOWANCE_SLOT_SEED), spender)) - sstore(keccak256(0x2c, 0x34), value) - // Emit the {Approval} event. - log3(add(m, 0x60), 0x20, _APPROVAL_EVENT_SIGNATURE, owner, spender) mstore(0x40, m) // Restore the free memory pointer. mstore(0x60, 0) // Restore the zero pointer. } + _approve(owner, spender, value); } /// @dev Returns the EIP-712 domain separator for the EIP-2612 permit. @@ -602,24 +676,50 @@ abstract contract ERC20 { mstore(0x0c, _ALLOWANCE_SLOT_SEED) mstore(0x00, owner) let allowanceSlot := keccak256(0x0c, 0x34) - let allowance_ := sload(allowanceSlot) - // If the allowance is not the maximum uint256 value. - if not(allowance_) { + let packedAllowance := sload(allowanceSlot) + let allowance_ := and(packedAllowance, _ALLOWANCE_VALUE_MASK) + let expiration := shr(192, packedAllowance) + // If the allowance is not the maximum uint256 value sentinel. + if iszero(eq(allowance_, _ALLOWANCE_VALUE_MASK)) { // Revert if the amount to be transferred exceeds the allowance. - if gt(amount, allowance_) { + if and( + iszero(iszero(amount)), or(gt(amount, allowance_), lt(expiration, timestamp())) + ) { mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. revert(0x1c, 0x04) } // Subtract and store the updated allowance. - sstore(allowanceSlot, sub(allowance_, amount)) + allowance_ := sub(allowance_, amount) + sstore( + allowanceSlot, + mul(iszero(iszero(allowance_)), or(shl(192, expiration), allowance_)) + ) + } + // If the allowance is the maximum uint256 value sentinel. + if eq(allowance_, _ALLOWANCE_VALUE_MASK) { + if and(iszero(iszero(amount)), lt(expiration, timestamp())) { + mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. + revert(0x1c, 0x04) + } } } } /// @dev Sets `amount` as the allowance of `spender` over the tokens of `owner`. /// - /// Emits a {Approval} event. + /// Emits {Approval} and {ApprovalExpiration} events. function _approve(address owner, address spender, uint256 amount) internal virtual { + _approve(owner, spender, amount, maxApprovalDuration()); + } + + /// @dev Sets `amount` as the allowance of `spender` over the tokens of `owner` + /// for `duration` seconds. + /// + /// Emits {Approval} and {ApprovalExpiration} events. + function _approve(address owner, address spender, uint256 amount, uint32 duration) + internal + virtual + { if (_givePermit2InfiniteAllowance()) { /// @solidity memory-safe-assembly assembly { @@ -630,16 +730,40 @@ abstract contract ERC20 { } } } + if (duration > maxApprovalDuration()) revert ApprovalDurationTooLong(); /// @solidity memory-safe-assembly assembly { + if iszero(or(lt(amount, _ALLOWANCE_VALUE_MASK), iszero(not(amount)))) { + mstore(0x00, 0xf9067066) // `AllowanceOverflow()`. + revert(0x1c, 0x04) + } + let expiration := 0 + if amount { + expiration := add(timestamp(), duration) + if or(shr(64, expiration), lt(expiration, timestamp())) { + mstore(0x00, 0xf915ba85) // `ApprovalExpirationOverflow()`. + revert(0x1c, 0x04) + } + } + let storedAmount := amount + if iszero(not(amount)) { storedAmount := _ALLOWANCE_VALUE_MASK } let owner_ := shl(96, owner) // Compute the allowance slot and store the amount. mstore(0x20, spender) mstore(0x0c, or(owner_, _ALLOWANCE_SLOT_SEED)) - sstore(keccak256(0x0c, 0x34), amount) + sstore(keccak256(0x0c, 0x34), or(shl(192, expiration), storedAmount)) // Emit the {Approval} event. mstore(0x00, amount) log3(0x00, 0x20, _APPROVAL_EVENT_SIGNATURE, shr(96, owner_), shr(96, mload(0x2c))) + // Emit the {ApprovalExpiration} event. + mstore(0x00, expiration) + log3( + 0x00, + 0x20, + _APPROVAL_EXPIRATION_EVENT_SIGNATURE, + shr(96, owner_), + shr(96, mload(0x2c)) + ) } } diff --git a/test/ERC20.t.sol b/test/ERC20.t.sol index aff0ff92d9..8110ffb99a 100644 --- a/test/ERC20.t.sol +++ b/test/ERC20.t.sol @@ -99,6 +99,10 @@ contract ERC20Test is SoladyTest { event Approval(address indexed owner, address indexed spender, uint256 amount); + event ApprovalExpiration(address indexed owner, address indexed spender, uint64 expiration); + + uint256 internal constant _MAX_PACKED_ALLOWANCE = uint256(type(uint192).max) - 1; + struct _TestTemps { address owner; address to; @@ -154,6 +158,58 @@ contract ERC20Test is SoladyTest { assertTrue(token.approve(address(0xBEEF), 1e18)); assertEq(token.allowance(address(this), address(0xBEEF)), 1e18); + (uint64 expiration, uint256 allowance) = + token.allowanceAndExpiration(address(this), address(0xBEEF)); + assertEq(expiration, uint64(block.timestamp + token.maxApprovalDuration())); + assertEq(allowance, 1e18); + } + + function testApproveForDuration() public { + vm.warp(1_000_000); + assertEq(token.maxApprovalDuration(), 1 days); + + vm.expectEmit(true, true, true, true); + emit Approval(address(this), address(0xBEEF), 100); + vm.expectEmit(true, true, true, true); + emit ApprovalExpiration(address(this), address(0xBEEF), uint64(block.timestamp + 3600)); + assertTrue(token.approveForDuration(address(0xBEEF), 100, 3600)); + + (uint64 expiration, uint256 allowance) = + token.allowanceAndExpiration(address(this), address(0xBEEF)); + assertEq(expiration, 1_003_600); + assertEq(allowance, 100); + assertEq(token.allowance(address(this), address(0xBEEF)), 100); + + vm.warp(1_003_600); + assertEq(token.allowance(address(this), address(0xBEEF)), 100); + + vm.warp(1_003_601); + assertEq(token.allowance(address(this), address(0xBEEF)), 0); + (expiration, allowance) = token.allowanceAndExpiration(address(this), address(0xBEEF)); + assertEq(expiration, 1_003_600); + assertEq(allowance, 100); + } + + function testApproveForDurationRevertsIfTooLong() public { + vm.expectRevert(ERC20.ApprovalDurationTooLong.selector); + token.approveForDuration(address(0xBEEF), 100, 1 days + 1); + } + + function testApproveZeroClearsExpiration() public { + token.approve(address(0xBEEF), 100); + token.approve(address(0xBEEF), 0); + (uint64 expiration, uint256 allowance) = + token.allowanceAndExpiration(address(this), address(0xBEEF)); + assertEq(expiration, 0); + assertEq(allowance, 0); + } + + function testApproveUnsupportedPackedAllowanceReverts() public { + vm.expectRevert(ERC20.AllowanceOverflow.selector); + token.approve(address(0xBEEF), type(uint192).max); + + vm.expectRevert(ERC20.AllowanceOverflow.selector); + token.approve(address(0xBEEF), uint256(type(uint192).max) + 1); } function testTransfer() public { @@ -206,6 +262,7 @@ contract ERC20Test is SoladyTest { function testPermit() public { _TestTemps memory t = _testTemps(); + t.amount = _boundValidAllowance(t.amount); t.deadline = block.timestamp; _signPermit(t); @@ -276,6 +333,8 @@ contract ERC20Test is SoladyTest { function testApprove(address to, uint256 amount) public { if (to == _PERMIT2) { amount = type(uint256).max; + } else { + amount = _boundValidAllowance(amount); } assertTrue(token.approve(to, amount)); assertEq(token.allowance(address(this), to), amount); @@ -305,6 +364,7 @@ contract ERC20Test is SoladyTest { uint256 amount ) public { vm.assume(spender != _PERMIT2); + approval = _boundValidAllowance(approval); amount = _bound(amount, 0, approval); token.mint(from, amount); @@ -355,7 +415,7 @@ contract ERC20Test is SoladyTest { function testDirectSpendAllowance(uint256) public { _TestTemps memory t = _testTemps(); - uint256 allowance = _random(); + uint256 allowance = _boundValidAllowance(_random()); vm.prank(t.owner); token.approve(t.to, allowance); assertEq(token.allowance(t.owner, t.to), allowance); @@ -373,6 +433,7 @@ contract ERC20Test is SoladyTest { function testPermit(uint256) public { _TestTemps memory t = _testTemps(); + t.amount = _boundValidAllowance(t.amount); if (t.deadline < block.timestamp) t.deadline = block.timestamp; _signPermit(t); @@ -385,6 +446,9 @@ contract ERC20Test is SoladyTest { function _checkAllowanceAndNonce(_TestTemps memory t) internal { assertEq(token.allowance(t.owner, t.to), t.amount); + (uint64 expiration, uint256 allowance) = token.allowanceAndExpiration(t.owner, t.to); + assertEq(expiration, _expectedDefaultExpiration(t.amount)); + assertEq(allowance, t.amount); assertEq(token.nonces(t.owner), t.nonce + 1); } @@ -418,7 +482,8 @@ contract ERC20Test is SoladyTest { uint256 amount ) public { if (approval == type(uint256).max) approval--; - amount = _bound(amount, approval + 1, type(uint256).max); + approval = _bound(approval, 0, _MAX_PACKED_ALLOWANCE - 1); + amount = _bound(amount, approval + 1, _MAX_PACKED_ALLOWANCE); address from = address(0xABCD); @@ -436,8 +501,8 @@ contract ERC20Test is SoladyTest { uint256 mintAmount, uint256 sendAmount ) public { - if (mintAmount == type(uint256).max) mintAmount--; - sendAmount = _bound(sendAmount, mintAmount + 1, type(uint256).max); + sendAmount = _bound(sendAmount, 1, _MAX_PACKED_ALLOWANCE); + mintAmount = _bound(mintAmount, 0, sendAmount - 1); address from = address(0xABCD); @@ -485,6 +550,7 @@ contract ERC20Test is SoladyTest { function testPermitReplayReverts(uint256) public { _TestTemps memory t = _testTemps(); + t.amount = _boundValidAllowance(t.amount); if (t.deadline < block.timestamp) t.deadline = block.timestamp; _signPermit(t); @@ -506,6 +572,18 @@ contract ERC20Test is SoladyTest { function _expectPermitEmitApproval(_TestTemps memory t) internal { vm.expectEmit(true, true, true, true); emit Approval(t.owner, t.to, t.amount); + vm.expectEmit(true, true, true, true); + emit ApprovalExpiration(t.owner, t.to, _expectedDefaultExpiration(t.amount)); + } + + function _boundValidAllowance(uint256 amount) internal pure returns (uint256) { + if (amount == type(uint256).max) return amount; + return amount % uint256(type(uint192).max); + } + + function _expectedDefaultExpiration(uint256 amount) internal view returns (uint64) { + if (amount == 0) return 0; + return uint64(block.timestamp + token.maxApprovalDuration()); } function _permit(_TestTemps memory t) internal { diff --git a/test/SafeTransferLib.t.sol b/test/SafeTransferLib.t.sol index 224206d2f7..ba47511ae5 100644 --- a/test/SafeTransferLib.t.sol +++ b/test/SafeTransferLib.t.sol @@ -508,6 +508,7 @@ contract SafeTransferLibTest is SoladyTest { } function testTransferFromWithStandardERC20(address from, address to, uint256 amount) public { + amount = _boundValidAllowance(amount); verifySafeTransferFrom(address(erc20), from, to, amount, _SUCCESS); } @@ -541,6 +542,7 @@ contract SafeTransferLibTest is SoladyTest { function testApproveWithStandardERC20(address to, uint256 amount) public { if (to == _PERMIT2) return; + amount = _boundValidAllowance(amount); verifySafeApprove(address(erc20), to, amount, _SUCCESS); } @@ -582,6 +584,8 @@ contract SafeTransferLibTest is SoladyTest { function testApproveWithRetry(address to, uint256 amount0, uint256 amount1) public { if (to == _PERMIT2) return; + amount0 = _boundValidAllowance(amount0); + amount1 = _boundValidAllowance(amount1); MockERC20LikeUSDT usdt = new MockERC20LikeUSDT(); assertEq(usdt.allowance(address(this), to), 0); SafeTransferLib.safeApproveWithRetry(address(usdt), _brutalized(to), amount0); @@ -804,7 +808,12 @@ contract SafeTransferLibTest is SoladyTest { mstore(0x00, from) allowanceSlot := keccak256(0x0c, 0x34) } - vm.store(token, allowanceSlot, bytes32(uint256(amount))); + uint256 packed; + if (amount != 0) { + packed = (uint256(uint64(block.timestamp + erc20.maxApprovalDuration())) << 192) + | (amount == type(uint256).max ? type(uint192).max : amount); + } + vm.store(token, allowanceSlot, bytes32(packed)); } else { vm.store( token, @@ -816,6 +825,11 @@ contract SafeTransferLibTest is SoladyTest { assertEq(ERC20(token).allowance(from, to), amount, "wrong allowance"); } + function _boundValidAllowance(uint256 amount) internal pure returns (uint256) { + if (amount == type(uint256).max) return amount; + return amount % uint256(type(uint192).max); + } + function forceSafeTransferETH(address to, uint256 amount, uint256 gasStipend) public { SafeTransferLib.forceSafeTransferETH(to, amount, gasStipend); } diff --git a/test/ext/zksync/SafeTransferLib.t.sol b/test/ext/zksync/SafeTransferLib.t.sol index fcd631bd1c..1b34e9eb00 100644 --- a/test/ext/zksync/SafeTransferLib.t.sol +++ b/test/ext/zksync/SafeTransferLib.t.sol @@ -345,6 +345,7 @@ contract SafeTransferLibTest is SoladyTest { } function testTransferFromWithStandardERC20(address from, address to, uint256 amount) public { + amount = _boundValidAllowance(amount); verifySafeTransferFrom(address(erc20), from, to, amount, _SUCCESS); } @@ -378,6 +379,7 @@ contract SafeTransferLibTest is SoladyTest { function testApproveWithStandardERC20(address to, uint256 amount) public { if (to == _REGULAR_EVM_PERMIT2) return; + amount = _boundValidAllowance(amount); verifySafeApprove(address(erc20), to, amount, _SUCCESS); } @@ -419,6 +421,8 @@ contract SafeTransferLibTest is SoladyTest { function testApproveWithRetry(address to, uint256 amount0, uint256 amount1) public { if (to == _REGULAR_EVM_PERMIT2) return; + amount0 = _boundValidAllowance(amount0); + amount1 = _boundValidAllowance(amount1); MockERC20LikeUSDT usdt = new MockERC20LikeUSDT(); assertEq(usdt.allowance(address(this), to), 0); SafeTransferLib.safeApproveWithRetry(address(usdt), _brutalized(to), amount0); @@ -641,7 +645,12 @@ contract SafeTransferLibTest is SoladyTest { mstore(0x00, from) allowanceSlot := keccak256(0x0c, 0x34) } - vm.store(token, allowanceSlot, bytes32(uint256(amount))); + uint256 packed; + if (amount != 0) { + packed = (uint256(uint64(block.timestamp + erc20.maxApprovalDuration())) << 192) + | (amount == type(uint256).max ? type(uint192).max : amount); + } + vm.store(token, allowanceSlot, bytes32(packed)); } else { vm.store( token, @@ -653,6 +662,11 @@ contract SafeTransferLibTest is SoladyTest { assertEq(ERC20(token).allowance(from, to), amount, "wrong allowance"); } + function _boundValidAllowance(uint256 amount) internal pure returns (uint256) { + if (amount == type(uint256).max) return amount; + return amount % uint256(type(uint192).max); + } + function forceSafeTransferETH(address to, uint256 amount, uint256 gasStipend) public { SafeTransferLib.forceSafeTransferETH(to, amount, gasStipend); } From 494e644a0be08c2edf9e07c01d1936bcde8101d7 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Fri, 8 May 2026 00:48:07 -0400 Subject: [PATCH 2/4] forge fmt --- src/tokens/ERC20.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tokens/ERC20.sol b/src/tokens/ERC20.sol index c09972d193..4fe7ef0e0d 100644 --- a/src/tokens/ERC20.sol +++ b/src/tokens/ERC20.sol @@ -683,7 +683,8 @@ abstract contract ERC20 { if iszero(eq(allowance_, _ALLOWANCE_VALUE_MASK)) { // Revert if the amount to be transferred exceeds the allowance. if and( - iszero(iszero(amount)), or(gt(amount, allowance_), lt(expiration, timestamp())) + iszero(iszero(amount)), + or(gt(amount, allowance_), lt(expiration, timestamp())) ) { mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. revert(0x1c, 0x04) From e68fdabe06373bbe838cb203f4c73369ef28b7dd Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Fri, 15 May 2026 13:12:29 -0400 Subject: [PATCH 3/4] handle the case where max approvals exist before a contract is upgraded to make it storage compatible --- src/tokens/ERC20.sol | 34 +++++++++++++++++++++++++++-- test/ERC20.t.sol | 51 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/tokens/ERC20.sol b/src/tokens/ERC20.sol index 4fe7ef0e0d..f4f8a757a5 100644 --- a/src/tokens/ERC20.sol +++ b/src/tokens/ERC20.sol @@ -38,6 +38,9 @@ abstract contract ERC20 { /// @dev The approval expiration timestamp has overflowed. error ApprovalExpirationOverflow(); + /// @dev The stored approval has an invalid packed representation. + error InvalidStoredApproval(); + /// @dev Insufficient balance. error InsufficientBalance(); @@ -191,15 +194,21 @@ abstract contract ERC20 { if (_givePermit2InfiniteAllowance()) { if (spender == _PERMIT2) return type(uint256).max; } + uint256 maxApprovalDuration_ = maxApprovalDuration(); /// @solidity memory-safe-assembly assembly { mstore(0x20, spender) mstore(0x0c, _ALLOWANCE_SLOT_SEED) mstore(0x00, owner) let packed := sload(keccak256(0x0c, 0x34)) + let expiration := shr(192, packed) result := and(packed, _ALLOWANCE_VALUE_MASK) if result { - if lt(shr(192, packed), timestamp()) { result := 0 } + if gt(expiration, add(timestamp(), maxApprovalDuration_)) { + mstore(0x00, 0x269f9e51) // `InvalidStoredApproval()`. + revert(0x1c, 0x04) + } + if lt(expiration, timestamp()) { result := 0 } if eq(result, _ALLOWANCE_VALUE_MASK) { result := not(0) } } } @@ -216,6 +225,7 @@ abstract contract ERC20 { if (_givePermit2InfiniteAllowance()) { if (spender == _PERMIT2) return (type(uint64).max, type(uint256).max); } + uint256 maxApprovalDuration_ = maxApprovalDuration(); /// @solidity memory-safe-assembly assembly { mstore(0x20, spender) @@ -225,7 +235,13 @@ abstract contract ERC20 { expiration := shr(192, packed) amount := and(packed, _ALLOWANCE_VALUE_MASK) if iszero(amount) { expiration := 0 } - if eq(amount, _ALLOWANCE_VALUE_MASK) { amount := not(0) } + if amount { + if gt(expiration, add(timestamp(), maxApprovalDuration_)) { + mstore(0x00, 0x269f9e51) // `InvalidStoredApproval()`. + revert(0x1c, 0x04) + } + if eq(amount, _ALLOWANCE_VALUE_MASK) { amount := not(0) } + } } } @@ -303,6 +319,7 @@ abstract contract ERC20 { /// Emits a {Transfer} event. function transferFrom(address from, address to, uint256 amount) public virtual returns (bool) { _beforeTokenTransfer(from, to, amount); + uint256 maxApprovalDuration_ = maxApprovalDuration(); // Code duplication is for zero-cost abstraction if possible. if (_givePermit2InfiniteAllowance()) { /// @solidity memory-safe-assembly @@ -316,6 +333,10 @@ abstract contract ERC20 { let packedAllowance := sload(allowanceSlot) let allowance_ := and(packedAllowance, _ALLOWANCE_VALUE_MASK) let expiration := shr(192, packedAllowance) + if and(allowance_, gt(expiration, add(timestamp(), maxApprovalDuration_))) { + mstore(0x00, 0x269f9e51) // `InvalidStoredApproval()`. + revert(0x1c, 0x04) + } // If the allowance is not the maximum uint256 value sentinel. if iszero(eq(allowance_, _ALLOWANCE_VALUE_MASK)) { // Revert if the amount to be transferred exceeds the allowance. @@ -374,6 +395,10 @@ abstract contract ERC20 { let packedAllowance := sload(allowanceSlot) let allowance_ := and(packedAllowance, _ALLOWANCE_VALUE_MASK) let expiration := shr(192, packedAllowance) + if and(allowance_, gt(expiration, add(timestamp(), maxApprovalDuration_))) { + mstore(0x00, 0x269f9e51) // `InvalidStoredApproval()`. + revert(0x1c, 0x04) + } // If the allowance is not the maximum uint256 value sentinel. if iszero(eq(allowance_, _ALLOWANCE_VALUE_MASK)) { // Revert if the amount to be transferred exceeds the allowance. @@ -669,6 +694,7 @@ abstract contract ERC20 { if (_givePermit2InfiniteAllowance()) { if (spender == _PERMIT2) return; // Do nothing, as allowance is infinite. } + uint256 maxApprovalDuration_ = maxApprovalDuration(); /// @solidity memory-safe-assembly assembly { // Compute the allowance slot and load its value. @@ -679,6 +705,10 @@ abstract contract ERC20 { let packedAllowance := sload(allowanceSlot) let allowance_ := and(packedAllowance, _ALLOWANCE_VALUE_MASK) let expiration := shr(192, packedAllowance) + if and(allowance_, gt(expiration, add(timestamp(), maxApprovalDuration_))) { + mstore(0x00, 0x269f9e51) // `InvalidStoredApproval()`. + revert(0x1c, 0x04) + } // If the allowance is not the maximum uint256 value sentinel. if iszero(eq(allowance_, _ALLOWANCE_VALUE_MASK)) { // Revert if the amount to be transferred exceeds the allowance. diff --git a/test/ERC20.t.sol b/test/ERC20.t.sol index 8110ffb99a..3d668c8c83 100644 --- a/test/ERC20.t.sol +++ b/test/ERC20.t.sol @@ -212,6 +212,45 @@ contract ERC20Test is SoladyTest { token.approve(address(0xBEEF), uint256(type(uint192).max) + 1); } + function testLegacyMaxApprovalSlotReverts() public { + vm.warp(1_000_000); + address owner = address(0xABCD); + address spender = address(this); + _storeRawAllowance(owner, spender, type(uint256).max); + + vm.expectRevert(ERC20.InvalidStoredApproval.selector); + token.allowance(owner, spender); + + vm.expectRevert(ERC20.InvalidStoredApproval.selector); + token.allowanceAndExpiration(owner, spender); + + token.mint(owner, 1); + vm.expectRevert(ERC20.InvalidStoredApproval.selector); + token.transferFrom(owner, address(0xBEEF), 1); + + vm.expectRevert(ERC20.InvalidStoredApproval.selector); + token.directSpendAllowance(owner, spender, 1); + } + + function testMaxApprovalSentinelExpirationBound() public { + vm.warp(1_000_000); + address owner = address(0xABCD); + address spender = address(this); + uint256 sentinel = uint256(type(uint192).max); + + uint256 maxValidExpiration = block.timestamp + token.maxApprovalDuration(); + _storeRawAllowance(owner, spender, (maxValidExpiration << 192) | sentinel); + assertEq(token.allowance(owner, spender), type(uint256).max); + + _storeRawAllowance(owner, spender, ((maxValidExpiration + 1) << 192) | 123); + vm.expectRevert(ERC20.InvalidStoredApproval.selector); + token.allowance(owner, spender); + + _storeRawAllowance(owner, spender, ((maxValidExpiration + 1) << 192) | sentinel); + vm.expectRevert(ERC20.InvalidStoredApproval.selector); + token.allowance(owner, spender); + } + function testTransfer() public { token.mint(address(this), 1e18); @@ -581,6 +620,18 @@ contract ERC20Test is SoladyTest { return amount % uint256(type(uint192).max); } + function _storeRawAllowance(address owner, address spender, uint256 packed) internal { + bytes32 allowanceSlot; + /// @solidity memory-safe-assembly + assembly { + mstore(0x20, spender) + mstore(0x0c, 0x7f5e9f20) + mstore(0x00, owner) + allowanceSlot := keccak256(0x0c, 0x34) + } + vm.store(address(token), allowanceSlot, bytes32(packed)); + } + function _expectedDefaultExpiration(uint256 amount) internal view returns (uint64) { if (amount == 0) return 0; return uint64(block.timestamp + token.maxApprovalDuration()); From e1f3edc739c02cbf471c7bbfab73c871fbcd85d4 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Wed, 20 May 2026 13:43:16 -0400 Subject: [PATCH 4/4] implement the legacy spender compatibility --- docs/tokens/erc20.md | 16 ++++++++++ docs/tokens/weth.md | 5 +++- src/tokens/ERC20.sol | 45 ++++++++++++++++++++++++----- src/tokens/WETH.sol | 5 ++++ test/ERC20.t.sol | 45 +++++++++++++++++++++++++++++ test/utils/mocks/MockERC20.sol | 9 ++++++ test/utils/mocks/MockERC20Votes.sol | 4 +++ test/utils/mocks/MockERC4626.sol | 4 +++ 8 files changed, 125 insertions(+), 8 deletions(-) diff --git a/docs/tokens/erc20.md b/docs/tokens/erc20.md index 4a27b888a7..c2b1aa144e 100644 --- a/docs/tokens/erc20.md +++ b/docs/tokens/erc20.md @@ -445,6 +445,22 @@ function _afterTokenTransfer(address from, address to, uint256 amount) Hook that is called after any transfer of tokens. This includes minting and burning. +### _isLegacySpender(address) + +```solidity +function _isLegacySpender(address spender) + internal + view + virtual + returns (bool) +``` + +Returns whether `spender` should be treated as ERC-8255 legacy-compatible. +This must be overridden by concrete implementations. + +Legacy-compatible spenders can use unrevoked allowances after their stored +expirations, and `allowanceAndExpiration` may report the current timestamp. + ## Permit2 ### _givePermit2InfiniteAllowance() diff --git a/docs/tokens/weth.md b/docs/tokens/weth.md index d1fd2bc172..ae575e2376 100644 --- a/docs/tokens/weth.md +++ b/docs/tokens/weth.md @@ -2,6 +2,9 @@ Simple Wrapped Ether implementation. +All spenders are treated as ERC-8255 legacy-compatible because this contract +has no owner-controlled spender policy. + @@ -64,4 +67,4 @@ Burns `amount` WETH of the caller and sends `amount` ETH to the caller. receive() external payable virtual ``` -Equivalent to `deposit()`. \ No newline at end of file +Equivalent to `deposit()`. diff --git a/src/tokens/ERC20.sol b/src/tokens/ERC20.sol index f4f8a757a5..ca02a5ac41 100644 --- a/src/tokens/ERC20.sol +++ b/src/tokens/ERC20.sol @@ -195,6 +195,7 @@ abstract contract ERC20 { if (spender == _PERMIT2) return type(uint256).max; } uint256 maxApprovalDuration_ = maxApprovalDuration(); + bool isLegacySpender = _isLegacySpender(spender); /// @solidity memory-safe-assembly assembly { mstore(0x20, spender) @@ -208,7 +209,7 @@ abstract contract ERC20 { mstore(0x00, 0x269f9e51) // `InvalidStoredApproval()`. revert(0x1c, 0x04) } - if lt(expiration, timestamp()) { result := 0 } + if and(iszero(isLegacySpender), lt(expiration, timestamp())) { result := 0 } if eq(result, _ALLOWANCE_VALUE_MASK) { result := not(0) } } } @@ -226,6 +227,7 @@ abstract contract ERC20 { if (spender == _PERMIT2) return (type(uint64).max, type(uint256).max); } uint256 maxApprovalDuration_ = maxApprovalDuration(); + bool isLegacySpender = _isLegacySpender(spender); /// @solidity memory-safe-assembly assembly { mstore(0x20, spender) @@ -240,6 +242,9 @@ abstract contract ERC20 { mstore(0x00, 0x269f9e51) // `InvalidStoredApproval()`. revert(0x1c, 0x04) } + if and(isLegacySpender, lt(expiration, timestamp())) { + expiration := timestamp() + } if eq(amount, _ALLOWANCE_VALUE_MASK) { amount := not(0) } } } @@ -320,6 +325,7 @@ abstract contract ERC20 { function transferFrom(address from, address to, uint256 amount) public virtual returns (bool) { _beforeTokenTransfer(from, to, amount); uint256 maxApprovalDuration_ = maxApprovalDuration(); + bool isLegacySpender = _isLegacySpender(msg.sender); // Code duplication is for zero-cost abstraction if possible. if (_givePermit2InfiniteAllowance()) { /// @solidity memory-safe-assembly @@ -342,7 +348,10 @@ abstract contract ERC20 { // Revert if the amount to be transferred exceeds the allowance. if and( iszero(iszero(amount)), - or(gt(amount, allowance_), lt(expiration, timestamp())) + or( + gt(amount, allowance_), + and(iszero(isLegacySpender), lt(expiration, timestamp())) + ) ) { mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. revert(0x1c, 0x04) @@ -356,7 +365,10 @@ abstract contract ERC20 { } // If the allowance is the maximum uint256 value sentinel. if eq(allowance_, _ALLOWANCE_VALUE_MASK) { - if and(iszero(iszero(amount)), lt(expiration, timestamp())) { + if and( + iszero(iszero(amount)), + and(iszero(isLegacySpender), lt(expiration, timestamp())) + ) { mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. revert(0x1c, 0x04) } @@ -404,7 +416,10 @@ abstract contract ERC20 { // Revert if the amount to be transferred exceeds the allowance. if and( iszero(iszero(amount)), - or(gt(amount, allowance_), lt(expiration, timestamp())) + or( + gt(amount, allowance_), + and(iszero(isLegacySpender), lt(expiration, timestamp())) + ) ) { mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. revert(0x1c, 0x04) @@ -418,7 +433,10 @@ abstract contract ERC20 { } // If the allowance is the maximum uint256 value sentinel. if eq(allowance_, _ALLOWANCE_VALUE_MASK) { - if and(iszero(iszero(amount)), lt(expiration, timestamp())) { + if and( + iszero(iszero(amount)), + and(iszero(isLegacySpender), lt(expiration, timestamp())) + ) { mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. revert(0x1c, 0x04) } @@ -695,6 +713,7 @@ abstract contract ERC20 { if (spender == _PERMIT2) return; // Do nothing, as allowance is infinite. } uint256 maxApprovalDuration_ = maxApprovalDuration(); + bool isLegacySpender = _isLegacySpender(spender); /// @solidity memory-safe-assembly assembly { // Compute the allowance slot and load its value. @@ -714,7 +733,10 @@ abstract contract ERC20 { // Revert if the amount to be transferred exceeds the allowance. if and( iszero(iszero(amount)), - or(gt(amount, allowance_), lt(expiration, timestamp())) + or( + gt(amount, allowance_), + and(iszero(isLegacySpender), lt(expiration, timestamp())) + ) ) { mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. revert(0x1c, 0x04) @@ -728,7 +750,10 @@ abstract contract ERC20 { } // If the allowance is the maximum uint256 value sentinel. if eq(allowance_, _ALLOWANCE_VALUE_MASK) { - if and(iszero(iszero(amount)), lt(expiration, timestamp())) { + if and( + iszero(iszero(amount)), + and(iszero(isLegacySpender), lt(expiration, timestamp())) + ) { mstore(0x00, 0x13be252b) // `InsufficientAllowance()`. revert(0x1c, 0x04) } @@ -810,6 +835,12 @@ abstract contract ERC20 { /// This includes minting and burning. function _afterTokenTransfer(address from, address to, uint256 amount) internal virtual {} + /// @dev Returns whether `spender` should be treated as ERC-8255 legacy-compatible. + /// + /// Legacy-compatible spenders can use unrevoked allowances after their stored + /// expirations, and `allowanceAndExpiration` may report the current timestamp. + function _isLegacySpender(address spender) internal view virtual returns (bool); + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* PERMIT2 */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ diff --git a/src/tokens/WETH.sol b/src/tokens/WETH.sol index b80b5baaa0..2138d9a08f 100644 --- a/src/tokens/WETH.sol +++ b/src/tokens/WETH.sol @@ -51,6 +51,11 @@ contract WETH is ERC20 { } } + /// @dev WETH is immutable and intentionally has no owner-controlled spender policy. + function _isLegacySpender(address) internal view virtual override returns (bool) { + return true; + } + /// @dev Equivalent to `deposit()`. receive() external payable virtual { deposit(); diff --git a/test/ERC20.t.sol b/test/ERC20.t.sol index 3d668c8c83..e22543189c 100644 --- a/test/ERC20.t.sol +++ b/test/ERC20.t.sol @@ -190,6 +190,50 @@ contract ERC20Test is SoladyTest { assertEq(allowance, 100); } + function testLegacyCompatibleSpender() public { + vm.warp(1_000_000); + address owner = address(0xABCD); + address spender = address(0xBEEF); + address recipient = address(0xCAFE); + + token.mint(owner, 100); + vm.prank(owner); + token.approveForDuration(spender, 100, 3600); + + vm.warp(1_003_601); + assertEq(token.allowance(owner, spender), 0); + (uint64 expiration, uint256 allowance) = token.allowanceAndExpiration(owner, spender); + assertEq(expiration, 1_003_600); + assertEq(allowance, 100); + + token.setLegacySpender(spender, true); + assertEq(token.allowance(owner, spender), 100); + (expiration, allowance) = token.allowanceAndExpiration(owner, spender); + assertEq(expiration, uint64(block.timestamp)); + assertEq(allowance, 100); + + vm.prank(spender); + token.transferFrom(owner, recipient, 40); + assertEq(token.balanceOf(recipient), 40); + assertEq(token.allowance(owner, spender), 60); + + token.directSpendAllowance(owner, spender, 10); + assertEq(token.allowance(owner, spender), 50); + + token.setLegacySpender(spender, false); + assertEq(token.allowance(owner, spender), 0); + (expiration, allowance) = token.allowanceAndExpiration(owner, spender); + assertEq(expiration, 1_003_600); + assertEq(allowance, 50); + + token.setLegacySpender(spender, true); + vm.prank(owner); + token.approve(spender, 0); + (expiration, allowance) = token.allowanceAndExpiration(owner, spender); + assertEq(expiration, 0); + assertEq(allowance, 0); + } + function testApproveForDurationRevertsIfTooLong() public { vm.expectRevert(ERC20.ApprovalDurationTooLong.selector); token.approveForDuration(address(0xBEEF), 100, 1 days + 1); @@ -217,6 +261,7 @@ contract ERC20Test is SoladyTest { address owner = address(0xABCD); address spender = address(this); _storeRawAllowance(owner, spender, type(uint256).max); + token.setLegacySpender(spender, true); vm.expectRevert(ERC20.InvalidStoredApproval.selector); token.allowance(owner, spender); diff --git a/test/utils/mocks/MockERC20.sol b/test/utils/mocks/MockERC20.sol index c4f9da1267..c950a600eb 100644 --- a/test/utils/mocks/MockERC20.sol +++ b/test/utils/mocks/MockERC20.sol @@ -11,6 +11,7 @@ contract MockERC20 is ERC20, Brutalizer { string internal _symbol; uint8 internal _decimals; bytes32 internal immutable _nameHash; + mapping(address => bool) internal _legacySpenders; constructor(string memory name_, string memory symbol_, uint8 decimals_) { _name = name_; @@ -51,6 +52,14 @@ contract MockERC20 is ERC20, Brutalizer { _spendAllowance(_brutalized(owner), _brutalized(spender), amount); } + function setLegacySpender(address spender, bool status) public virtual { + _legacySpenders[spender] = status; + } + + function _isLegacySpender(address spender) internal view virtual override returns (bool) { + return _legacySpenders[address(uint160(spender))]; + } + function transfer(address to, uint256 amount) public virtual override returns (bool) { return super.transfer(_brutalized(to), amount); } diff --git a/test/utils/mocks/MockERC20Votes.sol b/test/utils/mocks/MockERC20Votes.sol index 0a7fd4db6b..61616d5f98 100644 --- a/test/utils/mocks/MockERC20Votes.sol +++ b/test/utils/mocks/MockERC20Votes.sol @@ -31,6 +31,10 @@ contract MockERC20Votes is ERC20Votes, Brutalizer { _spendAllowance(owner, spender, amount); } + function _isLegacySpender(address) internal view virtual override returns (bool) { + return false; + } + function directDelegate(address delegator, address delegatee) public { _delegate(delegator, delegatee); } diff --git a/test/utils/mocks/MockERC4626.sol b/test/utils/mocks/MockERC4626.sol index 82cd582260..0f63fa5989 100644 --- a/test/utils/mocks/MockERC4626.sol +++ b/test/utils/mocks/MockERC4626.sol @@ -61,6 +61,10 @@ contract MockERC4626 is ERC4626 { return decimalsOffset; } + function _isLegacySpender(address) internal view virtual override returns (bool) { + return false; + } + function _beforeWithdraw(uint256, uint256) internal override { unchecked { ++beforeWithdrawHookCalledCounter;