Skip to content

Commit

Permalink
docs: add test for multisig tutorial (#76)
Browse files Browse the repository at this point in the history
- updates & adds a test for the multisig tutorial
- adds a code import plugin & replaces hard-coded code snippets with
direct imports
- minor small fixes
sarahschwartz authored Sep 23, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 72ad022 commit b46e863
Showing 28 changed files with 8,053 additions and 854 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/playwright.yaml
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ jobs:
- "tests/erc20-paymaster.spec.ts"
- "tests/how-to-test-contracts.spec.ts"
- "tests/daily-spend-limit.spec.ts"
- "tests/native-aa-multisig.spec.ts"

steps:
- uses: actions/checkout@v4
@@ -32,5 +33,5 @@ jobs:
export TERM=xterm-256color
export COLUMNS=80
export LINES=24
script -q -c "bun test:github ${{ matrix.tutorial }}"
bun test:github ${{ matrix.tutorial }}
1 change: 1 addition & 0 deletions code/multisig/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
WALLET_PRIVATE_KEY=
114 changes: 114 additions & 0 deletions code/multisig/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.vscode

# hardhat artifacts
artifacts
cache

# zksync artifacts
artifacts-zk
cache-zk
deployments-zk/

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.test

# parcel-bundler cache (https://parceljs.org/)
.cache

# Next.js build output
.next

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port
1 change: 1 addition & 0 deletions code/multisig/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
legacy-peer-deps=true
33 changes: 33 additions & 0 deletions code/multisig/contracts/AAFactory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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 AAFactory {
bytes32 public aaBytecodeHash;

constructor(bytes32 _aaBytecodeHash) {
aaBytecodeHash = _aaBytecodeHash;
}

function deployAccount(
bytes32 salt,
address owner1,
address owner2
) 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(owner1, owner2), IContractDeployer.AccountAbstractionVersion.Version1)
)
);
require(success, "Deployment failed");

(accountAddress) = abi.decode(returnData, (address));
}
}
270 changes: 270 additions & 0 deletions code/multisig/contracts/TwoUserMultisig.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// 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";

// ANCHOR: import-ECDSA
// Used for signature validation
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// ANCHOR_END: import-ECDSA

// ANCHOR: import-constants
// Access ZKsync system contracts for nonce validation via NONCE_HOLDER_SYSTEM_CONTRACT
import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol";
// ANCHOR_END: import-constants
// 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;

// ANCHOR: account-owner-vars
// state variables for account owners
address public owner1;
address public owner2;
// ANCHOR_END: account-owner-vars

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.
_;
}

// ANCHOR: constructor
constructor(address _owner1, address _owner2) {
owner1 = _owner1;
owner2 = _owner2;
}
// ANCHOR_END: constructor

function validateTransaction(
bytes32,
bytes32 _suggestedSignedHash,
Transaction calldata _transaction
) external payable override onlyBootloader returns (bytes4 magic) {
return _validateTransaction(_suggestedSignedHash, _transaction);
}

// ANCHOR: _validateTransaction
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);
}
}
// ANCHOR_END: _validateTransaction

function executeTransaction(
bytes32,
bytes32,
Transaction calldata _transaction
) external payable override onlyBootloader {
_executeTransaction(_transaction);
}

// ANCHOR: _executeTransaction
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 "enableEraVMExtensions" 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);
}
}
// ANCHOR_END: _executeTransaction

function executeTransactionFromOutside(Transaction calldata _transaction)
external
payable
{
bytes4 magic = _validateTransaction(bytes32(0), _transaction);
require(magic == ACCOUNT_VALIDATION_SUCCESS_MAGIC, "NOT VALIDATED");
_executeTransaction(_transaction);
}

// ANCHOR: isValidSignature
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);
}
}
// ANCHOR_END: isValidSignature

// 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)
}
}

// ANCHOR: payForTransaction
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");
}
// ANCHOR_END: payForTransaction

// ANCHOR: prepareForPaymaster
function prepareForPaymaster(
bytes32, // _txHash
bytes32, // _suggestedSignedHash
Transaction calldata _transaction
) external payable override onlyBootloader {
_transaction.processPaymasterInput();
}
// ANCHOR_END: prepareForPaymaster

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
}
}
32 changes: 32 additions & 0 deletions code/multisig/deploy/deploy-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { utils, Wallet } from 'zksync-ethers';
import type { HardhatRuntimeEnvironment } from 'hardhat/types';
import { Deployer } from '@matterlabs/hardhat-zksync';
import dotenv from 'dotenv';
dotenv.config();

