Status: updated in phase 3 (clock and committee modes implemented). Lists the six security invariants from CLAUDE.md and how each will be tested. No invariant may regress. A phase that touches an invariant is not done until its tests below exist and pass.
Assets
- Bid values and salts of every bidder (primary secret).
- Participation: the fact that a given party bid at all.
- Deposits and fees.
- Result integrity: that the published winner and price are correct.
- Committee private key (committee mode).
Adversaries
| Adversary | Can | Goal |
|---|---|---|
| Public observer | Read chain, indexer, API, UI | Learn bids or who bid |
| Other bidder | Above, plus submit transactions, time them | Learn rival bids; win unfairly; change own bid |
| Tender owner | Above, plus set parameters; before renunciation, the contract's maintenance key | Favour a bidder; learn bids; change the rules mid-tender |
| Committee (committee mode) | Decrypt all bids; generate revealWinner | Publish a wrong winner; drop a bid |
| Waxseal operator | Run apps/api, web hosting, logs | Learn bids or participation |
| Compromised dependency | Code execution in web/SDK | Exfiltrate witnesses |
Out of scope for now: a compromised bidder machine, and breaking the underlying cryptography.
Invariants and how each is tested
Test levels: C = circuit test on the compiled contract (packages/contracts, vitest + compact-runtime); Z = proof-level check (compile with keys, prove/verify, or /midnight-verify ZKIR checks); E = end-to-end against the local devnet; S = static/CI check; A = API integration test against Compose Postgres.
I1. A bid cannot be changed after commitment
Mechanism: commitment = hash(bid, salt, bidderId) is written once per bidder per tender. Every later circuit that uses the bid recomputes the hash from the witness and asserts equality with the stored commitment.
Tests:
- C: a second
commitBidfrom the same bidder for the same tender is rejected. - C:
claimAtPrice/ refund / not-won proof with a (bid', salt) where bid' ≠ bid fails the commitment check; same for (bid, salt'). - C: bidderId is bound. Bidder B cannot claim using bidder A's commitment even with A's (bid, salt).
- Z: the hash-equality constraint exists in the compiled ZKIR of every bid-consuming circuit (guards against a compiler or disclose() mistake removing it).
I2. A published winner has the best bid against every commitment on chain
Tests:
- Clock mode, C: a claim with bid above the current price (lowest-wins rule) or below it (highest-wins rule) is rejected. After the first valid claim in a round the contract is locked and further claims fail. The clock cannot run past the bid range.
- Clock mode, E: several bidders on the devnet, the correct bidder wins and the round ordering is correct.
- Committee mode, C:
revealWinnerrejects a k whose bid is not best. Property test over random bid vectors, including ties (tie-break rule to be specified before phase 3). - Z: the "best" comparison constraints exist for all N inputs.
I3. A committee cannot omit a commitment; the circuit consumes exactly N
Tests:
- C:
revealWinnerfails if the witness has fewer or more than the on-chain commitment count, or reorders commitments against the chain's order. - C: it fails if any (bid_i, salt_i) does not hash to on-chain commitment i.
- C: it fails if a commitment was dropped and a duplicate was substituted.
- C: batching for tenders larger than N covers every commitment exactly once.
I4. Deposits and fees are shielded; participation is not inferable from wallet activity
Tests:
- E: after
commitBidon the devnet, inspect the transaction through the indexer and assert that no bidder-identifying unshielded address or amount is present. - E: the deposit uses shielded (Zswap) coins; the refund and bond paths also stay shielded.
- Deposit token on test networks. NIGHT is an unshielded token and cannot be shielded (Midnight docs, "Tokens on Midnight"), so a shielded deposit needs a shielded token type. On Preprod, tenders use the
WaxsealTestDepositfaucet token (contractpackages/contracts/src/waxseal-test-deposit.compact, address inpackages/sdk/deployments/test-deposit.preprod.json): anyone can claim 10,000 units, the coin is minted and delivered to the caller's shielded key in one transaction, andcommitBidWithDepositreceives a fresh coin of that colour funded by the bidder's wallet (LacebalanceUnsealedTransaction). Verified end to end on the local devnet with real proofs (pnpm deposit:e2e: two deposits locked, loser refunded, winner bond kept). The test token has no value; mainnet will use a shielded stablecoin. - Review item (open risk): transaction fees are paid in DUST, which is generated from registered NIGHT. We must confirm whether fee payment links a transaction to a bidder's NIGHT address. To research with
/midnight-verifybefore phase 1 ends and record indocs/BLOCKERS.mdif it cannot be solved. - Known limit (third review, H1, 2026-09-30): refunds can be linked to a bidder's shielded address by anyone who knows that address. A contract-held deposit coin is public ledger state (
deposits: Map<Bytes<32>, QualifiedShieldedCoinInfo>), and the stdlib'ssendShieldedderives the refund output's nonce deterministically from that coin's nonce (verified in the compiled runtime,nonce_evolve). The output commitment binds the recipient's coin public key, so a party holding a list of candidate shielded addresses (rivals, a supplier directory, past invoices) can test each against the refund transaction and confirm "this wallet bid in this tender". The same applies to the bond and to faucet claims. What it does not reveal: bid values, or anything to someone without the candidate address. I4 therefore holds against the public, not against a party who already knows a bidder's shielded address. Fix planned for the next circuit release: build refund outputs with a witness-derived nonce (createZswapOutput) so the commitment cannot be recomputed from public state (docs/BLOCKERS.md).
I5. Losing bids are never revealed on chain, in logs, the indexer, the API, or the UI
Tests:
- C: no circuit discloses a bid or salt except the winner's price where the rule says so. Every
disclose()in contract source is listed and justified in review. - Z: public transcript / public inputs of each circuit contain no bid or salt value (assert against known test witnesses).
- S: CI grep fails on
bidorsaltin anylogger.*,console.*, analytics or error message inpackages/sdk,apps/web,apps/api. - A: API schema and migrations contain no column able to hold a bid or salt; request handlers reject bodies that contain them.
- E: after a full auction on the devnet, search indexer data, API responses and server logs for every losing bid's known value and salt: no match.
- UI: the "you did not win" proof shows the result only, never other bidders' data.
I6. Witnesses (bid, salt) never leave the bidder's machine
Tests:
- S: all witness-taking SDK functions live under
packages/sdk/src/private/. A lint rule forbids importing them fromapps/apior any server code. - E (browser): during commit and claim flows, record all network requests. The only requests carrying proof inputs go to the configured proof server, and that URL must be loopback or user-configured. No private value appears in a URL, cookie, local analytics event or React DevTools-visible global.
- UI: a visible warning whenever the proof server is the dev/testnet Compose one.
- Ops: Waxseal never deploys a shared proof server for real bids.
- In-browser proving (default in the web app). The ledger's WebAssembly prover (
@midnight-ntwrk/zkir-v2, the same code the wallet SDK uses) runs in a Web Worker on the bidder's machine; the witness-bearing preimage never leaves the browser. Only public key material is fetched, from waxseal.xyz itself (/zk/<build>/<contract>/for our circuits, versioned per build so cached keys of an older contract are never reused,/zk/shared/for the ledger's zswap/dust circuits and KZG parameters, mirrored from the Midnight file share at build time). Verified end to end on the local devnet (pnpm wasm:e2e): WASM-generated proofs forclaim,commitBidWithDeposit,midnight/zswap/outputandmidnight/zswap/spendwere accepted by the node. Cost: about 70–80 s per contract circuit and 30–150 s per zswap circuit on one CPU core (independent proofs run in parallel workers). The wallet still proves its own inputs (fees, coin spends) with the prover it is configured with; Lace needs a proof server for that part, wallets with built-in proving (1AM) do not.
Contract immutability (maintenance authority)
Found 2026-09-25 while building the independent verifier. midnight-js deploys every contract with a maintenance authority of one key (sampled at deploy, stored in the deployer's private-state store), threshold 1. Its holder can replace the contract's verifier keys at any time, i.e. change the circuits and so the rules: pick a winner, redirect deposits. That breaks I2 and deposit safety for anyone who does not trust the organiser. docs/MAINNET_READINESS.md previously stated that the contracts were not upgradeable; that was wrong.
- Fix.
createTender,createCommitteeTenderanddeployTestDepositcallrenounceMaintenanceright after deploying: a maintenance update, signed by the deployer key, replaces the authority with one that has no keys and threshold 1. The ledger then accepts no further maintenance update from anyone. The deployer key is then deleted from the vault. Code:packages/sdk/src/maintenance.ts. The web app runs the renunciation as its own step after registering the tender, so a wallet that rejects it leaves a live, registered tender; the tender page then shows "Rules not frozen" to everyone and a "Freeze the rules now" button to the vault that holds the deployer key. - Tests.
pnpm immutability:e2e(local devnet): after renouncing, a maintenance update signed with the old deployer key is rejected by the ledger, and bids still work.pnpm deposit:e2easserts the verifier reports the tender immutable.pnpm wasm:e2eruns the web app's separate freeze step with the WebAssembly prover the browser uses. - Existing Preprod tenders (#1
dcc24b34…, #1b139c1ada…, #2ddba829c…, #3c5618713…) were renounced on 2026-09-25 withscripts/preprod-renounce.ts; their history shows the renunciation, and that the verifier keys never changed. - Limit. The tSEAL faucet (
dab4f623…) predates the fix. Its deployer key was stored under a one-time random password that was not kept, so it cannot be used, but that cannot be proven on chain. It only mints a valueless test token. - Window. Between the deploy and the renunciation (a few blocks apart in tests) the deployer could act; the verifier checks every recorded state, so any change in that window would show as "rules changed".
Independent verification and conformance
packages/sdk/src/verify/reads a tender's full history from a Midnight indexer (contractActionssubscription) and checks: verifier keys against the append-only release manifest (packages/contracts/releases.json), maintenance authority renounced, keys never changed, no commitment replaced, commitments before the deadline, count consistency, result consistent with the rules, deposit book-keeping, metadata hash. Web/verify, CLIscripts/verify-tender.ts.- Trust in the indexer. The verifier believes the indexer it reads. A dishonest indexer could present a false history. For disputes, run your own node and indexer, or compare two independent indexers; the report records which indexer and block it used.
docs/COMMITMENT_FORMAT.mdspecifies every derived value; the compiled circuits, the SDK and a standard-library Python implementation are checked againstconformance/vectors.jsonin CI, with deliberately wrong implementations that must fail.- Independent time stamps (RFC 3161). The API time-stamps each tender's state after its latest transaction with DigiCert and Sectigo (
apps/api/src/anchors.ts); the verifier checks each token (CMS signature, ESS binding,id-kp-timeStampingkey, chain to pinned roots valid at the signed time) and that the statement equals the chain state (packages/sdk/src/timestamp/,verify/anchor.ts). Assumptions and limits: a time stamp is as trustworthy as its authority, which is why two independent ones are used; the operator cannot forge or backdate one but could omit one, and anyone can make their own (scripts/anchor-tender.ts); authorities see only a SHA-256 digest of public data; revocation is not checked by the SDK verifier (OpenSSL-crl_checkcan). The DER and CMS parsing is Waxseal's own code, so it is in the audit scope and is cross-checked against OpenSSL in tests. Tests:packages/sdk/test/timestamp.test.ts(real responses, tampered time, signature, imprint, nonce, untrusted root, Buffer views), API integration tests (never stores a token that does not verify), a tampered statement on Preprod makes the verifier fail. - Voluntary disclosure. A bidder can export an opening (bid and salt) from their vault to show a person of their choosing. It is created on the device, never sent by the software, and the UI requires an explicit confirmation. This does not weaken I5/I6: the system still never discloses a bid; only the bidder can.
Clock-mode guarantees and limits (as implemented, phase 2)
- One-step bound (I2). A bid is claimable only in its own round, i.e. the first round whose price reaches it (
price(r-1) < bid <= price(r)for lowest-wins). So the winner's bid is within one clock step of the best committed bid, provided the best bidder claims. The winning price is the round price, not the bid itself. - Silence is penalised, not prevented. A better bidder who does not claim cannot refund: claimRefund requires proving the bid was not reached before the winning round. After the refund deadline the owner may sweep it. This backs I2 economically only when the deposit is meaningful. A zero-deposit clock tender gives no I2 guarantee, because a bidder can commit several bids under different secrets and claim the one that suits them. The UI must warn when the deposit is 0 (review M3).
- Refund pattern leaks one bit (review L2). Every loser who refunds proves "my bid was not in an earlier round than the winner's". A deposit left unrefunded therefore suggests that its bidder's bid was better than
price(winningRound-1). This is inherent to the forfeit rule. - Same-round ties. Several bidders can qualify in the same round; the first included claim wins. Tie-break is transaction ordering. Same-round runners-up refund normally.
- Owner linkability (review L3). Eligibility keys are scoped per organiser, so a bidder cannot be linked across organisers. The owner id is the same across all of an organiser's tenders, by design (organisers are public).
- Deadlines are bounded (close time < 2^34 s, ≤ 100,000 rounds, 30 s–7 days per round, refund window 1 h–1 year) so no deadline arithmetic can overflow and lock deposits (review H1).
Committee-mode guarantees and limits (as implemented, phase 3)
Contract: packages/contracts/src/waxseal-committee.compact (security-reviewed; fixes H1, H2, M1, M2 and L1 applied and tested).
- Proof of correct encryption. Ciphertext and commitment are computed in the same circuit from the same (bid, salt). Encryption is hashed ElGamal on Jubjub with a domain-tagged keystream. The committee key must be non-identity and in the prime-order subgroup (checked at deploy; the runtime also refuses small-order points). The encryption randomness must give a non-identity
R. - I3 exactly-N.
revealWinnerwalks the on-chain insertion order (commitmentIndex), so it consumes every commitment exactly once. Openings cannot be reordered, so no ranking of the losing bids leaks (review M1). - Ties go to the earliest bid, deterministically (review M2).
- Deposits. The owner can only take the winner's bond. Losers' deposits can be refunded at any time and can never be swept (review H2).
- Trust assumptions (not fixable in code; review M3/M5): the committee can read every bid as soon as it is committed, and could leak it to a favoured late bidder. The committee can also let the tender expire instead of revealing. Mitigations: split the committee key t-of-n (
splitCommitteeKey,apps/committee), which reduces the risk of a single insider. Reputation and a committee bond are future work. Key reconstruction happens on one machine at reveal time; this is not threshold decryption. - Capacity / griefing (review M4, sharpened by the third review, H2): the tender holds at most 16 bids, the committee contract has no allow list, and every non-winner deposit is refundable, so filling the slots costs an attacker only fees and a temporary lock-up. Until the allow list is ported to the committee contract (next circuit release), run committee tenders with invited suppliers and a private link; the create form says so.
- Toolchain coupling (review L3): the keystream uses
transientHash, so the committee app must be built with the same pinned compiler (0.31.1) as the contract.
Sign-in and profiles (phase 4 web)
- A profile is keyed by a signed-in subject (wallet address or vault verifying key) and holds only self-published data: name, organisation, sectors, credentials as document hashes. The API schema has no column for bids or participation.
- Participation history is computed on the bidder's device by checking the local vault against the public tender list; it is never uploaded (I4). The public profile page states that bids and participation are not part of a profile.
- Testnet points and badges are computed on the device (vault + public tender list). They reach the API only if the user opts in, and then only as badge ids and a total; never tender addresses or bid values. They are labelled self-reported on public profiles.
- Sign-in messages contain a server nonce (single use, 10 min), the domain and timestamps; a signature proves key control only and cannot be replayed as a transaction.
Third internal review (2026-09-30)
A third adversarial review (compact-core:security-reviewer) of the three contracts and the SDK's private code, after the immutability and verification work. No critical finding. Status of each finding:
| Finding | Severity | Status |
|---|---|---|
H1 Refund/bond outputs linkable to a known shielded address (public deposit coin, deterministic sendShielded nonce) | High | Documented under I4; circuit fix (witness-derived output nonce) queued in docs/BLOCKERS.md |
| H2 Committee mode has no allow list and deposits are fully refundable, so 16 slots can be squatted cheaply | High | Mitigated in the UI and playbook (invited suppliers); allow list port queued |
| H3 Rounds can be shorter than the time to prove and include a claim (contract minimum 30 s; web allowed 1 min), so an honest best bidder can miss their only round and forfeit | High | Fixed in the web (minimum and default 10 minutes, proving time shown); contract minimum of 600 s and a one-round grace queued |
| M1 Refund window can be as short as 1 h, after which the organiser sweeps unclaimed deposits | Medium | Web and SDK default 7 days and do not expose a shorter value; 30-day contract minimum queued |
| M2 SDK deleted the local salt after an ambiguous submit error, which could orphan a landed commitment | Medium | Fixed: the bid is kept and marked unconfirmed; a retry may replace it only while the chain shows no commitment |
| L1 Faucet claims linkable as in H1; unlimited test minting makes deposit deterrence meaningless on testnets | Low | Documented; test tokens only |
| L2 Eligibility leaf is organiser-scoped, so invitee overlap across an organiser's tenders is visible | Low | Documented (waxseal:eligibility:v2 design choice); per-tender leaf queued |
| L3 Committee ciphertexts are permanent: a later key compromise opens every past losing bid | Low | Documented; per-tender committee keys recommended in the playbook |
| L4 Without an allow list, several bids from one party buy an "option" even with a deposit | Low | Documented (was stated only for zero-deposit tenders) |
Also queued: assert deposit == 0 || color != 0 in both constructors, and a test that a duplicate deposit coin (same public nonce) is rejected by the ledger (CommitmentAlreadyPresent).
Known open questions
- I4 fee linkability. See above; and the refund linkability limit (third review H1).
- Clock-mode front-running. Can a claim be observed in the mempool and copied or censored? Each claim is bound to the claimant's own commitment (I1), so copying fails. Censorship and ordering still need analysis.
- Committee-mode encryption proof. A proof that the ciphertext matches the commitment is required. Without it the committee model is broken. We still need to check which encryption can be proven efficiently in Compact.
- Tie-breaking in both modes must be specified before implementation.
Source: docs/THREAT_MODEL.md in the Waxseal repository.