⚠️ Alpha · testnets only. ChainRender is in active alpha and deployed on testnets only — Ethereum Sepolia, the Robinhood Chain Testnet, and the Circle Arc Testnet — not on any mainnet. Contracts, addresses, and APIs may change. Do not use with real funds or production assets.
Concepts

Rich Media: p5.js & three.js Over the On-Chain Floor

ChainRender supports rich, dynamic, GPU-accelerated visuals — p5.js sketches and three.js scenes — while staying trustless. The trick is the floor principle applied to rendering libraries: the on-chain floor renders first and always; the library runs as an enhancement over it, and the library itself is stored on-chain, not fetched from a CDN.

How it works

A rich-media component carries a scene binding — a library (by content hash), a seed, and an entry point. When it renders:

  1. The floor renders immediately. renderFloor(id) is a pure eth_call — a complete, fully on-chain SVG that needs nothing but the chain. This is what you always see; it's Grade A.
  2. The viewer then attempts the enhancement. It reads the binding, fetches the named library from the on-chain DependencyRegistry by hash, verifies keccak at every stage, gunzips it, and runs the sketch in a sandboxed iframe (sandbox="allow-scripts", CSP default-src 'none').
  3. It fails closed to the floor on any failure — no library, a hash mismatch, a script error, a strict CSP. You never get a broken render; worst case, you get the on-chain floor.
import { encodeSceneBinding } from "@chainrender/scene"; // (illustrative; see chainc/depscene.ts)

// bind a p5 sketch to an on-chain library, by content hash:
const binding = encodeSceneBinding({
  kind: "p5",              // "p5" | "three"
  libHash: p5LibraryHash,  // the DependencyRegistry content hash — the library, on-chain, by hash
  seed,                    // deterministic seed for the sketch
  entry: "sigil",          // the entry point to run
});

The library lives on-chain

This is the key difference from a normal web app that loads three.js from a CDN. Here, the library is stored on-chain in the DependencyRegistry:

  • Content-addressed & chunked. A gzipped library is split into ≤72KB parts, each stored on-chain, registered as a content-addressed manifest, and round-trip-verified.
  • Immutable & pinned once. You upload p5/three once (via scripts/upload-dependency.ts); components then reference it by hash forever — no re-upload per deploy.
  • Verifiable at render. The viewer keccak-verifies every chunk as it reassembles the library. You're running exactly the pinned bytes, not whatever a CDN happens to serve.

A gzipped modern p5 + three core is on the order of ~$20 to pin at sub-1-gwei gas — a one-time cost, after which every scene that references it is free to render.

The two tiers

  • Library-free tier (libHash: ZERO) — canvas-only sketches that need no external library. No upload; the floor + a canvas is all it takes.
  • Library tier — full p5.js or three.js scenes, with the library pinned on-chain as above.

Why this preserves trustlessness

A naive "on-chain three.js" would fetch the library from a CDN — reintroducing a trusted host and dropping the grade. ChainRender's version doesn't:

  • The floor is always fully on-chain and Grade A — the rich enhancement can vanish entirely and you still see a complete, correct, trustless render.
  • The library is on-chain and hash-verified — no CDN, no trusted host, running exactly the pinned bytes.
  • The sketch runs sandboxed (allow-scripts only, default-src 'none') — it can't reach the network, read storage, or escape its frame.

This is the floor principle at its most powerful: you get real p5/three richness and absolute trustlessness, because the rich layer is an enhancement over an on-chain floor, and even the library is on-chain. See The Floor.