export default async function (hre: HardhatRuntimeEnvironment) {
// Private key of the account used to deploy
const wallet = new Wallet(process.env.WALLET_PRIVATE_KEY!);
const deployer = new Deployer(hre, wallet);
const factoryArtifact = await deployer.loadArtifact('AAFactory');
const aaArtifact = await deployer.loadArtifact('TwoUserMultisig');

// Getting the bytecodeHash of the account
const bytecodeHash = utils.hashBytecode(aaArtifact.bytecode);

const factory = await deployer.deploy(
factoryArtifact,
[bytecodeHash],
undefined,
undefined, // Override transaction section
[
// Since the factory requires the code of the multisig to be available,
// we need to pass it here as well.
aaArtifact.bytecode,
]
);

const factoryAddress = await factory.getAddress();

console.log(`AA factory address: ${factoryAddress}`);
}
121 changes: 121 additions & 0 deletions code/multisig/deploy/deploy-multisig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// ANCHOR: min-script
import { utils, Wallet, Provider, EIP712Signer, types } from 'zksync-ethers';
import * as ethers from 'ethers';
import type { HardhatRuntimeEnvironment } from 'hardhat/types';
import dotenv from 'dotenv';
dotenv.config();

// Put the address of your AA factory
const AA_FACTORY_ADDRESS = '<FACTORY_ADDRESS>'; //sepolia

export default async function (hre: HardhatRuntimeEnvironment) {
// @ts-expect-error target network in config file
const provider = new Provider(hre.network.config.url);
// Private key of the account used to deploy
const wallet = new Wallet(process.env.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;
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}`);
// ANCHOR_END: min-script

// ANCHOR: send-funds
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.008'),
nonce: await wallet.getNonce(),
})
).wait();

let multisigBalance = await provider.getBalance(multisigAddress);

console.log(`Multisig account balance is ${multisigBalance.toString()}`);
// ANCHOR_END: send-funds

// ANCHOR: create-deploy-tx
// 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
);
// ANCHOR_END: create-deploy-tx

// ANCHOR: tx-gas
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: BigInt(0),
};
// ANCHOR_END: tx-gas

// ANCHOR: sign-tx
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,
};
// ANCHOR_END: sign-tx

// ANCHOR: broadcast-tx
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()}`);
// ANCHOR_END: broadcast-tx
}
47 changes: 47 additions & 0 deletions code/multisig/hardhat.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { 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',
},
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: {
enableEraVMExtensions: true,
// find all available options in the official documentation
// https://docs.zksync.io/build/tooling/hardhat/hardhat-zksync-solc#configuration
},
},
solidity: {
version: '0.8.17',
},
};

export default config;
6,810 changes: 6,810 additions & 0 deletions code/multisig/package-lock.json

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions code/multisig/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"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:f": "hardhat deploy-zksync --script deploy-factory.ts",
"deploy:m": "hardhat deploy-zksync --script deploy-multisig.ts",
"interact": "hardhat deploy-zksync --script interact.ts",
"compile": "hardhat compile",
"clean": "hardhat clean",
"test": "hardhat test --network hardhat"
},
"devDependencies": {
"@matterlabs/hardhat-zksync": "1.1.0",
"@matterlabs/zksync-contracts": "^0.6.1",
"@openzeppelin/contracts": "4.9.5",
"@types/chai": "^4.3.16",
"@types/mocha": "^10.0.7",
"chai": "^4.5.0",
"dotenv": "^16.4.5",
"ethers": "^6.13.2",
"hardhat": "^2.22.7",
"mocha": "^10.7.0",
"ts-node": "^10.9.2",
"typescript": "^5.5.4",
"zksync-ethers": "^6.11.0"
}
}
168 changes: 168 additions & 0 deletions code/multisig/template/Account.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// 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";


contract TwoUserMultisig is IAccount, IERC1271 {
// to get transaction hash
using TransactionHelper for Transaction;


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.
_;
}


function validateTransaction(
bytes32,
bytes32 _suggestedSignedHash,
Transaction calldata _transaction
) external payable override onlyBootloader returns (bytes4 magic) {
magic = _validateTransaction(_suggestedSignedHash, _transaction);
}

