A Step-by-step guide to using the Diamond Standard (EIP-2535) to Create and Upgrade an NFT (Mar 2023)

Tom Lehman (middlemarch.eth)
24 min readMar 16, 2023

--

Diamonds are a powerful way to split your app’s logic across multiple contracts with minimal extra code.

When I started work on Capsule 21’s Babylon I knew I needed this capability. Babylon is a blockchain game where you explore a creepy library full of CryptoPunks, and I knew we would need the ability to add new gameplay features (and fix bugs) in the future.

After researching the options, I decided the Diamond Standard was the best approach. Then I spent about a week straight figuring out how Diamonds worked before I wrote my first line of code.

But then, once I started coding, something curious dawned on me:

While the Diamond Pattern can be difficult to understand, using the standard to write your own Diamonds is incredibly easy (and, dare I say, FUN!)

This article takes the “learn by doing” approach I wish I had used.

Together, we will use the Diamond Pattern to launch an NFT, add features, and fix bugs.

Our NFT is called Gas Lovers. The app stores the gas price of each mint and maintains a “leaderboard” of the people who love to pay high gas prices. The token’s rank on the leaderboard is included on the token’s image and in the metadata:

Gas Lovers is basically the contemporary mint degen culture version of this Andy Warhol Quote:

I like money on the wall. Say you were going to buy a $200,000 painting. I think you should take that money, tie it up, and hang it on the wall. Then when someone visited you the first thing they would see is the money on the wall.

—Andy Warhol

It is currently deployed on Mainnet. You can mint here. The OpenSea page is here.

You can find all the code for this NFT in this GitHub repo. This code reflects the end state of the project, so spoiler alert if you read it now!

The Basics: What is the Diamond Standard

All you need to know for now is that the Diamond Pattern enables you to split your app’s logic across multiple contracts.

The main reason to do this is to allow you to upgrade parts of your application without throwing away the whole thing, but it’s also helpful for code organization and getting around the single-contract 24kb size limit Ethereum enforces.

On to the app!

Literally Programming a Real Diamond NFT

Step 1: Set Up Your Environment

To start quickly we’re going to clone a repo from project called Scaffold Eth called se-2. Go to their GitHub page and follow the instructions:

  1. Clone the repo & install dependencies
git clone https://github.com/scaffold-eth/se-2.git
cd se-2
yarn install

2. Open a terminal window and run a local blockchain that forks mainnet (this is different from what they tell you to do, but you’ll see why we did this later)

yarn fork

3. Open a second terminal and deploy the test contract that comes with the app to make sure it works:

yarn deploy

4. Open a third terminal and start the NextJS app which you can use to debug the locally deployed contracts.

yarn start

Visit your app on: http://localhost:3000.

Click around and get a feel for the app. If you want, try making a few changes to YourContract.sol in packages/hardhat/contracts and make sure it works after another yarn deploy. Test the functions at http://localhost:3000/debug.

Now, remove a bunch of checks to make development faster:

  • Go to .husky/pre-commit and comment out yarn lint-staged --verbose.
  • Go to next.config.js and set ignoreBuildErrors, ignoreDuringBuilds in to true, and reactStrictMode to false.
  • Go to packages/nextjs/.eslintrc.json and set @typescript-eslint/no-unused-vars to ["off"]

If you want to deploy to Vercel, remove generated from packages/nextjs/.gitignore.

Now delete the sample contract packages/hardhat/contracts/YourContract.sol.

Finally, add the additional packages we’ll need for our NFT (remember to go back to the project root after you cd elsewhere):

cd packages/hardhat/ && yarn add solady \
@openzeppelin/contracts-upgradeable \
hardhat-deploy@^0.11.25

Step 2: Writing The Deploy Script

The deploy script will give you a picture of how the app fits together.

We’re using hardhat-deploy which uses a declarative approach to Diamond deployment. This means that instead of explicitly writing out every individual transaction we’ll need to deploy our app, we just tell hardhat-deploy the end state we want and let it figure out how to get there.

To run our deploy scripts we will use two deploy commands:

  • yarn deploy will take your app from where it currently is to the desired end state.
  • yarn deploy --reset will delete your app and rebuild it from scratch to the end state.

Each of these will run all the code in all the files in packages/hardhat/deploy in alphabetical order. Multiple files help us group logical versions of the app, but right now we just need one file, which is packages/hardhat/deploy/00_deploy_your_contract.ts. Go to it now.

Delete the DeployFunction function and replace it with ours:

const deployYourContract: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
const { deployer } = await hre.getNamedAccounts();
const { diamond } = hre.deployments;

await diamond.deploy("GasLover", {
from: deployer,
autoMine: true,
log: true,
waitConfirmations: 1,
facets: [
"InitFacet",
"MintFacet",
"RenderFacet",
"ERC721AUpgradeable",
],
execute: {
contract: 'InitFacet',
methodName: 'init',
args: []
},
})
};

