Skip to main content

Settlement flow

This is the most important transaction in the protocol. Everything that needs to happen at auction close — the reserve reveal, the license mint, the seller payout, the winner's overpay refund, and every losing bidder's refund — happens in one atomic call to settle().

The full sequence

Step-by-step

1. Trigger

Anyone calls trigger(auctionId). The contract checks block.timestamp >= auction.deadline and flips state to Triggered. If bidCount == 0, state goes directly to ExpiredEmpty and the flow ends.

trigger() is the second gate that opens AuctionRevealCondition. Validators will not publish decryption shares until both gates pass: the deadline has elapsed AND isTriggered(auctionId) returns true.

2. Validators publish shares

This happens off-chain inside CDR. The AuctionTriggered event signals validators that every vault registered for this auction is now eligible for decryption. That includes both the bid vaults and the reserve vault.

Each validator computes their share for each uuid and publishes it to the CDR contract. Once t-of-n shares are available per uuid, the plaintext can be reconstructed. Validators only ever see their own share.

3. Orchestrator reads plaintext

The orchestrator calls into CDR to read the plaintext payload for every uuid in the auction: one per bid vault, one for the reserve vault.

The plaintext for each vault is a 149-byte payload:

| address (20 bytes) | amount (32) | nonce (32) | signature (65) |
signer uint256 bytes32 r,s,v

For a bid, signer is the bidder. For the reserve, signer is the seller. Both use the same digest format: keccak256(abi.encode(auctionId, signer, amount, nonce)), prefixed with the Ethereum signed message prefix so browser personal_sign and SDK private-key signing produce identical results.

4. Settle call

The orchestrator calls settle(auctionId, reveals[], reserveReveal) with the array of decrypted bid payloads plus the decrypted reserve payload.

The BidReveal struct per bid: {uint256 bidIndex, uint256 amount, bytes32 nonce, bytes signature}.

The ReserveReveal struct: {uint256 amount, bytes32 nonce, bytes signature}.

5. Reserve reveal and verification

Before evaluating any bid, the contract processes the reserve reveal:

  1. If reserveHasCiphertext == false (seller never sealed a reserve), the floor is 0. Every bid is a valid candidate.
  2. If reserveHasCiphertext == true, the contract calls BidPayload.recoverSigner on the reserveReveal. The recovered signer must equal auction.seller. If it does not, the contract reverts with InvalidReserveReveal and the entire settlement fails.
  3. On a valid reveal, reserveReveal.amount becomes the floor.

The reserve is never stored in plaintext on-chain; it exists only during this verification step.

6. Bid reveal verification

For each reveal in the bid array, the contract checks:

  1. Signature recovery. BidPayload.recoverSigner(reveal) must equal b.bidder (the address that allocated the slot). Tampered or forged signatures fail here.
  2. Ciphertext presence. The slot must have hasCiphertext == true. A slot that was allocated but never had ciphertext written is skipped and refunded.
  3. Deposit cap. reveal.amount <= b.deposit. You cannot reveal a bid larger than what you escrowed.
  4. Reserve floor. reveal.amount >= revealed reserve. Bids below the floor are skipped.

Bids that fail any check are dropped silently and refunded. A corrupt or malicious bid cannot block settlement.

7. Pick winner

Among valid revealed bids, the contract picks the highest amount. Ties are broken by lowest bidIndex (earlier slot allocation wins).

If no bid is valid, the contract goes to the no-winner path (step 9).

8. Winner path

The contract executes, in order:

  1. LicensingModule.mintLicenseTokens(winner, ipId, termsId, 1, ...) — mints the PIL license token to the winner. Reverts on failure, reverting the whole settlement.
  2. WIP.transfer(seller, winningAmount) — pays the seller the winning bid amount.
  3. WIP.transfer(winner, deposit - winningAmount) — refunds the winner's overpayment.
  4. For each loser: WIP.transfer(loser, deposit) — full refund.

Emits AuctionSettled(auctionId, winner, winningAmount, licenseTokenId) and flips state to Settled.

9. No-winner path

If no valid reveal cleared the reserve, the contract:

  1. For each bidder: WIP.transfer(bidder, deposit) — full refund.
  2. Flips state to ExpiredNoWinner.
  3. Emits AuctionExpiredNoWinner(auctionId).

No license is minted. The seller receives nothing. Every deposit is returned.

What's atomic about it

Steps 5–9 happen inside the same settle() call. If any single transfer or mint call fails, the entire transaction reverts. State stays at Triggered and the orchestrator can retry once the underlying issue is resolved.

This means:

  • Partial settlements are impossible. Either every payout happens or none do.
  • The license must mint, or nothing else happens. The seller cannot receive payment for a license that did not issue.
  • Loser refunds cannot be skipped. A buggy or malicious orchestrator cannot keep any bidder's deposit.

These properties are what allow the marketplace UI to say "license plus payment in one transaction" without hedging.

What a malicious orchestrator can and cannot do

The orchestrator relays the settle() call, but the contract re-verifies every signature and deposit cap on-chain. A malicious orchestrator can:

  • Censor (refuse to call settle() at all) — but anyone else can call it once they have the decrypted reveals.
  • Submit an incorrect reveal — but the signature check will reject it; the bid gets refunded.

A malicious orchestrator cannot:

  • Forge a winner (no valid signature)
  • Pay a non-winner seller (contract enforces the correct payout path)
  • Keep a loser's deposit (every deposit is pushed out atomically)

Gas considerations

Settlement gas scales linearly with the number of bids: O(n) WIP.transfer calls, one per bidder. The orchestrator pays this gas in the current deployment.

For now: typical auctions have few bidders, gas is paid by the orchestrator, and the atomic push-payment model is the simpler, safer default.

See also