Skip to content

Add ERC7579SignatureValidator #127

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

Merged
merged 22 commits into from
May 5, 2025
Merged

Conversation

ernestognw
Copy link
Member

@ernestognw ernestognw commented Apr 26, 2025

Description

Before writing some guides about ERC7579, we need this validator and also the executors in #121. The idea is that we write a guide for generalized signer modules with ERC-7913 and use these primitives as as building blocks for extended uses cases like social recovery

@ernestognw ernestognw marked this pull request as ready for review April 26, 2025 20:54
@ernestognw ernestognw requested a review from a team as a code owner April 26, 2025 20:54
@ernestognw ernestognw requested a review from gonzaotc April 26, 2025 20:54
@ernestognw ernestognw changed the base branch from chore/erc7913-is-valid-n-signatures to chore/multisig-nits April 27, 2025 00:36
* in your account that properly side effects of uninstallation. See {AccountERC7579-uninstallModule}.
*/
function onUninstall(bytes calldata) public virtual {
_setSigner(msg.sender, "");
Copy link
Contributor

@gonzaotc gonzaotc May 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that Module.onUninstall can be called directly perpetually by the Account, even if the module is not installed. In contrast, Module.onInstall reverts if the signer is already set, given the signer(msg.sender).length == 0 requirement included.

Should we also consider adding a valid signer set requirement at uninstallation and throw ERC7579SignatureValidatorUninstalled for consistency between both processes?

UPDATE:

After checking ERC-7579, it doesn't seem to enforce, either put the responsibility on Modules to revert installation if the module is already installed, the same logic applying for uninstallation. Instead, it states that it is the Account that MUST revert during Account.installModule and Account.uninstallModule, which we are correctly doing already in our AccountERC7579 implementation.

Therefore, the extra check at Module.onInstall has some degree of redundancy, but I understand that it fulfills the role of restricting the account from directly calling Module.onInstall indefinitely afterwards to arbitrarily change the signer via this means, so I think is okay and necessary to have this requirement here for this purpose, even if it is the Account that should be restricting installation and uninstallation.

Copy link
Contributor

@gonzaotc gonzaotc May 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UPDATE: It leads me to think we shouldn't have two separate sources of truth determining if the module is installed or not at a given moment, and that these two sources should be synced perpetually it they do coexist, otherwise it would lead to invalid states between Module and Account, where one considers the module is installed in the Account, while the other doesn't.

An example of this division is the current implementation, where the Module is restricting Module.onInstall if it has a signer already set, thus it is using it's own state to determine if the Module is installed, while the Account determines the module is installed if it's registered on it's storage. Therefore, if the Account calls directly Module.onInstall by human error, the Module get's into an state where it considers itself to be installed, but the Account considers it's not.

Going further, If the Account does this direct Module.onInstall call, (which should not be possible in an ideal world), it doesn't mean that the module is installed in the standard ERC-7579 procedure, and therefore, the module should still be installable by the standard procedure in my opinion, it should not be blocked for installation. This means, that Module.onInstall should still be callable and succeed via Account.installModule.

Given the ERC design, I think the correct source of truth for this means should be the Account, and therefore, if the module isn't registered as installed into it's storage via the standard procedure, we shouldn't block the Module.onInstall, since it leads to this un-synced states between Account and Module.

Since we cannot freely leave Module.onInstall without any restriction, given the Account could potentially use it posteriorly to modify the signer arbitrarily, I propose to make Module.onInstall depend directly on Account storage to determine if the module is installed.

function onInstall(bytes calldata data) public virtual {
        require(
            !IERC7579ModuleConfig(msg.sender).isModuleInstalled(
                MODULE_TYPE_VALIDATOR,
                address(this),
                ""
            ),
            ERC7579SignatureValidatorAlreadyInstalled()
        );
        setSigner(data);
    }

This approach provides some warranties:

  1. If the Account incorrectly calls directly Module.onInstall, it doesn't block the standard installation of the module via Account.installModule. (Not possible right now)
  2. After standard installation, the Account is no longer able to directly call Module.onInstall and use it to modify the signer arbitrarily.

Same strategy can be applied to Module.onUninstall:

function onUninstall(bytes calldata) public virtual {
        require(
            IERC7579ModuleConfig(msg.sender).isModuleInstalled(
                MODULE_TYPE_VALIDATOR,
                address(this),
                ""
            ),
            ERC7579SignatureValidatorNotInstalled()
        );
        _setSigner(msg.sender, "");
    }

However, this approach would require a minor change in AccountERC7579 to work correctly, specifically, in _installModule and _uninstallModule. We should call first Module.onInstall, and then registry the module in the Account storage, not in the opposite order.

Note the current order:

function _installModule(uint256 moduleTypeId, address module, bytes memory initData) internal virtual {
        require(supportsModule(moduleTypeId), ERC7579Utils.ERC7579UnsupportedModuleType(moduleTypeId));
        require(
            IERC7579Module(module).isModuleType(moduleTypeId),
            ERC7579Utils.ERC7579MismatchedModuleTypeId(moduleTypeId, module)
        );

        if (moduleTypeId == MODULE_TYPE_VALIDATOR) {
            require(_validators.add(module), ERC7579Utils.ERC7579AlreadyInstalledModule(moduleTypeId, module));
        } else if (moduleTypeId == MODULE_TYPE_EXECUTOR) {
            require(_executors.add(module), ERC7579Utils.ERC7579AlreadyInstalledModule(moduleTypeId, module));
        } else if (moduleTypeId == MODULE_TYPE_FALLBACK) {
            bytes4 selector;
            (selector, initData) = _decodeFallbackData(initData);
            require(
                _fallbacks[selector] == address(0),
                ERC7579Utils.ERC7579AlreadyInstalledModule(moduleTypeId, module)
            );
            _fallbacks[selector] = module;
        }

        IERC7579Module(module).onInstall(initData);
        emit ModuleInstalled(moduleTypeId, module);
    }

Let me know what you think

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When designing contracts, there is always a spectrum of deciding between efficiency and simplicity vs trying to ensure users don't shoot themselves in the foot. Our running assumption is that users won't be calling onInstall and onUninstall directly on modules--but if they do, that's their choice to do so.

Again, it's quite hard to draw the line, but IMO, we shouldn't make the onInstall and onUninstall flow overly complex or add another external call for this protection.

Comment on lines 12 to 16
it('handles installation and uninstallation', async function () {
await expect(this.mockFromAccount.onInstall(this.installData || '0x')).to.not.be.reverted;
await expect(this.mockFromAccount.onUninstall(this.uninstallData || '0x')).to.not.be.reverted;
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A module should be reinstallable

Suggested change
it('handles installation and uninstallation', async function () {
await expect(this.mockFromAccount.onInstall(this.installData || '0x')).to.not.be.reverted;
await expect(this.mockFromAccount.onUninstall(this.uninstallData || '0x')).to.not.be.reverted;
});
});
it('handles installation, uninstallation and re-installation', async function () {
await expect(this.mockFromAccount.onInstall(this.installData || '0x')).to.not.be.reverted;
await expect(this.mockFromAccount.onUninstall(this.uninstallData || '0x')).to.not.be.reverted;
await expect(this.mockFromAccount.onInstall(this.installData || '0x')).to.not.be.reverted;
});
});

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't successful execution implicit? Why add .to.not.be.reverted

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it should behave equally, as the test would throw if any of its functions throw. However, IMO current version is more declarative and explicit about the intended behavior, I personally like it more

