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:
- The floor renders immediately.
renderFloor(id)is a pureeth_call— a complete, fully on-chain SVG that needs nothing but the chain. This is what you always see; it's Grade A. - The viewer then attempts the enhancement. It reads the binding, fetches the named library from the
on-chain
DependencyRegistryby hash, verifieskeccakat every stage, gunzips it, and runs the sketch in a sandboxed iframe (sandbox="allow-scripts", CSPdefault-src 'none'). - 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-scriptsonly,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 dynamicimportof blob URLs, so it works under strict CSPs). Pin a bundled complete build —three.module.min.jsalone isn't self-contained (it importsthree.core). - This is a rich-media viewer capability. The
renderFloorpath 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 setswindow.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 globalTHREE(notimport). 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_CHAINis still a client-side enhancement over the on-chain floor (Grade ⚙). The floor renders first and always;CR_CHAINenriches 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 fromCR_CHAIN— never fromrandom()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. Callerc20()(ortokenDecimals()) once, cache it, and divide raw amounts by10 ** decimals. Hardcoding/ 1e6or/ 1e18will 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 — usereceipt()and readstatusbefore 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.lengthmatches the indexed-parameter count) — an ERC-721TransferindexestokenIdand has nodata, 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…". IfCR_CHAINis missing orliveis 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
blockNumberdoes not mean yourerc20()orfeeHistory()call succeeded — gate each region on its own result, not one global flag. - Cache one-time reads (
erc20metadata,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_CHAINis reachable. The sandbox blocks all other network access by design — do not attempt tofetchany 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
- The Floor — the principle this is built on.
- Visual & Media Capabilities — the on-chain floors (SDF, generative art) these enhance.
- What's Fully On-Chain — including the on-chain library storage.