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

Top comments (0)