Learning NFT Provenance by Example: A Bored Ape Investigation

Learning NFT Provenance by Example: A Bored Ape Investigation
Photo by Sepp Rutz / Unsplash

I was recently approached by an artist friend of mine to help them build and launch an NFT collection using their artwork. I've never worked with smart contracts or any of the NFT-related technologies, so I've been learning all of it myself. One concept that I found particularly interesting was the idea of provenance.

This post will cover provenance as it pertains to NFTs. It's broken up into two sections. The first section discuses my journey of learning about and calculating the provenance of a popular NFT collection. The second section discuses the curious discrepancy I found in my calculation, how I found it, and what it could mean.

What is Provenance?

The term "provenance" has existed long before smart contracts and NFTs. In general terms, it refers to the place of origin or earliest known history of something. The term is almost synonymous with "origin". In the crypto/NFT space, provenance could actually mean a few things, but it's usually referring more specifically to a provenance hash, which is a hash of all the assets of an NFT collection before minting.

The purpose of a provenance hash is to show that the assets that exist on-chain are the same assets that were initially generated. If any of the assets are manipulated in any way, it would be obvious because re-calculating the hash would result in a value different than the provenance hash.

One of the current issues with a provenance hash is that there is no standardized/consistent method of calculating it, so one collection might implement it differently than the next. It's up to the NFT creators to describe how their particular provenance hash was calculated so that it can be independently verified.

Armed with some conceptual knowledge of provenance, I decided to solidify my understanding of it by verifying the provenance of an existing NFT collection. I chose the Bored Ape Yacht Club (BAYC) collection to test on because of its ubiquity and because their provenance information is available publicly on their site (many NFT collections do not provide this information).

How is Provenance Calculated?

The code used in this section can be found in my bayc-provenance-verifier repo.

The BAYC provenance page describes exactly how their provenance hash was calculated:

Each Bored Ape image is firstly hashed using SHA-256 algorithm. A combined string is obtained by concatenating SHA-256 of each Bored Ape image in the specific order as listed below. The final proof is obtained by SHA-256 hashing this combined string. This is the final provenance record stored on the smart contract.

They also provide their final proof hash of cc354b3fcacee8844dcc9861004da081f71df9567775b3f3a43412752752c0bf.

The instructions above seemed simple enough to reproduce so I decided to do so using TypeScript. I used the following code:

import * as crypto from 'crypto'
import axios from 'axios'

type BAYCData = {
    provenance: string;
    collection: Metadata[];
}

type Metadata = {
    tokenId: number;
    image: string;
    imageHash: string;
    traits: object;
}

const BAYC_PROVENANCE = 'cc354b3fcacee8844dcc9861004da081f71df9567775b3f3a43412752752c0bf'
const BAYC_METADATA_URL = 'https://ipfs.io/ipfs/Qme57kZ2VuVzcj5sC3tVHFgyyEgBTmAnyTK45YVNxKf6hi'

async function main() {
    const fetchImage = (url: string): Promise<string> => new Promise(async (resolve) => {
        const image = await axios.get(url, { responseType: 'arraybuffer' })
        const hashedImage = crypto.createHash('sha256').update(image.data).digest('hex')
        resolve(hashedImage)
    })

    const response = await axios.get(BAYC_METADATA_URL)
    const data = await response.data as BAYCData
    const hashes: string[] = []

    // get every individual image hash
    for (let index = 0; index < data.collection.length; index++) {
        const hash = await fetchImage(data.collection[index].image)
        hashes.push(hash)
    }

    // make one big string and hash it
    const concatenatedHashString = hashes.join('')
    const result = crypto.createHash('sha256').update(concatenatedHashString).digest('hex')

    // check for a match
    console.log(result === BAYC_PROVENANCE ? 'SUCCESS' : 'FAILURE')
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error)
        process.exit(1)
    })

This script can be ran using ts-node.

As a warning: this script takes a long time to run. I didn't realize just how large of a number 10,000 is, but it takes a while to make all of those requests since this is being done synchronously and in serial.

My first attempt at writing this script was less synchronous and used Promise.all() to make the individual image requests concurrently, but it would always fail around the 200th request due to IPFS gateway issues. I suspect it was because of the huge amount of simultaneous outgoing requests being made.

My Results

I ran the script, waited a while, and saw the FAILURE message log to my console. Then, I double checked my logic and ran it again. FAILURE. I repeated this a few times. I had no idea why it wasn't working. The logic looked fine. I decided to compare the two concatenated hash strings instead.

Finding the Incorrect Hashes

I first tried to diff the concatenated hash string that I produced with the one listed on the site's provenance page, but since the output is only one line it doesn't tell me anything other than that the lines are, obviously, different.

