Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

offer — sealed-bid auction

audience: contributors

Proposed source: crates/offer/.

The sealed-bid auction organism. Searchers submit bundle bids per slot, threshold-encrypted to the offer committee’s DKG- produced public key. At auction close the committee runs a threshold combine inside apply to decrypt bids, picks a winner according to the lattice’s auction rule, and commits a single AuctionOutcome entry for the slot. No committee member — and no searcher other than the winner — ever sees a losing bid’s cleartext.

Crate layout

  • offer::proto — wire types, serialization of sealed bids and auction outcomes. No I/O.
  • offer::core — pure functions for sealing, combine-decrypt at close time, and winner selection.
  • offer::nodeOfferMachine, declare! items, role event loops, ticket validators.

Public facade:

  • offer::Offer::<B>::bid(&network, &Config) -> Bidder<B> — searcher-side writer of sealed bids.
  • offer::Offer::<B>::outcomes(&network, &Config) -> Outcomes<B> — stream of committed AuctionOutcomes.
  • offer::Config — const-constructible fingerprint input.

B: BundleDatum is a trait searchers implement for their bundle type (analogous to zipnet’s ShuffleDatum). Carries TYPE_TAG: UniqueId and MAX_BID_WIRE_SIZE: usize — bids are ciphertexts of bounded size to preserve threshold- encryption properties at the wire layer. See constant size argument.

Public surface

Bid<B> stream

declare::stream! {
    pub Bid<B: BundleDatum> = SealedBid<B>,
    derive_id: OFFER_ROOT.derive("bid"),
    producer require_ticket: SearcherJwt,
    consumer require_ticket: OfferMember,
}

pub struct SealedBid<B: BundleDatum> {
    pub nonce:       [u8; 24],         // unique per (searcher, slot)
    pub slot:        u64,
    pub searcher:    SearcherId,       // from the JWT
    pub ciphertext:  Vec<u8>,          // threshold-encrypted bundle + bid
    pub _phantom:    PhantomData<B>,
}

The searcher field is authenticated by the writer-side JWT and not encrypted — this is what gets paired with AuctionOutcome for attribution at refund time. ciphertext decrypts to a Bundle<B> struct that carries the bid value, the bundle contents, and the UnsealedRef dependency:

pub struct Bundle<B: BundleDatum> {
    pub bid:         u128,              // in chain native units
    pub payload:     B,                  // application-level bundle
    pub depends_on:  Option<UnsealedRef>, // reference into unseal::UnsealedPool
}

AuctionOutcome collection

declare::collection! {
    pub AuctionOutcome = Vec<CommittedOutcome>,
    derive_id: OFFER_ROOT.derive("outcome"),
    consumer require_ticket: LATTICE_READ_TICKET,
    writer   require_ticket: OfferMember,
}

pub struct CommittedOutcome {
    pub slot:       u64,
    pub winner:     SearcherId,
    pub bid:        u128,
    pub bundle:     EncodedBundle,   // cleartext of winning bundle only
    pub evidence:   CommitEvidence,  // hashes of losing-bid ciphertexts
}

bundle is cleartext (the winner has no anonymity to preserve against the builder — they want their txs included). evidence carries hashes of every losing bid’s ciphertext so that a post-hoc observer can check the committee considered all bids without needing the losing plaintexts.

SearcherRegistry

declare::collection! {
    pub SearcherRegistry = Map<SearcherId, SearcherBundle>,
    derive_id: OFFER_ROOT.derive("searcher-registry"),
    consumer require_ticket: LATTICE_READ_TICKET,
    writer   require_ticket: OfferMember,
}

Maintained by the committee; entries land when a searcher’s JWT authenticates against a new SearcherId.

Internal plumbing

Derived private network keyed off OFFER_ROOT.derive("private"):

  • DecryptShares — per-slot threshold-decryption shares exchanged at auction close. Same shape as unseal share gossip.

State machine

pub enum Command {
    OpenAuction { slot: u64, opened_at: UnixSecs },
    AcceptBid(BidAccept),
    SubmitShare(DecryptShare),
    CloseAuction(u64),
    MarkDone(u64),
}

pub enum Query {
    OpenAuctions,
    BidsFor(u64),
    OutcomeFor(u64),
    Searcher(SearcherId),
}

