Engineering December 17, 2024 7 min read

A Beginner’s Guide To Securing Smart Contracts Using Digital Signature Whitelisting

source: Unsplash

How We Ended Up Here

In decentralized blockchain applications, security is ingrained in the very fabric of the system. After all, smart contracts are digital immutable contracts where every transaction is processed by a decentralized network. Every node needs to agree on the outcome for a successful transaction. But what if you need an extra layer of security, like a digital Gandalf guarding the entrance, only allowing the chosen ones to pass?

source: Giphy
source: Giphy

Recently, we’ve experimented with smart contracts and stumbled upon this challenge. A way to make sure only our team members have access to specific features. This is where whitelisting comes into play.

So, what is whitelisting, and why should you care about it? In this article, we explain whitelisting from a beginner’s standpoint. We’ll tell you what it is, why it’s a great tool for securing your smart contracts, and how to implement it in a simple and straightforward way.

What is Whitelisting?

Whitelisting is the process of creating a list of trusted entities or individuals who are granted special access or privileges, a.k.a. a ✨ VIP list ✨.

By implementing a whitelist, you control who can do what and can gain access to certain parts of your smart contract. For example, if you wanted to implement a game, you could use whitelisting to control which users can join your game, and then give them access to specific features depending on their level.

How to Implement Whitelisting in Smart Contracts?

How do you implement whitelisting in your smart contract? The short answer is, it depends. There are many different ways to implement whitelisting and the best one will depend on your specific use case. In this section, we explore ways of implementing whitelisting, and list their pros and cons, as well as some example use cases of how to code them.

source: Tenor
source: Tenor

On-Chain Whitelisting

When you think about creating a list of users with access to your smart contract, the first solution that comes to mind is probably generating a list of addresses and then checking if the address of the message sender is in the list. Then you just need a couple of functions to manage those addresses and that’s it!

Code
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";

/**
 * @title OnChainWhitelist
 * @notice Manages and validates whitelisted addresses
 */
contract OnChainWhitelist is Ownable {
    constructor() Ownable(msg.sender) {}

    /// @notice Map of whitelisted addresses
    mapping(address => bool) public whitelist;

    /**
     * @notice Adds given addresses to the whitelist
     * @param toAddAddresses addresses to add
     */
    function addToWhitelist(
        address[] calldata toAddAddresses
    ) external onlyOwner {
        for (uint i = 0; i < toAddAddresses.length; i++) {
            whitelist[toAddAddresses[i]] = true;
        }
    }

    /**
     * @notice Removes given addresses from the whitelist
     * @param toRemoveAddresses addresses to remove
     */
    function removeFromWhitelist(
        address[] calldata toRemoveAddresses
    ) external onlyOwner {
        for (uint i = 0; i < toRemoveAddresses.length; i++) {
            delete whitelist[toRemoveAddresses[i]];
        }
    }

    /**
     * @notice Validates if msg sender is in the whitelist
     */
    function validateAccess() external view {
        require(whitelist[msg.sender], "Computer says no.");
    }
}

In this contract, we can use the Ownable contract module provided by Open Zeppelin to manage the access control of our contract and guarantee that only the owner of the contract can manage the whitelist. Once that is done, we can add and remove addresses from the list with a couple of functions and create another to validade if a certain message sender is part of our “VIP list”.

And we are done! Roll the credits, see you in the next one! 👋

Just kidding. The thing is… there is one big problem with this solution. Your smart contract is not just a script running on your local machine but instead something that will be validated by many machines, and those machines don’t work for free — someone needs to pay the gas bill. Literally.

Every transaction that changes the blockchain has gas fees, and keeping and managing a big list of addresses this way is the most expensive way to keep a whitelist.

If you want to learn more about how much each one of the main whitelisting solutions would cost, I recommend reading the FreeCodeCamp article about whitelisting. The article simulates the average cost of each transaction and explains how to calculate gas costs based on gas units. This is important to know if you want to start working with smart contracts — but don’t want to go bankrupt.

Although this solution is the easiest to implement, it is not the best for managing a large whitelist that changes often, which is our case. So we need a better solution.

Digital Signature Whitelisting

Before going into the whitelisting part, let’s first learn about digital signatures. In the digital world, a digital signature is like a fingerprint. It’s created using a mathematical algorithm that ensures the authenticity and integrity of the data. This means that when someone digitally signs a document it is the equivalent of saying, “I approve of this message, and it has not been tampered with.”

In smart contracts, we use these digital signatures to control user access in a very simple way. For that, we need a trusted address to create our signatures — we call it the signer address. Then, we need to give away those signatures to the users so they can use them when accessing our contract, kind of like an access token.

Code
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

/**
 * @title DigitalSignatureWhitelist
 * @notice Validates whitelisted addresses using digital signatures
 */
contract DigitalSignatureWhitelist is Ownable {
    using ECDSA for bytes32;
    using MessageHashUtils for bytes32;

    /// @notice Address used to validate whitelisted addresses
    address private signerAddress;

    constructor(address _signerAddress) Ownable(msg.sender) {
        signerAddress = _signerAddress;
    }

    /**
     * @notice Verify signature
     * @param signature digital signature to verify
     */
    function verifyAddressSigner(
        bytes memory signature
    ) private view returns (bool) {
        bytes32 messageHash = keccak256(abi.encodePacked(msg.sender));
        return
            signerAddress ==
            messageHash.toEthSignedMessageHash().recover(signature);
    }

    /**
     * @notice Validates if user can access a feature using a digital signature
     * @param signature digital signature to validate
     */
    function validateAccess(bytes memory signature) external view {
        require(verifyAddressSigner(signature), "Computer says no.");
    }
}

Generating the signatures will be as simple as this:

Code
const ethers = require("ethers");

async function generateSignature(signerPrivateKey, userAddress) {
    const signer = new ethers.Wallet(signerPrivateKey);
    const addressHash = ethers.solidityPackedKeccak256(
        ["address"],
        [userAddress]
    );
    const messageBytes = ethers.toBeArray(addressHash);
    const signature = await signer.signMessage(messageBytes);
    return signature;
}

And that’s it! The coolest thing about using digital signature whitelisting is that we can sign more complex messages instead of just simple strings and use that to create more complex validations.

For example, imagine that you are creating your own NFT collection and would like for the minting to be available only to the people in your very exclusive club, but you also want them to have access to mint tokens for only one month. It’s now or never! How would you do that? An easy solution is to create signed coupons where you include the information needed to validate the user and any other validations you might need, like the expiration date.

Code
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";

/**
 * @title CouponWhitelist
 * @notice Validates whitelisted addresses using coupons
 */
contract CouponWhitelist is Ownable {
    /// @notice Structure of a coupon
    struct Coupon {
        address recipient;
        uint256 expiration;
        bytes32 r;
        bytes32 s;
        uint8 v;
    }

    /// @notice Address used to validate coupons
    address private signerAddress;

    constructor(address _signerAddress) Ownable(msg.sender) {
        signerAddress = _signerAddress;
    }

    /**
     * @notice Verify coupon
     * @param coupon signed coupon to verify
     */
    function verifyAddressSigner(
        Coupon memory coupon
    ) internal view returns (bool) {
        bytes32 digest = keccak256(
            abi.encode(coupon.recipient, coupon.expiration)
        );
        return signerAddress == ecrecover(digest, coupon.v, coupon.r, coupon.s);
    }

    /**
     * @notice Validates if user can access a feature using a coupon
     * @param coupon signed coupon to verify user
     */
    function validateAccess(Coupon memory coupon) external view {
        // validate coupon
        require(verifyAddressSigner(coupon), "Computer says no.");

        // validate recipient
        require(coupon.recipient == msg.sender, "Do I know you?");

        // validate expiration time
        require(coupon.expiration > block.timestamp, "You're late!");
    }
}

Now we just need to generate the coupons, et voila!

Code
const ethers = require("ethers");
const {
    keccak256,
    toBuffer,
    ecsign,
    bufferToHex,
} = require("ethereumjs-utils");

async function generateCouponSignature(
    signerPrivateKey,
    userAddress,
    expiration
) {
    const couponBuffer = keccak256(
        toBuffer(
            ethers.AbiCoder.defaultAbiCoder().encode(
                ["address", "uint256"],
                [userAddress, expiration]
            )
        )
    );
    const privateKey = Buffer.from(signerPrivateKey.slice(2), "hex");
    const signature = ecsign(couponBuffer, privateKey);
    return signature;
}

async function generateCoupon(signerPrivateKey, userAddress, expiration) {
    const expirationTimestamp = Math.floor(expiration.getTime() / 1000);
    const signature = await generateCouponSignature(
        signerPrivateKey,
        userAddress,
        expirationTimestamp
    );
    return {
        recipient: userAddress,
        expiration: expirationTimestamp,
        r: bufferToHex(signature.r),
        s: bufferToHex(signature.s),
        v: signature.v,
    };
}

This way, we can manage the whitelist off-chain, which helps reduce the amount of gas fees needed to interact with the contract, and also add extra validations to keep our contract secure. Just keep in mind that the more complex your validations, the higher the gas fees will be for your users.

Additional Resources and References

Although we managed to find the solution to our problem in digital signature whitelisting, that doesn’t mean this is the right one for all types of problems and there are still other valid ways to implement whitelisting in your smart contract.

For example, if you don’t need the extra validations and don’t want to change your whitelist often, Merkle Tree whitelisting might be the best option for you.

Since the goal of this article is not to cover all of them, I decided to add this small section with some extra resources about whitelisting and access control in smart contracts that helped me and that you might also find useful.

How to Implement a Whitelist in Smart Contracts (ERC-721 NFT, ERC-1155, and others)By Igor Gaponov In this article I will show you three ways you can create a whitelist in a smart contract. Here's what…www.freecodecamp.org Access control on a NFT Solidity Contract | Miguel RodriguesUnderstand how to implement and test secure usage on a contract :rocket:.miguelrodrigues.org How to sign a message with ethers.js v6 and then validate it in SolidityCryptographic signatures play a pivotal role in blockchain technology, ensuring that transactions are secure…coinsbench.com