← David Ryan's builder page A left-facing synthetic CryptoPunk with a beard and an earring

CryptoPunksMarket security audit

Audit by David Ryan using Codex (GPT-5.6 Sol High)
2026‑10‑28 against block 26,291,046
Contract on Ethereum mainnet: CryptoPunksMarket

CryptoPunksMarket is the immutable contract that records ownership and runs the original marketplace for the 10,000 CryptoPunks. It was deployed in June 2017, before ERC-721 existed, so its ownership, transfer, bid and sale interfaces are unique to this collection.

This review found no exploitable vulnerability. Its ETH balance matched all pending withdrawals and active bids exactly, to the wei. One medium integration hazard and two low defects remain for callers and event consumers to handle.

How this audit was made

I commissioned this independent audit from Codex. It read the deployed bytecode, the verified source and mainnet state at one pinned block. The report records the commands, evidence and limits so that its results can be checked.

Summary

CryptoPunksMarket holds no exploitable vulnerability. Its ETH liabilities reconcile exactly, its owner powers are inert, and its unchecked arithmetic is safe under the contract's fixed bounds.

One Medium integration hazard remains. Both withdrawal paths use Solidity's transfer, so they forward only 2300 gas. A recipient contract with an expensive receive path can never collect its own ETH.

Two Low defects affect live state or event consumers. A zero-address transfer has stranded a 0.02 ETH bid on Punk 685. Accepted bids also emit a PunkBought event with a zero value and zero buyer.

Three Informational findings cover the non-standard interface, market ordering and a historical owner-only counter weakness that is now unreachable.

The contract is immutable. Integrators must handle these behaviours at their own boundary.

Findings at a glance

ID Severity Finding Priced exposure at block 26,291,046
M-1 Medium Withdrawals forward only 2300 gas Up to 3,145 ETH ($7.4M)
L-1 Low A zero-address transfer can strand an active bid 0.02 ETH ($47) is stranded now
L-2 Low Accepted-bid events record zero value and zero buyer 0 ETH at risk; event data is wrong
I-1 Info The interface is ERC-20-shaped but represents unique punks 0 ETH within this contract
I-2 Info Market ordering requires caller-side protection Limited to the caller-authorised price or bid
I-3 Info The initial assignment counter could underflow before finalisation 0 ETH now; the path is dead

Counts: 0 critical, 0 high, 1 medium, 2 low, 3 informational.

USD values use the Chainlink ETH/USD answer of $2,363.16 at the pinned block.

Verified onchain facts

Address 0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB
Deployed Block 3,914,495, 2017-06-22 19:40:00 UTC
Deployer and stored owner 0xC352B534e8b987e036A93539Fd6897F53488e56a
Creation transaction 0x0885b9e5184f497595e1ae2652d63dbdb2785de2e498af837d672f5765f28430
Compiler solc 0.4.11+commit.68ef5810, optimiser enabled, 200 runs
Runtime code 6,969 bytes: 6,926 executable plus a 43-byte bzzr0 metadata trailer
Instructions 5,035 decoded
Dispatch table 27 selectors, matching every function in the verified ABI
Upgrade and destruction paths No proxy slot, DELEGATECALL, CALLCODE or SELFDESTRUCT
Supply state totalSupply = 10,000; nextPunkIndexToAssign = 0
Assignment state allPunksAssigned = true; punksRemainingToAssign = 0
ETH held 3,145.153320691470851579 ETH
Pending withdrawals 2,952.112920691470851579 ETH across 816 non-zero accounts
Active bids 12 bids totalling 193.0404 ETH
Surplus or shortfall 0 wei
ETH/USD $2,363.16 from Chainlink ETH/USD

The following commands reproduce the state rows. Every call uses block 0x1912b66, which is decimal block 26,291,046.

python3 tools/probe.py --block 0x1912b66 screen \
  0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb

python3 tools/probe.py created \
  0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb

python3 tools/probe.py --block 0x1912b66 balance \
  0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb

python3 tools/probe.py --block 0x1912b66 batch \
  0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb \
  0x18160ddd 0x52f29a25 0x8126c38a 0xc0d6ce63 --as uint

python3 tools/probe.py --block 0x1912b66 batch \
  0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb \
  --slots 0-14 --as raw

python3 tools/probe.py --block 0x1912b66 batch \
  0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb \
  --sweep 0x6e743fa9 --range 0-9999 --word 3 --as 1e18 --sum \
  --multicall --chunk 500

