Skip to content

System architecture

Mochi Network consists of three parts and one VPS. The three parts divide the work clearly; the VPS is the only place that holds secrets.

PartWhereRole
ContractsMonad testnet (chain id 10143)Source of truth: posts, votes, karma, ranking algorithms
IndexerVPSCache: chooses which posts are considered and returns content. Never ranks
WebCloudFront + S3Static client. No secrets, no server
VPSapi.mochi.memeRuns the indexer and faucet. The faucet holds the system's only private key

The chain is the source of truth

Karma, weight, and feed order all live on-chain. The indexer only answers "which posts", not "which posts are better". See Karma and weight.

Diagram

text
      CloudFront + S3 (mochi.meme)              VPS (api.mochi.meme)
      static bundle, no secrets                 ├── indexer (SQLite, WS tail)
                                                └── faucet /drip (private key)
              │            │                                │
              │            └──── content · candidate selection ──┘

              └──────────────► Monad testnet ◄─────────────
                    PostRegistry · 4 feeds · 3 registries
                                = source of truth

      Cloudflare Pages (docs.mochi.meme) — static docs, public, connected to nothing

The web reads contracts directly for karma, weight, and ranking score, and calls the indexer for post content and candidate lists. The indexer reads chain logs. The faucet writes transactions to the chain with its own key.

Contracts — source of truth

Eight deployed contracts. Full addresses in Contracts and addresses.

  • PostRegistry holds posts, votes, and karma (karmaOf, weightOf).
  • ChronoFeed, HotFeed, BestFeed, ControversialFeed are the four ranking algorithms.
  • AlgorithmRegistry maps feed slots to algorithms. The default slot is HotFeed.
  • IdentityRegistry holds handles. CommunityRegistry holds community names and membership.

No contract has a governance function. There is no follow graph: vote weight is derived directly from the voter's own karma, not from who they follow.

Indexer — cache, not ranking

The indexer reads Monad logs into SQLite, runs one backfill, then tails over WebSocket.

It does two things:

  1. Select candidates. /candidates returns the list of post ids an algorithm should consider, according to one of five strategies (recent, popular, trending, community, joined). This is the indexer's only influence on the feed, and it is deliberately naive and public: no secret weights, no personalization.
  2. Return content. /posts, /profile, /communities, /thread… return text, images, raw counts, and notifications.

Scoring happens on-chain: the web gets ids from the indexer and performs an eth_call to rank(viewer, ids) on the contract. The indexer cannot manipulate ranking because it does not rank at all. See Indexer API.

Web — static client

The web is statically exported, uploaded to S3, and served through CloudFront. It:

  • Runs no server and holds no secrets. Every value in the bundle is public.
  • Signs transactions with a passkey (WebAuthn), so there is no seed phrase to store.
  • Reads the chain to display karma and weight, reads the indexer for content.

The documentation you are reading is a separate VitePress build (source in web/docs), hosted on Cloudflare Pages at docs.mochi.meme — it is not part of the app bundle. The old /docs URL on the app is permanently redirected to that host by an edge function.

On-chain and off-chain

DataWhereNotes
Posts, comments, parentId, community labelChain (PostRegistry)Written as events, immutable
Like/dislike votes and each vote's weightChainlike/dislike write both karma and weighted counters
karmaOf, weightOfChain (PostRegistry)No intermediary reputation service
Ranking scoreChain (rank)Web calls directly, free
HandleChain (IdentityRegistry)
Community registration, join/leaveChain (CommunityRegistry)
Selected feed slotChain (AlgorithmRegistry)
Post index by author / community / threadIndexerDerived from logs, rebuildable
Candidate listIndexerOnly selects posts, does not score
Raw counts likeCount / dislikeCountIndexerFor display
Profiles, notifications, searchIndexerThese are views of the chain
Gate cookies, drips table, image CIDsVPS / IPFSAuxiliary state and secrets

Karma is also cached by the indexer as postKarma/commentKarma to draw profiles, but the official number remains PostRegistry.karmaOf, read directly from the chain.

Why deleting the indexer is safe

Because the indexer is only a cache of facts already on-chain. Delete mochi.db and restart, and it rebuilds from DEPLOY_BLOCK:

  • No posts, votes, or karma lost — all of it remains in Monad's logs.
  • No ranking lost — ranking was never in the indexer.
  • No accounts lost — addresses and handles come from the contracts.

That is what makes the claim "the indexer cannot manipulate the feed" literally true. In exchange, rebuilding is not free: Monad produces roughly 288,000 blocks per day, and the public endpoint rejects eth_getLogs for more than 100 blocks. A dedicated RPC is therefore required, and mochi.db is kept across deploys.

Rebuilding is not a routine operation

Every hour that passes raises the backfill cost. Keep mochi.db alive across restarts; only rebuild when truly necessary.

Private gate

A deployment can be held behind a shared access code until it is ready to go public. The mechanism has only one secret, GATE_SECRET, set in two places:

  • Edge — a CloudFront Function (rewrite-profile.js) is published with the secret substituted into the placeholder at deploy time, so the secret never sits in the repo.
  • API — an environment variable on the VPS, next to the contract addresses.

The flow:

  1. A user submits a code at /gate/. This page does POST /gate to the API.
  2. The API checks the code against GATE_CODES, then sets an apex-scoped mochi_gate cookie.
  3. The cookie has the form expiry.HMAC-SHA256(secret, expiry). No DB, no session.
  4. Both edge and API verify the signature with that same secret before responding.

The edge checks before serving any route; the API checks before answering any route except /health and /gate*. /health is open because the deploy script and monitoring need it to answer before anyone has a code, and it only reports liveness plus a few numbers.

Rotating the secret and restarting the indexer revokes every old cookie at once. The gate hides the application, not the data: posts and votes are on-chain events, still public whether the gate is on or not.

One secret, two ends

Because the edge and API share exactly one secret and verify independently, there is no session store to break, to expire inconsistently, or to attack.

Documentation for Monad testnet. It describes what is actually running.