Idea: Extending the payable modifier to support multiple tokens beyond ETH

Not sure if this needs to be an EIP but I’ll post the draft of ideas here. It might be better if it is a utility in some library. Would appreciate feedback and discussion around this.

There seems to be a need to create a consistent standard extending the payable modifier. This idea emerged when implementing a ERC1155 contract that needed to accept multiple types of tokens to mint a token. An analogy would be crafting items in Minecraft, where you need 3 x Item A, 1 x Item B, so on to mint an asset.

Problems with the vanilla payable

Right now, the payable modifier on Ethereum supports only ETH or the network token if on another EVM chain. There is no consistent standard around paying a contract should there be a need to accept other token specifications like ERC20s, ERC721s, ERC1155s, and other future standards. This leads to a lack of consistency around how functions can get called on the UI or other contracts. Paying in ETH is different from paying in an ERC20.

Paying a contract in tokens is also not made explicit like how paying in ETH is like. This is most evident when interacting with contracts directly through libraries like ether.js, web3.js. Wallets like Metamask have a safeguard that makes you confirm transactions first and displaying asset transfers for ERC20s, however, this is not made explicit for ERC721s or ERC1155s. While approval functions were created as safeguards, most users would approve max ERC20, setApprovalForAll on ERC721 and ERC1155s out of convenience. While there would be malicious contracts that would not respect the payment values, this would still help users have agency over the amount transferred in benign contracts.

Beyond the lack of consistency around paying a contract, there’s a lack of a common interface for prices and assets that should be transferred. For example, a NFT mint function will typically check if msg.value < 0.01 ether for example. However, there’s no consistent interface around getting the price. This becomes a problem for account abstraction wallet providers which help onboard non-crypto natives. They may offer credit card payments for to abstract transactions. A user can pay directly in USD without buying tokens for gas. However, without the common interface such providers would then have to manually obtain values onchain or implement a complex parser and input the values on their metatransaction relayers.

Draft of Proposed Implementation

Various abstract contracts for specific asset classes will be implemented that standardizes the transfer of assets. These can be inherited by child contracts. These should be backwards compatible with various smart contracts. However, what will change is how such payable functions be called on the frontends through libraries like ether.js or web3.js.

Abstract Contracts for PayableERC20, PayableERC721, PayableERC1155

These should be inherited in the child contracts needing multiple payable tokens. Child contracts can then inherit the respective payable contracts for their needs.

// SPDX-License-Identifier: AGPL-3.0

pragma solidity >=0.8.0 <0.9.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol";
import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";


error DIFFERENT_LENGTH();

abstract contract PayableERC20 {
    using SafeERC20 for IERC20;

    /// @dev payable modifier for ERC20
    modifier payableERC20(address tokenAddress, uint256 amount) {
        IERC20(tokenAddress).safeTransferFrom(
            msg.sender, 
            address(this), 
            amount
        );
        _;
    }

    /// @dev payable modifier from multiple ERC20s
    modifier payableERC20Batch(
        address[] calldata tokenAddresses, 
        uint256[] calldata amounts
    ) {
        if (tokenAddresses.length < amounts.length) {
            revert DIFFERENT_LENGTH();
        }
        for (uint i=0; i < tokenAddresses.length; ) {
            IERC20(tokenAddresses[i]).safeTransferFrom(msg.sender, address(this), amounts[i]);

            unchecked {
                ++i;
            }
        }
        _;
    }
}

abstract contract PayableERC721 is IERC721Receiver {
    /// @dev payable modifier for ERC721 and one ERC721 token
    modifier payableERC721(address tokenAddress, uint256 id) {
        IERC721(tokenAddress).safeTransferFrom(
            msg.sender, 
            address(this), 
            id
        );
        _;
    }

    /// @dev payable modifier from multiple ERC721s and multiple ERC721 tokens
    modifier payableERC721Batch(
        address[] calldata tokenAddresses, 
        uint256[] calldata ids
    ) {
        if (tokenAddresses.length < ids.length) {
            revert DIFFERENT_LENGTH();
        }
        for (uint i=0; i < tokenAddresses.length; ) {
            // use transferFrom 
            IERC721(tokenAddresses[i]).safeTransferFrom(msg.sender, address(this), ids[i]);

            unchecked {
                ++i;
            }
        }
        _;
    }

    // generic receive function to be overriden
    function onERC721Received(
        address,
        address,
        uint256,
        bytes memory
    ) public virtual override returns (bytes4) {
        return this.onERC721Received.selector;
    }
}

