Threat model
What each key can do if it turns hostile, what a malicious bidder can attempt, the blast radius of each, and the version history of every hole that has been closed. A contract on its sixth version has five versions of mistakes behind it, and the useful document is the one that names them.
The actors#
| Actor | Holds | Can move funds? | Worst case |
|---|---|---|---|
| Bidder | Their own wallet | Their own, only | They cannot reach anyone else's balance under any call sequence. |
| Settler | A hot key on the auction server | Locked collateral → treasury, only | Encumber every balance in the contract; misappropriate locked amounts to an address the house already controls. |
| Guardian | A cold incident key | No | It can halt the house and freeze locked balances for up to 30 days, but it cannot take a cent and cannot become the settler. |
| Treasury | The settlement destination | Its own contents | It is not a role on this contract and cannot call any function on it. |
| Anyone | Gas | No | Can call expireLock on an overdue lock, which only ever returns collateral to its owner. |
A stolen settler key#
This is the realistic incident: a hot key on an internet-facing server. Assume it is fully compromised and the attacker is unconstrained.
What the attacker can do#
- Lock any bidder's free balance against lot ids they invent, instantly, for every bidder in the contract.
- Draw back requested-but-unclaimed withdrawals into those locks.
- Settle those locks to the treasury, no sooner than two minutes after each lock.
- Release locks, freeing collateral behind live lots so the house cannot collect on genuine wins.
- Propose and accept successor settler keys all day.
What the attacker cannot do#
- Send a single token to an address of their choosing. No function takes a destination at all, and
settletransfers to animmutable. This is the sentence that turns theft into misappropriation to an address the auction house already controls, recoverable in the ordinary legal way rather than gone. - Move free balance. Locking encumbers; it does not transfer. There is no path from free balance to any address but its owner's.
- Compose the lock and the settle.
SETTLE_DELAYmeans a stolen key cannot lock and sweep every balance in one transaction, which is the property that makes the brake reachable at all. - Stop a claim.
claimWithdrawis outside every modifier the settler can reach. - Prevent their own removal. One
revokeSettlerends every successor they accepted; a rival proposal would only have traded places with the last accept. - Hold a bidder for ever. 30 days per lock, roughly 60 per episode, and no chaining without a real exit window.
Recovery#
Pause. The guardian stops deposits, locks, settles and releases immediately. Standing locks keep draining through the unpausable
expireLock; free balance stays reachable throughout.Revoke.
revokeSettleris terminal: the role ends, nobody can be appointed to it because appointing isonlySettlerand there is no settler left, and every lock becomes immediately expirable by anyone, so the floor gets its collateral back at once instead of waiting out 30 days.Migrate. Recovery is a new contract and a public migration, which is already this contract's stated answer to a change of rules. There is no upgrade to perform and no state to rescue: bidders withdraw from the old contract themselves.
A pause freezes locked balances. An outbid bidder whose lock the settler had not yet released waits, for the settler, for 30 days, or for a revoke. That cost is deliberate: release is inside the brake precisely so that pausing during an incident cannot hand every in-flight winner their collateral back while the house is unable to collect. Free balance is never affected.
A hostile guardian#
The guardian is the incident key, and the design assumption is that it might itself be the incident.
What it can do#
- Pause indefinitely. Denial of service against the house's ability to collect, and against locked collateral for up to 30 days per lock.
- Revoke the settler. Terminal, and it kills the escrow permanently. Every lock becomes expirable and no settlement is ever possible again.
What it cannot do#
- Move a single token. Neither of the guardian's two levers touches a balance.
- Become the settler.
transferSettlerisonlySettler. The power is absent, not guarded; see below. - Trap deposits after a revoke.
revokeSettleralso pauses, because a deposit into an escrow that can never lock, settle or release is a trap. AndunpauserevertsSettlerRevokedafterwards, so the trap cannot be reopened. - Hold locked collateral for ever.
expireLockis not pausable, so 30 days is the ceiling on any pause's grip on any lock.
Letting the guardian propose a settler looks free, because acceptSettler refuses a caller that already holds the other role. But that check compares addresses, and separation of duties is about parties. A guardian holding a second address proposes it, accepts from it, and now holds both roles, a pause-only key that can 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. So the power is not there at all.
The same reasoning is why acceptSettler and acceptGuardian both refuse the treasury: the constructor's treasury != settler check is walked past by two ordinary, fully authorised rotations, and version 2 drew that conclusion for the guardian but not for the treasury.
Both keys, together#
Settler and guardian in one party is the worst configuration this contract has an answer for, and the answer is partial and stated as such.
- They still cannot send a token anywhere but the immutable treasury.
- They still cannot stop a ripened claim.
- They can encumber every free balance and settle it to the treasury, and pause to stop the house's own honest operations.
The mitigation is operational rather than cryptographic: the two keys are held by different parties, the guardian is cold, and the treasury is a third address that is neither of them and cannot become either. The contract enforces the last of those; the first two are a deployment discipline, and a reader should verify them against the addresses published with a deployment rather than take them on trust.
A malicious bidder#
| Attempt | Outcome |
|---|---|
| Bid, then withdraw the collateral before the close | Refused. requestWithdraw removes the amount from freeOf at once, and lock draws the shortfall back out of a pending withdrawal. This was version 2's worst defect and it is closed. |
Void the collateral behind a lot they are winning, via expireLock |
Refused. Only the lock's own 30-day clock frees it. Versions 3 and 4 permitted variants of this; version 5 removed the bidder clock from expireLock entirely. |
| Reach another bidder's balance | Impossible. Every bidder function acts on msg.sender. The three that take a bidder argument are onlySettler. |
| Re-enter through a malicious token | Harmless. Every external call is the last statement of its function, after all state changes and all events. See On reentrancy. |
Front-run a depositWithPermit signature to grief the depositor |
Harmless. The permit is wrapped in try/catch: consuming the nonce does the user a favour, and the deposit proceeds on the allowance that is now in place. |
| Deposit a fee-on-transfer token to be credited more than was received | Refused. The credited amount is verified against the contract's own balance delta. And the token is immutable anyway. |
| Shill-bid their own consignment | Refused at the engine, by account and by frozen address fingerprint. The residual, a puppet with a genuinely different address, is stated on Authenticity and enforcement rather than glossed. |
Infrastructure#
| Failure | Effect | Mitigation |
|---|---|---|
| RPC provider lies or goes down | The house cannot read or write chain state. Bids that need a backing check fail closed with 503. | The sale never depends on a transaction landing; the ordinary invoice and default machinery is the fallback. |
| Chain reorganisation | A booked settle could be un-booked. | Booking finality is max(confirmations, reorgDepth), which is 40 blocks here. Settling at 3 while treating 40 as replaceable was defect H-08. |
| Server compromise short of the key | The auction record could be manipulated. | The event chain is hash-linked and the ledger append-only: a silent edit breaks every hash after it. Receipts are countersigned at the time of the ruling. |
| Indexer pointed at the wrong contract | A v5 escrow read as though it were v6. | An identity gate refuses to write a v6 event stamp against an escrow reporting another VERSION. This gate has fired in earnest against a box pointed at a superseded contract, and that was the gate working, not failing. |
| The settler key leaks through a log | Full settler compromise. | The key's shape is validated by regex before BigInt ever sees it, and the thrown message names the variable, never the value. A correct 64-hex key pasted without 0x used to put the key itself into stdout on a five-second loop and into an admin response body. |
Version history#
Every version's defects, named. This is the record a reader should weigh when deciding how much to trust version 6.
Version 1#
No lock expiry, no settle delay, no guardian revoke, and no constructor checks beyond non-zero addresses. A lock could stand for ever; a stolen key could lock and sweep in one transaction.
Version 2#
Added those three, and left three holes:
- A bidder could escape collateral entirely by requesting a withdrawal before the house's lock landed. The lock reverted, the lead stood with zero collateral, and the deposit walked out an hour later. Bid, push the price, never pay. This was the worst defect in the contract's history.
- A settler could renew a freeze for ever by changing lot id: the per-lot expiry bounded a lock, not a bidder.
- Two authorised rotations could put the settler and the treasury in one hand, because the separation check lived only in the constructor.
Version 3#
Closed all three, and bounded the settler by leaving 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 could be voided 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.
Version 5#
Removed the bidder clock from expireLock altogether, so only a lock's own clock frees it, and enforced the bidder's bound in lock instead, by refusing to add encumbrance. This is the design version 6 kept. Audited independently twice.
Version 6#
Closes eight seams left around those rules. Five were found by one audit and missed by the other, three by both.
| ID | The v5 defect | The v6 answer |
|---|---|---|
| M-01 | A raise restarted lockedAt but left lockExpiresAt. A raise in the last SETTLE_DELAY became permissionlessly expirable before it was legally settleable: the house could lose collateral it was owed. | Both clocks refresh on a raise. |
| M-02 | The fresh-episode predicate accepted one withdrawDelay, which is not enough for request, wait, claim. | 2 × withdrawDelay + SETTLE_DELAY, and expireLock's cooldown stamp changed to match exactly. |
| M-03 | A positive freeOf did not prove a lock increase was legal: pause, revoke, cooldown or a due episode could make every raise revert after the server had accepted the bid. | New canIncreaseLock view, a line-for-line match of lock's own predicate. |
| L-03 | claimWithdraw and settle emitted events after the token transfer; a callback token could leave a stale last word in the stream. | Effects and events precede the transfer. |
| L-04 | The constructor accepted a code-less token, an EOA could become the immutable token, producing a correctly-populated getter around a dead escrow. | Requires token_.code.length != 0. |
| L-01 | settle(bidder, lot, 0) against a live lock cleared it, moved nothing, and emitted a fully formed Settled, a payment record for a payment that never happened. | Reverts ZeroAmount, placed after the no-lock check so an invented lot still reports the lock actually found. |
| L-02 | The guardian could unpause a revoked escrow, reopening deposits into a contract that can never lock, release or settle. | Reverts SettlerRevoked. |
| H-08 | Settles booked at 3 confirmations while the indexer treated 40 blocks as replaceable. | Finality is max(confirmations, reorgDepth). |
From the expert panel#
- A re-lock at the amount already held was a complete no-op that still emitted
Locked, a settlement-grade record of an event that did not occur. The contract's own "say nothing" rule had covered only zero-on-zero. Three independent lenses converged on it. v6 returns early; measured live at 29,132 gas, no event, no state change. bidderReleaseDueAtreturns 0, its documented "not encumbered" sentinel, in exactly the state wherelockrevertsEncumbranceDue. Four lenses converged. The behaviour is correct (canIncreaseLockis the right gate), so the NatSpec was corrected and the divergence pinned by a regression test rather than papered over.
The panel raised 37 findings and adversarial verification refuted 26. Every survivor was LOW. For a twice-audited contract that is the expected shape, and a panel that had produced a long list of confirmed criticals on a contract at this stage would have been evidence about the panel rather than about the contract.
What is not covered#
- No formal verification of the full state machine. The invariants on the reference page are asserted and tested, not proved.
- No bug bounty currently running.
- No insurance of any kind on escrow balances.
- No multisig on the settler. It is a single hot key by design: it needs to sign inside a bid transaction, and the mitigation is the narrowness of what it can do rather than the difficulty of using it.
- The two-party key discipline is operational. The contract can enforce that three addresses differ; it cannot enforce that three people hold them.