Skip to content
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

feat: add allowlist module #178

Merged
merged 1 commit into from
Sep 26, 2024
Merged

feat: add allowlist module #178

merged 1 commit into from
Sep 26, 2024

Conversation

adamegyed
Copy link
Contributor

Motivation

We want the ability to apply an allowlist to specific validations on an account.

Solution

Port over the Allowlist module from the RI. This checks addresses and optionally also checks selectors. It does not check any additional portions of calldata.

@adamegyed adamegyed requested a review from a team September 16, 2024 18:34
Copy link

octane-security-app-dev bot commented Sep 16, 2024

Summary by Octane

New Contracts

  • AllowlistModule.sol: This smart contract defines an "Allowlist Module" that enables setting and enforcing allowlists for address and function selectors to validate function calls within IModularAccount.execute and IModularAccount.executeBatch.

Updated Contracts

  • CustomValidationTestBase.sol: Added a hook _beforeInstallStep before account setup in CustomValidationTestBase.

🔗 Commit Hash: 4afc628

Copy link

github-actions bot commented Sep 16, 2024

Contract sizes:

 | Contract                     | Size (B) | Margin (B) |
 |------------------------------|----------|------------|
 | AccountFactory               |    4,169 |     20,407 |
+| AllowlistModule              |    5,817 |     18,759 |
 | ERC20TokenLimitModule        |    4,138 |     20,438 |
 | ModularAccount               |   25,983 |     -1,407 |
 | SemiModularAccount           |   26,487 |     -1,911 |
 | SingleSignerValidationModule |    3,444 |     21,132 |
 | TokenReceiverModule          |    2,189 |     22,387 |

Code coverage:

File % Lines % Statements % Branches % Funcs
src/account/AccountExecutor.sol 100.00% (4/4) 100.00% (4/4) 100.00% (1/1) 100.00% (1/1)
src/account/AccountStorageInitializable.sol 84.21% (16/19) 84.62% (22/26) 60.00% (3/5) 100.00% (2/2)
src/account/BaseAccount.sol 83.33% (5/6) 80.00% (4/5) 50.00% (1/2) 100.00% (2/2)
src/account/ModularAccount.sol 92.92% (223/240) 93.46% (300/321) 82.50% (33/40) 97.44% (38/39)
src/account/ModularAccountView.sol 96.55% (28/29) 95.24% (40/42) 100.00% (2/2) 100.00% (2/2)
src/account/ModuleManagerInternals.sol 84.92% (107/126) 84.62% (143/169) 42.86% (6/14) 100.00% (11/11)
src/account/SemiModularAccount.sol 0.00% (0/50) 0.00% (0/66) 0.00% (0/9) 0.00% (0/17)
src/factory/AccountFactory.sol 34.48% (10/29) 35.00% (14/40) 50.00% (1/2) 18.18% (2/11)
src/libraries/HookConfigLib.sol 47.06% (8/17) 65.62% (21/32) 100.00% (0/0) 66.67% (8/12)
src/libraries/KnownSelectorsLib.sol 100.00% (27/27) 100.00% (60/60) 100.00% (0/0) 100.00% (3/3)
src/libraries/ModuleEntityLib.sol 62.50% (5/8) 45.00% (9/20) 100.00% (0/0) 50.00% (3/6)
src/libraries/SparseCalldataSegmentLib.sol 100.00% (23/23) 100.00% (29/29) 100.00% (5/5) 100.00% (6/6)
src/libraries/ValidationConfigLib.sol 44.44% (8/18) 52.63% (20/38) 100.00% (0/0) 61.54% (8/13)
src/modules/BaseModule.sol 100.00% (11/11) 100.00% (15/15) 100.00% (1/1) 100.00% (2/2)
src/modules/ERC20TokenLimitModule.sol 70.27% (26/37) 76.92% (40/52) 70.00% (7/10) 55.56% (5/9)
src/modules/ModuleEIP712.sol 100.00% (1/1) 100.00% (2/2) 100.00% (0/0) 100.00% (1/1)
src/modules/ReplaySafeWrapper.sol 100.00% (6/6) 100.00% (7/7) 100.00% (0/0) 100.00% (2/2)
src/modules/TokenReceiverModule.sol 70.00% (7/10) 69.23% (9/13) 100.00% (0/0) 28.57% (2/7)
src/modules/permissions/AllowlistModule.sol 76.19% (32/42) 75.00% (42/56) 77.78% (7/9) 41.67% (5/12)
src/modules/validation/SingleSignerValidationModule.sol 88.89% (16/18) 91.30% (21/23) 100.00% (3/3) 88.89% (8/9)
Total 78.09% (563/721) 78.63% (802/1020) 67.96% (70/103) 66.47% (111/167)

This comment was marked as resolved.

Comment on lines +14 to +29
struct AllowlistInit {
address target;
bool hasSelectorAllowlist;
bytes4[] selectors;
}

struct AllowlistEntry {
bool allowed;
bool hasSelectorAllowlist;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I see what you are doing. Address is used as the entry point for both addresses and selectors. It wont support cases where user wants to keep addresses and selectors limits separate.

How about something like this?

    mapping(bytes4 selector => mapping(address target => mapping(address account => bool))))
    ) public selectorAllowList;
     mapping(address target => mapping(bytes4 selector => mapping(address account => bool))))
    ) public contractAllowList;

