When I started building an on-chain escrow protocol, the obvious design was one contract. Funds go in, parties interact, disputes get resolved, funds come out. One deployment, one address, one place to reason about. It is the design almost every tutorial reaches for, and for a lot of use cases it is fine.
I did not ship that. The escrow logic and the dispute-resolution logic live in two separate contracts that talk to each other across a deliberately narrow interface. This post is about why, because the reasoning generalizes well beyond escrow, and because "just put it all in one contract" is a decision most people make by default rather than on purpose.
The thing an escrow contract is actually for
An escrow contract has one job that matters above all others: hold funds and release them under exactly the agreed conditions. Everything else is secondary to that. If the release logic is correct, the product works. If the release logic is wrong, someone loses money that was supposed to be safe.
So the code that holds custody of funds should be the code you trust most, which means it should be the code that is smallest, simplest, and easiest to audit. Every additional line in that contract is another line that can contain the bug that drains it. The custody logic wants to be boring.
Dispute resolution is the opposite of boring. It involves roles, assignment, voting or review, timeouts, counter-parties paying fees, evidence handling, edge cases on top of edge cases. It is inherently the most complex and most frequently changed part of the system, because arbitration is where all the messy human disagreement lands.
Put those two facts next to each other and the tension is obvious. The part that must be simplest to keep funds safe is coupled to the part that is inherently the most complex and volatile. If they live in the same contract, the complexity of the second contaminates the safety of the first.
Blast radius
The security concept that made the decision for me is blast radius: when something goes wrong in one component, how far can the damage reach?
In a single-contract design, the blast radius of a dispute-logic bug is the entire contract, including the custody of every escrow it holds. A flaw in how you handle, say, evidence submission or fee accounting during a dispute is not contained to disputes. It sits in the same storage, behind the same functions, with the same access to the same funds. The most complex, most-edited code in the system has a direct path to the money.
Splitting the contracts draws a hard boundary through the middle of that. The escrow contract holds the funds and exposes a small, fixed surface for settling a dispute. The dispute contract does all the messy arbitration work and, at the end, tells the escrow contract the outcome. A bug in the arbitration contract can produce a wrong decision, which is bad, but it cannot reach in and move funds outside the escrow contract's own release rules. Two contracts, two blast radii, and the smaller one is wrapped around the money.
The interface is the whole point
The value of the split lives entirely in how narrow the boundary between the two contracts is. A wide, chatty interface would throw the benefit away, because you would just be spreading one tangled system across two addresses.
So the escrow contract exposes the minimum. The dispute contract can create a case and, when a case concludes, report a result. That is close to the entire surface. On the escrow side, the calls that the dispute system is allowed to make are gated behind a single hard check: the caller must be the known dispute contract, and the dispute system must be active. Anything else is rejected.
modifier onlyJuryContract() {
require(msg.sender == juryContract, "Only NovaJury");
require(isJurySystemActive, "Jury system not active");
_;
}
That modifier is the entire trust relationship between the two contracts, written down in two lines. The escrow contract does not trust the dispute contract to be correct about arbitration. It only trusts it to be the one specific address allowed to report an outcome, and only while the dispute system is switched on. Everything the dispute contract does internally, all its complexity, stays on its own side of that line.
Narrow interfaces are not a style preference here. The narrower the boundary, the smaller the surface an attacker or a bug can use to cross from the volatile contract into the one holding funds.
The unexpected benefit: I can evolve one without risking the other
The split was a security decision, but it paid off somewhere I did not plan for: change.
Arbitration is the part of the system I most expect to revise. My dispute mechanism might start simple and later move to something more decentralized, with independent participants instead of a designated reviewer. In a single-contract world, every one of those changes is a change to the same contract that holds live funds, which is exactly the contract you least want to keep redeploying and re-auditing.
With the split, the escrow contract can stay stable while the dispute side evolves. The escrow side even carries a switch: dispute resolution can be routed to a designated resolver, or activated to hand off to the separate arbitration contract, without touching the custody logic. The most volatile part of the product can iterate behind a fixed interface, while the part holding the money stays put. Stability where it protects funds, flexibility where the requirements actually move.
When you do not need this
I want to be honest about the tradeoffs, because splitting is not free. Two contracts mean more deployment complexity, a cross-contract call path to reason about, and an interface you have to get right. For a simple escrow with no dispute mechanism at all, or a throwaway prototype, one contract is the correct call and the split is over-engineering.
The split earns its cost specifically when you have a component that is both complex and volatile sitting next to a component that must be simple and stable. Escrow plus arbitration is a textbook case: custody wants to be boring and fixed, arbitration wants to be rich and changing. Any time you find that pattern, a component that must be trusted coupled to a component that must be flexible, the boundary is worth drawing.
The general principle
Strip away the escrow specifics and the rule is this: keep the code that holds value simpler than the code that makes decisions about it, and put a hard, narrow boundary between them.
You see the same idea everywhere once you look for it. Privilege separation in operating systems. Keeping keys in an HSM instead of in application memory. Microservice boundaries drawn around the data that matters most. It is all the same move: shrink the trusted core, wrap it in a minimal interface, and keep the complicated, fast-changing stuff on the outside where its blast radius is contained.
For escrow, that meant two contracts instead of one. The money lives in the boring one.
I build NovaCont, a non-custodial escrow protocol on Base and TON. Contract addresses and security notes are public. Docs.

Top comments (0)