ZkSynk Daily spending limit account Smart Contract | Деплой смарт контракта ЗкСинк
Это уже четвертый смарт контракт, так что если еще не деплоили ни одного, то можно начать с этого, или этого, и этого .
Требования к серверу:
я взяла СPХ31 хетцнере
Также нам понадобится:
- кошелек метамаск с 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
corepack enable corepack prepare [email protected] --activate
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