abstract contract PayableERC1155 {
    /// @dev payable modifier for ERC1155
    modifier payableERC1155(address tokenAddress, uint256 id, uint256 amount) {
        IERC1155(tokenAddress).safeTransferFrom(
            msg.sender, 
            address(this), 
            id,
            amount,
            "0x0"
        );
        _;
    }

    /// @dev payable modifier for one ERC1155 multiple ERC1155 tokens
    modifier payableERC1155Batch(
        address tokenAddress, 
        uint256[] calldata ids, 
        uint256[] calldata amounts
    ) {
        IERC1155(tokenAddress).safeBatchTransferFrom(
            msg.sender, 
            address(this), 
            ids,
            amounts,
            "0x0"
        );
        _;
    }

    /// @dev payable modifier for multiple ERC1155s and multiple ERC1155 tokens
    modifier payableERC1155Multi(
        address[] calldata tokenAddresses, 
        uint256[] calldata ids,
        uint256[] calldata amounts
    ) {
        if (tokenAddresses.length < ids.length && ids.length < amounts.length) {
            revert DIFFERENT_LENGTH();
        }
        for (uint i=0; i < tokenAddresses.length; ) {
            IERC1155(tokenAddresses[i]).safeTransferFrom(
                msg.sender, 
                address(this), 
                ids[i],
                amounts[i],
                "0x0"
            );

            unchecked {
                ++i;
            }
        }
        _;
    }

    // generic receive function to be overriden
    function onERC1155Received(
        address,
        address,
        uint256,
        uint256,
        bytes calldata
    ) public virtual returns (bytes4) {
        return this.onERC1155Received.selector;
    }

    function onERC1155BatchReceived(
        address,
        address,
        uint256[] calldata,
        uint256[] calldata,
        bytes calldata
    ) public virtual returns (bytes4) {
        return this.onERC1155BatchReceived.selector;
    }
}

IPrice

Interface to get prices. This might be a separate proposal from the payable modifiers above as they can exist independently

// SPDX-License-Identifier: AGPL-3.0

pragma solidity >=0.8.0 <0.9.0;

interface IPrice {

    // Notes on handling prices for different assets
    // The logic for asset prices accepted will be handled in the relevant functions
    // of a contract that uses the IPrice interface
    //
    // For ETH/Protocol Token, let tokenAddress be address(0), id be 0
    // For ERC20, let id be 0
    // For ERC721, specify ids and let amount be 1, if specific ids are required. 
    //   else set id to max uint256 if no specific ids are required
    //   This assumes no ERC721 ids ever hits max uint256
    // For ERC1155, fields are equivalent
    //   if the type of asset do not matter set id to max uint256
    //   This assumes no ERC1155 ids ever hit max uint256
    struct Price {
        address tokenAddress;
        uint256 id;
        uint256 amount;
    } 

    // return price for a given id, if id doesn't matter, in the case of
    // an ERC20 sale, ignore id
    function price(uint256 id) external view returns (Price[] memory); 
}

Example Implementation

One caveat is that the functions with multiple payable options can be unwieldy with all the functions

// SPDX-License-Identifier: AGPL-3.0

pragma solidity >=0.8.0 <0.9.0;

import {PayableERC20, PayableERC721, PayableERC1155} from "./PayableExtended.sol";
import {IPrice} from "./IPrice.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

// Sample contract that charges a fixed price across NFTs minted
contract ExampleContract is PayableERC20, PayableERC721, PayableERC1155, IPrice, ERC721 {
    Price[] public defaultPrice;
    uint256 public tokenCount;

    constructor() ERC721("Example", "Example") {}

    function setDefaultPrice(Price[] calldata prices) public {
        for(uint256 i=0; i < prices.length;) {
            defaultPrice.push(prices[i]);
            unchecked {
                ++i;
            }
        }
    }

    function price(uint256 id) external view returns (Price[] memory) {
        return defaultPrice;
    }

    function mint(
        address erc20Address,
        uint256 erc20Amount,
        address erc721Address,
        uint256 erc721Id,
        address erc1155Address,
        uint256 erc1155Id,
        uint256 erc1155Amount
    ) 
        public 
        payable
        payableERC20(erc20Address, erc20Amount)
        payableERC721(erc721Address, erc721Id)
        payableERC1155(erc1155Address, erc1155Id, erc1155Amount)
    {
        ++tokenCount;
        Price[] storage tempPrice = defaultPrice;
        // To do this, order things in a predetermined fashioned in the Price struct
        require(msg.value >= 0.01 ether, "Insufficient eth");
        require(erc20Address == tempPrice[0].tokenAddress, "Wrong erc20 address");
        require(erc20Amount >= tempPrice[0].amount, "Insufficient erc20");
        require(erc721Address == tempPrice[1].tokenAddress, "Wrong erc721 address");
        require(erc721Id >= 0 , "Insufficient erc721");
        require(erc1155Address == tempPrice[2].tokenAddress, "Wrong erc1155 address");
        require(erc1155Id == tempPrice[2].id, "Wrong erc1155 id");
        require(erc1155Amount >= tempPrice[2].amount, "");
        _safeMint(msg.sender, tokenCount);
    }
}

Backwards Compatibility

The extended payable contracts and IPrice interface are independent additions that could exist on any future contracts created.

Related EIPs

EIP-3589: Assemble assets into NFTs (ethereum.org): This is related as the transfer functions for ERC20s, ERC721s, ERC1155s is similar to how the extended payable modifiers should work.

EIP-5606: Multiverse NFTs: The extended payable modifier could also be useful for other EIP implementation that would like to compose multiple assets.