Onchain Hit Counter

Published 4/5/2024. Last updated 10/20/2024.

#onchain#astro#onchain#testnet

Astro DB, a simple, lightweight SQL server with a friendly ORM, recently generated some buzz. Developers wasted no time integrating the platform, with fun features like hit counters quickly gaining some virality.

The idea of hit counters struck a nostalgic chord. These widgets were everywhere across platforms like GeoCities and AngelFire when I was a kid, helping foster a spirit of playful creativity that defined the early web. I wanted to build something similar, but utilizing SQL felt like overkill for such a simple use case (which is humorous in hindsight). All that was needed was a straightforward, long-term datastore to keep that number ticking upward.

This desire for simplicity led to an unorthodox solution: the blockchain, or more specifically, a testnet. Holesky, billed as "the first long-standing, merged-from-genesis, public Ethereum testnet," emerged as my initial choice, mostly because I had a quite a bit of t. As the successor to Goerli, Holesky is projected to remain operational until 2028, offering ample shelf life. If needed, migrating the counter down the line is always an option. And thanks to Viem, the technical hurdles of interacting with the blockchain were minimized.

This project is no longer using Holesky. I have switched to Base Sepolia, which has a shorter projected lifespan but the transaction fees are much lower — meaning we have room to expand this to capture page views as well.

Here is the smart contract that powers this project:

pragma solidity ^0.8.25;

contract HitCounter {
    mapping(bytes32 => bool) private sessionExists;
    bytes32[] private sessionHashes;
    uint256 public sessionCount;
    event SessionAdded(bytes32 indexed sessionHash);
    address public owner;

    modifier onlyOwner() {
        require(msg.sender == owner, "Caller is not the owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function addSession(bytes32 sessionHash) external onlyOwner {
        require(!sessionExists[sessionHash], "Session already exists");
        sessionExists[sessionHash] = true;
        sessionHashes.push(sessionHash);
        sessionCount++;
        emit SessionAdded(sessionHash);
    }

    function getAllSessionHashes() external view returns (bytes32[] memory) {
        return sessionHashes;
    }
}

Unique session hashes are mapped and stored in an array, with the contract tracking the count. Both the sessionCount and sessionHashes array are exposed for later use.

Interacting with the contract is straightforward with Viem, which establishes the public and wallet clients for the Holesky Ethereum testnet:

import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { holesky } from "viem/chains";

export const publicClient = createPublicClient({
  chain: holesky,
  transport: http("https://ethereum-holesky-rpc.publicnode.com"),
});

export const walletClient = createWalletClient({
  chain: holesky,
  transport: http("https://ethereum-holesky-rpc.publicnode.com"),
});

export const account = privateKeyToAccount(import.meta.env.HIT_COUNTER_WALLET);

The backend code simulates and writes transactions to add new sessions:

import { account, publicClient, walletClient } from "@lib/viemClients";
import { addSessionABI } from "@lib/abi";

export async function POST({ request }) {
  const requestBody = await request.json();
  const sessionHash = requestBody.sessionHash;
  const contractAddress = import.meta.env.PUBLIC_HIT_COUNTER_CONTRACT;

  const { request: contractRequest } = await publicClient.simulateContract({
    address: `0x${contractAddress}`,
    abi: addSessionABI!,
    functionName: "addSession",
    args: [sessionHash],
    account,
  });

  let nonce = await publicClient.getTransactionCount({ address: account.address });
  contractRequest.nonce = nonce;

  await walletClient.writeContract(contractRequest);
  return new Response(JSON.stringify({ status: "OK" }), { headers: { "Content-Type": "application/json" } });
}

On the frontend, the current session count is displayed by reading directly from the contract:

<span id="sessionCount"></span>

<script>
  import { publicClient } from "@lib/viemClients";
  import { sessionCountABI } from "@lib/abi";

  document.addEventListener("DOMContentLoaded", async () => {
    const contractAddress = import.meta.env.PUBLIC_HIT_COUNTER_CONTRACT;

    const updateSessionCount = async () => {
      const data = await publicClient.readContract({
        address: `0x${contractAddress}`,
        abi: sessionCountABI,
        functionName: "sessionCount",
      });

      const sessionCountElement = document.getElementById("sessionCount");
      sessionCountElement.innerText = `${Number(data)}`;
    };

    updateSessionCount();
  });
</script>

By using a wallet provider and private key, the hit counter transactions are handled seamlessly behind the scenes. The blockchain serves as a reliable and immutable storage for the hit counter data, ensuring the accuracy and integrity.

On the frontend, the current session count is retrieved directly from the smart contract using the Viem library, allowing the website to display the real-time count of sessions that updates every block without relying on any user-provided information.

To formulate the sessionHash, a combination of the user's IP address, user agent, and the current timestamp is used. The IP address and user agent are extracted from the request headers, while the current timestamp is obtained using Date.now(). These three components are concatenated together and then hashed using the keccak256 function from the Viem library. The resulting hash serves as a unique identifier for each session.

Here's the relevant code snippet that generates the sessionHash:

// Extract IP and user agent from the request
const ip = Astro.clientAddress;
const userAgent = Astro.request.headers.get("user-agent");

// Get the current time in milliseconds
const currentTime = Date.now();

// Hash the IP, user agent, and current time together
const sessionHash = keccak256(`0x${ip + userAgent + currentTime}`);

By combining these elements, the sessionHash provides a unique and tamper-proof identifier for each session. This approach ensures that each session is counted only once, preventing duplicate entries and maintaining the integrity of the hit counter.

The sessionHash is then stored in the browser's local storage along with an expiration time. Before incrementing the hit counter, the frontend checks if a valid sessionHash already exists in the local storage. If it does and has not expired, the contract interaction is skipped to avoid unnecessary transactions. If the sessionHash is not present or has expired, a new sessionHash is generated, stored in the local storage with an updated expiration time, and the hit counter is incremented via the backend API.

This was a fun project, it has a bit of that "everything old is new again" feel. Here's to hit counters, blockchains, and making the web weird again.

KWH