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: [v0.8-develop] Add allowlist sample hook, refactor test base #70

Merged
merged 1 commit into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/account/UpgradeableModularAccount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,15 @@ contract UpgradeableModularAccount is
/// with user install configs.
/// @dev This function is only callable once, and only by the EntryPoint.

function initializeDefaultValidation(FunctionReference validationFunction, bytes calldata installData)
external
initializer
{
_installValidation(validationFunction, true, new bytes4[](0), installData, bytes(""), bytes(""));
function initializeWithValidation(
FunctionReference validationFunction,
bool shared,
bytes4[] memory selectors,
bytes calldata installData,
bytes calldata preValidationHooks,
bytes calldata permissionHooks
) external initializer {
_installValidation(validationFunction, shared, selectors, installData, preValidationHooks, permissionHooks);
emit ModularAccountInitialized(_ENTRY_POINT);
}

Expand Down
142 changes: 142 additions & 0 deletions src/samples/permissionhooks/AllowlistPlugin.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.25;

import {PackedUserOperation} from "@eth-infinitism/account-abstraction/interfaces/PackedUserOperation.sol";

import {PluginMetadata, PluginManifest} from "../../interfaces/IPlugin.sol";
import {IValidationHook} from "../../interfaces/IValidationHook.sol";
import {IStandardExecutor, Call} from "../../interfaces/IStandardExecutor.sol";
import {BasePlugin} from "../../plugins/BasePlugin.sol";

contract AllowlistPlugin is IValidationHook, BasePlugin {
enum FunctionId {
PRE_VALIDATION_HOOK
}

struct AllowlistInit {
address target;
bool hasSelectorAllowlist;
bytes4[] selectors;
}

struct AllowlistEntry {
bool allowed;
bool hasSelectorAllowlist;
}

mapping(address target => mapping(address account => AllowlistEntry)) public targetAllowlist;
mapping(address target => mapping(bytes4 selector => mapping(address account => bool))) public
selectorAllowlist;

error TargetNotAllowed();
error SelectorNotAllowed();
error NoSelectorSpecified();

function onInstall(bytes calldata data) external override {
AllowlistInit[] memory init = abi.decode(data, (AllowlistInit[]));

for (uint256 i = 0; i < init.length; i++) {
targetAllowlist[init[i].target][msg.sender] = AllowlistEntry(true, init[i].hasSelectorAllowlist);

if (init[i].hasSelectorAllowlist) {
for (uint256 j = 0; j < init[i].selectors.length; j++) {
selectorAllowlist[init[i].target][init[i].selectors[j]][msg.sender] = true;
}
}
}
}

function onUninstall(bytes calldata data) external override {
AllowlistInit[] memory init = abi.decode(data, (AllowlistInit[]));

for (uint256 i = 0; i < init.length; i++) {
delete targetAllowlist[init[i].target][msg.sender];

if (init[i].hasSelectorAllowlist) {
for (uint256 j = 0; j < init[i].selectors.length; j++) {
delete selectorAllowlist[init[i].target][init[i].selectors[j]][msg.sender];
}
}
}
}

function setAllowlistTarget(address target, bool allowed, bool hasSelectorAllowlist) external {
targetAllowlist[target][msg.sender] = AllowlistEntry(allowed, hasSelectorAllowlist);
}

function setAllowlistSelector(address target, bytes4 selector, bool allowed) external {
selectorAllowlist[target][selector][msg.sender] = allowed;
}

function preUserOpValidationHook(uint8 functionId, PackedUserOperation calldata userOp, bytes32)
external
view
override
returns (uint256)
{
if (functionId == uint8(FunctionId.PRE_VALIDATION_HOOK)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the point of this check, and the similar one in the runtime validation hook?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In other plugin examples we've been using an if-else chain for the internal function dispatcher, so I was following that pattern here too. I suppose we could cut it and just treat every function id the same here, because this one doesn't have multiple implementations.

Copy link
Collaborator

@fangting-alchemy fangting-alchemy Jun 18, 2024

Choose a reason for hiding this comment

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

Like the idea of not having the functionId, especially the plugins are global singleton.

Actually, we should be explicit since we do ask the functionId var to be passed.

_checkAllowlistCalldata(userOp.callData);
return 0;
}
revert NotImplemented();
}

function preRuntimeValidationHook(uint8 functionId, address, uint256, bytes calldata data, bytes calldata)
external
view
override
{
if (functionId == uint8(FunctionId.PRE_VALIDATION_HOOK)) {
_checkAllowlistCalldata(data);
return;
}

revert NotImplemented();
}

function pluginMetadata() external pure override returns (PluginMetadata memory) {
PluginMetadata memory metadata;
metadata.name = "Allowlist Plugin";
metadata.version = "v0.0.1";
metadata.author = "ERC-6900 Working Group";

return metadata;
}

// solhint-disable-next-line no-empty-blocks
function pluginManifest() external pure override returns (PluginManifest memory) {}
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this blank because it's supposed to only be installed with a user-provided config?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, basically. The manifest is empty because this plugin doesn't define anything that the manifest can install:

  • execution functions
  • execution hooks
  • validation functions
  • signature validators
  • call permissions


function _checkAllowlistCalldata(bytes calldata callData) internal view {
if (bytes4(callData[:4]) == IStandardExecutor.execute.selector) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Callout: when executeUserOp is used, we need to check calldata[4:8] instead

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If this hook doesn't depend on the contents of executeUserOp, then the account shouldn't send the user op, right? I guess this depends on how we do it, but it seems like the account should know whether to use msg.data or userOp.callData depending on the hook config.

Copy link
Collaborator

Choose a reason for hiding this comment

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

yeah agree that it should be abstracted from plugins. in the current permissions stack, we directly send over userOp.callData which could be abi.encodeCall(...) or abi.encodePacked(executeUserOp.selector, abi.encodeCall(...)) for hooks that don't require UO context, and for hooks that require UO context, they would always receive abi.encodePacked(executeUserOp.selector, abi.encodeCall(...)). Dislike that part and would likely change that though

(address target,, bytes memory data) = abi.decode(callData[4:], (address, uint256, bytes));
_checkCallPermission(msg.sender, target, data);
} else if (bytes4(callData[:4]) == IStandardExecutor.executeBatch.selector) {
Call[] memory calls = abi.decode(callData[4:], (Call[]));

for (uint256 i = 0; i < calls.length; i++) {
_checkCallPermission(msg.sender, calls[i].target, calls[i].data);
}
}
}

function _checkCallPermission(address account, address target, bytes memory data) internal view {
AllowlistEntry storage entry = targetAllowlist[target][account];
(bool allowed, bool hasSelectorAllowlist) = (entry.allowed, entry.hasSelectorAllowlist);

if (!allowed) {
revert TargetNotAllowed();
}

if (hasSelectorAllowlist) {
if (data.length < 4) {
revert NoSelectorSpecified();
}

bytes4 selector = bytes4(data);

if (!selectorAllowlist[target][selector][account]) {
revert SelectorNotAllowed();
}
}
}
}
13 changes: 3 additions & 10 deletions test/account/AccountLoupe.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,13 @@ import {FunctionReference, FunctionReferenceLib} from "../../src/helpers/Functio
import {ExecutionHook} from "../../src/interfaces/IAccountLoupe.sol";
import {IPluginManager} from "../../src/interfaces/IPluginManager.sol";
import {IStandardExecutor} from "../../src/interfaces/IStandardExecutor.sol";
import {ISingleOwnerPlugin} from "../../src/plugins/owner/ISingleOwnerPlugin.sol";

