Documentation Revision 1.2, 5 August 2026ProductionBase mainnet
MINTHOUSEDocs
Day Night
Back to the auction

Lock lifecycle and clocks

Four independent timers govern a lock, and none of them can be collapsed into another. This page states what each one bounds, what it deliberately does not bound, and the exact state transitions a lock can make.

The clocks are the hardest part of this contract to hold in your head, and every serious defect in versions 2 through 5 was a clock doing a job that belonged to a different clock. That history is the argument for keeping them separate, and it is on Threat model.

The four clocks#

ClockKeyed toLengthWhat it boundsWhat it does not do
lockExpiresAt (bidder, lot) 30 days How long one lock can stand. Past it, expireLock frees it and anyone may call. It does not bound the bidder. A settler rotating lot ids gets a fresh 30 days for each.
lockedAt (bidder, lot) +2 minutes When the lock may first settle. Set to the moment the amount last increased. It does not free anything. It only delays collection.
encumberedSince bidder 30 days How long a bidder can be continuously encumbered before the settler may add no more. Enforced in lock, as EncumbranceDue. It does not free a lock. Versions 3 and 4 let it, and both were fatal.
lockCooldownUntil bidder 2h 2m A window after an expiry in which no lock against this bidder may rise, so the bidder can actually get out. It does not block reductions or clears, because giving collateral back is never blocked.

lockCooldownUntil is 2 × withdrawDelay + SETTLE_DELAY, which is 2 hours 2 minutes with this deployment's one-hour delay.

Why the lot clock and the bidder clock are separate#

They do different jobs, and the conflict between them is real rather than an implementation detail.

  • Freeing collateral the house is still entitled to is destructive. It has to be governed by the lock's own age, so that a lock created against a live lot cannot be voided by a stranger before the house has had its settlement delay.
  • Bounding how long a bidder can be held is protective. It has to be keyed to the bidder, because the abuse it prevents (a settler rotating through fresh lot ids to renew a freeze for ever) is invisible to any per-lot clock.

Version 5 resolved this by making them separate mechanisms: only the lot's own clock frees a lock, and the bidder's clock instead refuses to let the settler add encumbrance. Nothing the settler does with lot ids gets past that, because the check does not look at the lot at all, and no honest lock is destroyed to achieve it.

The two ways this was got wrong

Version 3 left the bidder clock due for ever, which made every lock a long-active bidder would ever hold expirable by anyone the moment it was created. An honest lock against a live lot, seconds old, could be voided by a stranger for gas, and the house could never settle against that bidder again.

Version 4 scoped the waiver to locks created at or before the due instant. Narrower, and still fatal: the bidder chooses when to bid, so a bidder could void the collateral behind a lot she was winning.

The lock state machine#

A lock's states and the transitions between them No lock amount = 0 Standing settle blocked 2 min Settleable after SETTLE_DELAY Expirable after 30 days Settled to treasury Released back to free lock +2 min +30 days settle release expireLock (anyone) raise resets both
A raise returns a settleable or near-expirable lock to Standing, resetting both clocks together. The dashed arrow is the transition that defect M-01 got wrong: v5 restarted the settle clock without refreshing the expiry, so a raise in the last two minutes of an old lock became permissionlessly expirable before it could legally settle.

Transitions in full#

FromCallToClocks
No locklock(b, l, n), n > 0StandinglockedAt = now, lockExpiresAt = now + 30d. encumberedSince starts if lockedOf rose from 0.
Standinglock(b, l, n), n > currentStandingBoth reset. Refused if cooling down or the episode is due.
Standinglock(b, l, n), n < currentStandingUntouched. Never blocked.
Standinglock(b, l, n), n == currentStandingNo-op. Returns before writing or emitting.
Standinglock(b, l, 0)No lockBoth zeroed. freeSince stamped if lockedOf reached 0. No cooldown.
Standingrelease(b, l)ReleasedBoth zeroed. freeSince stamped. No cooldown.
Settleablesettle(b, l, a)SettledBoth zeroed. Any excess over a released in the same transaction.
ExpirableexpireLock(b, l)ReleasedBoth zeroed. Cooldown written: now + 2h 2m.
AnyrevokeSettler()ExpirableThe age requirement is waived entirely; every lock becomes expirable at once.

The cooldown asymmetry#

An episode the settler ends itself, by release, by settle or by locking to zero, stamps no lockCooldownUntil. An episode expireLock ends does. This looks inconsistent and is deliberate: ordinary auction turnover must stay usable, and a cooldown after every outbid would make the escrow unusable for a bidder who loses several lots in an evening.

The bidder is not left exposed by that, because the still-due episode clock requires the same free interval before it may restart. Episodes therefore cannot be chained into a continuous freeze whichever way the previous one ended.

