Cover photo

Building a Smart Passkey Wallet from scratch with ZKsync Native AA

Introduction

Smart Contract wallets have become very popular with the rise of account abstraction. Account abstraction allows you implementing any signature verification method, enabling your smartphones and computers to become transaction signers, instead of 12-word seed phrases everyone used to have. Additionally, many other programmable features can be attached to build more complex features (e.g spending limits).

In this article we are going to build a very simple smart account wallet on ZKsync, using native Account Abstraction and Passkeys. In general, the smart wallet we create will have following features:

  • Biometric authentication with Passkeys

  • Simple send money feature, supporting various tokens

  • Gasless transactions using Paymaster

  • Multicall to get multiple token balances from chain

  • Fully on-chain, without any need for off-chain server

Smart Contracts

As a smart contract architecture, we will be using Clave contracts. These account contracts are audited and production ready components for building any simple or complex wallet architecture. They are pretty similar to ERC-7579 smart contracts, but with a slightly different interface. The reason behind the difference is that Clave contracts were ready for production way before than the popularity of this standard. You can check out the audit reports of the contracts from the GitHub repository.

We will be focusing on more core and simple parts, which starts with deploying and ends with sending a simple transaction. Clave contracts are compatible with Passkeys and Figure 1 depicts how the signed transactions are reflected in blockchain.

Figure 1: Sending transaction with a Passkey
Figure 1: Sending transaction with a Passkey

1 - ZKsync Network Details (for new ZKsync users)

The first step in building a smart wallet is of course deploying the smart contracts to blockchain we are using - ZKsync in our case. If you are not familiar with ZKsync, it is a zero-knowledge rollup on top of Ethereum. If you need to add the network to your wallet before deployment, you can do it from Chainlist.

https://chainlist.org/chain/324

2 - Deploying Smart Contracts

2.1 - Cloning the Github Repository

Since we are going to use the Clave contracts, we need to clone the related repository from Github and proceed to deployment process. Here is the repository link on Github:

https://github.com/getclave/clave-contracts

2.2 - Installing Dependencies

After you clone the repository, you need to install the dependencies by running the install command with your package manager. I will be using npm since it is the most basic and compatible one. Running the following command will install the required dependencies.

2.3 - Setting up Environment

As also written in the README.md of the clave-contracts repo, we need to set the private key for deployment on .env file. Since it is dangerous to push .env file to GitHub, it is ignored from git using .gitignore. Create a .env file on root of the cloned repository and add your private key in the following format:

PRIVATE_KEY=********** // Replace stars with your actual private key

2.4 - Compiling the Smart Contracts

Before moving to deployment step, you need to compile the smart contracts in your local machine. You need to run the following command to compile the smart contracts with hardhat:

npm run compile

2.5 - Running Deployment Script

I prepared a simple deployment script, called deploy-mvp.ts for most viable product (MVP). It will deploy the following modules for you and print you the addresses of contracts in the end.

  • BatchCaller - Used for sending batch transactions. We will not use this module, but it is required by some other modules. Therefore, we need to deploy it too.

  • Implementation - Main implementation for account

  • Registry - Used to record the deployed user accounts

  • GaslessPaymaster - Smart contract that helps implementing gasless transactions

  • Proxy - Proxy contracts are mostly used to have an upgradable structure

  • PasskeyValidator - Smart contract that validates Passkey signatures

  • Factory - Smart contract that creates (deploys) accounts

Please make sure that you have enough ETH balance on your wallet used for deployment.

You need to run the following command to deploy the contracts:

npm run deploy:mvp

The default network for the repository is ZKsync Sepolia. To deploy the contracts in ZKsync Era, you need to explicitly pass the network name. The command will be npm run deploy:mvp -- --network zkSyncMainnet

After waiting for ~1 minute, you will get all the contract addresses in the form of object. The example output should be like below (of course, you will have different addresses):

{
  batchCaller: '0x1513dB8DdC9420728bFb2830AE6784B26Ac9bf25',
  implementation: '0x5627beD3bA7DFc5D9DbAa0122A52C7F22a2DD4D3',
  registry: '0x7f273AF2576EA32309c32c9bae2b609B6e4484aC',
  gaslessPaymaster: '0xF83F534153358AD6643B358AC3953f6467d5DAe7',
  claveProxy: '0x3b633b071ABFf838d30D1a326744D8277Fad468c',
  passkeyValidator: '0xDA63bBbc0A1a3F94e95c6bdd2DCB7B7112e3C635',
  accountFactory: '0x281d01350B4449D6F4B3a58ce7F342c5221E1636',
}

That is it, you are done with the contracts side and good to go to next step - building the user interface and interacting with smart contracts using ethers.js

2.6 - Funding Paymaster

As explained, you are sending gasless transactions using the GaslessPaymaster contract. However, this contract should have ETH balance to pay gas instead of users. Therefore, you just need to send some Ether to this contract using any wallet.

Alternatively, you can also follow these steps:

  • Copy your gasless paymaster address after deployment. It is defined with gaslessPaymaster key on the returned object response of deploy:mvp command.

  • Go to your wallet and select the network you deployed this contract to. In our case it is ZKsync Sepolia

  • Transfer some Ether amount. I will transfer around $25 worth of Ether, which will be more than enough for testing.

Figure 2: Funding Paymaster using Metamask
Figure 2: Funding Paymaster using Metamask

3 - Setting Up Client Repository

