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#
| Clock | Keyed to | Length | What it bounds | What 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.
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#
Transitions in full#
| From | Call | To | Clocks |
|---|---|---|---|
| No lock | lock(b, l, n), n > 0 | Standing | lockedAt = now, lockExpiresAt = now + 30d. encumberedSince starts if lockedOf rose from 0. |
| Standing | lock(b, l, n), n > current | Standing | Both reset. Refused if cooling down or the episode is due. |
| Standing | lock(b, l, n), n < current | Standing | Untouched. Never blocked. |
| Standing | lock(b, l, n), n == current | Standing | No-op. Returns before writing or emitting. |
| Standing | lock(b, l, 0) | No lock | Both zeroed. freeSince stamped if lockedOf reached 0. No cooldown. |
| Standing | release(b, l) | Released | Both zeroed. freeSince stamped. No cooldown. |
| Settleable | settle(b, l, a) | Settled | Both zeroed. Any excess over a released in the same transaction. |
| Expirable | expireLock(b, l) | Released | Both zeroed. Cooldown written: now + 2h 2m. |
| Any | revokeSettler() | Expirable | The 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.
Day 0: the bidder takes a lead.
encumberedSincestarts.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.
Day 30: the episode falls due.
locknow revertsEncumbranceDueon any raise. Existing locks are untouched and remain settleable: a lead taken in good faith before this instant can still be collected.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.
lockedOfreaches zero.freeSinceis stamped.Plus 2h 2m of genuine freedom, and only then may a new episode begin, with a fresh 30-day clock.
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.
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.
| Step | Effect on freeOf | Effect on the tokens |
|---|---|---|
requestWithdraw(n) | Falls by n immediately | None. Still in the contract, still reachable by a lock. |
+ withdrawDelay | Unchanged | None. A ripened request is still reachable by a lock. |
claimWithdraw() | Unchanged | Leave the contract. This is the only step that makes the money safe. |
cancelWithdraw() | Rises by the pending amount | None. |
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.
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#
| T | Event |
|---|---|
| 00:00 | Bidder takes the lead. lock for max + fee. Both clocks start. |
| 00:02 | Settleable. |
| 04:00 | Lot closes; the bidder won. settle for the invoice total; excess released. |
A raise near the close#
| T | Event |
|---|---|
| 00:00 | Lead taken, lock at $1,075. |
| 03:59 | Bidder raises their maximum. lock to $5,375. Both clocks reset: settle blocked until 04:01, expiry pushed to day 30 from now. |
| 04:00 | Lot closes. The settle is refused SettleTooEarly. |
| 04:01 | The 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#
| T | Event |
|---|---|
| day 0 | Lead taken, lock created. The house goes dark. |
| day 0+ | Free balance is unaffected throughout: request, wait an hour, claim. |
| day 30 | The lock expires. Anyone calls expireLock; the collateral returns to free balance and a 2h 2m cooldown is stamped. |
| day 30 + 1h | Claimed. |
If the guardian is still reachable, revokeSettler collapses day 30 to immediately.
Choosing the constants#
| Constant | Lower bound comes from | Upper 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.