Human-first NFT mints

Can you build an NFT distribution app that it is end-to-end protected from bots ? :robot:


Hey folks! Wanted to share a little experiment I coded during this last week. Hoping that it can spark some cool ideas for 2022.

For context, I never coded a production-ready NFT distribution mechanism. But I'm always down to grabbing some popcorn and read aftermath stories of unsuccessful drops from the comfort of my couch.

There I was, reading on a recent failed NFT drop (yet another one), which led me to read on some suggested mitigations, which finally led to this article. For the lazy ones, essentially it describes some techniques to mitigate adversarial bots that may pose a threat to the entire minting phase of a NFT.

The ultimate goal being to design more open and fair distributions that simply cannot be ruled by clever bot runners.

In other words, human-first NFT mints.


If you're familiar with coding smart contracts, the suggested solution may come across as plainly boring: for a mint operation to succeed, it must include some message signed by an off-chain service. This data is then verified on the smart contract before minting a new NFT.

But (I've been told) life is bigger than smart contracts. And so are our apps and their security.

Therefore, the real challenge is how to build and wire an entire NFT distribution app so that it can be fully protected from bots. And when I say app I mean frontend AND the off-chain signing service AND the smart contract(s).

It gets more complicated because teams these days don't have time to setup and maintain bulky backends. There's trouble in building, testing and maintaining them, let alone talk about keeping the signing private keys safe, etc, etc, etc. Too many moving parts.

So I set off to hack something simple and lightweight, yet robust, leveraging existing free platforms.

I may have done interesting progress. And it's been a fun challenge.

Let's explore how with a few tweaks to the OpenZeppelin Contracts ERC721 implementation, OpenZeppelin Defender and some tech from the web2 world, one can start to build more robust NFT distribution apps.


The architecture looks like this:

diagram

And for the anxious:

Code: github.com/tinchoabbate/nft-minter-for-humans.

Live app: nft-minter-for-humans.vercel.app (connected to Rinkeby).

If you run the live app, it's deadly boring. But there's some fun behind the scenes. Let me walk you through some interesting features, from back to front.


The smart contract

The Solidity code is here. Deployed at address 0x8a64d4D01E0c0BbBD60e7a48b88523E0d21EFb2C on Rinkeby.

Nothing too new, right ? It extends from OpenZeppelin Contract's ERC721 implementation.

Anyway, probably what's worth looking at here is the mint function:

function mint(
    bytes32 hash,
    bytes memory signature
)

and the verification of the signature:

require(
    hash == keccak256(abi.encode(msg.sender, tokenId, address(this))),
    "Invalid hash"
);
require(
    ECDSA.recover(ECDSA.toEthSignedMessageHash(hash), signature) == _defender,
    "Invalid signature"
);

The signature must come from the off-chain service in charge of verifying requests (discussed in next section). The address of the signing account is tracked in the _defender state variable, set during construction.

This means that minting is disallowed if, prior to calling the function, the caller hasn't obtained a signed message from the off-chain service. The signed message must include the caller's address, the ID of the token about to minted, and the address of the NFT contract.

Do note that functionality is quite limited. Doesn't allow specifying the recipient nor the amount of NFTs. Doesn't even charge anything! I preferred to keep it this way. Not only to favor simplicity, but also to make it harder for anybody to blindly copy-paste this and use it in production without due diligence.

Also worth saying that this on-chain signature validation process does increase gas usage on each mint.

Finally, at the bottom of the function you'll notice that I included two unrelated functions for pausability. Can't remember why. In any case, as we'll see later, the contract can be paused, without even touching these functions ;).

The off-chain signing service

I'm not going to make a full introduction to OpenZeppelin Defender. In short: it's a platform for secure operations of smart contracts. We'll use two components: one Autotask and one Relayer. The former is a serverless function that allows arbitrary code and conditional execution. The latter allows to sign stuff with a private key securely held on AWS key vaults.

Everything is for free, and you can sign up here.

These two components fit each other perfectly, and it's exactly what I needed to build the off-chain signing service. The Autotask will contain the signing logic, and will make use of the Relayer to sign the data.

So first, using Defender's UI, I set up a Relayer on Rinkeby:

Then I created the Autotask, wiring it to the Relayer (so that I can later invoke it). The Autotask is exposed to the Internet via a webhook. This means that to trigger the signing logic of the Autotask, we'll need to (somehow) send a POST request to the (secret but public) webhook that Defender creates automatically.

All the Autotask needs to do is receive some data, use it to create the message to be signed, invoke the Relayer to effectively sign it, and return the signed stuff.

You can find its code here.

The environment where the Autotask is executed already has the needed dependencies in place (like ethers for example). I didn't even need to handle the private key in any way. I never even saw it in plaintext anywhere - which is awesome.

At the end of the Autotask's code you'll se that it's returning some values. Namely, hash and signature. These are two parameters that at some point the user will need to send to the mint function of the smart contract I showed before.

Some interesting considerations to highlight at this point:

First of all, you are in full control of your Autotask. You can pause it. Ultimately this means that you can pause / resume the minting at any time, by just clicking a button in Defender. Without having to use the pause or unpause functions included in the smart contract. Off-chain pausing of an on-chain contract. That's cool, isn't it ?

The free tier of Defender allows up to 120 autotask runs per hour. So this signing service will only sign, at best, 120 messages / hour. Which, at best, translates to 120 NFT mints / hour. It does act as a rate limiting factor, which could or could not be considered a feature of the anti-bot design (in any case, this limit can be raised to suit your needs just by getting in touch with the OpenZeppelin team). This takes us to the next point.

The webhook endpoint is public. Anybody can call it if they know the URI. In such scenario, any bot could:

  • Hit the endpoint repeatedly, obtain as many signed messages as possible, and use a custom contract to, in a single transaction, mint the NFTs.
  • Simply spam the endpoint and make it reach 120 runs every hour, effectively DoSing the system.

So while the signing service protects the smart contract, who protects the signing service ?

We cannot simply build a front-end in front of it. We need something more robust. Another layer.

The web app

If we built a simple frontend app that communicated directly with the Autotask, we'd necessarily need to expose the webhook URI to the user. Sooner than later we'd be rekt.

There's an alternative though. What if we had a server-side rendered webapp ? In the backend we'd secretly keep the Autotask's secret webhook endpoint. And we'd just need to build a dumb frontend that queried the backend for the the signed message, and upon receiving it back, built the transaction to be sent to Ethereum (like via Metamask).

That's better. However, we still haven't solved the underlying issue: any bot could bypass our front-end, hit the backend directly and still obtain the signed messages.

Yet something's changed. Now we've taken the bot out of its comfort zone. Straight from their dark forest to the web2 browserland, where we know how to fight them. With a dreadful almighty tool we've known for a long time.

The captcha :upside_down_face:

So all we need to do know is setup a traditional captcha in our webapp. That should be easy! There's lots of tutorials around in case you've never done it (like me).

With all of this in mind, I built a Next.js webapp, and integrated hCaptcha to it (which has a nice React component).

Code of the webapp is here. It should be quite easy to follow, but just in case:

What does it look like ?

Like this:

recording


What now ?

There's clearly room for improvements, but the MVP works. I went from zero to a working bot-resistant serverless app in 3 days. With free tooling that anyone can access today. And I don't even consider myself a developer.

I encourage everyone to fork and extend it with more features and other anti-spam techniques. Some interesting avenues to explore:

  • Is the signed data enough to ensure security ? Do we need more fields ? Less ? Can we make the on-chain validation logic more gas-efficient ?
  • Is the captcha enough to protect the webapp ? Should you put a more robust infra in front of it ?
  • Could the webapp be more lightweight ? What about moving the captcha validation to the Autotask itself ? What are the tradeoffs ?
  • What other mechanisms can you implement in the Autotask ? There's a key-value store built-in in Autotasks that you could use for smarter and more complex validations.
  • How flexible is this mechanism to support more realistic features (payments, amount of NFTs to buy, selecting the recipient, etc) ?
  • Definitely the app introduces some points of centralization that might be undesirable for many use cases. I'm aware of that. Are there any alternative, production-ready, solutions to achieve something similar ?

And of course if you find any attack vectors that bots can exploit in the current version (in any of the components shown), I'm really really really interested in learning more about them :smiley:
Happy to discuss them here or feel free to reach out privately.

Hope this sparks some cool ideas, and looking forward to any of them reaching mainnet! Because I really think the community shouldn't need to be MEV engineers to have a slight chance of winning a cool NFT.

Big thanks to @spalladino for bearing with all my questions about Defender during this last week of the year, and also taking a first look at the code.

12 Likes

Awesome article @tinchoabbate! Thanks for setting this up and spreading the word about more use cases that can be powered using Defender.

I've been thinking about this. It would be ideal if you didn't have to set up a custom backend at all for interacting with your autotask, and Defender handled for you whatever user-based rate-limiting you need. Moving captcha validation to the Autotask does not really work, since a malicious user would exhaust your Autotask Runs quota pretty easily by just submitting invalid requests.

Looks like we have another item for 2022's roadmap!

3 Likes

Apologies for my previous reply. I didn’t finished reading before posting.:grimacing:

Would there be race condition issues when obtaining the signature?

For example, Alice could make a call to the API to obtain the signature but before she calls the mint function, the token ID remained the same. If Bob calls the API at this time, his signature would have the same token ID as Alice. And now, Alice calls mint function with the signature she obtained before Bob mints. Alice got her token. When Bob finally calls the mint function, his call will be reverted because the token ID has changed.

Could something like this happen?

1 Like

Yes, there's a race condition once at least two users obtain a signed message that includes the same token ID. As far as I've explored it, worst case scenario one of the users mints an NFT, the other doesn't, and has to obtain a new signed message with the next token ID, and submit a new transaction. So I didn't considered as an issue that would put at risk the system. But it's definitely there, I agree.

I thought about it for a while, and so far haven't come up with any practical solution for it that doesn't introduce other risky scenarios.

1 Like

In a very popular sale, I could imagine lots of people rushing to click the buy button. The concurrent traffic can be very high.

Let's say 500 people clicked on the buy button at roughly the same time just before the next successful mint. This means that there will be at least 499 failed transactions the next moment. Meanwhile, when the transaction is taking place in the pool, more people might continue to click on the buy button, which leads to more signatures of the same token ID, which then further leads to even more reverted transactions. This will continue to amplify as more people decide to retry and click the buy button again. Would this end up with much wasted gas in total?

Hey @tinchoabbate,

This is so cool! Very nice write up on your build process -- thank you for putting it together. I disagree with you on one thing: you very much are a developer.

We concur 100% that off-chain toolsets like OZD are both acceptable and warranted for great UX. Not to mention :exploding_head: in expanding the design surface.

For the race condition problem, I wonder if we don't need to lean on the tokenId so much? Maybe just exclude it or, maybe there's something that could be done with a timestamp?

Why not use UUIDs instead of autoincremental IDs for tokenIds? Wouldn't that solve the issue of Bob taking up Alice's tokenId? Another option is to delegate management of tokenIds to the contract itself (check out the Auto Increment IDs option in the wizard), and use a separate nonce for tracking signatures.

1 Like

Thank you @tinchoabbate for the article and the brilliant design!

I was thinking for race condition, could we have leverage centralised databases and record the ids that have been minted (or not minted) and update the status via transaction ?

For example, I was planning of building a contract which the public could mint and receive the NFT randomly. so could we have used a Firebase Firestore document to keep an array of ids say 1-1000, and then for every mint, the front end calls a firestore transaction, read the array of ids, randomly select an id from the array, and then remove the id from the list as a matter of locking the id for the mint. So if the mint succeeds others will be minting randomly from the remaining ids, if it fails, we'll call another transaction to put back the id. In this case, since transactions are atomic, every mint will be taking a different id (whether it is a fresh one from the list or one thats been put back due to failed minting)

I was thinking of merging your architecture for bots prevention to what I have in mind XD not sure if I 've missed out any potential issues.

Hope to get some insights and advice from you guys !

Hi, I'm building a smart contract where the buyer pays for the minting.
Buying/miniting an nft is done pressing a button on my website.
I've added the captch, but I don't seem to understand where to implement the request to "api.defender.openzeppelin.com/autotasks".
I am not using next.js, the button is a simple html/js component and I am using express.js as my server.
Could you please help?