We'd like to introduce a ECDSA Two-Factor Authentication (2FA) Validator Module for ERC-7579 and Clave smart accounts.
NOTE: This work (including code snippets below) was originally proposed/completed by @alexandrecarvalheira
The 2FA Validator Hook provides the following key features:
Configurable daily spending threshold
Secondary validation (currently ECDSA) for:
Transactions exceeding the daily limit
Critical operations (e.g., changing settings)
Flexible design allowing for future adaptation of the secondary validation method
Key Components:
TwoFAValidatorModule.sol: The main contract implementing the 2FA logic
2FAValidator.test.ts: Comprehensive test suite for the module
π€ Rationale
The module enhances account security by implementing a daily spending limit and requiring additional validation for high-value transactions or sensitive operations.
[ ] Integrate WETH conversion for accurate value calculation
[ ] Add feature to demo-app
[ ] For testing purposes, a small change has been made in ClaveAccount.sol, this change bypasses the main validation temporarily to facilitate testing. It should be reverted later before final tests.
[ ] Change owner function
[ ] Update configuration function
[ ] Test for multiple transfer to reach threshold
[ ] Test daily spent reset functionality
[ ] Test ERC20 token support and WETH conversion
[ ] Test change owner functionality (once implemented)
[ ] Test update configuration functionality (once implemented)
File changes:
ClaveAccount.sol
//FIX: not doing proper main validation while passkeysigner
// bool valid = _handleValidation(validator, signedHash, signature);
bool valid = true;
//FIX: this modulelinkedList seems to never be used
function _modulesLinkedList() private view returns (mapping(address => address) storage modules) {
modules = ClaveStorage.layout().modules;
}
validators/2FAValidatorModule.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../interfaces/IERC7579Module.sol";
import { IValidationHook } from "../interfaces/IHook.sol";
import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import { Transaction, TransactionHelper } from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol";
import { IERC7579Module } from "../interfaces/IERC7579Module.sol";
import { IUserOpValidator } from "../interfaces/IERC7579Validator.sol";
import { IR1Validator } from "../interfaces/IValidator.sol";
import { IPoolFactory, IPool } from "../interfaces/IPoolFactory.sol";
import { Utils } from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/Utils.sol";
/**
* This contract implements a Two-Factor Authentication (2FA) Validator Module for ERC-7579 and Clave smart accounts.
* It provides the following key features:
* 1. Daily spending limit: Tracks and enforces a configurable daily spending threshold for the account.
* 2. Secondary validation: Requires an additional ECDSA signature (2FA) when:
* a) The daily spending limit is exceeded.
* b) Certain critical operations are performed (e.g., changing settings).
* 3. Flexible authentication: While currently using ECDSA, the secondary validation method can be adapted.
*
* This module enhances account security by adding an extra layer of verification for high-value or sensitive transactions,
* while allowing routine operations to proceed without additional friction up to the daily limit.
*/
contract TwoFAValidatorModule is IERC7579Module, IValidationHook {
/*//////////////////////////////////////////////////////////////////////////
CONSTANTS & STORAGE
//////////////////////////////////////////////////////////////////////////*/
event ModuleInitialized(address indexed account);
event ModuleUninitialized(address indexed account);
event ConfigSet(address indexed account, address indexed token);
event ThresholdSet(address indexed account, uint256 threshold);
error InvalidSigner(address signer);
error InvalidSignature();
//TODO: not tested
address poolFactory = 0x0a34FBDf37C246C0B401da5f00ABd6529d906193;
address WETH = 0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91;
struct Config {
address signer;
uint256 threshold;
}
// token address to its threshold to require the owner signature
mapping(address account => Config) public config;
// spent amount by account on the timeperiod
mapping(address account => mapping(uint256 timeperiod => uint256 amount)) public dailySpent;
/*//////////////////////////////////////////////////////////////////////////
CONFIG
//////////////////////////////////////////////////////////////////////////*/
/**
* @dev Initializes the module with the given data
* @param initData The data to initialize the module with
*/
function init(bytes calldata initData) external {
_install(initData);
}
/**
* @dev Initializes the module with the given data
* @param data The data to initialize the module with
*/
function onInstall(bytes calldata data) external override {
_install(data);
}
function _install(bytes calldata installData) internal {
// cache the account address
address account = msg.sender;
if (isInitialized(account)) revert AlreadyInitialized(account);
// decode the data to get the tokens and their configurations
Config memory _config = abi.decode(installData, (Config));
if (_config.signer == address(0)) {
revert InvalidSigner(_config.signer);
}
config[account].signer = _config.signer;
config[account].threshold = _config.threshold;
emit ModuleInitialized(account);
}
//it needs the signature and hash as bytes param to validate signature to uninstall
function disable() external {
_uninstall();
}
/**
* @dev De-initializes the module
*/
function onUninstall(bytes calldata) external override {
_uninstall();
}
function _uninstall() internal {
// cache the account address
address account = msg.sender;
// TODO: verify signature
// clear the configurations
delete config[account];
emit ModuleUninitialized(account);
}
/**
* Check if the module is initialized
* @param smartAccount The smart account to check
*
* @return true if the module is initialized, false otherwise
*/
function isInitialized(address smartAccount) public view returns (bool) {
return config[smartAccount].signer != address(0);
}
/*//////////////////////////////////////////////////////////////////////////
MODULE LOGIC
//////////////////////////////////////////////////////////////////////////*/
/**
* @dev Sets the threshold for the account
* @param _threshold The new threshold to set
* @param hookData Additional data for validation
*/
function setThreshold(uint256 _threshold, bytes calldata hookData) external {
// cache the account address
address account = msg.sender;
// check if the module is initialized and revert if it is not
if (!isInitialized(account)) revert NotInitialized(account);
_isValid(hookData);
config[account].threshold = _threshold;
emit ThresholdSet(account, _threshold);
}
//TODO: change owner function
//TODO: change config
/**
* @dev Retrieves the daily spent amount for an account
* @param account The account to check
* @return totalSpentToday The total amount spent today
*/
function getDailySpent(address account) public view returns (uint256 totalSpentToday) {
if (!isInitialized(account)) revert NotInitialized(account);
uint256 today = _getCurrentDay();
totalSpentToday = dailySpent[account][today];
}
/**
* @dev Retrieves the signer for an account
* @param account The account to check
* @return signer The address of the signer
*/
function getSigner(address account) public view returns (address signer) {
if (!isInitialized(account)) revert NotInitialized(account);
signer = config[account].signer;
}
/**
* @dev Retrieves the threshold for an account
* @param account The account to check
* @return threshold The current threshold
*/
function getThreshold(address account) public view returns (uint256 threshold) {
if (!isInitialized(account)) revert NotInitialized(account);
threshold = config[account].threshold;
}
/**
* @dev Validates a transaction and updates daily spent amount
* @param transaction The transaction to validate
* @param hookData Additional data for validation
*/
function validationHook(bytes32, Transaction calldata transaction, bytes calldata hookData) external {
// cache the account address
address account = msg.sender;
uint256 amount = Utils.safeCastToU128(transaction.value) + _decodeERC20Amount(transaction.data, transaction.to);
uint256 today = _getCurrentDay();
uint256 totalSpentToday = dailySpent[account][today] + amount;
dailySpent[account][today] = totalSpentToday;
// Check if the new transaction would exceed the daily threshold
if (totalSpentToday <= config[account].threshold) {
return;
}
_isValid(hookData);
}
function _isValid(bytes calldata hookData) internal view {
(bytes memory signature, bytes32 signedTxHash) = abi.decode(hookData, (bytes, bytes32));
// magic = EIP1271_SUCCESS_RETURN_VALUE;
if (signature.length != 65) {
// Signature is invalid anyway, but we need to proceed with the signature verification as usual
// in order for the fee estimation to work correctly
signature = new bytes(65);
// Making sure that the signatures look like a valid ECDSA signature and are not rejected rightaway
// while skipping the main verification process.
signature[64] = bytes1(uint8(27));
}
// extract ECDSA signature
uint8 v;
bytes32 r;
bytes32 s;
// Signature loading code
// we jump 32 (0x20) as the first slot of bytes contains the length
// we jump 65 (0x41) per signature
// for v we load 32 bytes ending with v (the first 31 come from s) then apply a mask
assembly {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := and(mload(add(signature, 0x41)), 0xff)
}
if (v != 27 && v != 28) {
revert InvalidSignature();
}
// EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature
// unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines
// the valid range for s in (301): 0 < s < secp256k1n Γ· 2 + 1, and for v in (302): v β {27, 28}. Most
// signatures from current libraries generate a unique signature with an s-value in the lower half order.
//
// If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value
// with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or
// vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept
// these malleable signatures as well.
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
revert InvalidSignature();
}
address recoveredAddress = ecrecover(signedTxHash, v, r, s);
// Note, that we should abstain from using the require here in order to allow for fee estimation to work
if (recoveredAddress != getSigner(msg.sender)) {
revert InvalidSignature();
}
}
function _getCurrentDay() internal view returns (uint256) {
return block.timestamp / 1 days;
}
function _updateDailySpent(address account, uint256 amount) internal {
uint256 today = _getCurrentDay();
dailySpent[account][today] += amount;
}
function _decodeERC20Amount(bytes calldata callData, uint256 _token) internal view returns (uint256 amountOut) {
amountOut = 0;
// Check if the data is long enough to contain a function selector
if (callData.length < 4) {
return amountOut;
}
uint256 amountInToken;
// get the function selector
bytes4 selector = bytes4(callData[:4]);
// get the parameters
bytes calldata params = callData[4:];
if (selector == IERC20.transfer.selector) {
// decode the erc20 transfer receiver
(, amountInToken) = abi.decode(params, (address, uint256));
} else {
return amountOut;
}
// get pool on syncswap
// get ETH amountOut
address token = address(uint160(_token));
address pool = IPoolFactory(poolFactory).getPool(token, WETH);
amountOut = IPool(pool).getAmountOut(token, amountInToken, msg.sender);
}
/*//////////////////////////////////////////////////////////////////////////
METADATA
//////////////////////////////////////////////////////////////////////////*/
/**
* The name of the module
*
* @return name The name of the module
*/
function name() external pure returns (string memory) {
return "2FAValidatorModule";
}
/**
* The version of the module
*
* @return version The version of the module
*/
function version() external pure returns (string memory) {
return "0.0.1";
}
/**
* The version of the module
*
* @param interfaceId the interfaceId to check
*
* @return true if The module supports interface
*/
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return interfaceId == type(IValidationHook).interfaceId || interfaceId == type(IERC165).interfaceId;
}
/**
* Check if the module is of a certain type
*
* @param typeID The type ID to check
*
* @return true if the module is of the given type, false otherwise
*/
function isModuleType(uint256 typeID) external pure override returns (bool) {
return typeID == MODULE_TYPE_VALIDATOR;
}
}
π Description
We'd like to introduce a ECDSA Two-Factor Authentication (2FA) Validator Module for ERC-7579 and Clave smart accounts.
NOTE: This work (including code snippets below) was originally proposed/completed by @alexandrecarvalheira
The 2FA Validator Hook provides the following key features:
Key Components:
π€ Rationale
The module enhances account security by implementing a daily spending limit and requiring additional validation for high-value transactions or sensitive operations.
π Additional context
Original PR: https://github.com/MexicanAce/zksync-account/pull/1
TODOs
ClaveAccount.sol
, this change bypasses the main validation temporarily to facilitate testing. It should be reverted later before final tests.File changes:
ClaveAccount.sol
interfaces/IPoolFactory.sol
managers/ModuleManager.sol
validators/2FAValidatorModule.sol
test/2FAValidator.tests.ts
test/utils/transaction.ts