Atomic Settlement Across Domains Is Constructed, Never Inherited
· 18 min read
- settlement
- solidity
- architecture
- cross-chain
Atomic Settlement Across Domains Is Constructed, Never Inherited
The matching engine has already decided. Price agreed, size agreed, two legs left to move. If both legs are balance updates inside one contract, settlement is a single call and nobody asks what happens when half of it lands. The moment one leg settles on state the transaction can't reach, that question is the design.
EIP-140 defines the revert instruction as stopping execution and rolling back all state changes done so far EIP-140. That rollback belongs to the execution, not to the trade. A fill whose second leg lives on a domain this transaction cannot touch is bigger than the execution that would have undone it, so the guarantee does not reach the thing anyone cares about.
What's left is a boundary. On one side the machine supplies it to the whole transaction and the settlement path writes no code of its own for it. On the other a venue builds a substitute out of two locks, two deadlines, the gap between them, and a counterparty who has to act inside it. The substitute carries an in flight state, where one leg is commited and the other is not, and a second exit out of every leg.
Three reduced settlement contracts were written for this article, one inline and two locked, with three invariant campaigns, a gas run and a constructed worst case over them. The first two are printed whole, the third contributes its two lock functions. They put the safety of the constructed version in a relation between two absolute deadlines, and show what happens when the same window is written as a per leg duration.
The fill is bigger than the transaction
Why a venue wants both legs to move together isn't argued here: a reader who owns a settlement path has already priced a half settled trade.
The pair below is a reduced specimen written for this article, and it models the settlement state machine rather than reproducing one. No token moves, no access control, no fees.
Start with the version that inherits.
Role: reduced executable model, the complete inline settlement.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
/// Both legs of one matched fill inside one transaction, against balances this
/// contract owns. Atomicity is inherited from the transaction: if any line here
/// reverts, every line before it is undone, so the fill has one exit and no state
/// between unfilled and settled.
contract InlineFill {
mapping(address => uint256) public quote;
mapping(address => uint256) public base;
function settle(address maker, address taker, uint256 give, uint256 take) external {
base[maker] -= give;
base[taker] += give;
quote[taker] -= take;
quote[maker] += take;
}
}Nothing in that contract defends the fill. Its safety is the transaction's own: a debit that'd go negative reverts, and every line before it's undone. Zero require statements isn't zero guards. The guard is one solc 0.8.26 inserts on each debit, and it is the inherited atomicity this article replaces by hand.
Set the locked version beside it. The inline settlement is one external function with no require statement, and the same fill across a boundary is six external functions with ten. Each leg of the locked fill carries four states and two exits where the inline fill carries two states and one. Refund is that second exit, and counterparties take it.
Two locks, two deadlines, and the relation between them
The replacement for inherited atomicity is old. BIP-199 writes the construction down as a script that lets one party spend funds by disclosing the preimage of a hash, and lets a second party spend them after a timeout is reached, in a refund situation BIP-199.
The version under test holds the taker's asset on leg A for the maker and the maker's asset on leg B for the taker, where leg B settle somewhere this contract cannot see. The taker holds the secret and claims leg B first, and that reveal lets the maker claim leg A. The safety question is how much time the maker has when the secret becomes public. Read the two lock functions side by side and the whole disagreement is four lines.
Role: reduced executable model, the complete locked settlement under test.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
/// The same matched fill when leg B settles somewhere this contract cannot see.
/// Leg A holds the taker's asset for the maker. Leg B holds the maker's asset for
/// the taker, on another domain, and the taker holds the secret, so the taker
/// claims leg B first and that reveal is what lets the maker claim leg A. Only the
/// settlement state machine is modelled here and no value moves.
contract MatchedFill {
/// The claim window the maker must still have after the latest legal reveal.
uint64 public constant DELTA = 2 hours;
enum Leg { Open, Locked, Claimed, Refunded }
Leg public legA;
Leg public legB;
uint64 public deadlineA;
uint64 public deadlineB;
uint64 public revealedAt;
function lockA(uint64 deadline) external {
require(legA == Leg.Open, "A already set");
require(deadline > block.timestamp, "past");
deadlineA = deadline;
legA = Leg.Locked;
}
/// Leg B is bounded by the deadline leg A already fixed, not by its own duration.
function lockB(uint64 deadline) external {
require(legB == Leg.Open && legA == Leg.Locked, "order");
require(deadline > block.timestamp, "past");
require(deadline + DELTA <= deadlineA, "window");
deadlineB = deadline;
legB = Leg.Locked;
}
function claimB() external {
require(legB == Leg.Locked && block.timestamp <= deadlineB, "shut");
legB = Leg.Claimed;
revealedAt = uint64(block.timestamp);
}
function claimA() external {
require(legA == Leg.Locked && revealedAt != 0, "no secret");
require(block.timestamp <= deadlineA, "shut");
legA = Leg.Claimed;
}
function refundA() external {
require(legA == Leg.Locked && block.timestamp > deadlineA, "early");
legA = Leg.Refunded;
}
function refundB() external {
require(legB == Leg.Locked && block.timestamp > deadlineB, "early");
legB = Leg.Refunded;
}
}DELTA is the claim window the maker must still have after the latest legal reveal, and lockB is the only place the contract can enforce it. The guard says the offered deadline plus DELTA must be at most deadlineA, an absolute timestamp leg A fixed and nothing can change. Leg B's deadline is anchored to leg A's.
Laid beside what it replaced:
the same fill under one clock and under two
one matched fill, one clock one matched fill, two clocks
t ----------------------------> leg A t ------------------------>
lock claim | refund
settle() <-----|
| leg B t ------------------------>
both legs, one transaction lock claim | refund
a revert undoes both <-----|
every row below is a count off the two contracts printed on this page
states per leg 2 4
exits per leg 1 2
deadlines 0 2, and DELTA between them
require statements 0 10
external functions 1 6
who has to act nobody the maker, inside DELTANow write the same window as a caller asks for it, a duration per leg rather than a point in time. The two contracts differ in one state field, in how leg A records its window, and in the guards leg B checks before it opens.
Role: reduced executable model, the two lock functions of both contracts, extracted verbatim.
// The deadline form, extracted verbatim from src/MatchedFill.sol.
// Its state is: legA, legB, deadlineA, deadlineB, revealedAt.
function lockA(uint64 deadline) external {
require(legA == Leg.Open, "A already set");
require(deadline > block.timestamp, "past");
deadlineA = deadline;
legA = Leg.Locked;
}
/// Leg B is bounded by the deadline leg A already fixed, not by its own duration.
function lockB(uint64 deadline) external {
require(legB == Leg.Open && legA == Leg.Locked, "order");
require(deadline > block.timestamp, "past");
require(deadline + DELTA <= deadlineA, "window");
deadlineB = deadline;
legB = Leg.Locked;
}
// The duration form, extracted verbatim from src/MatchedFillByDuration.sol.
// Its state is the same five plus one more the other does not have: durationA.
function lockA(uint64 duration) external {
require(legA == Leg.Open, "A already set");
require(duration > 0, "zero");
durationA = duration;
deadlineA = uint64(block.timestamp) + duration;
legA = Leg.Locked;
}
function lockB(uint64 duration) external {
require(legB == Leg.Open && legA == Leg.Locked, "order");
require(duration > 0, "zero");
require(block.timestamp < deadlineA, "A expired");
require(duration + DELTA <= durationA, "window");
deadlineB = uint64(block.timestamp) + duration;
legB = Leg.Locked;
}Every guard on the duration side reads correctly on its own. It checks ordering, a non zero duration, leg A not yet expired, and a duration that with DELTA added still fits inside leg A's own, the same inequality in the caller's units. But the relation the property needs is between two absolute points, and two durations agree with it only when both legs lock in the same instant.
An invariant campaign was pointed at both contracts, same property, same seed, same budget, every offered call routed through try and catch so a refusal is counted, not swallowed.
The first harness written for this was wrong in a way that would've hidden the finding. It drew each deadline from block.timestamp with a floor of one, which mirrored the contract's own past and zero guards, leaving them untested. The version that ran anchors its window at the campaign's start and offers a past deadline as often as a future one. Each campaign also deletes cache/invariant before it starts, so no result on this page is a replay of a stored failure.
Over the contract whose window is a duration the campaign went red in 1,900 calls and shrank its counterexample from fourteen steps to four.
Role: the tool's own counterexample transcript, with the arithmetic worked out below a marked line.
--- printed by the tool, copied from proof/raw-output.txt, sender and handler addresses cut, tabs reindented ---
[FAIL: assertion failed]
[Sequence] (original: 14, shrunk: 4)
calldata=lockA(uint32) args=[9333]
calldata=warp(uint16) args=[46273]
calldata=lockB(uint32) args=[7]
calldata=claimB() args=[]
invariant_MakerWindowHoldsAfterReveal() (runs: 19, calls: 1900, reverts: 0)
--- worked out below this line, not printed by the tool ---
the handler puts each raw argument through bound() before it reaches the
contract, so 9333 and 7 arrive unchanged and 46273 arrives as 3073.
the campaign starts at 1700000000.
lockA(9333) durationA = 9333 deadlineA = 1700009333
warp(46273) now = 1700003073
lockB(7) order legB open, legA locked ok
zero 7 > 0 ok
A expired now < deadlineA ok
window 7 + DELTA = 7207 <= durationA = 9333 ok
deadlineB = 1700003080
claimB() shut now = 1700003073 <= deadlineB ok
revealedAt = 1700003073
all four of leg B's guards pass, and
maker window = deadlineA - revealedAt = 6260 s
DELTA = 7200 s
shortfall = 940 sThose four calls leave the maker 6,260 seconds of claim time after the reveal, against the 7,200 seconds the contract's own constant reserves for him. No guard was defeated to get there. Duration arithmetic compares two lengths and never asks where the second one starts.
That shortfall is the shortest counterexample, not the worst, and the worst was measured directly. Locking leg B one hour before leg A expires, with a one hour duration, passes every guard the contract writes down and leaves the maker a window of zero.
Offer the same lock to the sibling. Bound leg B by the deadline leg A already fixed and the lock is refused, and the latest one it will accept leaves the maker exactly the 7,200 seconds it promises. One is a floor enforced at lock time. The other is a hope about how two callers sequence themselves.
Over the deadline form the campaign ran to completion. The same property over that contract held across 25,600 calls with zero reverts, and of the 3,697 leg B locks offered, 365 were refused by the window relation itself rather than the ordering or expiry guards around it. A raw refusal total there's dominated by the ordering guard and carries nothing about the window, so I report the one that means something. Inside those 25,600 calls the campaign constructed 84 reveals, and 84 is what the green covers.
Raising the budget to 256,000 calls on the same seed produced 369 reveals and the same green. Here's what a green campaign hides. lockB requires the offered deadline plus DELTA to be at most deadlineA and assigns deadlineB to it; claimB requires the current time to be at most deadlineB and assigns revealedAt to it. deadlineA can't change once leg A is locked and leg B can't reopen, so revealedAt is at most deadlineA minus DELTA in every reachable state, and the asserted property is an algebraic consequence of the guard.
Which means the campaign over the corrected contract could not have failed. No varied input can write the failing state, so 256,000 calls of apparent assurance are a restatement of the guard in slower form, and a campaign that cannot fail may not be recorded as one that tried and survived. Those runs establish something narrower: the state machine admits no second route into the same state. The value of the exercise was on the other contract, where it found the bug, and the sibling is the form the caller's own units lead you to write.
Role: excerpt of the run script, the five invocations and the cache deletions between them.
rm -rf cache/invariant
...
"$FORGE" test --match-contract FillByDuration --match-test invariant_MakerWindowHoldsAfterReveal --fuzz-seed 1
...
rm -rf cache/invariant
...
"$FORGE" test --match-contract FillAbsolute --match-test invariant_MakerWindowHoldsAfterReveal --fuzz-seed 1
...
rm -rf cache/invariant
...
FOUNDRY_INVARIANT_RUNS=1024 FOUNDRY_INVARIANT_DEPTH=250 \
"$FORGE" test --match-contract FillAbsolute --match-test invariant_MakerWindowHoldsAfterReveal --fuzz-seed 1
...
"$FORGE" test --match-contract GasProfile -vv
...
"$FORGE" test --match-contract WorstCase -vvBuy the exposure instead of building it
The strongest argument against all of this is to not build it. A user expresses an order, a professional solver commits its own capital to fill it on the destination, and the venue settles with the solver inside one transaction, where the machine supplies atomicity again. The waiting, the timeout parameters and the refund path move onto a counterparty who prices them into a spread, and the team adopting it writes none of the timelock arithmetic that went wrong above.
That is not a paper design, and the Open Intents Framework ships that model as running code, with its settler contracts deployed at identical, predictable addresses on every supported chain the Open Intents Framework contracts. Ten mainnets and their testnets, addresses published.
The specification behind it is candid, and it names escrow first, fill first using resource locks, and auction based as execution shapes it deliberately leaves protocols room to differ across ERC-7683. That's right for a document about how an order reaches a solver, and it's why the model leaves a settlement owner's question open.
The exposure is relocated, not removed, and both documents say so. ERC-7683 opens the solver's risk when it commits capital, approvals or transactions to an order, and closes that risk only once the payment it expects is final and spendable ERC-7683. That window is the one leg A and leg B open around a matched fill, worn by another party.
The framework agrees, and its own dictionary puts the filler on the far side of the same exposure, as the party who will pay outputs first, and get their assets, the inputs, second the Open Intents Framework contracts. So the model wins for a venue that's a price taker on cross domain risk, and loses for the venue that must still operate the settlement contract underneath, because that venue becomes the filler it was trying to hire.
What this harness does not establish
I have stood in front of a whole company and told them what could break. What follows is that list for these contracts.
Everything above runs over three contracts of eleven, forty one and forty four source lines. They move no value and model the secret as a reveal rather than a preimage check, so the hash lock is never exercised. The other domain is absent, so the maker's action is an assumption rather than a call anyone made. No fees, no partial fills, one fill at a time. Nothing on this page measures a production settlement system.
The green campaign isn't a proof. It ran at one seed, over one handler, with the clock warped by a bounded step, and inside that model the property is foreclosed by the guard, so the outcome is what the algebra already implied. A different property, a different seed, or a defect the invariant doesn't describe sits outside these runs.
The counterexample has its own edges. Everything below the marked line is arithmetic worked out for this article, printed nowhere by the tool. What shrank in that sequence is his guaranteed window and not his asset, and the window that goes to nothing belongs to the constructed case rather than the counterexample.
Three numbers a team would want aren't here. How often a real settlement contract writes its window as a duration rather than a deadline is unmeasured, so nothing here says the mistake is common. The capital cost of the taker's locked funds is unmeasured in either direction. And the confirmation variance that would set DELTA on any pair of domains is absent from these contracts, which carry a constant where a real deployment carries a judgement about two chains. The alternative is evidenced as deployed code at published addresses, with no volume and no settlement share behind it. Existing, not adopted.
Who waits, and what retires the construction
Price the construction and execution gas is the least of it. Settling the fill inline costs 47,961 gas of execution and moves the value, while the locked lifecycle costs 29,787 gas of execution across four transactions and moves none of it. The coordination is cheap, and that's the wrong place to stop: price this off the gas alone and you'll conclude the construction is nearly free.
Those deltas leave out the floor every transaction pays before one opcode runs. EIP-7623 puts that cost at 21,000 gas a transaction, saved for each additional transaction whenever several are merged into one EIP-7623. Add the 21,000 gas floor each transaction pays and the inline fill costs 68,961 gas against 113,787 for the locked lifecycle, which is a little over one and a half times as much for a settlement that has still moved nothing.
Gas arrives on an invoice. The rest of the cost is a person: something has to fire the refund when the window shuts, and no guard inside a contract can make a counterparty act. The same document records the two sides pulling in opposite directions on that timeout, the buyer wanting it lower to reduce the time his funds are encumbered and the seller wanting it higher to reduce the risk that she cannot spend before the threshold BIP-199. That's a negotiation, not a constant somebody tunes.
There's a sharper name for what the waiting party holds, and a game theoretic reading of the same construction calls it an American call option nobody paid a premium for, held by the side who chooses when to reveal, and it leaves the other side carrying a loss for as long as his own assets stay locked in the contract a 2022 game theoretic analysis of these swaps. Widening DELTA is the lever this construction gives you, and whoever locked leg A pays in encumbered funds.
The same paper proposes another, and it runs the other way: lock a griefing premium beside the principal so a party who griefs pays it, and let either side cancel a 2022 game theoretic analysis of these swaps. That shortens the window instead of lengthening it, and the cost lands on whoever stops acting.
So what retires it? A market, not a better contract. On the day a liquid filler market quotes the pair at a spread below the cost of carrying the window in house, the construction is bought instead of built. Adoption still doesn't hand a venue the contract it has to write, and the standard leaves the machinery where it was, and says in its own words that no protocol adopting it has to share a common escrow, a common settlement contract or a common fill function ERC-7683.
There's a second reason that day hasn't arrived for a venue operating its own settlement layer, and the standard still carries draft status. A venue that adopts the model with no filler quoting its pairs becomes the counterparty the standard describes, on the same two clocks, with the same relation to get right.
Which leaves the rule these contracts exist to make visible. Draw the boundary deliberately, write the window as the absolute deadline it already is, and put the party who has to act into the design with a clock. Atomicity stops at the edge of what one transaction can revert, and everything past that edge is a timeout, a refund, and somebody waiting.
Abdel KIARI
I’ve owned the EVM side of a DeFi protocol with $110M+ in funding, including backing from Coinbase Ventures. I redesigned its architecture from scratch and built the Solidity infrastructure end to end.
Always happy to talk about interesting opportunities
abdel.kiari@gmail.com