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
Discussed in https://github.com/zkSync-Community-Hub/zksync-developers/discussions/625