Onchain Hit Counter
3 min read
Blockchain-powered hit counters revive nostalgic web elements by storing visitor counts on Ethereum testnets, a quirky solution making the.
This post is provided as an example, but the hit counter has been removed from the current version of this site for the time being.
Astro DB shipped recently, a small SQL server with a friendly ORM, and people built fun things on it fast. Hit counters were the obvious one.
Hit counters are a GeoCities and AngelFire memory for me. I wanted one too, but spinning up SQL for a single integer felt like a lot. All I needed was a place to store a number that goes up.
So I put it on a testnet. Holesky ("the first long-standing, merged-from-genesis, public Ethereum testnet") got the first build, mostly because I already had a bag of test ETH. It's the successor to Goerli and runs through 2028. Viem made the on-chain side painless.
No longer on Holesky. Switched to Base Sepolia: shorter projected lifespan, much cheaper transactions, enough room to capture page views too.
The contract:
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;
}
}
Each session hash gets pushed onto the array once, and the count goes up by one. Both sessionCount and sessionHashes are public.
Viem sets up the public and wallet clients:
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 simulates the call, writes the transaction, and returns OK:
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" } });
}
The frontend reads the count straight 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>
A wallet provider and private key handle signing. The chain stores the count and the count updates every block.
The sessionHash is built from IP, user agent, and Date.now(), then run through keccak256:
// 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}`);
The hash is unique per session, so the addSession call dedupes naturally on chain.
The browser caches the sessionHash in local storage with an expiration. If a fresh hash exists, the frontend skips the API call. If not, it generates a new one and increments the counter through the backend.
Hit counters, blockchains, weird web. Good combination.