Creating a Token-Gated Web Page With Clarity

Creating a Token-Gated Web Page With Clarity

This tutorial shows how to create a token-gated website using Clarity as the smart contract language. A token-gated website serves content to users with a certain amount of tokens in their wallets.

In this tutorial, we will create two Clarity smart contracts, FanToken and TokenGatedCommunity. Holders of the FanToken can join a community on-chain in the TokenGatedCommunity contract, and our frontend will serve users different content based on membership in the TokenGatedCommunity.

The diagram below illustrates the functionalities of the smart contracts we will be creating.

We will get up and running quickly on the front end by creating a NextJS application using a Stack.js template.

Subscribe to the Developer DAO Newsletter. Probably Nothing

Prerequisites

The author assumes the following prerequisites:

  • A beginner understanding of Clarity smart contract language

  • The reader is familiar with the use of a CLI and can navigate between folders via the CLI

  • Node is installed on the development machine of the reader

  • Familiarity with React and NextJS

Installing the Clarinet CLI

Clarinet provides a CLI package with a clarity runtime, a REPL, and a testing harness. Clarinet includes a Javascript library, a testing environment, and a browser-based Sandbox. With Clarinet, you can rigorously iterate on your smart contracts locally before moving into production. Install Clarinet here.

Installing the Leather wallet

In this tutorial, we will deploy the smart contracts we write to the testnet environment. To do so, we need to create a wallet and acquire some testnet STX. If you haven't already done so, visit Hiro's wallet page to install the Leather wallet.

After creating a new wallet, make sure to save your seed phrase somewhere safe. You will need it, along with the password you set when creating the wallet, every time you want to log into your wallet after logging out.

You can request some testnet STX by navigating to Hiro's testnet explorer sandbox.

Setting up the project

Now that we have created a wallet let's proceed with the project. Create a new directory on your development machine to house the project. This directory will contain the smart contract and frontend application.

mkdir tokenGateTutorial
cd tokenGateTutorial

The Clarity smart contract will be set up inside the project folder tokenGateTutorial. The clarinet command is used to scaffold a new Clarity project. This command becomes available after the successful installation of Clarity on our development machine.

To scaffold a new Clarity project, run the command below from the location of the tokenGateTutorial folder

clarinet new token-contracts

This command scaffolds a new Clarity smart contract project inside a folder called token-contracts. The folder name can be called anything you wish. Navigate into the token-contracts folder to inspect the files and folders created by the clarinet new folder-name command.

There is acontracts folder where the FanToken and TokenGatedCommunity contracts will be stored. There is also a settings folder that contains three files: Devnet.toml, Mainnet.toml and Testnet.toml. These files contain the seed phrases of wallet accounts used for the deployment of contracts to the different environments of Devnet, Mainnet and Testnet respectively. The project also contains the root Clarinet.toml configuration file and our test folder for writing tests.

Let's get to work and write our smart contract in the next section.

Creating the FanToken contract

Navigate into the contracts folder and run the code below to create a new smart contract.

clarinet contracts new FanToken

This command creates our actual Clarity file, FanToken.clar inside the contracts directory. This is the location where we will write the FanToken contract.

Open the file FanToken.clar, delete the comments inside, and copy and replace the code below into the file.

;; traits
(define-trait sip-010-trait
  (
    (transfer (uint principal principal (optional (buff 34))) (response bool uint))
    (get-name () (response (string-ascii 32) uint))
    (get-symbol () (response (string-ascii 32) uint))
    (get-decimals () (response uint uint))
    (get-balance (principal) (response uint uint))
    (get-total-supply () (response uint uint))
    (get-token-uri () (response (optional (string-utf8 256)) uint))
  )
)

In this snippet above, we are defining traits for a token. A trait is a standard or an interface to which an implementing token must conform. Top on the trait list is atransfer function used to transfer tokens between principals. Addresses in Clarity are referred to asprincipal. There are two types of principal:

  • Standard Principal: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM :

    standard principal is backed by a corresponding private key used for the signing of transactions. This principal starts with the letter ST, denoting that it can only be used in the testnet environment, while the mainnet principal begins with the letter SP and can be used in the mainnet environment.

  • Contract Principal: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.FanToken :

    A contract principal is the address of the deployed contract. It is a combination of the standard principal who deployed the contract and the contract name.

Let's continue with the implementation of the token methods. Copy the code below and add it to the file FanToken.clar


;;previous traits definition above

;; token definitions 
;;one million tokens total supply
(define-fungible-token fanToken u1000000000000) 
;; constants
(define-constant contract-owner tx-sender)
(define-constant err-owner-only (err u100))
(define-constant err-not-token-owner (err u101))
(define-constant err-amount-less-than-zero (err u102))

;;#[allow(unchecked_data)]
(define-public (mint (amount uint) (recipient principal))
    (begin
        (asserts! (> amount u0) err-amount-less-than-zero)
        (ft-mint? fanToken amount recipient)
    )
)

