Skip to content

Commit 2b163a0

Browse files
authored
7914 detector (#36)
* 7914 detector * Forge fmt * Enable solidity optimizer * Fix fuzz test bounds * Test via_ir * Remove test utils from forge build --sizes * Add natspec header * Add note about gas optimization
1 parent a4b7cd3 commit 2b163a0

10 files changed

Lines changed: 330 additions & 5 deletions

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ jobs:
2424
- name: Install Foundry
2525
uses: foundry-rs/foundry-toolchain@v1
2626
with:
27-
version: nightly
27+
version: v1.1.0
2828

2929
- name: Run Forge build
3030
run: |
3131
forge --version
32-
forge build --sizes
32+
forge build --sizes | grep -v -E "(CaliburEntryUtils|Mock.*|BaseAuthorization)"
3333
id: build
3434

3535
- name: Run Forge tests

src/ERC7914Detector.sol

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.0;
3+
4+
import {IERC7914} from "./interfaces/IERC7914.sol";
5+
6+
/// @title ERC7914Detector
7+
/// @notice A utility contract for detecting ERC7914 support in wallet contracts
8+
/// @dev This contract provides functionality to check if a given address supports
9+
/// the ERC7914 standard, which enables native token transfers from smart contract
10+
/// wallets. It handles both regular contracts and EIP-7702 account abstraction wallets.
11+
///
12+
/// The contract works by:
13+
/// 1. Checking if the address is an EOA (Externally Owned Account) - EOAs cannot support ERC7914
14+
/// 2. For EIP-7702 wallets, checking if they delegate to a known ERC7914-compliant contract
15+
/// 3. For regular contracts, checking if they implement the transferFromNative function
16+
///
17+
/// Intended for offchain view calls. Not gas optimized.
18+
/// @custom:security-contact security@uniswap.org
19+
contract ERC7914Detector {
20+
// EIP-7702 constants from account-abstraction library
21+
bytes3 internal constant EIP7702_PREFIX = 0xef0100;
22+
23+
// EIP-7702 bytecode structure: 3 bytes prefix + 20 bytes delegate address = 23 bytes total
24+
uint256 internal constant EIP7702_BYTECODE_SIZE = 23;
25+
26+
address public immutable caliburAddress;
27+
28+
constructor(address _caliburAddress) {
29+
caliburAddress = _caliburAddress;
30+
}
31+
32+
/**
33+
* @notice Check if a wallet supports ERC7914 by testing for transferFromNative function
34+
* @param wallet The wallet address to check
35+
* @return hasERC7914Support true if ERC7914 is supported, false otherwise
36+
*/
37+
function hasERC7914Support(address wallet) external view returns (bool) {
38+
// EOAs cannot support ERC7914
39+
uint256 codeSize;
40+
assembly {
41+
codeSize := extcodesize(wallet)
42+
}
43+
if (codeSize == 0) {
44+
return false;
45+
}
46+
47+
// Check if this is an EIP-7702 wallet delegating to Calibur
48+
if (_isEip7702Delegate(wallet)) {
49+
address delegate = _getEip7702Delegate(wallet);
50+
// If it delegates to Calibur, it has ERC7914 support
51+
if (delegate == caliburAddress) {
52+
return true;
53+
}
54+
// If it delegates to another contract, check that contract
55+
return _checkTransferFromNative(delegate);
56+
}
57+
58+
// For regular contracts, check if transferFromNative function exists
59+
return _checkTransferFromNative(wallet);
60+
}
61+
62+
/**
63+
* @notice Get the EIP-7702 bytecode from contract (prefix + delegate address)
64+
* @param wallet The wallet address to read from
65+
* @return code The first 23 bytes of bytecode (3 byte prefix + 20 byte delegate)
66+
*/
67+
function _getContractCode(address wallet) private view returns (bytes32 code) {
68+
assembly ("memory-safe") {
69+
extcodecopy(wallet, 0, 0, EIP7702_BYTECODE_SIZE)
70+
code := mload(0)
71+
}
72+
}
73+
74+
/**
75+
* @notice Check if an address is an EIP-7702 wallet
76+
* @param wallet The wallet address to check
77+
* @return true if it's an EIP-7702 wallet, false otherwise
78+
*/
79+
function _isEip7702Delegate(address wallet) private view returns (bool) {
80+
if (wallet.code.length < EIP7702_BYTECODE_SIZE) return false;
81+
return bytes3(_getContractCode(wallet)) == EIP7702_PREFIX;
82+
}
83+
84+
/**
85+
* @notice Get the delegate address from an EIP-7702 wallet
86+
* @param wallet The EIP-7702 wallet address
87+
* @return delegate The delegate contract address
88+
*/
89+
function _getEip7702Delegate(address wallet) private view returns (address) {
90+
bytes32 code = _getContractCode(wallet);
91+
return address(bytes20(code << 24));
92+
}
93+
94+
function _checkTransferFromNative(address wallet) private view returns (bool) {
95+
// Check if the function selector exists by examining the contract bytecode
96+
// Since transferFromNative requires authorization, we can't call it directly
97+
bytes4 selector = IERC7914.transferFromNative.selector;
98+
99+
// Use low-level call to check if function exists
100+
(bool success, bytes memory returnData) =
101+
wallet.staticcall(abi.encodeWithSelector(selector, address(0), address(0), 0));
102+
103+
// If the call succeeded and returned a valid boolean, the function exists
104+
if (success && returnData.length == 32) {
105+
// Decode and verify it's a valid boolean (0 or 1)
106+
uint256 returnValue = abi.decode(returnData, (uint256));
107+
if (returnValue <= 1) {
108+
return true;
109+
}
110+
}
111+
112+
// If the call succeeded but returned empty data, it hit the fallback
113+
// This indicates the function doesn't exist
114+
if (success && returnData.length == 0) {
115+
return false;
116+
}
117+
118+
return false; // Default to function not existing
119+
}
120+
}

src/interfaces/ICalibur.sol

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.0;
3+
4+
import {IERC7914} from "./IERC7914.sol";
5+
6+
/// A non-upgradeable contract that can be delegated to with a 7702 delegation transaction.
7+
/// This implementation supports:
8+
/// ERC-7914 transfer from native
9+
interface ICalibur is IERC7914 {}

src/interfaces/IERC7914.sol

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.0;
3+
4+
/// @title IERC7914
5+
interface IERC7914 {
6+
/// @notice Thrown when the caller's allowance is exceeded when transferring
7+
error AllowanceExceeded();
8+
/// @notice Thrown when the sender is not the expected one
9+
error IncorrectSender();
10+
/// @notice Thrown when the transfer of native tokens fails
11+
error TransferNativeFailed();
12+
13+
/// @notice Emitted when a transfer from native is made
14+
event TransferFromNative(address indexed from, address indexed to, uint256 value);
15+
/// @notice Emitted when a native approval is made
16+
event ApproveNative(address indexed owner, address indexed spender, uint256 value);
17+
/// @notice Emitted when a transfer from native transient is made
18+
event TransferFromNativeTransient(address indexed from, address indexed to, uint256 value);
19+
/// @notice Emitted when a transient native approval is made
20+
event ApproveNativeTransient(address indexed owner, address indexed spender, uint256 value);
21+
/// @notice Emitted when the native allowance of a spender is updated when a transfer happens
22+
event NativeAllowanceUpdated(address indexed spender, uint256 value);
23+
24+
/// @notice Returns the allowance of a spender
25+
function nativeAllowance(address spender) external view returns (uint256);
26+
27+
/// @notice Returns the transient allowance of a spender
28+
function transientNativeAllowance(address spender) external view returns (uint256);
29+
30+
/// @notice Transfers native tokens from the caller to a recipient
31+
/// @dev Doesn't forward transferFrom requests - the specified `from` address must be address(this)
32+
function transferFromNative(address from, address recipient, uint256 amount) external returns (bool);
33+
34+
/// @notice Approves a spender to transfer native tokens on behalf of the caller
35+
function approveNative(address spender, uint256 amount) external returns (bool);
36+
37+
/// @notice Transfers native tokens from the caller to a recipient with transient storage
38+
/// @dev Doesn't forward transferFrom requests - the specified `from` address must be address(this)
39+
function transferFromNativeTransient(address from, address recipient, uint256 amount) external returns (bool);
40+
41+
/// @notice Approves a spender to transfer native tokens on behalf of the caller with transient storage
42+
function approveNativeTransient(address spender, uint256 amount) external returns (bool);
43+
}

test/ERC7914Detector.t.sol

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.13;
3+
4+
import {Test} from "forge-std/Test.sol";
5+
import {ERC7914Detector} from "../src/ERC7914Detector.sol";
6+
import {IERC7914} from "../src/interfaces/IERC7914.sol";
7+
import {ICalibur} from "../src/interfaces/ICalibur.sol";
8+
import {CaliburEntryUtils} from "./util/CaliburEntryUtils.sol";
9+
import {MockWrongReturnTypeContract} from "./mock/MockWrongReturnTypeContract.sol";
10+
import {MockNonERC7914Contract} from "./mock/MockNonERC7914Contract.sol";
11+
12+
contract ERC7914DetectorTest is Test {
13+
ICalibur public calibur;
14+
uint256 signerPrivateKey = 0xa11ce;
15+
address signer = vm.addr(signerPrivateKey);
16+
17+
ERC7914Detector public detector;
18+
ICalibur public signerAccount;
19+
MockWrongReturnTypeContract public wrongReturnTypeContract;
20+
MockNonERC7914Contract public nonERC7914Contract;
21+
22+
function setUp() public {
23+
CaliburEntryUtils caliburEntryUtils = new CaliburEntryUtils();
24+
calibur = ICalibur(create2(caliburEntryUtils.getCaliburEntryCode(), bytes32(0)));
25+
_delegate(signer, address(calibur));
26+
signerAccount = ICalibur(signer);
27+
28+
detector = new ERC7914Detector(address(calibur));
29+
wrongReturnTypeContract = new MockWrongReturnTypeContract();
30+
nonERC7914Contract = new MockNonERC7914Contract();
31+
}
32+
33+
function create2(bytes memory initcode, bytes32 salt) internal returns (address contractAddress) {
34+
assembly {
35+
contractAddress := create2(0, add(initcode, 32), mload(initcode), salt)
36+
if iszero(contractAddress) {
37+
let ptr := mload(0x40)
38+
let errorSize := returndatasize()
39+
returndatacopy(ptr, 0, errorSize)
40+
revert(ptr, errorSize)
41+
}
42+
}
43+
}
44+
45+
function _delegate(address _signer, address _implementation) internal {
46+
vm.etch(_signer, bytes.concat(hex"ef0100", abi.encodePacked(_implementation)));
47+
require(_signer.code.length > 0, "signer not delegated");
48+
}
49+
50+
/// @notice Test ERC7914 detection functionality across different contract types
51+
function test_erc7914Detection() public {
52+
// Test with non-ERC7914 contract
53+
bool hasSupport = detector.hasERC7914Support(address(nonERC7914Contract));
54+
assertFalse(hasSupport, "Non-ERC7914 contract should not be detected");
55+
56+
// Test with EOA
57+
address eoa = makeAddr("testEOA");
58+
hasSupport = detector.hasERC7914Support(eoa);
59+
assertFalse(hasSupport, "EOA should not support ERC7914");
60+
61+
// Test with ERC7914-supporting contract (calibur address matches signerAccount)
62+
hasSupport = detector.hasERC7914Support(address(signerAccount));
63+
assertTrue(hasSupport, "ERC7914-supporting contract should be detected");
64+
65+
// Test with ERC7914-supporting contract (calibur address does not match signerAccount)
66+
detector = new ERC7914Detector(address(nonERC7914Contract));
67+
hasSupport = detector.hasERC7914Support(address(signerAccount));
68+
assertTrue(hasSupport, "ERC7914-supporting contract should be detected");
69+
}
70+
71+
/// @notice Test that contracts with transferFromNative function but wrong return type are not detected as ERC7914
72+
function test_erc7914DetectionWrongReturnType() public {
73+
// Test with contract that has transferFromNative function but returns uint256 instead of bool
74+
bool hasSupport = detector.hasERC7914Support(address(wrongReturnTypeContract));
75+
assertFalse(hasSupport, "Contract with wrong return type should not be detected as ERC7914");
76+
}
77+
}

test/FeeOnTransferDetector.t.sol

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,9 @@ contract FeeOnTransferDetectorTest is Test {
112112
}
113113

114114
function testBasicFotTokenFuzz(uint16 buyFee, uint16 sellFee) public {
115-
sellFee = uint16(bound(sellFee, 0, 10000));
116-
buyFee = uint16(bound(buyFee, 0, 10000));
115+
// detector.validate() will revert if the fee is 0, so we need to bound the fee to 1-9999
116+
sellFee = uint16(bound(sellFee, 1, 9999));
117+
buyFee = uint16(bound(buyFee, 1, 9999));
117118
MockFotToken fotToken = new MockFotToken(buyFee, sellFee);
118119
MockToken otherToken = new MockToken();
119120
address pair = factory.deployPair(address(fotToken), address(otherToken));
@@ -131,7 +132,8 @@ contract FeeOnTransferDetectorTest is Test {
131132
}
132133

133134
function testBasicFotTokenWithExternalFeesFuzz(uint16 fee) public {
134-
fee = uint16(bound(fee, 0, 10000));
135+
// detector.validate() will revert if the fee is 0, so we need to bound the fee to 1-9999
136+
fee = uint16(bound(fee, 0, 9999));
135137
MockFotTokenWithExternalFees fotToken = new MockFotTokenWithExternalFees(fee);
136138
MockToken otherToken = new MockToken();
137139
address pair = factory.deployPair(address(fotToken), address(otherToken));

test/mock/BaseAuthorization.sol

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.13;
3+
4+
/// @title BaseAuthorization
5+
/// @notice A base contract that provides a modifier to restrict access to the contract itself
6+
contract BaseAuthorization {
7+
/// @notice An error that is thrown when an unauthorized address attempts to call a function
8+
error Unauthorized();
9+
10+
/// @notice A modifier that restricts access to the contract itself
11+
modifier onlyThis() {
12+
if (msg.sender != address(this)) revert Unauthorized();
13+
_;
14+
}
15+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.13;
3+
4+
import {BaseAuthorization} from "./BaseAuthorization.sol";
5+
6+
/// @title MockNonERC7914Contract
7+
/// @notice A mock contract that implements BaseAuthorization but NOT ERC7914
8+
/// @dev Used for testing ERC7914 detection functionality
9+
contract MockNonERC7914Contract is BaseAuthorization {
10+
mapping(address => uint256) public someOtherAllowance;
11+
12+
constructor() {
13+
// No initialization needed for BaseAuthorization
14+
}
15+
16+
/// @notice A function that exists but is not part of ERC7914
17+
function setSomeAllowance(address spender, uint256 amount) external onlyThis {
18+
someOtherAllowance[spender] = amount;
19+
}
20+
21+
/// @notice Another non-ERC7914 function
22+
function transferSomeTokens(address to, uint256 amount) external onlyThis returns (bool) {
23+
// Mock implementation - doesn't actually transfer anything
24+
emit MockTransfer(address(this), to, amount);
25+
return true;
26+
}
27+
28+
/// @notice Receive function to accept ETH
29+
receive() external payable {}
30+
31+
/// @notice Fallback function
32+
fallback() external payable {}
33+
34+
event MockTransfer(address indexed from, address indexed to, uint256 value);
35+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.13;
3+
4+
import {BaseAuthorization} from "./BaseAuthorization.sol";
5+
6+
/// @title MockWrongReturnTypeContract
7+
/// @dev Used for testing ERC7914 detection functionality - has transferFromNative but wrong return type
8+
contract MockWrongReturnTypeContract is BaseAuthorization {
9+
/// @notice This function has the same signature as ERC7914's transferFromNative but returns uint256 instead of bool
10+
/// @dev Should cause the detector to return false since it doesn't return a valid boolean
11+
function transferFromNative(address, address, uint256) external pure returns (uint256) {
12+
// Return a non-boolean value (like 42)
13+
return 42;
14+
}
15+
}

test/util/CaliburEntryUtils.sol

Lines changed: 9 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)