Could someone reuse the signature from a previous NFT mint transaction to mint another token?

Hello, I am working on an erc-712 contract and including a presale mint function that is only successful if a signed message (that includes token recipient and tokenID) is passed as a parameter. The signed message comes from a signer account managed via the NFT website's server.

I would like to know if someone could somehow take a previous successful transaction and extract the signature to create a signed message that includes a different token recipient and different tokenID to successfully call the presale mint function?

Below is the current contract code for the presale mint function.

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol";

contract myNFT is ERC721Enumerable, EIP712, Ownable {
    using ECDSA for bytes32;
    using Strings for uint256;

    struct PresaleVoucher {
        uint256 tokenId;
        address recipient;
        bytes signature;
    }
    
    uint256 public constant PRESALE_MAX = 1000;
   
    bool public presaleActive;

    address private signerAddress;

    string private constant SIGNING_DOMAIN = "MYNFT-Voucher";

    string private constant SIGNATURE_VERSION = "1";

    constructor()
        ERC721("My NFT", "MYNFT")
        EIP712(SIGNING_DOMAIN, SIGNATURE_VERSION)
    {
        signerAddress = msg.sender; ///placeholder - will change for actual deployment
    }

    /**
     * @notice activate presale
     */
    function activatePresale() external onlyOwner {
        !presaleActive ? presaleActive = true : presaleActive = false;
    }

    /**
     * @notice mint presale
     */
    function mintPresale(PresaleVoucher calldata _presaleVoucher) external {
        require(presaleActive, "PRESALE_INACTIVE");
        require(msg.sender == _presaleVoucher.recipient, "INVALID_RECIPIENT");
        require(signerAddress == _verify(_presaleVoucher), "INVALID_SIGNER");
        require(_presaleVoucher.tokenId <= PRESALE_MAX, "INVALID_TOKENID");
        _safeMint(msg.sender, _presaleVoucher.tokenId);
    }

    /**
     * @notice verify
     */
    function _verify(PresaleVoucher calldata _presaleVoucher)
        internal
        view
        returns (address)
    {
        bytes32 digest = _hash(_presaleVoucher);
        return ECDSA.recover(digest, _presaleVoucher.signature);
    }

       /**
     * @notice hash
     */
    function _hash(PresaleVoucher calldata _presaleVoucher)
        internal
        view
        returns (bytes32)
    {
        return
            _hashTypedDataV4(
                keccak256(
                    abi.encode(
                        keccak256(
                            "PresaleVoucher(uint256 tokenId,address recipient)"
                        ),
                        _presaleVoucher.tokenId,
                        _presaleVoucher.recipient
                    )
                )
            );
    }

}

Don't think so, as it will not be possible to re-mint a token with the same ID.

And btw, I'd be interested to know how you generate the vouchers...

Sorry for the delay in reply!

Here is the JS code I'm using to generate vouchers:

const ethers = require('ethers')

// These constants must match the ones used in the smart contract.
const SIGNING_DOMAIN_NAME = "FNFT-Voucher"
const SIGNING_DOMAIN_VERSION = "1"

/**
 * JSDoc typedefs.
 * 
 * @typedef {object} PresaleVoucher
 * @property {ethers.BigNumber | number} tokenId the id of the un-minted NFT
 * @property {ethers.Signer.getAddress()} recipient the recipient address to redeem this NFT
 * @property {ethers.BytesLike} signature an EIP-712 signature of all fields in the NFTVoucher, apart from signature itself
 */

/**
 * LazyMinter is a helper class that creates NFTVoucher objects and signs them, to be redeemed later by the FashionNFT contract.
 */
class LazyMinter {

  /**
   * Create a new LazyMinter targeting a deployed instance of the FashionNFT contract.
   * 
   * @param {Object} options
   * @param {ethers.Contract} contract an ethers Contract that's wired up to the deployed contract
   * @param {ethers.Signer} signer a Signer whose account is authorized to mint NFTs on the deployed contract
   */
  constructor({ contract, signer }) {
    this.contract = contract
    this.signer = signer
  }

  /**
   * Creates a new NFTVoucher object and signs it using this LazyMinter's signing key.
   * 
   * @param {ethers.BigNumber | number} tokenId the id of the un-minted NFT
   * @param {ethers.Signer.getAddress()} recipient the account to redeem this NFT
   * 
   * @returns {PresaleVoucher}
   */
  async createVoucher(tokenId, recipient) {
    const voucher = { tokenId, recipient }
    const domain = await this._signingDomain()
    const types = {
      PresaleVoucher: [
        {name: "tokenId", type: "uint256"},
        {name: "recipient", type: "address"},
      ]
    }
    const signature = await this.signer._signTypedData(domain, types, voucher)
    return {
      ...voucher,
      signature,
    }
  }

  /**
   * @private
   * @returns {object} the EIP-721 signing domain, tied to the chainId of the signer
   */
  async _signingDomain() {
    if (this._domain != null) {
      return this._domain
    }
    const chainId = await this.contract.getChainID()
    this._domain = {
      name: SIGNING_DOMAIN_NAME,
      version: SIGNING_DOMAIN_VERSION,
      verifyingContract: this.contract.address,
      chainId,
    }
    return this._domain
  }
}

module.exports = {
  LazyMinter
}

Hi, your solution has really helped me a lot. I was wondering if you could give more clarity on how the voucher system is implemented and managed via the NFT websites server like you said. Hope to hear back, thanks!

I'm using something called CloudFlare Workers to host the minting code for the website.
It is cloud functions so you don't need to manage it in the website code itself. Instead you do an API call to the function every time you want to mint and pass in the tokenID and address for the voucher. In the website we are including user email authentication via firebase so we can assign the tokenID and address per user.