Guide: Writing an App Contract (the Backend in Solidity)
Goal: put an app's logic and data in your own contract, so the app runs on-chain end to end. The ChainRender component then reads the contract with on-chain slots, and its buttons call it through declared actions.
Most apps need no contract of their own. Composing reads of existing contracts is enough: balances, NFTs, prices, ENS (see Resolver Kinds). Write one when the app has its own rules or state: a fundraiser, a vote, a registry, a game, an escrow.
The worked example is contracts/examples/GoalEscrow.sol, an all-or-nothing fundraiser, with its tests in
test/GoalEscrow.ts. Every claim below about it is tested there.
Before you write: does it already exist?
These are deployed or built ChainRender contracts. Check Deployed Addresses for where each one is live.
| You need | Use | Guide |
|---|---|---|
| Two-player games, a waiting room, stakes, on-chain results | GameLobbyV2 + GameChannelV2 + your IGameRulesV2 |
Multiplayer & Lobbies |
| Show any view function of any contract | CallResolver (staticread), MultiCallResolver (multiread), ComputeResolver (compute) |
Resolver Kinds |
| A custom on-chain value or image renderer | your own resolver | Writing a Resolver |
The rules
| Rule | Why | In GoalEscrow |
|---|---|---|
Ownerless and immutable. No admin, no onlyOwner, no proxy, no pause. A new version is a new deployment, and the old one stays callable. |
An app whose backend can be changed or frozen by a key is not trustless, whatever the frontend says. | No owner state at all; one contract serves every campaign. |
| Parameters per item, not constants. Goals, deadlines, fees and windows are arguments stored per item. Never bake in chain limits (code size, gas caps, block times). | The same bytecode works on every chain and for every use. | create(beneficiary, goal, deadline, metaHash). |
| Pull payments. Each party withdraws what they are owed with their own call, and state changes before the transfer. Never loop over recipients to push funds. | One reverting recipient cannot block everyone else, and reentrancy finds nothing left to take. | refund zeroes the caller's balance, then sends it; payOut sets paidOut first. |
Few slot writes. Pack related fields into one 32-byte slot. Put descriptions in events or SourceStore, not storage. |
After Glamsterdam (EIP-8038), slot writes are the expensive part of a transaction. Reads are cheap. | Two slots per campaign; the description is a hash emitted in CampaignCreated. |
One single-value view per field. For example raisedOf(id), not only getCampaign(id) returning a struct. |
A staticread slot decodes one return value. With single-value views, the floor can show any field directly. |
goalOf, raisedOf, deadlineOf, statusOf, progressBps, beneficiaryOf, contributionOf. |
| Display-ready numbers. Return status as a small integer and progress in basis points. Document the encoding in NatSpec. | The resolver formats numbers (FMT.FIXED with decimals), but it cannot run your logic. |
statusOf returns 0 to 3; progressBps returns 10000 for 100%. |
| An event for every state change, with indexed ids. | Live views and indexers follow events (see Live Data & Events). | CampaignCreated, Contributed, PaidOut, Refunded. |
| Custom errors, not revert strings. | They are cheaper, and clients and tests can match them by name. | Closed(), GoalNotMet(), … |
NatSpec @notice on the contract and on every external function. |
The generated Deployed Addresses page uses the contract's @notice as "what it is". AIs and reviewers read it too. |
— |
| Say what the code does, nothing more. | Your page and the docs must not promise what the contract does not enforce. | The NatSpec states the payout rule exactly. |
Toolchain
Use the settings ChainRender itself is built with, which Local Build Environment lists:
- Hardhat 3, with solc 0.8.36 pinned from npm;
evmVersion: "cancun";- the optimizer on at 200 runs with
details: { yul: false }; - never viaIR;
pragma solidity ^0.8.28;.
Tests use node:test with viem through Hardhat (npx hardhat test nodejs test/<file>.ts).
Test it like the example
test/GoalEscrow.ts covers four things, and an app contract should have the same four kinds of test:
- The happy path, end to end. Create, contribute, reach the deadline, pay out, and check the balances moved.
- Every failure path the contract promises. Late contributions, an early payout, a double payout, a refund when the goal was met, a refund for someone who never contributed, and a double refund.
- Bad input. A zero beneficiary, a zero goal, a deadline in the past, an unknown id.
- The reads your app will display. Deploy
CallResolverin the test and read each field through it, exactly as the floor will. ThencheckNodethe component tree you plan to ship.
To move time in a test, use const t = await viem.getTestClient(); await t.increaseTime({ seconds }); await t.mine({ blocks: 1 }).
Deploy it
Keys and RPC URLs go through Hardhat configuration variables, which keeps them out of the shell history.
npx hardhat keystore set SEPOLIA_RPC_URL
npx hardhat keystore set SEPOLIA_PRIVATE_KEY # prompts; never paste a key on the command line
npx hardhat compile
npx hardhat test nodejs test/GoalEscrow.ts
A minimal deploy script (scripts/deploy-goal-escrow.ts):
import { network } from "hardhat";
const { viem } = await network.create();
const c = await viem.deployContract("GoalEscrow", []);
console.log("GoalEscrow", c.address);
npx hardhat run scripts/deploy-goal-escrow.ts --network sepolia
npx hardhat verify --network sepolia <address> # source verification on the explorer (verify)
Every chain in this repository's hardhat.config.ts has its own variables, for example
ROBINHOOD_TESTNET_PRIVATE_KEY and ARC_TESTNET_PRIVATE_KEY. Arc's gas token is USDC, so the deployer needs
testnet USDC there. On a mainnet, deploy from a hardware wallet or an encrypted keystore, never from a raw key.
To deploy the same address on several chains, deploy from the same fresh account with the same nonce on each chain, or use a deterministic deployer (CreateX, as the cross-chain contracts do; see Proven Balances).
Bind it into the app
import { compose, reads, checkNode, grade, type Node } from "@chainrender/client";
const ESCROW = "0x…" as `0x${string}`; // your deployed GoalEscrow
const CALL_RESOLVER = "0x…" as `0x${string}`; // CallResolver on the same chain (Deployed Addresses)
const read = (signature: string, args: unknown[] = [], fmt = {}) =>
reads.staticReadSlot(CALL_RESOLVER, { target: ESCROW, callData: reads.callData(signature, args), ...fmt }) as Node;
const eth = { format: reads.FMT.FIXED, decimals: 18, fracDigits: 3 };
const campaign = compose.box([
compose.heading("Campaign #0"),
compose.box([compose.text("Raised"), read("raisedOf(uint256)", [0n], eth),
compose.text("of"), read("goalOf(uint256)", [0n], eth)], { direction: "row" }),
compose.text("Progress (basis points):"), read("progressBps(uint256)", [0n]),
compose.button("Contribute 0.01", { variant: "primary",
action: { to: ESCROW, sig: "contribute(uint256)", value: "10000000000000000", args: "0" } }),
compose.button("Refund me", { action: { to: ESCROW, sig: "refund(uint256)", args: "0" } }),
], { direction: "column" });
console.log(checkNode(campaign).ok, grade(campaign).letter);
Things to know when binding:
- Slots and contracts must be on the same chain.
CallResolverSTATICCALLs your contract, so it can only read a contract on its own chain. - A declared action's
valueis fixed at compile time. The wallet decode can therefore state the exact amount sent, but a floor button sends a fixed amount. Offer fixed amounts ("Contribute 0.01", "0.1"), or let a client page build the transaction for an arbitrary amount. - User input fills
$namearguments. Aninputwithname: "id"fillsargs: "$id". The runtime coerces the text to the parameter type insig. - The grade reflects only what is on-chain. A tree of
staticreadslots and declared actions against an ownerless contract grades A. Anything the client computes or fetches is graded as such.
Related
- Multiplayer & Lobbies: a complete multi-contract backend (lobby, channel, rules)
- Declare a Safe Action · Bind Data · Writing a Resolver
- Local Build Environment · Deployed Addresses