The beauty hardhat-deploy’s syntax is that we can get a feel for how this app will be structured even with minimal knowledge of the Diamond Standard:

  • We will be creating an app called GasLover.
  • Its code will be split between four “Facets.” In Diamond Standard lingo, the contracts that hold your app’s code are called “Facets”.
  • The facets will have different responsibilities: one will handle app initialization, another will handle minting, a third rendering, and a forth the core ERC721 functions.
  • Once everything is hooked up correctly, our deploy will run the init() function on the InitFacet to set up our app.

Now you need to learn some some Diamond concepts that might not be obvious:

  • Each of our app’s public functions lives in exactly one of those four facets.
  • No two facets can implement the same public function. They can, and often do, implement the same internal functions.
  • None of our app’s code lives in a file called GasLover.sol. Everything that enables GasLover to route requests between our facets is handled by Diamond library code that lives in hardhat-deploy. We never see it. Relatedly, we could change the name GasLover in this deploy script to anything without having to update any other code.

Our application logic lives in the facets, so let’s write them!

Step 3: MintFacet.sol

Before we can write this we need a plan for how we will implement the leaderboard functionality.

How the Leaderboard Will Work

We want to be able to give everyone a rank based on the gas price they paid to mint, but of course mints won’t naturally be ordered by gas price, so we need to sort. Here’s how we will do it:

  1. When a user mints, push the gas price onto a uint[]. We will be using sequential ids, so we can always map token id to gas price by reading array[tokenId].
  2. In the tokenURI, pull this uint[] array into memory and sort it.
  3. Search the sorted array for the token’s gas price rank.

This approach is fine for a 10k collection, but it will have problems with large numbers of tokens. A red-black tree would be a more scalable approach, but it would make writes more expensive and because reads (outside of a transaction) are free (to a point) on Ethereum, sacrificing overall efficiency for write efficiency is usually worth it.

Now create a file in packages/hardhat/contracts called MintFacet.sol and paste in our mint facet:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import {ERC721AUpgradeableInternal} from "./ERC721AUpgradeable/ERC721AUpgradeableInternal.sol";
import {GasLoverStorage, WithStorage} from "./WithStorage.sol";
import {SafeCastLib} from "solady/src/utils/SafeCastLib.sol";

contract MintFacet is ERC721AUpgradeableInternal, WithStorage {
using SafeCastLib for *;

event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId);

function mint() external {
GasLoverStorage storage gs = s();

uint tokenId = _nextTokenId();

require(tokenId < gs.maxSupply, "Exceeds max supply");
require(msg.sender == tx.origin, "Contract cannot mint");

_mint(msg.sender, 1);

uint packed = packTokenInfo(tx.gasprice, block.timestamp, msg.sender);
gs.tokenIdToPackedInfo.push(packed);

if (tokenId != 0) emit BatchMetadataUpdate(0, tokenId - 1);
}

function packTokenInfo(uint gasPrice, uint timestamp, address creator) internal pure returns (uint) {
uint packedGasPrice = uint256(gasPrice.toUint64()) << 192;
uint packedTimestamp = uint256(timestamp.toUint32()) << 160;
uint packedCreator = uint256(uint160(creator));

return packedGasPrice | packedTimestamp | packedCreator;
}
}

This won’t compile of course because the imports don’t exist, but we’re going to try to understand what the imports do first.

Our ERC721 Base

Given this is a minter contract, we’re going to need an ERC721 base contract. Typically, you would inherit from ERC721A, or the upgradeable version ERC721AUpgradeable, but we are inheriting from something called ERC721AUpgradeableInternal.

This is because of a change that I made to ERC721AUpgradeable to make working with Diamonds easier. I split ERC721AUpgradeable into two files, one containing all public functions and the other all internal functions.

The reason for this is that I want all of my facets to be able to use ERC721A internal functions like _mint(), which means they need to inherit from something that implements _mint().

But if this “something” also implements a public function like transferFrom(), all of my facets will also inherit transferFrom(), which means that more than one facet will implement the same public function, which is not allowed.

Breaking off the public ERC721A functions is also nice because I can add all ERC721A public functions as a separate facet that my application code will never touch. If they were part of one of my facets, then I wouldn’t be able to upgrade that facet’s code without also redeploying all the ERC721A public functions.

Diamond Storage

Next, take a look at the line GasLoverStorage storage gs = s(). This is an instance of the Diamond Storage Pattern, which is how Diamonds typically handle application storage. It looks a bit weird, but it’s actually super-simple.

GasLoverStorage and s() come from WithStorage.sol. Here’s how this this setup world work in a non-Diamond with ordinary contract state variables:

// This code is not part of our NFT!
// It's demonstrating how a non-Diamond might do something

struct GasLoverStorage {
uint maxSupply;
}

GasLoverStorage private glStorage;

function s() internal view returns (GasLoverStorage storage) {
return glStorage;
}

Of course, in the example above, there would be no need for s() as you would just use glStorage directly.

Why does Diamond Storage require this level of indirection? To enable facets to share storage. Any facet can inherit from WithStorage, call s() , and get a storage pointer to the same exact struct.

For example, the code s().maxSupply = 10 will behave the same exact way no matter which facet it’s in. The Diamond Standard enables you to share application code without the overhead of worrying about which contract stores each piece of data.

