How do I make an ERC721 NFT contract that only accepts mint function calls from a specific domain?

Expert

Hey guys, I'm trying to build a domain locked NFT minting smart contract. I have been doing whitelisted nft mints to prevent botting attacks, but those are tedious. I have to give the minters time to give me their Ethereum addresses so I can add them to the whitelist to make the merkle root, and that takes a really long time. Plus, if you miss the whitelisting you can't mint the NFT. Once I deploy the merkle root, anyone who missed the time frame to give me their Ethereum wallet can't mint the NFT.

So I've concluded that I have to mint from a website protected with a captcha. This is really hard because people can just write scripts/bots that call the contract directly.

My 2 plans of attack are:

  1. I could incorporate some type of CORS policy in the NFT solidity contract that only allows minting function calls from the actual domain of my minting site... Does something like that exist?
  2. I could make a proxy contract that does all of the minting and my captcha webpage uses this proxy contract to call the NFT contract, mint an NFT, and send it to the person trying to claim an NFT. The biggest problems that I have with this is that a) I think I will have to pay the gas fees for my proxy contract to mint and send the NFT. I don't know if there's a way around this... and b) I will have to store my proxy contract owner's private key in my website deployment. This means the website can't be static, not to mention keeping a private key on a server is a security vulnerability.

Does anyone know how I should procede? Is there a third option that I'm missing?

Answers 2

I agree that approach # 1 will not work. I can't restrict how smart contract action is invoked and anyone can write a script or use it any other way.

One of the options that I can think of is co-signing a transaction. Users will be paying gas fees but it will require a centralized service.

From your website you call a centralized service where you pass an address of the wallet minting NFT, any other unique parameters of that NFT required for minting, and nonce. That service returns signature values: sigR (bytes), sigS (bytes), and sigV (int). You pass these 3 signature parameters to mint function along with other parameters that are required.

Within your contract you store nonce value and increment it with each minted NFT. This way signature can't be used to mint more than one NFT. Then you add a function to verify the signature:

struct WhitelistedNft {
        uint256 nonce;
        address from;
// add any other properties you need
    }

function verify(
        address signer,
        WhitelistedNft memory nft,
        bytes32 sigR,
        bytes32 sigS,
        uint8 sigV
    ) internal view returns (bool) {
        require(signer != address(0), "INVALID_SIGNER");
        return
            signer ==
            ecrecover(
                toTypedMessageHash(hashMetaTransaction(nft)),
                sigV,
                sigR,
                sigS
            );
    }

bytes32 private constant WHITELISTED_NFT_TYPEHASH = keccak256(
        bytes(
            "WhitelistedNft(uint256 nonce,address from)"
        )

function hashWhitelistedNft(WhitelistedNft memory nft)
        internal
        pure
        returns (bytes32)
    {
        return
            keccak256(
                abi. encode(
                    WHITELISTED_NFT_TYPEHASH,
                    nft.nonce,
                    nft.from
                )
            );
    }

And mint function will look something like this (not tested code):

address whitelistAccout;
mapping(address => uint256) nonces;

function mint(bytes32 sigR, bytes32 sigS, uint8 sigV) {
  WhitelistedNft memory nft = WhitelistedNft({
            nonce: nonces[msg.sender],
            from: msg.sender,
        }); 

  verify(whitelistAccout, nft, sigR, sigS, sigV);
  nonces[msg.sender] += 1;

 // your mint logic here

}

You can use ethers js library to sign the message:

// Sign the string message
let flatSig = await wallet.signMessage(message);

// For Solidity, we need the expanded-format of a signature
let sig = ethers.utils.splitSignature(flatSig);

sig variable will have three fields: v, r, and s.

The approach that I suggested is similar to the one used by meta transactions on Peeranha. Contract: https://github.com/peeranha/peeranha/blob/develop/contracts/base/NativeMetaTransaction.sol Client: https://github.com/peeranha/peeranha-web/blob/1f00e6af68d15df0002e533732df04da746e02b6/app/utils/ethereum.js#L305

You can use a similar approach for signing a message and verifying it on the contract from here - https://docs.ethers.org/v4/cookbook-signing.html

But the base idea is similar. You generate a message that includes the address of the whitelisted NFT minter + nonce and sign it. In the contract you generate the same message and verify that address of the signer from the signature matches your service account.

As for the security of the centralized, you can implement two levels of security. There is an admin account that can set a new whitelistAccout address. You will have to keep the keys for that address in the service but you will be able to rotate it. Admin private key can be kept in the hardware wallet for enhanced security, or even better make it multisig.

Hello! Your concerns regarding domain-locked NFT minting and preventing bot attacks are valid, and it's great that you're exploring multiple solutions. Here are some suggestions to address your plans:

1- CORS policy in the NFT contract: Unfortunately, you can't enforce a CORS policy within a smart contract since it's a feature of web browsers and servers. However, you can achieve a similar goal by implementing a challenge-response mechanism in your contract. For instance, your contract could require users to provide a signed message (proof) generated by your server, and only the server knows the secret key to generate this proof. This way, only users who pass the captcha on your site will receive a valid signed message, which is required to call the mint function on your contract.

2- Proxy contract: You're right about potential gas fees and security risks with storing private keys. To address these concerns, you can:

a) Use a meta-transaction approach, where users sign a message that includes their intent to mint an NFT. Your server, upon verifying the captcha, can relay this message to the proxy contract. This way, the user pays the gas fees for minting the NFT.

b) Implement an on-chain authentication mechanism, like a simple contract that only your server can call to add/remove authorized minters. Your server can then call this contract when a user passes the captcha, temporarily authorizing them to mint an NFT. This way, you won't need to store a private key on your server, and the NFT contract can reference this separate contract to check if the user is authorized.

As a third option, consider using an existing service like OpenZeppelin Defender, which offers a meta-transaction relayer service. It allows you to set up a relayer without managing private keys on your server, and users can still pay for their own gas fees.

Remember to always audit your smart contracts to ensure their security and proper functioning. Good luck with your project!