Building EventChain dApp: From Smart Contract to Interactive Frontend

Biliqis OnikoyiBiliqis Onikoyi
12 min read

Introduction: Why Build a Blockchain-Based Ticketing dApp?

Event ticketing platforms have transformed how we organize and attend events, offering features like online ticket sales, event page creation, and marketing tools. However, traditional systems often suffer from fraud, lack of transparency, and limited user control. Blockchain-based event ticketing platforms offer increased security, transparency, and efficiency compared to traditional systems. They utilise blockchain technology, often employing NFTs, to create unique, verifiable tickets, combating fraud and enhancing the attendee experience.

In this tutorial, I’ll guide you through building EventChain, a decentralized ticketing dApp on the Stacks blockchain. You’ll create a Clarity smart contract to manage events and tickets, deploy it to the Stacks testnet, and integrate it with a React frontend using Stacks.js. This tutorial is for developers with basic JavaScript and React experience; some blockchain familiarity is helpful but not required. By the end, you’ll have a fully functional dApp where users can create events, buy tickets, and manage them securely.

Why Stacks? Stacks is the leading Bitcoin L2, enabling smart contracts with Bitcoin’s security, making it ideal for transparent and secure applications like EventChain. As of 2025, with the Nakamoto upgrade and sBTC integration, Stacks offers faster transactions and enhanced DeFi capabilities, but this tutorial focuses on core smart contract and frontend basics.

Prerequisites

Before starting, ensure you have:

  • Node.js 18+ and npm installed (download here).

  • Clarinet installed globally for Clarity development (installation guide).

  • A basic frontend setup (e.g., React, created via npx create-react-app eventchain-frontend).

  • A Leather Wallet (developed by Hiro) set up with a testnet account (get started).

  • Familiarity with JavaScript and React; basic blockchain knowledge is helpful but not required.

Project Setup

Let’s set up the Clarity project for our smart contract. You can create this in the same directory as your frontend (e.g., a React app) or in a separate folder.

1.1 Create the Clarity Project

# Install Clarinet globally if you haven’t
npm install -g @hirosystems/clarinet

# Scaffold a new Clarity project called “eventchain”
clarinet new eventchain
cd eventchain

# Verify the setup
clarinet check

Your eventchain/ folder will look like:

eventchain/
├── contracts/ # Your Clarity (.clar) files
│ └── eventchain.clar
├── tests/ # Test files for Clarinet
├── Clarinet.toml # Project configuration
└── README.md # Usage instructions

Run clarinet check to ensure the project is set up correctly. If your frontend is in a separate folder (e.g., eventchain-frontend/), you can link them later during integration.

Writing the Clarity Contract

Below is the complete eventchain.clar contract with inline comments and improved error handling. We’ll define global state, data maps, and functions to manage events, tickets, and refunds. After the code, I’ll show how to test it locally.

Global State

;; Track the next available event and ticket IDs
(define-data-var next-event-id uint u1)
(define-data-var next-ticket-id uint u1)
;; Admin is set to the contract deployer
(define-data-var admin principal tx-sender)

Data Maps

;; Stores event metadata
(define-map events {event-id: uint} {
    creator: principal,
    name: (string-utf8 100),
    location: (string-utf8 100),
    timestamp: uint,
    price: uint,
    total-tickets: uint,
    tickets-sold: uint
})
;; Tracks ticket ownership and usage
(define-map tickets {event-id: uint, owner: principal} {
    used: bool,
    ticket-id: uint
})
;; Approved organizers
(define-map organizers {organizer: principal} {
    is-approved: bool
})
;; Flags cancelled events
(define-map event-cancelled {event-id: uint} bool)

Error Codes

