PERMANENT COLLECTIONProtocol Reference

Patron

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

Patron is the protocol's acquisition entry point. It holds the global live-bid ETH and is the only contract authorized to write acquisitions into PermanentCollection. Two permissionless entry points spend the bid: acceptBid consumes a Punk the owner has listed exclusively to Patron on the 2017 CryptoPunks market, and acceptListing consumes a public listing from an allowlisted seller and pays the caller a small finder fee. Every acquisition follows the same tail: debit the accounted live bid, buyPunk on the 2017 market at the listed price, transfer the Punk to ReturnAuctionModule, start its 72-hour return auction, and record the acquisition.

Patron has no admin withdrawal path. ETH leaves only as buyPunk payments on the two acquisition paths, the acceptListing finder fee, and unaccounted surplus forwarded back to LiveBidAdapter via skimSurplus. ETH enters only from LiveBidAdapter: receive() rejects every other sender, so the adapter is the single faucet that meters all bid-funding inflows.

Concepts

The priced-listing acceptBid model

acceptBid runs on the 2017 market's exclusive-listing primitive. The Punk's owner lists it exclusively to Patron with

solidity
punksMarket.offerPunkForSaleToAddress(punkId, listingWei, patron);

where 0 < listingWei <= bidBalance(). The listed price is the seller's explicit "sell to the protocol" signal: it must be a real positive value (ZeroListingPrice otherwise), it can't exceed the live bid (ListingExceedsBid), and there is no reserve floor. A seller may list at any positive price up to the bid; the protocol pays the listed price and the pool keeps the difference. The frontend lists at the full bid by default.

Once the listing exists, anyone may finalize by calling acceptBid(punkId, targetTraitId, expectedListingWei). Finalization is safe to leave permissionless because the target trait is protocol-derived (PermanentCollection.canonicalTargetOf), so a third-party caller has no discretion to abuse, and the seller is paid the listed price no matter who calls. expectedListingWei is the caller's overpay cap: if the seller raised the listed price after the caller's read, the call reverts ListingAboveExpected instead of paying more than the caller intended.

Payment goes through the market, not from Patron: buyPunk credits the listed price to the seller's pendingWithdrawals balance on the 2017 market, and the seller collects it with punksMarket.withdraw(). Patron pushes the seller nothing directly. A contract or EIP-7702-delegated seller should note that withdraw() uses a 2300-gas transfer, which reverts if the account runs code under the stipend.

acceptListing and the seller allowlist

acceptListing(punkId, targetTraitId) consumes a PUBLIC listing (onlySellTo == address(0)) on the 2017 market, but only when the listing's seller is on the allowed-sellers allowlist and its activation delay has elapsed. This opens a composition surface for recognized peer protocols whose contracts list Punks publicly, without letting the protocol chase arbitrary market listings.

The caller earns a finder fee for triggering the acquisition: min(bidBalance() * finderFeeCapBps / 10_000, finderFeeFixedCap) with finderFeeCapBps = 50 (0.5%) and finderFeeFixedCap = 0.01 ether, both protocol constants with no setter. The path only fires when the live bid is at least MIN_BID_FOR_LISTING (0.5 ETH) and the listed price plus the finder fee fit inside the bid. On this path the recorded acquirer is the caller (the finder) while the recorded originalSeller is the listing's seller, a distinct address that receives any future Proof.

The allowlist is admin-managed through addAllowedSeller / removeAllowedSeller, gated on the raw ProtocolAdmin.admin() address. This gate is a lifetime carve-out: it stays usable past the 1-year ProtocolAdmin auto-lock and closes only when the admin role is burned via ProtocolAdmin.transferAdmin(address(0)). Every add is subject to a 24-hour activation delay (ALLOWLIST_DELAY) before the seller's listings become consumable, giving a reaction window against a hostile or mistaken add.

The adapter-only receive and the accounted live bid

