You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 usedfunction _modulesLinkedList() privateviewreturns (mapping(address=>address) storagemodules) {
modules = ClaveStorage.layout().modules;
}
validators/2FAValidatorModule.sol
// SPDX-License-Identifier: MITpragma 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. */contractTwoFAValidatorModuleisIERC7579Module, IValidationHook {
/*////////////////////////////////////////////////////////////////////////// CONSTANTS & STORAGE //////////////////////////////////////////////////////////////////////////*/event ModuleInitialized(addressindexedaccount);
event ModuleUninitialized(addressindexedaccount);
event ConfigSet(addressindexedaccount, addressindexedtoken);
event ThresholdSet(addressindexedaccount, uint256threshold);
error InvalidSigner(addresssigner);
error InvalidSignature();
//TODO: not testedaddress poolFactory =0x0a34FBDf37C246C0B401da5f00ABd6529d906193;
address WETH =0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91;
struct Config {
address signer;
uint256 threshold;
}
// token address to its threshold to require the owner signaturemapping(address account => Config) public config;
// spent amount by account on the timeperiodmapping(address account =>mapping(uint256 timeperiod =>uint256amount)) public dailySpent;
/*////////////////////////////////////////////////////////////////////////// CONFIG //////////////////////////////////////////////////////////////////////////*//** * @dev Initializes the module with the given data * @param initData The data to initialize the module with */function init(bytescalldatainitData) external {
_install(initData);
}
/** * @dev Initializes the module with the given data * @param data The data to initialize the module with */function onInstall(bytescalldatadata) externaloverride {
_install(data);
}
function _install(bytescalldatainstallData) internal {
// cache the account addressaddress account =msg.sender;
if (isInitialized(account)) revertAlreadyInitialized(account);
// decode the data to get the tokens and their configurations
Config memory _config =abi.decode(installData, (Config));
if (_config.signer ==address(0)) {
revertInvalidSigner(_config.signer);
}
config[account].signer = _config.signer;
config[account].threshold = _config.threshold;
emitModuleInitialized(account);
}
//it needs the signature and hash as bytes param to validate signature to uninstallfunction disable() external {
_uninstall();
}
/** * @dev De-initializes the module */function onUninstall(bytescalldata) externaloverride {
_uninstall();
}
function _uninstall() internal {
// cache the account addressaddress account =msg.sender;
// TODO: verify signature// clear the configurationsdelete config[account];
emitModuleUninitialized(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(addresssmartAccount) publicviewreturns (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, bytescalldatahookData) external {
// cache the account addressaddress account =msg.sender;
// check if the module is initialized and revert if it is notif (!isInitialized(account)) revertNotInitialized(account);
_isValid(hookData);
config[account].threshold = _threshold;
emitThresholdSet(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(addressaccount) publicviewreturns (uint256totalSpentToday) {
if (!isInitialized(account)) revertNotInitialized(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(addressaccount) publicviewreturns (addresssigner) {
if (!isInitialized(account)) revertNotInitialized(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(addressaccount) publicviewreturns (uint256threshold) {
if (!isInitialized(account)) revertNotInitialized(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 calldatatransaction, bytescalldatahookData) external {
// cache the account addressaddress 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 thresholdif (totalSpentToday <= config[account].threshold) {
return;
}
_isValid(hookData);
}
function _isValid(bytescalldatahookData) internalview {
(bytesmemorysignature, bytes32signedTxHash) =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 =newbytes(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 signatureuint8 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 maskassembly {
r :=mload(add(signature, 0x20))
s :=mload(add(signature, 0x40))
v :=and(mload(add(signature, 0x41)), 0xff)
}
if (v !=27&& v !=28) {
revertInvalidSignature();
}
// 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) {
revertInvalidSignature();
}
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 workif (recoveredAddress !=getSigner(msg.sender)) {
revertInvalidSignature();
}
}
function _getCurrentDay() internalviewreturns (uint256) {
returnblock.timestamp/1days;
}
function _updateDailySpent(addressaccount, uint256amount) internal {
uint256 today =_getCurrentDay();
dailySpent[account][today] += amount;
}
function _decodeERC20Amount(bytescalldatacallData, uint256_token) internalviewreturns (uint256amountOut) {
amountOut =0;
// Check if the data is long enough to contain a function selectorif (callData.length<4) {
return amountOut;
}
uint256 amountInToken;
// get the function selectorbytes4 selector =bytes4(callData[:4]);
// get the parametersbytescalldata 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 amountOutaddress 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() externalpurereturns (stringmemory) {
return"2FAValidatorModule";
}
/** * The version of the module * * @return version The version of the module */function version() externalpurereturns (stringmemory) {
return"0.0.1";
}
/** * The version of the module * * @param interfaceId the interfaceId to check * * @return true if The module supports interface */function supportsInterface(bytes4interfaceId) externalpureoverridereturns (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(uint256typeID) externalpureoverridereturns (bool) {
return typeID == MODULE_TYPE_VALIDATOR;
}
}
test/2FAValidator.tests.ts
import{assert,expect}from"chai";import{randomBytes}from"crypto";import{AbiCoder,Contract,ethers,parseEther}from"ethers";import{it}from"mocha";import{encodePasskeyModuleParameters}from"zksync-account/utils";import{EIP712Signer,utils,Wallet}from"zksync-ethers";import{deployFactory,getProvider,getWallet,LOCAL_RICH_WALLETS}from"./utils";import{deployContract,ethersResponse,ethersStaticSalt,prepareTx}from"./utils/transaction";constprovider=getProvider();constabiCoder=newAbiCoder();letrichWallet: Wallet;letmockSigner: ethers.HDNodeWallet;letfactory: Contract;letimplementation: Contract;letpasskeyValidator: Contract;lettwofaValidator: Contract;letaccount: Contract;beforeEach(async()=>{richWallet=getWallet(LOCAL_RICH_WALLETS[0].privateKey);mockSigner=Wallet.createRandom(getProvider());implementation=awaitdeployContract("ERC7579Account",richWallet,ethersStaticSalt);awaitdeployContract("AccountProxy",richWallet,ethersStaticSalt,[awaitimplementation.getAddress()]);passkeyValidator=awaitdeployContract("WebAuthValidator",richWallet,ethersStaticSalt);factory=awaitdeployFactory("AAFactory",richWallet);twofaValidator=awaitdeployContract("TwoFAValidatorModule",richWallet,ethersStaticSalt);constpassKeyModuleData=encodePasskeyModuleParameters({passkeyPublicKey: awaitethersResponse.getXyPublicKeys(),expectedOrigin: ethersResponse.expectedOrigin,});constwebauthModuleData=abiCoder.encode(["address","bytes"],[awaitpasskeyValidator.getAddress(),passKeyModuleData]);constproxyAccountTX=awaitfactory.deployProxy7579Account(randomBytes(32),awaitimplementation.getAddress(),"ProxyAccount",[webauthModuleData],[]);constproxyAccountTxReceipt=awaitproxyAccountTX.wait();account=newContract(proxyAccountTxReceipt.contractAddress,implementation.interface,richWallet);// logInfo(`"7579Account" was successfully deployed to ${await account.getAddress()}`);await(awaitrichWallet.sendTransaction({to: awaitaccount.getAddress(),value: parseEther("10"),})).wait();});describe("2FA validation module",function(){it("should have correct state after deployment",asyncfunction(){expect(awaitprovider.getBalance(awaitaccount.getAddress())).to.eq(parseEther("10"),);constexpectedModules: Array<ethers.BytesLike>=[];constexpectedValidationHooks: Array<ethers.BytesLike>=[];constexpectedHooks: Array<ethers.BytesLike>=[];expect(awaitaccount.listModules()).to.deep.eq(expectedModules);expect(awaitaccount.listHooks(false)).to.deep.eq(expectedHooks);expect(awaitaccount.listHooks(true)).to.deep.eq(expectedValidationHooks);});it("should add 2fa validator Hook to account",asyncfunction(){constinitData=abiCoder.encode(["tuple(address,uint256)"],// Solidity equivalent: Config[[awaitrichWallet.getAddress(),parseEther("1"),]]);consthookAndData=ethers.concat([awaittwofaValidator.getAddress(),initData]);constcallData=account.interface.encodeFunctionData("addHook",[hookAndData,true]);consttx={to: awaitaccount.getAddress(),data: callData};constaddHook=awaitprepareTx(provider,account,tx,awaitpasskeyValidator.getAddress());consttxReceipt=awaitprovider.broadcastTransaction(utils.serializeEip712(addHook),);awaittxReceipt.wait();expect(awaitaccount.listHooks(true)).to.deep.eq([awaittwofaValidator.getAddress()]);});it("should transfer ETH without 2FA below threshold",asyncfunction(){constinitData=abiCoder.encode(["tuple(address,uint256)"],// Solidity equivalent: Config[[awaitrichWallet.getAddress(),parseEther("1"),]]);consthookAndData=ethers.concat([awaittwofaValidator.getAddress(),initData]);constcallData=account.interface.encodeFunctionData("addHook",[hookAndData,true]);consttx={to: awaitaccount.getAddress(),data: callData};constaddHook=awaitprepareTx(provider,account,tx,awaitpasskeyValidator.getAddress());consttxReceipt=awaitprovider.broadcastTransaction(utils.serializeEip712(addHook),);awaittxReceipt.wait();constreceiverBalanceBefore=awaitprovider.getBalance(awaitrichWallet.getAddress(),);consttransferValue=parseEther("0.01");consttx2={to: awaitrichWallet.getAddress(),data: "0x",value: transferValue};consttransfer=awaitprepareTx(provider,account,tx2,awaitpasskeyValidator.getAddress(),mockSigner);consttxReceipt2=awaitprovider.broadcastTransaction(utils.serializeEip712(transfer),);awaittxReceipt2.wait();assert.equal(receiverBalanceBefore+transferValue,awaitprovider.getBalance(awaitrichWallet.getAddress()));});it("should transfer ETH with 2FA above threshold",asyncfunction(){constinitData=abiCoder.encode(["tuple(address,uint256)"],// Solidity equivalent: Config[[awaitrichWallet.getAddress(),parseEther("1"),]]);consthookAndData=ethers.concat([awaittwofaValidator.getAddress(),initData]);constcallData=account.interface.encodeFunctionData("addHook",[hookAndData,true]);consttx={to: awaitaccount.getAddress(),data: callData};constaddHook=awaitprepareTx(provider,account,tx,awaitpasskeyValidator.getAddress());consttxReceipt=awaitprovider.broadcastTransaction(utils.serializeEip712(addHook),);awaittxReceipt.wait();constreceiverBalanceBefore=awaitprovider.getBalance(awaitrichWallet.getAddress(),);consttransferValue=parseEther("2");consttx2={to: awaitrichWallet.getAddress(),data: "0x",value: transferValue};consttransfer=awaitprepareTx(provider,account,tx2,awaitpasskeyValidator.getAddress(),richWallet);consttxReceipt2=awaitprovider.broadcastTransaction(utils.serializeEip712(transfer),);awaittxReceipt2.wait();assert.equal(receiverBalanceBefore+transferValue,awaitprovider.getBalance(awaitrichWallet.getAddress()));});it("should revert on transfer ETH without 2FA above threshold",asyncfunction(){constinitData=abiCoder.encode(["tuple(address,uint256)"],// Solidity equivalent: Config[[awaitrichWallet.getAddress(),parseEther("1"),]]);consthookAndData=ethers.concat([awaittwofaValidator.getAddress(),initData]);constcallData=account.interface.encodeFunctionData("addHook",[hookAndData,true]);consttx={to: awaitaccount.getAddress(),data: callData};constaddHook=awaitprepareTx(provider,account,tx,awaitpasskeyValidator.getAddress());consttxReceipt=awaitprovider.broadcastTransaction(utils.serializeEip712(addHook),);awaittxReceipt.wait();consttransferValue=parseEther("2");consttx2={to: awaitrichWallet.getAddress(),data: "0x",value: transferValue};consttransfer=awaitprepareTx(provider,account,tx2,awaitpasskeyValidator.getAddress(),mockSigner);consttxReceipt2=provider.broadcastTransaction(utils.serializeEip712(transfer),);awaitexpect(txReceipt2).to.be.rejected;});it("should change threshold with 2FA",asyncfunction(){constinitData=abiCoder.encode(["tuple(address,uint256)"],// Solidity equivalent: Config[[awaitrichWallet.getAddress(),parseEther("1"),]]);consthookAndData=ethers.concat([awaittwofaValidator.getAddress(),initData]);constcallData=account.interface.encodeFunctionData("addHook",[hookAndData,true]);consttx={to: awaitaccount.getAddress(),data: callData};constaddHook=awaitprepareTx(provider,account,tx,awaitpasskeyValidator.getAddress());consttxReceipt=awaitprovider.broadcastTransaction(utils.serializeEip712(addHook),);awaittxReceipt.wait();constnewThreshold=parseEther("1");consttx2={to: awaittwofaValidator.getAddress(),from: awaitaccount.getAddress(),nonce: awaitprovider.getTransactionCount(awaitaccount.getAddress()),gasPrice: awaitprovider.getGasPrice(),chainId: (awaitprovider.getNetwork()).chainId};constsignedTxHash=EIP712Signer.getSignedDigest(tx2);constsignature=ethers.Signature.from(richWallet.signingKey.sign(signedTxHash)).serialized;consthookData=ethers.AbiCoder.defaultAbiCoder().encode(["bytes","bytes32"],[signature,signedTxHash],);constcallData2=twofaValidator.interface.encodeFunctionData("setThreshold",[newThreshold,hookData]);tx2["data"]=callData2;consttransfer=awaitprepareTx(provider,account,tx2,awaitpasskeyValidator.getAddress(),richWallet);consttxReceipt2=awaitprovider.broadcastTransaction(utils.serializeEip712(transfer),);awaittxReceipt2.wait();expect(awaittwofaValidator.getThreshold(awaitaccount.getAddress())).to.eq(newThreshold);});it("should revert on change threshold without 2FA",asyncfunction(){constinitData=abiCoder.encode(["tuple(address,uint256)"],// Solidity equivalent: Config[[awaitrichWallet.getAddress(),parseEther("1"),]]);consthookAndData=ethers.concat([awaittwofaValidator.getAddress(),initData]);constcallData=account.interface.encodeFunctionData("addHook",[hookAndData,true]);consttx={to: awaitaccount.getAddress(),data: callData};constaddHook=awaitprepareTx(provider,account,tx,awaitpasskeyValidator.getAddress());consttxReceipt=awaitprovider.broadcastTransaction(utils.serializeEip712(addHook),);awaittxReceipt.wait();constnewThreshold=parseEther("1");consttx2={to: awaittwofaValidator.getAddress(),from: awaitaccount.getAddress(),nonce: awaitprovider.getTransactionCount(awaitaccount.getAddress()),gasPrice: awaitprovider.getGasPrice(),chainId: (awaitprovider.getNetwork()).chainId};constsignedTxHash=EIP712Signer.getSignedDigest(tx2);constsignature=ethers.Signature.from(mockSigner.signingKey.sign(signedTxHash)).serialized;consthookData=ethers.AbiCoder.defaultAbiCoder().encode(["bytes","bytes32"],[signature,signedTxHash],);constcallData2=twofaValidator.interface.encodeFunctionData("setThreshold",[newThreshold,hookData]);tx2["data"]=callData2;consttransfer=awaitprepareTx(provider,account,tx2,awaitpasskeyValidator.getAddress(),richWallet);consttxReceipt2=provider.broadcastTransaction(utils.serializeEip712(transfer),);awaitexpect(txReceipt2).to.be.reverted;});it.skip("should transfer twice with 2FA on 2nd transfer above threshold",asyncfunction(){});it.skip("should revert on 2nd transfer above threshold above 2FA",asyncfunction(){});});
📝 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
The text was updated successfully, but these errors were encountered: