Using Signatures (ECDSA) for NFT Whitelists

Alan
9 min readFeb 19, 2022

--

Introduction

In my previous article, I covered the concept of Merkle Trees and discussed how they played a critical role in ensuring both minter authenticity and inclusion when participating in an NFT whitelist mint. Today, I will cover a second and increasingly popular method which utilises digital signatures using the Open Zeppelin ECDSA library. For those who are curious, ECDSA stands for Elliptic Curve Digital Signature Algorithm. Feel free to follow along with the code that can be found in the GitHub repository for this article.

Fantastic, But What’s A Signature?

Before diving headfirst into the code, I believe it’s always important to understand the core concept of the mechanic you are attempting to implement. I’ll do my best to explain signatures in such a way that no matter your background, technical or not, by the conclusion of this article, you’ll have an efficient understanding of signatures and the way in which they guarantee minter authenticity and inclusion.

To provide some loose context, in the real world, we know that important documents such as a bank loan would most definitely require the borrower to provide a signature on some form of documentation from the lender to effectively acknowledge that they are participating in some form of transaction. Following this, you would then expect the borrower to then receive some amount of money from the lender given that everything checks out.

Figure 1. Signatures!

Yes, I know! The real world isn’t perfect and signatures can be forged with some amount of time and effort, but the core concept remains the same. Some documents (a message) are provided to the borrower (the signer) from the lender (the verifier). Keep a mental note of these 3 terms, as you’ll see them frequently mentioned throughout the article.

On-chain is no different. You can imagine yourself as a message provider which provides some data to a signer. This data is often processed, pre-signing, typically into a bytes32 hash (the message). This hash is then effectively signed by the signer using the signer’s private key ultimately returning a signature. From this point onwards, both the hash and signature are provided to a smart contract for verification where some fancy code is executed and the public key (wallet address) of the signer can be recovered using these two pieces of information.

Important Things To Note

Before continuing, I think it’s incredibly important to clarify some points made in the last paragraph. They are…

  • A signer is just a regular Ethereum wallet, it SHOULD however be a throwaway wallet that is never used to make transactions on the main Ethereum network. The reasoning for this is at some point the private key for this wallet will need to be stored somewhere either in RAM or ROM on your local machine, more on that later.
  • For complete verification of the signature, the signer’s wallet address is stored in the smart contract either at deployment or set at a later point in time and compared to the address that is recovered.
  • Everything up until providing the hash and the signature to the smart contract is done off-chain. Since we will be looking at signatures in a whitelist mint context, where all participating wallet addresses and mint quantities are known ahead of time, this will be a back-end less implementation. Implementing signatures in a public mint context will vary to the implementation provided below.
  • The way in which signatures will be demonstrated in this article will only require passing a signature to the minting function as the original hash which was created pre-signing will be replicated on-chain.

Context

With all that out of the way, we can now begin looking into the technical side of things to gain a deeper understanding of signatures and how they work. Let’s start by providing some context of our whitelist scenario and the requirements it must fulfil.

  1. Knowledge of all participating wallet addresses is known ahead of time.
  2. Whitelisted addresses can mint a maximum of 3 tokens either through a single transaction or multiple.
  3. The implementation must be protected from signature replay attacks.

Signing Script

As previously mentioned, the approach we are taking will be back-end less (not require an API) and only requires a signature to be passed to our smart contract rather than both a signature and a hash. The single key piece of information we will be using to generate our bytes32 hash within our signing script will be the whitelist minters wallet addresses which will be stored in the addresses.json file.

The benefit of using a callers wallet address in this hash generation process is that we know that the msg.sender value is immutable and cannot be altered. Since the hash will be replicated on-chain using this value, this alone protects us from the ability for a non-whitelisted user to exploit our contract through means of a signature replay attack in which a valid signature is reused by a non-whitelisted user.

Figure 2. The signature object returned from the sign() method.

The signing script itself is quite simple in nature, it simply recreates a string type message containing the whitelisted wallet address without the 0x prefix on the left-hand side. This message is then signed using the signer’s private key using the Web3 package’s sign() method which ultimately returns a object (Figure 2) containing the original message that was hashed, the hash of the original message, the signature, and various other information. The signature key value of this data is then placed into a JSON object where the wallet address that has just been signed acts as our key. This JSON object is then written to some arbitrary named file which we will later use on our front-end. Let’s call it signatures.json.

Figure 3. Running the script.

Technical Insight #1

So at this point, you may have a few questions. Why is the address stored on the right-hand side? Why is there 24 leading zeros on the left-hand side? Let me explain.

Before I continue, it is important to note that a bytes32 type variable is 64 characters long (00 = 1 byte) and is the same size as a uint256 type variable, both are 256 bits long. Ultimately, we need to create a message that is of type bytes32. By design, Solidity is a language that stores bytes in big-endian format. This infers that the most significant bytes will be stored in the most significant memory location.