Another example is _mint(). Any facet can call _mint() and it will work in the exact same way.

Compare this to a non-Diamond NFT. If you want two contracts to be able to mint the same NFT, you must first decide on the “main contract” which will store the ownership data and which can call _mint() directly.

Then, when another contract wants to mint, it will call a public function on the main contract instructing it to _mint(). Here’s how an non-Diamond NFT contract might give other contracts the ability to mint NFTs:

// This code is not part of our NFT!
// It's demonstrating how a non-Diamond might do something

mapping(address => bool) public minters;

function setMinter(address minter, bool newState) external onlyOwner {
minters[minter] = newState;
}

function mintFromMyOtherContract(address to) external {
require(minters[msg.sender]);
_mint(to, 1);
}

This kind of routing logic has nothing to do with your application, and the Diamond prevents you from having to write it or even see it. (If you’re thinking to yourself, “but that’s bad!” I discuss the topic in more detail below. But for now, stay with me!)

Uint Packing

The only other potentially tricky thing is that, instead of just storing the gas price of the mint transaction, I’m using packTokenInfo() to store the transaction’s timestamp and the address of the person doing the minting.

By choosing not to support gas prices larger than 64 bits, we can fit all of this info into one storage slot, which makes minting cheaper. Note that I’m using Solady’s SafeCastLib which will revert in the case that casting to a lower uint will lose data.

Finally, emit BatchMetadataUpdate(0, tokenId - 1) is important so that OpenSea and others refresh the tokenURIs (which have everyone’s rank) as these rankings might change on every mint.

Step 3: RenderFacet.sol

Now to render these beautiful NFTs!

Create a file in packages/hardhat/contracts called RenderFacet.sol and paste in our render facet:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import {DynamicBufferLib} from "solady/src/utils/DynamicBufferLib.sol";
import {Base64} from "solady/src/utils/Base64.sol";
import {LibString} from "solady/src/utils/LibString.sol";
import {LibSort} from "solady/src/utils/LibSort.sol";
import {ERC721AUpgradeableInternal} from "./ERC721AUpgradeable/ERC721AUpgradeableInternal.sol";
import {GasLoverStorage, WithStorage} from "./WithStorage.sol";

contract RenderFacet is ERC721AUpgradeableInternal, WithStorage {
using LibSort for uint[];
using DynamicBufferLib for DynamicBufferLib.DynamicBuffer;
using LibString for *;

function tokenURI(uint256 tokenId) public view returns (string memory) {
require(_exists(tokenId), "Token doesn't exist");

(uint rank, uint gasPrice, uint timestamp, address creator) = getAllTokenInfo(tokenId);

string memory svg = _tokenSVG(rank, gasPrice, creator);
string memory name = string.concat("Gas Lover Rank #", rank.toString());

return string(
abi.encodePacked(
'data:application/json;utf-8,{',
'"name":"', name, '",'
'"attributes":[',
'{"display_type": "number", "trait_type":"Rank","value":', rank.toString(), '},',
'{"display_type": "number", "trait_type":"Mint Tx Gas Price","value":', gasPrice.toString(), '},',
'{"display_type": "date", "trait_type":"Timestamp","value":', timestamp.toString(), '},',
'{"trait_type":"Creator","value":"', creator.toHexStringChecksummed(), '"}],',
'"image_data":"', svg,'"'
'}'
)
);
}

function getAllTokenInfo(uint tokenId) internal view returns (
uint rank, uint64 gasPrice, uint32 timestamp, address creator
) {
uint[] memory allPackedInfo = s().tokenIdToPackedInfo;
uint tokenPackedInfo = allPackedInfo[tokenId];

allPackedInfo.sort();

(, uint index) = allPackedInfo.searchSorted(tokenPackedInfo);

rank = _nextTokenId() - index;
gasPrice = uint64(tokenPackedInfo >> 192);
timestamp = uint32(tokenPackedInfo >> 160);
creator = address(uint160(tokenPackedInfo));
}

function weiToGweiString(uint weiAmount) internal pure returns (string memory) {
string memory wholePart = (weiAmount / 1 gwei).toString();
string memory decimalPart = ((weiAmount / 0.01 gwei) % 100).toString();

if (bytes(decimalPart).length == 1) decimalPart = string.concat("0", decimalPart);

return string.concat(wholePart, ".", decimalPart);
}

function _tokenSVG(
uint rank,
uint gasPrice,
address creator
) internal view returns (string memory) {
DynamicBufferLib.DynamicBuffer memory buffer;

string memory bgOpacity = string.concat('calc(', (rank - 1).toString(), ' / ', _nextTokenId().toString(), ')');

buffer.p(abi.encodePacked('<svg xmlns="http://www.w3.org/2000/svg" version="1.2" width="1200" height="1200" viewbox="0 0 1200 1200"><foreignObject x="0" y="0" width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" class="outer"><style>*{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;margin:0;border:0;box-sizing:border-box;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";color:#fff;font-size:40px;overflow:hidden}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}.outer{width:100%;height:100%;background-color:#0c4a6e;padding:3%;display:grid;grid-template-rows:repeat(3,minmax(0,1fr));place-items:center}.unit{display:flex;flex-direction:column;justify-content:center;align-items:center;height:100%;width:100%;gap:5px}.gradient{background-image:linear-gradient(to right,#ec4899,#ef4444,#eab308);color:rgba(255,255,255,', bgOpacity,');background-clip:text;-webkit-background-clip:text;font-weight:700;font-size:450%}.textlg{font-size:110%}.text3xl{font-size:230%}</style><div class="unit" style="justify-content:flex-start"><div>Mint Gas Price</div><div class="text3xl">', weiToGweiString(gasPrice),' gwei</div></div><div class="unit"><div class="gradient">Rank #', rank.toString(),'</div></div><div class="unit" style="justify-content:flex-end"><div>Minter</div><div class="textlg mono">', creator.toHexStringChecksummed(),'</div></div></div></foreignObject></svg>'));

return string.concat(
"data:image/svg+xml;base64,",
Base64.encode(
abi.encodePacked(
'<svg width="100%" height="100%" viewBox="0 0 1200 1200" version="1.2" xmlns="http://www.w3.org/2000/svg"><image width="1200" height="1200" href="data:image/svg+xml;base64,',
Base64.encode(buffer.data),
'"></image></svg>'
)
)
);
}

function tokenSVG(uint tokenId) external view returns (string memory) {
(uint rank, uint gasPrice, , address creator) = getAllTokenInfo(tokenId);

return _tokenSVG(rank, gasPrice, creator);
}
}

Again, don’t worry about the red squigglies and don’t get lost in the mechanics of unpacking the token data uint or the SVG life hacks I’m using. Let’s focus on the Diamond!

First, just as with MintFacet, we are inheriting from ERC721AUpgradeableInternal, WithStorage. This means we have all the same internal functions here that we had in MintFacet. Again, though facets cannot share public functions, they can (and should!) share internal functions!

RenderFacet just uses ERC721AUpgradeableInternal for _exists(), but again, it could also call _mint()!

The only time Diamond Storage is used is with uint[] memory allPackedInfo = s().tokenIdToPackedInfo. RenderFacet doesn’t need the “main contract” to manually pass it information about the token it needs to render. RenderFacet has direct access to all data available to any other facet and so it can just fetch the data it needs.

Compare this approach with the boilerplate you need if you want to manage a rendering contract manually instead of by using the Diamond Pattern:

// This code is not part of our NFT!
// It's demonstrating how a non-Diamond might do something

RenderContract renderer;

function setRenderer(RenderContract _renderer) external onlyOwner {
renderer = _renderer;
}

function tokenURI(uint tokenId) public view override returns (string memory) {
(uint rank, uint gasPrice, uint timestamp, address creator) = getAllTokenInfo(tokenId);

return renderer.renderTokenURI(
tokenId,
rank,
gasPrice,
timestamp,
creator
);
}

None of this has anything to do with what makes your application special, so why not let someone else (i.e., Nick Mudge, the creator of Diamonds) write it?

Step 4: Initializing our Diamond

Create a file called InitFacet.sol in packages/hardhat/contracts:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import {IDiamondLoupe} from "hardhat-deploy/solc_0.8/diamond/interfaces/IDiamondLoupe.sol";
import {IERC173} from "hardhat-deploy/solc_0.8/diamond/interfaces/IERC173.sol";
import {IERC165, IERC721, IERC721Metadata} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol";
import {UsingDiamondOwner, IDiamondCut} from "hardhat-deploy/solc_0.8/diamond/UsingDiamondOwner.sol";
import {ERC721AStorage} from "./ERC721AUpgradeable/ERC721AStorage.sol";
import {GasLoverStorage, WithStorage} from "./WithStorage.sol";

contract InitFacet is UsingDiamondOwner, WithStorage {
function a() internal pure returns (ERC721AStorage.Layout storage) {
return ERC721AStorage.layout();
}

function init() external onlyOwner {
if (s().isInitialized) return;

a()._name = "Gas Lovers";
a()._symbol = "GASLOVE";

s().maxSupply = 10_000;

ds().supportedInterfaces[type(IERC165).interfaceId] = true;
ds().supportedInterfaces[type(IDiamondCut).interfaceId] = true;
ds().supportedInterfaces[type(IDiamondLoupe).interfaceId] = true;
ds().supportedInterfaces[type(IERC173).interfaceId] = true;
ds().supportedInterfaces[type(IERC721).interfaceId] = true;
ds().supportedInterfaces[type(IERC721Metadata).interfaceId] = true;

s().isInitialized = true;
}
}

In the Diamond Standard you initialize with normal functions instead of constructors.

The only thing that makes init() an “initializer” is the fact that we set an isInitialized flag to prevent it from being run twice. However, this is a “soft limit.” We can always undo the flag by deploying a new facet (remember, all facets have access to the exact same storage, including this flag)!

If You Must Be An Owner To init(), Who Inits The Owner? (A: Coast Guard?)

