Creating CryptoPunks (NFTs part 2)

Writing CryptoPunks smart contract in Vyper

CryptoPunks is one of the first NFTs (Non-Fungible Tokens) on the Ethereum platform. They are pixelated arts of some sorts of punks. In this article, you are going to write CryptoPunks smart contract but in Vyper. You're going to understand how the smart contract works. How do people sell and buy CryptoPunks in the smart contract? How do we verify the image?

The CryptoPunks smart contract does not use minting approach. It takes an image sprite approach to "store" the images. An image sprite is a collection of images combined into one image file. It is a popular way to display images with CSS. Imagine you have three images. Each image has width 300px and height 200px. You stack the image vertically. So to display the first image, you only need to tell CSS to display the image with width 300px and height 200px. But to display the second image, you need to give an additional instruction to CSS, which is to scroll 200px in down direction.

This is all the images of CryptoPunks: https://raw.githubusercontent.com/larvalabs/cryptopunks/master/punks.png. There are 10,000 small images of individual punk. There are 100 rows. Each row has 100 images. So you can make some kind of consensus. The CryptoPunk number 1 is the top-left image. The CryptoPunk number 5 is the image number 5 from the left and it is in the top row. The CryptoPunk number 101 is in the second row and the first column.

So do you store the image sprite in the blockchain? No, of course. You store the hash of the image in the blockchain. This is how you get the hash of the image.


(.venv) $ openssl sha256 punks.png
SHA256(punks.png)= ac39af4793119ee46bbff351d8cb6b5f23da60222126add4268e261199a2921b

Then you store the hash of the image file in the smart contact. You can use a string constant. The image itself can be put anywhere (IPFS or GitHub). Now, let's write CryptoPunks smart contract in Vyper. As usual, initialize our Mamba project directory.


(.venv) $ mkdir cryptopunks
(.venv) $ cd cryptopunks
(.venv) $ mamba init

Create a new file, contracts/CryptoPunksMarket.vy. Add the following code to the file:


"""
@title CryptoPunksMarket
@license MIT
@author Arjuna Sky Kok
@notice Translated from Larvalabs's CryptoPunksMarket code: https://github.com/larvalabs/cryptopunks/blob/master/contracts/CryptoPunksMarket.sol
"""

imageHash: constant(String[100]) = "ac39af4793119ee46bbff351d8cb6b5f23da60222126add4268e261199a2921b"
numberOfImages: constant(uint256) = 10000

owner: public(address)

standard: constant(String[20]) = "CryptoPunks"
name: public(String[20])
symbol: public(String[20])
decimals: public(uint256)
totalSupply: public(uint256)

allPunksAssigned: public(bool)
punksRemainingToAssign: public(uint256)

punkIndexToAddress: public(HashMap[uint256, address])
balanceOf: public(HashMap[address, uint256])

struct Offer:
    isForSale: bool
    punkIndex: uint256
    seller: address
    minValue: uint256
    onlySellTo: address

struct Bid:
    hasBid: bool
    punkIndex: uint256
    bidder: address
    value: uint256

punksOfferedForSale: public(HashMap[uint256, Offer])
punkBids: public(HashMap[uint256, Bid])
pendingWithdrawals: public(HashMap[address, uint256])

event Assign:
    to: indexed(address)
    punkIndex: uint256

event Transfer:
    from_: indexed(address)
    to: indexed(address)
    value: uint256

event PunkTransfer:
    from_: indexed(address)
    to: indexed(address)
    punkIndex: uint256

event PunkOffered:
    punkIndex: indexed(uint256)
    minValue: uint256
    toAddress: indexed(address)

event PunkBidEntered:
    punkIndex: indexed(uint256)
    value: uint256
    fromAddress: indexed(address)

event PunkBidWithdrawn:
    punkIndex: indexed(uint256)
    value: uint256
    fromAddress: indexed(address)

event PunkBought:
    punkIndex: indexed(uint256)
    value: uint256
    fromAddress: indexed(address)
    toAddress: indexed(address)

event PunkNoLongerForSale:
    punkIndex: indexed(uint256)


@external
def __init__():
    self.owner = msg.sender
    self.totalSupply = numberOfImages
    self.punksRemainingToAssign = self.totalSupply
    self.name = "CRYPTOPUNKS"
    self.symbol = "e"
    self.decimals = 0
    self.balanceOf[ZERO_ADDRESS] = numberOfImages


@external
def setInitialOwner(to: address, punkIndex: uint256):
    if msg.sender != self.owner: raise "Sender is not owner"
    if self.allPunksAssigned: raise "All punks have been assigned"
    if punkIndex >= numberOfImages: raise "Index is invalid"

    if self.punkIndexToAddress[punkIndex] != to:
        if self.punkIndexToAddress[punkIndex] != ZERO_ADDRESS:
            self.balanceOf[self.punkIndexToAddress[punkIndex]] -= 1
        else:
            self.punksRemainingToAssign -= 1
        self.punkIndexToAddress[punkIndex] = to
        self.balanceOf[to] += 1
        log Assign(to, punkIndex)


@external
def allInitialOwnersAssigned():
    if msg.sender != self.owner: raise "Sender is not owner"
    self.allPunksAssigned = True


