The auction engine
The system that decides who wins. Sealed maximums, the increment ladder, extended bidding, reserves, the close sweeper, the marginal fee, and the signed receipt minted for every ruling it makes.
Everything a bidder can be bound by happens in one place, in one database transaction, against rules frozen before the lot opened. This page documents that path. The resolution algorithm itself, with worked examples, is Bidding mechanics.
One write path#
Every bid, whether from the site, from the desk or from an on-chain channel, goes through a single function. There is no second path that skips a check, no admin route that writes a bid row directly, and no client-side arithmetic that the server trusts. The client mirrors the fee and increment maths only to preview a keystroke; the number that binds is always the one the server computed.
| Stage | What it does |
|---|---|
| Idempotency | A repeated request with the same key returns the original ruling rather than placing a second bid. |
| Eligibility | Account status, compliance stamp, and the related-party check against the consignor. |
| Rules | Resolve the frozen auction_rules snapshot for this lot. |
| Backing | Where the escrow is on, read the bidder's on-chain position and refuse if it cannot be established. |
| Floor | Reject anything below the next minimum. |
| Resolution | Compare maximums, compute the new price and leader. |
| Extension | Move the close if the bid landed inside the window. |
| Horizon | Assert the new close still fits inside the escrow's collateral window. |
| Commit | Write the lot, the bid rows, the chain event, the receipt, the notification and the escrow outbox row, all in one transaction. |
Frozen rules#
A lot's economics come from an immutable snapshot, never from module constants that a deploy could change under a live auction. Each auction points at a rules_version, and that row holds the increment ladder, the fee tranches, the extension window and the extension cap as they stood when the sale opened.
{
feeTranches: [[0, 250000, 750], [250000, 1000000, 650], ...],
ladder: [[0, 10000, 1000], [10000, 50000, 2500], ...],
extWindowMs: 300000,
extCapMs: 1800000
}Rows are immutable, so the resolution is cached by version. The practical consequence: shipping a new fee schedule changes nothing about a lot that is already open, and a bidder who read the terms on Monday is bound by Monday's terms on Friday. Infinity serialises as null in the stored JSON and is rehydrated on read, so the top tranche has no magic number in it.
The increment ladder#
The next minimum bid is the current price plus the step for the band the current price falls in, or on a lot with no bids the opening price. The ladder is the one published in Conditions of Sale §2, resolved from the frozen snapshot rather than from the live table.
The fee#
The auction house fee is tiered and marginal: each rate applies only to the portion of the hammer inside its band, so the effective rate falls smoothly as the hammer rises and the total never jumps at a band boundary.
function houseFeeWith(tranches, hammerCents) {
let scaled = 0; // Σ portion × bps, exact integer
for (const [lo, hi, bps] of tranches) {
if (hammerCents <= lo) break;
const portion = Math.min(hammerCents, hi) - lo;
scaled += portion * bps;
}
return Math.floor((scaled + 5000) / 10000); // round half-up, no floats
}All arithmetic is exact integer maths on cents. The intermediate portion × bps peaks around 3.8 × 1012 and the sums stay under 1013, both comfortably inside Number.MAX_SAFE_INTEGER, so plain Number arithmetic is exact. The single division rounds half-up as floor((S + 5000) / 10000), reproducing round-to-nearest without a float ever existing.
The same module is the only place these numbers are computed. The engine imports it, the API exposes it at /api/lots/:id/quote and in the bootstrap payload, and the client mirrors exactly this integer algorithm for live previews. A worked table is in Conditions of Sale §4.
Extended bidding#
A bid inside the final five minutes moves the close to five minutes after that bid, anchored to the bid and never added to the old close, capped at 30 minutes of total extension per lot.
function extensionFor(lot, bidAt, rules) {
const end = lot.base_ends_at + lot.ext_ms;
if (bidAt > end || end - bidAt >= rules.extWindowMs) return { extended: false };
const wanted = bidAt + rules.extWindowMs - lot.base_ends_at;
const next = Math.min(Math.max(lot.ext_ms, wanted), rules.extCapMs);
if (next <= lot.ext_ms) return { extended: false };
return { extended: next < wanted ? "capped" : true, ext_ms: next };
}Anchoring matters. Stacking five minutes onto the old close each time would let a bidder who bids at the last second twice add ten minutes, and would make the close a function of how many late bids landed rather than of when the last one did. Here the close is always "five minutes after the most recent late bid," and the cap is on the total, so a lot cannot be held open indefinitely by a war of attrition.
The function is pure: it takes the lot and the bid time and returns a decision, and the caller persists inside its own transaction. Lots close independently and may close out of numerical order.
The close#
A sweeper finalises lots past their close. It does not run the instant the clock passes zero, for a reason worth stating:
A bid can arrive over a channel with confirmation latency, an on-chain transaction broadcast before the close but observed seconds after it. The engine tracks both: effectiveAt is when the bid was committed (block time, for an on-chain bid) and governs validity; at is when the house observed it. A bid whose effective time is before the close is honoured if it is observed inside a 30-second settlement grace, and the sweeper never finalises a lot until that window has passed. Purely late bids are still rejected.
At the close, the highest bid at or above any reserve wins and a contract of sale forms between the winner and the consignor. Below reserve the lot is passed; with no bids it is unsold. Under no circumstances does the system place a bid on the seller's behalf at or above the reserve.
A configuration knob that could stop the house#
The grace period is read through the same bounded integer parser as every other numeric setting, and the comment explaining why is a useful illustration of the engine's failure posture. Set it with a word, off or none, and a naive parseInt yields NaN; SQLite binds NaN as NULL; base_ends_at + ext_ms + NULL is NULL; and NULL < at is not true, so the sweeper's query selects no rows, for any lot, for ever. Lots stay open past their close, no winners, no invoices, no consignor paid, and a naive health check stays green, because the sweep still finished, having iterated an empty list.
Out-of-range or unreadable now falls back to 30 seconds and is named by the configuration-problem reporter that /api/health publishes. The floor is zero, because "no grace" is a thing an operator may legitimately ask for.
Receipts and the event chain#
Every material auction event (bid, maximum raise, extension, close, edit, listing, reservation) appends to a per-lot hash chain with a global monotonic sequence:
event_hash = sha256(prev_hash + "\n" + canonical(payload))The hash is computed inside the same transaction as the mutation it records, chained off a cache on the lot row. Rewriting any historical row breaks every hash after it; append-only triggers in SQLite block the rewrite at the database level; and a verification tool re-derives the whole chain from scratch.
Signed receipts#
On top of the chain, the house countersigns its own rulings. A receipt is an Ed25519 signature over the canonical bytes of a statement (a bid decision, a lot outcome, a listing), binding:
- the lot and the timestamp;
- the chain position:
seq,globalSeq,eventHash,prevHash; - the resulting price and the outcome (
lead,outbid,max-raise); - the bidder, and the rules version in force.
The key pair is generated at first boot and the public key is served at /api/audit/keys, so a receipt can be verified offline by anyone. Verification always consumes the stored canonical bytes. Nothing re-serialises, because a re-serialisation is a second chance to produce different bytes.
A receipt asserts what the house decided and recorded. It never asserts custody or movement of funds. Money movement is evidenced by the chain: the Base transaction hash, and the escrow's own public events.
The sealed maximum, and the salt#
The event hash is public the moment a bid lands. Without a salt, a low-entropy field (a whole-dollar maximum under $50M is about 226 possibilities) could be brute-forced straight out of it. Each payload therefore carries 128 bits of blinding nonce, and the whole payload, nonce included, is revealed when the lot closes.
A maximum raise is signed like any other bid decision, and its payload cites the chain position rather than the maximum itself, so the receipt binds the raise without publishing it.
Two independent records of the chain#
Where the escrow is enabled, the house keeps two on-chain records and reconciles them against each other rather than trusting either alone.
| The outbox | The chain log | |
|---|---|---|
| Is | What the house intended, plus the receipt status of the transaction it broadcast. | What the escrow contract actually did, every log it has ever emitted. |
| Keyed by | The operation. | (chain_id, contract, tx_hash, log_index): the only tuple canonical for a log. |
| Written by | The engine, in the same transaction as the bid or close it belongs to. | An indexer, from the chain. |
| Blind to | Anything this process did not send: an operator with the settler key and a terminal, a replay, a second instance. And every bidder verb: deposits, withdrawals, claims and expireLock, because the house never sends those. | Nothing the contract emitted. |
Not the transaction hash alone, because one transaction emits many logs; not (block, log_index), because a reorg re-mines the same log at a different block. The uniqueness constraint is what makes ingestion idempotent, which is what makes it safe to re-run a backfill, restart mid-range, or run two instances at once. A disagreement between the two records is an alarm rather than a silence.
Escrow integration#
The lock follows the standing maximum#
When a bid changes the leader, the engine enqueues the on-chain operations in the same database transaction as the bid, and a worker drains the queue. The lock amount is the standing maximum plus the fee on it, not the public price. The reasoning, and the privacy cost, are in What is revealed.
Rows update when a leader raises their own maximum even though the public price has not moved, because the obligation has.
Chain writes are asynchronous and the auction engine is synchronous, so the two meet in an outbox. A crash mid-drain retries. A failed settle leaves the invoice unpaid, and the ordinary invoice and default machinery is the fallback. The sale never depends on a transaction landing.
Wallet pinning, and the bug it prevents#
lock(wallet, lotKey, amount) is keyed to a wallet. Every later instruction about that money (release it, settle it, take a penalty out of it) must name the same wallet, or the contract has nothing to act on and the transaction reverts.
So "which wallet backs this account today" is the wrong question, and asking it caused a real failure. Re-signing a wallet updates its verification timestamp, which reorders "the account's first verified wallet", so a bidder who re-signed the wallet they were standing on promoted a different one. Nothing about the lock changed; the answer did. Every operation afterwards named a wallet with nothing locked on it: the settle at close reverted and the invoice never marked paid, the release on a reserve-not-met left collateral locked for ever, and a default's penalty was taken from a wallet that owed nothing. Any wallet revoke does the same thing.
The fix is that the lot's own lock is the authority, not the account. The operation record says which wallet each lock was taken on, and nothing deletes those rows, so the answer survives anything that happens to the account's wallet list afterwards. Revoked wallets are deliberately not filtered out: the case this exists for is money sitting on a wallet the account has disowned, and skipping it is precisely how it gets stranded.
The horizon assertion#
A v6 lock is permissionlessly releasable 30 days after it was created. A part-covered winner can legitimately remain unpaid for the 10-day invoice period plus the default grace, so the auction itself has to fit in the remainder with an operational margin:
auctionBudget = MAX_LOCK_AGE - INVOICE_DUE - DEFAULT_GRACE - CLOSE_GRACE - MARGINEvery create, edit, extend and bid path calls the same assertion, and a close that would outlive the collateral window is refused with escrow-duration and a message naming the latest permissible close. This is a protocol bound, not a UI preference.
Failing closed on an unknown backing#
Where the escrow is on, a bid whose collateral position cannot be established is refused with 503 backing-unknown rather than accepted optimistically. Two cases are deliberately distinguished:
- A standing leader with no linked wallet at all is a known zero, not an unknown. A lock can only exist against a wallet the house locked against, and zero is also the safe direction: it credits the challenger with nothing.
- A leader who has wallets, none of which resolves, is genuine ambiguity and still fails closed.
The distinction is not academic. Treating the first case as unknown made every bid on every lot with a pre-escrow leader fail, in production that is every lot whose leader bid before escrow mode was switched on.
Buy Now and Buy It Now#
| Buy Now | Buy It Now | |
|---|---|---|
| What it is | A fixed-price listing. | A winning bid on an auction lot. |
| Fee | None. The listed price is the full price. | The buy price is the hammer; the auction house fee is added. |
| Available | Until sold. | Until bidding reaches 75% of the Buy It Now price, and never during extended bidding. Where the escrow is on, also only to the standing leader, and only against a lock that already covers the buy price plus the fee. |
| Checkout | Holds the listing for 10 minutes. A lapsed hold voids its invoice and reopens the listing. | Immediate: it closes the lot. |
Reservations are rate-limited per account: at most three live at once, and at most three holds on any one lot within 24 hours. A hold is a way to take a listing off the market while you pay, not a way to take it off the market.
Invoices and default#
A sweeper walks invoices on the same schedule. Unpaid at the due date is late; unpaid five days after that is defaulted, which reverses the sale, passes the lot, charges the default fee and can pause bidding on the account. The rates are in Authenticity and enforcement.
Where the winner was collateralised, the ordinary remedy is a settle against the standing lock, not a default at all. A default is what happens when there is no lock to settle, or the lock does not cover the invoice.
What is published#
- Every price realized. The house is a marketplace of record.
- Bid histories, with bidders anonymised. Identities are never published.
- The public key for receipt verification, at
/api/audit/keys. - Every escrow event, on chain, permanently.