If you’re familiar with other delegatecall-based upgradeable contract patterns, you might be surprised to see this function is protected with an onlyOwner modifier. This owner, which we get from UsingDiamondOwner, is the owner of the Diamond Contract and the address that’s allowed to add and remove facets.

But if init() is our initialization function, who initializes the Diamond Contract’s owner?! Thankfully that is not our problem.

This is approach makes your initializers much safer. This is because if you must manually declare yourself the owner of your contract, your initializer must be unprotected.

This leads to a whole class of “gotchas” where the unprotected initialization function gets called on the wrong contract by an attacker. Here’s one iconic example. These gotchas can be avoided as long as you remember to add a different function somewhere else, but they come up so often that you have to wonder whether the traditional initializer approach is fundamentally flawed.

In practice, however, you probably do want to initialize a different authorization scheme in init() because you might not want normal app-level ownership permission to be tied with permission to add and remove Diamond facets.

Initializing Storage

We need to initialize three Diamond Storage structs: ours, ERC721A’s and that of the Diamond base proxy contract (remember from the deploy script, this is the one we don’t have to edit or see, but we do have to initialize).

The storage mechanism is familiar. In the case of ERC721A we get a struct called Layout when we call the layout() function, so we first wrap this in a small internal function to let us use the same terse syntax we use for s() and ds().

ds() stores information about the Diamond as a whole. Here, we need to manually “register” the interfaces we support so that the Diamond knows how supportsInterface() should work.

Ideally we would let each facet define its own supportsInterface() based on what it does. However, supportsInterface() is public and 🎶 multiple facets are not allowed to implement the same public function 🎶. Manually updating a central mapping is the workaround to “merge” the different interfaces the facets support.

Note: while this is annoying, if you make a mistake you can deploy a new facet to fix it. By contrast, if you set up supportsInterface() incorrectly in a non-Diamond, which you can easily do if you don’t read the docs, you’re screwed.

Step 5: The Storage Layout

Create a file called WithStorage.sol in packages/hardhat/contracts:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
import {LibDiamond} from "hardhat-deploy/solc_0.8/diamond/libraries/LibDiamond.sol";

struct GasLoverStorage {
uint maxSupply;
bool isInitialized;
uint[] tokenIdToPackedInfo;
}

contract WithStorage {
function s() internal pure returns (GasLoverStorage storage cs) {
bytes32 position = keccak256("gas.lovers.nft.contract.storage");
assembly {
cs.slot := position
}
}

function ds() internal pure returns (LibDiamond.DiamondStorage storage) {
return LibDiamond.diamondStorage();
}
}

If you want to learn the details of how Diamond Storage works, Google it. However, if you want to use Diamond Storage, all you need to know is this:

  1. If you want a new struct in addition toGasLoverStorage, define the new struct and copy-paste the s() getter function, changing the gas.lovers.nft.contract.storage string to something unique for each struct.
  2. You can also add members to existing structs as long as the new members go below the struct’s existing members and you adhere to a few more caveats you can find by Googling. Also, if you do this, you will have to recompile and redeploy everything that uses the old struct layout, which can be expensive.

Overall my recommendation is don’t add members to existing structs. Just create new top-level structs (remember to change the “magic string”!). Changing existing structs isn’t worth the headache required to make sure you’re doing it right.

Step 6: ERC721A

Now we need to add our custom version of ERC721A.

Download the ERC721Upgradeable directory in the repo, create a new folder in packages/hardhat/contracts called ERC721AUpgradeable, and add the files you downloaded. (The easiest way to download a directory from Github is to use their web VSCode editor).

I’m not going to walk you through how I created this modified version of ERC721A, but you can recreate this setup yourself. First, separating internal and public functions into different files.

Next, delete supportsInterface() and tokenURI() from the file that has the public functions. supportsInterface() is implemented by the main Diamond proxy, and I wanted to define tokenURI() in RenderFacet without having to inherit all ERC721 public methods and overriding tokenURI().

If you want a custom hook like _beforeTokenTransfers() you will have to change this and inherit all the ERC721 functions in one of your facets and override the hook.

If you want a general-purpose, non-sequential id ERC721 base contract, you can check out solidstate’s. However, this implements ERC721Enumerable, which might not be what you want. I’ve also written a general-purpose non-ERC721Enumerable base contract called ERC721D which I’ve included in the repo.

OpenZeppelin’s ERC721Upgradeable is not Diamond-compatible, so don’t use it.

Step 7: BOOOOOOMMMMM!

And that’s our app!

Now you can finally compile it with yarn compile and deploy it with yarn deploy --reset! If you played with YourContract.sol before deleting it you might have to run cd packages/hardhat/ && yarn hardhat clean to make your deploy work.

If you run yarn deploy you should have a working Diamond you can debug in the Scaffold Eth UI.

Note: When you load this UI you will see a list of all your facets at the top. However, do not interact with them directly! Instead, the only contract you should ever call is the GasLover Diamond. So click this button:

Click the top-right button get fake ETH:

Scroll down to mint() and click SEND:

Set whatever gas you want, or, if you’re using the “burner wallet,” do nothing.

