A crowdfunded raise for a real-world asset has exactly three endings: it hits its goal and succeeds, it misses its deadline and fails, or it's still running. That's the entire state space. Everything else about the campaign — how many fractional shares a given backer is owed, whether the project wallet can move funds yet, whether a backer is entitled to a refund — is a consequence of those three facts, not a fact of its own. Getting that idea into the contract, instead of into a pile of booleans someone has to remember to flip in the right order, was most of the actual engineering in an escrow we built for an RWA tokenization prototype: testnet-only, KYC-gated on the receiving end, built around a goal, a deadline, and a fixed pool of whole-unit shares.
This is a walk-through of that escrow — why its state is derived instead of stored, why claiming shares, withdrawing funds, and refunding contributions are three separate functions instead of one clever one, how integer division decides who gets a share and who gets dust, and why every function that moves value looks almost identical underneath. None of it is exotic. All of it is the kind of discipline that's boring to write and expensive to skip. Nothing here is financial advice, and nothing here has touched a mainnet.
§ 01A state machine with no stored state
The escrow has an enum — Funding, Succeeded, Failed — but no storage slot holding it. The phase is computed on every read from two numbers that already exist for other reasons: how much has been raised, and what time it is.
enum State { Funding, Succeeded, Failed } function state() public view returns (State) { if (raised >= goal) return State.Succeeded; if (block.timestamp >= deadline) return State.Failed; return State.Funding; } // goal and deadline are immutable; raised is the only mutable input
goal and deadline are set once at construction and never change. raised is a single counter that only ever grows, incremented in the one function that accepts money. There is no separate flag for "we're done, we succeeded" that a later function has to remember to set — which means there's no way for the contract's opinion of its own phase to drift from the two facts that actually define it. A stored-and-toggled state machine has to get every transition right, everywhere, forever; a derived one only has to get the read right, and it gets it right by definition, on every call, including calls that happen mid-transaction after other state has already changed.
§ 02Three exits, not one
Once a raise resolves, two very different things need to happen depending on which way it resolved. On success: backers claim the shares they're owed, and the project wallet withdraws the funds. On failure: backers pull their contribution back. That's three functions — claimShares, withdraw, refund — not one function branching on outcome, and the split isn't stylistic.
- The callers are different. Many backers call
claimSharesandrefund; exactly one project wallet callswithdraw. - The preconditions are mutually exclusive by construction.
withdrawrequiresState.Succeeded;refundrequiresState.Failed. A raise can never be in both, so there's never a code path where the "wrong" exit is even reachable — you don't need a runtime check to keep them apart, the derived state already does it. - The per-address bookkeeping differs. Claiming sets a
sharesClaimed[msg.sender]flag so shares can only be minted once per backer. Withdrawing sets a single contract-widefundsWithdrawnflag, since only one party ever withdraws. Refunding zeroescontributions[msg.sender]for that backer specifically.
Collapsing these into one "settle" function would mean one function body carrying two unrelated money-moving branches, two different sets of guards, and two different accounting updates — exactly the kind of surface where a mistake in one branch's condition leaks into the other. Three narrow functions, each doing one thing to one kind of caller, are each individually easy to reason about and easy to test in isolation. All three also follow the pull pattern rather than push: nobody loops over a list of backers sending them value. Every backer, and the one project wallet, initiates their own withdrawal. That keeps a stuck or reverting recipient from ever blocking anyone else's money from moving, and it keeps gas cost proportional to one transaction, not to the size of the backer list.
§ 03Whole shares and the arithmetic of dust
Shares in this design aren't fractional tokens with eighteen decimals — they're a fixed-supply, zero-decimal ERC-20, minted pro-rata against however much of the goal a backer covered:
sharesOf(backer) = contributions[backer] * totalShares / goal
Solidity integer division truncates, so this always rounds down. A backer whose contribution, scaled by totalShares, doesn't clear a whole share's worth of the goal gets floored to their nearest whole share — and a contribution small enough relative to goal and totalShares can round all the way to zero, entitling that backer to nothing. That's not a bug; it's the necessary consequence of choosing whole-unit shares over an eighteen-decimal ERC-20, and it's why the mint gate reverts with a dedicated "nothing to claim" error rather than silently minting a zero-amount transfer.
The same truncation means the shares actually minted across every backer can add up to slightly less than totalShares — a handful of shares' worth of dust that never gets distributed to anyone. We left that dust alone rather than writing a remainder-allocation pass to hand out the last few units, for three reasons: it costs more gas to compute and distribute than the dust is worth at goal-sized contributions, "who gets the remainder" has no single fair answer once you start asking, and it changes no economic outcome that matters at the scale a real raise operates at. A related rounding surface sits in contribute() itself: because it doesn't cap a contribution against the remaining gap to goal, the single contribution that pushes the raise over the line can overshoot it slightly. The raise still succeeds, shares are still computed against the fixed goal denominator, and the small excess just sits in the contract until withdraw sweeps the full balance out with everything else. Capping that last contribution to exactly close the gap is a straightforward addition; we left it out of the prototype deliberately, and flagged it as the one thing worth fixing before any of this saw a mainnet.
§ 04Every money-moving function gets the same treatment
contribute, claimShares, withdraw, and refund are the only four functions that touch value or mint shares, and all four are built to the same template: guard clauses first, storage effects second, the one external call last, wrapped in OpenZeppelin's nonReentrant modifier as a second line of defense on top of that ordering.
// checks, then effects, then the external call — last, always function refund() external nonReentrant { if (state() != State.Failed) revert NotFailed(); uint256 c = contributions[msg.sender]; if (c == 0) revert NothingToRefund(); contributions[msg.sender] = 0; // effect (bool ok,) = msg.sender.call{value: c}(""); // interaction if (!ok) revert TransferFailed(); emit Refunded(msg.sender, c); }
The reentrancy guard isn't a substitute for checks-effects-interactions. It's what catches you when a future edit breaks the ordering without anyone noticing.
Zeroing contributions[msg.sender] before the low-level call means that even if the recipient's fallback tries to call back into refund mid-transfer, there's nothing left for it to claim — the balance it would read is already zero. That's the checks-effects-interactions discipline doing the actual work. nonReentrant is there for what CEI alone doesn't cover: a call re-entering a different function on the same contract mid-transfer, or a future edit to one of these four functions that quietly reorders an effect after an interaction without the reviewer catching it. Belt and suspenders, on every function that moves value, with no exceptions for the ones that felt too simple to bother.
Same discipline as the receiving side of this raise: a KYC-gated share token that enforces its compliance check inside
_update, so mint, transfer, and burn all go through one hook instead of three separately-guarded entry points. We wrote that one up separately — see gating an ERC-20 in its update hook.
§ 05What testnet doesn't test
This escrow ran against a public EVM testnet, moved test ETH, and never went through a professional audit — none of what's here should be read as a claim that it's production-hardened. A few things we'd want re-verified before it ever touched real value: the overfunding rounding noted above, the assumption that withdraw is the only sink so the contract's balance always covers every outstanding refund and share claim, and the KYC gate's own trust assumptions, since the escrow itself doesn't perform compliance checks — it just calls a mint function that will revert if the receiving token's gate says no.
None of that changes the shape of the argument, though. Deriving state from facts you already have instead of storing a flag that can drift from them; splitting settlement into narrow functions gated by preconditions that are mutually exclusive by construction instead of by convention; treating integer rounding as a deliberate, bounded tradeoff instead of an afterthought; and applying the exact same checks-effects-interactions-plus-guard template to every function that moves value — that's a small enough set of habits to hold in your head on every contract, goal-based raise or not. The kind of discipline that's boring to write is usually the kind that's expensive to have skipped.
— End of essay. If you're designing a contract where the wrong function order costs someone real money, we've done this before. Start a project →