;;#[allow(unchecked_data)]
(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34))))
    (begin
        (asserts! (> amount u0) err-amount-less-than-zero)
        (asserts! (is-eq tx-sender sender) err-not-token-owner)
        (try! (ft-transfer? fanToken amount sender recipient))
        (match memo to-print (print to-print) 0x)
        (ok true)
    )
)
;; read only functions
(define-read-only (get-balance (owner principal))
    (ok (ft-get-balance fanToken owner))
)
(define-read-only (get-total-supply)
    (ok (ft-get-supply fanToken))
)
(define-read-only (get-token-uri)
    (ok none)
)
(define-read-only (get-name)
    (ok "FanToken")
)
(define-read-only (get-symbol)
    (ok "FT")
)
(define-read-only (get-decimals)
    (ok u6)
)

At the top of the code, a fungible token FanToken is defined as Clarity has built-in support for fungible and non-fungible tokens (NFTs). The token has a decimal of 6 digits and a total supply of one million. (define-fungible-token fanToken u1000000000000). The u in front of the digits represents an unsigned integer type.

After the token definition, we have some declared constant values. The first is the contract-owner which is equated to tx-sender. tx-sender refers to the principal executing the transaction. In this instance, the contract-owner will be the deployer of the contract. We also define other constants that denote different error messages.

The mint function is a public function that allocates tokens to a receiver. The function takes in two parameters of type unit and principal. Inside the function body, there's abegin block used to combine different statements for execution.

The assert! block checks for the correctness or truthfulness of a statement. The asserts! check is to see if the amount we want to mint exceeds 0. If the asserts! block passes, the token amount is minted to the recipient principal using the built-in mint function provided by Clarity. (ft-mint? fanToken amount recipient)

This token is set up this way for demonstration purpose as any principal can mint the token.


(define-public (mint (amount uint) (recipient principal))
    (begin
        (asserts! (> amount u0) err-amount-less-than-zero)
        (ft-mint? fanToken amount recipient)
    )
)

Thetransfer function is used to move tokens between different accounts. The transfer function takes four arguments, which are the amount to transfer, the sender (owner) of the token, the recipient of the token, and an optional bytes variable called memo.

Inside the function body, we have abegin block that contains other statements written inside it. The firstasserts! statement checks if the amount being transferred is greater than 0, and the next asserts! statement checks if tx-sender is equal to the sender arguments.

If both asserts! pass, we attempt to transfer the token using another built-in Clarity method,ft-transfer. The transfer of the token is wrapped in a try block. The try block controls the flow of the program; if the transfer is unsuccessful, the execution halts.

The match block checks the provision of the optional parameter, memo. If memo is passed to the transfer function, it binds the value memo to the variableto-print. The print statement emits the bonded variable. If memo was not passed, we write 0x.

At the end of the function, we return a response (ok true) indicating that all went well and the changes made in the function can be committed to the blockchain. A public function must return a response of either success or failure.


(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34))))
    (begin
        (asserts! (> amount u0) err-amount-less-than-zero)
        (asserts! (is-eq tx-sender sender) err-not-token-owner)
        (try! (ft-transfer? fanToken amount sender recipient))
        (match memo to-print (print to-print) 0x)
        (ok true)
    )
)

We are done discussing the two main functions of the FanToken contract, the other functions in the contract are read-only that display the token data.

Testing FanToken in the terminal

We have successfully created a contract, so let's deploy it for testing at the terminal. Navigate to the contracts folder located inside the token-contract folder. Run the code. This Clarinet CLI command loads and deploys contracts in the terminal environment.

clarinet console

This is a nice way of testing your contract manually before deploying. You can execute the contract's methods from the CLI.

To test our mint function, run the code below at the terminal.

(contract-call? .FanToken mint u4000000000 tx-sender)

We are calling a contract named; the dot in front of FanToken it is a shorthand way of representing the principal contract that was deployed. Remember, a contract principal is a combination of the standard principal that deployed the contract and the contract name. We are executing the contract in the context of the deployer account; that's why the shortcut.FanToken works.

The next parameter passed to the contract-call? block after the contract name is the function name, which, in our case, is mint. Then, the parameters of the function are passed after the function name. We are minting 4000 FT (FT token uses 6 digits) for the principaltx-sender.

Execute the code, and you will get a similar output below. A print block was emitted as the tokens were minted and transferred to tx-sender. A response(ok true) was returned from the function, showing that the execution was successful.

We can check the balance of tx-sender by executing the get-balance function.

(contract-call? .FanToken get-balance tx-sender)

Creating the TokenGatedCommunity contract

We shall proceed to create the second contractTokenGatedCommunity. Copy the code below and post it in the TokenGatedCommunity.char file located in the contracts folder.


;; title: TokenGatedCommunity
;; version: 1.0
;; summary: 
;; description:


;; constants
(define-constant contract-owner tx-sender)
(define-constant err-insufficient-token-balance (err u1002))
(define-constant err-getting-balance (err u1003))
(define-constant err-not-the-owner (err u1004))
(define-constant err-token-not-greaterThan-Zero (err u1005))
(define-constant err-unwrap-failed (err u1006))
(define-constant err-already-a-member (err u1007))
(define-constant err-not-a-member (err u1008))