import {ComprehensivePlugin} from "../mocks/plugins/ComprehensivePlugin.sol";
import {AccountTestBase} from "../utils/AccountTestBase.sol";

contract AccountLoupeTest is AccountTestBase {
ComprehensivePlugin public comprehensivePlugin;

FunctionReference public ownerValidation;

event ReceivedCall(bytes msgData, uint256 msgValue);

function setUp() public {
Expand All @@ -28,10 +25,6 @@ contract AccountLoupeTest is AccountTestBase {
vm.prank(address(entryPoint));
account1.installPlugin(address(comprehensivePlugin), manifestHash, "", new FunctionReference[](0));

ownerValidation = FunctionReferenceLib.pack(
address(singleOwnerPlugin), uint8(ISingleOwnerPlugin.FunctionId.VALIDATION_OWNER)
);

FunctionReference[] memory preValidationHooks = new FunctionReference[](2);
preValidationHooks[0] = FunctionReferenceLib.pack(
address(comprehensivePlugin), uint8(ComprehensivePlugin.FunctionId.PRE_VALIDATION_HOOK_1)
Expand All @@ -43,7 +36,7 @@ contract AccountLoupeTest is AccountTestBase {
bytes[] memory installDatas = new bytes[](2);
vm.prank(address(entryPoint));
account1.installValidation(
ownerValidation,
_ownerValidation,
true,
new bytes4[](0),
bytes(""),
Expand Down Expand Up @@ -111,7 +104,7 @@ contract AccountLoupeTest is AccountTestBase {
validations = account1.getValidations(account1.execute.selector);

assertEq(validations.length, 1);
assertEq(FunctionReference.unwrap(validations[0]), FunctionReference.unwrap(ownerValidation));
assertEq(FunctionReference.unwrap(validations[0]), FunctionReference.unwrap(_ownerValidation));
}

function test_pluginLoupe_getExecutionHooks() public {
Expand Down Expand Up @@ -152,7 +145,7 @@ contract AccountLoupeTest is AccountTestBase {
}

function test_pluginLoupe_getValidationHooks() public {
FunctionReference[] memory hooks = account1.getPreValidationHooks(ownerValidation);
FunctionReference[] memory hooks = account1.getPreValidationHooks(_ownerValidation);

assertEq(hooks.length, 2);
assertEq(
Expand Down
15 changes: 2 additions & 13 deletions test/account/DefaultValidationTest.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import {PackedUserOperation} from "@eth-infinitism/account-abstraction/interface
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

import {UpgradeableModularAccount} from "../../src/account/UpgradeableModularAccount.sol";
import {FunctionReference, FunctionReferenceLib} from "../../src/helpers/FunctionReferenceLib.sol";
import {ISingleOwnerPlugin} from "../../src/plugins/owner/ISingleOwnerPlugin.sol";

import {AccountTestBase} from "../utils/AccountTestBase.sol";
import {DefaultValidationFactoryFixture} from "../mocks/DefaultValidationFactoryFixture.sol";
Expand All @@ -16,11 +14,6 @@ contract DefaultValidationTest is AccountTestBase {

DefaultValidationFactoryFixture public defaultValidationFactoryFixture;

uint256 public constant CALL_GAS_LIMIT = 50000;
uint256 public constant VERIFICATION_GAS_LIMIT = 1200000;

FunctionReference public ownerValidation;

address public ethRecipient;

function setUp() public {
Expand All @@ -32,10 +25,6 @@ contract DefaultValidationTest is AccountTestBase {

ethRecipient = makeAddr("ethRecipient");
vm.deal(ethRecipient, 1 wei);

ownerValidation = FunctionReferenceLib.pack(
address(singleOwnerPlugin), uint8(ISingleOwnerPlugin.FunctionId.VALIDATION_OWNER)
);
}

function test_defaultValidation_userOp_simple() public {
Expand All @@ -57,7 +46,7 @@ contract DefaultValidationTest is AccountTestBase {
// Generate signature
bytes32 userOpHash = entryPoint.getUserOpHash(userOp);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Key, userOpHash.toEthSignedMessageHash());
userOp.signature = _encodeSignature(ownerValidation, DEFAULT_VALIDATION, abi.encodePacked(r, s, v));
userOp.signature = _encodeSignature(_ownerValidation, DEFAULT_VALIDATION, abi.encodePacked(r, s, v));

PackedUserOperation[] memory userOps = new PackedUserOperation[](1);
userOps[0] = userOp;
Expand All @@ -74,7 +63,7 @@ contract DefaultValidationTest is AccountTestBase {
vm.prank(owner1);
account1.executeWithAuthorization(
abi.encodeCall(UpgradeableModularAccount.execute, (ethRecipient, 1 wei, "")),
_encodeSignature(ownerValidation, DEFAULT_VALIDATION, "")
_encodeSignature(_ownerValidation, DEFAULT_VALIDATION, "")
);

assertEq(ethRecipient.balance, 2 wei);
Expand Down
3 changes: 0 additions & 3 deletions test/account/MultiValidation.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ contract MultiValidationTest is AccountTestBase {
address public owner2;
uint256 public owner2Key;

uint256 public constant CALL_GAS_LIMIT = 50000;
uint256 public constant VERIFICATION_GAS_LIMIT = 1200000;

function setUp() public {
validator2 = new SingleOwnerPlugin();

Expand Down
65 changes: 27 additions & 38 deletions test/account/PerHookData.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,63 +3,28 @@ pragma solidity ^0.8.25;

import {PackedUserOperation} from "@eth-infinitism/account-abstraction/interfaces/PackedUserOperation.sol";
import {IEntryPoint} from "@eth-infinitism/account-abstraction/interfaces/IEntryPoint.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

import {UpgradeableModularAccount} from "../../src/account/UpgradeableModularAccount.sol";
import {ISingleOwnerPlugin} from "../../src/plugins/owner/ISingleOwnerPlugin.sol";
import {FunctionReference, FunctionReferenceLib} from "../../src/helpers/FunctionReferenceLib.sol";

import {MockAccessControlHookPlugin} from "../mocks/plugins/MockAccessControlHookPlugin.sol";
import {Counter} from "../mocks/Counter.sol";
import {AccountTestBase} from "../utils/AccountTestBase.sol";
import {CustomValidationTestBase} from "../utils/CustomValidationTestBase.sol";

contract PerHookDataTest is AccountTestBase {
contract PerHookDataTest is CustomValidationTestBase {
using MessageHashUtils for bytes32;

MockAccessControlHookPlugin internal _accessControlHookPlugin;

Counter internal _counter;

FunctionReference internal _ownerValidation;

uint256 public constant CALL_GAS_LIMIT = 50000;
uint256 public constant VERIFICATION_GAS_LIMIT = 1200000;

function setUp() public {
_counter = new Counter();

_accessControlHookPlugin = new MockAccessControlHookPlugin();

// Write over `account1` with a new account proxy, with different initialization.

address accountImplementation = address(factory.accountImplementation());

account1 = UpgradeableModularAccount(payable(new ERC1967Proxy(accountImplementation, "")));

_ownerValidation = FunctionReferenceLib.pack(
address(singleOwnerPlugin), uint8(ISingleOwnerPlugin.FunctionId.VALIDATION_OWNER)
);

FunctionReference accessControlHook = FunctionReferenceLib.pack(
address(_accessControlHookPlugin), uint8(MockAccessControlHookPlugin.FunctionId.PRE_VALIDATION_HOOK)
);

FunctionReference[] memory preValidationHooks = new FunctionReference[](1);
preValidationHooks[0] = accessControlHook;

bytes[] memory preValidationHookData = new bytes[](1);
// Access control is restricted to only the _counter
preValidationHookData[0] = abi.encode(_counter);

bytes memory packedPreValidationHooks = abi.encode(preValidationHooks, preValidationHookData);

vm.prank(address(entryPoint));
account1.installValidation(
_ownerValidation, true, new bytes4[](0), abi.encode(owner1), packedPreValidationHooks, ""
);

vm.deal(address(account1), 100 ether);
_customValidationSetup();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do the old tests here test allowList fails?
Are the error thrown from AllowlistPlugin captured as AA errors?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, depending on the call path, they throw either user op or runtime errors, and the fuzzer expects them to be called.

The error to look for is loaded from here:

function _getExpectedUserOpError(Call[] memory calls) internal view returns (bytes memory) {
for (uint256 i = 0; i < calls.length; i++) {
Call memory call = calls[i];
(bool allowed, bool hasSelectorAllowlist) =
allowlistPlugin.targetAllowlist(call.target, address(account1));
if (allowed) {
if (
hasSelectorAllowlist
&& !allowlistPlugin.selectorAllowlist(call.target, bytes4(call.data), address(account1))
) {
return abi.encodeWithSelector(
IEntryPoint.FailedOpWithRevert.selector,
0,
"AA23 reverted",
abi.encodeWithSelector(AllowlistPlugin.SelectorNotAllowed.selector)
);
}
} else {
return abi.encodeWithSelector(
IEntryPoint.FailedOpWithRevert.selector,
0,
"AA23 reverted",
abi.encodeWithSelector(AllowlistPlugin.TargetNotAllowed.selector)
);
}
}
return "";
}
function _getExpectedRuntimeError(Call[] memory calls) internal view returns (bytes memory) {
for (uint256 i = 0; i < calls.length; i++) {
Call memory call = calls[i];
(bool allowed, bool hasSelectorAllowlist) =
allowlistPlugin.targetAllowlist(call.target, address(account1));
if (allowed) {
if (
hasSelectorAllowlist
&& !allowlistPlugin.selectorAllowlist(call.target, bytes4(call.data), address(account1))
) {
return abi.encodeWithSelector(
UpgradeableModularAccount.PreRuntimeValidationHookFailed.selector,
address(allowlistPlugin),
uint8(AllowlistPlugin.FunctionId.PRE_VALIDATION_HOOK),
abi.encodeWithSelector(AllowlistPlugin.SelectorNotAllowed.selector)
);
}
} else {
return abi.encodeWithSelector(
UpgradeableModularAccount.PreRuntimeValidationHookFailed.selector,
address(allowlistPlugin),
uint8(AllowlistPlugin.FunctionId.PRE_VALIDATION_HOOK),
abi.encodeWithSelector(AllowlistPlugin.TargetNotAllowed.selector)
);
}
}

And the test expects them here:

vm.expectRevert(expectedRevertData);
vm.expectRevert(expectedRevertData);

}

function test_passAccessControl_userOp() public {
Expand Down Expand Up @@ -358,4 +323,28 @@ contract PerHookDataTest is AccountTestBase {

return (userOp, userOpHash);
}

// Test config

function _initialValidationConfig()
internal
virtual
override
returns (FunctionReference, bool, bytes4[] memory, bytes memory, bytes memory, bytes memory)
{
FunctionReference accessControlHook = FunctionReferenceLib.pack(
address(_accessControlHookPlugin), uint8(MockAccessControlHookPlugin.FunctionId.PRE_VALIDATION_HOOK)
);

FunctionReference[] memory preValidationHooks = new FunctionReference[](1);
preValidationHooks[0] = accessControlHook;

bytes[] memory preValidationHookData = new bytes[](1);
// Access control is restricted to only the counter
preValidationHookData[0] = abi.encode(_counter);

bytes memory packedPreValidationHooks = abi.encode(preValidationHooks, preValidationHookData);

return (_ownerValidation, true, new bytes4[](0), abi.encode(owner1), packedPreValidationHooks, "");
}
}
Loading
Loading