diff --git a/contracts/README.md b/contracts/README.md index cf0525d..64d87b1 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -11,6 +11,7 @@ This repository contains several example Paymaster Smart Contracts that cover mo - 🎫 **[ERC20 Fixed Paymaster](./contracts/paymasters/ERC20fixedPaymaster.sol)**: Accepts a fixed amount of a specific ERC20 token in exchange for covering gas fees. It only services accounts that have a balance of the specified token. - 🎨 **[ERC721 Gated Paymaster](./contracts/paymasters/ERC721gatedPaymaster.sol)**: Pays fees for accounts that hold a specific ERC721 token (NFT). - 🎨 **[TimeBased Paymaster](./contracts/paymasters/TimeBasedPaymaster.sol)**: Pays fees for accounts that interact with contract at specific times. +- ✍🏻 **[SignatureBased Paymaster](./contracts/paymasters/SignatureBasedPaymaster.sol)**: Pays fees for accounts that provides valid signatures. Stay tuned! More Paymaster examples will be added over time. This project was scaffolded with [zksync-cli](https://github.com/matter-labs/zksync-cli). diff --git a/contracts/contracts/paymasters/SignatureBasedPaymaster.sol b/contracts/contracts/paymasters/SignatureBasedPaymaster.sol new file mode 100644 index 0000000..b37363f --- /dev/null +++ b/contracts/contracts/paymasters/SignatureBasedPaymaster.sol @@ -0,0 +1,137 @@ +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; + +import {IPaymaster, ExecutionResult, PAYMASTER_VALIDATION_SUCCESS_MAGIC} from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymaster.sol"; +import {IPaymasterFlow} from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymasterFlow.sol"; +import {TransactionHelper, Transaction} from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol"; + +import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol"; + +/// @notice This smart contract pays the gas fees on behalf of users that provide valid signature from the signer. +/// @dev This contract is controlled by an owner, who can update the signer, cancel a user's nonce and withdraw funds from contract. +contract SignatureBasedPaymaster is IPaymaster, Ownable, EIP712 { + using ECDSA for bytes32; + // Note - EIP712 Domain compliance typehash. TYPES should exactly match while signing signature to avoid signature failure. + bytes32 public constant SIGNATURE_TYPEHASH = keccak256( + "SignatureBasedPaymaster(address userAddress,uint256 lastTimestamp,uint256 nonces)" + ); + // All signatures should be validated based on signer + address public signer; + // Mapping user => nonce to guard against signature re-play attack. + mapping(address => uint256) public nonces; + + modifier onlyBootloader() { + require( + msg.sender == BOOTLOADER_FORMAL_ADDRESS, + "Only bootloader can call this method" + ); + // Continue execution if called from the bootloader. + _; + } + +/// @param _signer Sets the signer to validate against signatures +/// @dev Changes in EIP712 constructor arguments - "name","version" would update domainSeparator which should be taken into considertion while signing. + constructor(address _signer) EIP712("SignatureBasedPaymaster","1") { + require(_signer != address(0), "Signer cannot be address(0)"); + // Owner can be signer too. + signer = _signer; + } + + function validateAndPayForPaymasterTransaction( + bytes32, + bytes32, + Transaction calldata _transaction + ) + external + payable + onlyBootloader + returns (bytes4 magic, bytes memory context) + { + // By default we consider the transaction as accepted. + magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC; + require( + _transaction.paymasterInput.length >= 4, + "The standard paymaster input must be at least 4 bytes long" + ); + + bytes4 paymasterInputSelector = bytes4( + _transaction.paymasterInput[0:4] + ); + if (paymasterInputSelector == IPaymasterFlow.general.selector) { + // Note - We first need to decode innerInputs data to bytes. + (bytes memory innerInputs) = abi.decode( + _transaction.paymasterInput[4:], + (bytes) + ); + // Note - Decode the innerInputs as per encoding. Here, we have encoded lastTimestamp and signature in innerInputs + (uint lastTimestamp, bytes memory sig) = abi.decode(innerInputs,(uint256,bytes)); + + // Verify signature expiry based on timestamp. + // lastTimestamp is used in signature hash, hence cannot be faked. + require(block.timestamp <= lastTimestamp, "Paymaster: Signature expired"); + // Get user address from transaction.from + address userAddress = address(uint160(_transaction.from)); + // Generate hash + bytes32 hash = keccak256(abi.encode(SIGNATURE_TYPEHASH, userAddress,lastTimestamp, nonces[userAddress]++)); + // EIP712._hashTypedDataV4 hashes with domain separator that includes chain id. Hence prevention to signature replay atttacks. + bytes32 digest = _hashTypedDataV4(hash); + // Revert if signer not matched with recovered address. Reverts on address(0) as well. + require(signer == digest.recover(sig),"Paymaster: Invalid signer"); + + + // Note, that while the minimal amount of ETH needed is tx.gasPrice * tx.gasLimit, + // neither paymaster nor account are allowed to access this context variable. + uint256 requiredETH = _transaction.gasLimit * + _transaction.maxFeePerGas; + + // The bootloader never returns any data, so it can safely be ignored here. + (bool success, ) = payable(BOOTLOADER_FORMAL_ADDRESS).call{ + value: requiredETH + }(""); + require( + success, + "Failed to transfer tx fee to the bootloader. Paymaster balance might not be enough." + ); + } else { + revert("Unsupported paymaster flow"); + } + } + + function postTransaction( + bytes calldata _context, + Transaction calldata _transaction, + bytes32, + bytes32, + ExecutionResult _txResult, + uint256 _maxRefundedGas + ) external payable override onlyBootloader { + // Refunds are not supported yet. + } + function withdraw(address _to) external onlyOwner { + // send paymaster funds to the owner + (bool success, ) = payable(_to).call{value: address(this).balance}(""); + require(success, "Failed to withdraw funds from paymaster."); + + } + receive() external payable {} + + /// @dev Only owner should be able to change signer. + /// @param _signer New signer address + function changeSigner(address _signer) onlyOwner public { + signer = _signer; + } + /// @dev Only owner should be able to update user nonce. + /// @dev There could be a scenario where owner needs to cancel paying gas for a certain user transaction. + /// @param _userAddress user address to update the nonce. + function cancelNonce(address _userAddress) onlyOwner public { + nonces[_userAddress]++; + } + + function domainSeparator() public view returns(bytes32) { + return _domainSeparatorV4(); + } +} \ No newline at end of file diff --git a/contracts/deploy/signatureBasedPaymaster.ts b/contracts/deploy/signatureBasedPaymaster.ts new file mode 100644 index 0000000..48401b8 --- /dev/null +++ b/contracts/deploy/signatureBasedPaymaster.ts @@ -0,0 +1,76 @@ +import { Provider, Wallet } from "zksync-web3"; +import * as ethers from "ethers"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { Deployer } from "@matterlabs/hardhat-zksync-deploy"; +import { HttpNetworkUserConfig } from "hardhat/types"; + +// load env file +import dotenv from "dotenv"; + +dotenv.config(); + +// load wallet private key from env file +const PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY || ""; + +if (!PRIVATE_KEY) + throw "⛔️ Private key not detected! Add it to the .env file!"; + +export default async function (hre: HardhatRuntimeEnvironment) { + console.log( + `Running deploy script for the SignatureBasedPaymaster contract...`, + ); + // Currently targeting the Sepolia zkSync testnet + const network = hre.userConfig.networks?.zkSyncTestnet; + const provider = new Provider((network as HttpNetworkUserConfig).url); + + // The wallet that will deploy the paymaster + // It is assumed that this wallet already has sufficient funds on zkSync + const wallet = new Wallet(PRIVATE_KEY); + const deployer = new Deployer(hre, wallet); + + // Deploying the paymaster + const paymasterArtifact = await deployer.loadArtifact( + "SignatureBasedPaymaster", + ); + const deploymentFee = await deployer.estimateDeployFee(paymasterArtifact, [wallet.address]); + const parsedFee = ethers.utils.formatEther(deploymentFee.toString()); + console.log(`The deployment is estimated to cost ${parsedFee} ETH`); + // Deploy the contract with owner as signer + const paymaster = await deployer.deploy(paymasterArtifact, [wallet.address]); + console.log(`Paymaster address: ${paymaster.address}`); + console.log(`Signer of the contract: ${wallet.address}`); + + console.log("Funding paymaster with ETH"); + // Supplying paymaster with ETH + await ( + await deployer.zkWallet.sendTransaction({ + to: paymaster.address, + value: ethers.utils.parseEther("0.005"), + }) + ).wait(); + + let paymasterBalance = await provider.getBalance(paymaster.address); +// Only verify on live networks + if ( + hre.network.name == "zkSyncTestnet" || + hre.network.name == "zkSyncMainnet" + ) { + // Verify contract programmatically + // + // Contract MUST be fully qualified name (e.g. path/sourceName:contractName) + const contractFullyQualifedName = + "contracts/paymasters/SignatureBasedPaymaster.sol:SignatureBasedPaymaster"; + const verificationId = await hre.run("verify:verify", { + address: paymaster.address, + contract: contractFullyQualifedName, + constructorArguments: [wallet.address], + bytecode: paymasterArtifact.bytecode, + }); + console.log( + `${contractFullyQualifedName} verified! VerificationId: ${verificationId}`, + ); + } + console.log(`Done!`); + + +} diff --git a/contracts/package.json b/contracts/package.json index 4d5e09c..a0d03c7 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -34,6 +34,7 @@ "nftGated": "hardhat deploy-zksync --script erc721gatedPaymaster.ts", "fixedToken": "hardhat deploy-zksync --script erc20fixedPaymaster.ts", "timeBased": "hardhat deploy-zksync --script timeBasedPaymaster.ts", + "signatureBased":"hardhat deploy-zksync --script signatureBasedPaymaster.ts", "nft": "hardhat deploy-zksync --script erc721.ts", "token": "hardhat deploy-zksync --script erc20.ts", "compile": "hardhat compile", diff --git a/contracts/test/signatureBased.test.ts b/contracts/test/signatureBased.test.ts new file mode 100644 index 0000000..d294eb7 --- /dev/null +++ b/contracts/test/signatureBased.test.ts @@ -0,0 +1,283 @@ +import { expect } from "chai"; +import { Wallet, Provider, Contract, utils } from "zksync-web3"; +import hardhatConfig from "../hardhat.config"; +import { Deployer } from "@matterlabs/hardhat-zksync-deploy"; +import * as ethers from "ethers"; + +import { deployContract, fundAccount, setupDeployer } from "./utils"; + +import dotenv from "dotenv"; +import { _TypedDataEncoder } from "ethers/lib/utils"; +dotenv.config(); + +const PRIVATE_KEY = + process.env.WALLET_PRIVATE_KEY || + "0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110"; +const abiCoder = new ethers.utils.AbiCoder(); + +describe("SignatureBasedPaymaster", function () { + let provider: Provider; + let wallet: Wallet; + let deployer: Deployer; + let userWallet: Wallet; + let signerWallet: Wallet; + let paymaster: Contract; + let greeter: Contract; + + before(async function () { + const deployUrl = hardhatConfig.networks.zkSyncInMemory.url; + [provider, wallet, deployer] = setupDeployer(deployUrl, PRIVATE_KEY); + const emptyWallet = Wallet.createRandom(); + console.log(`User wallet's address: ${emptyWallet.address}`); + userWallet = new Wallet(emptyWallet.privateKey, provider); + signerWallet = Wallet.createRandom(); + console.log(`Signer wallet's address: ${signerWallet.address}`); + signerWallet = new Wallet(signerWallet.privateKey, provider); + paymaster = await deployContract(deployer, "SignatureBasedPaymaster", [ + signerWallet.address, + ]); + greeter = await deployContract(deployer, "Greeter", ["Hi"]); + await fundAccount(wallet, paymaster.address, "3"); + console.log(`Paymaster current signer: ${signerWallet.address}`); + }); + + async function createSignatureData( + signer: Wallet, + user: Wallet, + expiryInSeconds: number, + ) { + const nonce = await paymaster.nonces(user.address); + const typeHash = await paymaster.SIGNATURE_TYPEHASH(); + const eip712Domain = await paymaster.eip712Domain(); + const currentTimestamp = (await provider.getBlock("latest")).timestamp; + const lastTimestamp = currentTimestamp + expiryInSeconds; // 300 seconds + + const domain = { + name: eip712Domain[1], + version: eip712Domain[2], + chainId: eip712Domain[3], + verifyingContract: eip712Domain[4], + }; + // @dev : Upon changing types here, you need to ensure that + // `SIGNATURE_TYPEHASH` constant in signatureBasedPaymaster contract matches the changes + // Otherwise EIP712Domain based _signedTypedData will return wrong signatures. + // And test will fail. + const types = { + SignatureBasedPaymaster: [ + { name: "userAddress", type: "address" }, + { name: "lastTimestamp", type: "uint256" }, + { name: "nonces", type: "uint256" }, + ], + }; + const values = { + userAddress: user.address, + lastTimestamp: lastTimestamp, + nonces: nonce, + }; + + const signature = await signer._signTypedData(domain, types, values); + return [signature, lastTimestamp]; + } + + async function executeGreetingTransaction( + user: Wallet, + _innerInput: Uint8Array, + ) { + const gasPrice = await provider.getGasPrice(); + + const paymasterParams = utils.getPaymasterParams(paymaster.address, { + type: "General", + innerInput: _innerInput, + }); + + const setGreetingTx = await greeter + .connect(user) + .setGreeting("Hola, mundo!", { + maxPriorityFeePerGas: ethers.BigNumber.from(0), + maxFeePerGas: gasPrice, + gasLimit: 6000000, + customData: { + gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT, + paymasterParams, + }, + }); + + await setGreetingTx.wait(); + } + + it("should allow user to use paymaster if signature valid and used before expiry and nonce should be updated", async function () { + const expiryInSeconds = 300; + const beforeNonce = await paymaster.nonces(userWallet.address); + const [sig, lastTimestamp] = await createSignatureData( + signerWallet, + userWallet, + expiryInSeconds, + ); + + const innerInput = ethers.utils.arrayify( + abiCoder.encode(["uint256", "bytes"], [lastTimestamp, sig]), + ); + await executeGreetingTransaction(userWallet, innerInput); + const afterNonce = await paymaster.nonces(userWallet.address); + expect(afterNonce - beforeNonce).to.be.eq(1); + expect(await greeter.greet()).to.equal("Hola, mundo!"); + }); + + it("should fail to use paymaster if signature signed with invalid signer", async function () { + // Arrange + let errorOccurred = false; + const expiryInSeconds = 300; + const invalidSigner = Wallet.createRandom(); + const [sig, lastTimestamp] = await createSignatureData( + invalidSigner, + userWallet, + expiryInSeconds, + ); + + const innerInput = ethers.utils.arrayify( + abiCoder.encode(["uint256", "bytes"], [lastTimestamp, sig]), + ); + // Act + try { + await executeGreetingTransaction(userWallet, innerInput); + } catch (error) { + errorOccurred = true; + expect(error.message).to.include("Paymaster: Invalid signer"); + } + // Assert + expect(errorOccurred).to.be.true; + }); + + it("should fail to use paymaster if signature expired as lastTimestamp is passed ", async function () { + // Arrange + + let errorOccurred = false; + const expiryInSeconds = 300; + const [sig, lastTimestamp] = await createSignatureData( + signerWallet, + userWallet, + expiryInSeconds, + ); + + const innerInput = ethers.utils.arrayify( + abiCoder.encode(["uint256", "bytes"], [lastTimestamp, sig]), + ); + let newTimestamp: number = +lastTimestamp + 1; + await provider.send("evm_increaseTime", [newTimestamp]); + await provider.send("evm_mine", []); + + // Act + + try { + await executeGreetingTransaction(userWallet, innerInput); + } catch (error) { + errorOccurred = true; + expect(error.message).to.include("Paymaster: Signature expired"); + } + // Assert + expect(errorOccurred).to.be.true; + }); + + it("should fail to use paymaster if nonce updated by the owner to prevent any malicious transaction", async function () { + // Arrange + + let errorOccurred = false; + const expiryInSeconds = 300; + const [sig, lastTimestamp] = await createSignatureData( + signerWallet, + userWallet, + expiryInSeconds, + ); + + const innerInput = ethers.utils.arrayify( + abiCoder.encode(["uint256", "bytes"], [lastTimestamp, sig]), + ); + // Act + await paymaster.cancelNonce(userWallet.address); + + try { + await executeGreetingTransaction(userWallet, innerInput); + } catch (error) { + errorOccurred = true; + expect(error.message).to.include("Paymaster: Invalid signer"); + } + // Assert + expect(errorOccurred).to.be.true; + }); + + it("should fail to use paymaster if signature used by different user", async function () { + // Arrange + + let errorOccurred = false; + const expiryInSeconds = 300; + const [sig, lastTimestamp] = await createSignatureData( + signerWallet, + userWallet, + expiryInSeconds, + ); + + const innerInput = ethers.utils.arrayify( + abiCoder.encode(["uint256", "bytes"], [lastTimestamp, sig]), + ); + // Act + try { + await executeGreetingTransaction(wallet, innerInput); + } catch (error) { + errorOccurred = true; + expect(error.message).to.include("Paymaster: Invalid signer"); + } + // Assert + expect(errorOccurred).to.be.true; + }); + + it("should allow owner to update to newSigner and function properly as well", async function () { + // Arrange + + let errorOccurred = false; + const newSigner = Wallet.createRandom(); + await paymaster.changeSigner(newSigner.address); + + const expiryInSeconds = 300; + const [sig, lastTimestamp] = await createSignatureData( + newSigner, + userWallet, + expiryInSeconds, + ); + + const innerInput = ethers.utils.arrayify( + abiCoder.encode(["uint256", "bytes"], [lastTimestamp, sig]), + ); + // Act + try { + await executeGreetingTransaction(userWallet, innerInput); + } catch (error) { + errorOccurred = true; + } + // Assert + expect(errorOccurred).to.be.false; + + // Revert back to original signer for other test to pass. + await paymaster.changeSigner(signerWallet.address); + }); + + it("should prevent non-owners from withdrawing funds", async function () { + try { + await paymaster.connect(userWallet).withdraw(userWallet.address); + } catch (e) { + expect(e.message).to.include("Ownable: caller is not the owner"); + } + }); + + it("should allow owner to withdraw all funds", async function () { + try { + const tx = await paymaster.connect(wallet).withdraw(userWallet.address); + await tx.wait(); + } catch (e) { + console.error("Error executing withdrawal:", e); + } + + const finalContractBalance = await provider.getBalance(paymaster.address); + + expect(finalContractBalance).to.eql(ethers.BigNumber.from(0)); + }); +}); diff --git a/package.json b/package.json index 8628a56..66df14c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "deploy:nftGated": "yarn workspace contracts nftGated", "deploy:fixedToken": "yarn workspace contracts fixedToken", "deploy:timeBased": "yarn workspace contracts timeBased", + "deploy:signatureBased":"yarn workspace contracts signatureBased", "deploy:nft": "yarn workspace contracts nft", "deploy:token": "yarn workspace contracts token", "serve:ui": "yarn workspace frontend dev",