For wildcard support, we set the wildcard to address(0) or bytes4('').

When checking, we check the following:

selectorAllowList[selector][target] ||
selectorAllowList[selector][address(0)] ||
contractAllowList[target][bytes4('')]

We can avoid contractAllowList[target][selector] by use selectorAllowList as priority.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That could work, but I think it would increase the checking costs in most cases because it needs to cover 2 cases sequentially.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll implement it for now, and optimize later if/when we combine the checking hooks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh wait, this doesn't work - bytes4(0) is a real value, we can't treat it as a wildcard.

Let's keep it as-is for now, and move the address-wildcard restriction to a different PR.

Copy link
Collaborator

Choose a reason for hiding this comment

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

It would require a major refactor almost like a rewrite. Probably slightly easier to review as a new file.

Why cannot we treat bytes4(uint32(0)) as a wildcard?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why cannot we treat bytes4(uint32(0)) as a wildcard?

That is a valid selector - some contracts mine an all-zero selector, see https://www.4byte.directory/signatures/?bytes4_signature=0x00000000

If someone adds that selector to the allowlist, they would accidentally be adding a wildcard, and it may not be obvious if they're just retrieving things from an ABI.

Copy link
Collaborator

Choose a reason for hiding this comment

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

TIL.

We can update the second mapping actually. Better anyways.

mapping(bytes4 selector => mapping(address target => mapping(address account => bool))))
    ) public selectorAllowList;
mapping(address target => mapping(address account => bool))) public contractAllowList;

The second mapping is now basically the wildcard for selector for the contract, since all non-wildcard cases can be handled by the first mapping.

return "alchemy.allowlist-module.0.0.1";
}

function setAllowlistTarget(uint32 entityId, address target, bool allowed, bool hasSelectorAllowlist) public {
Copy link
Collaborator

Choose a reason for hiding this comment

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

For a target with selectors, would two calls required to set it up? Should we add one more input here to accept optional selectors?

Copy link
Collaborator

Choose a reason for hiding this comment

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

What we have in oninstall is does take that into consideration. We probably can reuse the logic there.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I think right now you would need to batch multiple steps together. Technically, the accounts can now directly call onInstall even after the module is already initialized, so they could use it to batch the call, but that's a bit of a clumsy workflow. Do you think we should add a new external function to do the same workflow as onInstall, just named something else? It'd be more intuitive for devs.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you think we should add a new external function to do the same workflow as onInstall, just named something else? It'd be more intuitive for devs.

Yeah, that'd be good. They can call the same internal func under the hood.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added!

}

function setAllowlistSelector(uint32 entityId, address target, bytes4 selector, bool allowed) public {
selectorAllowlist[entityId][target][selector][msg.sender] = allowed;
Copy link
Collaborator

Choose a reason for hiding this comment

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

When this is set for the first time for a target, should we update the hasSelectorAllowlist value intargetAllowlist?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we can't tell whether or not it's the first target, because these mappings are non-enumerable.

Copy link
Collaborator

Choose a reason for hiding this comment

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

In that case, should we do sth like the following?

If (allowed) {
   intargetAllowlist[entityId][target].hasSelectorAllowlist = true;
}

Otherwise, we will have to revert selector direct set for target where the target has hasSelectorAllowlist set to true. Because in this case, this selector wont be checked. So users will have to add selectors through the other set method.

I like the first option better.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we should avoid those extra storage writes - the user can already specify these two things independently. To address this, I kept the individual edit functions (setAllowlistTarget, setAllowlistSelector) in, as well as the new batch init function batchInitAllowlist.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The case I am concerned is.

  • User installs hook to limit a SK to call a contract (addressA).
  • User realizes that they can only allow one selector on addressA for the SK to achieve whatever they want. So they come to call setAllowlistSelector, and think that it would work.
  • It wont work because addressA has hasSelectorAllowlist to be true.

So I think this storage writes is actually worth it in this case.

The only alternative is to educate users/customers to be aware of this, which seems it will be more work.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think if they're on the level of calling individual functions on this contract, rather than using some higher-level abstractions from the SDK, then they need to be comfortable with understanding how the contract works. We shouldn't hamper performance to address a hypothetical user error.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We shouldn't hamper performance to address a hypothetical user error.

The only performance weakness is If (allowed) { this portion. Which is almost nothing.

  • if intargetAllowlist[entityId][target].hasSelectorAllowlist was true, the set costs nothing.
  • if it was originally false, (without setting it to true) the whole method call is wasted as it won't do anything.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the set costs nothing.

This is not true, solidity loads every storage slot before setting to keep other bits the same, and even a warm load or warm set costs something. (ref)

The users sets the config as they wish, including the ability to set it to something that isn't valid or useful. It's not the contract's job to enforce that, that happens on a different level.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh yeah, missed the loading part. Hmm. Need to weigh more.

Anyways, to no block this PR, can you add this behavior to docs as dev notice to warn this case?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a comment to call this out in the natspec, to warn users that this setting will only matter if the target address has the selector allowlist enabled.

@adamegyed adamegyed force-pushed the adam/add-allowlist branch 2 times, most recently from 6273dfe to e54e9a1 Compare September 25, 2024 20:56
@adamegyed adamegyed merged commit 5ac9555 into develop Sep 26, 2024
6 checks passed
@adamegyed adamegyed deleted the adam/add-allowlist branch September 26, 2024 17:42
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.

2 participants