A decentralized, single-deal escrow with a timelock and arbitration. Deploy one per exchange — funds settle by the code, with no admin keys and nobody able to touch them off-script.
HANDSHAKE
Solidity License Build Tests
A decentralized, single-deal escrow with a timelock and arbitration. To open an exchange you deploy the contract yourself with the two counterparties and an arbiter — then the trade settles by the rules in the code, with nobody (not even the deployer) able to touch the funds off-script.
One contract is one deal. No shared pool, no admin keys, no upstream owner. After the FTX implosion of late 2022, "deploy your own escrow and let the code hold the money" felt like the only honest way to swap value with a stranger.
The flow
┌──────────────────┐ buyer.deposit() ─────→│ AwaitingDelivery │ └───────┬──────────┘ seller delivers off-chain │ ┌─────────────────────────┼─────────────────────────┐ ↓ ↓ ↓ buyer.confirmReceipt() buyer/seller.raiseDispute() seller.claimTimeout() │ │ │ (after releaseTimeout) ↓ ↓ ↓ → seller (Complete) → Disputed → seller (Complete) │ arbiter.resolveDispute(toSeller) ↓ split seller / buyer, minus arbiter fee (Resolved)Plus two escape hatches: seller.refundBuyer() (voluntary return while funded) and cancel() (abandon before any funds move).
The two safety rails
- Timelock —
claimTimeout()lets the seller withdraw oncereleaseTimeout
has elapsed since funding and the buyer has neither confirmed nor disputed. A buyer who simply vanishes can't lock the seller's payment forever.
- Arbitration —
raiseDispute()(by either party) freezes the timeout and
hands the call to the arbiter, who assigns the post-fee pot between the two sides with resolveDispute(toSeller). The arbiter earns arbiterFeeBps (capped at 10%) only on a resolved dispute.
States
| State | Meaning | Exits |
|---|---|---|
AwaitingPayment | deployed, unfunded | deposit → AwaitingDelivery · cancel → Cancelled |
AwaitingDelivery | funded, seller delivering | confirmReceipt/claimTimeout → Complete · refundBuyer → Refunded · raiseDispute → Disputed |
Disputed | arbiter ruling | resolveDispute → Resolved |
Complete / Refunded / Resolved / Cancelled | terminal | — |
Deploy & use
// 1. Open a deal: 1 ETH escrow, 3-day timelock, 2% arbiter fee.// token = address(0) for ETH; pass an ERC20 address for tokens.Escrow escrow = new Escrow( buyer, // pays seller, // delivers arbiter, // neutral third party address(0), // ETH 1 ether, // amount 3 days, // releaseTimeout 200 // arbiterFeeBps = 2.00%); // 2. Buyer fundsescrow.deposit{value: 1 ether}(); // ERC20: approve first, then deposit() // 3a. Happy path — buyer got the goodsescrow.confirmReceipt(); // → seller // 3b. Buyer ghosted — seller waits out the timelockescrow.claimTimeout(); // → seller, after 3 days // 3c. Something went wrong — go to arbitrationescrow.raiseDispute(); // by buyer or sellerescrow.resolveDispute(0.6 ether); // arbiter: 0.6 → seller, rest → buyer (minus fee)Prefer one call? EscrowFactory.createEscrow(...) deploys the same Escrow and indexes it by buyer/seller/arbiter so a UI can list "my deals" with no backend.
Build & test
forge buildforge testSecurity
- Not audited. Independent work, published for review. Don't escrow real
value before an audit.
- Roles are immutable and must be three distinct addresses; the deployer
has no special power. Choose the arbiter as carefully as the counterparty — on a dispute they decide the split (a multisig / known mediator is wise).
- Every payout is
nonReentrantand follows checks-effects-interactions; state
flips before funds move.
- Fee-on-transfer / rebasing tokens are unsupported — the accounting assumes
the contract receives exactly amount.
- ETH payouts use
call; an arbiter/party that is a contract reverting on
receive can only block its own leg. Use EOAs or receivers that accept ETH.
License
MIT © 2022 c0llback