AZ
Back to constellation
Web3Draft

Settleo Escrow

The non-custodial trade escrow of Settleo: an immutable Solidity contract that releases funds only on 2-of-3 on-chain approval voting — or a permissionless time-locked auto-refund.

Solidity 0.8.28FoundryOpenZeppelin v5TypeScript@noble/curvesgRPCMongoDBTigerBeetle (via ledger)

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.sendernot 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

StateReached byCan leave to
None(unknown trade id)Opened via open
Openedopen (OPERATOR, 3 distinct parties, amount ≤ cap)Funded via fund
Fundedseller fund (locks exact value, starts pay-by clock)Released/Refunded (2-of-3), Disputed, or auto-Refunded
Disputedparty dispute (freezes the clock)Released/Refunded (2-of-3 only)
Released2-of-3 Release → buyerterminal
Refunded2-of-3 Refund, or permissionless refundExpired → sellerterminal

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.

Related write-ups

Write-up in progress

A companion post for this project is being drafted. Check back soon.

Settleo Escrow · Abdullah Zakariyya