Honest boundaries

  • Rendering the enhancement needs a capable client. three.js/p5 run client-side (that's the point of an enhancement). A client without JS/WebGL, or under a stricter CSP than the sandbox needs, gets the floor. That's by design — fail-closed, never broken.
  • three.js is ESM-heavy. The viewer decodes a complete, self-contained three.js (core + module + addons) and runs it via eval (no dynamic import of blob URLs, so it works under strict CSPs). Pin a bundled complete build — three.module.min.js alone isn't self-contained (it imports three.core).
  • This is a rich-media viewer capability. The renderFloor path is universal; the library enhancement requires the rich-media viewer (or your own client implementing the fetch-verify-sandbox flow).

Loading the library (the operational reality)

Both libraries load from chain, as plain script tags -- sandbox-safe and CSP-safe (no CDN):

  • p5 is a UMD build; it loads as a plain script and sets window.p5.
  • three.js must also be the UMD build (r160, three.min.js) -- it sets window.THREE. Modern three (r161+) is ESM-only with no global, which does not work loaded as a plain script in a strict-CSP sandboxed iframe. Sketches use the global THREE (not import). The r160 UMD is the last version with a global build; it is ample for generative/artistic 3D.

The viewer discovers each library by name in the DependencyRegistry (newest-wins, so a re-uploaded UMD library supersedes an older one), fetches the gzipped chunks via eth_call, decompresses with the browser's native DecompressionStream (no external gzip library), and injects the source. No importmaps, no blob URLs, no CDN -- which is what keeps it working under a strict Content-Security-Policy.

Large sketches — chunked SourceStore uploads

The library isn't the only thing that can exceed a single storage blob — so can your own sketch. When a sketch is larger than one SourceStore entry, the builder splits it into chunk blobs and writes one extra manifest blob whose payload is CRCHUNKS1|<id>,<id>,… (the ordered chunk ids). The SourceStore id the app references is that manifest. At load, the viewer reads the entry: if it begins with CRCHUNKS1| it fetches each listed chunk and concatenates them; otherwise it uses the entry as-is (a single blob). It then gunzips if the bytes carry the gzip magic, and runs the sketch. Fully backward-compatible: a small, raw, single-blob sketch has no manifest header and no gzip magic, so it's used verbatim. This is a deploy + runtime mechanism (builder writes, viewer reassembles) — the authoring SDK only estimates chunk count (buildManifest().chunkEstimate); it never needs to know how a large asset was split.

The full-page viewer

A deployed rich-media app runs in the full-page viewer (chainrender-viewer.html), which takes the scene's sketch (SourceStore id), lib (DependencyRegistry libHash), seed, and kind as URL params, loads them from chain, and runs the sketch full-viewport over the floor. Every deploy produces a complete shareable viewer URL, and the Apps directory lists deployed rich-media apps automatically (read from SceneComponentRegistry).

Reading live on-chain data from a sketch (window.CR_CHAIN)

A rich-media sketch runs sandboxed and cannot reach the network — but it can read real, live on-chain data through a single injected bridge: window.CR_CHAIN. This is how a p5/three sketch shows real balances, blocks, gas, token metadata, logs, and ENS names without a CDN, an API, or a trusted host. Every read is a plain JSON-RPC call to the chain.

Grade note. A sketch that reads CR_CHAIN is still a client-side enhancement over the on-chain floor (Grade ⚙). The floor renders first and always; CR_CHAIN enriches the enhancement with real numbers instead of fabricated ones. The rule is simple: never fake chain data. If you show a block number, a balance, a price, or a transfer, it must come from CR_CHAIN — never from random() or a hardcoded literal presented as live.

The snapshot (auto-polled)

CR_CHAIN exposes a live snapshot that refreshes every few seconds. Read these fields directly:

window.CR_CHAIN.blockNumber    // latest block (number) or null until loaded
window.CR_CHAIN.gasPriceGwei   // current gas price in gwei
window.CR_CHAIN.baseFeeGwei    // current base fee in gwei
window.CR_CHAIN.blockHash      // latest block hash
window.CR_CHAIN.txCount        // tx count in the latest block
window.CR_CHAIN.chainId        // e.g. "11155111" (Sepolia)
window.CR_CHAIN.live           // true ONLY when real data has loaded
window.CR_CHAIN.source         // "live" | "loading" | "unavailable"

Critical timing: the snapshot loads asynchronously (~1–2s after start) — it is not ready in setup(). Register a callback with window.CR_CHAIN.onReady(fn), or check window.CR_CHAIN.live each frame in draw() and only switch from a "Loading…" state to real numbers once live === true. Never read once in setup() and fall back to fake data.

window.setup = function () {
  createCanvas(800, 500);
  if (window.CR_CHAIN) window.CR_CHAIN.onReady(function () { /* real data is ready */ });
};
window.draw = function () {
  background(10, 12, 18);
  var live = window.CR_CHAIN && window.CR_CHAIN.live;
  text(live ? ("Block " + window.CR_CHAIN.blockNumber) : "Loading live data…", 20, 40);
};

The read helpers (all Promise-returning, all fail-closed to null)

For anything beyond the snapshot, CR_CHAIN exposes async helpers. Each returns a Promise, guards for nulls, and fails closed — on any RPC error it resolves to null rather than throwing, so your sketch degrades to a labeled fallback instead of breaking. Prefer these over hand-rolling eth_call encoding.

Generic:

CR_CHAIN.read(method, params)   // any read-only RPC method, returns the raw result
CR_CHAIN.call(to, data)         // eth_call to a contract (hex selector + args)
CR_CHAIN.balanceOf(addr)        // NATIVE ETH balance (in ether), NOT a token balance
CR_CHAIN.logs(filter)           // eth_getLogs with a raw filter object
CR_CHAIN.tx(hash)               // a transaction by hash
CR_CHAIN.block(numOrTag)        // a block (with transactions)

Tokens — read metadata and balances the honest way (never guess decimals):

CR_CHAIN.erc20(tokenAddr)                 // -> { name, symbol, decimals, totalSupply, totalSupplyFloat }
CR_CHAIN.tokenBalanceOf(tokenAddr, holder)// -> decimal string of an ERC-20 balance
CR_CHAIN.tokenDecimals(tokenAddr)         // -> the token's decimals (number)

Always scale token amounts by the real decimals. Call erc20() (or tokenDecimals()) once, cache it, and divide raw amounts by 10 ** decimals. Hardcoding / 1e6 or / 1e18 will render a real number at the wrong scale — which is a data-honesty violation exactly like fabricating one.

Transactions & accounts — including whether a tx actually succeeded:

CR_CHAIN.receipt(hash)  // -> { status: "success" | "reverted", gasUsed, effectiveGasPriceGwei, blockNumber, logs }
CR_CHAIN.nonceOf(addr)  // -> the address's transaction count

tx() alone can’t tell you if a transaction reverted — use receipt() and read status before showing any “confirmed”/“success” state.

Code & storage:

CR_CHAIN.isContract(addr)      // -> boolean (guards a hardcoded address before you read it)
CR_CHAIN.codeSize(addr)        // -> bytecode size in bytes
CR_CHAIN.storageAt(addr, slot) // -> raw storage slot (advanced)

Gas fee history — a real gas chart in one call (prefer over sampling gasPriceGwei over wall-clock time):

CR_CHAIN.feeHistory(nBlocks, percentiles)
// -> { baseFeeGwei: [...], gasUsedRatio: [...], oldestBlock, reward: [[...]] }

Event logs — the honest source for historical activity (transfers, mints, swaps), with decode helpers:

CR_CHAIN.logsBetween(addr, topic, fromBlock, toBlock)  // an explicit range
CR_CHAIN.recentLogs(addr, topic, lookbackBlocks)       // auto-ranges from the current block
CR_CHAIN.decodeTransfer(log)  // -> { from, to, valueRaw, tokenId, blockNumber, txHash } (handles ERC-20 vs ERC-721)
CR_CHAIN.topicAddr(topic32)   // extract an address from a 32-byte indexed topic

When decoding events, verify the shape (topics[0] matches the event signature, topics.length matches the indexed-parameter count) — an ERC-721 Transfer indexes tokenId and has no data, unlike ERC-20. And when you show a bounded window, disclose it ("last ~2000 blocks"), never imply a complete history.

ENS — human-readable names instead of raw hex (see the ENS guide):

CR_CHAIN.ensName(addr)       // reverse-resolve an address -> "vitalik.eth" (or null)
CR_CHAIN.ensAddr(name)       // forward-resolve a name -> address (or null)
CR_CHAIN.ensText(name, key)  // read a text record (avatar, url, com.twitter, …)

The honesty rules (enforced by the builder, expected of every sketch)

  • Gate the "LIVE" label on CR_CHAIN.live === true. Until then, show "Loading…". If CR_CHAIN is missing or live is false, label the display "Offline / Simulated" and suffix any placeholder with (SIM) — never a fake number under a LIVE label.
  • Label each data region by its own read. A successful blockNumber does not mean your erc20() or feeHistory() call succeeded — gate each region on its own result, not one global flag.
  • Cache one-time reads (erc20 metadata, decimals, isContract) and throttle repeating reads — never call a helper every frame; gate on a frame counter or a several-second interval.
  • Null-guard everything and .catch() every call, falling back to a clearly-labeled state.
  • Only CR_CHAIN is reachable. The sandbox blocks all other network access by design — do not attempt to fetch any other URL or CDN.

Icons in rich-media (and any) apps

On-chain icons are available to rich-media and standard apps alike -- discovered by name, injected, theme-adaptive. The viewer loads them the same CSP-safe way as p5/three.

Related