function _validateTransaction(
bytes32 _suggestedSignedHash,
Transaction calldata _transaction
) internal returns (bytes4 magic) {
// TO BE IMPLEMENTED
}

function executeTransaction(
bytes32,
bytes32,
Transaction calldata _transaction
) external payable override onlyBootloader {
_executeTransaction(_transaction);
}

function _executeTransaction(Transaction calldata _transaction) internal {
// TO BE IMPLEMENTED
}

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)
{
// TO BE IMPLEMENTED
}

function payForTransaction(
bytes32,
bytes32,
Transaction calldata _transaction
) external payable override onlyBootloader {
// TO BE IMPLEMENTED
}

function prepareForPaymaster(
bytes32, // _txHash
bytes32, // _suggestedSignedHash
Transaction calldata _transaction
) external payable override onlyBootloader {
// TO BE IMPLEMENTED
}

// 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)
}
}

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
}
}
13 changes: 13 additions & 0 deletions code/multisig/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"resolveJsonModule": true
},
"include": ["./hardhat.config.ts", "./scripts", "./deploy", "./test", "typechain/**/*"]
}
13 changes: 9 additions & 4 deletions content/tutorials/how-to-test-contracts/10.index.md
Original file line number Diff line number Diff line change
@@ -36,7 +36,8 @@ Select the `Create a TypeScript project` option and install the sample project's

To install the `hardhat-zksync` plugin, execute the following command:

:test-action{actionId="install-hh-zksync"}
:test-action{actionId="wait-for-initialization"}
:test-action{actionId="install-deps"}

::code-group

@@ -52,6 +53,7 @@ yarn add -D @matterlabs/hardhat-zksync

Once installed, add the plugin at the top of your `hardhat.config.ts` file.

:test-action{actionId="wait-for-deps"}
:test-action{actionId="import-zksync-config"}

```ts [hardhat.config.ts]
@@ -102,12 +104,15 @@ we can shut it down and continue with the tutorial.
### Integration with Hardhat

To enable the usage of ZKsync Era Test Node in Hardhat,
add the `zksync:true` option to the hardhat network in the `hardhat.config.ts` file
and the `latest` version of `zksolc`:
update the version of Solidity to `0.8.26`
in the `hardhat.config.ts` file,
add the `zksync:true` option to the hardhat network,
and add the `latest` version of `zksolc`:

:test-action{actionId="zksync-hh-network"}

```ts
solidity: "0.8.26",
zksolc: {
version: "latest",
},
@@ -158,7 +163,7 @@ import "@matterlabs/hardhat-zksync";
import "@nomicfoundation/hardhat-chai-matchers";