(define-map tokenBalances principal uint)

;; data vars
(define-data-var entryTokenAmount uint u50000000) ;;50 FT


(define-public (joinCommunity) 
  (begin
    (if (is-none (userTokenBalance tx-sender))
       (transferTokenAndJoinCommunity (var-get entryTokenAmount))
      err-already-a-member
    )
  )
)

 (define-private (transferTokenAndJoinCommunity (tokenAmount uint)) 
   (begin 
    (try! (contract-call? .FanToken transfer tokenAmount tx-sender (as-contract tx-sender) none))
    (map-set tokenBalances tx-sender tokenAmount)
    (ok true)
   )
 )

 (define-private (payoutTokens (receiver principal) (amount uint))
     (begin 
        (asserts! (is-eq .TokenGatedCommunity (as-contract tx-sender)) err-not-the-owner )
        (try! (as-contract (contract-call? .FanToken transfer amount .TokenGatedCommunity receiver  none)))
        (ok true)
      )
  )

 (define-public (removeTokenAndExitCommunity)
     (
        let (
            (balance (unwrap! (userTokenBalance tx-sender) err-not-a-member))
        )

        (try! (payoutTokens tx-sender balance))
        (map-delete tokenBalances tx-sender)
        (ok true)
     )
 )

;;public-function
(define-public (setEntryTokenAmount (newEntryTokenAmount uint) ) 
    (begin
        (asserts! (is-eq tx-sender contract-owner) err-not-the-owner)
        (asserts! (> newEntryTokenAmount u0) err-token-not-greaterThan-Zero)
        (var-set entryTokenAmount newEntryTokenAmount)
        (ok true)
     )
)

(define-read-only (userTokenBalance (user principal)) 
   (map-get? tokenBalances user)
)

(define-read-only (isUserACommunityMember (user principal)) 
   (match (map-get? tokenBalances user) bal
     (begin 
       bal
      (ok true)
     )
     (ok false)
   )
)

;;read-only function
(define-read-only (getEntryTokenAmount)
     (ok (var-get entryTokenAmount))
)

At the top of the contract, some useful constants are defined. The contract-owner is set to the contract deployer. Some other declared constant variables are used to represent errors.

A data variable (define-data-var entryTokenAmount uint u50000000) called entryToken is defined, and its initial value is set at 50 FT tokens.

The functionality of this contract;

  • users send FanToken to the contract, and they are registered in the community; their principal is added to a data-map variable tokenBalances

  • users can withdraw their FanToken from the TokenGateCommunity contract, and their principal is deleted from the data-map.

Sending tokens to the TokenGateCommunity Contract

The public function joinCommunity adds the user to the community if they are not already a member. Let's dissect the function to see how it works

(define-public (joinCommunity) 
  (begin
    (if (is-none (userTokenBalance tx-sender))
      (transferTokenAndJoinCommunity (var-get entryTokenAmount))
      err-already-a-member
    )
  )
)

The function accepts no parameter; inside the begin block, a read-only function userTokenBalance returns a value of an optional from the variabletokenBalances of the user ( caller of the function ). The return value of userTokenBalace could either be a (some unit) if the user principal is on the data-map variable ornone if it is not on the data-map variable.

(define-read-only (userTokenBalance (user principal)) 
   (map-get? tokenBalances user)
)

The if block checks the boolean value of the result from the is-none block. If the result from userTokenBalance is noneis-none block returns true and if the result is (some unit)is-none block returns false.

The if block runs when the user is not already a community member. The private function transferTokenAndJoinCommunity is executed. The function is accepted as a parameter; this is the entry fee used to join the community.

An external contract-call? is made to the .FanToken contract, calling its transfer function. An amount of tokens equal to the parameter tokenAmount is transferred from the user account to the TokenGatedCommunity contract. This transfer is wrapped in a try! block that halts contract execution if the token transfer fails.

Next, the user is added to the data-map , a response of ok is returned to the function caller. The token has been transferred successfully to the TokenGatedCommunity contract, and the user is now a community member.

The remaining portion left to dissect in the joinCommunity function is the else part of the if block. This throws an error stating that the user is already a community member.


(define-private (transferTokenAndJoinCommunity (tokenAmount uint)) 
   (begin 
    (try! (contract-call? .FanToken transfer tokenAmount tx-sender (as-contract tx-sender) none))
    (map-set tokenBalances tx-sender tokenAmount)
    (ok true)
   )
 )

Withdrawing tokens from the TokenGateCommunity Contract

This is when the user gets tired of the community and wants to leave. Ideally, they should get their tokens back from the TokenGateCommunity contract and their principal deleted from the userTokenBalancesdata-map.

Let's see the implementation details:

 (define-public (removeTokenAndExitCommunity)
     (
        let (
            (balance (unwrap! (userTokenBalance tx-sender) err-not-a-member))
        )
        (try! (payoutTokens tx-sender balance))
        (map-delete tokenBalances tx-sender)
        (ok true)
     )
 )

