Building 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
- 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();