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:
block.timestamp >= deadline(the clock gate)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
Openstate. - Verifies the deadline has passed (
DeadlineNotReachedreverts otherwise). - Flips state from
OpentoTriggered. - 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:
- Detect
AuctionTriggeredevents. - Poll CDR until
t-of-nshares per uuid are published. - Read the plaintext for each bid and for the reserve.
- Construct a
BidReveal[]array and aReserveRevealstruct. - 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, revertsInvalidReserveRevealon 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.transferper 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
Triggeredstate (WrongStateForSettle) — check if it was already settled by someone else. reserveHasCiphertext == trueand theReserveRevealis invalid (InvalidReserveReveal) — the reserve reveal was missing or corrupted.PILicenseTemplate.mintLicenseTokensrejects the mint — the license terms are likely not attached to the IP. This can happen if the seller listed an existing IP with the wronglicenseTermsId. 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
SealedAuction.settle— the function reference- Settlement flow — the sequence diagram
- Atomic settlement — the guarantees
- CDR network — AuctionRevealCondition details