CodeDescription
u100Event sold out
u101User already owns a ticket
u102STX transfer failed
u103Event not found
u201Ticket already used
u202Ticket not found
u301Ticket already checked in
u302Ticket not found for check-in
u303Unauthorized check-in attempt
u304Event not found for check-in
u401Unauthorized organizer action
u402Not an approved organizer
u403Invalid event parameters (e.g., zero tickets)
u501Unauthorized event cancellation
u502Event not found for cancellation
u503Refund transfer failed
u504Ticket not found for refund
u505Event not found for refund
u506Event not cancelled

Organizer Approval

;; Admin adds an approved organizer
(define-public (add-organizer (who principal))
  (if (is-eq tx-sender (var-get admin))
      (begin
        (map-set organizers {organizer: who} {is-approved: true})
        (ok true)
      )
      (err u401) ;; Only admin can approve organizers
  )
)

Create Event

;; Approved organizers create events with metadata
(define-public (create-event
    (name (string-utf8 100))
    (location (string-utf8 100))
    (timestamp uint)
    (price uint)
    (total-tickets uint)
  )
  (begin
    (asserts! (is-some (map-get? organizers {organizer: tx-sender})) (err u402)) ;; Check approval
    (asserts! (> total-tickets u0) (err u403)) ;; Ensure non-zero tickets
    (asserts! (> timestamp block-height) (err u403)) ;; Optional: Ensure future timestamp
    (let ((event-id (var-get next-event-id)))
      (map-set events {event-id: event-id}
        {
          creator: tx-sender,
          name: name,
          location: location,
          timestamp: timestamp,
          price: price,
          total-tickets: total-tickets,
          tickets-sold: u0
        }
      )
      (var-set next-event-id (+ event-id u1))
      (ok event-id)
    )
  )
)

Buying Tickets

;; Users buy tickets if available and not already owned
(define-public (buy-ticket (event-id uint))
  (match (map-get? events {event-id: event-id})
    event-data
    (let (
          (price (get price event-data))
          (sold (get tickets-sold event-data))
          (total (get total-tickets event-data))
         )
      (if (>= sold total)
          (err u100) ;; Sold out
          (match (map-get? tickets {event-id: event-id, owner: tx-sender})
            existing-ticket
            (err u101) ;; Already bought a ticket
            (if (is-ok (stx-transfer? price tx-sender (get creator event-data)))
                (begin
                    (map-set tickets {event-id: event-id, owner: tx-sender}
                      {used: false, ticket-id: (var-get next-ticket-id)}
                    )
                    (var-set next-ticket-id (+ (var-get next-ticket-id) u1))
                    (map-set events {event-id: event-id}
                      (merge event-data {tickets-sold: (+ sold u1)})
                    )
                    (ok true)
                )
                (err u102) ;; Transfer failed
            )
          )
      ))
    (err u103)) ;; Event not found
)

Transferring Tickets

;; Transfer unused tickets to another user
(define-public (transfer-ticket (event-id uint) (to principal))
  (match (map-get? tickets {event-id: event-id, owner: tx-sender})
    ticket-data
    (if (get used ticket-data)
        (err u201) ;; Already used
        (begin
          (map-delete tickets {event-id: event-id, owner: tx-sender})
          (map-set tickets {event-id: event-id, owner: to}
            {used: false, ticket-id: (get ticket-id ticket-data)}
          )
          (ok true)
        )
    )
    (err u202) ;; Ticket not found
  )
)

Event Check-In

;; Creator checks in a user's ticket
(define-public (check-in-ticket (event-id uint) (user principal))
  (match (map-get? events {event-id: event-id})
    event-data
    (if (is-eq tx-sender (get creator event-data))
        (match (map-get? tickets {event-id: event-id, owner: user})
          ticket-data
          (if (get used ticket-data)
              (err u301) ;; Already used
              (begin
                (map-set tickets {event-id: event-id, owner: user}
                  {used: true, ticket-id: (get ticket-id ticket-data)}
                )
                (ok true)
              )
          )
          (err u302) ;; Ticket not found
        )
        (err u303) ;; Unauthorized
    )
    (err u304) ;; Event not found
  )
)

