{"collection":"posts","slug":"1712329304675-onchain-hit-counter","cid":"bafkreibjhqf6y56kil3h24hblbhtdfqx2utosxnla2hwdhlovro5fq7dbe","title":"Onchain Hit Counter","excerpt":"A GeoCities-style hit counter that stores its count on an Ethereum testnet because a database felt excessive.","body":"\u003e 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.\n\n[Astro DB](https://astro.build/db/) shipped recently, a small SQL server with a friendly ORM, and people built fun things on it fast. [Hit counters](https://rosnovsky.us/blog/exploring-astro-db) were the obvious one.\n\nHit 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.\n\nSo I put it on a testnet. [Holesky](https://github.com/eth-clients/holesky?tab=readme-ov-file) (\"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](https://github.com/eth-clients/goerli) and runs through 2028. [Viem](https://viem.sh/) made the on-chain side painless.\n\n\u003e No longer on Holesky. Switched to Base Sepolia: shorter projected lifespan, much cheaper transactions, enough room to capture page views too.\n\nThe contract:\n\n```solidity\npragma solidity ^0.8.25;\n\ncontract HitCounter {\n    mapping(bytes32 =\u003e bool) private sessionExists;\n    bytes32[] private sessionHashes;\n    uint256 public sessionCount;\n    event SessionAdded(bytes32 indexed sessionHash);\n    address public owner;\n\n    modifier onlyOwner() {\n        require(msg.sender == owner, \"Caller is not the owner\");\n        _;\n    }\n\n    constructor() {\n        owner = msg.sender;\n    }\n\n    function addSession(bytes32 sessionHash) external onlyOwner {\n        require(!sessionExists[sessionHash], \"Session already exists\");\n        sessionExists[sessionHash] = true;\n        sessionHashes.push(sessionHash);\n        sessionCount++;\n        emit SessionAdded(sessionHash);\n    }\n\n    function getAllSessionHashes() external view returns (bytes32[] memory) {\n        return sessionHashes;\n    }\n}\n```\n\nEach session hash gets pushed onto the array once, and the count goes up by one. Both `sessionCount` and `sessionHashes` are public.\n\nViem sets up the public and wallet clients:\n\n```typescript\nimport { createPublicClient, createWalletClient, http } from \"viem\";\nimport { privateKeyToAccount } from \"viem/accounts\";\nimport { holesky } from \"viem/chains\";\n\nexport const publicClient = createPublicClient({\n  chain: holesky,\n  transport: http(\"https://ethereum-holesky-rpc.publicnode.com\"),\n});\n\nexport const walletClient = createWalletClient({\n  chain: holesky,\n  transport: http(\"https://ethereum-holesky-rpc.publicnode.com\"),\n});\n\nexport const account = privateKeyToAccount(import.meta.env.HIT_COUNTER_WALLET);\n```\n\nThe backend simulates the call, writes the transaction, and returns OK:\n\n```typescript\nimport { account, publicClient, walletClient } from \"@lib/viemClients\";\nimport { addSessionABI } from \"@lib/abi\";\n\nexport async function POST({ request }) {\n  const requestBody = await request.json();\n  const sessionHash = requestBody.sessionHash;\n  const contractAddress = import.meta.env.PUBLIC_HIT_COUNTER_CONTRACT;\n\n  const { request: contractRequest } = await publicClient.simulateContract({\n    address: `0x${contractAddress}`,\n    abi: addSessionABI!,\n    functionName: \"addSession\",\n    args: [sessionHash],\n    account,\n  });\n\n  let nonce = await publicClient.getTransactionCount({ address: account.address });\n  contractRequest.nonce = nonce;\n\n  await walletClient.writeContract(contractRequest);\n  return new Response(JSON.stringify({ status: \"OK\" }), { headers: { \"Content-Type\": \"application/json\" } });\n}\n```\n\nThe frontend reads the count straight from the contract:\n\n```html\n\u003cspan id=\"sessionCount\"\u003e\u003c/span\u003e\n\n\u003cscript\u003e\n  import { publicClient } from \"@lib/viemClients\";\n  import { sessionCountABI } from \"@lib/abi\";\n\n  document.addEventListener(\"DOMContentLoaded\", async () =\u003e {\n    const contractAddress = import.meta.env.PUBLIC_HIT_COUNTER_CONTRACT;\n\n    const updateSessionCount = async () =\u003e {\n      const data = await publicClient.readContract({\n        address: `0x${contractAddress}`,\n        abi: sessionCountABI,\n        functionName: \"sessionCount\",\n      });\n\n      const sessionCountElement = document.getElementById(\"sessionCount\");\n      sessionCountElement.innerText = `${Number(data)}`;\n    };\n\n    updateSessionCount();\n  });\n\u003c/script\u003e\n```\n\nA wallet provider and private key handle signing. The chain stores the count and the count updates every block.\n\nThe `sessionHash` is built from IP, user agent, and `Date.now()`, then run through `keccak256`:\n\n```typescript\n// Extract IP and user agent from the request\nconst ip = Astro.clientAddress;\nconst userAgent = Astro.request.headers.get(\"user-agent\");\n\n// Get the current time in milliseconds\nconst currentTime = Date.now();\n\n// Hash the IP, user agent, and current time together\nconst sessionHash = keccak256(`0x${ip + userAgent + currentTime}`);\n```\n\nThe hash is unique per session, so the `addSession` call dedupes naturally on chain.\n\nThe 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.\n\nHit counters, blockchains, weird web. Good combination.","tags":["web-development","blockchain","astro","testnet","smart-contract","viem","hit-counter"],"published":true,"createdAt":"2024-04-05T08:01:00Z","updatedAt":"2026-07-16T14:56:29Z"}
