Bid on a return auction
Every Punk the protocol acquires enters a 72-hour return auction on
ReturnAuctionModule at
0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe. Bid at or above the reserve and win, and the
Punk is yours at settle. If nobody bids by the deadline, the Punk is vaulted
permanently and its target trait becomes a permanent trait. This guide covers
finding auctions, the minimum-bid math, referral attribution, refunds, and
settlement.
Finding live auctions
Auctions are keyed by punkId. To discover them, watch the module's events:
event ReturnAuctionStarted(uint16 indexed punkId, uint128 acquisitionCost, uint128 reserveWei, uint64 startedAt, uint64 endsAt);
event BidPlaced(uint16 indexed punkId, address indexed bidder, address indexed referrer, uint256 amount, bytes32 tag, uint64 endsAt);
event ReturnAuctionExtended(uint16 indexed punkId, uint64 newEndsAt);Then confirm state with the read surface:
isLive(punkId) -> bool: true while the auction accepts bidsisSettleable(punkId) -> bool: true once the deadline has passed andsettlewould succeedgetSale(punkId)returns the full sale struct:(acquisitionCost, highBidWei, highBidder, startedAt, endsAt, reserveWei, targetTraitId, settled). A zero-valued struct means no sale has ever started for this PunkreserveOf(punkId) -> uint256,highBidOf(punkId) -> uint128,highBidderOf(punkId) -> address,endsAt(punkId) -> uint64,startedAt(punkId) -> uint64are the individual fields
RPC=https://ethereum-rpc.publicnode.com
RAM=0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe
PUNK_ID=1234
cast call $RAM "isLive(uint16)(bool)" $PUNK_ID --rpc-url $RPC
cast call $RAM \
"getSale(uint16)((uint128,uint128,address,uint64,uint64,uint128,uint8,bool))" \
$PUNK_ID --rpc-url $RPCMinimum-bid math
Two rules, both enforced on-chain:
First bid: meet the reserve. The reserve is snapshotted at auction start
as acquisitionCost x (101 + previousAttempts) / 100, rounded up, where
previousAttempts counts prior return auctions against the same target trait.
The first attempt against a trait opens at a 1% premium over what the protocol
paid, the second at 2%, and so on. A first bid below reserveOf(punkId)
reverts BidBelowReserve; a zero first bid reverts BidNotHigherThanCurrent.
Subsequent bids: beat the high by 1%. minBidIncrementBps is the protocol
constant 100 (1%, denominator 10,000). The next valid bid is
minNext = currentHigh + (currentHigh * 100) / 10_000with a defensive minNext = currentHigh + 1 if rounding would leave it equal.
A bid below minNext reverts BidBelowMinIncrement(bid, minNext).
RESERVE=$(cast call $RAM "reserveOf(uint16)(uint256)" $PUNK_ID --rpc-url $RPC | awk '{print $1}')
HIGH=$(cast call $RAM "highBidOf(uint16)(uint128)" $PUNK_ID --rpc-url $RPC | awk '{print $1}')
# first bid: RESERVE. later bids: HIGH + HIGH/100 (rounded down, min +1 wei)placeBid vs placeBidWithReferral
function placeBid(uint16 punkId) external payable
function placeBidWithReferral(uint16 punkId, address referrer, bytes32 tag) external payableplaceBid is the plain entry point and is exactly
placeBidWithReferral(punkId, address(0), bytes32(0)).
placeBidWithReferral attaches attribution for frontends and aggregators:
- if this bid is the winning bid at settle,
referrerearnsREFERRER_PREMIUM_BPS = 500(5%) of the premium, where the premium ishighBid - acquisitionCost. The reserve formula guarantees a positive premium on every cleared auction - the slot is winner-take-all:
referrerOfHighBid[punkId]is overwritten on every accepted bid, so an outbid bidder's referrer loses attribution. Only the final winning bid's referrer is paid - the payment is fail-closed: a zero referrer, or a referrer whose receive reverts or runs out of the 35,000-gas send budget, forfeits the share, which folds into the vault-burn pool. Settlement never blocks on a referrer
tagis a free-form 32-byte campaign marker emitted inBidPlacedfor off-chain attribution. It is not stored on-chain; passbytes32(0)if unused
Bid reverts to know: SaleMissing (no sale for this Punk), AlreadySettled,
SaleEnded (past the deadline), plus the two minimum-bid reverts above.
Anti-snipe extension
Any accepted bid placed within the final 15 minutes
(SNIPE_TRIGGER_WINDOW) moves the deadline to the bid's timestamp plus 1 hour
(SNIPE_EXTENSION) and emits ReturnAuctionExtended. The extension is
uncapped: an actively contested Punk stays in bidding for as long as bidders
keep escalating, and each round must exceed the last by 1%, so the locked
capital required to keep extending grows geometrically. Always read
endsAt(punkId) fresh rather than caching the original deadline.
Refunds when outbid
When you're outbid, the module pushes your ETH back immediately with a
30,000-gas send. If that push fails (a contract bidder whose receive needs
more gas), the amount is queued in pendingRefund[bidder] and RefundQueued
is emitted. Pull it any time:
function withdrawRefund() externalReverts NothingToWithdraw when your balance is zero. Check first with
pendingRefund(address) -> uint256.
Settling after the deadline
function settle(uint16 punkId) externalAnyone may call once block.timestamp >= endsAt (before that it reverts
SaleLive; after a settle it reverts AlreadySettled). There is no keeper
tip: as the winning bidder your incentive is that you receive the Punk only on
settle.
Cleared (a winning bid exists). The Punk is delivered to the winning
bidder through a settlement escrow round-trip that records a real
PunkBought at the hammer price on the 2017 market. The proceeds split,
hard-coded with no setter:
- 65% of
acquisitionCostto LiveBidAdapter, refilling the live bid - 25% of
acquisitionCostto BuybackBurner, which buys and burns $111 - 10% of
acquisitionCost, plus the premium (highBid - acquisitionCost) less any referrer share, to VaultBurnPool - 5% of the premium to the winning bid's referrer, if one was attached
ReturnAuctionCleared(punkId, buyer, referrer, highBidWei, liveBidShare, burnShare, vaultBurnShare, referrerShare) is emitted and the Punk's custody
becomes ReturnedToMarket. A returned Punk can be acquired again later, and
its next auction's reserve escalates by the per-trait attempt counter.
Unsold (no bids). The Punk transfers to
PunkVault permanently, the recorded target trait
(and only that trait) becomes a permanent trait, PunkVaulted(punkId) is
emitted, and, on a trait's first vaulting, the Proof NFT for that trait is
minted to the acquisition's recorded originalSeller.
cast walkthrough
RPC=https://ethereum-rpc.publicnode.com
RAM=0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe
PUNK_ID=1234
# ── State ──
cast call $RAM "isLive(uint16)(bool)" $PUNK_ID --rpc-url $RPC
RESERVE=$(cast call $RAM "reserveOf(uint16)(uint256)" $PUNK_ID --rpc-url $RPC | awk '{print $1}')
HIGH=$(cast call $RAM "highBidOf(uint16)(uint128)" $PUNK_ID --rpc-url $RPC | awk '{print $1}')
# ── First bid at the reserve ──
cast send $RAM "placeBid(uint16)" $PUNK_ID \
--value $RESERVE --private-key $KEY --rpc-url $RPC
# ── Or a later bid with referral attribution (HIGH + 1%; bc avoids 64-bit
# shell-arithmetic overflow on wei values) ──
BID=$(echo "$HIGH + $HIGH / 100" | bc)
cast send $RAM "placeBidWithReferral(uint16,address,bytes32)" \
$PUNK_ID $REFERRER 0x0000000000000000000000000000000000000000000000000000000000000000 \
--value $BID --private-key $KEY --rpc-url $RPC
# ── If outbid and the push refund failed ──
cast call $RAM "pendingRefund(address)(uint256)" $MY_ADDRESS --rpc-url $RPC
cast send $RAM "withdrawRefund()" --private-key $KEY --rpc-url $RPC
# ── After the deadline ──
cast call $RAM "isSettleable(uint16)(bool)" $PUNK_ID --rpc-url $RPC
cast send $RAM "settle(uint16)" $PUNK_ID --private-key $KEY --rpc-url $RPCviem walkthrough
import {createPublicClient, createWalletClient, http, parseAbi, zeroHash} from 'viem';
import {mainnet} from 'viem/chains';
const MODULE = '0x2ced1CA0BC7996c3dFB3D4E685d9d9E7405A7aCe';
const moduleAbi = parseAbi([
'function isLive(uint16 punkId) view returns (bool)',
'function isSettleable(uint16 punkId) view returns (bool)',
'function reserveOf(uint16 punkId) view returns (uint256)',
'function highBidOf(uint16 punkId) view returns (uint128)',
'function endsAt(uint16 punkId) view returns (uint64)',
'function pendingRefund(address bidder) view returns (uint256)',
'function placeBid(uint16 punkId) payable',
'function placeBidWithReferral(uint16 punkId, address referrer, bytes32 tag) payable',
'function withdrawRefund()',
'function settle(uint16 punkId)',
]);
const client = createPublicClient({chain: mainnet, transport: http()});
const wallet = createWalletClient({chain: mainnet, transport: http(), account});
const punkId = 1234;
if (!(await client.readContract({address: MODULE, abi: moduleAbi, functionName: 'isLive', args: [punkId]}))) {
throw new Error('No live return auction for this Punk');
}
// Minimum valid bid: reserve on the first bid, high + 1% after
const reserve = await client.readContract({address: MODULE, abi: moduleAbi, functionName: 'reserveOf', args: [punkId]});
const high = await client.readContract({address: MODULE, abi: moduleAbi, functionName: 'highBidOf', args: [punkId]});
let minBid: bigint;
if (high === 0n) {
minBid = reserve;
} else {
minBid = high + (high * 100n) / 10_000n;
if (minBid <= high) minBid = high + 1n;
}
// Bid with referral attribution (use placeBid to skip attribution)
await wallet.writeContract({
address: MODULE, abi: moduleAbi,
functionName: 'placeBidWithReferral',
args: [punkId, referrerAddress, zeroHash],
value: minBid,
});
// If outbid and the 30k-gas push refund failed, pull it back
const refund = await client.readContract({
address: MODULE, abi: moduleAbi, functionName: 'pendingRefund', args: [account.address],
});
if (refund > 0n) {
await wallet.writeContract({address: MODULE, abi: moduleAbi, functionName: 'withdrawRefund'});
}
// After endsAt, anyone can settle. If you won, this delivers the Punk
if (await client.readContract({address: MODULE, abi: moduleAbi, functionName: 'isSettleable', args: [punkId]})) {
await wallet.writeContract({address: MODULE, abi: moduleAbi, functionName: 'settle', args: [punkId]});
}Contract reference: ReturnAuctionModule, LiveBidAdapter, PunkVault, VaultBurnPool.