Efficiently Storing IPFS Hashes In Our NFT

Kevin
Coinmonks
3 min readFeb 7, 2022

--

21MM Pixels NFT contract code.

One of the unique features of the 21MM Pixels contract is its ability to efficiently store image location data in an efficient manner. Many contracts use string storage to store links to files or data located on IPFS, or elsewhere. Storing strings is inefficient, as strings use UTF-8 encoding (requiring a byte per character), and are prefixed with its 256-bit length, requiring 64 bytes of storage for even the shortest string, and at least 3 bytes for the typical IPFS cidV1 hash.

In the 21MM Pixels NFT contract, we use a structure consisting of two bytes30, a uint16 for the bit size of the bytes30 to read, a bytes1 to denote the encoding, and a uint8 which stores information about the tiles status to fit all the information needed in two storage slots. Two bytes30 storage slots are larger than needed for an IPFS content identifier, or an Arweave transaction ID, but with the way the Ethereum Virtual Machine works, we’re paying gas for 64 bytes of storage once we pass the 32-byte mark, so we might as well future proof (with plenty of additional bytes1 identifiers available to denote future encodings).

In order to save gas costs when storing the information to link to the images that token (aka “tiles”) holders wish to set for their 21MMpixels tiles, we created view functions to convert IPFS cidV1 hashes and Arweave transaction ID’s to the bytes arrays to store with the 21MMpixels NFT setImage function, as well as the view function to display stored IPFS hashes and Arweave transaction ID’s in human-readable (and usable) form.

The cidv1ToBytes and arweaveTxIdToBytes view functions are similar, both taking a string as an input. Under the hood, cidv1ToBytes uses Base32 encoding (5 bits per letter), and arweaveTxIdToBytes uses Base64URL encoding (6 bits per letter). Each function first creates a bytes array from the input string (bytes memory bytesArray = bytes(input)). The functions then iterate through this array, converting the UTF-8 string characters to uint8 (essentially converting the character to its ASCII code), and then converting from that ASCII code to the Base32 or Base64URL equivalent uint8 (“a” in ASCII is 65, and is 0 in Base32/Base64URL). We use integer math for this conversion, as it is simpler than using arrays in Solidity

The new uint8 representation of the Base32/Base64 character(thisByte) is converted to bytes30 (tempBytes in our code) using the function bytes30(uint240(thisByte)). This tempBytes is left shifted such that the first character of the string occurs in the leftmost position (for Base32, this leftshif is tempBytes << (5 * (47 — i)) for the first 48 characters, where i denotes the character, with the first character of the string being i = 0). We then take the left shifted tempBytes and use the bitwise or operator (|) with the digests, for example, digest1 = digest1 | tempBytes. Since digest1 and digest2 have been initialized as bytes30, this has the effect of placing each tempBytes in its appropriate position without affecting the previously added characters.

For each of the view functions, digest1 and digest2 are returned, representing the two bytes30 variables, along with size, a uint16 which designates the number of bits used between the two digests for the encoding, and a bytes1 which designates the encoding (0x98 or 0x66 for Base32 IPFS hashes, and 0x01 for Arweave transaction IDs).

In a future article I’ll cover how we convert stored information back to IPFS hashes and Arweave transaction IDs for display. Feel free to reach out with any questions.

--

--