Now enter 0 into the tokenSVG reader:

Copy that base64 URI, make sure you remove the enclosing quotes, and paste it into a new browser tab to see your image:

Pretty cool! You can also deploy on Goerli and Mainnet from se-2, but I’m not going to get into that here.

Unfortunately, you will not be able to use Etherscan to view your Diamond. Instead, you should use a tool called Louper, which looks like this.

What’s Next? Oh Nothing, Just Living The “Diamond Dream” (Upgrading Our Diamond)

The killer feature of Diamonds is that they making upgrading contracts easy.

By “upgrading” here I mean changing arbitrary parts of the code in ways that the original design didn’t necessarily anticipate, not swapping out pre-defined helper contracts (e.g., upgrading the render contract, changing price oracles, etc).

But first: should we even be upgrading contracts in the first place? There are a lot of strong feelings on this one.

Briefly, the “anti-upgrades” argument is that upgrades are “trustful” in that you need to trust the upgrader to not deploy an upgrade that steals all your money or accidentally loses all your money.

The “pro-upgrades” argument is that using an non-upgradeable contract is also “trustful” in that you need to trust that the original author didn’t do anything bad.

So, who do you trust more, the developer who deployed the contract, or the developer who upgrades it? The upgrader has more information, but is dealing with more complexity. Also you know less about their values and goals and whether they align with yours.

One thing you can be certain of: the deployer and upgrader are not the same person. As Heraclitus said (I’m paraphrasing here):

No dev ever works on the same smart contract twice, for it’s not the same contract and they are not the same dev.

—Heraclitus

Given that (a) it’s impossible to write bug free code (b) it’s impossible to determine what code will do without testing it, and (c) it’s impossible to test all realistic use-cases, in the end there are no easy answers and every dev must do the cost-benefit analysis for themselves.

A good litmus test to figure out where you stand is your perspective on accidentally locking 11,539 ETH in a contract forever. Is the system working correctly in this case? Or maybe it would be good to be able to fix this and get the millions of dollars out.

Upgrade #1: Adding a New Feature To a Diamond NFT

It would be nice if the token images showed the minter’s ENS instead of just their address. How can we add this feature post-deploy? By creating a new facet!

First, add the ENS contracts:

cd packages/hardhat/ && yarn add @ensdomains/ens-contracts

Next, create a file called RenderFacetWithENS.sol in packages/hardhat/contracts:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import {DynamicBufferLib} from "solady/src/utils/DynamicBufferLib.sol";
import {Base64} from "solady/src/utils/Base64.sol";
import {LibString} from "solady/src/utils/LibString.sol";
import {LibSort} from "solady/src/utils/LibSort.sol";
import {ERC721AUpgradeableInternal} from "./ERC721AUpgradeable/ERC721AUpgradeableInternal.sol";
import {GasLoverStorage, WithStorage} from "./WithStorage.sol";
import {UsingDiamondOwner} from "hardhat-deploy/solc_0.8/diamond/UsingDiamondOwner.sol";
import {ENS} from '@ensdomains/ens-contracts/contracts/registry/ENS.sol';
import {ReverseRegistrar} from '@ensdomains/ens-contracts/contracts/registry/ReverseRegistrar.sol';
import {Resolver} from '@ensdomains/ens-contracts/contracts/resolvers/Resolver.sol';

contract RenderFacetWithENS is ERC721AUpgradeableInternal, WithStorage, UsingDiamondOwner {
using LibSort for uint[];
using DynamicBufferLib for DynamicBufferLib.DynamicBuffer;
using LibString for *;

event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId);

function addressToEthName(address addr) internal view returns (string memory) {
address reverseResolverAddress = block.chainid == 5 ?
0xD5610A08E370051a01fdfe4bB3ddf5270af1aA48 :
0x084b1c3C81545d370f3634392De611CaaBFf8148;

ENS ens = ENS(0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e);
ReverseRegistrar reverseResolver = ReverseRegistrar(reverseResolverAddress);

bytes32 node = reverseResolver.node(addr);
address resolverAddr = ens.resolver(node);

if (resolverAddr == address(0)) return addr.toHexStringChecksummed();

string memory name = Resolver(resolverAddr).name(node);

bytes32 tldNode = keccak256(abi.encodePacked(bytes32(0), keccak256(bytes("eth"))));

bytes32 forwardNode = keccak256(abi.encodePacked(tldNode, keccak256(bytes(name.split(".")[0]))));

address forwardResolver = ens.resolver(forwardNode);

if (forwardResolver == address(0)) return addr.toHexStringChecksummed();

address resolved = Resolver(forwardResolver).addr(forwardNode);

if (resolved == addr) {
return name;
} else {
return addr.toHexStringChecksummed();
}
}

function initENS() external onlyOwner {
if (_nextTokenId() > 0) emit BatchMetadataUpdate(0, _nextTokenId() - 1);
}