After some googling I came across the fold command which can "fold long lines for finite width output device". SHA-256 hashes are 64 hex characters long, so the -w 64 flag will ensure that every individual hash from the concatenated hash is split onto its own line for the diff.

Now I could use the diff command and the fold command together to figure out where the two hash strings differed:

diff <(fold -w 64 my_hash.txt) <(fold -w 64 bayc_hash.txt)

The command above uses process substitution to return the output of fold as a file-descriptor so that it can be used as the arguments for diff. Here is a snippet of what the output looks like:

2032c2032
< e33123649788fb044e6d832e66231b26867af618ea80221e57166f388c2efb2f
---
> 5bb1c8a8390c284e9a4634c04eee34dfd08759d66d2b613b0631ab10e2f1f3d9
2034c2034
< 00ebcc4a1409783b3809a0ff383d38a54bc768aa6ff5490e008e788660a85fbd
---
> 5bb1c8a8390c284e9a4634c04eee34dfd08759d66d2b613b0631ab10e2f1f3d9
2039c2039
< d1ec635d95f689eb22191daba2b03efb092d1d8e56e9a09ad697f45c0dd1d014
---
> 5bb1c8a8390c284e9a4634c04eee34dfd08759d66d2b613b0631ab10e2f1f3d9
2041,2042c2041,2042
< 6c62372729385006372dd94807cbcc3d6bd3e4e6d84f349790b031de2bc318f4
< f0455b3cb1998c00ab6ea3bbdd74939aeaea08ab11bc79567d045e612882c6d6
---
> 5bb1c8a8390c284e9a4634c04eee34dfd08759d66d2b613b0631ab10e2f1f3d9
> 5bb1c8a8390c284e9a4634c04eee34dfd08759d66d2b613b0631ab10e2f1f3d9

The output is grouped together so that the line number(s) that differ are shown first, e.g. 2032c2032 and then difference between those lines.

Something Suspicious

Notice anything strange about the output above? It took me a second but then it hit me.

Nearly every line in the diff (31 out of 32) shows the same hash: 5bb1c8a8390c284e9a4634c04eee34dfd08759d66d2b613b0631ab10e2f1f3d9

Don't believe me? Go to the BAYC provenance page and cmd + f the hash above. It exists 31 times in their provenance.

How could that be possible? It's easy to miss something like this when glancing at the provenance page because there are 10,000 lines of hashes listed, but this is clear as day.

A Google search of this hash returns only one result, which is the provenance page for another NFT project called BearNBear. And just like the BAYC, the image shown in that collection does not actually produce this hash when run through SHA-256. How on Earth is it possible that two completely unrelated NFT projects both mention this hash?

A friend of mine, Matt, stepped in to help figure out the significance of this hash but we remain stumped. Another colleague of mine, @conundrumer, suggested that this hash may be that of a null value, but we quickly determined that this was not the case either.

Another Outlier

Finally, there was one hash out of the 32 mismatches that wasn't the same value as the rest of them:

2306c2306
< d68d5402848bb7fba993411e521e1352c3446cd6e6676c95fa55d3e51061d540
---
> d1d7cec7d41312780f1e5064fd2d199a7bef312ec84c8ceb367005bbb26f5d7a

This, to me, is even more fishy than the others, and could mean that Bored Ape #1159 was manually manipulated at some point after minting.

What Does it All Mean?

Where did these incorrect hashes come from? What image produces the hash that is listed 31 times? How has no one else noticed this before me?

Frankly, I don't know the answer to any of these questions. It's possible that there were some issues that came up during BAYC's image generation but were resolved before minting began. However, it is also possible that this means that assets were manipulated at some point after being minted, which would be extremely sus.

The former seems much more likely. Nevertheless, it's pretty suspicious and I really wish I could get an explanation directly from someone at Yuga Labs. It feels so wrong that I'm the first person to raise a concern about this.

The Bored Apes in question, based on my calculated hashes and accounting for the starting index offset of 8853, are numbers 885, 887, 892, 894, 895, 897, 1087, 1089, 1091, 1094, 1109, 1111, 1112, 1113, 1118, 1120, 1121, 1126, 1129, 1134, 1136, 1140, 1142, 1153, 1155, 1159, 1199, 1200, 1303, 2183, 5250, 8817.

Wrapping Up

I had a lot of fun learning about provenance and provenance hashes in this manner. Hopefully you found something of value in this post. If you'd like to follow along with the drama, I recently tweeted at the BAYC account in the hopes that someone in the community would see it and provide an explanation (or at least point me to someone who can). Any likes/retweets/follows are appreciated!

Thanks for reading!