I have created a simple web application that implements client-side interactions with smart contracts we deployed. It is built with NextJS. NextJS is a very popular React framework for building interactive websites with high complexity. The system requirements for the NextJS are as below:

  • Node.js 18.18 or later. You can check your node version by running node --version

  • macOS, Windows (including WSL), and Linux are supported.

You can check out more about the NextJS from the link below.

https://nextjs.org/

3.1 - Cloning the Client Repository

Here is the GitHub repository link for the client application:

https://github.com/getclave/zksync-smart-wallet

3.2 - Installing Packages

After you clone the repository, you need to install the dependencies by running the install command with your package manager. I will again use npm

If you are curious about which packages we use for client application, here is the list:

  • @passwordless-id/webauthn - Working with Passkeys (Webauthn)

  • ethers - Simplifying contract interactions and cryptographic operations like hashing. We are using ethers version 5, since it is used by more people based on npm data.

  • @tanstack/react-query - Handling RPC calls

  • react-icons - Easily displaying SVG icons

  • recoil - State management for React

  • zksync-ethers - Pretty much similar with ethers, but also contains some ZKsync Native AA interface features

  • cbor - Encode and parse data in the Concise Binary Object Representation (CBOR) data format (RFC8949).

  • react-qrcode-logo - Displaying Address QR Code

3.3 - Setting Deployer Wallet

Since the Clave smart accounts need to be deployed during the registration, we need to setup the deployer private key on NextJS side too. NextJS provides a way sending API requests with its own router and we can safely put the deployer private key to our client .env file. Please make sure that your private key is the same with private key used for deploying smart contracts on section 2.3.

Firstly, create an .env file on the client repository and use the following format:

DEPLOYER_PRIVATE_KEY=****** // Replace stars with your actual private key

3.4 - Using Your Deployed Contracts

On section 2.5, we got the output of our deployed smart contracts. To setup those addresses on your client application, you need to follow these steps:

  • Copy the your deployed contracts output object

  • Go to src/utils/contract.ts

  • Replace the contracts variable with your addresses. Pasting the object will be enough (also visible on Figure 3)

Figure 3: The location of contract addresses on src/utils/contract.ts
Figure 3: The location of contract addresses on src/utils/contract.ts

3.5 - Running Development Server

You can start the client development server by running the command below. It will start the development server on https://localhost:3000

npm run dev

4 - Sending Transactions

In this section, I will explain the logic of sending transaction. The only thing you should now about preparing transactions is that you should use Core util, located at src/utils/core.ts. The singleton class instance is exported, so that you can use the same core object everywhere.

  • To prepare the transaction you need to use the getTransaction method

  • To sign and send the transaction you need to call signAndSend method from output of getTransaction

Here is the example of getting a transaction instance, also sending it to network:

import { core } from '@/utils';

// Prepare transaction
const tx = await core.getTransaction({
    to: "0xc1ECfC78959484df5472b20Cb7D43dC8c57C767A",
    value: ethers.utils.parseEther("0.001"),
});

// Send transaction to ZKsync network
await tx.signAndSend();

The code above gets a transaction for sending 0.001 ETH to address 0xc1ECfC78959484df5472b20Cb7D43dC8c57C767A.

If you want to call a smart contract method, you need one additional step of preparing calldata of your transaction. This is possible with the following code block.

import { abiErc20, core } from '@/utils';

const USDC_ADDRESS = "0x235171e45abff2a15d117e3179df4cc35ebfae2f";
const USDC_DECIMALS = 6;
const RECEIVER = "0xc1ECfC78959484df5472b20Cb7D43dC8c57C767A";
const AMOUNT = "1";

// Prepare calldata
const calldata = core.getCalldata({
     abi: ERC20_ABI,
     method: 'transfer',
     args: [
         RECEIVER,
         ethers.utils.parseUnits(AMOUNT, USDC_DECIMALS),
     ],
 });

// Prepare transaction
const tx = await core.getTransaction({
    to: selectedToken.address,
    data: calldata,
});

// Send transaction to ZKsync network
await tx.signAndSend();

The code block above calls the transfer function from USDC contract to send 1 USDC to RECEIVER address.

5 - Sending Batch Transactions

There are some cases where you may want to send multiple transactions within the same transaction - batch transactions. Batch transactions are only possible with smart contract wallets, and currently, they are the main thing that makes Clave a perfect account abstraction wallet. The following core block allows you batching multiple transactions into a single one:

const RECEIVER_1 = "0xc1ECfC78959484df5472b20Cb7D43dC8c57C767A"
const RECEIVER_2 = "0x94E9b636d0f3BDc08019B450F7f2F4Ef5b4eb2Ca"

const AMOUNT = "0.001"

// Prepare transaction
const tx = await core.getBatchTransaction(
    {
      to: RECEIVER_1,
      value: ethers.utils.parseEther(AMOUNT),
    },
    {
      to: RECEIVER_2,
      value: ethers.utils.parseEther(AMOUNT),
    },
);

// Send transaction to ZKsync network
await tx.signAndSend();

The code above gets a batch transaction for sending 0.001 ETH to RECEIVER_1 and RECEIVER_2 addresses. You only sign once and send multiple transactions at the same time. By and also setting calldata parameter, you can call multiple methods from multiple smart contracts to make the user experience of you wallet better and better.

References & Nice To Checks

https://docs.zksync.io/

https://docs.getclave.io/en/welcome-to-clave