The public function removeTokenAndExitCommunity is called by the user to exit the community and get paid their tokens. Inside the function, there's a let block used to save values in a temporary data-binding variable called balance. The unwraps! function unwraps the optional value returned from the read-only function userTokenBalance. If unwrap was successful, the user token balance in the contract is stored in the temp variable balance but if unwrap! encounters a value of none in the function, meaning the principal is not in the tokenBalancesdata-map, it throws an err-not-a-member error.

After retrieving the user token stored by the contract, the private function payoutTokens is called with two parameters of the user principal and the user token amount in the contract. Let's examine the payoutTokens function below:

  (define-private (payoutTokens (receiver principal) (amount uint))
     (begin 
        (asserts! (is-eq .TokenGatedCommunity (as-contract tx-sender)) err-not-the-owner )
        (try! (as-contract (contract-call? .FanToken transfer amount .TokenGatedCommunity receiver  none)))
        (ok true)
      )
    )

The function starts with a begin block, which contains three statements. The first is an asserts! function that checks the equality of the contract principal with the caller; the (as-contract tx-sender) calls a function in the context of the contract and not the wallet principal.

The next statement makes an external call to the transfer function of .FanToken contract in the context of the .TokenGatedCommunity contract. This is necessary to transfer the tokens, as the tokens are owned by the .TokenGatedCommunity contract at this point. We return a response of ok if the tokens were successfully transferred to the recipient.

Finally in the removeTokenAndExitCommunity function, the user is deleted from the tokenBalancesdata-map and a response of ok is returned from the function.

Setting the entryTokenAmount value

The function sets the value of the data variable entryTokenAmount. The setEntryToken function accepts a newEntryTokenAmount as a parameter with a type of unit. The first asserts! checks that the contract-ownerconstant is the same as the principal calling the function. The next asserts! performs data sanitation, ensuring that the passed parameter is greater than 0. The value of entryTokenAmount is changed using the var-set function. A response of type (ok true) is returned from the function.

;;public-function
(define-public (setEntryTokenAmount (newEntryTokenAmount uint) ) 
(begin
    (asserts! (is-eq tx-sender contract-owner) err-not-the-owner)
    (asserts! (> newEntryTokenAmount u0) err-token-not-greaterThan-Zero)
    (var-set entryTokenAmount newEntryTokenAmount)
    (ok true)
)
)

Wooh! We have come to the end of creating the smart contracts. You can test this contract the same way we tested the FanToken contract.

Deploying Clarity smart contracts to testnet

Navigate to the settings folder in the Clarity project. The folder contains three files Devnet.toml, Testnet.toml and Mainnet.toml contained inside the contracts folder. These files will contain the seed phrases for the wallet that will deploy the contract in the respective environment of Devnet, Testnet and Mainnet. Devnet.toml is already filled with different wallet seed phrases.

Open the Testnet.toml and replace "<YOUR PRIVATE TESTNET MNEMONIC HERE>" with the seed phrase from the wallet we created earlier.

Move into the contracts folder of the Clarinet project and run the command below to generate a deployment file for the testnet environment.

clarinet deployment generate --testnet --low-cost

This command generates a deployment file for testnet using low-cost fees for the deployment. If we are deploying to mainnet, we change --testnet to --mainnet.

After creating the deployment file, the next step is to apply the file and deploy the contract to the chosen environment. This command applies the deployment:

clarinet deployment apply -p deployments/default.testnet-plan.yaml

The smart contracts are broadcast and deployed to the network. Our contract name is a combination of the principal that deployed the contract and the contract name.

Deploying to devnet

We will also deploy the smart contracts to devnet. Hiro has a wonderful explorer that can be used to simulate and test the functionality of contracts on the devnet. Deploying to the devnet is straightforward. Before proceeding, you should install Docker on your system. If you haven't installed it yet, you can install it from the official page.

If Docker has been installed, open the contracts folder of the Clarity projects at the terminal and run:

clarinet devnet start

Docker will download the containers needed to run the devnet the first time you run this command. It might take a little time, so be patient. Your terminal will look similar to the one below when the devnet is running. Stacks Devnet Explorer runs on localhost:8000.

Keep the devnet terminal running, and let's proceed to the frontend to connect our application to the devnet blockchain.

Creating a NextJS frontend

The smart contracts part of our application is ready. The next step is to create a NextJS frontend that will interact with the contracts. We will use Hiro starter templates for fast iteration.

Navigate to the root folder of your project and create a new folder named frontend. cd into the frontend folder and run the code below to create the NextJS application.

npm create stacks --template .

The stacks tool will walk you through creating a NextJS project. The period (.) after the word --template means we want the NextJS application to be installed in the current directory. Enter frontend as the package name when prompted by the CLI and follow the other prompts to install a NextJS application.

Next, install dependencies by running the command below to install the application dependencies.

npm install

The frontend application is started by running npm run dev. The starter template makes our integration faster as it contains useful boilerplate code, which we can modify to suit our needs.