@external
def getPunk(punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if self.punksRemainingToAssign == 0: raise "No punks remaining to be assigned"
    if self.punkIndexToAddress[punkIndex] != ZERO_ADDRESS: raise "Punk has been taken"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    self.punkIndexToAddress[punkIndex] = msg.sender
    self.balanceOf[msg.sender] += 1
    self.punksRemainingToAssign -= 1
    log Assign(msg.sender, punkIndex)


@external
def punkNoLongerForSale(punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if self.punkIndexToAddress[punkIndex] != msg.sender: raise "Owner is not sender"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    self.punksOfferedForSale[punkIndex] = Offer({
                                            isForSale: False,
                                            punkIndex: punkIndex,
                                            seller: msg.sender,
                                            minValue: 0,
                                            onlySellTo: ZERO_ADDRESS})
    log PunkNoLongerForSale(punkIndex)


@external
def transferPunk(to: address, punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if self.punkIndexToAddress[punkIndex] != msg.sender: raise "Owner is not sender"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    if self.punksOfferedForSale[punkIndex].isForSale:
        self.punksOfferedForSale[punkIndex] = Offer({
                                                isForSale: False,
                                                punkIndex: punkIndex,
                                                seller: msg.sender,
                                                minValue: 0,
                                                onlySellTo: ZERO_ADDRESS})
        log PunkNoLongerForSale(punkIndex)
    self.punkIndexToAddress[punkIndex] = to
    self.balanceOf[msg.sender] -= 1
    self.balanceOf[to] += 1
    log Transfer(msg.sender, to, 1)
    log PunkTransfer(msg.sender, to, punkIndex)
    bid : Bid = self.punkBids[punkIndex]
    if bid.bidder == to:
        self.pendingWithdrawals[to] += bid.value
        self.punkBids[punkIndex] = Bid({
                                     hasBid: False,
                                     punkIndex: punkIndex,
                                     bidder: ZERO_ADDRESS,
                                     value: 0})


@external
def offerPunkForSale(punkIndex: uint256, minSalePriceInWei: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if self.punkIndexToAddress[punkIndex] != msg.sender: raise "Owner is not sender"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    self.punksOfferedForSale[punkIndex] = Offer({
                                            isForSale: True,
                                            punkIndex: punkIndex,
                                            seller: msg.sender,
                                            minValue: minSalePriceInWei,
                                            onlySellTo: ZERO_ADDRESS})
    log PunkOffered(punkIndex, minSalePriceInWei, ZERO_ADDRESS)


@external
def offerPunkForSaleToAddress(punkIndex: uint256, minSalePriceInWei: uint256, toAddress: address):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if self.punkIndexToAddress[punkIndex] != msg.sender: raise "Owner is not sender"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    self.punksOfferedForSale[punkIndex] = Offer({
                                            isForSale: True,
                                            punkIndex: punkIndex,
                                            seller: msg.sender,
                                            minValue: minSalePriceInWei,
                                            onlySellTo: toAddress})
    log PunkOffered(punkIndex, minSalePriceInWei, toAddress)


@external
@payable
def buyPunk(punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    offer : Offer = self.punksOfferedForSale[punkIndex]
    if not offer.isForSale: raise "The punk is not for sale"
    if offer.onlySellTo != ZERO_ADDRESS and offer.onlySellTo != msg.sender:
        raise "Punk should not be sold to this address"
    if msg.value < offer.minValue:
        raise "Did not send enough money"
    if offer.seller != self.punkIndexToAddress[punkIndex]: raise "Seller no longer owns the punk"

    seller : address = offer.seller

    self.punkIndexToAddress[punkIndex] = msg.sender
    self.balanceOf[seller] -= 1
    self.balanceOf[msg.sender] += 1
    log Transfer(seller, msg.sender, 1)

    self.punksOfferedForSale[punkIndex] = Offer({
                                            isForSale: False,
                                            punkIndex: punkIndex,
                                            seller: msg.sender,
                                            minValue: 0,
                                            onlySellTo: ZERO_ADDRESS})
    log PunkNoLongerForSale(punkIndex)

    self.pendingWithdrawals[seller] += msg.value
    log PunkBought(punkIndex, msg.value, seller, msg.sender)

    bid : Bid = self.punkBids[punkIndex]
    if bid.bidder == msg.sender:
        self.pendingWithdrawals[msg.sender] += bid.value
        self.punkBids[punkIndex] = Bid({
                                     hasBid: False,
                                     punkIndex: punkIndex,
                                     bidder: ZERO_ADDRESS,
                                     value: 0})


@external
def withdraw():
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    amount : uint256 = self.pendingWithdrawals[msg.sender]
    self.pendingWithdrawals[msg.sender] = 0
    send(msg.sender, amount)


@external
@payable
def enterBidForPunk(punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    if self.punkIndexToAddress[punkIndex] == ZERO_ADDRESS: raise "The punk is not owned by anyone"
    if self.punkIndexToAddress[punkIndex] == msg.sender: raise "The punk is owned by sender"
    if msg.value == 0: raise "No money"
    existing: Bid = self.punkBids[punkIndex]
    if msg.value <= existing.value: raise "Bid is lower than the existing bid"
    if existing.value > 0:
        self.pendingWithdrawals[existing.bidder] += existing.value
    self.punkBids[punkIndex] = Bid({
                                 hasBid: True,
                                 punkIndex: punkIndex,
                                 bidder: msg.sender,
                                 value: msg.value})
    log PunkBidEntered(punkIndex, msg.value, msg.sender)


@external
def acceptBidForPunk(punkIndex: uint256, minPrice: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    if self.punkIndexToAddress[punkIndex] != msg.sender: raise "Sender is not owner"
    seller: address = msg.sender
    bid: Bid = self.punkBids[punkIndex]
    if bid.value == 0: raise "Bid value is 0"
    if bid.value < minPrice: raise "Bid value is below the minimum price"

    self.punkIndexToAddress[punkIndex] = bid.bidder
    self.balanceOf[seller] -= 1
    self.balanceOf[bid.bidder] += 1
    log Transfer(seller, bid.bidder, 1)

    self.punksOfferedForSale[punkIndex] = Offer({
                                            isForSale: False,
                                            punkIndex: punkIndex,
                                            seller: bid.bidder,
                                            minValue: 0,
                                            onlySellTo: ZERO_ADDRESS})
    amount : uint256 = bid.value
    self.punkBids[punkIndex] = Bid({
                                 hasBid: False,
                                 punkIndex: punkIndex,
                                 bidder: ZERO_ADDRESS,
                                 value: 0})
    self.pendingWithdrawals[seller] += amount
    log PunkBought(punkIndex, bid.value, seller, bid.bidder)


@external
def withdrawBidForPunk(punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    if self.punkIndexToAddress[punkIndex] == ZERO_ADDRESS: raise "The punk is not owned by anyone"
    if self.punkIndexToAddress[punkIndex] == msg.sender: raise "The punk is owned by sender"
    bid : Bid = self.punkBids[punkIndex]
    if bid.bidder != msg.sender: raise "Bidder is not sender"
    log PunkBidWithdrawn(punkIndex, bid.value, msg.sender)
    amount: uint256 = bid.value
    self.punkBids[punkIndex] = Bid({
                                 hasBid: False,
                                 punkIndex: punkIndex,
                                 bidder: ZERO_ADDRESS,
                                 value: 0})
    send(msg.sender, amount)

Let's talk about this smart contract. The variable imageHash holds the hash of the CryptoPunks image file. The variable numberOfImages tells the amount of CryptoPunks in this smart contract. The variable owner is the one who deploys the smart contract. The owner is special because she can give some CryptoPunks to anyone before anyone can do any operation in this smart contract. The variable name defines the name of the token. The variable symbol defines the symbol of the token. The variable decimals is 0 because you cannot split CryptoPunk into many pieces. The totalSupply is the amount of CryptoPunks in this smart contract. This variable is redundant with the variable numberOfImages. But using totalSupply is just following ERC20 standard.

The variable allPunksAssigned is to indicate whether the owner has assigned every CryptoPunk to other people. The variable punksRemainingToAssign is an indicator of how many CryptoPunks have not been assigned.

The variable punkIndexToAddress is where you keep track the ownership of the CryptoPunks. The key of this variable refers to the index of CryptoPunks. The address is the account of the owner of that CryptoPunk. So if one of the values of punkIndexToAddress is 8 => 0xAAAAA, it means the account 0xAAAAA owns CryptoPunk number 8 (the count starts from zero). The variable balanceOf is where you keep track how many CryptoPunk an account has. So if one of the values of balanceOf is 0xAAAAA => 3, it means the account 0xAAAAA has 5 CryptoPunks.

The struct Offer is a data structure which used by the owner of the CryptoPunks to describe the condition of the sale of the CryptoPunk, such as the price, the target buyer. The field isForSale to indicate whether this particular CryptoPunk is for sale or not. The field punkIndex is the index of the CryptoPunks. The field seller is the seller (obviously). The field minValue is the minimum price of this CryptoPunk (in wei). The field onlySellTo is the target buyer. If this field has value 0xBBBBB, it means only 0xBBBBB can buy that CryptoPunk. Other accounts are not able to. If the value is zero, it means any account can purchase the CryptoPunk.

The struct Bid is a data structure which used by the bidder. It's up to the owner of the CryptoPunks to accept the bid. The field hasBid is to indicate whether this particular CryptoPunk has any bid or not. The field punkIndex is the index of the CryptoPunks. The field bidder is the bidder (obviously). The field value is the money that the bidder is willing to lose (in wei).

The variable punksOfferedForSale connects between CryptoPunks and offers. The variable punkBids connects between CryptoPunks and bids. The variable pendingWithdrawals connects between the accounts and the refunded money.

It's time to discuss the methods.

This is the method __init__:


@external
def __init__():
    self.owner = msg.sender
    self.totalSupply = numberOfImages
    self.punksRemainingToAssign = self.totalSupply
    self.name = "CRYPTOPUNKS"
    self.symbol = "e"
    self.decimals = 0
    self.balanceOf[ZERO_ADDRESS] = numberOfImages

The above method sets the variable owner, totalSupply, punksRemainingToAssign, name, symbol, decimals, balanceOf for key ZERO_ADDRESS.

This is the method setInitialOwner:


@external
def setInitialOwner(to: address, punkIndex: uint256):
    if msg.sender != self.owner: raise "Sender is not owner"
    if self.allPunksAssigned: raise "All punks have been assigned"
    if punkIndex >= numberOfImages: raise "Index is invalid"

    if self.punkIndexToAddress[punkIndex] != to:
        if self.punkIndexToAddress[punkIndex] != ZERO_ADDRESS:
            self.balanceOf[self.punkIndexToAddress[punkIndex]] -= 1
        else:
            self.punksRemainingToAssign -= 1
        self.punkIndexToAddress[punkIndex] = to
        self.balanceOf[to] += 1
        log Assign(to, punkIndex)

The above method is used by the owner of the smart contract to give CryptoPunks to anyone. The parameter to is the destination. The punkIndex is the index of the CryptoPunks that the owner wants to give away. In this method, you change the value of the variable punkIndexToAddress, the variable of balanceOf, and the variable punksRemainingToAssign.

This is the method allInitialOwnersAssigned:


@external
def allInitialOwnersAssigned():
    if msg.sender != self.owner: raise "Sender is not owner"
    self.allPunksAssigned = True

The above method tells the smart contract that all punks have been assigned. This means users can sell and buy CryptoPunks from now on. Before this method is being executed, all other methods cannot be executed. The only method that can be executed is the previous method, which is the method setInitialOwner. You can say this is the first phase of the smart contract. The owner can spend her time to give away CryptoPunks to people. Once the owner is happy, she can execute the method allInitialOwnersAssigned. It's like screaming to the universe, "Okay, I have assigned all CryptoPunks. Now you all can sell and buy CryptoPunks from each other. Have fun!"

This is the method getPunk:


@external
def getPunk(punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if self.punksRemainingToAssign == 0: raise "No punks remaining to be assigned"
    if self.punkIndexToAddress[punkIndex] != ZERO_ADDRESS: raise "Punk has been taken"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    self.punkIndexToAddress[punkIndex] = msg.sender
    self.balanceOf[msg.sender] += 1
    self.punksRemainingToAssign -= 1
    log Assign(msg.sender, punkIndex)

There are 10,000 CryptoPunks, right? Suppose the owner of the smart contract only wants to give 10 CryptoPunks to her friends, there are still 9,990 CryptoPunks that have not been assigned. It's tiring to assign these 9,900 CryptoPunks, so the owner of the smart contract can call the method allInitialOwnersAssigned. Then other users can grab the remaining CryptoPunks for free (only paying gas fee) using the method getPunk.

This is the method offerPunkForSale:


@external
def offerPunkForSale(punkIndex: uint256, minSalePriceInWei: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if self.punkIndexToAddress[punkIndex] != msg.sender: raise "Owner is not sender"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    self.punksOfferedForSale[punkIndex] = Offer({
                                            isForSale: True,
                                            punkIndex: punkIndex,
                                            seller: msg.sender,
                                            minValue: minSalePriceInWei,
                                            onlySellTo: ZERO_ADDRESS})
    log PunkOffered(punkIndex, minSalePriceInWei, ZERO_ADDRESS)

The above method create an Offer and put it in the variable punksOfferedForSale. Notice that the field onlySellTo has the value ZERO_ADDRESS. It means the owner of the CryptoPunk want to sell her CryptoPunk to anyone who is willing to pay the price.

This is the method offerPunkForSaleToAddress:


@external
def offerPunkForSaleToAddress(punkIndex: uint256, minSalePriceInWei: uint256, toAddress: address):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if self.punkIndexToAddress[punkIndex] != msg.sender: raise "Owner is not sender"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    self.punksOfferedForSale[punkIndex] = Offer({
                                            isForSale: True,
                                            punkIndex: punkIndex,
                                            seller: msg.sender,
                                            minValue: minSalePriceInWei,
                                            onlySellTo: toAddress})
    log PunkOffered(punkIndex, minSalePriceInWei, toAddress)

The above method is exactly the same as the method offerPunkForSale, but the field onlySellTo has the value toAddress. It means any other account other than toAddress cannot purchase the CryptoPunk.

This is the method punkNoLongerForSale:


@external
def punkNoLongerForSale(punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if self.punkIndexToAddress[punkIndex] != msg.sender: raise "Owner is not sender"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    self.punksOfferedForSale[punkIndex] = Offer({
                                            isForSale: False,
                                            punkIndex: punkIndex,
                                            seller: msg.sender,
                                            minValue: 0,
                                            onlySellTo: ZERO_ADDRESS})
    log PunkNoLongerForSale(punkIndex)

The above method cancels what the method offerPunkForSale and offerPunkForSaleToAddress do. It withdraws the sale offer.

This is the method transferPunk:


@external
def transferPunk(to: address, punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if self.punkIndexToAddress[punkIndex] != msg.sender: raise "Owner is not sender"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    if self.punksOfferedForSale[punkIndex].isForSale:
        self.punksOfferedForSale[punkIndex] = Offer({
                                                isForSale: False,
                                                punkIndex: punkIndex,
                                                seller: msg.sender,
                                                minValue: 0,
                                                onlySellTo: ZERO_ADDRESS})
        log PunkNoLongerForSale(punkIndex)
    self.punkIndexToAddress[punkIndex] = to
    self.balanceOf[msg.sender] -= 1
    self.balanceOf[to] += 1
    log Transfer(msg.sender, to, 1)
    log PunkTransfer(msg.sender, to, punkIndex)
    bid : Bid = self.punkBids[punkIndex]
    if bid.bidder == to:
        self.pendingWithdrawals[to] += bid.value
        self.punkBids[punkIndex] = Bid({
                                     hasBid: False,
                                     punkIndex: punkIndex,
                                     bidder: ZERO_ADDRESS,
                                     value: 0})

The above method transfer the ownership of the CryptoPunk to someone else. First, it withdraws the sale offer. Then it transfer the CryptoPunk to the beneficiary. After doing that, it withdraws the bid from the beneficiary (if there is any). It refunds the money by putting the money to pendingWithdrawals.

This is the method buyPunk:


@external
@payable
def buyPunk(punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    offer : Offer = self.punksOfferedForSale[punkIndex]
    if not offer.isForSale: raise "The punk is not for sale"
    if offer.onlySellTo != ZERO_ADDRESS and offer.onlySellTo != msg.sender:
        raise "Punk should not be sold to this address"
    if msg.value < offer.minValue:
        raise "Did not send enough money"
    if offer.seller != self.punkIndexToAddress[punkIndex]: raise "Seller no longer owns the punk"

    seller : address = offer.seller

    self.punkIndexToAddress[punkIndex] = msg.sender
    self.balanceOf[seller] -= 1
    self.balanceOf[msg.sender] += 1
    log Transfer(seller, msg.sender, 1)

    self.punksOfferedForSale[punkIndex] = Offer({
                                            isForSale: False,
                                            punkIndex: punkIndex,
                                            seller: msg.sender,
                                            minValue: 0,
                                            onlySellTo: ZERO_ADDRESS})
    log PunkNoLongerForSale(punkIndex)

    self.pendingWithdrawals[seller] += msg.value
    log PunkBought(punkIndex, msg.value, seller, msg.sender)

    bid : Bid = self.punkBids[punkIndex]
    if bid.bidder == msg.sender:
        self.pendingWithdrawals[msg.sender] += bid.value
        self.punkBids[punkIndex] = Bid({
                                     hasBid: False,
                                     punkIndex: punkIndex,
                                     bidder: ZERO_ADDRESS,
                                     value: 0})

The above method sends money to purchase the CryptoPunk. But the sale offer for this CryptoPunk must exist first. Then it check the condition of this sale offer. If the condition of the sale offer is fulfilled, the method will transfer the ownership of the CryptoPunk to the buyer. It will put the money from the buyer to pendingWithdrawals so the seller can withdraw the money later.

This is the method withdraw:


@external
def withdraw():
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    amount : uint256 = self.pendingWithdrawals[msg.sender]
    self.pendingWithdrawals[msg.sender] = 0
    send(msg.sender, amount)

The above method withdraw the money from the smart contract to an account. The money can come from the sale and the failed bid.

This is the method enterBidForPunk:


@external
@payable
def enterBidForPunk(punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    if self.punkIndexToAddress[punkIndex] == ZERO_ADDRESS: raise "The punk is not owned by anyone"
    if self.punkIndexToAddress[punkIndex] == msg.sender: raise "The punk is owned by sender"
    if msg.value == 0: raise "No money"
    existing: Bid = self.punkBids[punkIndex]
    if msg.value <= existing.value: raise "Bid is lower than the existing bid"
    if existing.value > 0:
        self.pendingWithdrawals[existing.bidder] += existing.value
    self.punkBids[punkIndex] = Bid({
                                 hasBid: True,
                                 punkIndex: punkIndex,
                                 bidder: msg.sender,
                                 value: msg.value})
    log PunkBidEntered(punkIndex, msg.value, msg.sender)

Sometimes you want to purchase a CryptoPunk but there is no Offer yet. So you cannot buy the CryptoPunk. But you can make a bid to let the owner of the CryptoPunk that you're interested in purchasing the CryptoPunk. The Bid is put in punkBids. You have to send money. If your bid is outgunned by other bidders, your money will be put in pendingWithdrawals so you can withdraw your money.

This is the method acceptBidForPunk:


@external
def acceptBidForPunk(punkIndex: uint256, minPrice: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    if self.punkIndexToAddress[punkIndex] != msg.sender: raise "Sender is not owner"
    seller: address = msg.sender
    bid: Bid = self.punkBids[punkIndex]
    if bid.value == 0: raise "Bid value is 0"
    if bid.value < minPrice: raise "Bid value is below the minimum price"

    self.punkIndexToAddress[punkIndex] = bid.bidder
    self.balanceOf[seller] -= 1
    self.balanceOf[bid.bidder] += 1
    log Transfer(seller, bid.bidder, 1)

    self.punksOfferedForSale[punkIndex] = Offer({
                                            isForSale: False,
                                            punkIndex: punkIndex,
                                            seller: bid.bidder,
                                            minValue: 0,
                                            onlySellTo: ZERO_ADDRESS})
    amount : uint256 = bid.value
    self.punkBids[punkIndex] = Bid({
                                 hasBid: False,
                                 punkIndex: punkIndex,
                                 bidder: ZERO_ADDRESS,
                                 value: 0})
    self.pendingWithdrawals[seller] += amount
    log PunkBought(punkIndex, bid.value, seller, bid.bidder)

The above method is used by the owner of the CryptoPunk to accept the bid. It will nullify the sale offer and the bid for this CryptoPunk. It will transfer the CryptoPunk to the bidder. The money will be put in pendingWithdrawals so the previous owner can withdraw the money. If you notice, the second argument is minPrice. This is the minimum price you are willing to accept the bid. Imagine you want to sell a CryptoPunk with the minimum price of 3 ETH. The bidder A put a bid with 4 ETH. You want to accept the bid because 4 ETH is bigger than 3 ETH but by the time you want to accept the bid, the bidder A can withdraw the bid and other bidder can put a bid with lower price, such as 1 ETH. So minPrice is the protection for such case.

This is the method withdrawBidForPunk:


@external
def withdrawBidForPunk(punkIndex: uint256):
    if not self.allPunksAssigned: raise "All punks have not been assigned"
    if punkIndex >= numberOfImages: raise "Invalid punk index"
    if self.punkIndexToAddress[punkIndex] == ZERO_ADDRESS: raise "The punk is not owned by anyone"
    if self.punkIndexToAddress[punkIndex] == msg.sender: raise "The punk is owned by sender"
    bid : Bid = self.punkBids[punkIndex]
    if bid.bidder != msg.sender: raise "Bidder is not sender"
    log PunkBidWithdrawn(punkIndex, bid.value, msg.sender)
    amount: uint256 = bid.value
    self.punkBids[punkIndex] = Bid({
                                 hasBid: False,
                                 punkIndex: punkIndex,
                                 bidder: ZERO_ADDRESS,
                                 value: 0})
    send(msg.sender, amount)

The above method is to withdraw a bid. You nullify the bid and you make the smart contract send the money back to you.

This is the unit test. Name it test/test_cryptopunks.py. This is not a comprehensive unit test because I don't test all edge cases. This unit test is to give you the intuition of how this smart contract works.


from black_mamba.testlib import contract, eth_tester
import pytest
from eth_tester.exceptions import TransactionFailed
import web3

ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"

def test_init(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    assert crypto_punks_contract.functions.owner().call() == accounts[0]
    assert crypto_punks_contract.functions.totalSupply().call() == 10000
    assert crypto_punks_contract.functions.punksRemainingToAssign().call() == 10000
    assert crypto_punks_contract.functions.name().call() == "CRYPTOPUNKS"
    assert crypto_punks_contract.functions.symbol().call() == "e"
    assert crypto_punks_contract.functions.decimals().call() == 0
    assert crypto_punks_contract.functions.balanceOf(ZERO_ADDRESS).call() == 10000

def test_setInitialOwner(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    punkIndex = 3
    assert crypto_punks_contract.functions.punkIndexToAddress(punkIndex).call() == ZERO_ADDRESS
    assert crypto_punks_contract.functions.balanceOf(accounts[1]).call() == 0
    crypto_punks_contract.functions.setInitialOwner(accounts[1], punkIndex).transact({ "from": accounts[0] })
    assert crypto_punks_contract.functions.punkIndexToAddress(punkIndex).call() == accounts[1]
    assert crypto_punks_contract.functions.balanceOf(accounts[1]).call() == 1

    log = crypto_punks_contract.events.Assign.getLogs()[0]
    assert log["args"]["to"] == accounts[1]
    assert log["args"]["punkIndex"] == punkIndex

def test_allInitialOwnersAssigned(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    assert crypto_punks_contract.functions.allPunksAssigned().call() == False
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })
    assert crypto_punks_contract.functions.allPunksAssigned().call() == True

def test_getPunk(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })
    punkIndex = 3
    assert crypto_punks_contract.functions.punkIndexToAddress(punkIndex).call() == ZERO_ADDRESS
    assert crypto_punks_contract.functions.balanceOf(accounts[1]).call() == 0
    crypto_punks_contract.functions.getPunk(punkIndex).transact({ "from": accounts[1] })
    assert crypto_punks_contract.functions.punkIndexToAddress(punkIndex).call() == accounts[1]
    assert crypto_punks_contract.functions.balanceOf(accounts[1]).call() == 1

    log = crypto_punks_contract.events.Assign.getLogs()[0]
    assert log["args"]["to"] == accounts[1]
    assert log["args"]["punkIndex"] == punkIndex

def test_punkNoLongerForSale(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    punkIndex = 3
    ether = web3.Web3.toWei(1, "ether")
    crypto_punks_contract.functions.setInitialOwner(accounts[1], punkIndex).transact({ "from": accounts[0] })
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })

    crypto_punks_contract.functions.offerPunkForSale(punkIndex, ether).transact({ "from": accounts[1] })
    offer = crypto_punks_contract.functions.punksOfferedForSale(punkIndex).call()
    assert offer == [True, punkIndex, accounts[1], ether, ZERO_ADDRESS]
    crypto_punks_contract.functions.punkNoLongerForSale(punkIndex).transact({ "from": accounts[1] })
    offer = crypto_punks_contract.functions.punksOfferedForSale(punkIndex).call()
    assert offer == [False, punkIndex, accounts[1], 0, ZERO_ADDRESS]

    log = crypto_punks_contract.events.PunkNoLongerForSale.getLogs()[0]
    assert log["args"]["punkIndex"] == punkIndex

def test_transferPunk(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    punkIndex = 3
    ether = web3.Web3.toWei(1, "ether")
    crypto_punks_contract.functions.setInitialOwner(accounts[1], punkIndex).transact({ "from": accounts[0] })
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })

    crypto_punks_contract.functions.offerPunkForSale(punkIndex, ether).transact({ "from": accounts[1] })
    offer = crypto_punks_contract.functions.punksOfferedForSale(punkIndex).call()
    assert offer == [True, punkIndex, accounts[1], ether, ZERO_ADDRESS]

    crypto_punks_contract.functions.enterBidForPunk(punkIndex).transact({ "from": accounts[2], "value": ether })

    crypto_punks_contract.functions.transferPunk(accounts[2], punkIndex).transact({ "from": accounts[1] })
    assert crypto_punks_contract.functions.punkIndexToAddress(punkIndex).call() == accounts[2]
    assert crypto_punks_contract.functions.balanceOf(accounts[2]).call() == 1
    assert crypto_punks_contract.functions.balanceOf(accounts[1]).call() == 0

    offer = crypto_punks_contract.functions.punksOfferedForSale(punkIndex).call()
    assert offer == [False, punkIndex, accounts[1], 0, ZERO_ADDRESS]

    bid = crypto_punks_contract.functions.punkBids(punkIndex).call()
    assert bid == [False, punkIndex, ZERO_ADDRESS, 0]
    assert crypto_punks_contract.functions.pendingWithdrawals(accounts[2]).call() == ether

    log = crypto_punks_contract.events.PunkNoLongerForSale.getLogs()[0]
    assert log["args"]["punkIndex"] == punkIndex

    log = crypto_punks_contract.events.PunkTransfer.getLogs()[0]
    assert log["args"]["from_"] == accounts[1]
    assert log["args"]["to"] == accounts[2]
    assert log["args"]["punkIndex"] == punkIndex

    log = crypto_punks_contract.events.Transfer.getLogs()[0]
    assert log["args"]["from_"] == accounts[1]
    assert log["args"]["to"] == accounts[2]
    assert log["args"]["value"] == 1

def test_offerPunkForSale(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    punkIndex = 3
    ether = web3.Web3.toWei(1, "ether")
    crypto_punks_contract.functions.setInitialOwner(accounts[1], punkIndex).transact({ "from": accounts[0] })
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })

    crypto_punks_contract.functions.offerPunkForSale(punkIndex, ether).transact({ "from": accounts[1] })
    offer = crypto_punks_contract.functions.punksOfferedForSale(punkIndex).call()
    assert offer == [True, punkIndex, accounts[1], ether, ZERO_ADDRESS]

    log = crypto_punks_contract.events.PunkOffered.getLogs()[0]
    assert log["args"]["punkIndex"] == punkIndex
    assert log["args"]["minValue"] == ether
    assert log["args"]["toAddress"] == ZERO_ADDRESS

def test_buyPunk(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    punkIndex = 3
    ether = web3.Web3.toWei(1, "ether")
    crypto_punks_contract.functions.setInitialOwner(accounts[1], punkIndex).transact({ "from": accounts[0] })
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })
    crypto_punks_contract.functions.offerPunkForSale(punkIndex, ether).transact({ "from": accounts[1] })
    crypto_punks_contract.functions.enterBidForPunk(punkIndex).transact({ "from": accounts[2], "value": ether })

    crypto_punks_contract.functions.buyPunk(punkIndex).transact({ "from": accounts[2], "value": ether })
    assert crypto_punks_contract.functions.punkIndexToAddress(punkIndex).call() == accounts[2]
    assert crypto_punks_contract.functions.balanceOf(accounts[2]).call() == 1
    assert crypto_punks_contract.functions.balanceOf(accounts[1]).call() == 0

    log = crypto_punks_contract.events.Transfer.getLogs()[0]
    assert log["args"]["from_"] == accounts[1]
    assert log["args"]["to"] == accounts[2]
    assert log["args"]["value"] == 1

    offer = crypto_punks_contract.functions.punksOfferedForSale(punkIndex).call()
    assert offer == [False, punkIndex, accounts[2], 0, ZERO_ADDRESS]

    log = crypto_punks_contract.events.PunkNoLongerForSale.getLogs()[0]
    assert log["args"]["punkIndex"] == punkIndex

    assert crypto_punks_contract.functions.pendingWithdrawals(accounts[1]).call() == ether
    log = crypto_punks_contract.events.PunkBought.getLogs()[0]
    assert log["args"]["punkIndex"] == punkIndex
    assert log["args"]["value"] == ether
    assert log["args"]["fromAddress"] == accounts[1]
    assert log["args"]["toAddress"] == accounts[2]

    assert crypto_punks_contract.functions.pendingWithdrawals(accounts[2]).call() == ether
    bid = crypto_punks_contract.functions.punkBids(punkIndex).call()
    assert bid == [False, punkIndex, ZERO_ADDRESS, 0]

def test_offerPunkForSaleToAddress(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    punkIndex = 3
    ether = web3.Web3.toWei(1, "ether")
    crypto_punks_contract.functions.setInitialOwner(accounts[1], punkIndex).transact({ "from": accounts[0] })
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })

    crypto_punks_contract.functions.offerPunkForSaleToAddress(punkIndex, ether, accounts[3]).transact({ "from": accounts[1] })
    offer = crypto_punks_contract.functions.punksOfferedForSale(punkIndex).call()
    assert offer == [True, punkIndex, accounts[1], ether, accounts[3]]

    log = crypto_punks_contract.events.PunkOffered.getLogs()[0]
    assert log["args"]["punkIndex"] == punkIndex
    assert log["args"]["minValue"] == ether
    assert log["args"]["toAddress"] == accounts[3]

def test_withdraw(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    punkIndex = 3
    ether = web3.Web3.toWei(1, "ether")
    half_ether = web3.Web3.toWei(0.5, "ether")
    crypto_punks_contract.functions.setInitialOwner(accounts[1], punkIndex).transact({ "from": accounts[0] })
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })

    crypto_punks_contract.functions.enterBidForPunk(punkIndex).transact({ "from": accounts[2], "value": ether })
    crypto_punks_contract.functions.acceptBidForPunk(punkIndex, half_ether).transact({ "from": accounts[1] })
    assert crypto_punks_contract.functions.pendingWithdrawals(accounts[1]).call() == ether

    crypto_punks_contract.functions.withdraw().transact({ "from": accounts[1] })

def test_enterBidForPunk(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    punkIndex = 3
    ether = web3.Web3.toWei(1, "ether")
    crypto_punks_contract.functions.setInitialOwner(accounts[1], punkIndex).transact({ "from": accounts[0] })
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })

    crypto_punks_contract.functions.enterBidForPunk(punkIndex).transact({ "from": accounts[2], "value": ether })
    bid = crypto_punks_contract.functions.punkBids(punkIndex).call()
    assert bid == [True, punkIndex, accounts[2], ether]

    log = crypto_punks_contract.events.PunkBidEntered.getLogs()[0]
    assert log["args"]["punkIndex"] == punkIndex
    assert log["args"]["value"] == ether
    assert log["args"]["fromAddress"] == accounts[2]

    two_ether = web3.Web3.toWei(2, "ether")
    crypto_punks_contract.functions.enterBidForPunk(punkIndex).transact({ "from": accounts[3], "value": two_ether })
    assert crypto_punks_contract.functions.pendingWithdrawals(accounts[2]).call() == ether
    bid = crypto_punks_contract.functions.punkBids(punkIndex).call()
    assert bid == [True, punkIndex, accounts[3], two_ether]

    log = crypto_punks_contract.events.PunkBidEntered.getLogs()[0]
    assert log["args"]["punkIndex"] == punkIndex
    assert log["args"]["value"] == two_ether
    assert log["args"]["fromAddress"] == accounts[3]

def test_acceptBidForPunk(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    punkIndex = 3
    ether = web3.Web3.toWei(1, "ether")
    half_ether = web3.Web3.toWei(0.5, "ether")
    crypto_punks_contract.functions.setInitialOwner(accounts[1], punkIndex).transact({ "from": accounts[0] })
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })

    crypto_punks_contract.functions.enterBidForPunk(punkIndex).transact({ "from": accounts[2], "value": ether })
    crypto_punks_contract.functions.acceptBidForPunk(punkIndex, half_ether).transact({ "from": accounts[1] })
    assert crypto_punks_contract.functions.punkIndexToAddress(punkIndex).call() == accounts[2]
    assert crypto_punks_contract.functions.balanceOf(accounts[2]).call() == 1
    assert crypto_punks_contract.functions.balanceOf(accounts[1]).call() == 0
    assert crypto_punks_contract.functions.punksOfferedForSale(punkIndex).call() == [False, punkIndex, accounts[2], 0, ZERO_ADDRESS]
    assert crypto_punks_contract.functions.punkBids(punkIndex).call() == [False, punkIndex, ZERO_ADDRESS, 0]
    assert crypto_punks_contract.functions.pendingWithdrawals(accounts[1]).call() == ether

    log = crypto_punks_contract.events.Transfer.getLogs()[0]
    assert log["args"]["from_"] == accounts[1]
    assert log["args"]["to"] == accounts[2]
    assert log["args"]["value"] == 1

    log = crypto_punks_contract.events.PunkBought.getLogs()[0]
    assert log["args"]["punkIndex"] == punkIndex
    assert log["args"]["value"] == ether
    assert log["args"]["fromAddress"] == accounts[1]
    assert log["args"]["toAddress"] == accounts[2]

def test_withdrawBidForPunk(eth_tester):
    accounts = eth_tester.get_accounts()
    crypto_punks_contract = contract("CryptoPunksMarket", [])
    punkIndex = 3
    ether = web3.Web3.toWei(1, "ether")
    crypto_punks_contract.functions.setInitialOwner(accounts[1], punkIndex).transact({ "from": accounts[0] })
    crypto_punks_contract.functions.allInitialOwnersAssigned().transact({ "from": accounts[0] })

    crypto_punks_contract.functions.enterBidForPunk(punkIndex).transact({ "from": accounts[2], "value": ether })
    crypto_punks_contract.functions.withdrawBidForPunk(punkIndex).transact({ "from": accounts[2] })
    assert crypto_punks_contract.functions.punkBids(punkIndex).call() == [False, punkIndex, ZERO_ADDRESS, 0]

    log = crypto_punks_contract.events.PunkBidWithdrawn.getLogs()[0]
    assert log["args"]["punkIndex"] == punkIndex
    assert log["args"]["value"] == ether
    assert log["args"]["fromAddress"] == accounts[2]

The downside of this smart contract is you have to release all NFTs in one go. You cannot add another CryptoPunk to this smart contract (minting). In next episode, I'm going to show you another kind of NFTs smart contract on which you can mint an NFT. Stay tuned. Follow my twitter: @arjunaskykok!