zkSync-Community-Hub / zksync-developers

Project for Discussions with the ZKsync Community.
369 stars 233 forks source link

[Deployment] Deploying multisig smart wallet in the Account abstraction multisig tutorial #660

Open bxpana opened 3 months ago

bxpana commented 3 months ago

Discussed in https://github.com/zkSync-Community-Hub/zksync-developers/discussions/625

Originally posted by **tuyennt550** July 11, 2024 ### Environment Testnet ### zkSolc Version latest ### zksync-ethers Version 6.7.0 ### Hardhat.config.ts ```javascript import { HardhatUserConfig } from "hardhat/config"; import "@matterlabs/hardhat-zksync"; const config: HardhatUserConfig = { defaultNetwork: "zkSyncSepoliaTestnet", networks: { zkSyncSepoliaTestnet: { url: "https://sepolia.era.zksync.dev", ethNetwork: "sepolia", zksync: true, verifyURL: "https://explorer.sepolia.era.zksync.dev/contract_verification", }, zkSyncMainnet: { url: "https://mainnet.era.zksync.io", ethNetwork: "mainnet", zksync: true, verifyURL: "https://zksync2-mainnet-explorer.zksync.io/contract_verification", }, zkSyncGoerliTestnet: { // deprecated network url: "https://testnet.era.zksync.dev", ethNetwork: "goerli", zksync: true, verifyURL: "https://zksync2-testnet-explorer.zksync.dev/contract_verification", }, dockerizedNode: { url: "http://localhost:3050", ethNetwork: "http://localhost:8545", zksync: true, }, inMemoryNode: { url: "http://127.0.0.1:8011", ethNetwork: "localhost", // in-memory node doesn't support eth node; removing this line will cause an error zksync: true, }, hardhat: { zksync: true, }, }, zksolc: { version: "latest", settings: { // find all available options in the official documentation // https://era.zksync.io/docs/tools/hardhat/hardhat-zksync-solc.html#configuration isSystem: true, }, }, solidity: { version: "0.8.17", }, }; export default config; ``` ### Deployment Script (WITHOUT PRIVATE KEY) ```javascript import { utils, Wallet, Provider, EIP712Signer, types } from "zksync-ethers"; import * as ethers from "ethers"; import { HardhatRuntimeEnvironment } from "hardhat/types"; import dotenv from "dotenv"; // Load env file dotenv.config(); const PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY || ""; // Put the address of your AA factory const AA_FACTORY_ADDRESS = "0x14eE2E811547fC3d0eDFb453f0865267a56d6878"; export default async function (hre: HardhatRuntimeEnvironment) { const provider = new Provider(hre.network.config.url); // const provider = new Provider('http://127.0.0.1:8011') // Private key of the account used to deploy const wallet = new Wallet(PRIVATE_KEY).connect(provider); const factoryArtifact = await hre.artifacts.readArtifact("AAFactory"); const aaFactory = new ethers.Contract( AA_FACTORY_ADDRESS, factoryArtifact.abi, wallet, ); // The two owners of the multisig const owner1 = Wallet.createRandom(); const owner2 = Wallet.createRandom(); // For the simplicity of the tutorial, we will use zero hash as salt const salt = ethers.ZeroHash; console.log(salt, owner1.address, owner2.address); // deploy account owned by owner1 & owner2 const tx = await aaFactory.deployAccount( salt, owner1.address, owner2.address, ); await tx.wait(); // Getting the address of the deployed contract account // Always use the JS utility methods const abiCoder = new ethers.AbiCoder(); const multisigAddress = utils.create2Address( AA_FACTORY_ADDRESS, await aaFactory.aaBytecodeHash(), salt, abiCoder.encode(["address", "address"], [owner1.address, owner2.address]), ); console.log(`Multisig account deployed on address ${multisigAddress}`); console.log("Sending funds to multisig account"); // Send funds to the multisig account we just deployed await ( await wallet.sendTransaction({ to: multisigAddress, // You can increase the amount of ETH sent to the multisig value: ethers.parseEther("0.0008"), nonce: await wallet.getNonce(), }) ).wait(); let multisigBalance = await provider.getBalance(multisigAddress); console.log(`Multisig account balance is ${multisigBalance.toString()}`); // Transaction to deploy a new account using the multisig we just deployed let aaTx = await aaFactory.deployAccount.populateTransaction( salt, // These are accounts that will own the newly deployed account Wallet.createRandom().address, Wallet.createRandom().address, ); const gasLimit = await provider.estimateGas({ ...aaTx, from: wallet.address, }); const gasPrice = await provider.getGasPrice(); aaTx = { ...aaTx, // deploy a new account using the multisig from: multisigAddress, gasLimit: gasLimit, gasPrice: gasPrice, chainId: (await provider.getNetwork()).chainId, nonce: await provider.getTransactionCount(multisigAddress), type: 113, customData: { gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT, } as types.Eip712Meta, value: 0n, }; const signedTxHash = EIP712Signer.getSignedDigest(aaTx); // Sign the transaction with both owners const signature = ethers.concat([ ethers.Signature.from(owner1.signingKey.sign(signedTxHash)).serialized, ethers.Signature.from(owner2.signingKey.sign(signedTxHash)).serialized, ]); aaTx.customData = { ...aaTx.customData, customSignature: signature, }; console.log( `The multisig's nonce before the first tx is ${await provider.getTransactionCount( multisigAddress, )}`, ); const sentTx = await provider.broadcastTransaction( types.Transaction.from(aaTx).serialized, ); console.log(`Transaction sent from multisig with hash ${sentTx.hash}`); await sentTx.wait(); // Checking that the nonce for the account has increased console.log( `The multisig's nonce after the first tx is ${await provider.getTransactionCount( multisigAddress, )}`, ); multisigBalance = await provider.getBalance(multisigAddress); console.log(`Multisig account balance is now ${multisigBalance.toString()}`); } ``` ### Package.json ```json { "name": "zksync-hardhat-template", "description": "A template for zkSync smart contracts development with Hardhat", "private": true, "author": "Matter Labs", "license": "MIT", "repository": "https://github.com/matter-labs/zksync-hardhat-template.git", "scripts": { "deploy:factory": "hardhat deploy-zksync --script deploy-factory.ts", "deploy:multisig": "hardhat deploy-zksync --script deploy-multisig.ts", "compile": "hardhat compile", "clean": "hardhat clean", "test": "hardhat test --network hardhat" }, "devDependencies": { "@matterlabs/hardhat-zksync": "^1.0.0", "@matterlabs/zksync-contracts": "^0.6.1", "@nomiclabs/hardhat-etherscan": "^3.1.7", "@openzeppelin/contracts": "^4.6.0", "@types/chai": "^4.3.4", "@types/mocha": "^10.0.1", "chai": "^4.3.7", "dotenv": "^16.0.3", "ethers": "^6.9.2", "hardhat": "^2.12.4", "mocha": "^10.2.0", "ts-node": "^10.9.1", "typescript": "^4.9.5", "zksync-ethers": "^6.7.0" } } ``` ### Contract Code ```solidity // 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 "@openzeppelin/contracts/interfaces/IERC1271.sol"; // Used for signature validation import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; // Access zkSync system contracts for nonce validation via NONCE_HOLDER_SYSTEM_CONTRACT import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol"; // to call non-view function of system contracts import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/SystemContractsCaller.sol"; contract TwoUserMultisig is IAccount, IERC1271 { // to get transaction hash using TransactionHelper for Transaction; // state variables for account owners address public owner1; address public owner2; bytes4 constant EIP1271_SUCCESS_RETURN_VALUE = 0x1626ba7e; modifier onlyBootloader() { require( msg.sender == BOOTLOADER_FORMAL_ADDRESS, "Only bootloader can call this function" ); // Continue execution if called from the bootloader. _; } constructor(address _owner1, address _owner2) { owner1 = _owner1; owner2 = _owner2; } 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 magic) { // Incrementing the nonce of the account. // Note, that reserved[0] by convention is currently equal to the nonce passed in the transaction 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) { magic = ACCOUNT_VALIDATION_SUCCESS_MAGIC; } else { magic = bytes4(0); } } 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 _transaction) external payable { bytes4 magic = _validateTransaction(bytes32(0), _transaction); require(magic == ACCOUNT_VALIDATION_SUCCESS_MAGIC, "NOT VALIDATED"); _executeTransaction(_transaction); } function isValidSignature(bytes32 _hash, bytes memory _signature) public view override returns (bytes4 magic) { magic = EIP1271_SUCCESS_RETURN_VALUE; if (_signature.length != 130) { // Signature is invalid anyway, but we need to proceed with the signature verification as usual // in order for the fee estimation to work correctly _signature = new bytes(130); // Making sure that the signatures look like a valid ECDSA signature and are not rejected rightaway // while skipping the main verification process. _signature[64] = bytes1(uint8(27)); _signature[129] = bytes1(uint8(27)); } (bytes memory signature1, bytes memory signature2) = extractECDSASignature(_signature); if(!checkValidECDSASignatureFormat(signature1) || !checkValidECDSASignatureFormat(signature2)) { magic = bytes4(0); } address recoveredAddr1 = ECDSA.recover(_hash, signature1); address recoveredAddr2 = ECDSA.recover(_hash, signature2); // Note, that we should abstain from using the require here in order to allow for fee estimation to work if(recoveredAddr1 != owner1 || recoveredAddr2 != owner2) { magic = 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 extractECDSASignature(bytes memory _fullSignature) internal pure returns (bytes memory signature1, bytes memory signature2) { require(_fullSignature.length == 130, "Invalid length"); signature1 = new bytes(65); signature2 = new bytes(65); // Copying the first signature. Note, that we need an offset of 0x20 // since it is where the length of the `_fullSignature` is stored assembly { let r := mload(add(_fullSignature, 0x20)) let s := mload(add(_fullSignature, 0x40)) let v := and(mload(add(_fullSignature, 0x41)), 0xff) mstore(add(signature1, 0x20), r) mstore(add(signature1, 0x40), s) mstore8(add(signature1, 0x60), v) } // Copying the second signature. assembly { let r := mload(add(_fullSignature, 0x61)) let s := mload(add(_fullSignature, 0x81)) let v := and(mload(add(_fullSignature, 0x82)), 0xff) mstore(add(signature2, 0x20), r) mstore(add(signature2, 0x40), s) mstore8(add(signature2, 0x60), v) } } 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 } } ``` ### Does this work on other EVMs? (If yes, please list at least 1 of them) I didn't try it ### Description of What Your Contract Does Deployes an multisig wallet account ### Repo Link (Optional) _No response_ ### Additional Details i'm following this tutorial: https://code.zksync.io/tutorials/native-aa-multisig