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.
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. Thisprincipal
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 thestandard 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 adata-map
variabletokenBalances
users can withdraw their
FanToken
from theTokenGateCommunity
contract, and their principal is deleted from thedata-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>
<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.Deny
means 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>
<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>
);
}
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.