From c853a29fd5c43b9c39ebdf4bda9b7da1171099c8 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Fri, 8 May 2026 00:47:02 -0400 Subject: [PATCH 1/2] 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/2] 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)