From db11bffd70714bef74e4eb5bb6efe1bdec6c7680 Mon Sep 17 00:00:00 2001 From: hoshiyari Date: Sun, 10 Mar 2024 16:12:12 +0530 Subject: [PATCH 1/5] feat: add signatureBasedPaymaster contract --- .../paymasters/SignatureBasedPaymaster.sol | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 contracts/contracts/paymasters/SignatureBasedPaymaster.sol diff --git a/contracts/contracts/paymasters/SignatureBasedPaymaster.sol b/contracts/contracts/paymasters/SignatureBasedPaymaster.sol new file mode 100644 index 0000000..8bc0e3c --- /dev/null +++ b/contracts/contracts/paymasters/SignatureBasedPaymaster.sol @@ -0,0 +1,121 @@ +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"; + +contract SignatureBasedPaymaster is IPaymaster, Ownable, EIP712 { + address public signer; + mapping(address => uint256) public nonces; + using ECDSA for bytes32; + bytes32 public constant SIGNATURE_TYPEHASH = keccak256( + "SignatureBasedPaymaster(address userAddress,uint256 lastTimestamp,uint256 nonces)" + ); + + modifier onlyBootloader() { + require( + msg.sender == BOOTLOADER_FORMAL_ADDRESS, + "Only bootloader can call this method" + ); + // Continue execution if called from the bootloader. + _; + } + + constructor(address _signer) EIP712("SignatureBasedPaymaster","1") { + require(_signer != address(0), "Signer cannot be address(0)"); + 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) { + + (bytes memory innerInputs) = abi.decode( + _transaction.paymasterInput[4:], + (bytes) + ); + (uint lastTimestamp, bytes memory sig) = abi.decode(innerInputs,(uint256,bytes)); + + // Verify if token is the correct one + require(block.timestamp <= lastTimestamp, "Paymaster: Signature expired"); + // We verify that the user has provided enough allowance + address userAddress = address(uint160(_transaction.from)); + bytes32 hash = keccak256(abi.encode(SIGNATURE_TYPEHASH, userAddress,lastTimestamp, nonces[userAddress]++)); + + bytes32 digest = _hashTypedDataV4(hash); + 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 {} + + function changeSigner(address _signer) onlyOwner public{ + signer = _signer; + } + function cancelNonce(address _userAddress) onlyOwner public{ + nonces[_userAddress]++; + } + + function domainSeparator() public view returns(bytes32){ + return _domainSeparatorV4(); + } +} \ No newline at end of file From eca49e35e24c3989be1e528aa9ec600ff7614b30 Mon Sep 17 00:00:00 2001 From: hoshiyari Date: Sun, 10 Mar 2024 22:44:39 +0530 Subject: [PATCH 2/5] feat: add unit testing, deploy script, update package.json scripts --- contracts/deploy/signatureBasedPaymaster.ts | 73 +++++ contracts/package.json | 1 + contracts/test/signatureBased.test.ts | 290 ++++++++++++++++++++ package.json | 1 + 4 files changed, 365 insertions(+) create mode 100644 contracts/deploy/signatureBasedPaymaster.ts create mode 100644 contracts/test/signatureBased.test.ts diff --git a/contracts/deploy/signatureBasedPaymaster.ts b/contracts/deploy/signatureBasedPaymaster.ts new file mode 100644 index 0000000..c0a669a --- /dev/null +++ b/contracts/deploy/signatureBasedPaymaster.ts @@ -0,0 +1,73 @@ +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 token and 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); + console.log(`Paymaster ETH balance is now ${paymasterBalance.toString()}`); + + // 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..15ae9a3 --- /dev/null +++ b/contracts/test/signatureBased.test.ts @@ -0,0 +1,290 @@ +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}`); + console.log("--------------------------------"); + }); + + async function createSignatureData( + signer: Wallet, + user: Wallet, + delay: 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 + delay; // 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); + + console.log("Signature generated successfully !"); + //console.log("Signer : " + signer.address); + //console.log("Paymaster : "+ paymaster.address); + //console.log(`Signature valid till: ${lastTimestamp} for user: ${user.address} and nonce: ${nonce}`); + 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 delay and nonce should be updated", async function () { + let errorOccurred = false; + const delay = 300; + const beforeNonce = await paymaster.nonces(userWallet.address); + const [sig, lastTimestamp] = await createSignatureData( + signerWallet, + userWallet, + delay, + ); + + 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 delay = 300; + const invalidSigner = Wallet.createRandom(); + const [sig, lastTimestamp] = await createSignatureData( + invalidSigner, + userWallet, + delay, + ); + + 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 delay is passed ", async function () { + // Arrange + + let errorOccurred = false; + const delay = 300; + const [sig, lastTimestamp] = await createSignatureData( + signerWallet, + userWallet, + delay, + ); + + const innerInput = ethers.utils.arrayify( + abiCoder.encode(["uint256", "bytes"], [lastTimestamp, sig]), + ); + let newTimestamp: number = +lastTimestamp + 1; + await provider.send("evm_increaseTime", [delay + 1]); + 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 delay = 300; + const [sig, lastTimestamp] = await createSignatureData( + signerWallet, + userWallet, + delay, + ); + + 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 delay = 300; + const [sig, lastTimestamp] = await createSignatureData( + signerWallet, + userWallet, + delay, + ); + + 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 delay = 300; + const [sig, lastTimestamp] = await createSignatureData( + newSigner, + userWallet, + delay, + ); + + 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", From 9aa5b7c56b0a5dbbffdf6096b4d24a78ea53f1c3 Mon Sep 17 00:00:00 2001 From: hoshiyari Date: Mon, 11 Mar 2024 01:05:04 +0530 Subject: [PATCH 3/5] feat: update readme --- contracts/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/README.md b/contracts/README.md index cf0525d..e841688 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. (Not supported for Sepolia testnet) Stay tuned! More Paymaster examples will be added over time. This project was scaffolded with [zksync-cli](https://github.com/matter-labs/zksync-cli). From 45e90d79cb61e893f25a68dfb544e98f89fa6dc6 Mon Sep 17 00:00:00 2001 From: hoshiyari Date: Mon, 11 Mar 2024 02:47:00 +0530 Subject: [PATCH 4/5] fix: update comments --- .../contracts/paymasters/SignatureBasedPaymaster.sol | 8 +++++--- contracts/deploy/signatureBasedPaymaster.ts | 2 +- contracts/test/signatureBased.test.ts | 3 +-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/contracts/contracts/paymasters/SignatureBasedPaymaster.sol b/contracts/contracts/paymasters/SignatureBasedPaymaster.sol index 8bc0e3c..386df6d 100644 --- a/contracts/contracts/paymasters/SignatureBasedPaymaster.sol +++ b/contracts/contracts/paymasters/SignatureBasedPaymaster.sol @@ -61,12 +61,14 @@ contract SignatureBasedPaymaster is IPaymaster, Ownable, EIP712 { ); (uint lastTimestamp, bytes memory sig) = abi.decode(innerInputs,(uint256,bytes)); - // Verify if token is the correct one + // Verify signature expiry based on timestamp. + // Timestamp is used in signature hash, hence cannot be faked. require(block.timestamp <= lastTimestamp, "Paymaster: Signature expired"); - // We verify that the user has provided enough allowance + // 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]++)); - + // Hashing with domain separator includes chain id. Hence prevention to signature replay atttacks. bytes32 digest = _hashTypedDataV4(hash); require(signer == digest.recover(sig),"Paymaster: Invalid signer"); diff --git a/contracts/deploy/signatureBasedPaymaster.ts b/contracts/deploy/signatureBasedPaymaster.ts index c0a669a..6f075b7 100644 --- a/contracts/deploy/signatureBasedPaymaster.ts +++ b/contracts/deploy/signatureBasedPaymaster.ts @@ -23,7 +23,7 @@ export default async function (hre: HardhatRuntimeEnvironment) { const network = hre.userConfig.networks?.zkSyncTestnet; const provider = new Provider((network as HttpNetworkUserConfig).url); - // The wallet that will deploy the token and the paymaster + // 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); diff --git a/contracts/test/signatureBased.test.ts b/contracts/test/signatureBased.test.ts index 15ae9a3..729c9a7 100644 --- a/contracts/test/signatureBased.test.ts +++ b/contracts/test/signatureBased.test.ts @@ -39,7 +39,6 @@ describe("SignatureBasedPaymaster", function () { greeter = await deployContract(deployer, "Greeter", ["Hi"]); await fundAccount(wallet, paymaster.address, "3"); console.log(`Paymaster current signer: ${signerWallet.address}`); - console.log("--------------------------------"); }); async function createSignatureData( @@ -78,7 +77,7 @@ describe("SignatureBasedPaymaster", function () { const signature = await signer._signTypedData(domain, types, values); - console.log("Signature generated successfully !"); + //console.log("Signature generated successfully !"); //console.log("Signer : " + signer.address); //console.log("Paymaster : "+ paymaster.address); //console.log(`Signature valid till: ${lastTimestamp} for user: ${user.address} and nonce: ${nonce}`); From f63bbf5b8b3b6e42f25a0b6eea42c19b86aa2909 Mon Sep 17 00:00:00 2001 From: hoshiyari Date: Wed, 13 Mar 2024 13:54:43 +0530 Subject: [PATCH 5/5] fix: addressed requested changes --- contracts/README.md | 2 +- .../paymasters/SignatureBasedPaymaster.sol | 34 +++++++++++----- contracts/deploy/signatureBasedPaymaster.ts | 37 +++++++++-------- contracts/test/signatureBased.test.ts | 40 ++++++++----------- 4 files changed, 62 insertions(+), 51 deletions(-) diff --git a/contracts/README.md b/contracts/README.md index e841688..64d87b1 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -11,7 +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. (Not supported for Sepolia testnet) +- ✍🏻 **[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 index 386df6d..b37363f 100644 --- a/contracts/contracts/paymasters/SignatureBasedPaymaster.sol +++ b/contracts/contracts/paymasters/SignatureBasedPaymaster.sol @@ -11,13 +11,18 @@ import {TransactionHelper, Transaction} from "@matterlabs/zksync-contracts/l2/sy 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 { - address public signer; - mapping(address => uint256) public nonces; 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( @@ -28,8 +33,11 @@ contract SignatureBasedPaymaster is IPaymaster, Ownable, EIP712 { _; } +/// @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; } @@ -54,22 +62,24 @@ contract SignatureBasedPaymaster is IPaymaster, Ownable, EIP712 { _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. - // Timestamp is used in signature hash, hence cannot be faked. + // 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]++)); - // Hashing with domain separator includes chain id. Hence prevention to signature replay atttacks. + // 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"); @@ -101,23 +111,27 @@ contract SignatureBasedPaymaster is IPaymaster, Ownable, EIP712 { ) external payable override onlyBootloader { // Refunds are not supported yet. } - function withdraw(address _to) external onlyOwner { + 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 {} - function changeSigner(address _signer) onlyOwner public{ + /// @dev Only owner should be able to change signer. + /// @param _signer New signer address + function changeSigner(address _signer) onlyOwner public { signer = _signer; } - function cancelNonce(address _userAddress) onlyOwner public{ + /// @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){ + 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 index 6f075b7..48401b8 100644 --- a/contracts/deploy/signatureBasedPaymaster.ts +++ b/contracts/deploy/signatureBasedPaymaster.ts @@ -50,23 +50,26 @@ export default async function (hre: HardhatRuntimeEnvironment) { ).wait(); let paymasterBalance = await provider.getBalance(paymaster.address); - console.log(`Paymaster ETH balance is now ${paymasterBalance.toString()}`); - - // 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}`, - ); - +// 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/test/signatureBased.test.ts b/contracts/test/signatureBased.test.ts index 729c9a7..d294eb7 100644 --- a/contracts/test/signatureBased.test.ts +++ b/contracts/test/signatureBased.test.ts @@ -44,13 +44,13 @@ describe("SignatureBasedPaymaster", function () { async function createSignatureData( signer: Wallet, user: Wallet, - delay: number, + 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 + delay; // 300 seconds + const lastTimestamp = currentTimestamp + expiryInSeconds; // 300 seconds const domain = { name: eip712Domain[1], @@ -76,11 +76,6 @@ describe("SignatureBasedPaymaster", function () { }; const signature = await signer._signTypedData(domain, types, values); - - //console.log("Signature generated successfully !"); - //console.log("Signer : " + signer.address); - //console.log("Paymaster : "+ paymaster.address); - //console.log(`Signature valid till: ${lastTimestamp} for user: ${user.address} and nonce: ${nonce}`); return [signature, lastTimestamp]; } @@ -110,14 +105,13 @@ describe("SignatureBasedPaymaster", function () { await setGreetingTx.wait(); } - it("should allow user to use paymaster if signature valid and used before delay and nonce should be updated", async function () { - let errorOccurred = false; - const delay = 300; + 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, - delay, + expiryInSeconds, ); const innerInput = ethers.utils.arrayify( @@ -132,12 +126,12 @@ describe("SignatureBasedPaymaster", function () { it("should fail to use paymaster if signature signed with invalid signer", async function () { // Arrange let errorOccurred = false; - const delay = 300; + const expiryInSeconds = 300; const invalidSigner = Wallet.createRandom(); const [sig, lastTimestamp] = await createSignatureData( invalidSigner, userWallet, - delay, + expiryInSeconds, ); const innerInput = ethers.utils.arrayify( @@ -154,22 +148,22 @@ describe("SignatureBasedPaymaster", function () { expect(errorOccurred).to.be.true; }); - it("should fail to use paymaster if signature expired as delay is passed ", async function () { + it("should fail to use paymaster if signature expired as lastTimestamp is passed ", async function () { // Arrange let errorOccurred = false; - const delay = 300; + const expiryInSeconds = 300; const [sig, lastTimestamp] = await createSignatureData( signerWallet, userWallet, - delay, + expiryInSeconds, ); const innerInput = ethers.utils.arrayify( abiCoder.encode(["uint256", "bytes"], [lastTimestamp, sig]), ); let newTimestamp: number = +lastTimestamp + 1; - await provider.send("evm_increaseTime", [delay + 1]); + await provider.send("evm_increaseTime", [newTimestamp]); await provider.send("evm_mine", []); // Act @@ -188,11 +182,11 @@ describe("SignatureBasedPaymaster", function () { // Arrange let errorOccurred = false; - const delay = 300; + const expiryInSeconds = 300; const [sig, lastTimestamp] = await createSignatureData( signerWallet, userWallet, - delay, + expiryInSeconds, ); const innerInput = ethers.utils.arrayify( @@ -215,11 +209,11 @@ describe("SignatureBasedPaymaster", function () { // Arrange let errorOccurred = false; - const delay = 300; + const expiryInSeconds = 300; const [sig, lastTimestamp] = await createSignatureData( signerWallet, userWallet, - delay, + expiryInSeconds, ); const innerInput = ethers.utils.arrayify( @@ -243,11 +237,11 @@ describe("SignatureBasedPaymaster", function () { const newSigner = Wallet.createRandom(); await paymaster.changeSigner(newSigner.address); - const delay = 300; + const expiryInSeconds = 300; const [sig, lastTimestamp] = await createSignatureData( newSigner, userWallet, - delay, + expiryInSeconds, ); const innerInput = ethers.utils.arrayify(