The episode, and its ceiling#

An encumbrance episode begins when lockedOf rises from zero and ends only when the bidder has been at zero for the full exit window. Within one episode the ceiling is 2 × MAX_LOCK_AGE, and the house states that as 60 days rather than 30 because 30 would be a number it could only honour by voiding collateral it had already promised.

  1. Day 0: the bidder takes a lead. encumberedSince starts.

  2. Days 0–30: the settler may raise, add locks on other lots, and draw on a pending withdrawal. Every raise gives that lock a fresh 30 days of its own.

  3. Day 30: the episode falls due. lock now reverts EncumbranceDue on any raise. Existing locks are untouched and remain settleable: a lead taken in good faith before this instant can still be collected.

  4. Up to day 60: the last lock creatable was created an instant before day 30 and runs its own 30 days. Locks drain by their own expiries.

  5. lockedOf reaches zero. freeSince is stamped.

  6. Plus 2h 2m of genuine freedom, and only then may a new episode begin, with a fresh 30-day clock.

Why "is the bidder free" is not lockedOf == 0

The settler can make that true for the length of one transaction: lock to zero, lock straight back. Keying the restart on it let the freeze renew through a door that had already been shut on expireLock. The real test is the one _markEncumbrance already owns, a new encumbrance begins only when the bidder was unencumbered for the complete exit interval, and lock shares the same helper, so the advisory answer cannot drift looser than the state-changing one.

Solidity
function _exitWindowElapsed(uint256 freed) private view returns (bool) {
    return freed != 0 && block.timestamp >= freed + 2 * withdrawDelay + SETTLE_DELAY;
}

The withdrawal clock#

A fifth timer, on the bidder's own side rather than the house's.

StepEffect on freeOfEffect on the tokens
requestWithdraw(n)Falls by n immediatelyNone. Still in the contract, still reachable by a lock.
+ withdrawDelayUnchangedNone. A ripened request is still reachable by a lock.
claimWithdraw()UnchangedLeave the contract. This is the only step that makes the money safe.
cancelWithdraw()Rises by the pending amountNone.

The immediate fall in freeOf is the anti-sniper property: the house counts the request against bidding power at once, so funds cannot back a bid and exit before its close. The tokens staying put is what lets lock draw them back if a lead needs collateralising: the correction of version 2's worst defect, and the reason limit 2 exists.

A ripened request is not a completed exit

lock reads pendingWithdrawalOf without consulting withdrawableAt. A request that finished its delay a week ago and was never claimed can be drawn back in full. Claim it.

Worked timelines#

An ordinary win#

TEvent
00:00Bidder takes the lead. lock for max + fee. Both clocks start.
00:02Settleable.
04:00Lot closes; the bidder won. settle for the invoice total; excess released.

A raise near the close#

TEvent
00:00Lead taken, lock at $1,075.
03:59Bidder raises their maximum. lock to $5,375. Both clocks reset: settle blocked until 04:01, expiry pushed to day 30 from now.
04:00Lot closes. The settle is refused SettleTooEarly.
04:01The server's retry lands. Settled.

The one-minute wait is why SETTLE_DELAY is two minutes and not twenty: it sits below the five-minute extension window, so a raise this late has already moved the close anyway and no ordinary sale is delayed by it.

A house that stops answering#

TEvent
day 0Lead taken, lock created. The house goes dark.
day 0+Free balance is unaffected throughout: request, wait an hour, claim.
day 30The lock expires. Anyone calls expireLock; the collateral returns to free balance and a 2h 2m cooldown is stamped.
day 30 + 1hClaimed.

If the guardian is still reachable, revokeSettler collapses day 30 to immediately.

Choosing the constants#

ConstantLower bound comes fromUpper bound comes from
MAX_LOCK_AGE Must exceed the longest interval between taking a lead and that lot closing, plus the invoice due period and the default grace. Otherwise an honest lock expires mid-sale and leaves an unsecured lead. It is the bidder's worst-case wait when the house disappears. Every day added is a day of someone's money held.
SETTLE_DELAY Must be long enough that a human can reach the guardian's brake between a lock and a settle. Must sit below the 5-minute extension window so an ordinary close is never delayed, and far below the 10-minute stale-op alarm so a settle that does wait never reads as stuck.
withdrawDelay Constructor floor of 1 minute. Zero would let request and claim complete in the same block, collapsing the anti-sniper gap entirely. Constructor ceiling of 7 days. An unbounded value would brick every withdrawal.

The auction engine enforces the first of these from its own side too: every lot create, edit, extend and bid path asserts that the close, plus the invoice period, plus the default grace, plus an operational margin, still fits inside the lock's remaining life, and refuses the operation with escrow-duration if it does not. A protocol bound is not a UI preference.