The dapp

The app in the repository is cairn-app/: a Next.js 16 front end (App Router) with Tailwind v4, and wagmi and viem for the wallet, served on port 3100 locally. It replaced the earlier React + Vite dapp on 2026-10-02. It carries the project's new name, Cairn.fun, under which the protocol token is $CAIRN; this book keeps the name StockFun.

The app runs on demo data, marked "Illustrative", until a deployment is configured, and nothing is deployed. This page describes it as it stands.

The screens

Route Screen
/ Landing: the next drop, the market tape, the public record of drops, the fee calculator, the baskets, the protocol token, the FAQ
/markets Discovery: treasury pulse, the protocol token's strip, filters, sort and search, the market table
/market/[slug] Market detail: chart, drop panel, trades, trade panel with the fee split before signing, band 1 progress, contracts, the wallet's position
/launch Launch form: identity, basket picker, live preview, irreversibility checklist. Since the eighth audit loop it offers only the baskets read from the chain, and checks the chosen one against the chain again before sending
/claim The holders' airdrop claim, window by window, and the creator fees
/drops Every drop, window by window
/docs Mechanism, fees, baskets, drops, the protocol token, limits

The Treasury Ratio, obsolete since the airdrop decision, which the earlier dapp still displayed on 2026-09-28, does not appear in the app in the repository. The metric that replaces it is still to be decided.

The claim screen

Since 2026-10-05 /claim lists every window the connected wallet can claim, market by market: the window's end, its stocks, the amounts and their value in dollars. The amounts come from the airdrop contract alone (claimable), read again every 60 seconds while the page is open and after each of the wallet's transactions. A stock the airdrop contract is short of after an emergency transfer is marked "awaiting settlement" and left out of the claim.

