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
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:
cast call 0xC8ED01ffd957f5a62b1526d58E309c8bf2BB4A4c "bidBalance()(uint256)" \
--rpc-url https://ethereum-rpc.publicnode.comWorked example: the full acceptBid flow
Three transactions from two parties. The owner lists, anyone finalizes, the owner collects from the 2017 market.
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_KEYA 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
function acceptBid(uint16 punkId, uint8 targetTraitId, uint256 expectedListingWei) externalAccess: 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:
- Reads the listing from
punksMarket.punksOfferedForSale(punkId). RevertsPunkNotListedToHubif there is no listing or it isn't exclusive to Patron,ZeroListingPriceifL == 0 - Bounds the price: reverts
ListingExceedsBidifLexceeds the accounted live bid,ListingAboveExpectedifLexceeds the caller'sexpectedListingWeicap - 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)
- Debits
accountedLiveBidWeibyL, buys the Punk viapunksMarket.buyPunk{value: L}, transfers it toReturnAuctionModule, callsstartSale, and records the acquisition inPermanentCollectionwith the seller as bothacquirerandoriginalSeller
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
function acceptListing(uint16 punkId, uint8 targetTraitId) externalAccess: 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:
- Reverts
BidBelowMinimumif the accounted live bid is belowMIN_BID_FOR_LISTING(0.5 ETH) - Reads the listing. Reverts
PunkNotPubliclyListedunless it is public (onlySellTo == address(0)),SellerNotAllowedunless the seller is allowlisted,SellerNotYetActiveduring the 24h activation delay, andZeroListingPriceifminValue == 0 - Computes the finder fee,
min(liveBid * finderFeeCapBps / 10_000, finderFeeFixedCap), and revertsListingExceedsBidifminValue + finderFeeexceeds the live bid - Validates target and custody with the same guards as
acceptBid - Debits the finder fee and the listed price, buys via
buyPunk{value: minValue}, transfers toReturnAuctionModule, starts the return auction, and records the acquisition with the caller asacquirerand the listing seller asoriginalSeller - Sends the finder fee to the caller (reverts
FinderPaymentFailedif the send fails)
The seller is paid minValue through the market's pendingWithdrawals.
Emits ListingAccepted. Guarded nonReentrant and notInSwap.
skimSurplus
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
function addAllowedSeller(address seller) externalAccess: 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
function removeAllowedSeller(address seller) externalAccess: 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
function setWiring(address _permanentCollection, address _finalSaleModule, address _liveBidAdapter) externalAccess: 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
receive() external payableAccess: 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
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
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
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
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
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
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.
cast call 0xC8ED01ffd957f5a62b1526d58E309c8bf2BB4A4c "bidBalance()(uint256)" \
--rpc-url https://ethereum-rpc.publicnode.comfinderFeeCapBps
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
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
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
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
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
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
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
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
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
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
event AllowedSellerRemoved(address indexed seller)Emitted when the admin removes an allowlisted seller. Only fires when the seller was actually allowlisted.
BidAccepted
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
event Finalized()Emitted exactly once, when setWiring closes the one-shot setup gate. After
this event the contract's wiring is permanent.
ListingAccepted
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
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
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.