PERMANENT COLLECTIONProtocol Reference

ReturnAuctionModule

0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe · view on evm.now · deployed at block 25270161

ReturnAuctionModule runs the 72-hour return auction that follows every acquisition. It holds custody of each accepted Punk for the duration of its auction, accepts open ETH bids above a snapshotted reserve, and settles into one of two outcomes: cleared (the high bidder receives the Punk and the proceeds are split across the protocol) or unsold (the Punk enters PunkVault permanently and the recorded target trait is collected).

The module is fully permissionless on the bid and settle side. Only Patron can open an auction (startSale), and the two post-deploy wiring setters are deployer one-shots. There is no admin role: the auction duration, anti-snipe windows, reserve formula, minimum bid increment, and the cleared-path proceeds split are all compile-time constants.

Concepts

Auction lifecycle

  1. Patron acquires a Punk (via acceptBid or acceptListing), transfers it to this module, and calls startSale(punkId, acquisitionCost, targetTraitId). The auction opens for AUCTION_DURATION (72 hours)
  2. Anyone bids via placeBid or placeBidWithReferral. Each bid must meet the reserve and, after the first bid, exceed the current high bid by at least minBidIncrementBps (100 bps, 1%). The outbid bidder is refunded immediately (push with a 30k gas budget, pull fallback via withdrawRefund)
  3. A bid placed inside the final SNIPE_TRIGGER_WINDOW (15 minutes) extends endsAt by SNIPE_EXTENSION (1 hour). Extensions are uncapped, so an actively contested Punk stays in bidding as long as bidders keep escalating
  4. After endsAt, anyone calls settle(punkId) to finalize

State transitions for a punkId slot: never started (endsAt == 0), live (endsAt != 0 && !settled && now < endsAt), settleable (endsAt != 0 && !settled && now >= endsAt), settled. Use isLive and isSettleable to read these directly.

Reserve formula

The reserve is snapshotted at startSale and never moves for that sale:

reserve = ceil(acquisitionCost × (101 + previousAttempts) / 100)

previousAttempts is PermanentCollection.attemptCount(targetTraitId) at startSale time, before recordAcquisition bumps it for this acquisition. So the first attempt against a trait requires a 1% premium over the price paid, the second 2%, and so on. The division rounds up (ceil-div), so the premium is enforced even for dust-sized acquisitions. Because the reserve strictly exceeds acquisitionCost, every cleared settle has a strictly positive premium highBid − cost.

Cleared outcome (a bid met the reserve)

The high bidder receives the Punk through a provenance round-trip via ReturnAuctionEscrow: the module transfers the Punk to the escrow, the escrow lists it exclusively back to the module at the hammer price, the module buys it (so the canonical market records a real PunkBought(escrow, module, highBid) rather than a price-less transfer), the proceeds round-trip back, and the module transfers the Punk to the winner. Net ETH movement through the escrow is zero. The recorded buyer on the canonical market is the module (a protocol contract); the winner is the final transferPunk recipient.

The recovered highBid is then split (all constants, no setters):

Share Amount Destination
Live-bid share 65% of acquisitionCost (CLEARED_BID_BPS) LiveBidAdapter.poolReplenish (buffered and metered into the live bid)
Burn share 25% of acquisitionCost (residual) BuybackBurner
Vault-burn from cost 10% of acquisitionCost (CLEARED_VAULT_BURN_BPS) VaultBurnPool
Referrer share 5% of the premium (REFERRER_PREMIUM_BPS), only if the winning bid carried a referrer high bidder's referrer
Premium remainder premium − referrerShare VaultBurnPool

The referrer send is gas-capped at REFERRER_GAS (35,000) and fail-closed: if the referrer is address(0) or the send reverts or runs out of gas, the slice folds into the vault-burn share and the settle proceeds. The live-bid and burn shares are never reduced by referral attribution; the referral comes entirely from the winner's overbid premium. Custody in PermanentCollection moves to ReturnedToMarket, which means the Punk can be re-acquired and re-auctioned later.

