A technical deep-dive for developers. Code snippets, architecture, and the lessons learned building a trust-minimized crowdfunding escrow on Arc Testnet.
TL;DR
Round is a crowdfunding escrow where backers commit USDC to a funding goal + deadline. The founder is paid only if the goal is reached; otherwise every backer gets a full refund. No admin, no pause, no upgrade, no fee — each raise is its own independent contract. The whole thing runs on Arc Testnet, where USDC is both the native gas token and the ERC-20 being escrowed.
Stack: Solidity 0.8.20 + Foundry, React 18 + Vite + TypeScript, wagmi v2 / viem v2, Anon-RPC for privacy-preserving reads. 59 contract tests, 31 frontend tests.
Why build this?
Traditional crowdfunding platforms (Kickstarter, GoFundMe) are custodial: they hold funds, charge fees, and can freeze or refund at their discretion. For micro-raises under $10k, that overhead and trust assumption is disproportionate. Round removes the middleman entirely: the escrow logic lives on-chain, anyone can finalize an outcome, and refunds are pull-based by each backer.
Architecture
The RPC adapter rule
Every chain read goes through src/lib/rpc.ts, and every export makes exactly one JSON-RPC request. A JSON-RPC batch counts as one. This keeps the home page (which loads many rounds) to a single private round trip:
// src/lib/rpc.ts export async function roundSummaries(rounds: Address[]): Promise<RoundData[]> { if (rounds.length === 0) return [] const raws = await ethCallBatch(rounds.map((to) => ({ to, data: summaryData }))) return rounds.map((address, i) => toRoundData(address, raws[i])) } No viem http() transport is used anywhere in src/. wagmi's transport is a custom one layered over anonRpc, so all traffic is anonymized through a null-origin iframe + WebRTC worker.
Reads are simulated before writes
Before opening the wallet, we dry-run the write with eth_call so a revert becomes a named custom error:
export async function simulateWrite(req: WriteRequest, account: Address): Promise<void> { const data = encodeFunctionData({ abi: req.abi, functionName: req.functionName, args: req.args }) try { await anonRpc('eth_call', [{ from: account, to: req.address, data }, 'latest']) } catch (error) { if (error instanceof JsonRpcError && typeof error.data === 'string' && error.data.startsWith('0x')) { const decoded = decodeErrorResult({ abi: ERROR_ABI, data: error.data as Hex }) throw new RpcRevertError(decoded.errorName) } throw error } } Smart contracts
RoundFactory.sol
Permissionless registry and deployer. No admin, fees, upgrade, or custody.
function createRound(string title, string metadataURI, uint256 target, uint64 deadline, uint256 perBackerCap) external returns (address) { address round = address(new Round(msg.sender, title, metadataURI, target, deadline, perBackerCap, usdc)); rounds.push(round); emit RoundCreated(round, msg.sender, title, target, deadline); return round; } Round.sol — all-or-nothing escrow
State machine: Open → Settled | Refunded.
function commit(uint256 amount) external nonReentrant { if (state != State.Open) revert NotOpen(); if (block.timestamp >= deadline) revert DeadlinePassed(); if (amount == 0) revert ZeroAmount(); uint256 previous = commitments[msg.sender]; if (amount > perBackerCap - previous) revert ExceedsCap(); if (previous == 0) { backers.push(msg.sender); backerCount++; } commitments[msg.sender] = previous + amount; totalRaised += amount; // reject fee-on-transfer / non-standard tokens uint256 before = usdc.balanceOf(address(this)); usdc.safeTransferFrom(msg.sender, address(this), amount); uint256 after_ = usdc.balanceOf(address(this)); if (after_ < before || after_ - before != amount) revert UnsupportedToken(); _checkInvariant(); emit Committed(address(this), msg.sender, amount, totalRaised); } Closing is permissionless and deterministic:
function close() external { if (state != State.Open) revert NotOpen(); if (block.timestamp < deadline) revert DeadlineNotReached(); state = totalRaised >= target ? State.Settled : State.Refunded; emit Closed(state, totalRaised); } Withdrawal sweeps the full balance (including direct donations) and asserts the contract is empty:
function withdraw() external nonReentrant { if (msg.sender != creator) revert NotCreator(); if (state != State.Settled) revert NotSettled(); if (withdrawn) revert AlreadyWithdrawn(); withdrawn = true; uint256 amount = usdc.balanceOf(address(this)); usdc.safeTransfer(creator, amount); _checkInvariant(); if (usdc.balanceOf(address(this)) != 0) revert InvariantViolated(); emit Withdrawn(address(this), creator, amount); } Refunds are pull-based, one transaction per backer:
function refund() external nonReentrant { if (state != State.Refunded) revert NotRefunded(); uint256 amount = commitments[msg.sender]; if (amount == 0) revert NothingToRefund(); commitments[msg.sender] = 0; refunded[msg.sender] = true; totalRefunded += amount; usdc.safeTransfer(msg.sender, amount); _checkInvariant(); emit Refunded(address(this), msg.sender, amount); } The invariant guard:
function _checkInvariant() internal view { if (usdc.balanceOf(address(this)) < accountedBalance()) revert InvariantViolated(); } function accountedBalance() public view returns (uint256) { return withdrawn ? 0 : totalRaised - totalRefunded; } Decimals
Contracts are decimals-agnostic — they operate on raw token units. The 6-decimal USDC view is enforced only in the frontend via @/onchain-money. On Arc, USDC is both the native gas (18-decimal view, used only for gas/msg.value) and the ERC-20 (6-decimal view, used for all balances/transfers). Never sum the two views.
Frontend
- Router:
/,/new,/round/:address(React Router 6). - Data: react-query hooks over
rpc.ts. - Wallet: wagmi v2 + ConnectKit (injected connector).
- Formatting:
@/onchain-moneyfor 6-decimal USDC display. - Toasts: Sonner.
Example of reading a round:
const { data: round } = useQuery({ queryKey: ['round', address], queryFn: () => roundSummary(address), }) Testing
- Contracts: 59 Foundry tests (
forge test) covering commit caps, fee-on-transfer rejection, close timing, withdraw/refund flows, invariant checks, and reentrancy. - Frontend: 31 Bun tests (
bun test tests) for the RPC adapter and formatting. - RPC rule tests:
bun run test:rpcasserts one-request-per-export.
Deployment
Factory is deployed at 0xd6c786d3eabddd791e9ad9554e56e808075a3a95 on Arc Testnet (constructor arg = USDC 0x3600…0000). Rounds are created by users through /new. Seeded rounds (real txs) exercise both outcomes.
Circle integration
- USDC on Arc: native gas + ERC-20 at
0x3600…0000. - Developer-controlled wallets: used by
scripts/seed-rounds.tsto create Wallet A/B for seeded rounds. - Smart Contract Platform: optional path for deploying/importing contracts via Circle APIs.
- Circle skills: installed in
.agents/skills/— runcircle skill listto browse.
Lessons learned
- One request per read keeps the privacy boundary clean and makes batching explicit.
- Simulate before signing turns opaque reverts into actionable named errors.
- Decimals-agnostic contracts avoid a whole class of off-by-10^12 bugs; push decimal handling to the edge.
- Permissionless finalization (
close()callable by anyone) removes a liveness dependency on the founder. - Pull-based refunds avoid a single transaction that could run out of gas for large backer sets.
Code & more: https://www.dailybuild.xyz/project/307-round
