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

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-money for 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:rpc asserts 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.ts to 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/ — run circle skill list to browse.

Lessons learned

  1. One request per read keeps the privacy boundary clean and makes batching explicit.
  2. Simulate before signing turns opaque reverts into actionable named errors.
  3. Decimals-agnostic contracts avoid a whole class of off-by-10^12 bugs; push decimal handling to the edge.
  4. Permissionless finalization (close() callable by anyone) removes a liveness dependency on the founder.
  5. 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