function tokenURI(uint256 tokenId) public view returns (string memory) {
require(_exists(tokenId), "Token doesn't exist");

(uint rank, uint gasPrice, uint timestamp, address creator) = getAllTokenInfo(tokenId);

string memory svg = _tokenSVG(rank, gasPrice, creator);
string memory name = string.concat("Gas Lover Rank #", rank.toString());

return string(
abi.encodePacked(
'data:application/json;utf-8,{',
'"name":"', name, '",'
'"attributes":[',
'{"display_type": "number", "trait_type":"Rank","value":', rank.toString(), '},',
'{"display_type": "number", "trait_type":"Mint Tx Gas Price","value":', gasPrice.toString(), '},',
'{"display_type": "date", "trait_type":"Timestamp","value":', timestamp.toString(), '},',
'{"trait_type":"Creator","value":"', creator.toHexStringChecksummed(), '"}],',
'"image_data":"', svg,'"'
'}'
)
);
}

function getAllTokenInfo(uint tokenId) internal view returns (
uint rank, uint64 gasPrice, uint32 timestamp, address creator
) {
uint[] memory allPackedInfo = s().tokenIdToPackedInfo;
uint tokenPackedInfo = allPackedInfo[tokenId];

allPackedInfo.sort();

(, uint index) = allPackedInfo.searchSorted(tokenPackedInfo);

rank = _nextTokenId() - index;
gasPrice = uint64(tokenPackedInfo >> 192);
timestamp = uint32(tokenPackedInfo >> 160);
creator = address(uint160(tokenPackedInfo));
}

function weiToGweiString(uint weiAmount) internal pure returns (string memory) {
string memory wholePart = (weiAmount / 1 gwei).toString();
string memory decimalPart = ((weiAmount / 0.01 gwei) % 100).toString();

if (bytes(decimalPart).length == 1) decimalPart = string.concat("0", decimalPart);

return string.concat(wholePart, ".", decimalPart);
}

function _tokenSVG(
uint rank,
uint gasPrice,
address creator
) internal view returns (string memory) {
DynamicBufferLib.DynamicBuffer memory buffer;

string memory bgOpacity = string.concat('calc(', (rank - 1).toString(), ' / ', _nextTokenId().toString(), ')');

buffer.p(abi.encodePacked('<svg xmlns="http://www.w3.org/2000/svg" version="1.2" width="1200" height="1200" viewbox="0 0 1200 1200"><foreignObject x="0" y="0" width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" class="outer"><style>*{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;margin:0;border:0;box-sizing:border-box;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";color:#fff;font-size:40px;overflow:hidden}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}.outer{width:100%;height:100%;background-color:#0c4a6e;padding:3%;display:grid;grid-template-rows:repeat(3,minmax(0,1fr));place-items:center}.unit{display:flex;flex-direction:column;justify-content:center;align-items:center;height:100%;width:100%;gap:5px}.gradient{background-image:linear-gradient(to right,#ec4899,#ef4444,#eab308);color:rgba(255,255,255,', bgOpacity,');background-clip:text;-webkit-background-clip:text;font-weight:700;font-size:450%}.textlg{font-size:110%}.text3xl{font-size:230%}</style><div class="unit" style="justify-content:flex-start"><div>Mint Gas Price</div><div class="text3xl">', weiToGweiString(gasPrice),' gwei</div></div><div class="unit"><div class="gradient">Rank #', rank.toString(),'</div></div><div class="unit" style="justify-content:flex-end"><div>Minter</div><div class="textlg mono">', addressToEthName(creator),'</div></div></div></foreignObject></svg>'));

return string.concat(
"data:image/svg+xml;base64,",
Base64.encode(
abi.encodePacked(
'<svg width="100%" height="100%" viewBox="0 0 1200 1200" version="1.2" xmlns="http://www.w3.org/2000/svg"><image width="1200" height="1200" href="data:image/svg+xml;base64,',
Base64.encode(buffer.data),
'"></image></svg>'
)
)
);
}

function tokenSVG(uint tokenId) external view returns (string memory) {
(uint rank, uint gasPrice, , address creator) = getAllTokenInfo(tokenId);

return _tokenSVG(rank, gasPrice, creator);
}
}

The only change here is the addition of the addressToEthName() function and the modification of the SVG generation logic to use it.

(No, I don’t know a simpler way to implement addressToEthName() functionality. Yes, I have asked Solady to do it).

Now create a file called 01_add_ens_to_render_facet.ts in your deploy directory. Copy-paste the 00_deploy_your_contract.ts version and replace the diamond.deploy() call with this:

await diamond.deploy("GasLover", {
from: deployer,
autoMine: true,
log: true,
waitConfirmations: 1,
facets: [
"InitFacet",
"MintFacet",
"RenderFacetWithENS",
"ERC721AUpgradeable",
],
execute: {
contract: 'RenderFacetWithENS',
methodName: 'initENS',
args: []
},
})

More declarative clarity: we don’t have to explicitly say “remove the old RenderFacet.” We just say “we want to end up with these four facets” and hardhat-deploy takes care of the “how.”

Finally, we execute initENS() to update the images for already-minted tokens.