Comment on lines 79 to 81
* IMPORTANT: A signer will be set for the calling account. In case the account calls this function
* directly, the signer will be set to the provided data even if the account didn't track
* the module's installation. Future installations will revert.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little confusing to me. Couldn't we just say an account can only call onInstall once?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO is not only that Module.onInstall can't be called twice, but also that it SHOULDN'T be called in other context than the standard module installation procedure (Account.installModule calling Module.onInstall) since non-standard installation (the Account calling Module.onInstall directly), has non desirable side effects (blocks posterior attempts of standard installation). I think the current warning fulfills this purpose

Copy link
Member Author

@ernestognw ernestognw May 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Aryeh that it can be simplified. Let's do:

Suggested change
* IMPORTANT: A signer will be set for the calling account. In case the account calls this function
* directly, the signer will be set to the provided data even if the account didn't track
* the module's installation. Future installations will revert.
* IMPORTANT: An account can only call onInstall once. If called directly by the account,
* the signer will be set to the provided data. Future installations will revert.

* in your account that properly side effects of uninstallation. See {AccountERC7579-uninstallModule}.
*/
function onUninstall(bytes calldata) public virtual {
_setSigner(msg.sender, "");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When designing contracts, there is always a spectrum of deciding between efficiency and simplicity vs trying to ensure users don't shoot themselves in the foot. Our running assumption is that users won't be calling onInstall and onUninstall directly on modules--but if they do, that's their choice to do so.

Again, it's quite hard to draw the line, but IMO, we shouldn't make the onInstall and onUninstall flow overly complex or add another external call for this protection.

@arr00
Copy link
Collaborator

arr00 commented May 2, 2025

Would a social recovery module inherit from this? I assume not given the architecture enforces a 1:1 account to signer.

@ernestognw
Copy link
Member Author

Would a social recovery module inherit from this? I assume not given the architecture enforces a 1:1 account to signer.

Correct, a social recovery module won't inherit from this. We agreed such module should be an executor instead of a validator but we need a generic validator and a guide before the executors.

In the future we can explore ERC7913 variants of validators if we have bandwidth

@ernestognw ernestognw changed the base branch from chore/multisig-nits to master May 2, 2025 19:55
@gonzaotc
Copy link
Contributor

gonzaotc commented May 2, 2025

Proposed a decomposition of ERC7579SignatureValidator in ERC7579Module and ERC7579ValidatorModule at #133

gonzaotc
gonzaotc previously approved these changes May 5, 2025
@ernestognw ernestognw merged commit dd628d5 into master May 5, 2025
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants