ERC2771ContextUpgradeable implementation

Hello,
I am currently working on an ERC721A implementation that utilizes ERC2771ContextUpgradeable for handling meta-transactions. I have a few questions regarding my approach and some related functionalities:

  • Implementation Approach: I would like to confirm if my implementation of the ERC721A contract with ERC2771ContextUpgradeable is correct. Specifically, I am using the constructor to set the trusted forwarder directly. Is this the recommended way to do it?

  • Purpose of ERC2771ForwarderUpgradeable: What is the actual purpose of the ERC2771ForwarderUpgradeable contract? If I can already set a forwarder (at contract deploy time) by implementing ERC2771ContextUpgradeable, what additional benefits does the forwarder provide?

  • Transfer Functionality without forwarder: if i'm using ERC2771ContextUpgradeable with _msgSender overriding how can I implement a transfer method in my SC where gas fees are paid by users instead of the forwarder? (I need a transfer where the fees are paid by the forwarder and one where they are paid by the forwarder)

My Implementation

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "erc721a-upgradeable/contracts/ERC721AUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/metatx/ERC2771ContextUpgradeable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "./lib/ERC721AURIStorageUpgradeable.sol";

contract ERC721ACustom is
    ERC721AUpgradeable,
    ERC721AURIStorageUpgradeable,
    AccessControlUpgradeable,
    ERC2771ContextUpgradeable 
{
    using Strings for uint256;

    /* ========== STATE VARIABLES ========== */
    // URI
    string private _contractURI;

    // ROLE
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    /* ========== EVENTS ========== */
    event Mint(address _from, uint256 quantity);

    /* ========== CONSTRUCTOR ========== */
    constructor(address trustedForwarder) ERC2771ContextUpgradeable(trustedForwarder) {
        _disableInitializers();
    }

    /* ========== INITIALIZER ========== */
    function initialize(
        string memory _tokenName,
        string memory _symbol,
        string memory contractURI_,
        uint256 _collectionSize,
        address _collectionOwnerAddress
    ) public initializerERC721A initializer {
        require(_collectionOwnerAddress != address(0), "ERC721ACustom: owner address not valid");

        __ERC721A_init(_tokenName, _symbol);
        __AccessControl_init();
        __ERC721URIStorage_init();

        MAX_COLLECTION_SUPPLY = _collectionSize;
        _contractURI = contractURI_;

        _grantRole(DEFAULT_ADMIN_ROLE, _collectionOwnerAddress);
        _grantRole(MINTER_ROLE, _collectionOwnerAddress);
    }

    /* ========== MODIFIER ========== */

    modifier _requireMint(uint256 _quantity) {
        if (tx.origin != _msgSender()) revert OnlyHumans();
        if (_totalMinted() + _quantity > MAX_COLLECTION_SUPPLY) revert MintingFinished();
        _;
    }

    /* ========== LOGIC ========== */
    function mintToken(
        uint256 _quantity,
        address _to,
        string[] memory _URIs
    ) external _requireMint(_quantity) onlyRole(MINTER_ROLE) {
        require(_quantity == _URIs.length, "ERC721ACustom: quantity and URIs length mismatch");

        uint256 startTokenId = _nextTokenId();

        _safeMint(_to, _quantity);
        for (uint i = 0; i < _URIs.length; i++) {
            _setTokenURI(startTokenId + i, _URIs[i]);
        }
        emit Mint(_msgSender(), _quantity);
    }

    function _beforeTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal override {
        super._beforeTokenTransfers(from, to, startTokenId, quantity);
    }

    /* ========== VIEWS ========== */

    function isApprovedForAll(address _owner, address _operator) public view override returns (bool) {
        return super.isApprovedForAll(_owner, _operator);
    }

    function tokenURI(
        uint256 tokenId
    ) public view override(ERC721AUpgradeable, ERC721AURIStorageUpgradeable) returns (string memory) {
        return super.tokenURI(tokenId);
    }

    function contractURI() external view returns (string memory) {
        return _contractURI;
    }

    function maxSupply() external view returns (uint256) {
        return MAX_COLLECTION_SUPPLY;
    }

    function _burn(uint256 tokenId) internal override(ERC721AUpgradeable, ERC721AURIStorageUpgradeable) {
        super._burn(tokenId);
    }

     function _msgSender() internal view virtual override(ContextUpgradeable, ERC2771ContextUpgradeable) returns (address sender) {
         return super._msgSender();
     }

     function _msgData() internal view virtual override(ContextUpgradeable, ERC2771ContextUpgradeable) returns (bytes calldata) {
         return super._msgData();
     }

     function _contextSuffixLength() internal view virtual override(ContextUpgradeable, ERC2771ContextUpgradeable) returns (uint256) {
         return super._contextSuffixLength();
     }

    /**
     * @dev See {IERC165-supportsInterface}.
     */
    function supportsInterface(
        bytes4 interfaceId
    ) public view override(AccessControlUpgradeable, ERC721AUpgradeable) returns (bool) {
        return interfaceId == type(IERC721AUpgradeable).interfaceId || super.supportsInterface(interfaceId);
    }

}

I appreciate any insights or guidance you can provide on these topics. Thank you! Best regards.

Hi @Cryptochain_Italia,

Thanks for sharing your questions. From high level the implementation makes sense. I'm answering your questions:

Specifically, I am using the constructor to set the trusted forwarder directly. Is this the recommended way to do it?

Yes. The variable is stored in immutable arguments to reduce the gas costs of reading the forwarder address from storage on every pass. Although it's the default recommendation and the way the contract works out of the box, you can override the trustedForwarder() virtual function if you want to customize how it is stored.

What is the actual purpose of the ERC2771ForwarderUpgradeable contract?

Some users may want to reset the forwarder after deployment. Perhaps it is by governance or by using a multisig. Generally, some systems may decide to have an upgradeable approach to keep the flexibility of changing the implementation in the future.

if i'm using ERC2771ContextUpgradeable with _msgSender overriding how can I implement a transfer method in my SC where gas fees are paid by users instead of the forwarder?

If the user calls transferFrom in your ERC721, the sender will be the user so they will pay for the execution. ERC2771ContextUpgradeable is there to enable sponsored transactions but the ERC721 should remain working the same even if _msgSender was overridden.

Hope this helps!

Thank you @ernestognw,
Im using use the ERC2771ContextUpgradeable and ERC2771ForwarderUpgradeable version because my 721 is an ERC721AUpgradeable.

1)Unfortunately I have a problem during ERC2771ForwarderUpgradeable deploy. My Forwarder:

contract ERC2771Forwarder is ERC2771ForwarderUpgradeable{
    function initialize(string memory name) public override initializer {
        __ERC2771Forwarder_init(name);
    }
}

with hardhat and @openzeppelin/hardhat-upgrades i tried with:

    const forwarderFactory: ERC2771Forwarder__factory = await ethers.getContractFactory("ERC2771Forwarder");
    const forwarder = await upgrades.deployProxy(forwarderFactory, ["Forwarder"], { initializer: 'initialize' });

but i dont know how to get the Forwarder implementation address to inject into master collection

    const masterCollection = await deploy("ERC721ACustom", {
      from: deployer,
      log: true,
      args: [forwarder.address]
    });
  1. There are best practices for the Forwarder name? Can i use "Forwarder?

Thanks!

Im using use the ERC2771ContextUpgradeable and ERC2771ForwarderUpgradeable version because my 721 is an ERC721AUpgradeable.

You can use ERC2771ContextUpgradeable within your ERC721AUpgradeable implementation but have a non-upgradeable forwarder. Just a suggestion.

I don't think you need the implementation address. If you're using an ERC2771ForwarderUpgradeable, then use the address of its proxy so you can change the implementation later. Otherwise, with a non-upgradeable ERC2771Forwarder just pass its address and you'll be fine.

  1. There are best practices for the Forwarder name? Can i use "Forwarder?

Not really. You can just use "Forwarder". It is used to create an EIP-712 domain, which depends on the contract's chain ID and address. Thus, it can't be replicated, so there are no security concerns when selecting a name for the forwarder.

Hi Ernesto,
thank you very much for your help, I managed to implement the contracts but now I just have an error when testing with mocha/chai.