python3 tools/probe.py --block 0x1912b66 batch \
  0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb \
  --sweep 0xf3f43703 --args liability-addresses.txt --as 1e18 --sum \
  --multicall --chunk 500

python3 tools/probe.py --block 0x1912b66 batch \
  0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419 \
  0xfeaf968c --word 1 --as uint

The exact compiler comes from the verified source. The archival build records the 200-run optimiser setting and reproduces all 6,969 deployed bytes.

Method

I pinned one Ethereum block before reading state. I then read runtime code, proxy slots, ordinary storage, public getters and ETH balances from that block.

The opcode walker strips the 43-byte metadata trailer before decoding. Its executable sweep found no upgrade or destruction opcode. All 27 dispatcher selectors match the verified ABI.

The source came from the explorer verification record. Larva Labs' archival build independently confirms that the published source compiles byte-for-byte to the deployed program. The GitHub source therefore does not differ from the deployed source.

I swept all 10,000 punkBids entries. I built the withdrawal address universe from 96,165 bid events and 30,638 sale events in the official CryptoPunks history API. Their bidder, seller and buyer fields produced 13,413 unique addresses.

Those event roles cover every route into pendingWithdrawals. The routes are sale proceeds, displaced bids, and a bid refunded when its bidder receives the punk. I then swept pendingWithdrawals for all 13,413 addresses.

tools/probe.py logs was attempted first. Blockscout returned HTTP 429 before the historical scan completed. The official history supplied the same event roles without depending on that rate-limited explorer route.

Prior work changed two checks. Etherscan lists a DTF audit dated 2021-09-02, but its linked report was unavailable during this review. The NFTX incident post-mortem describes a 2021 integration loss involving directed Punk offers. The deployed source confirms that the loss sat in the recipient vault's caller accounting, outside this contract.

The Medium issue is explicit in the public source and supplies no attacker method. It therefore does not require a private vulnerability embargo.

Findings

M-1 · Withdrawals forward only 2300 gas Medium

Priced exposure. The known amount lost through this cause is 0 ETH. The gross balance that can pass through these payout paths is 3,145.133320691470851579 ETH, or $7,432,453.26. This excludes the independently stranded 0.02 ETH bid in L-1.

withdraw() clears pendingWithdrawals[msg.sender], then uses msg.sender.transfer(amount). withdrawBidForPunk() clears the bid, then uses the same transfer primitive. Each call forwards exactly 2300 gas.

A recipient contract whose receive path exceeds 2300 gas cannot collect its ETH. The transfer reverts, so Ethereum restores the cleared state. The funds remain recorded, but only that same address may retry.

The bid path has the same problem. An outbid contract receives a pending withdrawal. A contract withdrawing its live bid receives a direct 2300-gas transfer. Neither route lets it nominate another recipient.

Gas repricing can create this failure after an integration launches. A fixed stipend that worked before Istanbul may fail after an opcode repricing.

Mitigation. A contract bidder or seller must expose a receive path that fits inside 2300 gas. Otherwise, use a thin receiver that can forward later.

L-1 · A zero-address transfer can strand an active bid Low

Priced exposure. Punk 685 has a stranded bid of 0.02 ETH, or $47.26.

transferPunk() accepts the zero address. It clears any sale offer but leaves an unrelated bid in place. withdrawBidForPunk() then rejects the withdrawal because it requires the punk owner to be non-zero.

The live state proves this sequence matters. Punk 685 has owner 0x0000000000000000000000000000000000000000 and an active 0.02 ETH bid from 0x8e4237fd48bbb843683c93bacad6a6966c9e9883. That bidder now carries 23 bytes of account code, but the owner guard fails before payout behaviour matters.

No owner can accept the bid, and the bidder cannot withdraw it. No function can restore a punk from the zero address. The 0.02 ETH is permanently locked.

Mitigation. Treat the zero address as a destructive burn. Never transfer a punk there while any bid exists. A caller should withdraw its bid before a known burn, although it cannot force the owner to wait.

L-2 · Accepted-bid events record zero value and zero buyer Low

Priced exposure. The direct value at risk is 0 ETH, or $0. Ownership and seller credit remain correct.

acceptBidForPunk() creates a local storage reference named bid. It copies bid.value into amount, clears the stored bid, and credits the seller with amount. It then emits PunkBought from the cleared storage reference.

The resulting event records value = 0 and a zero buyer. Transaction 0xc3a763…ddf66 accepted a 62 ETH bid for Punk 3960 at block 15,390,973. Its Transfer event names the real buyer. Its PunkBought event reports zero for both fields.

