Building smart accounts

Discover the process of building custom smart accounts.

To build custom accounts on our platform, developers must implement specific interfaces and follow our recommended best practices for account deployment and management.

Interface Implementation

Every custom account should implement the IAccount interface. You can find an example of a typical account implementation, resembling standard Externally Owned Accounts (EOA) on Ethereum, in the DefaultAccount.sol on GitHub.

This implementation returns an empty value when called by an external address, which may not be the desired behavior for your custom account.

EIP1271 Integration

For smart wallets, we highly encourage the implementation of the EIP1271 signature-validation scheme. This standard is endorsed by the ZKsync team and is integral to our signature-verification library.

Deployment Process

While deploying account logic is similar to deploying a standard smart contract, it’s important to differentiate contracts intended to function as accounts. For this purpose, use the createAccount or create2Account methods of the deployer system contract, rather than the general create or create2 methods, which are used for contracts not intended to act as accounts.

Example Using zksync-ethers SDK (v6)

You can use the ECDSASmartAccount or MultisigECDSASmartAccount to create a standard smart account or multisig smart account.

import { types, ECDSASmartAccount, MultisigECDSASmartAccount, } from "zksync-ethers";

const account = ECDSASmartAccount.create(ADDRESS, PRIVATE_KEY, provider);
const multisigAccount = MultisigECDSASmartAccount.create(multisigAddress, [PRIVATE_KEY1, PRIVATE_KEY2], provider);

You can also deploy with custom factory and account contracts using the example below.

async function deployAAFactory(hre: HardhatRuntimeEnvironment) {
  const deployer = hre.deployer;
  // the factory contract
  const factoryArtifact = await deployer.loadArtifact('AAFactory');
  // the account contract
  const aaArtifact = await deployer.loadArtifact('Account');

  const factory = await deployer.deploy(
    factoryArtifact,
    [utils.hashBytecode(aaArtifact.bytecode)],
    undefined,
    undefined,
    [aaArtifact.bytecode]
  );
  const factoryAddress = await factory.getAddress();
  console.log(`AA factory address: ${factoryAddress}`);
  return factory;
}

Verification Step Limitations

Currently, the verification rules for custom accounts are not fully enforced, which might change in the future:
  • Accounts must only interact with slots that belong to them.
  • Context variables (e.g., block.number) are prohibited in account logic.
  • Accounts must increment the nonce by 1 to maintain hash collision resistance.

These limitations are not yet enforceable at the circuit/VM level and do not apply to L1->L2 transactions.

Nonce Management

Both transaction and deployment nonces are consolidated within the NonceHolder system contract for optimization. Use the incrementMinNonceIfEquals function to safely increment your account's nonce.

Sending Transactions

Currently, only EIP712 formatted transactions are supported for sending from custom accounts. Transactions must specify the from field as the account's address and include a customSignature in the customData.

Example Transaction Submission With a Private Key

To send a transaction using the private key of a deployed smart account, you can use the SmartAccount class from zksync-ethers:

import { SmartAccount, Provider, types } from "zksync-ethers";
import { parseEther } from "ethers";

const provider = Provider.getDefaultProvider(types.Network.Sepolia);
const account = new SmartAccount({ address: ADDRESS, secret: PRIVATE_KEY }, provider);

const signedTx = await account.sendTransaction({
  to: recipientAddress,
  value: parseEther(amount),
});

Example Transaction Submission Without a Private Key

You can also manually add a custom signature to a transaction, like in the example below.

import { types, Provider } from "zksync-ethers";
import { parseEther, AbiCoder } from "ethers";

const provider = new Provider(rpcUrl);
let tx: types.TransactionLike = {
  to: recipientAddress,
  value: parseEther(amount),
  gasPrice: await provider.getGasPrice(),
  gasLimit: BigInt(20000000),
  chainId: (await provider.getNetwork()).chainId,
  nonce: await provider.getTransactionCount(aaAddress),
};

const abiCoder = new AbiCoder();
const aaSignature = abiCoder.encode(
  ['string', 'string'],
  ['hello', 'world']
);

tx.from = aaAddress;
tx.type = 113;
tx.customData = {
  ...tx.customData,
  customSignature: aaSignature,
};
const sentTx = await provider.broadcastTransaction(
    types.Transaction.from(tx).serialized
  );
await sentTx.wait();

Made with ❤️ by the ZKsync Community