Frontend integration

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

Step 3 — Frontend Development

This section focuses on the /frontend directory.

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

.
├── README.md
├── app
│   ├── assets
│   ├── components
│   ├── constants
│   ├── context
│   ├── favicon.ico
│   ├── globals.css
│   ├── hooks
│   ├── layout.tsx
│   ├── page.tsx
│   └── types
├── next-env.d.ts
├── next.config.js
├── package.json
├── postcss.config.js
├── public
├── tailwind.config.js
└── tsconfig.json
  1. Let's spin up our frontend to see what we need to implement:
    yarn dev
    

    This will start a local server running on http://localhost:3000/. You should see something that looks similar to:
    starter frontend

Connecting our WalletButton Component

The first component we are going to interact with is the WalletButton component.

  1. Navigate to app/components/WalletButton.tsx
    cd app/components/WalletButton.tsx
    
  2. Implement the requirements for the connectWallet function
    The connectWallet function requirements indicates that we need to ensure we are connected to the correct network, connect to the users MetaMask account, store some variables using React's Context API, and then call initContracts function to instantiate our contracts. Let's proceed to do that:
    app/components/WalletButton.tsx
    const connectWallet = async () => {
      if (!networkOk) await switchNetwork();
      try {
        if ((window as any).ethereum) {
          const provider = new Web3Provider((window as any).ethereum);
          web3Context.setProvider(provider);
    
          const data = await provider.send("eth_requestAccounts", []);
    
          const signerInstance = provider.getSigner();
          web3Context.setSigner(signerInstance);
    
          setWallet({ address: data[0], acc_short: shortenAddress(data[0]) });
    
          await initContracts(provider, signerInstance);
        }
      } catch (error) {
        console.error("Error connecting DApp to your wallet");
        console.error(error);
      }
    };
    
  3. Great! Let's continue within our WalletButton component and implement the initContracts function.
    The initContracts function should instantiate our Greeter and NFT token contracts, check if the connected wallet contains our specific NFT, and if so fetch the NFT metadata from IPFS and store some variables using React's Context API. Copy and paste the below code snippet.
    app/components/WalletButton.tsx
    const initContracts = async (provider: Web3Provider, signer: Signer) => {
      if (provider && signer) {
        const greeterContract = new Contract(GREETER_ADDRESS, GREETER_CONTRACT_ABI, signer);
    
        web3Context.setGreeterContractInstance(greeterContract);
    
        const fetchedGreeting = await greeterContract.greet();
        web3Context.setGreetingMessage(fetchedGreeting);
    
        const nftContract = new Contract(NFT_CONTRACT_ADDRESS, NFT_CONTRACT_ABI, signer);
    
        const address = await signer.getAddress();
        const balance = await nftContract.balanceOf(address);
        if (balance > 0) {
          let ownedStones: PowerStoneNft[] = [];
          const ownedTokensResponse = await nftContract.tokensOfOwner(address);
    
          for (let i = 0; i < ownedTokensResponse.length; i++) {
            const tokenId = ownedTokensResponse[i];
    
            const tokenURI = await nftContract.tokenURI(tokenId);
            if (tokenURI == undefined || tokenURI == "") {
              continue;
            }
    
            const response = await fetch(tokenURI);
            if (!response.ok) {
              continue;
            }
    
            ownedStones.push((await response.json()) as PowerStoneNft);
          }
    
          web3Context.setNfts(ownedStones);
        } else {
          web3Context.setNfts([]);
        }
      }
    };
    

We can now return to our running local page and click the 'Connect Wallet' button which should connect to your MetaMask account as depicted in the below image.

Connected wallet

We have connected our wallet to our application but we now need to add our GreeterMessage and Input components.

Importing the GreeterMessage and Input Components

  1. Navigate to app/page.tsx
    Scrolling to the button you can see the requirements outlined. We need to import our components and pass the specified props. Let's first import our components at the top of the file:
    app/page.tsx
    import Greeting from "./components/GreeterMessage";
    import Input from "./components/Input";
    
  2. Add the components to the return statement and pass desired props
    app/page.tsx
    <Greeting greeting={web3Context.greeting} />
    <Input
        greeterInstance={web3Context.greeterContractInstance}
        setGreetingMessage={web3Context.setGreetingMessage}
        provider={web3Context.provider}
        nfts={web3Context.nfts}
    />
    

Now if we check our local page we can see our rendered Greeter message and Input box!

Greeter Message

