BidEscrow v6 contract reference
The complete external surface: storage, constants, every function with its full precondition set, every event, every custom error, and the invariants that hold across all of them. Written for a reader with the source open.
VERSION()Constants and immutables#
| Name | Kind | Value here | Meaning |
|---|---|---|---|
| VERSION | constant | 6 | Schema version for indexers. New rules mean a new contract; anything reading < 6 is reading an older one with known holes. |
| MAX_LOCK_AGE | constant | 30 days | The longest a lock may stand before anyone may expire it. A constant rather than a constructor argument, deliberately: an immutable custody contract should not carry one more number a deployer can get wrong. Must exceed the longest interval between taking a lead and that lot closing. |
| SETTLE_DELAY | constant | 2 minutes | How long a lock must stand before any of it can settle. Chosen below the server's 5-minute extension window so an ordinary close is never delayed, and far below its 10-minute stale-op alarm so a settle that does wait never reads as stuck. |
| token | immutable | 0x015f…6DB8 | The ERC-20 this escrow holds. Constructor requires code.length != 0. |
| treasury | immutable | 0x069C…cC24 | The single destination a settle can reach. No setter exists. |
| withdrawDelay | immutable | 3600 | Seconds between requestWithdraw and claimWithdraw. Constructor bounds it to [1 minute, 7 days]: zero would collapse the anti-sniper gap entirely, unbounded would brick every withdrawal. |
Constructor preconditions#
constructor(address token_, address treasury_, address settler_, address guardian_, uint256 withdrawDelay_) {
require(token_ != address(0) && treasury_ != address(0) && settler_ != address(0) && guardian_ != address(0), "zero address");
require(token_.code.length != 0, "token has no code");
require(withdrawDelay_ >= 1 minutes && withdrawDelay_ <= 7 days, "withdrawDelay out of range");
require(settler_ != guardian_, "settler is the guardian");
require(treasury_ != settler_ && treasury_ != guardian_, "treasury is a role key");
require(treasury_ != address(this) && treasury_ != token_, "treasury is the escrow or the token");
...
}Every one of these guards a value nobody can change afterwards, so every one is checked here rather than in a deploy script that may not be the thing which deploys. The treasury != settler check in particular is what makes the sentence "a stolen settler key cannot steal" true, and it is enforced again in acceptSettler, because a constructor check alone is walked past by two ordinary, fully authorised rotations.
Storage#
| Slot | Type | Meaning |
|---|---|---|
| balanceOf[bidder] | uint256 | Total deposited: free + locked + pending withdrawal. |
| lockedOf[bidder] | uint256 | Sum of active locks across all lots. |
| lockOf[bidder][lot] | uint256 | The active lock for one (bidder, lot) pair. One lot, one lock per bidder. |
| lockExpiresAt[bidder][lot] | uint256 | When anyone may expire this lock. Set on creation and refreshed when the amount rises. |
| lockedAt[bidder][lot] | uint256 | When this lock's amount last increased. The settle clock runs from here, not from creation. |
| pendingWithdrawalOf[bidder] | uint256 | Requested, not yet claimed. Still in the contract, and still reachable by a lock. |
| withdrawableAt[bidder] | uint256 | When the pending amount may be claimed. |
| encumberedSince[bidder] | uint256 | When this bidder's continuous encumbrance episode began. Set when lockedOf rises from zero; deliberately not cleared when it returns to zero. |
| freeSince[bidder] | uint256 | When lockedOf last fell to zero. Decides whether a later re-lock starts a new episode or carries the old clock forward. |
| lockCooldownUntil[bidder] | uint256 | Until when the settler may not raise this bidder's lock. Written only by expireLock. |
| settler / pendingSettler | address | The house's operational key, and a two-step transfer. |
| guardian / pendingGuardian | address | The incident key, and a two-step transfer. |
| paused | bool | The brake. |
encumberedSince survives a zero
Clearing it when lockedOf hits zero is the obvious implementation and it is wrong, because the settler decides when the total touches zero. Release and re-lock in one transaction, or lock to zero and straight back, and the bidder is frozen for another 30 days having been "free" for zero blocks. That is the renewable freeze the per-lot expiry already had, rebuilt one level up. Instead the moment of freedom is recorded in freeSince, and a new episode only begins if the bidder was actually free for the complete exit window.
Bidder functions#
Always self-signed. None of them is gated on the house, and three of the five are outside the brake.
Credits amount to balanceOf[msg.sender]. Requires a prior ERC-20 approve.
The credited amount is verified against the contract's own balance delta rather than trusted from the argument, so a fee-on-transfer token is refused instead of crediting a bidder more than the escrow actually received:
uint256 before = IERC20Minimal(token).balanceOf(address(this));
if (!IERC20Minimal(token).transferFrom(msg.sender, address(this), amount)) revert TransferFailed();
require(IERC20Minimal(token).balanceOf(address(this)) == before + amount, "balance delta mismatch");Reverts: ZeroAmount, IsPaused, TransferFailed, or the balance-delta require.
EIP-2612: one signature, one transaction, no prior approve. This is the path the site uses by default because Base USDC supports it.
The permit call is wrapped in try/catch on purpose. A permit can be front-run: anyone may submit the same signature, consuming the nonce and making a second call revert. If the allowance is already in place the deposit should still succeed rather than fail on a griefing transaction that did the user a favour. The transferFrom below it is the real authorisation check.
Moves amount of free balance into pendingWithdrawalOf and sets withdrawableAt = block.timestamp + withdrawDelay. A second request adds to the pending amount and restarts the clock on the whole of it.
The delay is the anti-sniper gap: because freeOf subtracts the pending amount immediately, the house counts a requested withdrawal against bidding power the moment it lands, so funds cannot back a bid and exit before its close.
Reverts: ZeroAmount, InsufficientFree(requested, free).
Transfers the pending amount to msg.sender after the delay. This is the exit, and it is deliberately unconditional: no modifier, and no read of settler, guardian or paused anywhere in the body.
The event is emitted before the token call. The state change is already complete and a failed transfer rolls the log back with it, so emitting first means a token callback that deposits again appears afterwards, matching the final absolute balance in the event stream rather than contradicting it.
Reverts: NothingPending, TooEarly(nowTs, claimableAt), TransferFailed.
Returns the pending amount to bidding power and clears the clock. Reverts: NothingPending.
House functions#
All three are onlySettler and all three are inside the brake. None of them can reach an address the house did not fix at deployment.
Sets the lock for (bidder, lot) to amount, an absolute adjustment, up or down, not a delta. Moves no tokens.
Preconditions, in the order they are checked#
msg.sender == settler, elseNotSettler.!paused, elseIsPaused.- No-op short circuit.
amount == currentreturns immediately, writing nothing and emitting nothing. A re-lock at the value already held subtracts zero, writes the value back over itself, refreshes no clock, and used to emit a fully formedLockedanyway: a settlement-grade record of an event that did not occur, in the one stream the contract asks auditors to trust.
On a raise only (amount > current), three further gates:
block.timestamp >= lockCooldownUntil[bidder], elseLockCoolingDown. Checked only on a raise: lowering or clearing a lock gives collateral back and must never be blocked, least of all during the window that exists to release the bidder.- The episode gate. If
encumberedSince != 0andblock.timestamp >= encumberedSince + MAX_LOCK_AGE, the raise is refused withEncumbranceDueunless the bidder genuinely starts a fresh clock, meaninglockedOf == 0and the full exit window elapsed sincefreeSince. - Funding.
added = amount - currentmust be covered byfreeOf(bidder), or the shortfall is drawn out ofpendingWithdrawalOf, emittingWithdrawReduced. If the shortfall exceeds the pending amount too,InsufficientFree(added, free + pending).
This is an increase in the settler's reach: a requested withdrawal is no longer certain to complete. It exists because version 2 reverted here instead, and that revert was the contract's worst bug, a bidder who took the lead and then immediately requested a withdrawal could not be locked against at all, so the lead stood with zero collateral behind it and the whole deposit walked out an hour later. Bid, push the price, never pay.
Nothing is taken that the bidder had actually received: these tokens are in the contract either way, the lock still cannot exceed their balance, still cannot reach anyone else's, and still settles only to the immutable treasury. The bound is the same as every other lock's. A fully consumed request clears withdrawableAt as well as the amount, because leaving the timestamp behind would let a later request inherit a claim time that has already passed and claim in the same block.
Effects on a raise#
lockedOf[bidder] += added;
lockedAt[bidder][lot] = block.timestamp;
lockExpiresAt[bidder][lot] = block.timestamp + MAX_LOCK_AGE;Both clocks refresh together. Restarting settlement without refreshing expiry made a raise in the last two minutes of an old lock permissionlessly expirable before it could legally settle: the house could lose collateral it was owed. This was defect M-01, found in the v5 audit.
On a reduction, lockedOf decreases and the clocks are untouched. Setting amount = 0 additionally zeroes both clock slots. Every path then calls _markEncumbrance and emits Locked(bidder, lot, amount, lockedOf[bidder]).
Locked.amount is the new absolute lock, because a re-lock adjusts rather than adds. Released.amount is the amount removed. They do not compose into one another, and an indexer that sums Locked minus Released will be wrong.
Clears the lock entirely: the bidder was outbid, the lot was withdrawn, or the sale resolved without them. Their balance stays theirs; nothing moves. Idempotent: a double release on a zero lock returns silently.
Why it is pausable. This is the half of the brake that used to be missing. settle is pausable, so the brake stopped the house collecting; release was not, and the server answers a reverted settle by releasing the winner's collateral. Pressing the brake therefore handed every in-flight winner their money back with the invoice unpaid. Inside the brake, the settlement state freezes instead of unwinding. A bidder loses nothing they could rely on: release is onlySettler, so it was never a path they could take. expireLock is, and it is not pausable.
Transfers amount to the immutable treasury in payment of the bidder's invoice, clears the lock, and releases any part of it above amount in the same transaction.
| Check | Revert | Why in this order |
|---|---|---|
lockOf[bidder][lot] != 0 | InsufficientLock(amount, 0) | No lock, no settlement record. Settling zero against no lock moved nothing and emitted a fully formed Settled for a lot nobody had bid on, which the server's own reconciliation reads as truth. |
amount != 0 | ZeroAmount | A live lock does not make zero a payment. Accepting it cleared every lock field and emitted a fully formed Settled while transferring nothing, a release wearing a payment's audit identity. Deliberately checked after the no-lock test, so an invented lot still reports the lock the contract actually found. |
amount <= current | InsufficientLock(amount, current) | The house can never settle more than it locked. |
block.timestamp >= lockedAt + SETTLE_DELAY | SettleTooEarly(nowTs, readyAt) | The lock and the settle cannot sit in one transaction. This is what makes the guardian's brake reachable. |
Events precede the token call, for the same reason as in claimWithdraw: if the transfer fails the revert removes both state and logs, while a callback that re-locks this pair leaves its later absolute Locked event as the stream's last word, matching final storage.
Permissionless functions#
Releases a lock that has stood past its own lockExpiresAt. This is the only function that undoes a lock without the settler, and it is what makes the encumbrance bounded rather than indefinite.
The age requirement is waived once the settler has been revoked. The wait exists to give the house time to settle a lock it is entitled to; with no settler there is nobody who can ever settle or release it, so the timer has nothing on the other end and holding a bidder to it would be punishment for the house's incident. settler == address(0) is reachable only through revokeSettler.
The cooldown it writes#
uint256 coolUntil = block.timestamp + 2 * withdrawDelay + SETTLE_DELAY;
if (coolUntil > lockCooldownUntil[bidder]) {
lockCooldownUntil[bidder] = coolUntil;
emit LockCooldown(bidder, coolUntil);
}Twice the delay, and the margin after it is not padding. A withdrawal takes two steps separated by withdrawDelay. A window of one delay is never usable: expire at T and the window closes at T+D; the bidder cannot request before T, so her claim ripens at T+D at the very earliest: the same instant the settler may lock again, decided by ordering within one block. Two delays put a last-moment request's claim at exactly the old boundary, where ordering still wins. SETTLE_DELAY is the smallest security interval this contract already asks the chain to preserve, and it remains after the latest claim can ripen.
Versions 3 and 4 also freed a lock because the bidder's clock was due, and both were wrong in the same direction: v3 voided every future lock for ever, v4 voided any lock created at or before the due instant. The second is narrower and still fatal, because the bidder chooses when to bid, a live lot's collateral could become publicly expirable just before SETTLE_DELAY let the house collect it.
No arithmetic on creation times fixes that, because the conflict is real: freeing collateral the house is still entitled to, and bounding the bidder's encumbrance, are different jobs. They are done by different mechanisms. This one, the only one that destroys a claim, is purely the per-lot clock. The bidder's bound is enforced in lock instead.
Emits Released and LockExpired, never one instead of the other, so an existing indexer stays correct with no schema change.
Views#
Whether a positive increase to any lock for bidder is legal right now, before considering the amount of free collateral. This is the exact non-money precondition lock enforces, published so an off-chain bid gate can refuse an obligation before recording a lead it cannot collateralise.
if (paused || settler == address(0)) return false;
if (block.timestamp < lockCooldownUntil[bidder]) return false;
uint256 since = encumberedSince[bidder];
if (since == 0 || block.timestamp < since + MAX_LOCK_AGE) return true;
return lockedOf[bidder] == 0 && _exitWindowElapsed(freeSince[bidder]);freeOf
freeOf can be positive while the brake, an expiry cooldown, or a due episode makes every positive lock increase revert. A server that checks only freeOf accepts a bid it then cannot collateralise. This view was added in v6 precisely because that gap existed (defect M-03). Reductions and clears deliberately remain possible when this returns false, which is why the name says INCREASE rather than "lockable".
When this bidder's episode clock falls due: encumberedSince + MAX_LOCK_AGE, or 0 when lockedOf is 0. Since v5 that does not make any lock expirable: it is the instant from which lock refuses to add encumbrance.
This returns zero whenever lockedOf is zero, but encumberedSince deliberately survives that moment, so a bidder the settler has just released still carries a running episode, and lock will revert EncumbranceDue on the next raise until the episode is genuinely ended by a full exit window. A caller that reads zero as "unrestricted" will accept an obligation the contract then refuses. Use canIncreaseLock for admission; use this only to show a human when a standing episode falls due. The divergence is pinned by a regression test rather than left to comments.
return balanceOf[bidder] - lockedOf[bidder] - pendingWithdrawalOf[bidder];What new bids can be backed by, and what can be requested for withdrawal.
lockedAt + SETTLE_DELAY, or 0 when there is no lock. The server asks this before spending gas on a settle it would only have to re-broadcast.
Public mappings#
Every storage slot in the table above has an automatic getter: balanceOf, lockedOf, lockOf, lockExpiresAt, lockedAt, pendingWithdrawalOf, withdrawableAt, encumberedSince, freeSince, lockCooldownUntil, plus settler, pendingSettler, guardian, pendingGuardian, paused, token, treasury, withdrawDelay, VERSION, MAX_LOCK_AGE, SETTLE_DELAY.
Roles and the brake#
| Function | Caller | Effect |
|---|---|---|
| pause() | guardian | Stops deposit, depositWithPermit, lock, release, settle. Never touches the withdrawal path or expireLock. |
| unpause() | guardian | Reverts SettlerRevoked if settler == address(0), a terminal revoke cannot be undone by reopening deposits into a contract that can never lock, release or settle. |
| transferSettler(address) | settler only | Proposes a successor. The guardian deliberately cannot propose; see below. |
| acceptSettler() | pendingSettler | Reverts RolesMustDiffer if the caller is the guardian or the treasury. |
| revokeSettler() | guardian | Terminal. Sets settler = address(0), clears pendingSettler, sets paused = true, emits SettlerTransferred(previous, address(0)). |
| transferGuardian(address) | guardian | Proposes a successor. |
| acceptGuardian() | pendingGuardian | Reverts RolesMustDiffer if the caller is the settler or the treasury. |
Why the guardian cannot propose a settler#
Letting it looks free, because acceptSettler refuses a caller that already holds the other role. That check compares addresses. Separation of duties is about parties, a guardian with a second address proposes it, accepts from it, and holds both roles: a pause-only key that can then encumber every bidder's free balance and push locked collateral into the treasury. No on-chain check can tell that address apart from an honest new settler key, which is why the power is absent rather than guarded.
Why revokeSettler also pauses#
deposit is the one verb a revoke does not otherwise stop, and a deposit into an escrow that can never lock, settle or release is a trap. The revoke cannot be raced: a thief holding the settler key can propose and accept successors all day, and one revoke ends every one of them, where a rival proposal only ever trades places with the last accept.
Events#
| Event | Notes |
|---|---|
| Deposited(bidder, amount, newBalance) | newBalance is the absolute total after the credit, not the delta. |
| WithdrawRequested(bidder, amount, claimableAt) | amount is the amount added by this request, not the new total. |
| WithdrawClaimed(bidder, amount, newBalance) | Emitted before the transfer. |
| WithdrawCancelled(bidder, amount) | The whole pending amount returns to bidding power; a partial cancel does not exist. |
| WithdrawReduced(bidder, lot, amount, stillPending) | A lock drew part of a pending withdrawal back into collateral. Its own event rather than a silent mutation: an indexer replaying WithdrawRequested minus WithdrawClaimed would otherwise carry a pending withdrawal that no longer exists, for ever. |
| Locked(bidder, lot, amount, totalLocked) | amount is the new absolute lock. |
| Released(bidder, lot, amount) | amount is the amount removed. |
| LockExpired(bidder, lot, amount) | Emitted alongside Released, never instead of it. |
| LockCooldown(bidder, until) | The exit window an expiry opened. Its own event because an operator reading a failed lock needs to see why in the log rather than infer it from a revert. |
| Settled(bidder, lot, amount, treasury) | Emitted before the transfer. |
| Paused(by) / Unpaused(by) | by is indexed, so answering "who pressed the brake, and when" does not mean fetching every Paused log ever emitted and decoding each data field, during the incident the brake was pressed for. |
| SettlerTransferStarted / SettlerTransferred | A revoke emits SettlerTransferred(previous, address(0)) rather than a new event, so an indexer tracking the role reads address(0) and stays correct with no schema change. |
| GuardianTransferStarted / GuardianTransferred | Two-step, like the settler's. There is no guardian equivalent of a revoke. |
Custom errors#
| Error | Raised by |
|---|---|
| NotSettler() | lock, release, settle, transferSettler |
| NotGuardian() | pause, unpause, revokeSettler, transferGuardian |
| NotPending() | acceptSettler, acceptGuardian |
| IsPaused() | every pausable function |
| ZeroAmount() | deposit, depositWithPermit, requestWithdraw, settle |
| InsufficientFree(requested, free) | requestWithdraw, lock |
| InsufficientLock(requested, locked) | settle |
| NothingPending() | claimWithdraw, cancelWithdraw |
| TooEarly(nowTs, claimableAt) | claimWithdraw |
| TransferFailed() | deposit, claimWithdraw, settle |
| LockNotExpired(nowTs, expiresAt) | expireLock |
| SettleTooEarly(nowTs, readyAt) | settle |
| RolesMustDiffer() | acceptSettler, acceptGuardian |
| LockCoolingDown(nowTs, until) | lock, on a raise |
| EncumbranceDue(nowTs, dueAt) | lock, on a raise |
| SettlerRevoked() | unpause |
Invariants#
balanceOf[b] >= lockedOf[b] + pendingWithdrawalOf[b]for everyb.freeOfwould underflow otherwise, and it is unchecked arithmetic by construction rather than byunchecked.lockedOf[b] == Σ lockOf[b][lot]over all lots. Maintained by every path that touches a lock.- The token balance of the contract is at least
Σ balanceOf. Deposits verify the received delta; the only outflows areclaimWithdrawandsettle, each of which decrementsbalanceOfby exactly what it sends. encumberedSince[b] != 0wheneverlockedOf[b] != 0.- A settle is impossible earlier than
SETTLE_DELAYafter the lock's last increase: the property that makes the brake reachable. - No function transfers to an address that is neither
msg.sendernor the immutabletreasury.
On reentrancy#
There is no reentrancy guard, and none is needed: every external token call is the last statement of its function, after all state changes and all event emissions. A malicious token that re-enters finds storage already consistent and the event stream already written, and any state it then creates appears afterwards in both, which is exactly the ordering an indexer should see. The two functions that call out are claimWithdraw and settle; deposit calls out first but re-reads the balance afterwards and reverts on any mismatch.
Integration checklist#
- Assert
VERSION() == 6at startup. The house's own indexer refuses to write a v6 event stamp against an escrow reporting anything else, which is what caught a box pointed at the wrong contract before it wrote a single stamp. - Call
canIncreaseLock, notfreeOf, before accepting an off-chain obligation. - Treat
Locked.amountas absolute andReleased.amountas a delta. - Handle
WithdrawReducedor your pending-withdrawal figure will drift permanently. - Book at a depth at least as deep as your reorg assumption. This deployment books at
max(confirmations, reorgDepth)= 40 blocks; settling at 3 while treating 40 as replaceable was defect H-08. - Read
settleReadyAtbefore broadcasting a settle. - Pin the wallet a lock was taken on to the lot, not to the account. See the auction engine for what goes wrong otherwise.