Now run yarn deploy (not yarn deploy --reset as we are upgrading our existing deployment, not creating a new one!). Note in the deploy log how all existing facets are being reused. The only change is deploying the new facet and telling the Diamond to use it (which is called a “Diamond Cut” in Diamond lore lingo).

As long as you started your local blockchain with yarn fork, you should be able to test this locally. Go back to http://localhost:3000/debug and request the tokenSVG for the same token you minted pre-upgrade. If you minted it with an account that has a mainnet ENS, you should see the image has updated!

Upgrade #2: Fixing a Horrible Bug

Unfortunately, becase our NFT contains no bugs to fix, it is not possible for me to write this section. (Kidding! I make no representations about this code! Beware!).

To give us something to talk about, let’s invent a bug. In fact, let’s invent the worst possible bug: you forgot to add a withdraw function!

How do we fix this? Another facet!

Create WithdrawFacet.sol in packages/hardhat/contracts:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol";
import {UsingDiamondOwner} from "hardhat-deploy/solc_0.8/diamond/UsingDiamondOwner.sol";

contract WithdrawFacet is UsingDiamondOwner {
using SafeTransferLib for address;

function withdraw() external onlyOwner {
// Change this address unless you want to send your money to me!
// Keep it the same if you do!
address middleMarch = 0xC2172a6315c1D7f6855768F843c420EbB36eDa97;
middleMarch.safeTransferETH(address(this).balance);
}
}

Now add a new deployment script 02_add_withdraw_facet.ts, paste in one of your existing scripts, and replace the diamond.deploy() with this:

await diamond.deploy("GasLover", {
from: deployer,
autoMine: true,
log: true,
waitConfirmations: 1,
facets: [
"InitFacet",
"MintFacet",
"RenderFacetWithENS",
"WithdrawFacet",
"ERC721AUpgradeable",
]
})

Run yarn deploy and now you can get your money out!

Note: while hardhat-deploy’s declarative syntax is very clear, once you have multiple deploy scripts you might start caring about not just where your diamond ends up, but also how it gets there.

To ensure that you’re not doing any unnecessary Diamond Cuts, I recommend using hardhat-deploy’s upgradeIndex feature. This will enable you to fully control the order of each deploy and ensure they only run once.

And that’s it!

Wow, This Was An Amazing Article! I’m Ready to Leave My Family And Devote My Remaining Years To Diamond Evangelism! Are Diamonds Really Perfect?

Unfortunately, Diamonds aren’t perfect. There are tradeoffs.

Cost

One downside to Diamonds is that they cost more gas to deploy and upgrade than non-Diamonds.

For example, recall the non-Diamond rendering contract approach has this boilerplate:

// This code is not part of our NFT!
// It's demonstrating how a non-Diamond might do something

function setRenderer(RenderContract _renderer) external onlyOwner {
renderer = _renderer;
}

While this approach means writing more boilerplate and also defining in advance and forever which helper contracts you will need and what they will do, when you want to swap out a helper contract it’s just one storage write.

By contrast, when you replaced RenderFact with RenderFacetWithENS, you had to do a storage write for every function you wanted to change or remove.

You can mitigate this cost by writing smaller facets. Also, in principle, it is not “all or nothing” with facets. The Diamond Standard allows you to use any number of a facet’s functions. Unfortunately, today this is not possible with hardhat-deploy. However the author is apparently working on it!

Explicit v. Implicit

There are those who think that avoiding setRenderer()-type boilerplate is bad even if it were free. To them, what I would call “boilerplate,” they would call “explicitly describing what your application does,” and being explicit makes your code easier to understand.

The other side of the coin is that being more explicit requires writing more code, and the more code you write the more mistakes you will make!

Again, it’s a balance. We could write all our contracts in assembly if we wanted to be explicit about memory management. Conversely, we could (in principle) write our contracts in Ruby where the functions you call don’t even need to be defined before you call them! Even I don’t think Ruby is a good choice for Blockchain development.

Your Audit Budget

If you have a large audit budget, first of all, congratulations!

Audits are more effective on initial deploys than upgrades because auditing upgrades requires knowledge of the history of the project in addition to how the new code works.

Because of this, if audits are important to you, you should consider whether the Diamond approach will give you enough “bang for your audit-buck.”

Library Diligence

With Diamonds you must ensure every library you use uses Diamond Storage. Fortunately Diamond Storage is the best way to support any kind of delegatecall-based upgradeable contract, not just Diamonds, so I anticipate more and more libraries will come around.

And That’s It!

Do you have comments on this article? Did I make a mistake? Was I more correct about something than I gave myself credit for?

My Twitter DMs are open!

Oh, and

PLAY CAPSULE 21’S BABYLON!!

Thanks to @outerpockets for giving me feedback on a draft, to @squeebo, for helping me get started with Diamonds, and to @optimizoor for Solady, the greatest Solidity library of all time!

Ps. If you want to share this article, you can use this “social image”:

--

--

Tom Lehman (middlemarch.eth)

Facet co-founder | Facet Discord: https://discord.gg/facet | Ethscriptions co-founder | @_capsule21 co-founder | http://Genius.com co-founder & former CEO