Due to an Ethereum wallet address being 40 hex characters long without the 0x prefix, and a single hex character being equivalent to 4 bits, we can determine that storing the address in our bytes32 variable will only consume 20 of the 32 available bytes (40 * 4 = 160 bits/20 bytes). Due to 00 being equivalent to a single byte and 12 of the original 32 bytes being unused, this explains why our message has 24 leading zeros.

Figure 4. Feelsbadman.

Website Implementation

I won’t lie to you. I have extremely limited front-end experience as most of the work I have undertaken during my time is strictly related to the back-end and/or smart contracts. Whilst I can’t provide a visual example, I’ll explain to you the flow from the perspective of a whitelisted user.

  • The previously mentioned signatures.json file is stored on your front-end.
  • A user visits your minting page during your whitelist claim and connects their wallet.
  • After a connection has been made, your front-end code then takes the connected wallet address and attempts to return the associated signature from the signatures.json file.
  • If a signature has been found, enable the mint button and provide this signature as a parameter in the minting transaction.
  • If there is no signature associated with the connected wallet address, keep the mint button disabled as this is not a whitelisted user.

Verifying The Signature On-Chain

Here’s where the fun part begins, we can now start to look at how this signature is verified on-chain to confirm minter inclusion using the Open Zeppelin ECDSA library. Let’s start by taking note of the key changes that allow this library to be used within this contract, feel free to follow along as the line numbers from the smart contract are referenced.

  • Line 7 — Import statement that allows the ECDSA library to be used within our smart contract.
  • Line 11 — Association of the ECDSA library with variables of type bytes32.
  • Line 20 — Defining the _signerAddress private variable of type address.
  • Line 25/27 — Setting a value to the _signerAddress variable within the constructor, this is optional and can be done at a later point in time user a setter method.
  • Line 35–40 — This is where the magic happens. These 5 lines of code are responsible for replicating our original pre-signed message on chain, converting that message to an Ethereum signed message, recovering the signer’s public key (wallet address), and confirming that the recovered signer address matches our intended signer.
Figure 5. Where the magic happens.

Let’s hone in for a second on Line 38 and simultaneously refer to Figure 2. After this line of code has executed, the bytes32 value will be identical to the message key value and the keccak256 hash of this prefixed message (Line 35) will match the messageHash key in the shown object. Calling the ECDSA .recover() method on this hash, remember that we associated the ECDSA library with variables of type bytes32, we can provide the signature that was passed as the second argument to the minting function to successfully recover the public key (wallet address) of the account that signed this message.

Technical Insight #2

So at this point, you may have a few MORE questions. For a great read on why \x19Ethereum Signed Message:\n32 is prefixed to the replicated message, I recommend checking out the opening statements of this article written by @RicMoo, who addresses the purpose for this in an easy to digest manner. The reason that this prefix is also not included in our signing script is because the sign() method of the Web3 package automatically prefixes our passed message with this. Alternatively, you may also be asking yourself what’s with the casting of the msg.sender value? Let me explain.

Figure 6. Computer does indeed say no sometimes.

The reason that the msg.sender value is initially casted to a uint160 than a uint256 rather then directly to a uint256 is due to… well… you simply can’t cast an address type directly to a uint256. However, there is a little more explaining to do. If you recall as previously mentioned, an Ethereum wallet address without the 0x prefix is exactly 160 bits long. So natively, as well as according to the Solidity documentation (Figure 7), we can cast the msg.sender value to a uint160. From this point, we can cast this uint160 to a uint256 and then to a bytes32. Doing this in such a manner also provides us with the correct endianness.

Figure 7. Casting of the address type.

The Home Stretch

Given that all the above has gone well and a valid signature has been provided to the function, a whitelisted user will be able to mint their amount of allocated tokens successfully. The only way for this function to execute without reverting is to provide a valid signature that is associated with the on-chain generated message which is derived from the msg.sender value. This will effectively eliminate any chances for a non-whitelisted user to call this function given that the signing wallet account has not been compromised in such a way that users can sign their own messages.

Closing Thoughts

There you have it, an explanation on how to effectively use signatures for your NFT whitelist mint. Before closing out the article, I just want to give a special shout-out to Jeffrey Scholz. Jeff has been a great thought leader in the space and has written amazing articles on a different strategy for NFT whitelist minting using signatures, I strongly recommend checking out Part 3 of his “Hardcore Gas Savings in NFT Minting” series.

To wrap things up. Like you, I am also learning more with each passing day and the motivation to write this article stemmed from wanting to share my learning journey into the world of Solidity. I hope you enjoyed this article as much as I did writing it and am looking forward to seeing you in the next one. If you learnt something new, or enjoyed reading it, please feel free to applaud and follow me on Twitter where I often share updates on things I have learnt and what I am currently working on. See you in the next one! ✌️

--

--

Alan

Blockchain Engineer | 1/2 of Boost Labs | Spending each day learning something new and interesting, interested in the EVM and Blockchain Security.