The misreported 62 ETH would equal $146,515.92 at this review's exchange rate. The seller still received the correct 62 ETH credit.

Indexers cannot treat every PunkBought event as authoritative. They must identify an accepted bid from the transaction call and preceding bid state.

The other events carry the values used by their state change. Ordinary buyPunk calls also emit a correct PunkBought event.

Mitigation. Reconcile accepted-bid events against Transfer, transaction input and storage history. Keep ordinary buyPunk events on a separate path.

I-1 · The interface is ERC-20-shaped but represents unique punks Info

Priced exposure. This causes no direct loss within the contract: 0 ETH. Downstream accounting can still be wrong.

The contract exposes name, symbol, decimals, totalSupply, balanceOf and an ERC-20-shaped Transfer. It has no ERC-20 transfer, allowance or transferFrom function.

It also has no ERC-721 ownerOf, approval, safeTransferFrom, tokenURI or ERC-165 support. Ownership lives in punkIndexToAddress(index).

The ERC-20-shaped Transfer carries the value 1, not the punk index. A generic event consumer learns that an account count changed, but not which punk moved. PunkTransfer supplies the index for manual transfers. Purchases instead rely on PunkBought, which has the accepted-bid defect above.

I-2 · Market ordering requires caller-side protection Info

Priced exposure. The contract cannot take more than the ETH a caller submits. The exposed amount is therefore the caller-authorised purchase price or bid, with no additional protocol balance at risk.

A public fixed-price offer goes to the first valid buyPunk transaction. Another buyer can move ahead in the mempool. Later buyers revert after the ownership or offer checks fail.

A private offer enforces onlySellTo == msg.sender. An outsider cannot buy it directly. A recipient contract can reintroduce a race if it credits its own external caller without checking the punk owner. NFTX documented a 6 ETH loss from that downstream mistake in 2021.

buyPunk treats the offer value as a minimum. It credits the seller with all of msg.value and refunds no excess. Buyers should send the exact price that they intend to pay.

Bid acceptance uses the seller's minPrice. A lower bid reverts. A bidder can withdraw or replace a bid only with a higher value, so a mempool observer cannot lower the seller's settlement.

I-3 · The initial assignment counter could underflow before finalisation Info

Priced exposure. The live path exposes 0 ETH. Assignment is closed, and the owner cannot reach the affected subtraction.

Before finalisation, setInitialOwner did not preserve its remaining-count invariant when the owner reassigned a punk to the zero address. That operation decremented the old holder, assigned zero, and did not increment punksRemainingToAssign.

A later reassignment from zero treated the same punk as newly assigned and decremented the counter again. Repeating the sequence could make the counter reach zero early or wrap below zero.

The weakness required the trusted owner and only affected initial assignment. allPunksAssigned is now true, so setInitialOwner reverts before this code. The current assignment flag is true, and its remaining counter is zero.

Stale offers and bids

The state transitions are internally consistent.

At the pinned block, four active bids also had open sale offers. Punks 1930, 4202, 5170 and 6938 had offers of 32.980711, 40, 65 and 32.97 ETH. Their bids were 0.01, 38, 45 and 30.5 ETH. This confirms that unrelated offers and bids coexist as the source specifies.

Ordering and reentrancy

buyPunk changes ownership and balances, clears the offer, and credits the seller. It then clears a bid from the buyer, if one exists. It makes no external call.

acceptBidForPunk changes ownership and balances, clears the offer, snapshots the bid value, clears the bid, and credits the seller. It makes no external call.

enterBidForPunk credits the displaced bidder before replacing the bid. The credit is an internal accounting write, with no external call.

withdraw snapshots the credit and writes zero before transferring ETH. withdrawBidForPunk snapshots and clears the bid before transferring ETH. Each transfer forwards 2300 gas. A failed transfer reverts the whole call, so Ethereum restores the earlier state.

These sequences follow checks-effects-interactions. Reentrancy cannot observe an uncleared withdrawal or bid, and the stipend prevents a useful stateful callback. The fixed stipend creates M-1 instead.

What the contract cannot do

The stored owner is the original deployer. Three functions check that address.

Owner-gated function Present effect
setInitialOwner Always reverts because allPunksAssigned is true
setInitialOwners Any non-empty call reaches setInitialOwner and reverts; an empty call changes nothing
allInitialOwnersAssigned Can only write true over the existing true value

No function changes owner. The remaining owner authority is inert.

Why 2017 arithmetic is safe here