Unsold outcome (no bid)

The Punk transfers to PunkVault, the terminal custodian with no withdrawal path. markCustody(Vaulted) collects only the recorded targetTraitId; other uncollected traits on the Punk's mask remain available through future acquisitions of other Punks. If this is the first vaulting of the target trait, a Proof NFT (token id equal to the trait id, on PunkVault) is minted to the acquisition's recorded originalSeller. The mint is atomic with the vaulting: a mint failure reverts the entire settle, leaving it retryable, so a collected trait can never exist without its Proof. The settle also sweeps VaultBurnPool, which burns its accrued $111 balance and forwards its ETH to BuybackBurner. No referrer is paid on this path (no premium exists).

Slot reuse

A settled sale slot is reusable. A Punk that cleared (custody ReturnedToMarket) can be re-acquired, and startSale then fully resets the slot: high bid, high bidder, settled flag, and referrerOfHighBid[punkId] are cleared, and the reserve is re-snapshotted off the new acquisition price with the current per-trait attempt count (the 1%-per-attempt escalation carries forward). Only a live, unsettled sale blocks a new startSale. A vaulted Punk never re-auctions.

Refund queue

When a new high bid lands, the previous bid is pushed back to the outbid bidder with a 30,000 gas budget. If the push fails (for example, a contract bidder whose receive needs more gas), the amount accrues in pendingRefund[bidder] and RefundQueued is emitted; the bidder pulls it any time via withdrawRefund. Refunds are never lost, only deferred.

Reading and bidding

Read the full sale state, liveness, and settleability with cast:

bash
# Full ReturnAuction struct: (acquisitionCost, highBidWei, highBidder,
# startedAt, endsAt, reserveWei, targetTraitId, settled)
cast call 0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe \
  "getSale(uint16)((uint128,uint128,address,uint64,uint64,uint128,uint8,bool))" \
  1234 --rpc-url https://ethereum-rpc.publicnode.com

# Is the auction accepting bids right now?
cast call 0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe "isLive(uint16)(bool)" 1234 \
  --rpc-url https://ethereum-rpc.publicnode.com

# Would settle(punkId) succeed right now?
cast call 0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe "isSettleable(uint16)(bool)" 1234 \
  --rpc-url https://ethereum-rpc.publicnode.com

Place a referral-bearing bid with viem:

ts
import {createWalletClient, http, parseEther, stringToHex} from 'viem';
import {mainnet} from 'viem/chains';
import {abi} from '@/lib/abis/ReturnAuctionModule';

const client = createWalletClient({chain: mainnet, transport: http()});

// Bid 10.2 ETH on Punk 1234, crediting a referrer with an optional campaign tag.
// The bid must be >= reserveOf(1234) and, if a high bid exists, >= the current
// high bid plus 1%.
const hash = await client.writeContract({
  address: '0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe',
  abi,
  functionName: 'placeBidWithReferral',
  args: [
    1234,                                        // punkId (uint16)
    '0xYourReferrerAddress',                     // referrer
    stringToHex('my-campaign', {size: 32}),      // tag (bytes32, emitted only)
  ],
  value: parseEther('10.2'),
});

Write functions

placeBid

solidity
function placeBid(uint16 punkId) external payable

Access: permissionless

Place a bid on the return auction for punkId with no referral attribution. Exactly equivalent to placeBidWithReferral(punkId, address(0), bytes32(0)).

Requirements, checked in order:

  • A sale must exist (SaleMissing), not be settled (AlreadySettled), and block.timestamp must be before endsAt (SaleEnded)
  • msg.value must be at least the snapshotted reserve (BidBelowReserve)
  • The first bid must be strictly positive (BidNotHigherThanCurrent); every subsequent bid must be at least the current high bid plus minBidIncrementBps (1%), else BidBelowMinIncrement

On acceptance the previous high bid is refunded (push with 30k gas, queue on failure), referrerOfHighBid[punkId] is set to address(0), and a bid inside the final 15 minutes extends endsAt by 1 hour (uncapped). Emits BidPlaced and, when the extension fires, ReturnAuctionExtended.

