NFT and paymaster contracts

Build a frontend that allows users to interact smart contracts with zero transaction fees if they hold an NFT.

Step 2 — Contract Development

Implementing ERC721.sol Mint Function

Quick Tip: Always be in the /zksync directory for this section.

Let’s first break down the project structure within /zksync:

├── LICENSE
├── README.md
├── contracts
├── deploy
├── hardhat.config.ts
├── package.json
├── test
└── yarn.lock

The template provides a ready-to-use hardhat.config.ts file that targets ZKsync Sepolia Testnet. If you are unfamiliar with ZKsync Era Hardhat configurations please refer to the documentation here.

  1. To configure your private key, copy the .env.example file, rename the copy to .env, and add your wallet private key.
    WALLET_PRIVATE_KEY=YourPrivateKeyHere...
    

    Your private key will be used for paying the costs of deploying the smart contract.
    A heads up! Make sure your account has ZKsync Sepolia Testnet or Sepolia ETH to successfully deploy the contracts.
  2. Navigate to /contracts directory and open up the ERC721.sol contract. We will implement the missing logic for the mint function.
    The contract code defines an ERC721 collection and allows the owner to mint a collectible to a provided recipient.
    The skeleton contract looks like this:
    ERC721.sol
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
    
    import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
    import "@openzeppelin/contracts/access/Ownable.sol";
    import "@openzeppelin/contracts/utils/Strings.sol";
    
    contract InfinityStones is ERC721URIStorage, Ownable {
        uint256 public tokenId;
        string public baseURI;
        mapping (string => bool) public stoneExists;
        mapping (address => uint256[]) private _ownedTokens;
    
        string[] public stones = [
            "Space Stone",
            "Mind Stone",
            "Reality Stone",
            "Power Stone",
            "Time Stone",
            "Soul Stone"
        ];
    
        constructor() ERC721("InfinityStones", "ISTN") {}
    
        function mint(address recipient, string memory stoneName) public onlyOwner {
            // TODO: TO BE IMPLEMENTED
            // REQUIREMENTS:
            // 1. Only the owner of the contract can mint
            // 2. The stone name must be one of the 6 stones
            // 3. The stone name must not have been minted before
            // 4. The stoneName cannot be empty
            // 5. The recipient must be a valid non-zero address
            // 6. We must add the token to the list of tokens owned by the recipient
        }
    
        function setBaseURI(string memory _baseURI) public onlyOwner {
            baseURI = _baseURI;
        }
    
        function tokenURI(uint256 _tokenId) public view override returns (string memory) {
            require(_exists(_tokenId), "ERC721URIStorage: URI query for nonexistent token");
            return bytes(baseURI).length > 0 ? string(abi.encodePacked(baseURI, "/", Strings.toString(_tokenId))) : "";
        }
    
        function tokensOfOwner(address owner) public view returns (uint256[] memory) {
            return _ownedTokens[owner];
        }
    }
    

The function contains outlined requirements to assist in our implementation. We need to first make some checks to ensure that the stoneName is not empty, that the recipient address is not a zero address, and ensure that the stoneName has not been minted before. Let's start with that.

ERC721.sol
function mint(address recipient, string memory stoneName) public onlyOwner {
    require(bytes(stoneName).length > 0, "stoneName must not be empty");
    require(recipient != address(0), "recipient must not be the zero address");
    require(!stoneExists[stoneName], "This stone already exists");

    // TODO: MORE TO BE IMPLEMENTED
}

Great! A few criteria are met, but we aren't done yet. We still need to ensure the stoneName is 1 of 6 stones, and update our owners mapping. Lets do that now.

ERC721.sol
function mint(address recipient, string memory stoneName) public onlyOwner {
    require(bytes(stoneName).length > 0, "stoneName must not be empty");
    require(recipient != address(0), "recipient must not be the zero address");
    require(!stoneExists[stoneName], "This stone already exists");

    for(uint i=0; i<stones.length; i++) {
        if(keccak256(bytes(stoneName)) == keccak256(bytes(stones[i]))) {
            stoneExists[stoneName] = true;
            _safeMint(recipient, tokenId);
            _ownedTokens[recipient].push(tokenId);
            _setTokenURI(tokenId, stoneName);
            tokenId++;
            break;
        }
    }
}