Apply semantics

  • OpenAuction. Issued by the state-machine leader when unseal::UnsealedPool[S] is observed. Creates an open auction window for slot S with the configured auction_window. Idempotent per slot.
  • AcceptBid. Committed when a committee member observes a fresh SealedBid on the public Bid<B> stream and proposes its inclusion in slot S’s auction. Validation:
    • slot matches an open auction.
    • nonce is unique for this (searcher, slot) pair — duplicate nonces reject.
    • The ciphertext length is within B::MAX_BID_WIRE_SIZE.
    • searcher is admitted by the searcher JWT. First-committee-member-to-propose wins; duplicates are silent.
  • SubmitShare. Committee member’s threshold share for auction-close decryption of one specific bid ciphertext. Shares are indexed by (slot, bid_hash). Same validation shape as unseal::SubmitShare.
  • CloseAuction. Idempotent per slot. When the leader observes the auction window elapsed, it issues this command. The apply handler:
    1. Collects every AcceptBid for the slot.
    2. For each bid, if t shares have landed, combines them and decrypts the bid.
    3. If a bid’s shares are insufficient, its ciphertext is considered non-admissible and is excluded.
    4. Among the admissible bids, picks the winner by the lattice’s auction rule (default: highest bid, with deterministic tie-break by blake3 of the bid ciphertext).
    5. Constructs CommittedOutcome and appends to AuctionOutcome.
  • MarkDone. GC after the slot is fully attributed by downstream tally.

Invariants

  1. One outcome per slot. Enforced in CloseAuction by reject-if-already-closed.
  2. Monotonic slots. AuctionOutcome[S+1] cannot commit before AuctionOutcome[S].
  3. Losing bids stay encrypted. CloseAuction apply decrypts only the winning bid; losing-bid shares are discarded post-close without their cleartext ever leaving apply.
  4. Bid admissibility is a pure function of the commit log. A replica re-applying the same commit sequence reaches the same winner.

Signature versioning

fn signature(&self) -> UniqueId {
    let tag = format!(
        "offer.v{WIRE_VERSION}.window={}ms.t={}.n={}.scheme={}.rule={}",
        self.config.auction_window.as_millis(),
        self.config.threshold.t,
        self.config.threshold.n,
        self.config.scheme,
        self.config.auction_rule.tag(),  // "highest-bid", etc.
    );
    UniqueId::from(tag.as_str())
}

Every knob folds in. A lattice swapping auction rule from highest-bid to highest-profit-by-sim is a fingerprint change.

DKG ceremony

Separate from unseal’s. Offer’s DKG produces an aggregate public key whose secret shares are held by the offer committee. Integrators (searchers) encrypt bids to this key before submitting.

Rerun independently from unseal DKG — a compromised offer-committee member does not force an unseal rerun.

ACL composition

impl Config {
    pub fn bid_validator(&self) -> Validators {
        // Writers: searchers authenticated by the operator's searcher JWT.
        Validators::stacked().with(JwtIssuer::from(self.searcher_jwt_key))
    }

    pub fn member_validator(&self) -> Validators {
        // Committee members: lattice-level JWT, optional TDX.
        let mut v = Validators::stacked().with(JwtIssuer::from(self.operator_jwt_key));
        if let Some(mrtd) = self.member_mrtd {
            v = v.with(Tdx::new().require_mrtd(mrtd));
        }
        v
    }
}

TDX on offer members is optional in v1. A lattice prioritising bid confidentiality under a stronger trust model adds TDX; most lattices run it with JWT-only admission.

Trust shape

Majority-honest committee for winner integrity; threshold cryptography for bid confidentiality against a minority of compromised members. See threat-model.md — offer.

Open questions

  • Auction rule extensibility. The default rule is highest-bid. Some lattices will want “highest-profit after simulation against the unsealed pool”, which requires running simulation inside offer’s committee. That is conceptually a cross-organism call between offer and atelier; the clean pattern is to have offer commit a PendingOutcome with the top-K bids and let atelier compute the actual profit. Not specified yet.
  • Withdrawal semantics. Searchers may want to withdraw a bid once the slot’s UnsealedPool is revealed (if the revealed order flow makes the bid uneconomical). The BundleWithdraw command is in the spec but withdrawal deadlines vs auction close need tightening.
  • Cross-chain bids. A bid targeting ethereum.mainnet slot S1 and unichain.mainnet slot S2 is a single atomic desire from the searcher’s perspective but two independent AuctionOutcome commits. Out of scope here; see cross-chain — Shape 3.

Cross-references