This is my test:

 it("should batch transfer tokens with meta-transaction", async function () {
        // Mint 2 token and transfer without meta tx
        await this.erc721Collection.mintToken(2, this.signers.owner.address, ["URI", "URI"]);

        expect(await this.erc721Collection.ownerOf(0)).to.equal(this.signers.owner.address);
        expect(await this.erc721Collection.ownerOf(1)).to.equal(this.signers.owner.address);

        await this.erc721Collection.connect(this.signers.owner).batchTransferToken(this.signers.client1.address, [0, 1]);

        expect(await this.erc721Collection.ownerOf(0)).to.equal(this.signers.client1.address);
        expect(await this.erc721Collection.ownerOf(1)).to.equal(this.signers.client1.address);

        // Meta Tx
        await this.erc721Collection.connect(this.signers.client1).setApprovalForAll(await this.erc721Collection.getAddress(), true);

        const data = this.erc721Collection.interface.encodeFunctionData("batchTransferToken", [this.signers.client2.address, [0, 1]]);

        const domain = {
            name: "Forwarder",
            version: "1",
            chainId: 31337,
            verifyingContract: await this.forwarder.getAddress(),
        };

        const types = {
            ForwardRequest: [
                { name: "from", type: "address" },
                { name: "to", type: "address" },
                { name: "value", type: "uint256" },
                { name: "gas", type: "uint256" },
                { name: "nonce", type: "uint256" },
                { name: "deadline", type: "uint48" },
                { name: "data", type: "bytes" }
            ]
        };

        const message = {
            from: this.signers.client1.address,
            to: await this.erc721Collection.getAddress(),
            value: 0,
            gas: 100000,
            nonce: await this.forwarder.nonces(this.signers.client1.address),
            deadline: Math.floor(Date.now() / 1000) + 3600,
            data: data
        };

        const signature = await this.signers.client1.signTypedData(domain, types, message);

        const request: ERC2771Forwarder.ForwardRequestDataStruct = {
            ...message,
            signature: signature
        };

        expect(await this.forwarder.verify(request)).to.be.true;
        
        await this.forwarder.execute(request);

        expect(await this.erc721Collection.ownerOf(0)).to.equal(this.signers.client2.address);
        expect(await this.erc721Collection.ownerOf(1)).to.equal(this.signers.client2.address);
    });

And I receive the following error:

should batch transfer tokens with meta-transaction:
     Error: VM Exception while processing transaction: reverted with custom error 'FailedCall()'
    at Forwarder.execute (@openzeppelin/contracts/metatx/ERC2771Forwarder.sol:136)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async HardhatNode._mineBlockWithPendingTxs (node_modules\hardhat\src\internal\hardhat-network\provider\node.ts:1866:23)
    at async HardhatNode.mineBlock (node_modules\hardhat\src\internal\hardhat-network\provider\node.ts:524:16)
    at async EthModule._sendTransactionAndReturnHash (node_modules\hardhat\src\internal\hardhat-network\provider\modules\eth.ts:1482:18)
    at async HardhatNetworkProvider.request (node_modules\hardhat\src\internal\hardhat-network\provider\provider.ts:124:18)
    at async HardhatEthersSigner.sendTransaction (node_modules\@nomicfoundation\hardhat-ethers\src\signers.ts:125:18)
    at async send (node_modules\ethers\src.ts\contract\contract.ts:313:20)

the error is quite generic and I don't think it's a problem with the signature, the verify before the execute is OK.

What could it be?

Thank you!

Hello @ernestognw,
do you have any advice on this?

Thank you

Hi @Cryptochain_Italia,

The signature is correct as you mention. Note that the error is pointing to ERC2771Forwarder.sol:136, which means that the underlying call failed (see internal _execute). Also, to save some gas, the forwarder doesn't copy the returned data to memory so the error is not bubbled up (this is why it's quite generic).

Would you mind sharing your Solidity code to understand how batchTransferToken is constructed? The error must be happening in your contract but I can't say for sure where without the source code.

Hi @ernestognw,
thanks for the support, I solved the problem after carrying out more in-depth debugging.

See you soon!

2 Likes