From 361dbbf429974bd5eeb1ef6877c2ab10f6e060d8 Mon Sep 17 00:00:00 2001 From: Roman Petriv Date: Sat, 11 Nov 2023 04:10:59 +0200 Subject: [PATCH] feat: add AA example for multi owner + restrictions --- README.md | 5 + .../SharedAccountWithRestrictions.sol | 276 ++++++++++++++++++ .../SharedAccountWithRestrictionsFactory.sol | 32 ++ .../TestContract.sol | 16 + ...deploy-shared-account-with-restrictions.ts | 16 + package.json | 3 +- test/shared-account-with-restrictions.test.ts | 133 +++++++++ test/utils.ts | 22 ++ utils.ts | 36 +++ 9 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 contracts/SharedAccountWithRestrictions/SharedAccountWithRestrictions.sol create mode 100644 contracts/SharedAccountWithRestrictions/SharedAccountWithRestrictionsFactory.sol create mode 100644 contracts/SharedAccountWithRestrictions/TestContract.sol create mode 100644 deploy/deploy-shared-account-with-restrictions.ts create mode 100644 test/shared-account-with-restrictions.test.ts create mode 100644 utils.ts diff --git a/README.md b/README.md index 4effc16..6358cd4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ # Account Abstraction Examples This repo contains examples for account abstraction on zkSync. + +### SharedAccountWithRestrictions + +The idea is that I as an admin need to create some accounts that have some limitations in terms of what the owners of these accounts should be able to do with them. `SharedAccountWithRestrictions` allows admin to create such account and specify which contracts and which methods of these contracts the owners of the account will be able to call. Admin can add/remove restrictions whenever it is needed. +Additionally `SharedAccountWithRestrictions` allows admin to set more than 1 owner per account. This allows many people to use a single account simultaneously. Let's say I need to provide an account for testing with limited functionality to my entire team, there is no need to create a separate account for everyone and keep each of them funded. `SharedAccountWithRestrictions` allows me to create one account and just set multiple owners. Admin can add/remove owners whenever it is needed. diff --git a/contracts/SharedAccountWithRestrictions/SharedAccountWithRestrictions.sol b/contracts/SharedAccountWithRestrictions/SharedAccountWithRestrictions.sol new file mode 100644 index 0000000..61865f8 --- /dev/null +++ b/contracts/SharedAccountWithRestrictions/SharedAccountWithRestrictions.sol @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IAccount.sol"; +import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol"; +import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/SystemContractsCaller.sol"; +import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol"; + +import "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +bytes4 constant EIP1271_SUCCESS_RETURN_VALUE = 0x1626ba7e; + +contract SharedAccountWithRestrictions is IAccount, IERC1271, IERC721Receiver { + using TransactionHelper for Transaction; + + address private admin; + address[] private owners; + address[] private allowedCallAddresses; + mapping(address => bytes4[]) public callAddressAllowedMethodSelectors; + + modifier onlyBootloader() { + require( + msg.sender == BOOTLOADER_FORMAL_ADDRESS, + "Only bootloader can call this function" + ); + // Continue execution if called from the bootloader. + _; + } + + modifier onlyAdmin() { + require( + msg.sender == admin, + "Forbidden" + ); + // Continue execution if called from the admin. + _; + } + + constructor(address _admin) { + admin = _admin; + } + + function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) { + return IERC721Receiver.onERC721Received.selector; + } + + function addOwner(address owner) external onlyAdmin { + owners.push(owner); + } + + function deleteOwners() external onlyAdmin { + delete owners; + } + + function getOwners() public view returns (address[] memory) { + return owners; + } + + function addAllowedCallAddress(address allowedCallAddress, bytes4[] memory allowedMethodSelectors) external onlyAdmin { + allowedCallAddresses.push(allowedCallAddress); + callAddressAllowedMethodSelectors[allowedCallAddress] = allowedMethodSelectors; + } + + function clearAllowedCallAddresses() external onlyAdmin { + for (uint i = 0; i < allowedCallAddresses.length; i++) { + delete callAddressAllowedMethodSelectors[allowedCallAddresses[i]]; + } + delete allowedCallAddresses; + } + + function getAllowedCallAddresses() public view returns (address[] memory) { + return allowedCallAddresses; + } + + function isAllowedCallAddress(address addr) private view returns (bool) { + for (uint i = 0; i < allowedCallAddresses.length; i++) { + if (allowedCallAddresses[i] == addr) { + return true; + } + } + return false; + } + + function isAllowedCallAddressMethodSelector(address addr, bytes4 methodSelector) private view returns (bool) { + if (!isAllowedCallAddress(addr)) { + return false; + } + // empty array of method selectors means every method is allowed + if (callAddressAllowedMethodSelectors[addr].length == 0) { + return true; + } + for (uint i = 0; i < callAddressAllowedMethodSelectors[addr].length; i++) { + if (callAddressAllowedMethodSelectors[addr][i] == methodSelector) { + return true; + } + } + return false; + } + + function validateTransaction( + bytes32, + bytes32 _suggestedSignedHash, + Transaction calldata _transaction + ) external payable override onlyBootloader returns (bytes4 magic) { + return _validateTransaction(_suggestedSignedHash, _transaction); + } + + function _validateTransaction( + bytes32 _suggestedSignedHash, + Transaction calldata _transaction + ) internal returns (bytes4) { + // Incrementing the nonce of the account. + SystemContractsCaller.systemCallWithPropagatedRevert( + uint32(gasleft()), + address(NONCE_HOLDER_SYSTEM_CONTRACT), + 0, + abi.encodeCall(INonceHolder.incrementMinNonceIfEquals, (_transaction.nonce)) + ); + + bytes32 txHash; + // While the suggested signed hash is usually provided, it is generally + // not recommended to rely on it to be present, since in the future + // there may be tx types with no suggested signed hash. + if (_suggestedSignedHash == bytes32(0)) { + txHash = _transaction.encodeHash(); + } else { + txHash = _suggestedSignedHash; + } + + // The fact there is enough balance for the account + // should be checked explicitly to prevent user paying for fee for a + // transaction that wouldn't be included on Ethereum. + uint256 totalRequiredBalance = _transaction.totalRequiredBalance(); + require(totalRequiredBalance <= address(this).balance, "Not enough balance for fee + value"); + + if (isValidSignature(txHash, _transaction.signature) != EIP1271_SUCCESS_RETURN_VALUE) { + return bytes4(0); + } + + address to = address(uint160(_transaction.to)); + bytes4 methodSelector = bytes4(_transaction.data[0:4]); + if (!isAllowedCallAddressMethodSelector(to, methodSelector)) { + return bytes4(0); + } + + return ACCOUNT_VALIDATION_SUCCESS_MAGIC; + } + + function executeTransaction( + bytes32, + bytes32, + Transaction calldata _transaction + ) external payable override onlyBootloader { + _executeTransaction(_transaction); + } + + function _executeTransaction(Transaction calldata _transaction) internal { + address to = address(uint160(_transaction.to)); + uint128 value = Utils.safeCastToU128(_transaction.value); + bytes memory data = _transaction.data; + + if (to == address(DEPLOYER_SYSTEM_CONTRACT)) { + uint32 gas = Utils.safeCastToU32(gasleft()); + + // Note, that the deployer contract can only be called + // with a "systemCall" flag. + SystemContractsCaller.systemCallWithPropagatedRevert(gas, to, value, data); + } else { + bool success; + assembly { + success := call(gas(), to, value, add(data, 0x20), mload(data), 0, 0) + } + require(success); + } + } + + function executeTransactionFromOutside(Transaction calldata) + external + payable + { + revert("executeTransactionFromOutside is not implemented"); + } + + function isValidSignature(bytes32 _hash, bytes memory _signature) + public + view + override + returns (bytes4) + { + if(!checkValidECDSASignatureFormat(_signature)) { + return bytes4(0); + } + + (address recoveredAddr, ECDSA.RecoverError error) = ECDSA.tryRecover(_hash, _signature); + if (error != ECDSA.RecoverError.NoError) { + return bytes4(0); + } + + // check if signer is one of the owners + for (uint i = 0; i < owners.length; i++) { + if (owners[i] == recoveredAddr) { + return EIP1271_SUCCESS_RETURN_VALUE; + } + } + return bytes4(0); + } + + // This function verifies that the ECDSA signature is both in correct format and non-malleable + function checkValidECDSASignatureFormat(bytes memory _signature) internal pure returns (bool) { + if(_signature.length != 65) { + return false; + } + + uint8 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 mask + assembly { + r := mload(add(_signature, 0x20)) + s := mload(add(_signature, 0x40)) + v := and(mload(add(_signature, 0x41)), 0xff) + } + if(v != 27 && v != 28) { + return false; + } + + // 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) { + return false; + } + + return true; + } + + function payForTransaction( + bytes32, + bytes32, + Transaction calldata _transaction + ) external payable override onlyBootloader { + bool success = _transaction.payToTheBootloader(); + require(success, "Failed to pay the fee to the operator"); + } + + function prepareForPaymaster( + bytes32, // _txHash + bytes32, // _suggestedSignedHash + Transaction calldata _transaction + ) external payable override onlyBootloader { + _transaction.processPaymasterInput(); + } + + fallback() external { + // fallback of default account shouldn't be called by bootloader under no circumstances + assert(msg.sender != BOOTLOADER_FORMAL_ADDRESS); + + // If the contract is called directly, behave like an EOA + } + + receive() external payable { + // If the contract is called directly, behave like an EOA. + // Note, that is okay if the bootloader sends funds with no calldata as it may be used for refunds/operator payments + } +} diff --git a/contracts/SharedAccountWithRestrictions/SharedAccountWithRestrictionsFactory.sol b/contracts/SharedAccountWithRestrictions/SharedAccountWithRestrictionsFactory.sol new file mode 100644 index 0000000..1b44c9f --- /dev/null +++ b/contracts/SharedAccountWithRestrictions/SharedAccountWithRestrictionsFactory.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol"; +import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/SystemContractsCaller.sol"; + +contract SharedAccountWithRestrictionsFactory { + bytes32 public aaBytecodeHash; + + constructor(bytes32 _aaBytecodeHash) { + aaBytecodeHash = _aaBytecodeHash; + } + + function deployAccount( + bytes32 salt, + address admin + ) external returns (address accountAddress) { + (bool success, bytes memory returnData) = SystemContractsCaller + .systemCallWithReturndata( + uint32(gasleft()), + address(DEPLOYER_SYSTEM_CONTRACT), + uint128(0), + abi.encodeCall( + DEPLOYER_SYSTEM_CONTRACT.create2Account, + (salt, aaBytecodeHash, abi.encode(admin), IContractDeployer.AccountAbstractionVersion.Version1) + ) + ); + require(success, "Deployment failed"); + + (accountAddress) = abi.decode(returnData, (address)); + } +} diff --git a/contracts/SharedAccountWithRestrictions/TestContract.sol b/contracts/SharedAccountWithRestrictions/TestContract.sol new file mode 100644 index 0000000..4a8488b --- /dev/null +++ b/contracts/SharedAccountWithRestrictions/TestContract.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +contract TestContract { + function testFunction1(address caller) public pure returns (address) { + return caller; + } + + function testFunction2() public pure returns (bool) { + return true; + } + + function testFunction3() public pure returns (bool) { + return true; + } +} diff --git a/deploy/deploy-shared-account-with-restrictions.ts b/deploy/deploy-shared-account-with-restrictions.ts new file mode 100644 index 0000000..9cf4f59 --- /dev/null +++ b/deploy/deploy-shared-account-with-restrictions.ts @@ -0,0 +1,16 @@ +import { Wallet, Provider } from "zksync-web3"; +import { HardhatRuntimeEnvironment, HttpNetworkConfig } from "hardhat/types"; +import { Deployer } from "@matterlabs/hardhat-zksync-deploy"; +import { deployAccountAbstraction } from "../utils"; + +import dotenv from "dotenv"; +dotenv.config(); + +const KEY = process.env.PRIVATE_KEY as string; + +export default async function (hre: HardhatRuntimeEnvironment) { + const provider = new Provider({ url: (hre.network.config as HttpNetworkConfig).url }); + const wallet = new Wallet(KEY).connect(provider); + const deployer = new Deployer(hre, wallet); + await deployAccountAbstraction(deployer, "SharedAccountWithRestrictionsFactory", "SharedAccountWithRestrictions"); +} diff --git a/package.json b/package.json index af963b3..fbbd1c5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "test": "hardhat test --network hardhat", "deploy:factory": "hardhat deploy-zksync --script deploy-factory.ts --network zkSyncTestnet", - "deploy:multisig": "hardhat deploy-zksync --script deploy-multisig.ts --network zkSyncTestnet" + "deploy:multisig": "hardhat deploy-zksync --script deploy-multisig.ts --network zkSyncTestnet", + "deploy:shared-account-with-restrictions": "hardhat deploy-zksync --script deploy-shared-account-with-restrictions.ts --network zkSyncTestnet" }, "devDependencies": { "@types/chai": "^4.3.10", diff --git a/test/shared-account-with-restrictions.test.ts b/test/shared-account-with-restrictions.test.ts new file mode 100644 index 0000000..69a9f03 --- /dev/null +++ b/test/shared-account-with-restrictions.test.ts @@ -0,0 +1,133 @@ +import "@matterlabs/hardhat-zksync-node/dist/type-extensions"; +import * as hre from "hardhat"; +import { ethers, BigNumber } from "ethers"; +import * as zks from "zksync-web3"; +import { Deployer } from "@matterlabs/hardhat-zksync-deploy"; +import { expectThrowsAsync, fundAccount } from "./utils"; +import { deployAccountAbstraction, deployContract } from "../utils"; + +const config = { + firstWalletPrivateKey: "0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110", + firstWalletAddress: "0x36615Cf349d7F6344891B1e7CA7C72883F5dc049", +}; + +async function sendAATransaction( + tx: ethers.PopulatedTransaction, + aaContractAddress: string, + provider: zks.Provider, + signer: zks.Wallet, + customGasLimit?: BigNumber +) { + tx.from = aaContractAddress; + const gasLimit = customGasLimit || await provider.estimateGas(tx); + const gasPrice = await provider.getGasPrice(); + tx = { + ...tx, + gasLimit, + gasPrice, + chainId: (await provider.getNetwork()).chainId, + nonce: await provider.getTransactionCount(aaContractAddress), + type: 113, + customData: { + gasPerPubdata: zks.utils.DEFAULT_GAS_PER_PUBDATA_LIMIT + } as zks.types.Eip712Meta, + value: BigNumber.from(0), + }; + const signedTxHash = zks.EIP712Signer.getSignedDigest(tx); + const signature = ethers.utils.joinSignature(signer._signingKey().signDigest(signedTxHash)); + tx.customData = { + ...tx.customData, + customSignature: signature, + }; + const aaTx = await provider.sendTransaction(zks.utils.serialize(tx)); + await aaTx.wait(); +} + +describe("Shared account with restrictions", function () { + let provider: zks.Provider; + let admin: zks.Wallet; + + before(async function () { + provider = new zks.Provider(hre.network.config.url); + admin = new zks.Wallet(config.firstWalletPrivateKey, provider); + }); + + // single test just to showcase how it works + it("verifies account works as expected", async () => { + const deployer = new Deployer(hre, admin); + + // Deploy AA contract + const aaContract = await deployAccountAbstraction(deployer, "SharedAccountWithRestrictionsFactory", "SharedAccountWithRestrictions"); + + // Fund the AA contract + await fundAccount(admin, aaContract.address, "2"); + + // Deploy some test contracts + const testContract1 = await deployContract(deployer, "TestContract", []); + const testContract2 = await deployContract(deployer, "TestContract", []); + const testContract3 = await deployContract(deployer, "TestContract", []); + + // add owners + const owner1 = zks.Wallet.createRandom(); + const owner2 = zks.Wallet.createRandom(); + await (await aaContract.addOwner(owner1.address)).wait(); + await (await aaContract.addOwner(owner2.address)).wait(); + + // add allowed call addresses and methods + await (await aaContract.addAllowedCallAddress(testContract1.address, [ + testContract1.interface.getSighash('testFunction1') + ])).wait(); + await (await aaContract.addAllowedCallAddress(testContract2.address, [ + testContract2.interface.getSighash('testFunction2') + ])).wait(); + + // try to use account with some random wallet + const randomWallet = zks.Wallet.createRandom(); + let txWithRandomSigner = await testContract1.populateTransaction.testFunction1(randomWallet.address) + const sendTxSignedByRandomWallet = () => sendAATransaction( + txWithRandomSigner, + aaContract.address, + provider, + randomWallet + ) + await expectThrowsAsync(sendTxSignedByRandomWallet, "Account validation error"); + + // use account with owner wallet and allowed contract method + // should not throw + let txWithOwner1Signer = await testContract1.populateTransaction.testFunction1(owner1.address) + await sendAATransaction( + txWithOwner1Signer, + aaContract.address, + provider, + owner1 + ); + // should not throw + let txWithOwner2Signer = await testContract2.populateTransaction.testFunction2() + await sendAATransaction( + txWithOwner2Signer, + aaContract.address, + provider, + owner2 + ); + + // try to use account with owner wallet and not allowed call address + let txWithNotAllowedCallAddress = await testContract3.populateTransaction.testFunction1(owner1.address) + const sendTxWithNotAllowedCallAddress = () => sendAATransaction( + txWithNotAllowedCallAddress, + aaContract.address, + provider, + owner1 + ) + await expectThrowsAsync(sendTxWithNotAllowedCallAddress, "Account validation error"); + + // try to use account with owner wallet and allowed call address but not allowed method + let txWithNotAllowedMethod = await testContract1.populateTransaction.testFunction3() + const sendTxWithNotAllowedMethod = () => sendAATransaction( + txWithNotAllowedMethod, + aaContract.address, + provider, + owner1 + ) + await expectThrowsAsync(sendTxWithNotAllowedMethod, "Account validation error"); + }); +}); diff --git a/test/utils.ts b/test/utils.ts index e7a2278..b0b4222 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -2,6 +2,7 @@ import { utils, Wallet, Provider, types, EIP712Signer } from "zksync-web3"; import * as hre from "hardhat"; import { ethers } from "ethers"; import { Deployer } from "@matterlabs/hardhat-zksync-deploy"; +import { expect } from "chai"; export async function deployFactory(wallet: Wallet, accountContractName: string, accountFactoryContractName: string) { const deployer = new Deployer(hre, wallet); @@ -118,6 +119,27 @@ export class MultiSigWallet extends Wallet { } } +export async function expectThrowsAsync( + // eslint-disable-next-line @typescript-eslint/ban-types + method: Function, + errorMessage: string +): Promise { + let error = null; + try { + await method(); + } catch (err) { + error = err; + } + + expect(error).to.be.an("Error"); + if (errorMessage) { + expect((error as unknown as Error).message).to.include(errorMessage); + return (error as unknown as Error).message; + } + + return ""; +} + function createMockAddress(base: string) { const baseHex = base.replace(/[^0-9A-Fa-f]/g, ''); // Remove non-hex characters const paddingLength = 40 - baseHex.length; // Calculate padding length diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..c6ead40 --- /dev/null +++ b/utils.ts @@ -0,0 +1,36 @@ +import { Contract, utils } from "zksync-web3"; +import { Deployer } from "@matterlabs/hardhat-zksync-deploy"; +import { ethers } from "ethers"; + +export async function deployContract(deployer: Deployer, contractName: string, args: any[] = [], additionalFactoryDeps: string[] = []): Promise { + const artifact = await deployer.loadArtifact(contractName); + const deploymentFee = await deployer.estimateDeployFee(artifact, args); + const parsedFee = ethers.utils.formatEther(deploymentFee.toString()); + console.log(`The ${artifact.contractName} deployment is estimated to cost ${parsedFee} ETH`); + const contract = await deployer.deploy(artifact, args, undefined, additionalFactoryDeps); + logBlue(`${artifact.contractName} was deployed to ${contract.address}`); + return contract; +} + +export async function deployAccountAbstraction(deployer: Deployer, factoryContractName: string, accountContractName: string): Promise { + const aaArtifact = await deployer.loadArtifact(accountContractName); + const bytecodeHash = utils.hashBytecode(aaArtifact.bytecode); + const aaFactoryContract = await deployContract(deployer, factoryContractName, [bytecodeHash], [aaArtifact.bytecode]); + const salt = ethers.utils.hexlify(ethers.utils.randomBytes(32)); + // using deployer address as account admin + let tx = await aaFactoryContract.deployAccount(salt, deployer.zkWallet.address); + await tx.wait(); + const abiCoder = new ethers.utils.AbiCoder(); + const accountAbstractionAddress = utils.create2Address( + aaFactoryContract.address, + await aaFactoryContract.aaBytecodeHash(), + salt, + abiCoder.encode(["address"], [deployer.zkWallet.address]) + ); + logBlue(`${aaArtifact.contractName} was deployed to ${accountAbstractionAddress}`); + return new ethers.Contract(accountAbstractionAddress, aaArtifact.abi, deployer.zkWallet); +} + +function logBlue(value: string) { + console.log('\x1b[36m%s\x1b[0m', value); +} \ No newline at end of file