The frontend — wallets, images, live UX

By Milkie · 9 min read

The frontend is a Next.js Pages Router app, deployed at social.asentum.com, source on GitHub. It's intentionally not a SPA — every page server-renders a static shell, then the wallet + indexer state come in client-side. About a thousand lines of React across maybe 12 components.

There were three problems on the frontend side that were genuinely interesting. None of them are about React or Tailwind. They're about how a chain like ours plugs into a modern web app.

problem 1 — wallet connect, but not Ethereum

If you've shipped any kind of web3 frontend in the last five years, you reach for AppKit (formerly Web3Modal), or RainbowKit, or ConnectKit, or wagmi, or one of a dozen excellent open-source kits that handle "let the user click a button and connect their wallet." They all share an assumption: the wallet signs ECDSA over secp256k1. That's the Ethereum signature scheme, and basically the entire web3 wallet ecosystem is built around it.

AsentumChain doesn't use ECDSA. We use Dilithium3 — the post-quantum signature scheme standardized by NIST as ML-DSA-65 in FIPS 204. Public keys are ~1.3 KB, signatures are ~3.3 KB, and no MetaMask plugin will ever sign one for you. Which means no AppKit, no WalletConnect, no RainbowKit. It's not a configuration choice; it's a different cryptographic universe.

What we have instead is two native flows that we built ourselves:

Flow 1: The Asentum browser extension. A real Chrome extension that holds a Dilithium3 keypair, exposes a window.asentum object on every page, and pops a confirmation modal whenever a dapp calls connect() or signTransaction(). Behaves a lot like MetaMask if you've used MetaMask. The extension is in packages/extension and ships with the chain.

Flow 2: The Telegram wallet bot. This is the more interesting one. Users who don't want to install a browser extension talk to @AsentumBot on Telegram. The bot creates a Dilithium3 wallet for them, encrypts it with a PIN they set, and acts as a remote signer. Dapps connect via a 6-digit code: the dapp asks our wallet bot API for a session, gets back a one-time code, displays it. The user types the code into the bot, the bot pairs the session with their wallet, the dapp now has an authorized session id. From then on, every transaction the dapp wants signed gets pushed as a Telegram message — "Approve this contract call?" — with inline buttons.

The connect modal on social.asentum.com offers both:

┌──────────────────────────────────────────┐
│   Connect wallet                       × │
│ ────────────────────────────────────────│
│   ┌──────────────┐   ┌──────────────┐    │
│   │      🧠      │   │      📱      │    │
│   │  Extension   │   │ 6-digit code │    │
│   │              │   │              │    │
│   │ Browser      │   │ Telegram bot │    │
│   │ extension    │   │ no install   │    │
│   └──────────────┘   └──────────────┘    │
└──────────────────────────────────────────┘

Whichever flow the user picks, the rest of the dapp doesn't know the difference. There's a single React context (useWallet()) that exposes one unified API:

const { isConnected, address, callContract, deployContract } = useWallet();

await callContract({
  to: CONTRACTS.posts,
  method: 'post',
  args: [content, imageUrl],
});

Internally, callContract either calls window.asentum.callContract(...) (extension path) or POSTs a sign request to wallet.asentum.com/api/sessions/<id>/sign-request and polls for approval (Telegram path). The dapp code is identical either way.

The thing I want to land here: once you stop assuming "wallet === metamask," the design problem gets clearer. A wallet is a thing that can sign messages and approve transactions. It doesn't need to be in a browser extension to do that. A Telegram bot can do it. An iOS app could do it. A hardware device could do it. The "extension or 6-digit code" pattern is genuinely better UX than the WalletConnect QR-dance because it doesn't depend on the user already having installed something.

problem 2 — images, on a chain with no IPFS

Images are the canonical "you can't put this on a chain" problem.

A typical post-it-shaped image is 200 KB. Base64-encoded into a string (which is what we'd need to put it in our string-keyed VM storage), that's 270 KB. Times every node that ever syncs the chain. Times every block that contains it. Times every replica that re-applies the chain on a fresh sync. It's a non-starter. Every single chain in production handles images the same way: by not handling them.

The accepted decentralized solution is IPFS. You pin the image bytes to IPFS, get back a content hash, store the hash on-chain. When the frontend wants to render the image, it fetches the bytes from any IPFS gateway. This is architecturally beautiful. In practice it's slow (gateway lookups can take seconds), expensive (pinning isn't free unless you run your own pinning infrastructure), and prone to bit-rot (gateways disappear, pinning services churn).

For this case study I picked Cloudinary. Yes, that's centralized. Yes, it's a single point of failure. I picked it because:

  • The infrastructure is one line of config. The site already uses Cloudinary for marketing assets. Adding a server-side upload endpoint took maybe 30 lines.
  • It's free at the volume we'll see for the foreseeable future. A few thousand images at the free tier, automatic transformations, automatic CDN. Better than 99% of self-hosted setups for a tiny dapp.
  • The on-chain story doesn't change. Whether the image bytes live on IPFS or Cloudinary, the chain just stores a string. We can swap the upload backend any time without touching contracts.