Let's do a little bit of housekeeping. You can delete the ContractVote.js file found in src/components folder. Delete the content of ConnectWallet.js file located in the components directory and replace it with the below code;

"use client";

import React, { useEffect, useState } from "react";
import { AppConfig, showConnect, UserSession } from "@stacks/connect";
import styles from "../app/page.module.css";

const appConfig = new AppConfig(["store_write", "publish_data"]);

export const userSession = new UserSession({ appConfig });

const trimAddress = (address) => {
  if (address) {
    const start = address.substr(0, 6);
    const middle = ".....";
    const end = address.substr(address.length - 6, address.length)
    return `${start}${middle}${end}`
  }
  return null;
}

function authenticate() {
  showConnect({
    appDetails: {
      name: "Token Gated Demo",
      icon: window.location.origin + "/logo512.png",
    },
    redirectTo: "/",
    onFinish: () => {
      window.location.reload();
    },
    userSession,
  });
}

function disconnect() {
  userSession.signUserOut("/");
}

const ConnectWallet = () => {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);

  if (mounted && userSession.isUserSignedIn()) {
    return (
      <div className="Container">
        <button className={styles.buttonConnected} onClick={disconnect}>
          Disconnect Wallet {trimAddress(userSession.loadUserData().profile.stxAddress.testnet)}
        </button>
      </div>
    );
  }

  return (
    <button className={styles.button} onClick={authenticate}>
      Connect Wallet
    </button>
  );
};

export default ConnectWallet;

The addition we made to the file is the trimAddress function used to trim the connected wallet principal. We won't be discussing any CSS styles in this integration.

The ConnectWallet component shows a button asking a user to connect their wallet. Most of the code is boilerplate, and we won't spend time discussing it. The meat of the frontend application will be in the page.js located in the app folder.

Open the page.js file and replace its content following code:

"use client";
import { useEffect, useState } from "react";
import styles from "./page.module.css";
import { Connect } from "@stacks/connect-react";
import ConnectWallet, { userSession } from "/../components/ConnectWallet";

const deployer = "replace-with-the-account-that-deployed-the-project" //acount that deployed the contract
export default function Home() {
  const [isClient, setIsClient] = useState(false);
  const [isQualified, setIsQualified] = useState(false);
  const [userTokenAmount, setUserTokenAmount] = useState(0)
  const [tokensToMint, setTokensToMint] = useState(0);
  const [tokenReceiver, setTokenReceiver] = useState(null)


  useEffect(() => {
    setIsClient(true);
  }, []);


  if (!isClient) return null;

  return (
    <Connect
      authOptions={{
        appDetails: {
          name: "Token Gated Community",
          icon: window.location.origin + "/logo.png",
        },
        redirectTo: "/",
        onFinish: () => {
          window.location.reload();
        },
        userSession,
      }}
    >
      <div className={styles.container}>
        <header className={styles.header}>
          <h1>Token Gated Website</h1>
          <nav className={styles.nav}>

            <ConnectWallet />

          </nav>
        </header>
        <main>


          <div className={styles.inputContainerColumn}>
            <p>Mint FT Tokens</p> &nbsp;
            <input className={styles.input} value={tokensToMint}
              onChange={(e) => setTokensToMint(e.target.value)}
              type="number" placeholder="00"
            />
            <input className={styles.input}
              type="text"
              placeholder="principal to receive the token"
              value={tokenReceiver}
              onChange={(e) => setTokenReceiver(e.target.value)}

            />
            <button className={styles.button}>Mint FT Tokens</button>
          </div>


          {!isQualified &&
            <div className={styles.container}>
              <h3>Join Community</h3>

              <button className={styles.button}
                >Join Community</button>
            </div>
          }



          {/*Token gated content */}

          {isQualified &&

            <div>
              <h1>Welcome to the community of dog lovers Premium content</h1>
              <h3>This part of the website is token gated!</h3>
              <div className={styles.card}>
                <p>Send a transaction to change the entryTokenAmount variable</p>
                <div className={styles.inputContainer}>
                  <input type="number" className={styles.input} placeholder="00" />
                  <button className={styles.button}>Change Entry Fee</button>
                </div>
              </div>



              <div className={styles.inputContainer}>
                <p>Exit Community and claim back your tokens</p>

                <button className={styles.button}>Exit Community</button>
              </div>

            </div>

          }


        </main>
      </div>
    </Connect>
  );
}

The application should look like the one below. At the top of the file, we specified that the file will only run in the client environment, useState and useEffect is imported from react.

Some react state is defined that will be used in the application. Look at the deployer variable; replace this value with the account that deployed the contract. If working with devnet this will be the first account listed in the Devnet.toml file.

The ConnectWallet button was imported, as well as a Connect component from the package @stacks/connect-react. The Connect component manages the user session as they sign out and sign in via their wallets.

The simple UI of the front end will allow a user to mint FanToken, join the community, and exit the community. The isQualified state renders the token-gated portion of the website if its value is true. Let's see how to read contract value from a Clarity smart contract in the frontend application.

