Overview
What it is
Settleo Escrow holds the crypto leg of a P2P trade. It is made of two parts: an
immutable Solidity contract (SettleoEscrow, Solidity 0.8.28, OpenZeppelin
v5.1.0, deployed with no proxy) that locks funds, and an off-chain
settleo-escrow-orchestrator that drives the lifecycle and mirrors every
settlement into the double-entry ledger. A separate settleo-dispute service runs
arbitration under the same 2-of-3 rule. The contract's own docstring states the
thesis: "2-of-3, never 1 … the platform alone can never decide an outcome."
The correct mechanism (not signatures)
The "2-of-3" is on-chain approval voting by msg.sender — not an ECDSA
threshold, not EIP-712 typed data, not a multisig. There is no domain
separator and no on-chain nonce scheme. Each of the three designated addresses
(buyer, seller, arbiter) calls approve itself; the contract records that
msg.sender's vote and settles the instant two distinct parties back the same
outcome, in the same call:
function approve(bytes32 tradeId, Outcome outcome) external nonReentrant {
if (outcome == Outcome.None) revert WrongState();
Escrow storage e = _escrows[tradeId];
if (e.state != State.Funded && e.state != State.Disputed) revert WrongState();
if (msg.sender != e.buyer && msg.sender != e.seller && msg.sender != e.arbiter) revert NotSigner();
if (_votes[tradeId][msg.sender] == outcome) revert AlreadyVoted();
_votes[tradeId][msg.sender] = outcome;
emit EscrowApproved(tradeId, msg.sender, outcome);
if (_tally(tradeId, e, outcome) >= 2) { _settle(tradeId, e, outcome); }
}
The tally is O(1) over the three designated slots — one signer voting repeatedly
can never reach the threshold, and AlreadyVoted blocks a re-vote for the same
outcome. (Off-chain EIP-1559 signing exists only in the orchestrator to submit
these approve calls; delegated EIP-712 for gasless UX is explicitly deferred
until after audit because it would add signature-replay surface.)
The on-chain state machine
| State | Reached by | Can leave to |
|---|---|---|
None | (unknown trade id) | Opened via open |
Opened | open (OPERATOR, 3 distinct parties, amount ≤ cap) | Funded via fund |
Funded | seller fund (locks exact value, starts pay-by clock) | Released/Refunded (2-of-3), Disputed, or auto-Refunded |
Disputed | party dispute (freezes the clock) | Released/Refunded (2-of-3 only) |
Released | 2-of-3 Release → buyer | terminal |
Refunded | 2-of-3 Refund, or permissionless refundExpired → seller | terminal |
Why the platform can never decide alone
The operator holds OPERATOR_ROLE — it can open an escrow and trigger the
permissionless expired refund, but it holds no vote. Moving funds always needs
two of the three party keys; the arbiter is only the swing vote. There are exactly
two paths out:
- 2-of-3 approval — cooperative buyer + seller, or on a dispute arbiter + the favoured party.
- A permissionless, time-locked auto-refund to the seller after
refundDeadline— so an absent or malicious operator can never strand funds; anyone can call it once the clock runs out.
Raising a dispute freezes the auto-refund clock (a Disputed escrow is rejected
by refundExpired), so a disputed escrow can only exit by a 2-of-3 ruling.
pause gates only new intake (open/fund); approve, dispute and
refundExpired keep working so locked funds can always leave. Assets are
deny-by-default — escrowable only while a governor-set cap is > 0 and never
above it. Settlement is Checks-Effects-Interactions plus ReentrancyGuard: the
escrow is terminalized and _locked decremented before any payout, native value
is sent by a low-level call that reverts the whole settlement on failure, and
receive() rejects stray ETH so nothing can enter except through fund.
The off-chain half — settling THROUGH the ledger
The orchestrator's OnchainEscrowExecutor client-side signs EIP-1559 transactions
with @noble/curves (its own EVM stack — no ethers/viem) and is idempotent under
retry via on-chain reads: before it acts it reads state and skips an
open/fund already applied, and reads each signer's recorded vote to skip one
already cast (so an at-least-once redelivery never double-submits and reverts
AlreadyVoted). It never writes balances itself — it settles through the
ledger:
- fund opens a two-phase ledger hold (seller → escrow), mirroring the on-chain lock;
- release commits the hold and pays escrow → buyer (net) + escrow → fee in
one all-or-nothing linked batch tagged with the trade — which is what makes
the ledger emit
TradeSettled; - refund voids the hold.
Deterministic ledger ids (escrow:<tradeId>:hold|release|fee|refund) make every
ledger call idempotent, and the on-chain key itself is one-way —
onchainTradeId = keccak256(utf8(uuid)) — so the reorg-safe indexer emits the
bytes32 and the orchestrator resolves the order via a stored index. The order's
own FSM is legal-only:
requested → funding → funded → (releasing|refunding|disputed) → released|refunded|failed,
and an illegal transition throws.
Arbitration under the same rule
settleo-dispute opens a case off DisputeRaised, assigns a neutral arbiter,
bundles chat by reference only ({transcriptRef, messageCount}, never bodies),
and resolves by a 2-of-3 vote whose tally keeps only each party's latest vote —
so one signer voting repeatedly can never reach the threshold. Resolution stages
DisputeResolved in the same write as the case, and the orchestrator drives the
on-chain release/refund off that event (single-sourced, no synchronous call).
The service is IDOR-hardened: a caller's party is derived from the
gateway-asserted actorId, never claimed, and the arbiter console path forces
party = 'arbiter' and requires actorId === arbiterId so one operator can't cast
two votes to self-resolve:
function partyOf(c: DisputeCase, actorId: string): Party {
if (actorId === c.buyerId) return 'buyer';
if (actorId === c.sellerId) return 'seller';
if (c.arbiterId !== undefined && actorId === c.arbiterId) return 'arbiter';
throw new ForbiddenError({ details: { reason: 'not_a_party_to_dispute' } });
}
Testing & security posture
The contracts carry 46 unit + 5 fuzz + 3 invariant tests. The invariants prove
solvency (invariant_solvent: contract balance == lockedOf), value
conservation, and that terminal escrows hold nothing; the fuzz suite proves
value-conservation on native release and ERC20 refund, auto-refund timing, and cap
enforcement; attacker mocks (ReentrantActor, RejectNative) exercise the
reentrancy and failed-payout guards. A STRIDE model catalogues sixteen threats
(E1–E16), each mapped to a mitigation and a named test — from "operator drains an
escrow" (E1) to "governance pause traps funds" (E15). Signature malleability is
not applicable: there are no off-chain signatures to recover, so replay is
covered structurally (terminal states reject re-entry) rather than by nonces.
Honest caveats carried into the case study: the contract is currently unaudited
— mainnet is gated on a clean external report — and the MPC signer for real
party/operator keys is an external component not yet built.