Skip to main content

Triggering auctions

This page is for anyone running infrastructure that closes out auctions: the SealedIP orchestrator, a third-party relayer, or a bidder self-settling.

The two-phase reveal model

SealedIP uses a two-gate reveal condition. Neither gate alone opens the CDR vault:

  1. block.timestamp >= deadline (the clock gate)
  2. SealedAuction.isTriggered(auctionId) == true (the trigger gate)

Before trigger, bid ciphertexts and the sealed reserve are locked regardless of the deadline. After someone calls trigger() AND the deadline has elapsed, the AuctionRevealCondition starts returning true and CDR validators begin publishing partial decryption shares.

Calling trigger

Anyone can call trigger(uint256 auctionId) once block.timestamp >= deadline. The function:

  • Verifies the auction is in Open state.
  • Verifies the deadline has passed (DeadlineNotReached reverts otherwise).
  • Flips state from Open to Triggered.
  • Emits AuctionTriggered(uint256 indexed auctionId).
function trigger(uint256 auctionId) external

There is no onlyOwner or access control. Triggering is permissionless.

const hash = await walletClient.writeContract({
address: SEALED_AUCTION_ADDRESS,
abi: sealedAuctionAbi,
functionName: 'trigger',
args: [auctionId],
})

The caller pays gas. Gas cost for trigger is small (a state write plus one cross-contract read to register the condition change).

The orchestrator's role after trigger

After trigger() flips an auction to Triggered, CDR validators start publishing partial decryption shares for each bid uuid and the reserve uuid. The orchestrator's job:

  1. Detect AuctionTriggered events.
  2. Poll CDR until t-of-n shares per uuid are published.
  3. Read the plaintext for each bid and for the reserve.
  4. Construct a BidReveal[] array and a ReserveReveal struct.
  5. Call SealedAuction.settle(auctionId, reveals, reserveReveal).

Detecting triggers

Watch the AuctionTriggered(uint256 indexed auctionId) event:

const unwatch = client.watchContractEvent({
address: SEALED_AUCTION_ADDRESS,
abi: sealedAuctionAbi,
eventName: 'AuctionTriggered',
onLogs: async logs => {
for (const log of logs) {
await handleTrigger(log.args.auctionId)
}
},
})

On startup, also scan recent blocks for AuctionTriggered events to catch any triggers you missed while offline.

Polling CDR for shares

Poll until each uuid in the auction has t shares. The display threshold from NEXT_PUBLIC_CDR_THRESHOLD (default 3) is a hint; the real threshold is determined by CDR governance. In practice, poll until CDR.reveal(uuid) succeeds rather than counting shares directly.

Reading plaintext and constructing reveals

Once all uuids have a share quorum, read plaintext from CDR and decode each 149-byte BidPayload:

| address bidder (20 bytes) | uint256 amount (32) | bytes32 nonce (32) | bytes signature (65) |

For each bid slot:

const payload = await cdr.reveal(slot.ciphertextUuid)
const { bidder, amount, nonce, signature } = decodeBidPayload(payload)
const bidReveal = { bidIndex: BigInt(i), amount, nonce, signature }

For the reserve vault (uuid is auction.reserveUuid):

const reservePayload = await cdr.reveal(auction.reserveUuid)
const { amount, nonce, signature } = decodeReservePayload(reservePayload)
const reserveReveal = { amount, nonce, signature }

If the seller never submitted an encrypted reserve (reserveHasCiphertext == false), pass a zeroed ReserveReveal; the contract will skip reserve verification and apply a floor of 0.

Calling settle

const hash = await walletClient.writeContract({
address: SEALED_AUCTION_ADDRESS,
abi: sealedAuctionAbi,
functionName: 'settle',
args: [auctionId, bidReveals, reserveReveal],
})

In one transaction, the contract:

  • Re-verifies every bid signature (eth-signed-prefix ecrecover).
  • Skips bids where amount > deposit.
  • Verifies the sealed reserve (if reserveHasCiphertext, reverts InvalidReserveReveal on bad or missing signature).
  • Picks the highest valid bid at or above the reserve.
  • Mints a Story PIL license token to the winner.
  • Transfers the winning amount (WIP) to the seller.
  • Refunds the winner's overpayment and every losing deposit.
  • All in one atomic transaction: partial settlement is impossible.

If no bid clears the reserve, the auction transitions to ExpiredNoWinner and all deposits are refunded.

Gas cost

Settlement gas scales linearly with bid count:

  • One license mint (Story PIL)
  • One seller payment
  • One winner overpay refund
  • One WIP.transfer per losing bidder

For typical auctions with small bid counts this is well within Aeneid block limits. For auctions with many bidders, gas could become a constraint. See Limitations.

What to do when settle reverts

settle reverts if:

  • The auction is not in Triggered state (WrongStateForSettle) — check if it was already settled by someone else.
  • reserveHasCiphertext == true and the ReserveReveal is invalid (InvalidReserveReveal) — the reserve reveal was missing or corrupted.
  • PILicenseTemplate.mintLicenseTokens rejects the mint — the license terms are likely not attached to the IP. This can happen if the seller listed an existing IP with the wrong licenseTermsId. The contract has no recovery path in this case; deposits stay escrowed.

Why settle is permissionless

settle() has no onlyOrchestrator modifier. This is intentional:

  • Single-party orchestration means single-party uptime risk.
  • Permissionless settlement means the system survives any single party going down.
  • The atomic settlement guarantee means any caller produces the same outcome.

The orchestrator is a convenience for liveness, not a permission gate.

Reading deeper