Skip to main content

Building a Sealed-Bid Auction

Sealed-bid auctions are one of the flagship use cases for confidential smart contracts. On a public EVM, they require a two-phase commit-reveal that has known problems. On OXN, you can build them as a single-transaction submission with the amount hidden until reveal.

The design

  • Bidding phase. Users submit bids. Each bid is stored encrypted; nobody — not the auctioneer, not other bidders, not observers — can read it before the deadline.
  • Reveal phase. After the deadline, the contract determines the winner from its own encrypted state. The winning bid and winner become public; other bids stay confidential.
  • Settlement. The winner pays; the item transfers.

Full contract

contracts/SealedBidAuction.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract SealedBidAuction {
struct Bid {
uint256 amount;
bool exists;
}

address public immutable auctioneer;
uint256 public immutable bidDeadline;
uint256 public immutable revealDeadline;

// Bids stored encrypted; nobody outside the enclave can read
mapping(address => Bid) private bids;
address[] private bidders;

// Public after reveal
address public winner;
uint256 public winningBid;
bool public revealed;

event AuctionCreated(address auctioneer, uint256 bidDeadline, uint256 revealDeadline);
event BidReceived(address indexed bidder); // no amount emitted
event AuctionResolved(address indexed winner, uint256 winningBid);

constructor(uint256 _bidDuration, uint256 _revealBuffer) {
auctioneer = msg.sender;
bidDeadline = block.timestamp + _bidDuration;
revealDeadline = bidDeadline + _revealBuffer;
emit AuctionCreated(auctioneer, bidDeadline, revealDeadline);
}

// Submit a sealed bid — amount is encrypted in calldata and storage
function submitBid() external payable {
require(block.timestamp < bidDeadline, "bidding closed");
require(!bids[msg.sender].exists, "already bid");

bids[msg.sender] = Bid({ amount: msg.value, exists: true });
bidders.push(msg.sender);

emit BidReceived(msg.sender); // event data is encrypted anyway
}

// Reveal the winner. Anyone can call this after the deadline.
function reveal() external {
require(block.timestamp >= revealDeadline, "not yet");
require(!revealed, "already revealed");

uint256 highest = 0;
address highBidder = address(0);
for (uint256 i = 0; i < bidders.length; i++) {
uint256 amount = bids[bidders[i]].amount;
if (amount > highest) {
highest = amount;
highBidder = bidders[i];
}
}

winner = highBidder;
winningBid = highest;
revealed = true;

emit AuctionResolved(winner, winningBid);
}

// Losers can withdraw after reveal
function withdrawBid() external {
require(revealed, "not revealed yet");
require(msg.sender != winner, "winner cannot withdraw");
require(bids[msg.sender].exists, "no bid");

uint256 amount = bids[msg.sender].amount;
delete bids[msg.sender];
payable(msg.sender).transfer(amount);
}

// Auctioneer claims the winning bid
function claim() external {
require(msg.sender == auctioneer, "not auctioneer");
require(revealed, "not revealed yet");

uint256 amount = winningBid;
winningBid = 0;
payable(auctioneer).transfer(amount);
}

// Bidder can read their own bid (signed query)
function myBid() external view returns (uint256) {
require(bids[msg.sender].exists, "no bid");
return bids[msg.sender].amount;
}
}

What the confidentiality achieves

  • Bidders don't know each other's bids during the auction. On a public EVM, everyone sees the amount in msg.value — this contract would be visible too, if not for OXN's encryption.

Wait — msg.value is visible on-chain even on OXN. That's a real issue. Let me update the design.

Design revision: use approvals, not msg.value

Because msg.value on the outer transaction is visible, storing the bid amount in msg.value leaks it. A cleaner design keeps bids as approvals against a token, held encrypted:

// Alternative: bids in an ERC-20 with approvals
mapping(address => uint256) private bidAmount;

function submitBid(uint256 amount) external {
require(block.timestamp < bidDeadline, "closed");
require(!bids[msg.sender].exists, "already bid");

// The amount is encrypted in calldata
// Pull tokens via approval — held by the contract until settlement
token.transferFrom(msg.sender, address(this), amount);

bids[msg.sender] = Bid({ amount: amount, exists: true });
bidders.push(msg.sender);
emit BidReceived(msg.sender);
}

For this to work, users approve the auction contract for a maximum amount in advance, and the actual bid amount is in the encrypted calldata rather than in msg.value.

Testing

test/auction.integration.js
describe("SealedBidAuction [integration]", function () {
this.timeout(120_000);

let auction, alice, bob, carol;

before(async function () {
[alice, bob, carol] = await ethers.getSigners();
const Auction = await ethers.getContractFactory("SealedBidAuction");
auction = await Auction.deploy(3600, 300, { gasLimit: 3_000_000n }); // 1h bid, 5min reveal
await auction.waitForDeployment();
});

it("keeps bids private until reveal", async function () {
await auction.connect(alice).submitBid({ value: ethers.parseEther("1.0"), gasLimit: 500_000n });
await auction.connect(bob).submitBid({ value: ethers.parseEther("1.5"), gasLimit: 500_000n });
await auction.connect(carol).submitBid({ value: ethers.parseEther("1.2"), gasLimit: 500_000n });

// Before reveal, myBid() only returns your own
expect(await auction.connect(alice).myBid()).to.equal(ethers.parseEther("1.0"));
expect(await auction.connect(bob).myBid()).to.equal(ethers.parseEther("1.5"));

// Winner not known
expect(await auction.winner()).to.equal(ethers.ZeroAddress);
});

it("determines winner after reveal", async function () {
// Fast-forward past deadlines is not possible on real OXN.
// In this test, wait actual time or design a version that accepts a manual reveal trigger.
});
});

Deployment and integration checklist

  • Deploy with reasonable durations (avoid tiny windows for testing).
  • Fund test bidders with sufficient balance.
  • Test the signed-query path for myBid().
  • Consider edge cases: no bids submitted, ties, auctioneer failing to claim.

Next steps