Fetching the Gas details and adding the Modal Component

  1. Navigate to app/components/Input.tsx component
    We need to write our estimateGas function as we will want to pass those details to our Modal component to display.
  2. Implement estimateGas function
    We want to display the current gas price, estimate the amount of gas required to execute our setGreeting transaction, and store these variables to be used later.
    app/components/Input.tsx
    async function getEstimate() {
      if (!provider) return;
      let gasPrice = await provider.getGasPrice();
      let price = ethers.utils.formatEther(gasPrice.toString());
      setPrice(price);
    
      if (!greeterInstance) return;
      let gasEstimate = await greeterInstance.estimateGas["setGreeting"](message);
      let gas = ethers.utils.formatEther(gasEstimate.toString());
      setGas(gas);
    
      let transactionCost = gasPrice.mul(gasEstimate);
      let cost = ethers.utils.formatEther(transactionCost.toString());
      setCost(cost);
    }
    
  3. Add Modal component to return statement
    app/components/Input.tsx
    {
      isOpen && (
        <Modal closeModal={closeModal} greeterInstance={greeterInstance} message={message} setGreetingMessage={setGreetingMessage} cost={cost} price={price} gas={gas} nfts={nfts} />
      );
    }
    

This will open the Modal component once the "Change message" button is clicked.

Setup our Paymaster Hook

We are ready to implement our paymaster hook which will be used if the connected wallet possesses one of the applicable NFT's we minted earlier.

  1. Navigate to app/hooks/usePaymaster.tsx
    The requirements outline we need to prepare and return the paymasterParams to then be passed alongside the setGreeting transaction.
    app/hooks/usePaymaster.tsx
    const usePaymaster = async ({ greeterInstance, message, price }: PaymasterProps) => {
      let gasPrice = ethers.utils.parseEther(price);
      const paymasterParams = utils.getPaymasterParams(PAYMASTER_CONTRACT_ADDRESS, {
        type: "General",
        innerInput: new Uint8Array(),
      });
    
      const gasLimit = await greeterInstance.estimateGas.setGreeting(message, {
        customData: {
          gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
          paymasterParams: paymasterParams,
        },
      });
    
      return {
        maxFeePerGas: gasPrice,
        maxPriorityFeePerGas: ethers.BigNumber.from(0),
        gasLimit: gasLimit,
        customData: {
          gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
          paymasterParams: paymasterParams,
        },
      };
    };
    

We have prepared our paymasterParams to be used in our application! Let's navigate to our last component that needs to be implemented, the Checkout component.

Implement updateGreeting function in the Checkout component

This function is responsible for initiating the transaction that will interact with our Greeter.sol contract to update the message. It will check if the connected wallet contains our specific NFT and if so, call our usePaymaster hook created above to pass along the paymasterParams to facilitate a gasless transaction for the user. Without the NFT, the user will be required to pay the gas fee.

  1. Import usePaymaster hook in the Checkout component
    import usePaymaster from "../hooks/usePaymaster";
    
  2. Implement the updateGreeting function
    const updateGreeting = async ({ message }: GreeterData) => {
      try {
        if (greeterInstance == null) {
          return;
        }
    
        let txHandle;
        if (hasNFT) {
          const params = await usePaymaster({ greeterInstance, message, price });
          txHandle = await greeterInstance.setGreeting(message, params);
        } else {
          txHandle = await greeterInstance.setGreeting(message);
        }
    
        await txHandle.wait();
    
        const updatedGreeting = await greeterInstance.greet();
        setGreetingMessage(updatedGreeting);
      } catch (error) {
        console.error("Failed to update greeting: ", error);
      }
    };
    

Amazing we have successfully implemented all our frontend requirements! Now its time to test the application.

Step 4 — Test the Application

Navigate to http://localhost:3000 and refresh the page. Click on "Connect Wallet" to link your MetaMask account. Ensure you connect the address that received the minted NFT during contract deployment; otherwise, you'll bear the gas fees!

  1. Input your own message into the Input component
  2. Click the "Change message" button
  3. If you have the right NFT you should be presented with the below image: Checkout component
  4. Enjoy a gasless transaction!

Conclusion

As we conclude, you've not only set up and run a seamless integration between a frontend and ZKsync Sepolia Testnet but have also gained hands-on expertise. You've connected your MetaMask, engaged with smart contracts, and seen the magic of paymasters unfold right on your local server.

If you want to continue to learn about paymaster or see additional examples checkout the paymaster-examples repo for further inspiration.


Made with ❤️ by the ZKsync Community