ZkSynk Daily spending limit account Smart Contract | Деплой смарт контракта ЗкСинк*
Требования к серверу:
Также нам понадобится:
- кошелек метамаск с ETH Goerli
- Тестовая сеть zksynk era testnet (можно подключить
тут)
- Тестовые токенамы ETH в сети zksynk (кран
тут)
- Приватный ключ от метамаска (не используйте кошельки с ральными деньгами!)
- RPC ETH1 goerli (можно взять на
инфуре)
Подготавливаем сервер:
Код:
sudo apt-get update -y && sudo apt upgrade -y && sudo apt-get install make build-essential unzip lz4 gcc git jq chrony -y
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
source ~/.bashrc
nvm -v
nvm install v16.16.0
node -v
#вывод - v16.16.0
Код:
mkdir custom-spendlimit-tutorial && cd custom-spendlimit-tutorial
yarn init -y
yarn add -D typescript ts-node ethers@^5.7.2 @ethersproject/web zksync-web3 @ethersproject/hash hardhat @matterlabs/hardhat-zksync-solc @matterlabs/hardhat-zksync-deploy
yarn add -D @matterlabs/zksync-contracts @openzeppelin/contracts
mkdir contracts deploy
nano hardhat.config.ts
#вставляем:
import { HardhatUserConfig } from "hardhat/config";
import "@matterlabs/hardhat-zksync-deploy";
import "@matterlabs/hardhat-zksync-solc";
const config: HardhatUserConfig = {
zksolc: {
version: "1.3.10",
compilerSource: "binary",
settings: {
isSystem: true,
},
},
defaultNetwork: "zkSyncTestnet",
networks: {
zkSyncTestnet: {
url: "https://testnet.era.zksync.dev",
ethNetwork: "goerli", // Can also be the RPC URL of the network (e.g. `https://goerli.infura.io/v3/<API_KEY>`)
zksync: true,
},
},
solidity: {
version: "0.8.13",
},
};
export default config;
сохраняем и выходим из нано
Код:
cd contracts
nano SpendLimit.sol
#вставляем:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract SpendLimit {
uint public ONE_DAY = 24 hours;
// uint public ONE_DAY = 1 minutes; // set to 1 min for tutorial
/// 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
modifier onlyAccount() {
require(
msg.sender == address(this),
"Only the account that inherits this contract can call this method."
);
_;
}
/// 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; // L1 batch 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;
}
// 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; // L1 batch 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;
}
}
сохраняем и выходим из нано
Код:
nano Account.sol
#вставляем:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
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";
// Used for signature validation
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// Access zkSync system contracts for nonce validation via NONCE_HOLDER_SYSTEM_CONTRACT
import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol";
// to call non-view function of system contracts
import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/SystemContractsCaller.sol";
import "./SpendLimit.sol";
contract Account is IAccount, IERC1271, SpendLimit {
// to get transaction hash
using TransactionHelper for Transaction;
// state variable for account owner
address public owner;
bytes4 constant EIP1271_SUCCESS_RETURN_VALUE = 0x1626ba7e;
modifier onlyBootloader() {
require(
msg.sender == BOOTLOADER_FORMAL_ADDRESS,
"Only bootloader can call this method"
);
// Continue execution if called from the bootloader.
_;
}
constructor(address _owner) {
owner = _owner;
}
function validateTransaction(
bytes32,
bytes32 _suggestedSignedHash,
Transaction calldata _transaction
) external payable override onlyBootloader returns (bytes4 magic) {
return _validateTransaction(_suggestedSignedHash, _transaction);
}
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 are 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);
}
}
function executeTransaction(
bytes32,
bytes32,
Transaction calldata _transaction
) external payable override onlyBootloader {
_executeTransaction(_transaction);
}
function _executeTransaction(Transaction calldata _transaction) internal {
address to = address(uint160(_transaction.to));
uint128 value = Utils.safeCastToU128(_transaction.value);
bytes memory data = _transaction.data;
// Call SpendLimit contract to ensure that ETH `value` doesn't exceed the daily spending limit
if (value > 0) {
_checkSpendingLimit(address(ETH_TOKEN_SYSTEM_CONTRACT), value);
}
if (to == address(DEPLOYER_SYSTEM_CONTRACT)) {
uint32 gas = Utils.safeCastToU32(gasleft());
// Note, that the deployer contract can only be called
// with a "systemCall" 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);
}
}
function executeTransactionFromOutside(
Transaction calldata _transaction
) external payable {
_validateTransaction(bytes32(0), _transaction);
_executeTransaction(_transaction);
}
function isValidSignature(
bytes32 _hash,
bytes memory _signature
) public view override returns (bytes4 magic) {
magic = EIP1271_SUCCESS_RETURN_VALUE;
if (_signature.length != 65) {
// 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(65);
// 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));
}
// extract ECDSA signature
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) {
magic = bytes4(0);
}
// 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
) {
magic = bytes4(0);
}
address recoveredAddress = ecrecover(_hash, v, r, s);
// Note, that we should abstain from using the require here in order to allow for fee estimation to work
if (recoveredAddress != owner && recoveredAddress != address(0)) {
magic = bytes4(0);
}
}
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");
}
function prepareForPaymaster(
bytes32, // _txHash
bytes32, // _suggestedSignedHash
Transaction calldata _transaction
) external payable override onlyBootloader {
_transaction.processPaymasterInput();
}
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
}
}
сохраняем и выходим из нано
Код:
nano AAFactory.sol
#вставляем:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
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));
}
}
сохраняем и выходим из нано
Код:
cd ..
#вы должны быть в папке custom-spendlimit-tutorial
yarn hardhat compile
cd deploy
nano deployFactoryAccount.ts
#вставляем:
import { utils, Wallet, Provider } from "zksync-web3";
import * as ethers from "ethers";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { Deployer } from "@matterlabs/hardhat-zksync-deploy";
export default async function (hre: HardhatRuntimeEnvironment) {
// @ts-ignore target zkSyncTestnet in config file which can be testnet or local
const provider = new Provider(hre.config.networks.zkSyncTestnet.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.utils.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]
);
console.log(`AA factory address: ${factory.address}`);
const aaFactory = new ethers.Contract(
factory.address,
factoryArtifact.abi,
wallet
);
const owner = Wallet.createRandom();
console.log("SC Account owner pk: ", owner.privateKey);
const salt = ethers.constants.HashZero;
const tx = await aaFactory.deployAccount(salt, owner.address);
await tx.wait();
const abiCoder = new ethers.utils.AbiCoder();
const accountAddress = utils.create2Address(
factory.address,
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.utils.parseEther("0.02"),
})
).wait();
console.log(`Done!`);
}
Заменить <DEPLOYER_PRIVATE_KEY> на приватный ключ от метамаска
Сохраняем и выходим из нано
Код:
yarn hardhat deploy-zksync --script deployFactoryAccount.ts
#вывод примерно такой:
1я строка - это адесс АА
2я строка - это <DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY>
3я строка - это <DEPLOYED_ACCOUNT_ADDRESS>
Код:
nano setLimit.ts
#вставляем:
import { utils, Wallet, Provider, Contract, EIP712Signer, types,
} from "zksync-web3";
import * as ethers from "ethers";
import { HardhatRuntimeEnvironment } from "hardhat/types";
const ETH_ADDRESS = "0x000000000000000000000000000000000000800A";
const ACCOUNT_ADDRESS = "<DEPLOYED_ACCOUNT_ADDRESS>";
export default async function (hre: HardhatRuntimeEnvironment) {
// @ts-ignore target zkSyncTestnet in config file which can be testnet or local
const provider = new Provider(hre.config.networks.zkSyncTestnet.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.populateTransaction.setSpendingLimit(
ETH_ADDRESS,
ethers.utils.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: ethers.BigNumber.from(0),
};
setLimitTx.gasPrice = await provider.getGasPrice();
setLimitTx.gasLimit = await provider.estimateGas(setLimitTx);
const signedTxHash = EIP712Signer.getSignedDigest(setLimitTx);
const signature = ethers.utils.arrayify(
ethers.utils.joinSignature(owner._signingKey().signDigest(signedTxHash))
);
setLimitTx.customData = {
...setLimitTx.customData,
customSignature: signature,
};
console.log("Setting limit for account...");
const sentTx = await provider.sendTransaction(utils.serialize(setLimitTx));
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());
}
Заменить <DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY и <DEPLOYED_ACCOUNT_ADDRESS> на значения из предыдущего вывода
Сохраняем и выходим из нано
Код:
yarn hardhat deploy-zksync --script setLimit.ts
#вывод:
Код:
nano transferETH.ts
#вставляем:
import {
utils,
Wallet,
Provider,
Contract,
EIP712Signer,
types,
} from "zksync-web3";
import * as ethers from "ethers";
import { HardhatRuntimeEnvironment } from "hardhat/types";
const ETH_ADDRESS = "0x000000000000000000000000000000000000800A";
const ACCOUNT_ADDRESS = "<DEPLOYED_ACCOUNT_ADDRESS>";
export default async function (hre: HardhatRuntimeEnvironment) {
// @ts-ignore target zkSyncTestnet in config file which can be testnet or local
const provider = new Provider(hre.config.networks.zkSyncTestnet.url);
const owner = new Wallet("<DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY>", provider);
// account that will receive the ETH transfer
const receiver = "<RECEIVER_ACCOUNT>";
// ⚠️ update this amount to test if the limit works; 0.00051 fails but 0.0049 succeeds
const transferAmount = "0.00049"
let ethTransferTx = {
from: ACCOUNT_ADDRESS,
to: receiver,
chainId: (await provider.getNetwork()).chainId,
nonce: await provider.getTransactionCount(ACCOUNT_ADDRESS),
type: 113,
customData: {
ergsPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
} as types.Eip712Meta,
value: ethers.utils.parseEther(transferAmount),
gasPrice: await provider.getGasPrice(),
gasLimit: ethers.BigNumber.from(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.utils.arrayify(
ethers.utils.joinSignature(owner._signingKey().signDigest(signedTxHash))
);
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.
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.sendTransaction(utils.serialize(ethTransferTx));
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;
}
Заменить <DEPLOYED_ACCOUNT_OWNER_PRIVATE_KEY и <DEPLOYED_ACCOUNT_ADDRESS> на значения, как в предыдущем файле
Заменить <RECEIVER_ACCOUNT> на адрес кошелька получателя
Код:
yarn hardhat deploy-zksync --script deploy/transferETH.ts
Успешный вывод такой:
Напоминаю, что это четвертый контракт из серии, не забывайте выполнить первый и подписывайтесь - скоро будут еще!