Solidity 0.4.11 does not check overflow or underflow. This contract uses no SafeMath, so each arithmetic operation needs its own bound.

setInitialOwner safely decrements an old holder's balance because that holder owns the punk. Its remaining-count decrement did have the owner-only weakness described in I-3. The whole function is now dead because assignment has closed.

getPunk checks that assignments are complete, that a punk is unowned, and that the remaining count is non-zero. The live remaining count is zero, so this path is also dead.

Every market transfer decrements the current owner's balanceOf. Ownership of that punk proves the balance is at least one. No subtraction can underflow.

Each balanceOf increment is bounded by the fixed supply of 10,000. No account can approach the uint256 limit.

nextPunkIndexToAssign is never modified after its zero initialisation. totalSupply is written once as 10,000. The setInitialOwners loop counter is bounded by calldata length and transaction gas, far below 2^256 iterations.

Every bid value comes directly from msg.value. A replacement must exceed the stored bid, and the displaced value moves unchanged into a withdrawal credit.

Each pendingWithdrawals addition is backed by ETH already held in the contract. Overflow would require almost 2^256 wei. Ethereum's total supply is many orders of magnitude lower.

The exact solvency reconciliation corroborates these invariants. No unchecked operation has created or erased a wei of liability.

Compiler risk

The verified build used solc 0.4.11 with the optimiser enabled for 200 runs. The Solidity project lists 19 known bugs for that exact release. None intersects this source.

The contract has no memory byte deletion or mutable storage array operations. It never resizes its stored strings. It also has no signed arrays, function pointers, inheritance, libraries, exponentiation, delegatecall, ecrecover, nested array decoding or empty-string external calls. These facts clear 16 entries.

ImplicitConstructorCallvalueCheck requires a missing constructor and an inherited constructor. This contract has an explicit payable constructor and no inheritance.

KeccakCaching affects optimiser reuse of hashes with equal rounded memory contents but different lengths. The source has no inline assembly. Its compiler-generated mapping hashes always use fixed, full-word inputs.

ZeroFunctionSelector does not apply. All 27 dispatched selectors are non-zero, and the contract defines no payable fallback.

The 19 checked names follow.

The solvency result

The contract was exactly solvent at block 26,291,046.

Component ETH USD at $2,363.16/ETH
Pending withdrawals 2,952.112920691470851579 $6,976,315.17
12 active bids 193.0404 $456,185.35
Total liabilities 3,145.153320691470851579 $7,432,500.52
Contract ETH balance 3,145.153320691470851579 $7,432,500.52
Surplus or shortfall 0 $0.00

The equality is exact in wei:

2,952,112,920,691,470,851,579
  193,040,400,000,000,000,000
=3,145,153,320,691,470,851,579 wei

The 0.02 ETH stranded bid remains a liability in the active-bid total. Exact solvency therefore does not imply that every creditor can withdraw.

Integration checklist

  1. Read ownership from punkIndexToAddress(index). Do not call ERC-721 ownerOf.
  2. Use the punk index in PunkTransfer, PunkBought or Assign. The ERC-20-shaped Transfer value is always one.
  3. Reconstruct accepted bids from transaction input, Transfer and bid state. Their PunkBought value and buyer are zero.
  4. Send the exact intended price to buyPunk. Excess ETH goes to the seller.
  5. Treat public listings as first-transaction-wins. Use a directed offer when a specific buyer must receive the punk.
  6. A recipient contract must bind its external caller to the punk owner before it consumes a directed offer. The native onlySellTo check is insufficient for the recipient's own accounting.
  7. Keep any withdrawal receive path below 2300 gas. The contract cannot pay a different address.
  8. Check for a live bid before any zero-address transfer. The bid can become permanently unreachable.
  9. Use an ERC-721 wrapper when an integration requires approvals, safe transfers or ERC-165. The legacy WrappedPunks contract is 0xb7F7F6C52F2e2fdb1963Eab30438024864c313F6. The later CryptoPunks721 wrapper is 0x000000000000003607fce1aC9E043a86675C5C2F.
  10. Read images and attributes from the sealed 2021 data contract at 0x16F5A35647D6F03D5D3da7b35409D65ba03aF3B2. They are not stored here.

Do not confuse this contract with the earlier deployment at 0x6Ba6f2207e343923BA692e5Cae646Fb0F566DB8D. The earlier address still has 4,131 bytes of runtime code onchain. Larva Labs states that its purchase path allocated sale funds to the buyer instead of the seller. CryptoPunksMarket is the replacement reviewed here and does not carry that error.

Limits of this review