I want to be very clear about the tradeoff: this is a real centralization point in an otherwise decentralized application. If Cloudinary turned off our account tomorrow, every avatar and post image would 404. The chain itself would still have the URL strings, so we could re-upload elsewhere and the broken links could be fixed in code, but the user-facing experience would degrade.

The IPFS upgrade path looks exactly like:

  1. Add an ipfs:// URL scheme to the upload route, fall back to Cloudinary if pinning fails.
  2. Frontend renders both URL schemes via a tiny resolveUrl() helper.
  3. New uploads go to IPFS first; old Cloudinary URLs keep working.

Probably a day's work. Not a v1 priority because the centralization tradeoff is well-understood and the cost-benefit isn't there yet.

The mechanics of the upload itself are boring: client picks a file, FileReader produces a base64 data URI, POST to /api/upload with the URI plus the connected address (server-side throws if address looks bogus), Cloudinary uploads, returns secure URL. Frontend writes that URL into whatever string field the contract has — Profile.avatar, Posts.imageUrl, Gallery.imageUrl. The chain sees a string. The image is anywhere it needs to be.

problem 3 — rendering live data without losing the user's mind

A naive web3 frontend pattern: fetch contract state on mount, useState it, render. User refreshes if they want updates.

That works for static-ish state — a token balance, a contract's owner, etc. It does not work for a feed. A feed needs to render new posts the moment they land. A profile needs to update when the owner edits their bio. A vote count needs to tick up when someone votes.

The brute-force fix is polling: refetch every 5 seconds. This is bad in three ways: it hammers the RPC, it never feels truly live (there's always a 0-5 second lag), and it doubles the load on the chain for every user with the page open.

The better fix is what we built. The indexer's WebSocket pushes a single JSON frame for every new on-chain activity. The frontend opens one WS connection per tab and dispatches the frames into the right places:

  • New post.created event → home feed prepends it (or re-sorts if "Hot" tab)
  • New follow.followed where target = my profile → my follower count tick
  • New post.voted on a post in view → that post's score updates
  • Any new activity → toast lane fires

This means the page content is fully reactive without anyone refreshing anything, and the only network activity per tab is the one WebSocket. It's the same pattern Twitter, Discord, and every other modern social app uses; the only difference is the data is coming from the chain via our indexer instead of a SaaS backend.

Here's the React boilerplate for hooking into the stream — it's about as small as you'd hope:

import { subscribe } from '@/lib/activityStream';

useEffect(() => {
  const offHello = subscribe('hello', (recent) => { /* catch-up batch */ });
  const offActivity = subscribe('activity', (a) => {
    // dispatch by activity.type
    if (a.type === 'post.created') prependPost(a);
    else if (a.type === 'post.voted' && a.data.postId === thisPostId) {
      updateScore(a.data.score);
    }
    // ...
  });
  return () => { offHello(); offActivity(); };
}, [thisPostId]);

One singleton WebSocket per page, an EventTarget that components subscribe to, no global Redux store, no React Query, no overcomplicated cache invalidation. Every component listens for the events it cares about and ignores the rest.

the design pass

I'll spare the typography rant, but the visual redesign is worth a single paragraph. The first version of this site was functional and ugly — borders too sharp, type too inconsistent, mobile layout an afterthought. The polished version (also on the live site) borrows three things from modern social apps:

  1. Rounded everything. 16px radius on cards, full-pill on buttons, circle avatars with a subtle accent ring. Sharp corners in 2026 read as "1980s terminal," which is fine for the marketing site but wrong for a social app.
  2. Mobile-first nav. A bottom tab bar appears under 640px (Home / Activity / Profile / Settings). On desktop the bar disappears and the same items merge into the top header. One layout, two breakpoints.
  3. White CTAs on dark. The "Connect" button, the "Hot" tab pill, the "Follow" button — all white-on-black. They pop against the dark UI in a way the previous green-on-dark did not. Accent green is reserved for active states and hover.

Total redesign time: about an hour and a half. That's a useful data point in itself — when the underlying components are decoupled and your design tokens are centralized, a "redesign" is a one-evening pass over tailwind.config.js and a handful of components.

Frontend stack, summarized:

  • Next.js 15 + Pages Router — server-renders the shell, client-renders state
  • Tailwind 3 — design tokens, utility-first
  • AsentumWallet context — extension or Telegram bot, behind one API
  • Cloudinary — image upload, with a planned IPFS migration path
  • Indexer WebSocket — single-source-of-truth for live events
  • No AppKit, RainbowKit, wagmi, ethers (well, ethers v6 briefly, then ripped out)

In Chapter 6, the lessons. Including the state-divergence bug we found and fixed mid-build that nearly killed the demo.