Cancelling Events & Issuing Refunds

;; Creator cancels an event
(define-public (cancel-event (event-id uint))
  (match (map-get? events {event-id: event-id})
    event-data
    (if (is-eq tx-sender (get creator event-data))
        (begin
          (map-set event-cancelled {event-id: event-id} true)
          (ok true)
        )
        (err u501)
    )
    (err u502)
  )
)
;; User claims refund for cancelled event
(define-public (refund-ticket (event-id uint))
  (match (map-get? event-cancelled {event-id: event-id})
    cancelled-status
    (if cancelled-status
        (match (map-get? events {event-id: event-id})
          event-data
          (match (map-get? tickets {event-id: event-id, owner: tx-sender})
            ticket-data
            (if (is-ok (stx-transfer? (get price event-data) (get creator event-data) tx-sender))
                (begin
                  (map-delete tickets {event-id: event-id, owner: tx-sender})
                  (ok true)
                )
                (err u503)
            )
            (err u504)
          )
          (err u505)
        )
        (err u506)
    )
    (err u506)
  )
)

Read-Only Functions

;; Fetch event details
(define-read-only (get-event (event-id uint))
  (map-get? events {event-id: event-id})
)
;; Check if event is cancelled
(define-read-only (is-event-cancelled (event-id uint))
  (default-to false (map-get? event-cancelled {event-id: event-id}))
)
;; Fetch ticket details
(define-read-only (get-ticket (event-id uint) (owner principal))
  (map-get? tickets {event-id: event-id, owner: owner})
)
;; Get admin address
(define-read-only (get-admin)
  (var-get admin)
)

These are useful for the frontend to:

  • Display events and tickets.

  • Check organizer status.

  • Track ticket ownership.

Local Testing with Clarinet

To ensure the contract works as expected, write tests in tests/eventchain_test.clar. Here’s an example test for creating an event and buying a ticket:

import { describe, it, expect } from "vitest";
import { Cl } from "@stacks/transactions";

const accounts = simnet.getAccounts();
const deployer = accounts.get("deployer")!;
const user1 = accounts.get("wallet_1")!;
const organizer = accounts.get("wallet_1")!;


describe("EventChain Contract", () => {
  it("should deploy the contract", () => {
    const contract = simnet.getContractSource("eventchain");
    expect(contract).toBeDefined();
  });

  it("should allow deployer to create a new event", () => {
    // First add deployer as organizer
    simnet.callPublicFn(
      "eventchain",
      "add-organizer",
      [Cl.principal(deployer)],
      deployer
    );

    const createEventCall = simnet.callPublicFn(
      "eventchain",
      "create-event",
      [
        Cl.stringUtf8("Tech Conference 2025"),
        Cl.stringUtf8("Lagos"),
        Cl.uint(1750000000), // timestamp
        Cl.uint(1000000), // price in microSTX
        Cl.uint(100), // total tickets
      ],
      deployer
    );

    expect(createEventCall.result).toStrictEqual(Cl.ok(Cl.uint(1)));
  });

  it("should allow user to buy a ticket", () => {
    // First add deployer as organizer
    simnet.callPublicFn(
      "eventchain",
      "add-organizer",
      [Cl.principal(deployer)],
      deployer
    );

    // Then create an event
    simnet.callPublicFn(
      "eventchain",
      "create-event",
      [
        Cl.stringUtf8("Tech Conference 2025"),
        Cl.stringUtf8("Lagos"),
        Cl.uint(1750000000), // timestamp
        Cl.uint(1000000), // price in microSTX
        Cl.uint(100), // total tickets
      ],
      deployer
    );

    const buyTicketCall = simnet.callPublicFn(
      "eventchain",
      "buy-ticket",
      [Cl.uint(1)],
      user1
    );

    expect(buyTicketCall.result).toStrictEqual(Cl.ok(Cl.bool(true)));
  });
 });

