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.
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.
Submissions close 31 Aug 2026, 23:59 UTC. Whatever this repository shows at that moment is the entry — there is no separate submission step.
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 alter the outcome, exclude a bid, or misreport the price without failing a proof or being slashed in the dispute window. What is not: after sealing, the auctioneer learns every bid amount — it can never publish them, prove a false outcome, or spend anyone's funds, but it knows them; 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.
Nothing is on mainnet yet, and mainnet is the deliverable. There is a full Sepolia rehearsal — contracts deployed and a complete ten-transaction auction run, see docs/deployments.md — and the site is hosted at https://vickrey.0xo.in, currently pointed at that rehearsal. The mainnet fields in strk20.json stay empty until they are real.
Mainnet cost is measured rather than estimated: docs/mainnet.md.
| Piece | State |
|---|---|
| Auction contract | Written, 51 tests passing, not deployed, not audited |
| Anonymizer helper | Written, 7 tests passing, not deployed, not audited |
| Client library | Written, 32 tests passing, hash-conformant with Cairo, action shapes type-checked against the wallet's own types |
| Web app | Reads and writes the contracts. Design under review — the current UI is a working prototype, not the shipped design |
| Sepolia rehearsal | Done. Contracts deployed, one complete auction, 10 transactions |
| Hosted demo | Done, pointed at Sepolia. Mainnet is three env vars away |
| Mainnet deployment | Not done — needs ~40 STRK, itemised in docs/mainnet.md |
| Mainnet run with ≥5 bidders | Not done |
| A wallet-signed bid through the pool | Not done — the one step needing a human |
| Demo video | Not made |
| Visual direction | Second pass under review — the shipped UI is the previous direction |
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.
- No wallet has assembled, proven and submitted one of these. That is the remaining gap and the only one needing a human at a browser.
- The helper has never run, because nothing is deployed.
- Mainnet gas for
settle, measured rather than estimated.
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, the winner's included. Silence forfeits collateral and settlement proceeds without it.
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 and the auctioneer's slashable 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 never in the calldata and never reaches the chain.
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.
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 refunds as a private note; losers refund in full, privately. The cost is capital efficiency — you lock the cap, not your bid — and that is an honest trade, not a hidden one.
Bidders transmit their seed to the auctioneer only after observing the Sealed
event. The auctioneer cannot decrypt early because it has not been sent anything.
A bidder who then goes quiet leaves a bid the auctioneer cannot disposition, so it is
marked Forfeit: excluded from the ranking, escrow retained but redeemable by its
owner forever with a loser-side proof they can generate whenever they come back.
Settlement always completes.
That mechanism could be abused to exclude an honest high bid, so settlement moves no
money. It opens a dispute window. A forfeited bidder who proves ℓ ≥ ℓ*+1 voids
the settlement and takes the auctioneer's bond. 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 throughclaim_refund, 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 |
| 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
forfeited bidder can prove they were above the clearing price, void the result and take
the auctioneer's bond. finalize releases funds only after it closes clean.
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, 51 tests
packages/anonymizer/ the privacy_invoke helper, 7 tests
client/ bid crypto, settlement planning, action building, 32 tests
web/ create / bid / settle / result. 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 # 58 Cairo tests
cd client && npm install && npm test # 32 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 <account-name> <privacy-pool-address>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.