Every ETH source that funds the live bid (the hook's trading-fee leg, attributed contribute top-ups, bare sends, and the cleared return-auction refund) enters LiveBidAdapter, which buffers and meters it into Patron. Patron's receive() accepts ETH ONLY from the adapter and reverts NotAdapter for any other sender, so the bid rises smoothly under the adapter's rate policy and never spikes from a direct send.

bidBalance() returns accountedLiveBidWei, the adapter-accounted bid, not the raw contract balance. The raw balance can exceed the accounted bid because the EVM can force-credit ETH without calling receive() (selfdestruct, coinbase). That surplus is excluded from all payout, finder-fee, and reserve math; anyone can call skimSurplus() to forward it back to the adapter, where it re-enters the bid under the same metering as every other inflow.

Read the live bid at any time:

bash
cast call 0xC8ED01ffd957f5a62b1526d58E309c8bf2BB4A4c "bidBalance()(uint256)" \
  --rpc-url https://ethereum-rpc.publicnode.com

Worked example: the full acceptBid flow

Three transactions from two parties. The owner lists, anyone finalizes, the owner collects from the 2017 market.

bash
RPC=https://ethereum-rpc.publicnode.com
MARKET=0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB   # 2017 CryptoPunks market
PUNK_ID=1234

# 1. Read the current live bid
BID=$(cast call 0xC8ED01ffd957f5a62b1526d58E309c8bf2BB4A4c "bidBalance()(uint256)" --rpc-url $RPC)

# 2. Read the protocol-derived canonical target for the Punk
PC=$(cast call 0xC8ED01ffd957f5a62b1526d58E309c8bf2BB4A4c "permanentCollection()(address)" --rpc-url $RPC)
TARGET=$(cast call $PC "canonicalTargetOf(uint16)(uint8)" $PUNK_ID --rpc-url $RPC)

# 3. Owner lists the Punk EXCLUSIVELY to Patron at the full bid
#    (any positive price <= the bid works; the full bid is the default)
cast send $MARKET "offerPunkForSaleToAddress(uint256,uint256,address)" \
  $PUNK_ID $BID 0xC8ED01ffd957f5a62b1526d58E309c8bf2BB4A4c \
  --rpc-url $RPC --private-key $OWNER_KEY

# 4. Anyone finalizes. expectedListingWei = the price read in step 3;
#    a listing raised past it reverts ListingAboveExpected
cast send 0xC8ED01ffd957f5a62b1526d58E309c8bf2BB4A4c "acceptBid(uint16,uint8,uint256)" \
  $PUNK_ID $TARGET $BID \
  --rpc-url $RPC --private-key $ANY_KEY

# 5. Seller collects the payment queued in the market's pendingWithdrawals
cast send $MARKET "withdraw()" --rpc-url $RPC --private-key $OWNER_KEY

A frontend can bundle steps 3 and 4 with wallet-native batched calls (EIP-5792 / Multicall3). Step 5 is always a separate transaction by the seller. Between steps 2 and 4 the canonical target can shift (another acquisition may collect or occupy the trait); the call then reverts NotCanonicalTarget with the fresh canonical value rather than recording a different permanent trait.

Write functions

acceptBid

solidity
function acceptBid(uint16 punkId, uint8 targetTraitId, uint256 expectedListingWei) external

Access: permissionless (owner pre-condition: the Punk must be listed

exclusively to Patron on the 2017 market at a positive price)

Accepts the live bid for punkId. Requires an existing exclusive listing to Patron (onlySellTo == patron) at price L with 0 < L <= bidBalance(). Anyone may call: the target is protocol-derived and the seller is paid regardless of who finalizes.

Step by step:

  1. Reads the listing from punksMarket.punksOfferedForSale(punkId). Reverts PunkNotListedToHub if there is no listing or it isn't exclusive to Patron, ZeroListingPrice if L == 0
  2. Bounds the price: reverts ListingExceedsBid if L exceeds the accounted live bid, ListingAboveExpected if L exceeds the caller's expectedListingWei cap
  3. Validates the target (present on the Punk, uncollected, not pending, sole-carrier guard, canonical) and the Punk's custody (never acquired, or returned to market in a prior return auction)
  4. Debits accountedLiveBidWei by L, buys the Punk via punksMarket.buyPunk{value: L}, transfers it to ReturnAuctionModule, calls startSale, and records the acquisition in PermanentCollection with the seller as both acquirer and originalSeller

The market credits L to the seller's pendingWithdrawals; the seller collects with punksMarket.withdraw(). Emits BidAccepted. Guarded nonReentrant and notInSwap.

targetTraitId is a verified expectation, not a choice: read PermanentCollection.canonicalTargetOf(punkId) off-chain and pass that value. Any other value reverts NotCanonicalTarget.

acceptListing

solidity
function acceptListing(uint16 punkId, uint8 targetTraitId) external

Access: permissionless (listing pre-condition: the seller must be allowlisted

and past its 24h activation delay)

Buys an eligible Punk from an allowlisted seller's PUBLIC listing on the 2017 market and pays the caller a finder fee.

Step by step:

  1. Reverts BidBelowMinimum if the accounted live bid is below MIN_BID_FOR_LISTING (0.5 ETH)
  2. Reads the listing. Reverts PunkNotPubliclyListed unless it is public (onlySellTo == address(0)), SellerNotAllowed unless the seller is allowlisted, SellerNotYetActive during the 24h activation delay, and ZeroListingPrice if minValue == 0
  3. Computes the finder fee, min(liveBid * finderFeeCapBps / 10_000, finderFeeFixedCap), and reverts ListingExceedsBid if minValue + finderFee exceeds the live bid
  4. Validates target and custody with the same guards as acceptBid
  5. Debits the finder fee and the listed price, buys via buyPunk{value: minValue}, transfers to ReturnAuctionModule, starts the return auction, and records the acquisition with the caller as acquirer and the listing seller as originalSeller
  6. Sends the finder fee to the caller (reverts FinderPaymentFailed if the send fails)

The seller is paid minValue through the market's pendingWithdrawals. Emits ListingAccepted. Guarded nonReentrant and notInSwap.

skimSurplus

solidity
function skimSurplus() external returns (uint256 amount)

Access: permissionless

Forwards forced or otherwise unaccounted ETH (raw balance minus accountedLiveBidWei) back to LiveBidAdapter. Reverts NoSurplus when the raw balance doesn't exceed the accounted bid, ZeroAddress if the adapter isn't wired yet, and SurplusForwardFailed if the forward call fails. The forwarded ETH re-enters the live bid only through the adapter's normal metering. Emits SurplusForwarded. Guarded nonReentrant and notInSwap.

addAllowedSeller

solidity
function addAllowedSeller(address seller) external

Access: admin-only (raw ProtocolAdmin.admin(); a lifetime carve-out that

bypasses the 1y auto-lock and closes only when the admin role is burned)

Adds seller to the acceptListing allowlist. Reverts ZeroAddress for the zero address, NotAdmin for any caller other than the current admin. Idempotent: re-adding an existing seller changes nothing and emits no event. A fresh add sets allowedSellerActiveAt[seller] = block.timestamp + ALLOWLIST_DELAY (24 hours), so the seller's listings become consumable only after the delay, leaving a window for an emergency removeAllowedSeller if the add was hostile or mistaken. Emits AllowedSellerAdded.

removeAllowedSeller

solidity
function removeAllowedSeller(address seller) external

Access: admin-only (raw ProtocolAdmin.admin(); same lifetime carve-out as

addAllowedSeller)

Removes seller from the allowlist and zeroes its activation timestamp, so a later re-add re-engages the full 24h delay. Idempotent: removing a non-allowlisted seller changes nothing and emits no event. Emits AllowedSellerRemoved.

setWiring

solidity
function setWiring(address _permanentCollection, address _finalSaleModule, address _liveBidAdapter) external

Access: deployer one-shot (gated by OneTimeSetup; permanently closed after

the first successful call)

One-shot wiring of permanentCollection, returnAuctionModule (the _finalSaleModule parameter), and liveBidAdapter. Resolves the constructor-time cycle between Patron and the contracts that reference it. Reverts NotDeployer for any caller other than the deployer recorded at construction, AlreadyFinalized on a second call, AlreadyInitialized if the records core is already set, and ZeroAddress for any zero argument. After this call receive() accepts ETH only from the wired adapter. Emits WiringFinalized and Finalized.

receive

solidity
receive() external payable

Access: adapter-only (msg.sender must be liveBidAdapter)

Accepts ETH from LiveBidAdapter and credits it to accountedLiveBidWei. Any other sender reverts NotAdapter; direct top-ups belong on the adapter's receive() or contribute() instead, where they are buffered and metered in. Forced ETH bypasses this function entirely and is therefore excluded from the accounted bid until skimSurplus routes it back through the adapter.

Read functions

accountedLiveBidWei

solidity
function accountedLiveBidWei() external view returns (uint256)

The logical live bid, filled only through LiveBidAdapter. This is the sole value used for acceptBid / acceptListing affordability, finder-fee, and downstream reserve math; force-sent ETH is not counted here.

adminContract

solidity
function adminContract() external view returns (ProtocolAdmin)

The immutable ProtocolAdmin reference. Patron's only admin-gated surface is the seller allowlist, which checks the raw admin() getter and so remains editable past the 1y auto-lock until the role is burned.

allowedSellerActiveAt

solidity
function allowedSellerActiveAt(address) external view returns (uint64)

Timestamp at which an allowlisted seller becomes consumable by acceptListing. Set to block.timestamp + ALLOWLIST_DELAY on a fresh add, zeroed on removal, zero for addresses that were never added.

allowedSellers

solidity
function allowedSellers(address) external view returns (bool)

Whether an address is on the acceptListing allowlist. Allowlisted is necessary but not sufficient: the seller must also be past its allowedSellerActiveAt timestamp.

ALLOWLIST_DELAY

solidity
function ALLOWLIST_DELAY() external view returns (uint64)

Constant, 24 hours (86400, uint64 seconds). The delay between addAllowedSeller and the seller's public listings becoming consumable by acceptListing.

bidBalance

solidity
function bidBalance() external view returns (uint256)

The current live bid, equal to accountedLiveBidWei. This is the number an acceptBid seller lists against and the ceiling on what the protocol pays.

bash
cast call 0xC8ED01ffd957f5a62b1526d58E309c8bf2BB4A4c "bidBalance()(uint256)" \
  --rpc-url https://ethereum-rpc.publicnode.com

finderFeeCapBps

solidity
function finderFeeCapBps() external view returns (uint256)

Constant, 50 (0.5% of the live bid). The proportional cap on the acceptListing finder fee; the fee paid is min(bidBalance * 50 / 10_000, finderFeeFixedCap). A protocol constant with no setter.

finderFeeFixedCap

solidity
function finderFeeFixedCap() external view returns (uint256)

Constant, 0.01 ETH. The absolute cap on the acceptListing finder fee, which binds whenever the live bid exceeds 2 ETH. A protocol constant with no setter.

liveBidAdapter

solidity
function liveBidAdapter() external view returns (address)

The single inflow governor, set once at setWiring. The only address receive() accepts ETH from, and the destination of skimSurplus forwards.

MIN_BID_FOR_LISTING

solidity
function MIN_BID_FOR_LISTING() external view returns (uint256)

Constant, 0.5 ETH. The minimum accounted live bid required for acceptListing to fire, defending against dust listings draining finder fees against a trivial bid.

permanentCollection

solidity
function permanentCollection() external view returns (IPermanentCollection)

The records-only core, set once at setWiring. Patron is the only address authorized to call its recordAcquisition writer. Read its canonicalTargetOf(punkId) to obtain the targetTraitId both entry points require.

punksData

solidity
function punksData() external view returns (IPunksData)

The immutable reference to the sealed PunksData dataset, read for the Punk's trait mask on every acquisition.

punksMarket

solidity
function punksMarket() external view returns (ICryptoPunksMarket)

The immutable reference to the 2017 CryptoPunks market (0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB). All Punk-side listing reads, purchases, and transfers flow through it.

returnAuctionModule

solidity
function returnAuctionModule() external view returns (IReturnAuctionModule)

The 72-hour return auction, set once at setWiring. Patron transfers every acquired Punk to it and calls startSale immediately after buyPunk.

setupFinalized

solidity
function setupFinalized() external view returns (bool)

Whether the one-shot setup gate has closed (true after setWiring). Off-chain tooling can check this before treating the wiring as permanent.

Events

AllowedSellerAdded

solidity
event AllowedSellerAdded(address indexed seller)

Emitted when the admin adds a new seller to the acceptListing allowlist. Only fires on a fresh add (the setter is idempotent). Indexers should pair it with a read of allowedSellerActiveAt(seller) since the seller isn't consumable until 24h later.

AllowedSellerRemoved

solidity
event AllowedSellerRemoved(address indexed seller)

Emitted when the admin removes an allowlisted seller. Only fires when the seller was actually allowlisted.

BidAccepted

solidity
event BidAccepted(uint16 indexed punkId, address indexed seller, uint256 payout)

Emitted on every successful acceptBid. punkId and seller are indexed; payout is the listed price the seller is owed through the market's pendingWithdrawals (collected with withdraw(), not pushed by Patron). One BidAccepted corresponds to one new acquisition row in PermanentCollection and one startSale on the return auction.

Finalized

solidity
event Finalized()

Emitted exactly once, when setWiring closes the one-shot setup gate. After this event the contract's wiring is permanent.

ListingAccepted

solidity
event ListingAccepted(uint16 indexed punkId, address indexed seller, address indexed caller, uint256 minValue, uint256 finderFee)

Emitted on every successful acceptListing. punkId, seller (the public-listing seller, the recorded originalSeller), and caller (the finder, the recorded acquirer) are indexed; minValue is the listed price paid through the market and finderFee is what the caller received.

SurplusForwarded

solidity
event SurplusForwarded(address indexed caller, uint256 amount)

Emitted when skimSurplus forwards forced/unaccounted ETH back to LiveBidAdapter. caller is whoever triggered the skim; amount is the surplus forwarded. This ETH is not part of the live bid until the adapter meters it back in.

WiringFinalized

solidity
event WiringFinalized(address indexed permanentCollection, address indexed returnAuctionModule)

Emitted once at setWiring with the permanently-fixed permanentCollection and returnAuctionModule addresses. The adapter address set in the same call is readable via liveBidAdapter().

Errors

AlreadyFinalized()

setWiring was called after the one-shot setup gate already closed. The wiring is permanent; there is no second chance.

AlreadyInitialized()

setWiring found permanentCollection already set. Redundant protection alongside the setup gate.

BidBelowMinimum(uint256 liveBid, uint256 minimum)

acceptListing was called while the accounted live bid is below MIN_BID_FOR_LISTING (0.5 ETH). Wait for the bid to refill.

FinderPaymentFailed()

The finder-fee send to the acceptListing caller failed, reverting the whole call. Call from an address that accepts plain ETH transfers.

InSwap()

A guarded entry point was called during a swap while a bound extension has the PCSwapContext.inSwap flag raised. While no authorized extension is bound to PCSwapContext, the flag is permanently false and this error cannot fire.

InvalidPunkId(uint16 punkId)

punkId is 10000 or higher. Punk ids run 0 through 9999.

InvalidTargetTrait(uint16 punkId, uint8 targetTraitId)

targetTraitId is out of range (>= 111) or the Punk doesn't carry that trait. Read PermanentCollection.canonicalTargetOf(punkId) for the correct value.

ListingAboveExpected(uint256 listing, uint256 expected)

The acceptBid listed price exceeds the caller's expectedListingWei cap: the seller raised the price after the caller's read. Re-read the listing and retry with an acceptable cap, or don't.

ListingExceedsBid(uint256 listing, uint256 liveBid)

The required outflow exceeds the accounted live bid. On acceptBid this is the listed price alone; on acceptListing it is the listed price plus the finder fee. Wait for the bid to grow or (for acceptBid) have the seller relist lower.

NoSurplus()

skimSurplus found no unaccounted ETH: the raw balance doesn't exceed accountedLiveBidWei.

NotAdapter()

receive() was called by an address other than liveBidAdapter. Direct top-ups must go to the adapter's receive() or contribute(), the single faucet into the live bid.

NotAdmin()

An allowlist setter was called by an address other than the current ProtocolAdmin.admin().

NotCanonicalTarget(uint16 punkId, uint8 provided, uint8 canonical)

The supplied targetTraitId is not the protocol-derived canonical target (the rarest uncollected, non-pending trait the Punk carries). The error carries the canonical value; re-read canonicalTargetOf and retry. This mirrors the authoritative check in PermanentCollection.recordAcquisition and fails loud if the canonical target shifted between the caller's read and inclusion.

NotDeployer()

setWiring was called by an address other than the deployer recorded at construction.

PunkAlreadyAcquired(uint16 punkId)

The Punk's custody is InReturnAuction (a return auction is live) or Vaulted (terminal). Only custody None or ReturnedToMarket can be acquired.

PunkNotListedToHub(uint16 punkId)

acceptBid found no active listing for the Punk that is exclusive to Patron (onlySellTo == patron). The owner must first call offerPunkForSaleToAddress(punkId, price, patron).

PunkNotPubliclyListed(uint16 punkId)

acceptListing found no active PUBLIC listing for the Punk (onlySellTo must be the zero address). Exclusive listings belong on the acceptBid path.

PunkTransferFailed()

Post-buyPunk ownership check failed: the 2017 market did not record Patron as the Punk's owner. Defensive; not expected against the canonical market.

Reentrant()

A nonReentrant entry point was re-entered within the same transaction, for example from a seller's receive() during a payout. The transient-storage mutex spans acceptBid, acceptListing, and skimSurplus.

SellerNotAllowed(address seller)

The public listing's seller is not on the allowed-sellers allowlist. Only listings from admin-recognized sellers are consumable by acceptListing.

SellerNotYetActive(address seller, uint64 activeAt)

The seller is allowlisted but still inside the 24-hour activation delay. The error carries the activeAt timestamp; retry after it.

SoleCarrierMustTargetTrait(uint16 punkId, uint8 requiredTraitId)

The acquisition targets Punk #8348 (the unique carrier of trait bit 23, "7 Attributes") with a target other than bit 23 while that trait is uncollected. The error carries the required trait id; the authoritative guard lives in PermanentCollection.recordAcquisition.

SurplusForwardFailed()

The skimSurplus forward to LiveBidAdapter failed. Not expected: the adapter accepts bare ETH.

TargetTraitAlreadyCollected(uint8 targetTraitId)

The target trait's bit is already set in collectedMask. A collected trait can never be a target again; read canonicalTargetOf for the Punk's current eligible target.

TargetTraitPending(uint8 targetTraitId)

A return auction is already in flight for the target trait. Only one in-flight attempt per uncollected trait is permitted at a time; wait for the pending auction to settle.

ZeroAddress()

A zero address was supplied where a real one is required: constructor references, setWiring arguments, addAllowedSeller, or an unwired adapter in skimSurplus.

ZeroListingPrice(uint16 punkId)

The listing's price is zero. Both paths require a real positive price: a 0-priced exclusive listing is not a valid "sell to the protocol" signal, and a 0-priced public listing is not consumable.

Patron · Permanent Collection