Run tests with:

npm run test

This ensures your contract behaves correctly before deployment. Add tests for buying tickets, transfers, check-ins, and refunds to cover all scenarios, including edge cases like sold-out events or invalid transfers.

Security Note: Clarity is designed for safety (e.g., no reentrancy by default), but always validate inputs (as added in create-event), check tx-sender for permissions, and avoid overflows. For production, audit the contract via tools like Clarinet's static analysis or external auditors.

Deploying the EventChain Smart Contract to Testnet (with Clarinet)

Once your contract passes local tests, deploy it to the Stacks testnet using Clarinet.

Step 1: Estimate Deployment Costs

Check the STX cost of deployment:

clarinet deployment cost --testnet

This analyzes your contract and estimates fees based on its size and complexity.

Step 2: Generate a Deployment Plan

clarinet deployment generate --testnet

This creates deployments/default.testnet-plan.yaml, which defines:

  • Contract deployment order

  • Testnet network settings

  • Sender addresses

  • Contract file paths

Review the file to adjust costs or batch settings if needed.

Step 3: Configure Your Wallet

Ensure settings/Testnet.toml contains your testnet wallet:

[network]
name = "testnet"
deployment_fee_rate = 10

[accounts.deployer]
mnemonic = "<YOUR TESTNET MNEMONIC>"
balance = 100_000_000_000_000
derivation = "m/44'/5757'/0'/0/0"

If you need testnet STX, use the Stacks Testnet Faucet to fund your address.

Step 4: Deploy to Testnet

clarinet deployment apply --testnet

Clarinet will sign and broadcast transactions. Expect output like:

✓ Deployed: contracts/eventchain.clar
→ Contract ID: ST3ABC...XYZ.eventchain

If deployment fails, check for:

  • Insufficient STX balance (use the faucet).

  • Network connectivity issues (retry or check node status at Hiro Explorer).

Step 5: Verify Deployment

Visit the Hiro Testnet Explorer and search your contract ID to confirm it’s live.

Integrating the Smart Contract with the Frontend using Stacks.js

Now, let’s connect the contract to a React frontend using Stacks.js. This allows users to interact with EventChain via the Leather Wallet.

Architecture Overview

Below is a diagram of how the components interact:

graph 
    A[User Browser] --> B[Leather Wallet]
    B --> C[Stacks.js]
    C --> D[Stacks Testnet]
    D --> E[EventChain Contract]

Step 1: Install Stacks.js

npm install @stacks/connect @stacks/transactions @stacks/network

Step 2: Set Up Constants

In src/constants.js:

import { StacksTestnet } from '@stacks/network';

export const network = new StacksTestnet();
export const contractAddress = 'ST3ABC...XYZ'; // Your deployed contract address
export const contractName = 'eventchain';

Step 3: Full React Component Example

Here’s a complete EventList.js component to display events and allow ticket purchases:

import React, { useState, useEffect } from 'react';
import { showConnect, UserSession } from '@stacks/connect';
import { callReadOnlyFunction, openContractCall, cvToJSON, uintCV } from '@stacks/transactions';
import { network, contractAddress, contractName } from './constants';

const appConfig = { /* Your app config if needed */ };
const userSession = new UserSession({ appConfig });