Reading values from Clarity smart contracts using Stacks.js

The first function we shall implement reads the amount of FanToken owned by the signed-in user. Copy and paste the code below in the page.js file after the useEffect function.

 import { StacksTestnet, StacksDevnet } from "@stacks/network";
 import { callReadOnlyFunction, standardPrincipalCV } from '@stacks/transactions';

//code removed here

  useEffect(() => {
    setIsClient(true);
  }, []);

const getTokenBalance = async () => {
    const userDevnetAddress = userSession.loadUserData().profile.stxAddress.testnet
    console.log("devNetAddress ", userDevnetAddress)
    try {
      const contractName = 'FanToken';
      const functionName = 'get-balance';
      const network = new StacksDevnet();
      const senderAddress = userDevnetAddress;
      const options = {
        contractAddress: deployer,
        contractName,
        functionName,
        functionArgs: [standardPrincipalCV(userDevnetAddress)],
        network,
        senderAddress,
      };
      const result = await callReadOnlyFunction(options);
      const { value: { value } } = result
      const tokenAmount = +value.toString() / 1_000_000
      setUserTokenAmount(tokenAmount)
      console.log("result => ", tokenAmount + "FT")
    } catch (error) {
      console.log("error => ", error);
    }
  };

//code remove for brevity

The getTokenBalance gets the balance of the user token. This is a read-only function, so we ain't making a transaction. We imported StacksDevnet network from the @stacks/network. If the contract we are working with is deployed on a testnet, we will use the StacksTestnet network constructor.

The connected user wallet is retrieved from the userSession object, and an options object that contains the following properties is constructed;

 const options = {
        contractAddress: deployer,
        contractName,
        functionName,
        functionArgs: [standardPrincipalCV(userDevnetAddress)],
        network,
        senderAddress,
      };

Function arguments have to be converted to Clarity type. The standardPrincipalCV converts the user address to a Clarity principal type. Next, we call the contract, passing the options object we have defined and await the result of the call.

const result = await callReadOnlyFunction(options);

The result is an object that contains the user balance sent from the smart contract we just read. We destructure the result object and divide the value obtained by 100000 to get the token amount, as the FanToken operates internally with 6 digits. Lastly, we update the state using setUserTokenAmount function.

const { value: { value } } = result
const tokenAmount = +value.toString() / 1_000_000
setUserTokenAmount(tokenAmount)

ThegetTokenBalance function is triggered in a useEffect when the user sign in in with the wallet. Add this useEffect code;

  useEffect(() => {
    getTokenBalance()
    isUserACommunityMember()
  }, [userSession.isUserSignedIn()])

Let's define another read-only function that checks if the user is a community member, isUserACommunityMember. Copy and paste the code below in the page.js file.

import {
  callReadOnlyFunction, standardPrincipalCV, ClarityType, AnchorMode,
  PostConditionMode, contractPrincipalCV, uintCV
} from '@stacks/transactions';
//top of code removed. paste after the getTokenBalance function
 const isUserACommunityMember = async () => {
    const userDevnetAddress = userSession.loadUserData().profile.stxAddress.testnet
    try {
      const contractName = 'TokenGatedCommunity';
      const functionName = 'isUserACommunityMember';
      const network = new StacksDevnet();
      const senderAddress = userDevnetAddress;
      const options = {
        contractAddress: deployer,
        contractName,
        functionName,
        functionArgs: [standardPrincipalCV(userDevnetAddress)],
        network,
        senderAddress,
      };
      const result = await callReadOnlyFunction(options);
      if (result.value.type == ClarityType.BoolTrue) {
        setIsQualified(true)
      } else {
        setIsQualified(false)
      }
    } catch (error) {
      console.log("error => ", error);
    }
  };

This function calls the isUserACommunityMember function of the TokenGatedCommunity contract. The function returns a boolean indicating whether the user is a member. The state isQualified is set based on the passed boolean result.

Sending transactions to a Clarity contract using Stacks.js

We will examine how to send transactions to our smart contracts. Previously, we saw how to obtain values from the contract via read-only functions. This section will examine the mintToken function to see how this is done. The mintFunction is used to mint tokens; it accepts a token amount and the receiver address as parameters. The user needs to have some tokens before joining the community, so the mintToken function gives the user free tokens.

const mintTokens = async () => {
    let amount = tokensToMint * 1000_000; //token is 6 digit
    amount = uintCV(amount);
    openContractCall({
      network: new StacksDevnet(), //on testnet new StacksTestnet()
      anchorMode: AnchorMode.Any,
      contractAddress: deployer,
      contractName: 'FanToken',
      functionName: 'mint',
      functionArgs: [amount, standardPrincipalCV(tokenReceiver)],
      postConditionMode: PostConditionMode.Deny,
      postConditions: [],
      onFinish: data => {
        // WHEN user confirms pop-up
        setTokensToMint(0)
        setTokenReceiver(null)
        //on testnet the url will be => https://explorer.hiro.so/txid/${data.txId}?chain=testnet
        window
          .open(
            `http://localhost:8000/txid/${data.txId}?chain=testnet&api=http://localhost:3999`,
            "_blank"
          )
          .focus();
      },
      onCancel: () => {
        // WHEN user cancels/closes pop-up
        console.log("onCancel:", "Transaction was canceled");
      },
    });
  }

