Daily spending limit account
This tutorial will guide you through the process of building a smart contract account that has a daily spend limit. We'll create a factory contract that deploys new smart accounts and configure the account limit and test it works as expected
What you'll learn:
- How to build a smart contract factory
- How to build a smart contract account
- How to send transactions through a smart contract account
Tools:
- - zksync-cli
- - zksync-ethers
- - Hardhat
This tutorial shows you how to create a smart contract account with a daily spending limit using the ZKsync Era native account abstraction.
The daily limit feature prevents an account from spending more ETH than the limit set by the account's owner.
Prerequisites
- Make sure your machine satisfies the system requirements.
- Node.js and Yarn are installed on your machine.
- You are already familiar with deploying smart contracts on ZKsync Era. If not, please refer to the first section of the quickstart tutorial.
- A wallet with sufficient Sepolia
ETH
on Ethereum and ZKsync Sepolia Testnet to pay for deploying smart contracts. You can get Sepolia ETH from the network faucets.- Get testnet
ETH
for ZKsync Era using bridges to bridge funds to ZKsync.
- Get testnet
- You know how to get your private key from your MetaMask wallet.
- We encourage you to read the basics of account abstraction on ZKsync Era and complete the multisig account tutorial before attempting this tutorial.
zksync-cli
for local testing.
Simply execute npx zksync-cli dev start
to initialize a local ZKsync development environment,
which includes local Ethereum and ZKsync nodes.
This method allows you to test contracts without requesting external testnet funds.
Explore more in the zksync-cli documentation.Complete Project
Download the complete project on GitHub. Additionally, the repository contains a test folder with more detailed tests for running on a ZKsync Era local network.
Project Setup
We will use the ZKsync Era Hardhat plugins to build, deploy, and interact with the smart contracts in this project.
- Initiate a new project by running the command:
npx zksync-cli create custom-spendlimit-tutorial --template hardhat_solidity
This creates a new ZKsync Era project calledcustom-spendlimit-tutorial
with a few example contracts. - Navigate into the project directory:
cd custom-spendlimit-tutorial
- For the purposes of this tutorial, we don't need the example contracts related files. So, proceed by removing all the
files inside the
/contracts
and/deploy
folders manually or by running the following commands:rm -rf ./contracts/* rm -rf ./deploy/*
- Add the ZKsync and OpenZeppelin contract libraries:
npm install -D @matterlabs/zksync-contracts @openzeppelin/contracts@4.9.5 @matterlabs/hardhat-zksync-deploy@1.3.0
This project does not use the latest version available of@openzeppelin/contracts
. Make sure you install the specific version mentioned above. - Include the
isSystem: true
setting in thezksolc
section of thehardhat.config.ts
configuration file to allow interaction with system contracts:hardhat.config.tsimport { HardhatUserConfig } from "hardhat/config"; import "@matterlabs/hardhat-zksync-node"; import "@matterlabs/hardhat-zksync-deploy"; import "@matterlabs/hardhat-zksync-solc"; import "@matterlabs/hardhat-zksync-verify"; 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", }, 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, }, // Additional networks }, zksolc: { version: "latest", settings: { isSystem: true, // ⚠️ Make sure to include this line }, }, solidity: { version: "0.8.17", }, }; export default config;
Design
Now let’s dive into the design and implementation of the daily spending limit feature.
The SpendLimit
contract inherits from the Account
contract as a module that does the following:
- Allows the account to enable/disable the daily spending limit in a token (ETH in this example).
- Allows the account to change (increase/decrease or remove) the daily spending limit.
- Rejects token transfer if the daily spending limit has been exceeded.
- Restores the available amount for spending after 24 hours.
Basic Structure
Below you'll find the SpendLimit
skeleton contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract SpendLimit {
uint public ONE_DAY = 24 hours;
modifier onlyAccount() {
require(
msg.sender == address(this),
"Only the account that inherits this contract can call this method."
);
_;
}
function setSpendingLimit(address _token, uint _amount) public onlyAccount {
}
function removeSpendingLimit(address _token) public onlyAccount {
}
function _isValidUpdate(address _token) internal view returns(bool) {
}
function _updateLimit(address _token, uint _limit, uint _available, uint _resetTime, bool _isEnabled) private {
}
function _checkSpendingLimit(address _token, uint _amount) internal {
}
}
The mapping limits
and struct Limit
below serve as data storage for the state of daily limits accounts enable.
The roles of each variable in the struct are detailed in the comments.
/// This struct serves as data storage of daily spending limits users enable
/// limit: the amount of a daily spending limit
/// available: the available amount that can be spent
/// resetTime: block.timestamp at the available amount is restored
/// isEnabled: true when a daily spending limit is enabled
struct Limit {
uint limit;
uint available;
uint resetTime;
bool isEnabled;
}
mapping(address => Limit) public limits; // token => Limit
Note that the limits
mapping uses the token address as its key.
This means that users can set limits for ETH and any other ERC20 token.
Setting and Removing the Daily Spending Limit
The code below sets and removes the limit.
/// this function enables a daily spending limit for specific tokens.
/// @param _token ETH or ERC20 token address that a given spending limit is applied.
/// @param _amount non-zero limit.
function setSpendingLimit(address _token, uint _amount) public onlyAccount {
require(_amount != 0, "Invalid amount");
uint resetTime;
uint timestamp = block.timestamp; // L2 block timestamp
if (isValidUpdate(_token)) {
resetTime = timestamp + ONE_DAY;
} else {
resetTime = timestamp;
}
_updateLimit(_token, _amount, _amount, resetTime, true);
}
// this function disables an active daily spending limit,
// decreasing each uint number in the Limit struct to zero and setting isEnabled false.
function removeSpendingLimit(address _token) public onlyAccount {
require(isValidUpdate(_token), "Invalid Update");
_updateLimit(_token, 0, 0, 0, false);
}
// verify if the update to a Limit struct is valid
// Ensure that users can't freely modify(increase or remove) the daily limit to spend more.
function isValidUpdate(address _token) internal view returns (bool) {
// Reverts unless it is first spending after enabling
// or called after 24 hours have passed since the last update.
if (limits[_token].isEnabled) {
require(
limits[_token].limit == limits[_token].available ||
block.timestamp > limits[_token].resetTime,
"Invalid Update"
);
return true;
} else {
return false;
}
}
// storage-modifying private function called by either setSpendingLimit or removeSpendingLimit
function _updateLimit(
address _token,
uint _limit,
uint _available,
uint _resetTime,
bool _isEnabled
) private {
Limit storage limit = limits[_token];
limit.limit = _limit;
limit.available = _available;
limit.resetTime = _resetTime;
limit.isEnabled = _isEnabled;
}
Both setSpendingLimit
and removeSpendingLimit
can only be called by account contracts that inherit the contract
SpendLimit
.
This is ensured by the onlyAccount
modifier.
They call _updateLimit
and pass the arguments to modify the storage data of the limit after the verification in _isValidUpdate
succeeds.
Specifically, setSpendingLimit
sets a non-zero daily spending limit for a given token, and removeSpendingLimit
disables the active daily spending limit by decreasing limit
and available
to 0 and setting isEnabled
to false.
_isValidUpdate
returns false if the spending limit is not enabled and also throws an Invalid Update
error if the
user has spent some amount in the day (the available amount is different from the limit) or the function is called
before 24 hours have passed since the last update.
This ensures that users can't freely modify (increase or remove) the daily limit to spend more.
Checking Daily Spending Limit
The _checkSpendingLimit
function is internally called by the account contract before executing the transaction.
// this function is called by the account before execution.
// Verify the account is able to spend a given amount of tokens. And it records a new available amount.
function _checkSpendingLimit(address _token, uint _amount) internal {
Limit memory limit = limits[_token];
// return if spending limit hasn't been enabled yet
if (!limit.isEnabled) return;
uint timestamp = block.timestamp; // L2 block timestamp
// Renew resetTime and available amount, which is only performed
// if a day has already passed since the last update: timestamp > resetTime
if (limit.limit != limit.available && timestamp > limit.resetTime) {
limit.resetTime = timestamp + ONE_DAY;
limit.available = limit.limit;
// Or only resetTime is updated if it's the first spending after enabling limit
} else if (limit.limit == limit.available) {
limit.resetTime = timestamp + ONE_DAY;
}
// reverts if the amount exceeds the remaining available amount.
require(limit.available >= _amount, "Exceed daily limit");
// decrement `available`
limit.available -= _amount;
limits[_token] = limit;
}
If the daily spending limit is disabled, the checking process immediately stops.
if(!limit.isEnabled) return;
Before checking the spending amount, this method renews the resetTime
and available
amount if a day has already
passed since the last update: timestamp > resetTime
.
It only updates the resetTime
if the transaction is the first spending after enabling the limit.
This way the daily limit actually starts with the first transaction.
if (limit.limit != limit.available && timestamp > limit.resetTime) {
limit.resetTime = timestamp + ONE_DAY;
limit.available = limit.limit;
// Or only resetTime is updated if it's the first spending after enabling limit
} else if (limit.limit == limit.available) {
limit.resetTime = timestamp + ONE_DAY;
}
Finally, the method checks if the account can spend a specified amount of the token.
If the amount doesn't exceed the available amount, it decrements the available
in the limit:
require(limit.available >= _amount, 'Exceed daily limit');
limit.available -= _amount;
Full Code for the SpendLimit
Contract
- In the folder
contracts
, add a file calledSpendLimit.sol
touch contracts/SpendLimit.sol
- Copy/paste the complete code below.The value of the
ONE_DAY
variable is initially set to1 minutes
instead of24 hours
. This is just for testing purposes as we don't want to wait a full day to see if it works! Don't forget to change the value before deploying the contract.
Account
and AAFactory
Contracts
Let's create the account contract Account.sol
, and the factory contract that deploys account contracts, in
AAFactory.sol
.
As noted earlier, those two contracts are based on the implementations of
the multisig account abstraction tutorial.
The main difference is that our account has a single signer.
Account.sol
- Create a file
Account.sol
in thecontracts
folder.touch contracts/Account.sol
- Copy/paste the code below.
The account implements the IAccount interface
and inherits the SpendLimit
contract we just created.
Since we are building an account with signers, we should also implement
EIP1271.
The isValidSignature
method will take care of verifying the signature and making sure the extracted address matches
with the owner of the account.
0x000000000000000000000000000000000000800a
. Neither the well-known 0xEee...EEeE
used by protocols as a placeholder
on Ethereum, nor the zero address 0x000...000
,
that (zksync-ethers
provides) has a more user-friendly alias.SpendLimit
account is token-agnostic. This means an extension is also possible:
add a check for whether or not the execution is an ERC20 transfer by extracting the function selector in
bytes from transaction calldata.AAFactory.sol
The AAFactory.sol
contract is responsible for deploying instances of the Account.sol
contract.
- Create the
AAFactory.sol
file in thecontracts
folder.touch contracts/AAFactory.sol
- Copy/paste the code below.
// 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 owner
) 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(owner),
IContractDeployer.AccountAbstractionVersion.Version1
)
)
);
require(success, "Deployment failed");
(accountAddress) = abi.decode(returnData, (address));
}
}
Compile and Deploy the Smart Contracts
- Compile the contracts from the project root.
npm run compile
- Create a file named
deploy/deploy.ts
. Then, copy and paste the following code into it. Remember to add yourDEPLOYER_PRIVATE_KEY
to the .env file.touch deploy/deploy.ts
The script deploys the factory, creates a new smart contract account, and funds it with some ETH.deploy/deploy.tsimport { utils, Wallet, Provider } from "zksync-ethers"; import * as ethers from "ethers"; import { HardhatRuntimeEnvironment } from "hardhat/types"; import { Deployer } from "@matterlabs/hardhat-zksync-deploy"; // load env file import dotenv from "dotenv"; dotenv.config(); const DEPLOYER_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY || ""; export default async function (hre: HardhatRuntimeEnvironment) { // @ts-ignore target zkSyncSepoliaTestnet in config file which can be testnet or local const provider = new Provider(hre.network.config.url); const wallet = new Wallet(DEPLOYER_PRIVATE_KEY, provider); const deployer = new Deployer(hre, wallet); const factoryArtifact = await deployer.loadArtifact("AAFactory"); const aaArtifact = await deployer.loadArtifact("Account"); // Bridge funds if the wallet on ZKsync doesn't have enough funds. // const depositAmount = ethers.parseEther('0.1'); // const depositHandle = await deployer.zkWallet.deposit({ // to: deployer.zkWallet.address, // token: utils.ETH_ADDRESS, // amount: depositAmount, // }); // await depositHandle.wait(); const factory = await deployer.deploy(factoryArtifact, [utils.hashBytecode(aaArtifact.bytecode)], undefined, [aaArtifact.bytecode]); const factoryAddress = await factory.getAddress(); console.log(`AA factory address: ${factoryAddress}`); const aaFactory = new ethers.Contract(factoryAddress, factoryArtifact.abi, wallet); const owner = Wallet.createRandom(); console.log("SC Account owner pk: ", owner.privateKey); const salt = ethers.ZeroHash; const tx = await aaFactory.deployAccount(salt, owner.address); await tx.wait(); const abiCoder = new ethers.AbiCoder(); const accountAddress = utils.create2Address(factoryAddress, await aaFactory.aaBytecodeHash(), salt, abiCoder.encode(["address"], [owner.address])); console.log(`SC Account deployed on address ${accountAddress}`); console.log("Funding smart contract account with some ETH"); await ( await wallet.sendTransaction({ to: accountAddress, value: ethers.parseEther("0.02"), }) ).wait(); console.log(`Done!`); }
- Run the script.
npm run deploy
You should see the following:AA factory address: 0x0d3205bc8134A11f9402fBA01947Bf377FaE4C39 SC Account owner pk: 0x4ze1f87c5e575dsb6b35afe70ftu93fs53bca24592f6ng25f3v1a4f6fsd3vz SC Account deployed on address 0x29d0D17857bdA971F40FF11145859eED7bD15c00 Funding smart contract account with some ETH Done! ✨ Done in 10.85s.
Open up the ZKsync Sepolia Testnet Block Explorer and search for the deployed Account contract address in order to track transactions and changes in the balance.
Set the daily spending limit
- Create the file
setLimit.ts
in thedeploy
folder and copy/paste the example code below.touch deploy/setLimit.ts
- Replace
<DEPLOYED_ACCOUNT_ADDRESS>
and<DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY>
with the output from the previous section in your.env
file.
To enable the daily spending limit, we execute thesetSpendingLimit
function with two parameters: token address and limit amount. The token address isETH_ADDRESS
and the limit parameter is0.0005
in the example below (and can be any amount).setLimit.tsimport { utils, Wallet, Provider, Contract, EIP712Signer, types } from "zksync-ethers"; import * as ethers from "ethers"; import { HardhatRuntimeEnvironment } from "hardhat/types"; // load env file import dotenv from "dotenv"; dotenv.config(); // load the values into .env file after deploying the FactoryAccount const DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY = process.env.DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY || ""; const ETH_ADDRESS = process.env.ETH_ADDRESS || "0x000000000000000000000000000000000000800A"; const ACCOUNT_ADDRESS = process.env.DEPLOYED_ACCOUNT_ADDRESS || ""; export default async function (hre: HardhatRuntimeEnvironment) { // @ts-ignore target zkSyncSepoliaTestnet in config file which can be testnet or local const provider = new Provider(hre.network.config.url); const owner = new Wallet(DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY, provider); const accountArtifact = await hre.artifacts.readArtifact("Account"); const account = new Contract(ACCOUNT_ADDRESS, accountArtifact.abi, owner); let setLimitTx = await account.setSpendingLimit.populateTransaction(ETH_ADDRESS, ethers.parseEther("0.0005")); setLimitTx = { ...setLimitTx, from: ACCOUNT_ADDRESS, chainId: (await provider.getNetwork()).chainId, nonce: await provider.getTransactionCount(ACCOUNT_ADDRESS), type: 113, customData: { gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT, } as types.Eip712Meta, value: BigInt(0), }; setLimitTx.gasPrice = await provider.getGasPrice(); setLimitTx.gasLimit = await provider.estimateGas(setLimitTx); const signedTxHash = EIP712Signer.getSignedDigest(setLimitTx); const signature = ethers.concat([ethers.Signature.from(owner.signingKey.sign(signedTxHash)).serialized]); setLimitTx.customData = { ...setLimitTx.customData, customSignature: signature, }; console.log("Setting limit for account..."); const sentTx = await provider.broadcastTransaction(types.Transaction.from(setLimitTx).serialized); await sentTx.wait(); const limit = await account.limits(ETH_ADDRESS); console.log("Account limit enabled?: ", limit.isEnabled); console.log("Account limit: ", limit.limit.toString()); console.log("Available limit today: ", limit.available.toString()); console.log("Time to reset limit: ", limit.resetTime.toString()); }
- Run the script.
npx hardhat deploy-zksync --script setLimit.ts
You should see the following output:Setting limit for account... Account limit enabled?: true Account limit: 500000000000000 Available limit today: 500000000000000 Time to reset limit: 1708688165
Perform ETH Transfer
Let's test the SpendLimit
contract works to make it refuse ETH transfers that exceed the daily limit.
- Create
transferETH.ts
and copy/paste the example code below, replacing the placeholder constants as before and adding an account address forRECEIVER_ACCOUNT
.touch deploy/transferETH.ts
deploy/transferETH.tsimport { utils, Wallet, Provider, Contract, EIP712Signer, types } from "zksync-ethers"; import * as ethers from "ethers"; import type { HardhatRuntimeEnvironment } from "hardhat/types"; // load env file import dotenv from "dotenv"; dotenv.config(); // load the values into .env file after deploying the FactoryAccount const DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY = process.env.DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY || ""; const ETH_ADDRESS = process.env.ETH_ADDRESS || "0x000000000000000000000000000000000000800A"; const ACCOUNT_ADDRESS = process.env.DEPLOYED_ACCOUNT_ADDRESS || ""; const RECEIVER_ACCOUNT = process.env.RECEIVER_ACCOUNT || ""; export default async function (hre: HardhatRuntimeEnvironment) { // @ts-ignore target zkSyncSepoliaTestnet in config file which can be testnet or local const provider = new Provider(hre.network.config.url); const owner = new Wallet(DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY, provider); // ⚠️ update this amount to test if the limit works; 0.00051 fails but 0.00049 succeeds const transferAmount = "0.00051"; let ethTransferTx = { from: ACCOUNT_ADDRESS, to: RECEIVER_ACCOUNT, // account that will receive the ETH transfer chainId: (await provider.getNetwork()).chainId, nonce: await provider.getTransactionCount(ACCOUNT_ADDRESS), type: 113, customData: { gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT, } as types.Eip712Meta, value: ethers.parseEther(transferAmount), gasPrice: await provider.getGasPrice(), gasLimit: BigInt(20000000), // constant 20M since estimateGas() causes an error and this tx consumes more than 15M at most data: "0x", }; const signedTxHash = EIP712Signer.getSignedDigest(ethTransferTx); const signature = ethers.concat([ethers.Signature.from(owner.signingKey.sign(signedTxHash)).serialized]); ethTransferTx.customData = { ...ethTransferTx.customData, customSignature: signature, }; const accountArtifact = await hre.artifacts.readArtifact("Account"); // read account limits const account = new Contract(ACCOUNT_ADDRESS, accountArtifact.abi, owner); const limitData = await account.limits(ETH_ADDRESS); console.log("Account ETH limit is: ", limitData.limit.toString()); console.log("Available today: ", limitData.available.toString()); // L1 timestamp tends to be undefined in latest blocks. So it should find the latest L1 Batch first. if (hre.network.config.ethNetwork !== 'localhost') { let l1BatchRange = await provider.getL1BatchBlockRange(await provider.getL1BatchNumber()); let l1TimeStamp = (await provider.getBlock(l1BatchRange[1])).l1BatchTimestamp; console.log('L1 timestamp: ', l1TimeStamp); console.log('Limit will reset on timestamp: ', limitData.resetTime.toString()); } // actually do the ETH transfer console.log("Sending ETH transfer from smart contract account"); const sentTx = await provider.broadcastTransaction(types.Transaction.from(ethTransferTx).serialized); await sentTx.wait(); console.log(`ETH transfer tx hash is ${sentTx.hash}`); console.log("Transfer completed and limits updated!"); const newLimitData = await account.limits(ETH_ADDRESS); console.log("Account limit: ", newLimitData.limit.toString()); console.log("Available today: ", newLimitData.available.toString()); console.log("Limit will reset on timestamp:", newLimitData.resetTime.toString()); if (newLimitData.resetTime.toString() == limitData.resetTime.toString()) { console.log("Reset time was not updated as not enough time has passed"); } else { console.log("Limit timestamp was reset"); } return; }
- Run the script to attempt to make a transfer.
npx hardhat deploy-zksync --script transferETH.ts
You should see an error message with the following content so we know it failed because the amount exceeded the limit.An unexpected error occurred: Error: transaction failed... shortMessage: 'execution reverted: "Exceed daily limit"'
You can also search the transaction in the explorer to see the error reason.
After the error, we can rerun the code with a different ETH amount that doesn't exceed the limit, say "0.00049", to see if theSpendLimit
contract doesn't refuse the amount lower than the limit.
If the transaction succeeds, the output should look something like this:Account ETH limit is: 500000000000000 Available today: 494900000000000 Limit will reset on timestamp: 1708689912 Sending ETH transfer from smart contract account ETH transfer tx hash is 0x6e7742e6555a88ca1489a06992711d413a12358f77c611cca96aba112ced812b Transfer completed and limits updated! Account limit: 500000000000000 Available today: 449000000000000 Limit will reset on timestamp: 1708690236 Current timestamp: 1708690185 Reset time was not updated as not enough time has passed
Theavailable
value in theLimit
struct updates to the initial limit minus the amount we transferred.
SinceONE_DAY
is set to 1 minute for this test in theSpendLimit.sol
contract, you should expect it to reset after 60 seconds.
Common Errors
- Insufficient gasLimit: Transactions often fail due to insufficient gasLimit. Please increase the value manually when transactions fail without clear reasons.
- Insufficient balance in account contract: transactions may fail due to the lack of balance in the deployed account
contract. Please transfer funds to the account using MetaMask or
wallet.sendTransaction()
method used indeploy/deployFactoryAccount.ts
. - Transactions submitted in a close range of time will have the same
block.timestamp
as they can be added to the same L1 batch and might cause the spend limit to not work as expected.
Learn More
- To learn more about L1->L2 interaction on ZKsync, check out the documentation.
- To learn more about the
zksync-ethers
SDK, check out its documentation. - To learn more about the ZKsync hardhat plugins, check out their documentation.
Credits
Written by porco-rosso for the GitCoin bounty.