This repo contains OpenZeppelin's standard ERC 1155 contracts, slightly modified for:
- EIP 2981 royalties standard for forward compatibility.
- OpenSea-recommended features like whitelisting and meta-transactions to reduce trading friction, a
PermanentURI
event to signal frozen metadata, a simple ERC 721-like token metadata return, and contract-level metadata that collectively streamline listing. - Hard caps on token and edition supply, so buyers can rely on token scarcity.
You're looking to create a semi-fungible NFT series that's forward-compatible with EIP 2981, allows for easy OpenSea import, and enforces hard caps on tokens/editions.
Semi-fungible: ERC 1155's semi-fungible standard refers to a collection consisting of non-fungible tokens, each with multiple fungible editions. For instance, the ParkPics collection featured as an example in this repo includes 14 non-fungible tokens, each of which can be minted up to 10 times in identical (or fungible) editions.
EIP 2981: As of January 2022, Ethereum and Polygon NFT royalties are set by exchanges, which makes royalty enforcement challenging. EIP 2981 is a royalty standard that will likely be implemented across NFT exchanges in the near future. The standard's royaltyInfo
function returns a public address for the intended royalty recipient and a royalty amount in the sale currency. An exchange would query the function with the NFT's tokenId
and sale price, and then remit royalties accordingly.
Note: The royalty payment isn't built into the contract's transfer functions, so we still rely on exchanges for payment.
Easy OpenSea import: As of January 2022, OpenSea remains the largest NFT marketplace. If you're looking to enable OpenSea listing (for primary or secondary sales), they recommend a few additions to OpenZeppelin's standard contracts to streamline integration.
- Whitelisting the OpenSea proxy contract address enables NFT buyers to list on OpenSea without paying gas fees.
- Meta-transactions via
ContextMixin
enable gasless user transactions. PermanentURI
signals frozen token metadata.- An override of the
uri
metadata function ensures OpenSea correctly caches token metadata/images without needing to rely on the ERC 1155 ID substitution format. - Contract-level metadata pre-populates basic information about the collection upon import.
Note: These implementations are based on OpenSea support docs that might be outdated. But even if not required with OpenSea's latest releases, at a minimum, the changes shouldn't interfere with your listing.
Hard caps: If your token strategy relies on scarcity, you'll likely want to include caps in your contracts to reassure potential buyers. The examples contracts in this repo effectively cap tokens/editions at fourteen/ten, respectively (which you can easily modify).
Note: Our token hard cap could also be implemented in the contract mint functions, as explained below.
- Upload/pin token metadata through a decentralized service. We used IPFS and Filecoin in this repo via NFT.storage; Arweave is another popular solution.
- Adjust and/or update the smart contracts for your project's needs. If you're just looking to test deployment, you can use the contracts in this repo as-is and experiment using the ParkPics metadata and images. Otherwise, adapt the contracts as desired for your project.
- Deploy your contract to a testnet, then mainnet for any EVM blockchain. We include steps for Remix (easiest) and Hardhat (most robust), but you can also use Truffle (java-based) or Brownie (python-based) for deployment. You can also mint at this stage through a script, limited to about 150 NFTs per command. We'll also show steps for minting via the block explorer write functions.
- Verify your contract on the applicable block explorer. We'll show you how to verify contracts using HardHat, one easy option.
- Import your contract to OpenSea. Once your contract is deployed and verified, you can quickly import to OpenSea via Get Listed. You just need the contract address, which you can copy from the block explorer. You'll also need to be signed into OpenSea with the contract's owner address before adjusting collection information.
If you're in this repo, we assume you understand the basics of NFTs and metadata pinning/storage. Quick refresh:
- At a high-level, an NFT is simply a ledger entry within a smart contract (or program that runs on blockchain) that allocates ownership of a specific token ID. That smart contract then points to metadata via a
uri
function (Uniform Resource Indicator), generally in JSON format, and/or an image for each token ID via a link/pin. - That link could be as simple as a website URL where the data is stored, but then the NFT would be vulnerable to changes made by the domain owner. To ensure the NFT doesn't change over time (unless change is a desired feature), projects generally use content addressing and decentralized file storage. One common combination is IPFS and Filecoin; another is Arweave.
- Occasionally, NFT collections include metadata on-chain, but generally storing metadata on blockchains is prohibitively expensive, versus a solution like IPFS/Filecoin.
If new to NFT metadata standards, we recommend these guides from OpenSea and NFT School; if new to content addressing, we recommend these tutorials from ProtoSchool: Content Addressing and Anatomy of a CID.
Also, check out our CSV-to-JSON repo for a simple python script that creates token JSONs from a CSV file containing metadata traits.
For this repo, we used IPFS and Filecoin via two easy tools:
- IPFS CAR generator: Convert token-level images (likely PNGs) and metadata (JSONs) into CARs. You'll need to upload the images first, so you can include the images' CAR pins in the token JSONs.
- NFT.Storage for upload: Upload CARs to IPFS and Filecoin servers for decentralized pinning and storage, respectively.
The IPFS CAR generator returns a Content-Addressed Archive (CAR file) with a unique Content Identifier (CID) from the uploaded files. You can use this tool for both your token-level metadata (individual JSONs for each token) and images. For instance, the two ParkPics CARs consist of (1) JSONs numbered 1.json
to 14.json
containing each token's metadata (park, feature, type, image URI, etc.), and (2) PNGs numbered 1.png
to 14.png
with the park pictures themselves.
For this smart contract's uri
function to work, token metadata needs to be stored in a CAR with directory file names that match each token's ID. For instance, token two's metadata should be stored as 2.json
within the CAR. After uploading the token-level metadata and images (separately), you'll be able to download each CAR file from the generator.
Reminder: Start with your images, because your JSON files will need to include image pins for each token before you can generate the metadata CAR.
Once you have the image and metadata CARs, you can use NFT.Storage, a free service provided by Protocol Labs, to upload those CARs to Filecoin and IPFS servers.
Note: You can also use this tool to upload your contract-level metadata, if desired. (See below for more context).
After uploading the CARs via NFT.Storage, you'll be able to view your metadata via the IPFS pins. There may be a slight delay, so give the network at least a few minutes to process your CAR uploads before trying to retrieve files.
Using Brave, a browser that natively integrates IPFS, you can access IPFS via ipfs://<pin>/<file>
; for instance, metadata for ParkPics token one is available via ipfs://bafybeigpo7cmcfkicsee3redrzcwzqsnywvyjehvam4mim3v7ng65titby/1.json
.
Using a web browser without IPFS integrated, you can access the same metadata via https://bafybeigpo7cmcfkicsee3redrzcwzqsnywvyjehvam4mim3v7ng65titby.ipfs.dweb.link/1.json
.
Using the same retrieval approach in Brave, you can access the token's image via ipfs://bafybeiatmiig6ylhha5p7o7bxvqutfitv6k2n5ghche4r22tgkmoz6gu5u/1.png
per the JSON's image
field (see above). Note: This JSON field illustrates why you need to start with the images CAR.
To display images and metadata for NFTs, services like OpenSea retrieve and cache your collection's data following these same steps, albeit less manually.
There are many other approaches to pin/upload metadata to IPFS and Arweave. We just picked two easy tools that require limited technical knowledge. And to the extent you don't want to use CARs, you'll just need to adjust the smart contracts' uri
function and PermanentURI
events accordingly.
This repo assumes basic knowledge of Solidity. If you're new to the language, we recommend this tutorial.
To edit your smart contracts, we recommend an integrated development environment like VS Code. If using VS Code, you'll want an extension for Solidity like this popular option.
We started with the OpenZeppelin Wizard to create the base ERC 1155 contracts.
- Functions: We selected Mintable and Pausable (allows for owner minting and contract pausing, in the event something goes wrong).
- Access Control: We selected Ownable (one account can mint, pause, etc., versus segregated permissions for multiple accounts).
If you'd like to change these presets, it's probably easiest to start with the OpenZeppelin Wizard, download your new contracts, and then manually incorporate EIP 2981 royalties, the OpenSea-specific changes, and token/edition hard caps, as documented below.
EIP 2981 includes two key functions: royaltyInfo
and supportsInterface
. In addition to those functions, we included an onlyOwner
ability to change royalty recipient. To remove that flexibility, replace _recipient
in the royaltyInfo
function with the desired recipient public address and delete the two setRoyalties
functions.
In this example, all functions required by EIP 2981 were implemented in ParkPics.sol
.
import "./@openzeppelin/contracts/interfaces/IERC2981.sol";
Best practice with any standards implementation is to start with an interface. We then override the IERC2981.sol
royaltyInfo
function in ParkPics.sol
(see below).
function royaltyInfo(uint256 _tokenId, uint256 _salePrice) external view override returns (address receiver, uint256 royaltyAmount) {
return (_recipient, (_salePrice * 1000) / 10000);
}
When called, this function returns royalty recipient and amount, indifferent as to sale currency. We set royalties at 10% or 1,000 basis points; to set a different percentage, just adjust the 1000
to your desired royalty share (in basis points).
Note: Royalties are not built into the contract's transfer
functions, which don't have an input for sales price. Instead, for secondary sales, the token owner generally delegates transfer ability to exchanges via the setApprovalForAll
function in ERC1155.sol
. Then, the exchange initiates (1) token transfer once the listing price is fulfilled and (2) royalty payout, which is currently based on information provided to each exchange by the collection creator (for OpenSea, the contract owner needs to populate royalty recipient and amount manually on their site when updating collection information). If an owner gifts or moves their token to a different wallet address via the transfer
functions, no royalty will be triggered.
Reputable exchanges tend to follow community standards, which we expect EIP 2981 to become in the future. Therefore, we're including EIP 2981 functions for forward compatibility, even if most exchanges don't follow the standard as of January 2022.
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC1155, IERC165) returns (bool) {
return (interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId));
}
This function signals the contract is compatible with EIP 2981, in addition to ERC 1155 and ERC 165 (via the super
call).
address private _recipient;
...
constructor() ERC1155("") {
...
_recipient = owner();
}
...
function _setRoyalties(address newRecipient) internal {
require(newRecipient != address(0), "Royalties: new recipient is the zero address");
_recipient = newRecipient;
}
...
function setRoyalties(address newRecipient) external onlyOwner {
_setRoyalties(newRecipient);
}
These additions (1) create a private variable for royalty recipient address, (2) define that address as the contract owner upon deployment in the constructor, and (3) create an onlyOwner
function to update that recipient address in the future.
If you don't need the flexibility to change recipient, feel free to delete the variable, constructor definition, and setRoyalties
functions, and replace _recipient
in the royaltyInfo
function with a static public address for the desired recipient.
The following OpenSea additions enable whitelisting, meta-transactions, a permanent token metadata event, token metadata override, and contract-level metadata. These changes were implemented in ParkPics.sol
and ERC1155.sol
.
Note: Some of these additions may be redundant and/or uneccessary, but to cover all bases, we pieced together and implemented each per OpenSea's developer docs.
function isApprovedForAll(...) ... (...) {
/** @dev OpenSea whitelisting. */
if(operator == address(0x207Fa8Df3a17D96Ca7EA4f2893fcdCb78a304101)){
return true;
}
/** @dev Standard ERC1155 approvals. */
return _operatorApprovals[account][operator];
}
The if
addition automatically approves a token owner's OpenSea listing without requiring the owner to pay gas fees for an approval transaction. If you'd like to add additional marketplace addresses, you can use ||
operators ("or" operator in Solidity) to add those addresses to the if
statement.
import "./@openzeppelin/contracts/utils/ContextMixin.sol";
...
function _msgSender() internal override view returns (address) {
return ContextMixin.msgSender();
}
After adding ContextMixin.sol
to contract/utils
, we import that contract to the token contract and add a function to override _msgSender
. Learn more from OpenSea about gas-less transactions.
import "../../utils/Strings.sol";
...
using Strings for uint256;
string internal _uriBase;
event PermanentURI(string _value, uint256 indexed _id);
...
constructor(string memory uri_) {
...
// Set metadata pin for uri override and permanentURI events
_uriBase = "ipfs://bafybeicvbipj7n6zkphi7u5tu4gmu7oubi7nt5s2fjvkzxn7ggr4fjv2jy/"; // IPFS base for ParkPics collection
...
}
...
function _mint(...) ... {
...
// Signals frozen metadata to OpenSea
emit PermanentURI(string(abi.encodePacked(_uriBase, Strings.toString(id), ".json")), id);
...
}
...
function _mintBatch(...) ... {
...
for (uint256 i = 0; i < ids.length; i++) {
...
// Signals frozen metadata for OpenSea
emit PermanentURI(string(abi.encodePacked(_uriBase, Strings.toString(ids[i]), ".json")), ids[i]);
}
...
}
OpenSea looks for a PermanentURI
event to determine a token's metadata is frozen. The event simply emits the URI for each token when minted. Since we also need to return the URI in the token contract (ParkPics.sol
), we define _uriBase
as an internal variable that can be called in the token contract uri
function.
Learn more here.
function uri(uint256 tokenId) override public view returns (string memory) {
...
return string(abi.encodePacked(_uriBase, Strings.toString(tokenId), ".json"));
}
Rather than relying on marketplaces to support the ERC 1155 ID substitution method for token metadata, we overrode the function to return an IPFS pin for the token's applicable JSON file.
For our function to work properly, metadata needs to be stored in a Content-Addressed Archive (CAR). See above for details on metadata pinning/upload.
string public name;
string public symbol;
...
constructor() ERC1155("") {
name = "Park Pics";
symbol = "PPS";
...
}
...
function contractURI() public pure returns (string memory) {
return "ipfs://bafkreigpykz4r3z37nw7bfqh7wvly4ann7woll3eg5256d2i5huc5wrrdq"; // Contract-level metadata for ParkPics
}
Upon importing the contract to OpenSea, contract-level metadata will now pre-populate in the applicable collection fields. You can override collection details in OpenSea as desired.
Hard caps were implemented in ParkPics.sol
and ERC1155.sol
.
constructor() ERC1155("") {
...
total_supply = 14;
...
}
...
function uri(...) ... (...) {
// Tokens minted above the supply cap will not have associated metadata.
require(tokenId >= 1 && tokenId <= total_supply, "ERC1155Metadata: URI query for nonexistent token");
...
}
Our require
function effectively limits the total token supply by blocking metadata retrieval above the token hard cap (e.g., token fourteen for ParkPics). Alternatively, we could implement this limit through the contracts' mint functions via additional require
hooks, similar to the edition cap below.
// Mapping from token ID to global token editions and global limit
mapping(uint256 => uint256) private _globalEditions;
uint256 private _editionLimit;
...
constructor(...) {
...
// Set maximum editions per token
_editionLimit = 10;
}
...
function _mint(...) ... {
...
// Caps per token supply to 10 editions
require((_globalEditions[id] + amount) <= _editionLimit, "ERC1155: exceeded token maximum editions");
...
// Tracks number of editions per token
_globalEditions[id] += amount;
...
}
...
function _mintBatch(...) ... {
...
for (uint256 i = 0; i < ids.length; i++) {
// Caps per token supply to 10 editions
require((_globalEditions[ids[i]] + amounts[i]) <= _editionLimit, "ERC1155: exceeded token maximum editions");
...
// Tracks number of editions per token
_globalEditions[ids[i]] += amounts[i];
...
}
...
}
Each time a new token is minted, the edition counter is updated. In this sample collection, those editions are capped at ten each in the constructor, after which the mint functions throw an error message. As mentioned above, a token supply limit could be implemented in a similar manner through these mint functions.
- Minting factory: We didn't include a minting factory in this repo. All tokens are minted by the owner and then transfered or listed for sale by the owner. An additional factory contract would be required to enable minting at a set price or through an auction.
- OpenSea additions and hard caps: If you don't need the OpenSea additions and/or token/edition hard caps, we recommend starting with the OpenZeppelin Wizard and then implementing EIP 2981 royalties per the steps above.
If you don't need to change the contracts as drafted in this repo, simply follow the steps below to update key variables to suit your project (e.g., contract name, token supply, and metadata pins).
In the token contract (ParkPics.sol
, to be renamed)
- Change
ParkPics.sol
file name (not strictly required, but recommended). - Change
ParkPics
contract name (also not strictly required, but recommended). - In the constructor, update
name
,symbol
andtotal_supply
. If you're looking to maintain flexibility to expand token count in the future, removetotal_supply
in the constructor and from theuri
function. - In the
contractURI
function, change the pin to your collection-level metadata. If not needed, you can removecontractURI
without impacting other functions in the contracts.
In ERC1155.sol
- In the constructor, update
_uriBase
for your token-level metadata CAR. - In the constructor, update
_editionLimit
for your desired editions cap. Like token hard caps, you can eliminate edition caps by removing the additionalrequire
hooks in the mint functions (see above).
Once you've finalized your smart contracts, we recommend saving them in a subfolder contracts
within the folder you're using for deployment. Hardhat will specifically look for a contracts
subdirectory when compiling contracts.
To the extent you reorganize the contracts
subdirectories in this repo, be sure to update the import
calls in each contract accordingly.
To execute the commands below on a Windows machine, we generally recommend a Git Bash terminal in VS Code. But, there are plenty of other options.
Remix IDE is a web-based development environment designed for Solidity smart contracts. You can easily connect web wallets to Remix and deploy contracts without needing to add private keys to a config file, which also allows for easy deployment from a hardware wallet (like Ledger via MetaMask. For security purposes, we recommend deploying to the mainnet with a hardward wallet as owner.
When you open Remix, you'll see a sample workspace with some sample smart contracts.
While Remix offers a few options to upload smart contracts, we'll use -connect to localhost-
via remixd
from the workspace dropdown menu. Find a detailed tutorial for remixd
here, and summarized below.
First, you'll need npm
and node
; if not already installed, follow these steps. Once you've installed npm
and node
, install remixd
.
On your desktop, we recommend opening the folder that contains subfolder contracts
in an IDE like VS Code, then running these commands. This approach isn't strictly required, but it'll make it easier to edit your smart contracts and verify via Hardhat (once deployed).
npm install -g @remix-project/remixd
Then, enter the following command to connect to Remix, substituting <ABSOLUTE-PATH>
with the contracts
folder path. If using Git Bash, refer to this article to troubleshoot path issues.
remixd -s <ABSOLUTE-PATH> --remix-ide https://remix.ethereum.org
Returning to Remix in your web brower, select -connect to localhost-
and Continue
. You should now be able to see files in your contracts
folder in Remix. Find detailed steps to compile and deploy smart contracts through Remix here, and summarized below.
First, select the token contract (ParkPics.sol
in our sample contracts) in the File Explorer
tab. Then, in the Solidity Compiler
tab, select a compiler version at least as recent as that specified in the contracts (0.8.2 per pragma solidity ^0.8.2;
at the top of ParkPics.sol
) and click Compile <CONTRACT>
. Finally, in the Deploy & Run Transactions
tab, select your environment and owner wallet (see below), and deploy the token contract.
To test integration with OpenSea, we recommend using Injected Web3
for the environment and connecting a web wallet like MetaMask. You can connect to Remix much like other web3 services by signing a transaction with your wallet. In your web wallet, select the desired network for deployment; we recommend trying a testnet first like Rinkeby for Ethereum or Mumbai for Polygon.
Note: If you don't already have Polygon integrated with your web wallet, follow these steps released by the Polygon team. You'll also need some Ether or Matic in your wallet to cover gas fees for deployment. For testnet transactions, use these faucets for testnet Ether and Matic, respectively: Rinkeby and Mumbai.
Once you've configured the environment and wallet, click Deploy
in Remix. Once deployed, you should see a contract address that you can track in a block explorer: Etherscan or Polygonscan.
See steps below for easy contract verification through HardHat, which will enable read/write calls from the explorer itself.
Find detailed Hardhat instructions here, and key steps summarized below.
First, install Hardhat and a few key packages (waffle, ethers, and etherscan).
npm install --save-dev hardhat
npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers @nomiclabs/hardhat-etherscan
Set your directory via cd
to the folder containing subfolder contracts
. You should see a config file called hardhat.config.js
. Before compiling your contracts, update the compiler version to at least solidity: "0.8.2"
for these contracts. (You can find a sample config file in this repo with some required fields added.)
Using Hardhat, compile your smart contracts. This will create artifacts, ABI, and bytecode for deployment and verification.
npx hardhat compile
Before deployment, you'll need to update the config file with an API key and private key (server and wallet, respectively) for each blockchain you plan to use. We have placeholders for Mumbai, Polygon, Rinkeby, and Ethereum.
- Servers: Infura and Alchemy are two popular options that allow you to get started for free. We set the sample config file to Infura: just add your API key.
- Wallet: You'll need to paste your private key (see steps here to export from MetaMask) into the config file's
accounts
field for each applicable blockchain.
Next, you'll need a .js
script to deploy your contract. We included a simple sample script for ParkPics.sol
in this repo that you can modify accordingly.
In the command below, replace <SCRIPT-PATH>
with your script path/filename and <NETWORK>
with your desired blockchain, consistent with the spelling/case of the labels in your config file, and run the command below.
You'll also want to add require("@nomiclabs/hardhat-waffle");
, which implicitly adds ethers
, to your config file. Learn more about the Waffle library from the Hardhat and Waffle teams.
npx hardhat run <SCRIPT-PATH> --network <NETWORK>
Example for Mumbai testnet using our sample config file:
npx hardhat run ./scripts/deploy.js --network mumbai
That's it; you've deployed your contracts via Hardhat. Using our deploy script, you should see a contract address you can use in a block explorer.
After deploying your smart contracts, you should verify them with the applicable block explorer to enable read/write interactions from that explorer and easy auditing by stakeholders.
If you deployed the contracts in Remix, you'll just need to follow the Hardhat steps above through compilation and then use Hardhat for verification. (No need to deploy via Hardhat, but you'll need the artifacts, ABI, and bytecode.)
Before verifying through Hardhat, you need to add an API key for Etherscan or Polygonscan (easy to set up once you create an account) and require("@nomiclabs/hardhat-etherscan");
to your config file. (By way of example, see our sample config file.)
Install the etherscan package (also works for Polygonscan with an API key) and then run the Hardhat verify command, replacing <NETWORK>
with the applicable blockchain and <CONTRACT>
with the deployed contract's address.
npm install --save-dev @nomiclabs/hardhat-etherscan
npx hardhat verify --network <NETWORK> <CONTRACT>
Once you receive a success message, you should be able to view your contracts and their read/write functions on the applicable block explorer. If you connect a web wallet that contains the contract's owner account, you'll be able to execute onlyOwner
write transactions (like minting) from the explorer.
With your contracts deployed and verified, you're now ready to import the collection to OpenSea. First, navigate to OpenSea's Get Listed page. Select the appropriate testnet or mainnet, as supported by OpenSea (again, we recommend testnet deployment/import before mainnet).
Enter the contract address, and OpenSea will import your collection.
If you sign into OpenSea with the contract's owner address, you'll be able to update collection information. To the extent you included a contractURI
function, some collection details should be prepopulated.
Since OpenSea doesn't currently support EIP 2981 (as of January 2022), you'll need to manually enter royalty information. If that changes in the future, your contracts should be forward compatible.
Note: It often takes OpenSea 24 to 48 hours to cache all the metadata for minted NFTs in your collection and then add the metadata filter fields, so be patient.