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
Patronacquires a Punk (viaacceptBidoracceptListing), transfers it to this module, and callsstartSale(punkId, acquisitionCost, targetTraitId). The auction opens forAUCTION_DURATION(72 hours)- Anyone bids via
placeBidorplaceBidWithReferral. Each bid must meet the reserve and, after the first bid, exceed the current high bid by at leastminBidIncrementBps(100 bps, 1%). The outbid bidder is refunded immediately (push with a 30k gas budget, pull fallback viawithdrawRefund) - A bid placed inside the final
SNIPE_TRIGGER_WINDOW(15 minutes) extendsendsAtbySNIPE_EXTENSION(1 hour). Extensions are uncapped, so an actively contested Punk stays in bidding as long as bidders keep escalating - After
endsAt, anyone callssettle(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:
# 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.comPlace a referral-bearing bid with viem:
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
function placeBid(uint16 punkId) external payableAccess: 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), andblock.timestampmust be beforeendsAt(SaleEnded) msg.valuemust 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 plusminBidIncrementBps(1%), elseBidBelowMinIncrement
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
function placeBidWithReferral(uint16 punkId, address referrer, bytes32 tag) external payableAccess: 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
function settle(uint16 punkId) externalAccess: 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
function withdrawRefund() externalAccess: 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
function startSale(uint16 punkId, uint128 acquisitionCost, uint8 targetTraitId) externalAccess: 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
SaleExistsif a live, unsettled sale is already open forpunkId. A settled slot is reusable: the call fully resets the slot (high bid, high bidder, settled flag, andreferrerOfHighBid[punkId]) so nothing leaks across auctions - Reverts
PunkNotInCustodyunless the canonical market shows this module as the Punk's owner - Snapshots the reserve as
ceil(acquisitionCost × (101 + previousAttempts) / 100), wherepreviousAttemptsis the target trait's attempt count before this acquisition. RevertsReserveOverflowif the result exceedsuint128
Emits ReturnAuctionStarted with the snapshotted reserve and deadline.
setLiveBidAdapter
function setLiveBidAdapter(address payable _liveBidAdapter) externalAccess: 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
function setVaultBurnPool(address payable _vaultBurnPool) externalAccess: 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
receive() external payableAccess: 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
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
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
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
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
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
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
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
function highBidderOf(uint16 punkId) external view returns (address)Current winning bidder for punkId. address(0) if no bids yet.
highBidOf
function highBidOf(uint16 punkId) external view returns (uint128)Current winning bid in wei for punkId. 0 if no bids yet.
isLive
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
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
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
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
function patron() external view returns (address payable)The Patron hub, the only authorized caller of startSale. Immutable.
pendingRefund
function pendingRefund(address) external view returns (uint256)Queued refund balance in wei for a bidder whose push refund failed.
Withdrawable via withdrawRefund.
permanentCollection
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
function punksMarket() external view returns (ICryptoPunksMarket)The 2017 CryptoPunks market contract. Immutable.
punkVault
function punkVault() external view returns (IPunkVault)The terminal PunkVault custodian that receives unsold Punks and mints the
Proof NFTs. Immutable.
REFERRER_GAS
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
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
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
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
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
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
function startedAt(uint16 punkId) external view returns (uint64)block.timestamp when startSale ran for punkId. 0 if never started.
vaultBurnPool
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
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
event LiveBidAdapterSet(address indexed liveBidAdapter)Emitted exactly once, at the deployer's setLiveBidAdapter call.
PunkVaulted
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
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
event RefundWithdrawn(address indexed bidder, uint256 amount)Emitted when a bidder pulls a previously queued refund.
ReturnAuctionCleared
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
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
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
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).