{"kind":"doc","doc":{"title":"Onchain Hit Counter","section":{"slug":"posts","name":"Posts"},"rkey":"1712329304675-onchain-hit-counter","cid":"bafkreibjhqf6y56kil3h24hblbhtdfqx2utosxnla2hwdhlovro5fq7dbe","renderKey":"bafkreibjhqf6y56kil3h24hblbhtdfqx2utosxnla2hwdhlovro5fq7dbe@2024-04-05T08:01:00Z@2026-07-16T14:56:29Z@Posts|","href":"/posts/1712329304675-onchain-hit-counter","publishedAt":"2024-04-05T08:01:00Z","updatedAt":"2026-07-16T14:56:29Z","tags":["web-development","blockchain","astro","testnet","smart-contract","viem","hit-counter"],"description":"A GeoCities-style hit counter that stores its count on an Ethereum testnet because a database felt excessive.","readingMinutes":3,"markdown":"> 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> 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 => 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<span id=\"sessionCount\"></span>\n\n<script>\n  import { publicClient } from \"@lib/viemClients\";\n  import { sessionCountABI } from \"@lib/abi\";\n\n  document.addEventListener(\"DOMContentLoaded\", async () => {\n    const contractAddress = import.meta.env.PUBLIC_HIT_COUNTER_CONTRACT;\n\n    const updateSessionCount = async () => {\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</script>\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.","media":{},"series":{},"related":[{"title":"orbz dot com","href":"/posts/1736537615834-orbz-dot-com","section":"posts","description":"orbz.fun reads Orbiter's onchain site registry and turns it into a StumbleUpon-style discovery tool.","publishedAt":"2025-01-10T11:33:00Z"},{"title":"Revisiting Obsidian as a CMS, again","href":"/posts/1710177704945-revisiting-obsidian-as-a-cms-again","section":"posts","description":"Obsidian, GitHub's GraphQL API, and Astro form a free, flexible publishing system for Markdown content.","publishedAt":"2024-03-11T10:21:00Z"},{"title":"Mint thyself","href":"/posts/1685948400001-mint-thyself","section":"posts","description":"A Zora HTML edition that embeds its own mint interface and can be minted from itself.","publishedAt":"2023-06-28T13:00:00Z"},{"title":"This is your sign","href":"/posts/1793118574479-this-is-your-sign","section":"posts","description":"You can add it with one meta tag.","publishedAt":"2026-10-27T16:30:11Z"},{"title":"Post-UI","href":"/posts/1784126858578-post-ui","section":"posts","description":"Do we still need to ship a frontend?","publishedAt":"2026-07-15T17:27:00Z"},{"title":"Farfield","href":"/posts/1779066375000-farfield","section":"posts","description":"Five small Go services, single binaries, content-addressed records, running on my homelab. The new backend for the site.","publishedAt":"2026-05-18T01:06:15Z"}],"prev":{"title":"Revisiting Obsidian as a CMS, again","href":"/posts/1710177704945-revisiting-obsidian-as-a-cms-again"},"next":{"title":"Generate a new private key","href":"/posts/1712432046964-generate-a-new-private-key"}}}