function EventList() {
  const [userData, setUserData] = useState(null);
  const [events, setEvents] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // Fetch all events (up to a reasonable limit)
  useEffect(() => {
    const fetchEvents = async () => {
      setLoading(true);
      try {
        const eventPromises = [];
        for (let i = 1; i <= 10; i++) { // Assume max 10 events for demo; adjust as needed
          eventPromises.push(
            callReadOnlyFunction({
              contractAddress,
              contractName,
              functionName: 'get-event',
              functionArgs: [uintCV(i)],
              network,
              senderAddress: 'ST000000000000000000002AMW42H', // Dummy address for read-only
            }).then(cvToJSON)
          );
        }
        const results = await Promise.all(eventPromises);
        setEvents(results.filter(r => r.success && r.value.value).map(r => r.value.value));
      } catch (err) {
        setError('Failed to load events');
      }
      setLoading(false);
    };
    fetchEvents();
  }, []);

  const connectWallet = () => {
    showConnect({
      appDetails: { name: 'EventChain', icon: window.location.origin + '/logo.png' },
      onFinish: ({ userSession }) => setUserData(userSession.loadUserData()),
      userSession,
    });
  };

  const buyTicket = async (eventId) => {
    setLoading(true);
    setError(null);
    try {
      await openContractCall({
        contractAddress,
        contractName,
        functionName: 'buy-ticket',
        functionArgs: [uintCV(eventId)],
        network,
        appDetails: { name: 'EventChain', icon: window.location.origin + '/logo.png' },
        onFinish: ({ txId }) => {
          console.log('Transaction submitted:', txId);
          // Poll for confirmation and refresh events
        },
      });
    } catch (err) {
      setError('Transaction failed. Ensure you have enough STX and check wallet permissions.');
    }
    setLoading(false);
  };

  return (
    <div>
      <h1>EventChain</h1>
      {userData ? (
        <div>
          <p>Connected as {userData.profile.stxAddress.testnet}</p>
          <button onClick={() => userSession.signUserOut()}>Disconnect</button>
        </div>
      ) : (
        <button onClick={connectWallet}>Connect Leather Wallet</button>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {loading && <p>Loading...</p>}
      <h2>Events</h2>
      <ul>
        {events.map(event => (
          <li key={event['event-id']}>
            <p>{event.name} in {event.location} - {event['tickets-sold']}/{event['total-tickets']} sold</p>
            <button
              onClick={() => buyTicket(event['event-id'])}
              disabled={!userData || loading}
            >
              Buy Ticket ({event.price / 1000000} STX)
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default EventList;

Step 4: Handle Transaction Status

Poll transaction status to update the UI:

async function checkTxStatus(txId) {
  const url = `${network.coreApiUrl}/extended/v1/tx/${txId}`;
  const response = await fetch(url);
  const data = await response.json();
  return data.tx_status; // e.g., 'success', 'pending'
}

Step 5: Production Tips

  • Error Handling: Display user-friendly messages for errors (e.g., insufficient funds).

  • Fees: Inform users about STX transaction fees (visible in Leather Wallet).

  • UI Library: Use @stacks/ui for consistent styling.

  • Scalability: For many events, implement pagination or filtering in fetchEvents.

Troubleshooting

  • Error: Insufficient STX: Fund your wallet via the Testnet Faucet.

  • Error: Transaction Failed: Check network status, increase nonce in openContractCall, or verify contract ID.

  • UI Not Updating: Ensure transaction polling is implemented and refresh state after success.

  • Clarinet Errors: Run clarinet check for syntax issues; update Clarinet if deprecated features are flagged.

  • Wallet Connection Issues: Ensure Leather Wallet is installed and on testnet; clear browser cache if needed.

Next Steps

Conclusion

Congratulations on building EventChain, a fully functional decentralized ticketing dApp on the Stacks blockchain! You’ve implemented a secure Clarity smart contract, deployed it to the testnet, and integrated it with a React frontend using Stacks.js. This tutorial provides a solid foundation for creating blockchain-based applications, and you can now extend EventChain with features like NFT-based tickets, event filtering, or mainnet deployment for real-world use. Explore the Stacks documentation and GitHub repo for further inspiration, and join the Stacks Forum to share your work or troubleshoot issues. Start experimenting, and bring your decentralized ticketing vision to life!

0
Subscribe to my newsletter

Read articles from Biliqis Onikoyi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Biliqis Onikoyi
Biliqis Onikoyi

Web3 || FrontEnd Dev