We have now fully implemented our mint function on the ERC721.sol contract. We can now proceed to implement the validation logic on the ERC721GatedPaymaster.sol contract. Let's open that file up and move to the next steps.

Implementing validateAndPayForPaymasterTransaction Function

The skeleton contract looks like this:

ERC721GatedPaymaster.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.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";

/// @author Matter Labs
/// @notice This smart contract pays the gas fees on behalf of users that are the owner of a specific NFT asset
contract ERC721GatedPaymaster is IPaymaster, Ownable {
    IERC721 private immutable nft_asset;

    modifier onlyBootloader() {
        require(
            msg.sender == BOOTLOADER_FORMAL_ADDRESS,
            "Only bootloader can call this method"
        );
        // Continue execution if called from the bootloader.
        _;
    }

    // The constructor takes the address of the ERC721 contract as an argument.
    // The ERC721 contract is the asset that the user must hold in order to use the paymaster.
    constructor(address _erc721) {
        nft_asset = IERC721(_erc721); // Initialize the ERC721 contract
    }

    // The gas fees will be paid for by the paymaster if the user is the owner of the required NFT asset.
    function validateAndPayForPaymasterTransaction(
        bytes32,
        bytes32,
        Transaction calldata _transaction
    )
        external
        payable
        onlyBootloader
        returns (bytes4 magic, bytes memory context)
    {
        // TODO: TO BE IMPLEMENTED
        // REQUIREMENTS:
        // 1. Only the bootloader can validate and pay for the paymaster transaction.
        // 2. The standard paymaster input must be at least 4 bytes long.
        // 3. We must use a valid paymaster input selector (e.g. General or Approval-based).
        // 4. The user address from the transaction must own the required NFT asset to use the paymaster.
        // 5. We need to calculate the minimum required ETH value to pay for the transaction.
        // 6. We need to to use the Bootloader to execute the transaction.
    }

    function postTransaction(
        bytes calldata _context,
        Transaction calldata _transaction,
        bytes32,
        bytes32,
        ExecutionResult _txResult,
        uint256 _maxRefundedGas
    ) external payable override onlyBootloader {
    }

    function withdraw(address payable _to) external onlyOwner {
        // send paymaster funds to the owner
        uint256 balance = address(this).balance;
        (bool success, ) = _to.call{value: balance}("");
        require(success, "Failed to withdraw funds from paymaster.");
    }

    receive() external payable {}
}
  • Only the bootloader is allowed to call the validateAndPayForPaymasterTransaction and postTransaction functions.
  • To implement that, the onlyBootloader modifier is used on these functions.

Parsing the Paymaster Input

The paymaster pays the transaction fees only if the user possesses one of NFT's from our previous contract.

The input that the paymaster receives is encoded in the paymasterInput within the validateAndPayForPaymasterTransaction function.