const config: HardhatUserConfig = {
solidity: "0.8.24",
solidity: "0.8.26",
zksolc: {
version: "latest",
},
961 changes: 128 additions & 833 deletions content/tutorials/native-aa-multisig/10.index.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion content/tutorials/native-aa-multisig/_dir.yml
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@ what_you_will_learn:
- How to build a smart contract factory
- How to build a smart contract account
- How to send transactions through a smart contract account
updated: 2024-05-09
updated: 2024-09-17
tools:
- zksync-cli
- zksync-ethers
3 changes: 3 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -6,6 +6,9 @@ export default defineNuxtConfig({
name: 'Community Code',
url: process.env.NUXT_SITE_ENV ? 'https://staging-code.zksync.io' : 'https://code.zksync.io',
},
nitro: {
plugins: ['./plugins/content.ts'],
},
runtimeConfig: {
public: {
app: 'code',
97 changes: 97 additions & 0 deletions server/plugins/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { EOL } from 'os';
import { join } from 'path';
import { readFileSync } from 'fs';

// files cache
const files = new Map<string, string>();

export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('content:file:beforeParse', (file) => {
if (file.body.includes(':code-import{filePath')) {
const newBody = handleCodeImport(file.body);
file.body = newBody;
}
});
});

function handleCodeImport(body: string) {
const lines = body.split(EOL);
let inCodeBlock = false;
let codeBlockIndent = '';

for (let i = 0; i < lines.length; i++) {
const trimmedLine = lines[i].trim();

if (trimmedLine.startsWith('```')) {
inCodeBlock = !inCodeBlock;
if (inCodeBlock) {
const matches = lines[i].match(/^\s*/);
codeBlockIndent = matches ? matches[0] : '';
}
}

if (inCodeBlock && trimmedLine.includes(':code-import{filePath')) {
const filepath = trimmedLine.split('"')[1];
let newCode = getCodeFromFilepath(filepath);

newCode = newCode
.split(EOL)
.map((line) => codeBlockIndent + line)
.join(EOL);

lines[i] = newCode;
}
}

return lines.join(EOL);
}

function getCodeFromFilepath(filepath: string) {
const splitPath = filepath.split(':');
const cleanPath = splitPath[0];
const cache = files.get(cleanPath);
let code;
if (cache) {
code = cache;
} else {
const fullPath = join(process.cwd(), 'code', cleanPath);
code = readFileSync(fullPath, 'utf8');
files.set(filepath, code);
}
const exampleComment = splitPath[1] || null;
if (exampleComment) {
code = extractCommentBlock(code, exampleComment);
}
// remove any other ANCHOR tags
const lines = code.split(EOL);
const trimmedLines = lines.filter((line) => !line.trimStart().startsWith('// ANCHOR'));
return trimmedLines.join(EOL);
}

function extractCommentBlock(content: string, comment: string | null) {
const commentTypes = ['<!--', '{/*', '//', '/*'];

const lines = content.split(EOL);
if (!comment) {
return content;
}

let lineStart = 1;
let lineEnd = 1;
let foundStart = false;

for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].replace(/\s/g, '');
const start = commentTypes.some((type) => trimmed === `${type}ANCHOR:${comment}`);
if (start === true && !foundStart) {
lineStart = i + 1;
foundStart = true;
} else {
const end = commentTypes.some((type) => trimmed === `${type}ANCHOR_END:${comment}`);
if (end === true) lineEnd = i;
}
}
const newLines = lines.slice(lineStart, lineEnd);
const linesContent = newLines.join(EOL);
return linesContent;
}
4 changes: 4 additions & 0 deletions tests/configs/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { steps as erc20PaymasterSteps } from './erc20-paymaster';
import { steps as howToTestContractsSteps } from './how-to-test-contracts';
import { steps as dailySpendLimitSteps } from './daily-spend-limit';
import { steps as multisigSteps } from './native-aa-multisig';