placeBidWithReferral

solidity
function placeBidWithReferral(uint16 punkId, address referrer, bytes32 tag) external payable

Access: permissionless

Place a bid carrying auction-referral attribution. Identical validation and effects to placeBid, plus: referrer is written to referrerOfHighBid[punkId], overwriting any prior value. The slot tracks the current high bidder's referrer only, so an outbid bidder's referrer loses attribution. If this bid is still the high bid at settle, the referrer earns REFERRER_PREMIUM_BPS (5%) of the premium (highBid − acquisitionCost) on the cleared path. referrer = address(0) opts out. tag is a free-form campaign tag emitted in BidPlaced for off-chain attribution and not stored on-chain.

settle

solidity
function settle(uint16 punkId) external

Access: permissionless

Finalize the auction for punkId after endsAt. Reverts SaleMissing if no sale exists, AlreadySettled if already settled, SaleLive before the deadline. Settlement pays no protocol-funded caller tip on either path; it is self-incentivized (the cleared path by the winning bidder's escrowed ETH, the unsold path by the Proof recipient and any mission-aligned caller).

With a high bidder (cleared): runs the escrow provenance round-trip, delivers the Punk to the winner, distributes highBid per the split above (65% of cost to LiveBidAdapter.poolReplenish, 25% residual to BuybackBurner, 10% of cost plus the premium remainder to VaultBurnPool, 5% of premium to the recorded referrer, fail-closed), and marks custody ReturnedToMarket. Any failed transfer to a protocol contract reverts the whole settle (TransferFailed), leaving it retryable; only the referrer send is allowed to fail without reverting. Emits ReturnAuctionCleared.

With no bids (unsold): transfers the Punk to PunkVault, marks custody Vaulted (collecting only the recorded target trait), mints the trait's Proof NFT to the acquisition's recorded originalSeller when this is the trait's first vaulting (the mint is required, a failure reverts and the settle stays retryable), and sweeps VaultBurnPool. Emits PunkVaulted.

withdrawRefund

solidity
function withdrawRefund() external

Access: permissionless

Pull the caller's queued refund balance (bids that failed to push back when outbid). Zeroes pendingRefund[msg.sender] before sending the full amount. Reverts NothingToWithdraw when the balance is zero and TransferFailed when the send fails (the balance is then restored by the revert, so nothing is lost). Emits RefundWithdrawn.

startSale

solidity
function startSale(uint16 punkId, uint128 acquisitionCost, uint8 targetTraitId) external

Access: patron-only (msg.sender == patron, else NotPatron)

Open the 72-hour return auction for a freshly acquired Punk. Patron calls this immediately after buyPunk transfers the Punk into this module.

  • Reverts SaleExists if a live, unsettled sale is already open for punkId. A settled slot is reusable: the call fully resets the slot (high bid, high bidder, settled flag, and referrerOfHighBid[punkId]) so nothing leaks across auctions
  • Reverts PunkNotInCustody unless the canonical market shows this module as the Punk's owner
  • Snapshots the reserve as ceil(acquisitionCost × (101 + previousAttempts) / 100), where previousAttempts is the target trait's attempt count before this acquisition. Reverts ReserveOverflow if the result exceeds uint128

Emits ReturnAuctionStarted with the snapshotted reserve and deadline.

setLiveBidAdapter

solidity
function setLiveBidAdapter(address payable _liveBidAdapter) external

Access: deployer one-shot (msg.sender == deployer, once, else

NotDeployer / AlreadyWired)

One-time post-deploy wiring of the LiveBidAdapter address. The adapter's constructor references this module (to gate its poolReplenish), so the adapter deploys after this module and is wired back here. Rejects address(0) (ZeroAddress). Set synchronously in the deploy broadcast; immutable for protocol purposes afterward. Emits LiveBidAdapterSet.

setVaultBurnPool

solidity
function setVaultBurnPool(address payable _vaultBurnPool) external

Access: deployer one-shot (msg.sender == deployer, once, else

NotDeployer / AlreadyWired)

One-time post-deploy wiring of the VaultBurnPool address, resolving the same constructor cycle as setLiveBidAdapter (the pool's constructor depends on this module's address). Rejects address(0) (ZeroAddress). Emits VaultBurnPoolSet.

receive

solidity
receive() external payable

Access: escrow-only (msg.sender == address(escrow), else TransferFailed)

Accepts ETH only from the settlement escrow when it forwards canonical-market proceeds back during a cleared settle. Bids arrive through the payable bid functions, never here.

Read functions

AUCTION_DURATION

solidity
function AUCTION_DURATION() external view returns (uint64)

Constant 72 hours: the span from startSale to the initial endsAt. Late bids can extend the deadline past this.

buybackBurner

solidity
function buybackBurner() external view returns (address payable)

The BuybackBurner address that receives the residual burn share (25% of cost) on cleared settles. Immutable.

CLEARED_BID_BPS

solidity
function CLEARED_BID_BPS() external view returns (uint256)

Constant 6500: the live-bid share of cleared proceeds as bps of acquisitionCost (65% of cost, routed to LiveBidAdapter.poolReplenish). Hard-coded, no setter.

CLEARED_VAULT_BURN_BPS

solidity
function CLEARED_VAULT_BURN_BPS() external view returns (uint256)

Constant 1000: the in-cost vault-burn share of cleared proceeds as bps of acquisitionCost (10% of cost to VaultBurnPool, on top of the premium that also routes there). The burn share to BuybackBurner is the residual 25%. Hard-coded, no setter.

endsAt

solidity
function endsAt(uint16 punkId) external view returns (uint64)

Current deadline for the sale on punkId, including any anti-snipe extensions. 0 if no sale has ever started.

escrow

solidity
function escrow() external view returns (ReturnAuctionEscrow)

The ReturnAuctionEscrow deployed by this module's constructor and pinned to it. Used only during cleared settles for the provenance round-trip that records a real PunkBought at the hammer price on the canonical market.

getSale

solidity
function getSale(uint16 punkId) external view returns (ReturnAuctionModule.ReturnAuction)

Full ReturnAuction struct for punkId: acquisitionCost, highBidWei, highBidder, startedAt, endsAt, reserveWei (snapshot at sale start), targetTraitId (collected only on the vault path), settled. Returns a zero-valued struct if no sale has ever started for this Punk. After a re-auction the struct reflects the latest sale only.

highBidderOf

solidity
function highBidderOf(uint16 punkId) external view returns (address)

Current winning bidder for punkId. address(0) if no bids yet.

highBidOf

solidity
function highBidOf(uint16 punkId) external view returns (uint128)

Current winning bid in wei for punkId. 0 if no bids yet.

isLive

solidity
function isLive(uint16 punkId) external view returns (bool)

True iff the sale on punkId is currently accepting bids: a sale exists, is not settled, and block.timestamp < endsAt.

isSettleable

solidity
function isSettleable(uint16 punkId) external view returns (bool)

True iff settle(punkId) would succeed right now: a sale exists, is not settled, and the deadline has passed.

liveBidAdapter

solidity
function liveBidAdapter() external view returns (address payable)

The LiveBidAdapter that receives the 65%-of-cost live-bid share on cleared settles via poolReplenish. address(0) until the deployer's one-shot setLiveBidAdapter.

minBidIncrementBps

solidity
function minBidIncrementBps() external view returns (uint256)

Constant 100 (1%): minimum overbid as bps of the current high bid. The next valid bid must be at least currentHigh × 10_100 / 10_000. Forces geometric growth of locked capital per anti-snipe round, which bounds indefinite-extension griefing. A protocol constant, not admin-tunable.

patron

solidity
function patron() external view returns (address payable)

The Patron hub, the only authorized caller of startSale. Immutable.

pendingRefund

solidity
function pendingRefund(address) external view returns (uint256)

Queued refund balance in wei for a bidder whose push refund failed. Withdrawable via withdrawRefund.

permanentCollection

solidity
function permanentCollection() external view returns (IPermanentCollection)

The records-only PermanentCollection core. Read for attemptCount at startSale and for originalSellerOf / acquisitionIndexOf / collectedCount at vault-path settle; receives markCustody on both settle paths. Immutable.

punksMarket

solidity
function punksMarket() external view returns (ICryptoPunksMarket)

The 2017 CryptoPunks market contract. Immutable.

punkVault

solidity
function punkVault() external view returns (IPunkVault)

The terminal PunkVault custodian that receives unsold Punks and mints the Proof NFTs. Immutable.

REFERRER_GAS

solidity
function REFERRER_GAS() external view returns (uint256)

Constant 35,000: gas budget for the outgoing send to the auction referrer on cleared settles. Matches ReferralPayout.CLAIM_GAS so receiver-side gas behavior observed on one surface applies to both.

REFERRER_PREMIUM_BPS

solidity
function REFERRER_PREMIUM_BPS() external view returns (uint256)

Constant 500: the winning bidder's referrer share as bps of the premium (highBid − acquisitionCost), so 5% of the premium. The remainder of the premium flows to VaultBurnPool. Hard-coded, no setter.

referrerOfHighBid

solidity
function referrerOfHighBid(uint16) external view returns (address)

Referrer attached to the current high bid for punkId. Overwritten on every accepted bid and cleared at each startSale, so it tracks only the current auction's current high bidder. address(0) means a no-attribution bid. Read at cleared settle to size the referrer share of the premium.

reserveOf

solidity
function reserveOf(uint16 punkId) external view returns (uint256)

Bid floor in wei for punkId, snapshotted at startSale. A first bid must be at least this amount.

SNIPE_EXTENSION

solidity
function SNIPE_EXTENSION() external view returns (uint64)

Constant 1 hour: how far endsAt moves when a bid triggers the anti-snipe extension.

SNIPE_TRIGGER_WINDOW

solidity
function SNIPE_TRIGGER_WINDOW() external view returns (uint64)

Constant 15 minutes: a bid placed with less than this remaining triggers the anti-snipe extension.

startedAt

solidity
function startedAt(uint16 punkId) external view returns (uint64)

block.timestamp when startSale ran for punkId. 0 if never started.

vaultBurnPool

solidity
function vaultBurnPool() external view returns (address payable)

The VaultBurnPool that receives the 10%-of-cost slice plus the premium remainder on cleared settles, and is swept on every vault-path settle. address(0) until the deployer's one-shot setVaultBurnPool.

Events

BidPlaced

solidity
event BidPlaced(uint16 indexed punkId, address indexed bidder, address indexed referrer, uint256 amount, bytes32 tag, uint64 endsAt)

Emitted on every accepted bid. Carries the bidder, the referrer attached to this bid (address(0) for no attribution), the bid amount, the free-form tag, and endsAt as it stands after any anti-snipe extension this bid triggered. Indexers can credit referrers from this event alone without reading the referrerOfHighBid slot; note only the final high bid's referrer is paid.

LiveBidAdapterSet

solidity
event LiveBidAdapterSet(address indexed liveBidAdapter)

Emitted exactly once, at the deployer's setLiveBidAdapter call.

PunkVaulted

solidity
event PunkVaulted(uint16 indexed punkId)

Emitted on the unsold settle path. The Punk is now in PunkVault permanently and the recorded target trait has been collected. Pair with the vault's Transfer event for the Proof mint when this was the trait's first vaulting.

RefundQueued

solidity
event RefundQueued(address indexed bidder, uint256 amount)

Emitted when an outbid refund couldn't push to the previous bidder within the 30k gas budget. The amount is added to pendingRefund[bidder] for later withdrawRefund.

RefundWithdrawn

solidity
event RefundWithdrawn(address indexed bidder, uint256 amount)

Emitted when a bidder pulls a previously queued refund.

ReturnAuctionCleared

solidity
event ReturnAuctionCleared(uint16 indexed punkId, address indexed buyer, address indexed referrer, uint256 highBidWei, uint256 liveBidShare, uint256 burnShare, uint256 vaultBurnShare, uint256 referrerShare)

Emitted on the cleared settle path with the full proceeds split: liveBidShare (65% of cost, to LiveBidAdapter), burnShare (25% of cost, to BuybackBurner), vaultBurnShare (10% of cost plus the premium remainder, to VaultBurnPool), and referrerShare (paid to referrer, 0 when unattributed or when the send failed and the amount folded into vaultBurnShare). The four shares always sum to highBidWei.

ReturnAuctionExtended

solidity
event ReturnAuctionExtended(uint16 indexed punkId, uint64 newEndsAt)

Emitted when an anti-snipe extension has moved endsAt further into the future. newEndsAt is the new deadline.

ReturnAuctionStarted

solidity
event ReturnAuctionStarted(uint16 indexed punkId, uint128 acquisitionCost, uint128 reserveWei, uint64 startedAt, uint64 endsAt)

Emitted once per startSale. reserveWei is the snapshotted reserve (ceil(acquisitionCost × (101 + previousAttempts) / 100)). A re-auction of a returned Punk emits this again with the new cost and reserve.

VaultBurnPoolSet

solidity
event VaultBurnPoolSet(address indexed vaultBurnPool)

Emitted exactly once, at the deployer's setVaultBurnPool call.

Errors

AlreadySettled(uint16 punkId)

The sale for this punkId has already been settled. Bids and repeat settles are rejected; a new startSale (after a re-acquisition) reopens the slot.

AlreadyWired()

The one-shot wiring setter (setLiveBidAdapter or setVaultBurnPool) was already called. The wiring is immutable for protocol purposes.

BidBelowMinIncrement(uint256 bid, uint256 minNext)

The bid doesn't exceed the current high bid by the required 1% increment. minNext in the error payload is the smallest acceptable bid; resend with at least that value.

BidBelowReserve(uint256 bid, uint256 reserve)

The bid is below the snapshotted reserve. Read reserveOf(punkId) and bid at least that amount.

BidNotHigherThanCurrent(uint256 bid, uint256 currentHigh)

The first bid on a sale was zero. Bids must be strictly positive even when the reserve is zero.

InSwap()

A decorated entry point was called during an official-pool swap while a bound extension had the swap-context flag raised. Retry outside the swap.

NotDeployer()

A wiring setter was called by an address other than the deploying address.

NothingToWithdraw()

withdrawRefund was called with a zero pendingRefund balance.

NotPatron()

startSale was called by an address other than Patron. Only the acquisition hub can open a return auction.

PunkNotInCustody(uint16 punkId)

startSale was called before the Punk was transferred to this module; the canonical market doesn't show this module as the owner of punkId.

Reentrant()

A nonReentrant entry point was re-entered within the same call. The mutex uses transient storage and clears at transaction end.

ReserveOverflow(uint256 reserveU)

The computed reserve exceeds uint128. Only reachable with an implausibly large acquisition cost; the sale cannot be opened at that price.

SaleEnded(uint16 punkId)

A bid arrived at or after endsAt. The auction is over; call settle instead.

SaleExists(uint16 punkId)

startSale was called while a live, unsettled sale is already open for this punkId.

SaleLive(uint16 punkId)

settle was called before endsAt. Wait for the deadline (check isSettleable).

SaleMissing(uint16 punkId)

The punkId has no sale slot at all (never started). Check getSale or isLive first.

TransferFailed()

An ETH send that must succeed failed: a settle-path transfer to a protocol contract, a withdrawRefund send to the caller, or ETH arriving at receive() from any sender other than the escrow. Settle-path failures revert the whole settlement, leaving it retryable.

ZeroAddress()

A constructor argument or wiring setter argument was address(0).

ReturnAuctionModule · Permanent Collection