As described in the paymaster documentation, there are standardized ways to encode user interactions with paymasterInput. To cover the gas costs of a user, we need to ensure the user has the appropriate NFT in their account.

  1. Firstly, we check that the paymasterInput is encoded and uses the General flow, and that the account address has a balance of at least 1 of the required NFTs.
    ERC721GatedPaymaster.sol
    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) {
        address userAddress = address(uint160(_transaction.from));
    
        require(
            nft_asset.balanceOf(userAddress) > 0,
            "User does not hold the required NFT asset and therefore must pay for their own gas!"
        );
    
        //
        // ...
        //
    } else {
        revert("Unsupported paymaster flow");
    }
    
  2. Next, we check the price of transaction fees, and transfer the correspondent gas fee from the paymaster to the bootloader to cover the transaction fees.
    ERC721GatedPaymaster.sol
    // 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;
    
    (bool success, ) = payable(BOOTLOADER_FORMAL_ADDRESS).call{
        value: requiredETH
    }("");
    

    The full validateAndPayForPaymasterTransaction function should resemble the following:
    ERC721GatedPaymaster.sol
    function validateAndPayForPaymasterTransaction(
        bytes32,
        bytes32,
        Transaction calldata _transaction
    )
        external
        payable
        onlyBootloader
        returns (bytes4 magic, bytes memory context)
    {
        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) {
            address userAddress = address(uint160(_transaction.from));
    
            require(
                nft_asset.balanceOf(userAddress) > 0,
                "User does not hold the required NFT asset and therefore must pay for their own gas!"
            );
    
            uint256 requiredETH = _transaction.gasLimit *
                _transaction.maxFeePerGas;
    
            (bool success, ) = payable(BOOTLOADER_FORMAL_ADDRESS).call{
                value: requiredETH
            }("");
        } else {
            revert("Invalid paymaster flow");
        }
    }
    

Amazing! We have successfully written the smart contracts let's proceed to deploy them using the deployment scripts provided.

Contract Deployment

The deployment scripts provided will deploy the ERC721.sol, ERC721GatedPaymaster.sol, and Greeter.sol contracts. You will need to provide the deployment script with an address to receive the NFT which we will be required to make use of the paymaster. But before we deploy the contracts we first need to compile the contracts.

  1. Navigate to the root directory of the repository and run:
    yarn compile:contracts
    

    The output of the command should resemble the following:
    Successfully compiled 43 Solidity files
    
  2. Deploy the contracts to ZKsync Sepolia Testnet. This will also programmatically verify the contracts on ZKsync Sepolia Testnet.
    yarn deploy:contracts
    

    You will be prompted to input the recipient public address to receive a NFT.
    Running deploy script for the ERC721 contract...
    You first need to add a RECIPIENT_ADDRESS to mint the NFT to...
    Please provide the recipient address to receive an NFT: <INSERT_PUBLIC_ADDRESS_HERE>
    

    After inserting the recipient address the script will continue:
    Running deploy script for the ERC721 contract...
    You first need to add a RECIPIENT_ADDRESS to mint the NFT to...
    Please provide the recipient address to receive an NFT: 0xf0e0d7709a335C2DD712F4F0F907017886B26707
    NFT Contract address: 0xb38b08fC34313A5Be7975FFE2C63F78f843325c1
    The Power Stone has been given to 0xf0e0d7709a335C2DD712F4F0F907017886B26707
    Balance of the recipient: 1
    New baseURI is https://ipfs.io/ipfs/QmPtDtJEJDzxthbKmdgvYcLa9oNUUUkh7vvz5imJFPQdKx
    Your verification ID is: 34297
    Contract successfully verified on ZKsync block explorer!
    contracts/ERC721.sol:InfinityStones verified! VerificationId: 34297
    Done!
    Running deploy script for the ERC721GatedPaymaster contract...
    The deployment is estimated to cost 0.0001577065 ETH
    Paymaster address: 0x83F1C9e8f03C5A756e3eed38823A14d1D6dA6f98
    Funding paymaster with ETH
    Paymaster ETH balance is now 5000000000000000
    Your verification ID is: 34298
    Contract successfully verified on ZKsync block explorer!
    contracts/ERC721GatedPaymaster.sol:ERC721GatedPaymaster verified! VerificationId: 34298
    Done!
    Running deploy script for the Greeter contract
    The deployment is estimated to cost 0.000140617 ETH
    Constructor args:0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000094869207468657265210000000000000000000000000000000000000000000000
    Greeter was deployed to 0x19720a45b7aB632Cc380A33E0964bc90013CCB2e
    Your verification ID is: 34299
    Contract successfully verified on ZKsync block explorer!
    Done!
    

We have successfully compiled and deployed our smart contracts to ZKsync Sepolia Testnet! Let's move over to the /frontend directory to interact with these smart contracts.


Made with ❤️ by the ZKsync Community