Transactions are sent using the openContractCall Stacks.js function. The function accepts as an argument an object that contains the functionName, contractAddress like the callReadOnlyFunction we used earlier to get the token balance. It also contains additional properties like postConditionMode, postCondition , onFinish and onCancel.

The postCondistionMode has a value of PostConditionMode.Deny and PostConditionMode.Allow. PostConditionMode.Denymeans that tokens will not be transferred from the user. This protects the user against malicious contracts, as the transaction will revert if any such token transfer is attempted. The postCodition array sets conditions specifying how many tokens can be withdrawn from the user.

onFinish function is triggered after the transaction has been created; in this example, we open the explorer to view the transaction. onCancel is triggered if the user cancels the transaction. The value of the amount of token we are minting is retrieved from the tokenToMint state, and it is converted to a Clarity type of uintCV, so also the token receiver value is converted to a standardPrincipal and both values are passed to the functionArgs array.

After minting the tokens, we can refresh the page to see our minted token. There are two more transactions to define for this webpage: a transaction function to join the community and another function to exit the community.

Finishing the frontend

The complete code of the page.js function is given below. We define a function joinTokenGatedCommunity and exitTokenGatedCommunity and some other utility functions. The isQualified state regulates access to the token-gated parts of the webpage. isQualified is set based on the value returned from the read-only function isUserACommunityMember.

Adding the complete code to page.js, we will see that there is some restricted parts of our webpage. We can build on this idea to create a more robust web application for token-gated users.

"use client";
import { useEffect, useState } from "react";
import styles from "./page.module.css";

import { Connect } from "@stacks/connect-react";

import ConnectWallet, { userSession } from "../components/ConnectWallet";
import { StacksTestnet, StacksDevnet } from "@stacks/network";
import {
  callReadOnlyFunction, standardPrincipalCV, ClarityType, AnchorMode,
  PostConditionMode, contractPrincipalCV, uintCV
} from '@stacks/transactions';
import { openContractCall } from '@stacks/connect';


const deployer = "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM" //acount that deployed the contract

