-
Notifications
You must be signed in to change notification settings - Fork 10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add ERC-7579 Social Recovery Executor Module (In Progress) #85
base: master
Are you sure you want to change the base?
Add ERC-7579 Social Recovery Executor Module (In Progress) #85
Conversation
test/account/extensions/ERC7579Modules/SocialRecoveryExecutorModule.test.js
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
… recovery cancellation
…ontracts into feature/SocialRecoveryExecutor
contracts/mocks/account/modules/ERC7579ReconfigurableValidatorMock.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
if (_threshold == 0 || _threshold > _guardians.length) { | ||
revert InvalidThreshold(); | ||
} | ||
if (_timelock == 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the rational for accepting 1
but refusing 0
?
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
if (recoveryStart == 0) { | ||
return RecoveryStatus.NotStarted; | ||
} | ||
if (block.timestamp < recoveryStart + _recoveryConfigs[account].timelock) { | ||
return RecoveryStatus.Started; | ||
} | ||
return RecoveryStatus.Ready; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO that is clearer (but I guess its subjective)
if (recoveryStart == 0) { | |
return RecoveryStatus.NotStarted; | |
} | |
if (block.timestamp < recoveryStart + _recoveryConfigs[account].timelock) { | |
return RecoveryStatus.Started; | |
} | |
return RecoveryStatus.Ready; | |
return recoveryStart == 0 | |
? RecoveryStatus.NotStarted | |
: (block.timestamp < recoveryStart + _recoveryConfigs[account].timelock) | |
? RecoveryStatus.Started | |
: RecoveryStatus.Ready; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would personally choose current for readability. I'm not a big fan of nested ternaries, but I agree that is subjective. Let's take the approach more commonly used across OpenZeppelin contracts?, on which I will take your advice for sure
if (recoveryStart == 0) { | ||
return RecoveryStatus.NotStarted; | ||
} | ||
if (block.timestamp < recoveryStart + _recoveryConfigs[account].timelock) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the rational for storing the start, and doing that math when recovering the status versus storing the end (doing the add math when initiating a recovery) ?
I'm not sure if one is clearly better than the other, but we should documetn that choice with regard to the delay changing during the recovery.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, moving the addition from getRecoveryStatus
to startRecovery,
which is less frequently used, would slightly get us some gas. We would also be saving one load on getRecoveryStatus
, as currently it needs to load both the recovery start
and the timelock
. In contrast, with this other approach, it would just need to load the recovery end.
Regarding documenting the decision, under the current approach a change in the timelock during an active recovery pushes the recoveryEnd
further in time. In contrast, with this alternative approach, it would be fixed.
This being said, the account owner can always cancelRecovery
if he wants or needs to, so both approaches are okay for me in this sense, as they don't present anything that wouldn't be achievable with the other. I would pick the cheapest
contracts/account/extensions/ERC7579Modules/SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
/// @param digest The digest to verify the signatures against | ||
function _validateGuardianSignatures( | ||
address account, | ||
GuardianSignature[] calldata guardianSignatures, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what about using bytes[]
instead of GuardianSignature[]
and doing the lookup through
address signer = address(bytes20(guardianSignatures[i][0:20]));
bytes calldata signerSign = guardianSignatures[i][20:];
That would reduce the size of the calldata.
return _recoveryConfigs[account].timelock; | ||
} | ||
|
||
function maxGuardians() public pure virtual returns (uint256) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the rational for having this function ? If someone for some reasons wants to set 100 guardians, would there be any technical issues that would cause this contrains to fail ?
bytes32 digest | ||
) internal view virtual { | ||
// bound `for` cycle | ||
if (guardianSignatures.length > maxGuardians()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why use maxGuardians
here?
The unicity check will fail if guardianSignatures.length
is greater than _recoveryConfigs[account].guardians.length
anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR contains a lot of different visibility and overridability.
There are external, public and internal functions, some of which are virtual, some of which are not. Is there any undocummented logic to it? Note that at OZ we try to only use public virtual
and internal virtual
(and private
)
Thank you for raising the point about visibility and overridability. After further examination and understanding that external functions cannot be called upon inheritance with super, that they don't improve gas costs, and aiming at flexibility for module consumers, I opted to follow the OZ pattern of The only exceptions are:
Update: digest getters also made virtual based on the Ernesto recommendation for flexibility |
c92c965
to
943d665
Compare
contracts/account/extensions/ERC7579Modules/ERC7579SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/ERC7579SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/ERC7579SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/ERC7579SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
contracts/account/extensions/ERC7579Modules/ERC7579SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
function _setTimelock(address account, uint256 timelock) internal virtual { | ||
if (timelock == 0) { | ||
revert InvalidTimelock(); | ||
} | ||
_recoveryConfigs[account].timelock = timelock; | ||
emit TimelockChanged(account, timelock); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An important guarantee of a timelock is that there shouldn't be a way to reset it or change it. The reason is that is usually there to have time in case an attacker gains control of the account. Here, I understand that the attacker having control of the account is essentially game over, but I think the general principle that the timelock shouldn't be accelerated must remain.
Consider taking a look to the logic in Time.sol
/// @notice Adds a new guardian to the account's recovery configuration | ||
/// @dev Only callable by the account itself | ||
/// @param guardian Address of the new guardian | ||
function addGuardian(address guardian) public virtual { | ||
_addGuardian(msg.sender, guardian); | ||
} | ||
|
||
/// @notice Removes a guardian from the account's recovery configuration | ||
/// @dev Only callable by the account itself | ||
/// @param guardian Address of the guardian to remove | ||
function removeGuardian(address guardian) public virtual { | ||
_removeGuardian(msg.sender, guardian); | ||
} | ||
|
||
/// @notice Changes the number of required guardian signatures | ||
/// @dev Only callable by the account itself | ||
/// @param threshold New threshold value | ||
function updateThreshold(uint256 threshold) public virtual { | ||
_setThreshold(msg.sender, threshold); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently, all these 3 are validating that the threshold can be met after the update has been done.
/// _addGuardian
if (_recoveryConfigs[account].guardians.length() >= maxGuardians()) {
revert TooManyGuardians();
}
/// _removeGuardian
if (_recoveryConfigs[account].guardians.length() == _recoveryConfigs[account].threshold) {
revert CannotRemoveGuardian();
}
/// _setThreshold
if (threshold == 0 || threshold > _recoveryConfigs[account].guardians.length()) {
revert InvalidThreshold();
}
In order to keep the code dry, I think it's better to just do the update and have an internal _validateThreshold
that does validate the state is still consistent. Concretely I'm thinking of:
function _validateThreshold(address account) internal view virtual {
uint256 guardians = _recoveryConfigs[account].guardians.length()
uint256 _threshold =_recoveryConfigs[account].threshold
if (guardians < _threshold) revert UnreachableThreshold(account, guardians, _threshold);
}
Then, all these functions become essentially reduced to:
function _addGuardian(address account, address guardian) internal virtual {
if (!_recoveryConfigs[account].guardians.add(guardian)) revert AlreadyGuardian();
_validateThreshold(account);
emit GuardianAdded(account, guardian);
}
function _removeGuardian(address account, address guardian) internal virtual {
if (!_recoveryConfigs[account].guardians.remove(guardian)) revert GuardianNotFound();
_validateThreshold(account);
emit GuardianRemoved(account, guardian);
}
function _setThreshold(address account, uint256 threshold) internal virtual {
_recoveryConfigs[account].threshold = threshold;
_validateThreshold(account);
emit ThresholdChanged(account, threshold);
}
if (guardian == address(0)) { | ||
revert InvalidGuardian(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this check is not required. The user must sign a transaction to set a signer as the address(0)
and the bottom line is that there's very little we can do to protect them to set an unrecognized address as a guardian.
if (guardian == address(0)) { | |
revert InvalidGuardian(); | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, in _validateGuardianSignatures
we're doing:
if (uint160(signer) <= uint160(lastSigner)) {
revert DuplicatedOrUnsortedGuardianSignatures();
}
So we can implicitly exclude the address(0)
by just changing <=
for <=
if (uint160(signer) < uint160(lastSigner)) {
revert DuplicatedOrUnsortedGuardianSignatures();
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we have <
, that removes the duplication check. We could increment the initial lastSigner
from address(0)
if this is a concern. isValidSignatureNow
will always fail on sig from address 0 though since we are using ECDSA.tryRecover
. Don't think we should think too hard here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks to both, as Arr mentioned, <=
is required to check for duplication on the sorted signatures.
About the guardian != address(0)
condition, it's true that it doesn't add any required protection to signature validation, as _validateGuardianSignatures
will definitively revert with InvalidGuardianSignature
anyway, as no one has the corresponding private key to sign with it.
Nonetheless, I put this check on guardian addition mainly as a common practice to protect clients when they pass the ZeroAddress due to human or software error.
If we agree this is a validation we don't typically use on addresses in our codebase, I will remove it for consistency
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, is a good observation that <=
disallows duplication.
Still, I don't think is worth checking that guardian != address(0)
. Agree there could be a human/software error but I consider that out of scope of the library.
/// @notice Adds a new guardian to the account's recovery configuration | ||
/// @param account The ERC-7579 Account to add the guardian to | ||
/// @param guardian Address of the new guardian | ||
function _addGuardian(address account, address guardian) internal virtual { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the signature of both the _addGuardian
and _removeGuardian
functions must allow batch adding/removing guardians natively. It'd be more elegant but it's also opinionated since the account can batch operations. It's just that batching operations at the account level may require extra work than just calling a batched function directly
for (uint256 i = 0; i < signers.length; i++) {
if (!_associatedSigners[account].add(guardians[i]))
revert AlreadyGuardian(account, guardians[i]);
}
emit GuardiansAdded(account, guardians);
address account, | ||
GuardianSignature[] calldata guardianSignatures, | ||
bytes32 digest | ||
) internal view virtual { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be better if this function returns a boolean instead of reverting. This way there can be a public view getter called validateGuardianSignatures
that allows to test if the guardian signatures construction is correct
contracts/account/extensions/ERC7579Modules/ERC7579SocialRecoveryExecutor.sol
Outdated
Show resolved
Hide resolved
…ts, adjusted onInstall param from calldata to memory
…, one-lined digest and nonce usage
contracts/account/README.adoc
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated account readme.adoc with recent changes from pr #87
Add Social Recovery Executor Module for ERC-7579 (In Progress)
Overview
Introduces a Social Recovery Executor Module that implements account recovery functionality following the ERC-7579 standard. The module enables accounts to designate guardians who can collectively initiate and execute recovery operations in case of key loss or compromise.
Key Features
On-going
executionCalldata
in the EIP-712 RECOVERY_TYPEHASH and validating at execution.Note: updated submodules for making use of
EnumerableSet.clear