export function getConfig(tutorialName: string) {
let steps;
@@ -14,6 +15,9 @@ export function getConfig(tutorialName: string) {
case 'daily-spend-limit':
steps = dailySpendLimitSteps;
break;
case 'native-aa-multisig':
steps = multisigSteps;
break;
default:
break;
}
15 changes: 12 additions & 3 deletions tests/configs/how-to-test-contracts.ts
Original file line number Diff line number Diff line change
@@ -4,10 +4,18 @@ export const steps: IStepConfig = {
'initialize-hardhat-project': {
action: 'runCommand',
},
'install-hh-zksync': {
'wait-for-initialization': {
action: 'wait',
timeout: 10000,
},
'install-deps': {
action: 'runCommand',
commandFolder: 'tests-output/hardhat-project',
},
'wait-for-deps': {
action: 'wait',
timeout: 10000,
},
'import-zksync-config': {
action: 'modifyFile',
filepath: 'tests-output/hardhat-project/hardhat.config.ts',
@@ -20,7 +28,7 @@ export const steps: IStepConfig = {
},
'wait-for-hh-node': {
action: 'wait',
timeout: 7000,
timeout: 10000,
},
'test-hh-node': {
action: 'checkIfBalanceIsZero',
@@ -30,7 +38,8 @@ export const steps: IStepConfig = {
'zksync-hh-network': {
action: 'modifyFile',
filepath: 'tests-output/hardhat-project/hardhat.config.ts',
atLine: 7,
atLine: 6,
removeLines: [6],
},
'install-chai-ethers': {
action: 'runCommand',
138 changes: 138 additions & 0 deletions tests/configs/native-aa-multisig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { IStepConfig } from '../utils/types';

export const steps: IStepConfig = {
'initialize-project': {
action: 'runCommand',
prompts: 'Private key of the wallet: |❯ npm: ',
},
'wait-for-init': {
action: 'wait',
timeout: 15000,
},
'move-into-project': {
action: 'runCommand',
},
'delete-templates': {
action: 'runCommand',
commandFolder: 'tests-output/custom-aa-tutorial',
},
'install-deps': {
action: 'runCommand',
commandFolder: 'tests-output/custom-aa-tutorial',
},
'hardhat-config': {
action: 'writeToFile',
filepath: 'tests-output/custom-aa-tutorial/hardhat.config.ts',
},
'use-local-node': {
action: 'modifyFile',
filepath: 'tests-output/custom-aa-tutorial/hardhat.config.ts',
useSetData: " defaultNetwork: 'inMemoryNode',",
atLine: 6,
removeLines: [6],
},
'start-local-node': {
action: 'runCommand',
commandFolder: 'tests-output/custom-aa-tutorial',
useSetCommand: "bun pm2 start 'era_test_node fork sepolia-testnet' --name era-test-node",
},
'make-multisig-contract': {
action: 'runCommand',
commandFolder: 'tests-output/custom-aa-tutorial',
},
'multisig-contract-code': {
action: 'writeToFile',
filepath: 'tests-output/custom-aa-tutorial/contracts/TwoUserMultisig.sol',
addSpacesAfter: false,
},
'make-factory-contract': {
action: 'runCommand',
commandFolder: 'tests-output/custom-aa-tutorial',
},
'factory-contract-code': {
action: 'writeToFile',
filepath: 'tests-output/custom-aa-tutorial/contracts/AAFactory.sol',
},
'make-deploy-script': {
action: 'runCommand',
commandFolder: 'tests-output/custom-aa-tutorial',
},
'deploy-script-code': {
action: 'writeToFile',
filepath: 'tests-output/custom-aa-tutorial/deploy/deploy-factory.ts',
},
'env-pk': {
action: 'modifyFile',
filepath: 'tests-output/custom-aa-tutorial/.env',
atLine: 1,
removeLines: [1],
useSetData: 'WALLET_PRIVATE_KEY=0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110',
},
'compile-and-deploy-factory': {
action: 'runCommand',
commandFolder: 'tests-output/custom-aa-tutorial',
checkForOutput: 'AA factory address:',
saveOutput: 'tests-output/custom-aa-tutorial/deployed-factory-address.txt',
},
'create-deploy-multisig': {
action: 'runCommand',
commandFolder: 'tests-output/custom-aa-tutorial',
},
'deploy-multisig-code': {
action: 'writeToFile',
filepath: 'tests-output/custom-aa-tutorial/deploy/deploy-multisig.ts',
},
'deposit-funds': {
action: 'modifyFile',
filepath: 'tests-output/custom-aa-tutorial/deploy/deploy-multisig.ts',
atLine: 41,
addSpacesBefore: 1,
},
'create-deploy-tx': {
action: 'modifyFile',
filepath: 'tests-output/custom-aa-tutorial/deploy/deploy-multisig.ts',
atLine: 56,
addSpacesBefore: 1,
},
'modify-deploy-tx': {
action: 'modifyFile',
filepath: 'tests-output/custom-aa-tutorial/deploy/deploy-multisig.ts',
atLine: 64,
addSpacesBefore: 1,
},
'sign-deploy-tx': {
action: 'modifyFile',
filepath: 'tests-output/custom-aa-tutorial/deploy/deploy-multisig.ts',
atLine: 82,
addSpacesBefore: 1,
},
'send-deploy-tx': {
action: 'modifyFile',
filepath: 'tests-output/custom-aa-tutorial/deploy/deploy-multisig.ts',
atLine: 95,
addSpacesBefore: 1,
},
'final-deploy-script': {
action: 'compareToFile',
filepath: 'tests-output/custom-aa-tutorial/deploy/deploy-multisig.ts',
},
'get-deployed-account-address': {
action: 'extractDataToEnv',
dataFilepath: 'tests-output/custom-aa-tutorial/deployed-factory-address.txt',
regex: /0x[a-fA-F0-9]{40}/,
variableName: 'AA_FACTORY_ADDRESS',
envFilepath: 'tests-output/custom-aa-tutorial/.env',
},
'deploy-multisig-account': {
action: 'modifyFile',
filepath: 'tests-output/custom-aa-tutorial/deploy/deploy-multisig.ts',
atLine: 8,
removeLines: [8],
useSetData: 'const AA_FACTORY_ADDRESS = process.env.AA_FACTORY_ADDRESS || "";',
},
'run-deploy-multisig': {
action: 'runCommand',
commandFolder: 'tests-output/custom-aa-tutorial',
checkForOutput: 'Multisig account balance is now',
},
};
6 changes: 6 additions & 0 deletions tests/native-aa-multisig.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { test } from '@playwright/test';
import { setupAndRunTest } from './utils/runTest';

test('Native AA Multisig', async ({ page, context }) => {
await setupAndRunTest(page, context, 'custom-aa-tutorial', ['/native-aa-multisig'], 'native-aa-multisig');
});
9 changes: 4 additions & 5 deletions tests/utils/files.ts
Original file line number Diff line number Diff line change
@@ -3,9 +3,9 @@ import { clickCopyButton } from './button';
import { expect, type Page } from '@playwright/test';
import { EOL } from 'os';

export async function writeToFile(page: Page, buttonName: string, filePath: string) {
export async function writeToFile(page: Page, buttonName: string, filePath: string, addSpacesAfter: boolean = true) {
const content = await clickCopyButton(page, buttonName);
writeFileSync(filePath, `${content}\n\n`);
writeFileSync(filePath, addSpacesAfter ? `${content}\n\n` : `${content.trimEnd()}\n`);
}

export async function modifyFile(
@@ -47,10 +47,9 @@ export async function modifyFile(
});
}
if (atLine) {
lines.splice(atLine - 1, 0, contentText);
lines.splice(atLine - 1, 0, spacesBefore + contentText + spacesAfter);
}
let finalContent = lines.filter((line: string) => line !== '~~~REMOVE~~~').join('\n');
finalContent = spacesBefore + finalContent + spacesAfter;
const finalContent = lines.filter((line: string) => line !== '~~~REMOVE~~~').join('\n');
writeFileSync(filePath, finalContent, 'utf8');
}
}
5 changes: 2 additions & 3 deletions tests/utils/runCommand.ts
Original file line number Diff line number Diff line change
@@ -53,10 +53,9 @@ async function run(command: string, saveOutput?: string, checkForOutput?: string

return new Promise<void>((resolve, reject) => {
exec(command, { encoding: 'utf-8' }, (error, stdout, stderr) => {
console.log('EXPECT ERROR', expectError);

if (error) {
if (expectError) {
console.log('EXPECT ERROR', expectError);
const hasError = [error.message, stdout, stderr].some((message) => message.includes(expectError));
console.log('HAS ERROR', hasError);
if (hasError) {
@@ -96,7 +95,7 @@ async function createNewHHProject(goToFolder: string, projectFolder: string) {
const destinationFolder = join(goToFolder, projectFolder);
copyFolder(sourceFolder, destinationFolder);
const installCommand = `cd ${destinationFolder} && npm init -y && npm install --save-dev "hardhat@^2.22.6" "@nomicfoundation/hardhat-toolbox@^5.0.0" `;
run(installCommand);
await run(installCommand);
}

function copyFolder(source: string, destination: string) {
2 changes: 1 addition & 1 deletion tests/utils/runTest.ts
Original file line number Diff line number Diff line change
@@ -68,7 +68,7 @@ export async function runTest(page: Page, url: string, config: IStepConfig) {
await page.waitForTimeout(stepData.timeout);
break;
case 'writeToFile':
await writeToFile(page, stepID, stepData.filepath);
await writeToFile(page, stepID, stepData.filepath, stepData.addSpacesAfter);
break;
case 'modifyFile':
await modifyFile(
4 changes: 2 additions & 2 deletions tests/utils/setup.ts
Original file line number Diff line number Diff line change
@@ -4,8 +4,8 @@ import type { Page } from '@playwright/test';

export async function startLocalServer(page: Page) {
console.log('STARTING...');
await page.waitForTimeout(15000);
console.log('WAITED 15 SECONDS FOR LOCAL SERVER TO START');
await page.waitForTimeout(20000);
console.log('WAITED 20 SECONDS FOR LOCAL SERVER TO START');
}

export function stopServers() {
1 change: 1 addition & 0 deletions tests/utils/types.ts
Original file line number Diff line number Diff line change
@@ -41,6 +41,7 @@ export interface IWait {
export interface IWriteToFile {
action: 'writeToFile';
filepath: string;
addSpacesAfter?: boolean;
}

export interface IModifyFile {
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
"extends": "./.nuxt/tsconfig.json",
"exclude": ["code"]
}

0 comments on commit b46e863

Please sign in to comment.