Full mainnet demo, 2:46
Sealed-bid auctions on STRK20 where the losing bids are never published.
Open the live auctions · How it works, in full — the six properties, the hash-chain construction, and what the STRK20 integration does and does not reveal.
A Vickrey auction is the theoretically optimal auction: the highest bidder wins and pays the second-highest bid, so bidding your true valuation is the dominant strategy. It has been known since 1961 and has never been deployable on a public chain, because sealing the bids required trusting an auctioneer and revealing them destroyed the privacy that made the mechanism work.
This one settles with a proof. The winner and the clearing price are established on-chain by hash-preimage witnesses over a bid set the contract froze before anyone could open it. The losing bids are never published — not on chain, not in the app, not anywhere except the bidders' own devices. Neither is the winner's.
Warning
Settlement has not yet run end to end on mainnet. The bids placed on 7 Sep lost the seed that settlement needs, so those auctions can never settle. The cause has been found, and the browser store now refuses to drop a bid without proof from the chain. A full seal → settle → finalize → claim with a human in the browser has not yet been done on mainnet. Save the claim secret the app shows you when it shows it. Full disclosure below.
To try it: connect a wallet, pick a level, sign. That is the public rail and it needs no shielded balance and no set-up. Your bid is sealed either way — the private rail additionally hides your address, and costs a pool fee and a shield you make inside your own wallet. Both rails, and which one you want.
Built for the Starknet Private Sprint (RFP-08). Submissions closed 7 Sep 2026, 23:59 UTC; work has continued in this repository since.
What is assured: the winner and the clearing price are established by hash-preimage proofs verified on-chain over a bid set the contract froze before any bid could be opened, so the auctioneer cannot misplace any bid it settles, or misreport the price those bids set, without failing a proof. What is not: after sealing, the auctioneer learns every bid amount — it can never publish them or spend anyone's funds, but it knows them, and every bid sits on chain encrypted to the auction's key, readable by anyone who ever holds that key; it can settle without a bid by recording it as forfeited, but a bid it left out that beat the price, whose reveal was posted in time, voids that settlement and takes the auctioneer's bond; and the number of bids, their timing, and the uniform escrow amount are public on-chain.
On the first-version contracts, which stay readable on the site, the second sentence ends differently: "it can settle without a bid by recording it as forfeited, and on the current contracts the dispute window does not reliably penalise that; and the number of bids, their timing, and the uniform escrow amount are public on-chain."
The long form, including everything this does not protect against, is in TRUST.md.
The second version is live on Starknet mainnet since 4 Oct 2026. It adds NFT and off-chain lots, posts each reveal on chain encrypted to the auctioneer's per-auction key, and lets a bid the settlement left out void it. Nothing has been audited. The site at https://vickrey.0xo.in points at it. The first version, live since 7 Sep 2026, is frozen and stays readable on the site.
| Piece | State |
|---|---|
| Auction contract (v2) | Deployed on mainnet 0x01b81e43…08c7. 139 tests passing. Not audited |
| Anonymizer helper (v2) | Deployed on mainnet 0x00cb8007…e050, wired to the STRK20 pool. 14 tests passing. Not audited |
| Reveal relay | Live. Posts encrypted reveals and disputes from its own account, 0x00b6bd64…9966, so a bidder's wallet is not attached |
| Client library | 66 tests passing, hash- and encryption-conformant with Cairo |
| Web app | 160 tests passing. Every screen recorded at 1440 and 390, light and dark, before the switch |
| Mainnet smoke test, 4 Oct 2026 | Done on the public rail at nominal value: a token lot with a forfeit redeemed, an NFT lot, off-chain confirm / reject / release, a dispute through the relay, an abandon, and two auctions with no bids. Transactions |
| Sepolia end to end | Done through the UI: 17 auctions across every path, including the screens no chain state produces on demand |
| Private bids through the pool | Work on mainnet, as on 7 Sep |
| Private collect | Waiting on the pool. The pool screens open-note deposits from contracts it has not cleared, and it reports the helper as Required. Until it is cleared, the site shows the private collect as unavailable, says why, and offers the public one. It never sends a private collect the pool would refuse |
| First version | Frozen at 0x02d893ac…6831. Read-only on the site |
| Demo video | Done |
Mainnet cost is measured rather than estimated: docs/mainnet.md.
Two dependency advisories are open and deliberately not chased: postcss and sharp
reach the web app only as Next.js build-time transitives, and clearing them needs
Next 16, a breaking major. Neither is reachable here (no attacker-controlled CSS, image
optimization unused). The one critical advisory, in Next itself, is patched.
The two items previously marked UNVERIFIED are now resolved against source:
- The STRK20 action envelope — typed against
STRK20_ACTIONas shipped in@starknet-io/starknet-types-0103, the Wallet API 0.10.3 typesstarknet@10.4.0vendors. This caught a real bug:FELTis prefixed hex with no leading zeros, and the first implementation passed the collateral as a decimal string, which the wallet would have rejected after the user signed. OpenNoteDeposit's layout — read frompackages/privacy/src/objects.cairo@36eac4ea88cd8c59dde1493176e16501c6e90328and byte-for-byte identical to the local declaration. The revision is cited in the file;test_layout.cairopins the serialization.
compile_actions is a view on the STRK20 pool, so our calldata can be put in front
of the real contract read-only. npm run verify:pool in client/ does that, building
the actions with the production code rather than a copy:
| Case | Live Sepolia pool | Reading |
|---|---|---|
bid [Withdraw, InvokeExternal] |
NEGATIVE_INTERMEDIATE_BALANCE |
shape accepted, reached the balance invariant |
claim [CreateOpenNote, Invoke] |
SUBCHANNEL_NOT_FOUND |
shape accepted, reached state lookup |
| control: reversed | ACTIONS_OUT_OF_ORDER |
|
| control: invoke alone | NO_REPLAY_PROTECTION |
|
| control: two invokes | ACTIONS_OUT_OF_ORDER |
Our shapes fail on state; the controls fail on shape. That asymmetry is the point, and the controls are there so the check can still fail if the encoding drifts.
Sepolia pool 0x254a…0d91, live get_fee_amount 2 STRK — not the 4 the docs quote
for mainnet, which is why it is read and never hardcoded.
- A browser wallet assembled, proved and submitted three of these. Auction #1,
three private-rail bids, each verified against the §6c predicate:
0x57ba561f…,0x3b113126…,0x58742bf9…. - The helper has run. It is deployed at
0x04628cab…and each of those three transactions carries itsRoutedevent alongside the auction'sBidPlaced. - Pool economics measured rather than quoted: 6 STRK per operation, read live from
get_fee_amount, taken from the shielded balance — the public balance moved by exactly 0.000000 on each bid. Gas about 2.73 STRK per bid, paymaster-sponsored.
The 7 Sep auctions can never settle: the witnesses settle verifies are built from each
bid's seed, and those seeds were destroyed by the defect disclosed immediately below.
Settlement first ran on mainnet on the second version, in the 4 Oct smoke test.
A bid placed through this app may leave no recoverable claim secret in the browser, and an auction it was placed in cannot then be settled. Found during the mainnet run, by losing six of our own.
What happens: the bid transaction succeeds and is verifiable on chain. The app writes
the claim secret and the seed to localStorage before calling the wallet, and shows
the claim secret afterwards behind an "I have saved it" gate. But on mainnet the stored
entry was repeatedly absent afterwards, with no error raised anywhere.
What that costs. The escrow is still recoverable — claim_refund takes the claim
secret, the app displays it, and abandon makes it claimable after the dispute window
— so a bidder who saves what the screen tells them to save keeps their money. What is
lost is settlement: the seed is never displayed, it exists only in the vault, and
without it no bidder can produce the witnesses settle verifies. An auction bid through
this app therefore cannot currently reach a proved clearing price.
What deleted them: the dashboard reconciler, on a 20-second poll, in every tab that
had the dashboard open. A stored bid's index is a guess taken from a polled bidCount.
The reconciler checked that guess by searching bid indices 0..count-1 for the commitment
— bounding the search with that same polled count. Immediately after a bid the poll is
short by exactly the bid just placed, so the stored index equals the stale count: the
direct read is skipped, the search stops one index before the bid it is looking for, and
the null it returns means the range excluded it, not no bid carries this
commitment. The reconciler read that as a negative and deleted the entry.
An earlier version of this disclosure said it fired "when the bidder opened the dashboard
to look at the bid they had just made." That was wrong in the way that mattered. It fired
in every tab that already had the dashboard open, on useDashData's 20-second poll.
Four Vickrey tabs were open during the mainnet bids — /auction/1, /auction/2,
/app and /app/bids — and each seed was written by one tab and deleted by another
within twenty seconds, which is why six went in twenty-second intervals while the tab
doing the bidding raised no error.
The reconciler fix was necessary and not sufficient. It shipped on 8 Sep at
04:30 UTC. That morning a public-rail bid on auction #5 lost its seed again. A storage
monitor caught it: a storage event roughly 25 seconds after every write — the 20-second
poll plus the RPC round-trips — from another tab. Two fresh tabs on the fixed build were
tested side by side and do not clobber each other; the tab doing the deleting was one
opened before the deploy, still running the old bundle. Nothing a deploy ships can reach
a tab that has already loaded old JavaScript.
What is done about it now, in web/lib/vault.ts and web/lib/vaultSync.ts:
- The store refuses to shrink without proof. Every write goes through one function, and a write may not remove a bid unless it names that bid and carries a positive on-chain answer. Anything else that would vanish is put back into the write. No caller, present or future, can silently reduce the set. Identity is the commitment, not the index, so a reindex is an edit and a second attempt at the same index keeps the first.
- Current tabs undo a stale tab's wipe. A
storageevent carries the value before and after. A shrink that no tab announced proof for on the BroadcastChannel is restored within milliseconds. The current bundle announces every proven drop before it writes; the old bundle never announces, so its deletions are undone and a real cleanup is left alone. - Version skew is named, in the right tab. Each build carries an ordered id. The tab that hears a newer one knows it is old and offers a reload; the tab that hears an older one — or that has just had to restore something — says plainly that another tab is on an older version and will keep deleting saved bids every 20 seconds until it is closed or reloaded.
Both defects were reproduced before they were fixed, and the tests fail on the builds
before each fix. npm run reconcile-probe seeds one vault entry, loads the dashboard
against a real chain, and reports whether it survived:
| case | before the reconciler fix | after |
|---|---|---|
| index 3, search covers 0–2 — unreachable | deleted | kept |
| index 1, search covers 0–2 — genuinely absent | dropped | dropped |
node scripts/vault-skew-test.mjs builds the pre-fix bundle and the current one, serves
them on one port in turn, opens a tab against each, and seeds a bid. The old tab deletes it
on its poll; the current tab puts it back and says why. Against the build before the
cross-tab fix it fails, as it must — a test that passes on both proves nothing.
For the record, the three hypotheses eliminated on mainnet before the cause was found, in order:
- The rollback in the bid flow. It deleted the entry whenever a flag set after the
wallet call was still false, which could not distinguish a throw before broadcast from
one after. It was a real defect and is removed. It is not this one: the failing bid
raised no error at all, so the
catchnever ran. - Blocked or full site data.
localStorage.setItemfollowed bygetItemreturns the written value on the affected origin, and the store holds older entries from previous deployments. - The dashboard reconciler. It can drop an entry the chain does not confirm, but it
lives in
useDashData, which the auction page does not use.
The first two were correctly eliminated. The third was not — and it was eliminated for the wrong reason twice: first because the auction page does not use it, then because a single fresh tab does not trigger it. The reconciler did not need to run in the tab that bid. It needed to be running in any other tab at all.
Cost to us: the first-version mainnet contract still holds 2.34 STRK — nine
bids' escrow at 0.24 and six bonds at 0.03 — and the 600 VLOT lot, across auctions
#0 to #5 (checked on chain 4 Oct 2026). None of them can settle. Anyone can seal them and,
after the grace period, abandon them: the VLOT then returns to the seller, and each
bid's escrow, with its share of the bond, comes back to whoever still holds that bid's
claim secret — on the public rail, for gas alone. Where the secret was lost, as it was
for #5, the escrow stays in the contract permanently. Full account in
docs/mainnet.md.
The three qualifying transactions are unaffected. They are on chain, verified, and independent of any secret.
The reveal relay at /api/reveals had an unauthenticated read. GET /api/reveals?auctionId=N returned every reveal posted for that auction — each carrying
the bidder's exact level — to anyone who asked, and auction ids are sequential integers.
For the window between a bidder revealing and the auctioneer settling, every bid amount
was public. On a project whose claim is that losing bids are never published, that was
the claim.
It never held a claim secret, so no funds were reachable through it, and it could not change an outcome — reveals are re-checked against the anchors on chain. The exposure was of information, which is the part that matters here.
Worth the context, since otherwise the bar is unreadable: the other entries in this RFP publish every bid at reveal, by design — commit–reveal posts each bid on chain so the contract can check it against its hash. The property we broke for a few days is one nobody else in the category is attempting. That is not an excuse; a claim you keep only by accident is not a claim. It is the measure: the bar was ours, we set it above the field, and we failed it somewhere we had not looked.
Found by auditing the reveal channel rather than the contract. The route's own comment had reasoned about the write path — "the worst it can do is withhold a reveal" — and never asked what the read path returned. No contract audit would have caught it.
Fixed the same day: the relay is off unless REVEAL_RELAY=on, which production does
not set; both verbs return 503. Reveals travel by copy-and-paste over a channel the
bidder picks. Authenticating the read is designed and deliberately unbuilt — see
/docs.
Measured with /wallet-check, which makes a real strk20Balances([STRK]) call rather
than testing that the method exists. The wallet answers for its own network, so both
columns come from one deployment by switching networks in the wallet.
| Wallet | Sepolia | Mainnet |
|---|---|---|
| Xverse | pass | pass |
| Ready X | fail | pass |
Two consequences. Xverse is the wallet for the whole run, and because it works on
Sepolia the pool leg is rehearsable on testnet — shield the smallest note, wait for
maturity, confirm with scripts/pool-status.mjs that registered flips no→yes and
channels 0→1, then place a real private-rail bid. Only then does the mainnet declare
happen, so Rule 1 still holds on mainnet but the step is no longer unrehearsed.
Ready X cannot use the private rail on Sepolia. The app detects this rather than documenting it alone: a real STRK20 call that fails on a wallet that advertises support disables the private rail and says why, instead of letting a bid fail.
Almost every sealed-bid auction on a public chain is commit–reveal — post a hash, then post the bid so the contract can check it. It is sound and well understood, and it ends with every bid public. Your valuation becomes permanently readable, and it is still true at the next auction. A bidder who dislikes the result can also simply withhold their reveal, which in a second-price auction changes what the winner pays.
Here the auction ends and one number is published: the clearing price, because it is the price. Every other bid is still two hashes and a ciphertext that only the auction's key opens, the winner's included. A bid that is not revealed in time is settled around, not waited for.
One corollary, stated because it cuts against us if left unsaid. In commit–reveal the question "can the auctioneer exclude a rival's bid?" largely dissolves — by settlement every bid is public and anyone can recompute the result. That is a genuine answer. It is bought by publishing the bids. Keeping them sealed means the exclusion problem has to be solved rather than dissolved, which is what freezing the set before any reveal, posting the reveals on chain, and the auctioneer's bond are for.
Bidding on the public rail is the ordinary path. Connect a wallet, pick a level, sign. Nothing to install, nothing to fund in advance, and your bid is sealed — the amount is not in the bid's calldata, and it reaches the chain only after the seal, encrypted to the auctioneer.
The private rail additionally hides your address, by funding the bid from a shielded balance inside the STRK20 pool. It is the deeper integration and it is what the rest of this section is about. It also has a real cost of entry: you need a shielded balance before you can bid from one, this app currently sends you to your wallet to create it, and the pool charges its fee for the shield and again for the bid.
So, in one sentence: both rails seal your bid; the only difference is whether your address is publicly linked to having bid. If you are here to try the auction, take the public rail. If you are here to see the pool integration, the private rail is the one to read about.
| Set-up | Cost | Address | Bid | |
|---|---|---|---|---|
| Public | none | gas only | public | sealed |
| Private | shield in your wallet first | pool fee ×2 + gas | private | sealed |
| Sponsored private | none | free to the bidder | private | sealed |
Sponsored is costed and the pool supports it; no relayer is deployed, so the interface shows it and does not offer it.
These sound like they disagree and they do not, so it is worth stating both plainly in one place.
The app leads with the public rail because it is the one a visitor completes. There is nothing to install and nothing to fund in advance, and their bid is sealed. Leading with a rail that requires leaving the site, activating privacy in your own wallet and paying a fee twice would mean most people abandon a bid halfway — a worse outcome than a public address on a sealed bid.
The sprint counts the shielded rail because its rule is conjunctive: a qualifying
mainnet transaction must touch the STRK20 pool and carry an event from a contract we
deployed. Only pool → AuctionAnonymizer → SealedBidAuction does both. Public-rail bids
never touch the pool, so no number of them counts.
So the shielded rail is not the ambitious extra on this entry — it is the part that makes it scoreable, and the entry runs at least three of those transactions on mainnet. The public rail is the front door. Both are real, both seal the bid, and the honest way to describe the difference has not changed: only your address is treated differently.
The ranking proof is our own Poseidon construction, because it has to be — STRK20 exposes no custom-circuit slot (PHASE0.md Q2). Everything around it is the pool, and the value never leaves it:
1. Shielded notes are the value rail, in both directions. A bid is funded from shielded balance and escrowed by the pool itself. Every way value comes back — a loser's refund, the winner's surplus, a forfeited escrow redeemed late, the lot — comes back as an open note credited inside the pool. There is no public leg on the way out, so winning an auction does not put an address on-chain next to a price.
2. Our own anonymizer contract makes bid-and-escrow atomic.
packages/anonymizer implements privacy_invoke, which the pool reaches through
INVOKE_SELECTOR = selector!("privacy_invoke"). The bid leg is the deposit half of the
sandwich: the pool withdraws collateral to the helper, the helper forwards it into the
auction and returns an empty span — the protocol's way of saying "credit nothing",
because the funds are parked, not returned. A revert anywhere aborts the entire pool
transaction and no funds move. The claim legs run the sandwich the other way, measuring
output by balance delta rather than trusting a return value, and approving the pool
to pull rather than transferring. The helper pins the pool address in its constructor
and asserts the caller, because privacy_invoke is otherwise publicly callable.
3. The Wallet API is used as the SDK, not worked around. Actions are typed against
the wallet's own STRK20_ACTION union rather than a local look-alike. Capability is
detected with walletV6.supportedWalletApi and a version compare — never by probing
strk20Balances, which is a balance read wallets gate behind a consent prompt for data
this app has no reason to see. Every bid is dry-run through
strk20PrepareInvoke(actions, true) before submission. The pool fee is read live from
get_fee_amount and shown separately from the collateral, because the collateral comes
back and the fee does not.
No viewing key ever reaches this app. The wallet holds it and uses it on your behalf
— strk20PrepareInvoke is answered with the key on the wallet's side of the boundary,
which is how the private rail works without one being handed over. The single private
figure this app can see is your shielded balance, and only if you press the button that
asks for it: the wallet prompts, the answer is one whole-account number, it is never
scoped to an auction, it is never sent anywhere, and it is gone on reload. Nothing reads
it unprompted.
(The self-custody Privacy SDK route is not publicly installable —
@starkware-libs/starknet-privacy-sdk is not on npm. The Wallet API is the only route
a clean npm install reaches, so that is the one wired properly rather than
half-wired alongside a second.)
4. Protocol constraints shaped the design rather than being patched around.
| Constraint | What it forced |
|---|---|
| The pool→helper withdraw is a public ERC-20 transfer | Uniform cap collateral. Escrowing your bid would publish it |
| At most one external invoke per pool transaction | Bidding and claiming are separate transactions; no batching |
| Viewing keys are per-user and whole-history, not scopeable | The reveal path uses no viewing keys at all — a per-bid seed instead |
| Notes mature ~10 blocks; proving takes ~30s | The bid flow is built around the wait rather than appearing hung |
| Bidder identity never crosses the helper boundary | Bids are keyed by a claim commitment, never an address |
The integration-depth criterion names "the SDK" and "stealth accounts". We use neither, deliberately, and a judge working down that list should find the reasoning rather than a silence.
@starkware-libs/starknet-privacy-sdk is not published to the public npm registry.
Re-checked on 29 Aug 2026: that name and two plausible variants all return 404.
@starkware-libs/starknet-privacy-sdk 404
@starkware-libs/starknet-privacy 404
starknet-privacy 404
So the Wallet API is the only route a clean npm install reaches, which settles the
choice rather than leaving it a preference. What it means in practice is that "use the
SDK properly" becomes "use the Wallet API surface properly", and that is what
client/src/strk20.ts does: actions typed against the wallet's
own STRK20_ACTION union rather than a local look-alike, capability detected with
walletV6.supportedWalletApi and a version compare, and every bid dry-run through
strk20PrepareInvoke(actions, true) before it is submitted.
The encoding is checked against the live mainnet pool rather than against our reading
of it — npm run verify:pool runs our two real action shapes plus three deliberately
malformed controls through the pool's compile_actions view. The real ones fail on
state, the controls fail on shape. A check that cannot fail proves nothing, which is why
the controls are there.
A stealth account gives a recipient a fresh, unlinkable address to be paid at. That is the right tool when value has to land somewhere public.
Here it never does. Every way value comes back — a loser's refund, the winner's surplus,
a forfeited escrow redeemed late, the lot itself — is returned as an OpenNoteDeposit
credited inside the pool, by
AuctionAnonymizer::credit. There is
no recipient address in any of those paths, stealth or otherwise, because there is no
public leg to receive on.
Adding stealth accounts would mean introducing an address where the design currently has none. That is a step backwards from the property we are claiming, and the claim is the product.
An earlier version of this section said the Wallet API "has exactly three STRK20 methods and none of them deposits", and concluded that shielding can only happen inside a wallet's own interface.
The first half is true and the conclusion was wrong. There are three methods, but
strk20InvokeTransaction carries actions, and the action union in Wallet API 0.10.3
includes one:
export type STRK20_DEPOSIT_ACTION = {
type: 'deposit';
token: ADDRESS;
amount: FELT;
};Its own doc comment: "Deposits public funds from the user's account into the privacy
pool." Checked against @starknet-io/types-js@0.10.3, which is the latest published
version — the spec has not moved since we pinned it.
Submitted to the live mainnet pool read-only, a deposit action is accepted in sequence:
deposit → withdraw → invoke fails on NO_REPLAY_PROTECTION, not on
ACTIONS_OUT_OF_ORDER, so the ordering is legal and only the missing note-spend stops
it. What that does not establish is that a freshly deposited note is immediately
spendable — deposits appear to need to mature before they can fund a withdraw, so
shielding and bidding in one transaction is likely still out of reach.
The practical consequence is narrower than it first looks, and worth stating rather than quietly fixing: shielding does not have to send the user to their wallet's UI. This app currently does, which is a product decision we could revisit, not a platform limit.
Escrow the bid and the bid is public — the pool→helper leg is a plain ERC-20 transfer. Commit to the bid instead and nothing is actually locked, so a bidder can commit to a price they cannot pay. Publish the bids at reveal time and every loser's valuation is on-chain forever. Ask the bidders to reveal and any one of them can grief the auction by going quiet.
Bids are levels on a public price ladder: price(ℓ) = reserve + ℓ · tick, for
ℓ ∈ [0, P). Level 0 is the reserve, so bidding at all means bidding at least the
reserve.
While bidding is open, each bidder publishes two hash-chain anchors and nothing else:
step(x) = poseidon([CHAIN_TAG, auction_id, claim_commitment, x])
up_anchor = step^( ℓ ) a depth-t preimage proves ℓ ≥ t
down_anchor = step^(P-1-ℓ) a depth-(P-1-t) preimage proves ℓ ≤ t
Producing a witness for a bound you did not commit to is a Poseidon preimage break.
Producing one for a bound you did commit to reveals only that bound — never ℓ.
At clearing level ℓ*, Vickrey needs exactly N+1 witnesses:
- the winner proves
ℓ ≥ ℓ* - the runner-up proves
ℓ ≥ ℓ*andℓ ≤ ℓ*, pinning them at exactlyℓ* - everyone else proves
ℓ ≤ ℓ*
The second-highest bid is then ≥ ℓ* (the runner-up is there) and ≤ ℓ* (everyone
but the winner is at most there), so it is ℓ*. The Vickrey price, proved, without
opening a single bid.
First-price is the same shape with the winner pinned instead of the runner-up — their bid is the price, so it necessarily becomes public. The losers' still do not.
Every bidder escrows the same amount: the price at the top of the ladder. Uniform collateral is the only escrow that satisfies "real locked funds" and "the bid stays secret" at once. The winner pays the clearing price out of it and the surplus comes back; losers get theirs back in full. On the private rail both come back as private notes, once the pool clears the helper (see Status). The cost is capital efficiency — you lock the cap, not your bid — and that is an honest trade, not a hidden one.
After the Sealed event, each bid's seed and level are posted on chain with
post_reveal, encrypted to a key the auctioneer published with this auction alone — by
the app's relay, or by the bidder. post_reveal reverts before the seal, so no amount
can be read while the set can still change.
A bid whose reveal is not posted by the reveal deadline leaves the auctioneer nothing to
disposition it with, so it is marked Forfeit and excluded from the ranking. Settlement always completes. What the
bidder gets back depends on where their bid sat: at or below the clearing price,
redeem_forfeit returns the escrow after finalization, from a loser-side proof built
from the bid's seed; above it, redeem_forfeit cannot return it.
That mechanism could be abused to exclude an honest high bid, so settlement moves no
money. It opens a dispute window. A bid marked Forfeit whose reveal was posted in
time and opens to both of its anchors, and which sat above the clearing price (or the
settlement named no winner), voids the settlement; the auctioneer's bond is added to
its escrow. finalize releases funds only after the window closes clean.
What the bond covers, and a correction. The bond answers for a dishonest settlement and for walking away.
abandonforfeits it to the bidders, paid pro-rata throughcollect, andcreate_auctionrequirestick ≤ bond ≤ cap.It did not always. Until 30 Aug 2026 the bond was pulled from the seller and returned to the seller by
abandon, and no minimum was enforced — so where one address was both seller and auctioneer, the auctioneer could seal, read the outcome off-chain, abandon if the price disappointed, and re-list having lost only gas. That was cheaper than the exclusion attack the bond was built for, and this file claimed a protection stronger than the contract provided. Found by audit rather than by testing; the fix, the reasoning and the rejected alternatives are in docs/proposal-bond.md, and three tests intest_negative.caironow assert that discarding an outcome costs the bond.
| Public | Private |
|---|---|
| The auction, its ladder and deadlines | Every bid amount, winner's included — on chain only as a ciphertext to the auction's key |
| Each bid's encrypted reveal, after the seal | |
| That a bid arrived, and when | Which address bid — bids are keyed by a claim commitment, never an address |
| The number of bids | Any losing bid, ever |
| The uniform escrow amount | |
| The clearing price and winning index |
Settlement moves no money. It records the outcome and opens a window in which a bid it
left out — one whose reveal was posted in time and that beat the clearing price — can
void the result and take the auctioneer's bond. finalize releases funds only after it
closes clean. The reveal window before it is set and shown the same way.
The length is a parameter fixed at listing and public on-chain, and the contract enforces no minimum. That is deliberate: any floor short enough to demo an auction end-to-end would be far too short for real value, so a floor would buy nothing and imply a safety it could not deliver. Instead the value is visible before anyone bids, and a bidder who thinks it is too short can decline.
| Preset | Seconds | When |
|---|---|---|
demo |
180 | Nominal amounts, bidders in the room. Demo only |
supervised |
3,600 | A staffed auction where participants are actively watching |
suggested |
86,400 | Real value. Assumes nobody was watching |
Defined in ladder.cairo as DEMO_DISPUTE_WINDOW / SUGGESTED_DISPUTE_WINDOW, and in
the client as DISPUTE_WINDOW, with disputeWindowAdvice() returning the plain-language
read a listing UI should show. The demo runs at 180 seconds. Nothing carrying real
value should.
packages/auction/ the auction contract, the ladder, on-chain reveals, 139 tests
packages/anonymizer/ the privacy_invoke helper, 14 tests
client/ bid crypto, reveal encryption, settlement planning, 66 tests
web/ create / bid / reveal / settle / collect / deliver, 160 tests. No login.
PHASE0.md the investigation this design came out of
TRUST.md the long-form trust statement
Requires the toolchain in .tool-versions (scarb 2.14.0, starknet-foundry 0.53.0) and
Node 24.
scarb build && snforge test # 153 Cairo tests across both packages
cd client && npm install && npm test # 66 client tests
cd client && npm run verify:pool # our calldata vs the live Sepolia pool
cd web && npm install && npm run buildDeploying, once you have a funded sncast account:
scripts/deploy.sh <sepolia|mainnet> <account-name>It runs the whole suite before it touches a network.
There are no external Cairo dependencies. scarb build works on a clean machine
with no git fetches.
From packages/auction/tests/test_benchmark.cairo, at N=10 bids on a P=256 ladder,
subtracting the baseline_ten_bids_without_settling scenario:
| Scenario | settle alone |
|---|---|
| Clearing at level 150 of 256 | ~32.3M l2 gas |
| Clearing at level 0 (worst case) | ~38.1M l2 gas |
These are snforge estimates, not mainnet measurements. See PHASE0.md Q8.
PHASE0.md is the investigation that produced it, written before the code.
The short version: custom circuits are not available on STRK20 — proving runs a
fixed program (VIRTUAL_SNOS) over a virtual Starknet execution, with a closed set of
proof facts and no application circuit slot. That killed the original plan's Tier A.
It turned out not to matter. The ranking proof does not need the pool's proof system at all; it needs Poseidon and a contract of our own. The result is stronger than the Tier B that was the fallback: properties 3, 4, 5 and 6 are cryptographically enforced rather than procedurally asserted.
MIT. See LICENSE.