export default function Home() {
  const [isClient, setIsClient] = useState(false);
  const [isQualified, setIsQualified] = useState(false);
  const [userTokenAmount, setUserTokenAmount] = useState(0)
  const [tokensToMint, setTokensToMint] = useState(0);
  const [tokenReceiver, setTokenReceiver] = useState(null)

  const mintTokens = async () => {
    let amount = tokensToMint * 1000_000; //token is 6 digit
    amount = uintCV(amount);
    openContractCall({
      network: new StacksDevnet(), //on testnet new StacksTestnet()
      anchorMode: AnchorMode.Any,
      contractAddress: deployer,
      contractName: 'FanToken',
      functionName: 'mint',
      functionArgs: [amount, standardPrincipalCV(tokenReceiver)],

      postConditionMode: PostConditionMode.Deny,
      postConditions: [],

      onFinish: data => {
        // WHEN user confirms pop-up
        setTokensToMint(0)
        setTokenReceiver(null)
        //on testnet the url will be => https://explorer.hiro.so/txid/${data.txId}?chain=testnet
        window
          .open(
            `http://localhost:8000/txid/${data.txId}?chain=testnet&api=http://localhost:3999`,
            "_blank"
          )
          .focus();
      },
      onCancel: () => {
        // WHEN user cancels/closes pop-up
        console.log("onCancel:", "Transaction was canceled");
      },
    });

  }


  const joinTokenGatedCommunity = async () => {
    try {
      openContractCall({
        network: new StacksDevnet(), //on testnet new StacksTestnet()
        anchorMode: AnchorMode.Any,
        contractAddress: deployer,
        contractName: 'TokenGatedCommunity',
        functionName: 'joinCommunity',
        functionArgs: [],

        postConditionMode: PostConditionMode.Allow,
        postConditions: [],
        onFinish: data => {
          setTokenReceiver(null);
          setTokensToMint(0)
          // WHEN user confirms pop-up
          //on testnet the url will be => https://explorer.hiro.so/txid/${data.txId}?chain=testnet
          window
            .open(
              `http://localhost:8000/txid/${data.txId}?chain=testnet&api=http://localhost:3999`,
              "_blank"
            )
            .focus();
        },
        onCancel: () => {
          // WHEN user cancels/closes pop-up
          console.log("onCancel:", "Transaction was canceled");
        },
      });
    } catch (error) {
      console.log("error => ", error);
    }
  };

  const exitTokenGatedCommunity = async () => {
    try {
      openContractCall({
        network: new StacksDevnet(), //on testnet new StacksTestnet()
        anchorMode: AnchorMode.Any,
        contractAddress: deployer,
        contractName: 'TokenGatedCommunity',
        functionName: 'removeTokenAndExitCommunity',
        functionArgs: [],
        postConditionMode: PostConditionMode.Allow,
        postConditions: [],
        onFinish: data => {
          // WHEN user confirms pop-up
          //on testnet the url will be => https://explorer.hiro.so/txid/${data.txId}?chain=testnet
          window
            .open(
              `http://localhost:8000/txid/${data.txId}?chain=testnet&api=http://localhost:3999`,
              "_blank"
            )
            .focus();
        },
        onCancel: () => {
          // WHEN user cancels/closes pop-up
          console.log("onCancel:", "Transaction was canceled");
        },
      });
    } catch (error) {
      console.log("error => ", error);
    }
  };

  const getTokenBalance = async () => {
    const userDevnetAddress = userSession.loadUserData().profile.stxAddress.testnet
    console.log("devNetAddress ", userDevnetAddress)
    try {
      const contractName = 'FanToken';
      const functionName = 'get-balance';
      const network = new StacksDevnet();
      const senderAddress = userDevnetAddress;
      const options = {
        contractAddress: deployer,
        contractName,
        functionName,
        functionArgs: [standardPrincipalCV(userDevnetAddress)],
        network,
        senderAddress,
      };
      const result = await callReadOnlyFunction(options);
      const { value: { value } } = result
      const tokenAmount = +value.toString() / 1_000_000
      setUserTokenAmount(tokenAmount)
      console.log("result => ", tokenAmount + "FT")
    } catch (error) {
      console.log("error => ", error);
    }
  };

  const isUserACommunityMember = async () => {
    const userDevnetAddress = userSession.loadUserData().profile.stxAddress.testnet
    try {
      const contractName = 'TokenGatedCommunity';
      const functionName = 'isUserACommunityMember';
      const network = new StacksDevnet();
      const senderAddress = userDevnetAddress;
      const options = {
        contractAddress: deployer,
        contractName,
        functionName,
        functionArgs: [standardPrincipalCV(userDevnetAddress)],
        network,
        senderAddress,
      };
      const result = await callReadOnlyFunction(options);
      if (result.value.type == ClarityType.BoolTrue) {
        setIsQualified(true)
      } else {
        setIsQualified(false)
      }

    } catch (error) {
      console.log("error => ", error);
    }
  };


  useEffect(() => {
    setIsClient(true);
  }, []);

  useEffect(() => {
    getTokenBalance()
    isUserACommunityMember()
  }, [userSession.isUserSignedIn()])

  if (!isClient) return null;

  return (
    <Connect
      authOptions={{
        appDetails: {
          name: "Token Gated Community",
          icon: window.location.origin + "/logo.png",
        },
        redirectTo: "/",
        onFinish: () => {
          window.location.reload();
        },
        userSession,
      }}
    >
      <div className={styles.container}>
        <header className={styles.header}>
          <h1>Token Gated Website</h1>
          <nav className={styles.nav}>

            <ConnectWallet />

          </nav>
        </header>
        <main>

          <p>
            User Fan Token Balance: {userTokenAmount} FT
          </p>


          <div className={styles.inputContainerColumn}>
            <p>Mint FT Tokens</p> &nbsp;
            <input className={styles.input} value={tokensToMint}
              onChange={(e) => setTokensToMint(e.target.value)}
              type="number" placeholder="00"
            />
            <input className={styles.input}
              type="text"
              placeholder="principal to receive the token"
              value={tokenReceiver}
              onChange={(e) => setTokenReceiver(e.target.value)}

            />
            <button className={styles.button} onClick={mintTokens}>Mint FT Tokens</button>
          </div>


          {!isQualified &&
            <div className={styles.container}>
              <h3>Join Community</h3>

              <button className={styles.button}
                onClick={joinTokenGatedCommunity}>Join Community</button>
            </div>
          }

          {/*Token gated content */}

          {isQualified &&

            <div>
              <h1>Welcome to the community of dog lovers Premium content</h1>
              <h3>This part of the website is token gated!</h3>
              <div className={styles.card}>
                <p>Send a transaction to change the entryTokenAmount variable</p>
                <div className={styles.inputContainer}>
                  <input type="number" className={styles.input} placeholder="00" />
                  <button className={styles.button}>Change Entry Fee</button>
                </div>
              </div>



              <div className={styles.inputContainer}>
                <p>Exit Community and claim back your tokens</p>

                <button className={styles.button}
                  onClick={exitTokenGatedCommunity}>Exit Community</button>
              </div>

            </div>

          }
        </main>
      </div>
    </Connect>
  );
}

Subscribe to the Developer DAO Newsletter. Probably Nothing

Conclusion

This example application could be built upon to solidify the knowledge gained in integrating Clarity smart contracts with a frontend application.

In this blog post, we learned how to create Clarity smart contracts, fungible tokens, and how to token-gate a webpage using a token. We also learned how to deploy smart contracts to either the testnet or devnet and connect with a front-end to send transactions to the network. I hope you have learned a thing or two from this piece.

Thank you for reading to the end. The source code can be found here.