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.
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.
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.
| 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.
| 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 uintThe exact compiler comes from the verified source. The archival build records the 200-run optimiser setting and reproduces all 6,969 deployed bytes.
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.
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.
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.
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.
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.
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.
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.
The state transitions are internally consistent.
buyPunk clears the offer. An unrelated bid remains
live.buyPunk clears and refunds
that bid through pendingWithdrawals.acceptBidForPunk clears both the sale offer and the
accepted bid.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.
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.
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.
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.
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.
MemoryByteArrayElementDeleteClearsWholeWordLostStorageArrayWriteOnSlotOverflowDirtyBytesArrayToStorageKeccakCachingEmptyByteArrayCopyDynamicArrayCleanupImplicitConstructorCallvalueCheckTupleAssignmentMultiStackSlotComponentsMemoryArrayCreationOverflowprivateCanBeOverriddenSignedArrayStorageCopyUninitializedFunctionPointerInConstructor_0.4.xIncorrectEventSignatureInLibraries_0.4.xExpExponentCleanupNestedArrayFunctionCallDecoderZeroFunctionSelectorDelegateCallReturnValueECRecoverMalformedInputSkipEmptyStringLiteralThe 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.
punkIndexToAddress(index). Do not
call ERC-721 ownerOf.PunkTransfer,
PunkBought or Assign. The ERC-20-shaped
Transfer value is always one.Transfer and bid state. Their PunkBought value
and buyer are zero.buyPunk. Excess ETH
goes to the seller.onlySellTo
check is insufficient for the recipient's own accounting.0xb7F7F6C52F2e2fdb1963Eab30438024864c313F6. The later
CryptoPunks721 wrapper is
0x000000000000003607fce1aC9E043a86675C5C2F.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.