Below the list come a total and "Claim all", which sends claimMany in batches of five windows (ten until Ethereum's Glamsterdam upgrade reached Sepolia on 2026-10-06, below), and claim naming the other stocks for a window with a stock awaiting settlement. Every call is simulated first: a batch that would fail is split into its windows, and a window into its other stocks, so a window or a stock that cannot be paid now is skipped and named, never sinking the rest. A transaction whose receipt cannot be read keeps its hash and shows as sent, its confirmation not read yet; nothing is sent again, and the page follows it until it, or a transaction in its place, is mined: since the tenth audit loop, a speed-up in the wallet is taken for the claim itself, "View tx" pointing at it, while a cancel or any other replacement ends it as not done, "View tx" on what was mined (below). The result says what was paid, the stocks deferred, which stay due, and the windows skipped, with their reason.

Since 2026-10-06:

  • Only the wallet's refusal stops a run, and since the tenth audit loop a cancel or a replacement in the wallet too. A transaction that fails is counted ("did not go through"), what it covered stays listed, and the other transactions go
  • Room for a stock still on its way. A claimMany pays every stock a window lists when it runs, and a window still open to deliveries can be credited one more stock between the estimate and the inclusion: the app adds 400,000 gas per stock of the basket the window does not list yet (100,000 until the Glamsterdam upgrade). Unused gas is not charged
  • Gas sized for Glamsterdam. Sepolia activated Ethereum's Glamsterdam upgrade on 2026-10-06, which makes a new storage slot cost about five times its former gas; mainnet had no date yet. Each stock a claim pays can write three new slots, so the app leaves 400,000 gas per stock still on its way and claims five windows per transaction, where ten five-stock windows could take up to about 14 million gas
  • Each write's own gas. Under the upgrade a storage slot written from zero costs about 110,000 gas, and a trade can meet writes its estimate never saw: the hook's fee accruals emptied by a claim just before (two of those claims are anyone's to call), the next hour's supply mark, the trader's own record moved in the same block. Since the ninth audit loop the app gives every write its estimate plus 50,000 gas, and a trade, estimated at the latest block, also the gas of each such write that can still happen, read at that same block (112,000 per accrual, 150,000 for the mark, 135,000 for the record; all of them when a read fails). A write whose estimate fails goes with a fixed limit sized from its heaviest case, never the wallet's own estimate, which has no margin; a launch is then not sent, and the page says to try again. Until then every write got 150,000 gas over its estimate (35,000 before the upgrade). A wallet sets aside the limit times its fee before it signs; only the gas used is charged
  • A stock not paid at the last claim. A stock a claim deferred, because its token refused the transfer to this wallet (an issuer freeze, say), is remembered by the browser, shown "not paid at your last claim" and left out of "Claim all". Each claim tries it alone first and takes it back once its claim would go through; when nothing else is due, the button reads "Try the assets not paid again"
  • Unchecked, never refused. A simulation counts only when the node says why the call would fail. A window whose check met an RPC error stays listed as not checked yet, and when no simulation answered at all, nothing is sent
  • Unread is not nothing. A market whose figures could not be read is named, never shown as owing nothing: "Nothing to claim" appears only when every market was read, and the creator section says its markets could not be read rather than "You haven't launched a market"

The creator fees are claimed on the same page, market by market.

Figures unavailable

Since 2026-10-05 a treasury whose vault does not answer its own views, after a faulty upgrade for instance, shows "Figures unavailable" instead of its figures: in the market list's row and card, on the protocol token's banner and on the market page. Its stocks and its ETH are still listed, and the total on the markets page says how many treasuries it left out. The app learns it from the Lens, which reads each vault in its own call: see Architecture.

Since 2026-10-06 the ETH the hook and the liquidity lock hold for a vault, after a payment the vault refused, is part of its treasury, of its value and of the totals: a line "ETH owed", with a note that it is not in the vault yet and moves in once the vault takes it. It is read on the hook and the lock, so it stays current even while the vault does not answer. A vault that does not answer and was never read takes the rail the protocol's bridge setup implies, and the drop panel says so: where its stocks sit is inferred, and its holdings may be incomplete. The protocol token's market stays on screen as last read, marked stale, when the Lens cannot read its treasury, instead of showing as not launched.

What the market page shows

Since 2026-10-06:

  • A recovered pool has no chart. Once the lock's end mode has taken a pool's liquidity out, anyone can move its price for free. The page already showed no price and no trading for it; it now shows no 24-hour change and no chart either, and says why, and the Worker stops sampling its price
  • A trade's share for the stocks is what that trade's own tax lines sent the treasury, at the settings it paid, never today's; "—" when it is not known
  • The market cap counts the circulating supply, the total less what the burn address holds, in the header and on the chart alike; since the seventh audit loop it shows "—", never $0, when the supply could not be read
  • A balance that could not be read is never zero: the position panel says it could not be read, and the trade panel shows the last balance read, marked "last read", without refusing a sale above it; the transaction's simulation refuses a real overdraft
  • The protocol token's market kept as last read says so. While the Lens cannot read its treasury, its price and market cap are marked "(last read)" wherever they show, with no 24-hour change, and its chart reads "Last read" instead of "Now". The Worker records no price for it until the Lens answers again, so the outage adds no point to the chart. Its tax right now is unknown: the page shows no anti-snipe tag, and the trade panel shows the normal tax in force, with no surplus line. Its token's own figures are still read on the token, so "Burned by buyback" on the landing stays current; its market cap is the last one read. Since the seventh audit loop the outage shows on the chart as a gap, the last price read dated by its age, and the token's figures, the market read or kept, take the last values read when they do not answer, never zeros or an empty name; a contract of the protocol that fails after a faulty upgrade no longer blanks them

Since the seventh audit loop, on 2026-10-06:

  • The chart places each point at its time. Each price sample sits where its time falls on the range: 1H, 24H and 7D end now, and "All" starts at the first sample, never at the market's age. A hovered point says how old it is ("3h 20m ago"), the line breaks where samples are missing (an outage, a night when nobody had the page open), and at rest the chart shows the market's price, "Now", or "Last read" for a market kept as last read. Until then the points were spread evenly and dated by their position, so the last price read before an outage showed as current
  • The 24-hour volume shows "—" while it cannot be counted. When the Worker's reads of the trade logs keep failing, its trade window stops advancing; the volume, per market and in the totals, then shows "—" instead of a figure that would shrink as if trading had stopped, and comes back with the first read that catches up

Since the eighth audit loop, on 2026-10-06:

  • A whitelisted wallet sees the normal tax. During a market's anti-snipe window, a connected wallet on that market's whitelist pays the normal tax through the StockFun router, which the trade panel trades through: the panel now shows no anti-snipe surplus for it, and says why in a note. The quote was already right; the surplus line contradicted it. While the whitelist or the wallet is unknown, the panel shows the tax outside the exemptions
  • Why a stock has no price. On Robinhood Chain the oracle holds a stock's price back during a corporate action, and every stock's price while the sequencer is down or just back up, a check off until Chainlink publishes an uptime feed for the chain (see The Robinhood rail). Where a stock's value is missing for that reason, the app says so: "No price for NVDA right now: corporate action in progress", or the Robinhood Chain sequencer down, recovering, or its status unknown, under the drop panel's basket table and the landing's drop card, and next to the stock on the claim page. The landing's card showed such a stock at $0.00; it now shows "—"
  • The 24-hour volume counts each block once. A Worker read answered by a node a few blocks behind the one before no longer makes the trade window count the same blocks twice. For that one read the newest trades may be missing from the feed; the next read restores them

Since the ninth audit loop, on 2026-10-06:

  • A pot that is an estimate says so. When part of a treasury cannot be read or priced (a stock whose price the oracle holds back, a stale ETH/USD feed, a mirror vault kept as last read), its figure counts what could be priced and now carries a "+" wherever it shows: the market list's rows and cards, the protocol token's card, the treasury pulse, the landing, the drop panel and a holder's share. The market list says such a pot sorts by what could be priced, and the pulse says how many treasuries it counted that way. Until then only the drop panel said "an estimate"
  • An approval that could not be read is not zero. A sale through the StockFun router needs the router's allowance first. A failed read of it used to count as none: a holder who had approved was asked to approve again, with two signatures, and a second failure sent an approval for nothing. The trade panel now says the approval could not be read and quotes nothing, and sends neither an approval nor a sale until it reads. Since the tenth audit loop the sale right after an approval reads the allowance, quotes and is checked at no earlier than the approval's block (below), so a node a block behind can no longer answer zero

Since the tenth audit loop, on 2026-10-06:

  • A transaction the wallet cancels is never shown as done. A wallet can cancel a pending transaction (a transfer of nothing to oneself at the same nonce) or speed it up (the same call at a higher fee). The app now reads what was mined in its place: a speed-up is taken for the action itself, "View tx" pointing at it; a cancel or any other replacement ends the flow as not done, "Cancelled in your wallet." or "Replaced by another transaction in your wallet.", "View tx" on what was mined, and stops a claim run as the wallet's refusal does. Until then a cancelled trade read "Done", a cancelled approval counted as given, a cancelled creator's claim read "Claimed" and hid the ETH for the session, and a cancelled launch read "$TICKER is live." with the demo's invented token address; a transaction sped up after the usual three minutes' wait was never confirmed, and the panel stayed locked until the page was reloaded
  • Followed after the wait. Once the usual wait gives up, after three minutes, the app follows the transaction by its nonce: when the wallet's count of sent transactions has moved past it and the transaction has no receipt, it looks for what was mined at that nonce, never in a block from before the send, and applies the same rule. A cancel or a replacement is only ever read from a transaction actually found in a block, never from a missing receipt
  • A launch is live only when the chain says so. The launch reads the market's creation from the factory's own event; without it, it goes back to the form with the transaction's link, and the demo's address never shows outside the demo
  • The block of your last transaction, for a minute. For 60 seconds after one of its own transactions is mined, the app checks, estimates and quotes what it sends next on that chain at a block no earlier than that one, and reads a sale's allowance there. A load-balanced endpoint can answer from a node a block behind: the sale right after its approval used to be refused there ("Approve it first"), and a second try could send a second approval. A node that has not reached that block now counts as late and is asked again; if none reaches it within a few seconds, nothing is sent. For a sale right after its approval the page then says "Your approval went through, but no quote could be read from the pool, so the sale was not sent…" (its quote is the first read made there); a write whose own check cannot reach the block (an approval, a claim, a launch) says "This could not be checked against the block of your last transaction just now, so nothing was sent. Try again in a moment."
  • What is left. A false "Replaced" stays possible in one narrow case, when all of these hold: no node showed the sale, so the app guessed its nonce from the wallet's count; a node behind the others, though not behind the block read before the send, answered the count; another transaction of the same wallet, which that node had not seen, took the guessed nonce; and the sale, sent through a private relay, was still unmined after the three minutes and a grace of about 36 seconds. A cancel is never shown as done, and nothing stays locked

Launching

The launch form offers only the baskets read from the chain's registry, under the registry's own numbering: before the first read it offers none ("Reading the baskets from the chain…"), and before the contracts are deployed it says launching opens once they are live. Just before sending, it reads again from the factory both the creation fee and the chosen basket, its name and its stocks with their weights, and refuses to send, sending nothing, if either differs from what it shows; the message names the basket the chain holds under that number. Until the eighth audit loop the form offered the configured baskets, with their configured numbers, before its first read, and a deployment whose registry numbers its baskets otherwise could have launched a market on another basket than the one shown. The form pays the creation fee alone: it has no buy of the creator's own, and names no anti-snipe whitelist, which it says (the factory accepts both from a direct call: see Launching a market). The confirmation names the basket checked at sending.

Art direction

The app's design rules live in its own DESIGN.md: a warm light theme on a white canvas, terracotta for the drops and the key figures, Poppins for the interface and Instrument Serif for large financial figures, tabular figures in the tables.

Where things come from

Fees, launch parameters, supply and basket composition are never written in a component: they live in the app's configuration or come from the chain, and the fee lines are checked at start against the constants generated from @stockfun/shared, which mirrors the contracts. A percentage typed into a page is how a product ends up advertising a schedule the contracts do not implement. Since 2026-10-05 the numbers are owner settings: the shared package holds their defaults, and a live value is read from the contract that holds it.

Protocol state comes from the chain; there is no indexer. A Worker, cairn-worker/, reads it once for every open page: a snapshot, then a WebSocket that pushes only the changes. When the Worker fails, the page reads the chain itself through a public RPC. The wallet's own data, from its balances to what it can claim, is read by the browser. Since the seventh audit loop the Worker reads only endpoints that say they serve its chain: one on another chain counts as down, so a backup on the wrong network can never make every market disappear. Since the eighth it publishes its eighth data schema, which adds the reason a stock's price is missing; an app reading an older schema still works, without that reason. Since the ninth audit loop the Worker's watcher keeps what it learnt of its RPCs (which endpoint is failing and since when, how long to back off, which chain each serves) across the moments its Cloudflare object sleeps between two reads, without ever storing an endpoint's address: an outage of the primary RPC costs one probe per doubling pause instead of three requests at every read, and a healthy read asks nothing twice. It also records Robinhood Chain's own block number, where it used to record the block of the chain Robinhood Chain settles on.

The copy audit

pnpm audit:copy scans the backend, keeper, shared and contract sources. It fails on a single forbidden word or statically checkable visual prohibition. It runs on demand; it is not part of pnpm build. Since the earlier dapp left the repository it scans no front end: in the app, the forbidden vocabulary is a review rule, written in its PRODUCT.md.

It is not a style linter: the forbidden vocabulary is a legal constraint.