What Is Polygon zkEVM
Polygon zkEVM is the latest network released from Polygon that helps scale Ethereum transactions, at a cheaper cost, and while leveraging the security of Ethereum itself.
What this means for NFT creators is that this is an interesting way to save on costs for transactions, the ability to bridge tokens back and forth (NFTs bridging coming), and taking advantage of native ETH to handle payments through bridging.
Cheaper, faster, and build like you would on Ethereum.
What Is An ERC1155 Contract
An ERC1155 contract is an NFT token that takes the best of ERC20 and ERC721 and allows you to manage multiple tokens that are fungible or non-fungible in one contract.
This functionality is ideal for games that want to integrate some sort of ownership or NFT component for different items that may have multiple owners, for example like have 10 broad swords weapons that have the same attributes but will belong to different people.
Creating Design Assets For Mythic Cards
This article will show you how I created the design graphics with Midjourney, the details and description with ChatGPT, and the card animations. In true NFT fashion I also show you how I uploaded the files to Arweave to make the files more permanent.
If you want to skip the design aspect and go straight into the contract, see the Building An ERC1155 NFT Collection On Polygon zkEVM section.
Midjourney Card Design Prompts
I took a lot of inspiration from the Blizzard game Hearstone to design these items. With Midjourney, I knew that I needed both the items themselves, the surrounding decorative graphic that would make them look consistent, and a standard design for the back of the cards.
Midjourney Item Prompts
I tried a few prompts but the one that seem to work the best was the following:
// an axe with no characters in the photo and just the item iteself, in the style of blizzard heartstone
// OR
// purple fiery dagger with no characters in the photo and just the item itself without any other items, in the style of blizzard heartstone
Sword, axe, and skull I got to generate had no problem, but Midjourney had an issue with the word Bow (even tried Archery Bow) for a bow and arrow.
With the sword, axe, skull, and a heart looking item, I landed on these four items.
Generating Decorations & Card Back
Once I had all four items, I made sure to ask it to generate some random rock formations that I knew I could mold to cover the items. I also made a prompt for some nice wooden doors for the back of the cards.
// a decorative rock background that takes up the entire photo, with no characters in the photo, just the item itself, and without any other items, in the style of blizzard heartstone
// a decorative wooden background with no characters in the photo and just the item itself without any other items, facing forward, in the style of blizzard heartstone
Editing Images In Photoshop
The cards weren’t complete, because I still needed to edit them a bit in Photoshop to increase the height I was looking for, creating the decorative border, and adding some slight glows to them.
Creating SVG Card Animation
This took me a bit longer to do because there is this css property called backface-visibility
, which unfortunately doesn’t work within SVGs.
In order to get the card animation I wanted, I needed to create a rotating rectangle (later switched for an image), that would rotate up to the point where it was sideways, go completely transparent, and then reappear to complete the full rotation cycle. With two of these rectangles, I could mimic the backface-visibility
and make it appear as if the rotation was smoothly transitioning back and forth between the front and back of the card.
Card Animation Iteration 1 — Basic Rotation
Getting the one side of the card to rotate in the middle and go invisible when rotated on its side.
Card Animation Iteration 2 — Alternating
Getting both the front end back of the card to iterate back and forth between showing and hiding their respective sides.
Card Animation Iteration 3— Supporting Graphics
Taking advantage of being able to use base64 encoding for image data, we can replace the rect
tags with image
tags with a href
attribute starting with the following:
<image href="data:image/jpeg;base64,..."
Using base64.guru we can upload a file and get it to generate the base64 encoding we need for the image.
Card Animation Iteration 4— Styling
The last step is to get the round edges applied and give it a radial gradient background. Unfortunately there is no easy way to do a rounded border radius with the image
tag, so we’ll need to use a clipPath
rect applied to both image
tags.
With the animation and format done, I can save all 4 items as separate SVG images. We’ll put all four images into an SVG folder.
# ./zkevm-erc1155
# svg
# ├── axe.svg
# ├── heart.svg
# ├── skull.svg
# └── sword.svg
Uploading Our Graphics & Metadata To Arweave Permaweb
Now that we have the images, we need to store these SVG images in a place where we can reference them later for the metadata JSON file that we need for OpenSea and other NFT readers to download and display it correctly.
NOTE: For these next steps, in order to upload we’ll need either real Ethereum ETH or Polygon MATIC in order to upload to Arweave.
Dependency Install & Setup
# FROM: ./zkevm-erc1155
npm init -y;
npm install dotenv @bundlr-network/client bignumber.js;
npm install -D @types/node ts-node typescript;
./node_modules/.bin/tsc --init;
mkdir upload; # Folder storing our file upload scripts
touch .env;
In our .env
file we’ll add the private key of the wallet which has MATIC to pay for the upload.
File: ./.env
WALLET_PRIVATE_KEY="<YOUR-WALLET-PRIVATE-KEY>"
Uploading Files To Arweave
In our new upload folder, we’ll create an file uploader that will read the svg folder’s files, estimate their cost, fund a Bundlr node, and upload them to Arweave.
File: ./upload/arweaveFiles.ts
// Imports
// ========================================================
import Bundlr from "@bundlr-network/client";
import fs from "fs";
import path from 'path';
import dotenv from 'dotenv';
import BigNumber from "bignumber.js";
// Config
// ========================================================
dotenv.config();
const ARWEAVE_TX_URL = "https://arweave.net/";
const privateKey = process.env.WALLET_PRIVATE_KEY;
const bundlr = new Bundlr("http://node1.bundlr.network", "matic", privateKey); // NOTE: You need matic in your wallet to upload
// Main Upload Script
// ========================================================
(async () => {
try {
// Retrieve current balance
console.group('Current Balance');
const atomicBalance = await bundlr.getLoadedBalance();
console.log({ atomicBalance });
console.log({ atomicBalance: atomicBalance.toString() });
const convertedBalance = bundlr.utils.unitConverter(atomicBalance);
console.log({ convertedBalance });
console.log({ convertedBalance: convertedBalance.toString() });
console.groupEnd();
// Get all files
console.group('Files');
const folderPath = path.join(__dirname, '..', 'svg');
const files = await fs.readdirSync(folderPath);
console.log({ files });
const svgFiles = files.filter(i => i.includes('.svg'));
console.log({ svgFiles });
console.groupEnd();
// Get total file size for all files
console.group('Funding Node');
let size = 0;
for (let i = 0; i < svgFiles.length; i++) {
const fileToUpload = path.join(__dirname, '..', 'svg', svgFiles[i]);
const fileSize = await fs.statSync(fileToUpload);
size += fileSize.size;
}
console.log({ size });
const price = await (await bundlr.getPrice(size)).toNumber() / 1000000000000000000;
console.log({ price });
// Fund if needed
if (price > parseFloat(convertedBalance.toString())) {
console.log('Funding...');
const fundAmountParsed = BigNumber(price as any).multipliedBy(bundlr.currencyConfig.base[1]);
console.log({ fundAmountParsed: fundAmountParsed.toString() });
await bundlr.fund(fundAmountParsed.toString());
const convertedBalance = bundlr.utils.unitConverter(atomicBalance);
console.log({ convertedBalance: convertedBalance.toString() });
}
console.groupEnd();
console.group('Uploading...');
for (let i = 0; i < svgFiles.length; i++) {
const fileToUpload = path.join(__dirname, '..', 'svg', svgFiles[i]);
const response = await bundlr.uploadFile(fileToUpload);
console.log(`${ARWEAVE_TX_URL}${response.id}`);
}
console.groupEnd();
} catch (e) {
console.error("Error uploading file ", e);
}
})();
We’ll make it slightly easier on ourselves to add the upload command to our package.json
.
File: ./package.json
// ...
"scripts": {
"uploadFiles": "./node_modules/.bin/ts-node upload/arweaveFiles.ts"
},
// ...
If we run uploadFiles
we should get the following:
# FROM ./zkevm-erc1155
npm run uploadFiles;
# Expected Output:
# Current Balance
# { atomicBalance: BigNumber { s: 1, e: 15, c: [ 15, 71544535461003 ] } }
# { atomicBalance: '1571544535461003' }
# {
# convertedBalance: BigNumber { s: 1, e: -3, c: [ 157154453546, 10030000000000 ] }
# }
# { convertedBalance: '0.001571544535461003' }
# Files
# { files: [ 'axe.svg', 'heart.svg', 'skull.svg', 'sword.svg' ] }
# { svgFiles: [ 'axe.svg', 'heart.svg', 'skull.svg', 'sword.svg' ] }
# Funding Node
# { size: 2550214 }
# { price: 0.007514900904697801 }
# Funding...
# { fundAmountParsed: '7514900904697801' }
# { convertedBalance: '0.001571544535461003' }
# Uploading...
# https://arweave.net/JtA8psYtbOP9i3QqxBAnkWVSVbNtL7-F_x-I3JMadE4
# https://arweave.net/CcNM3Jaq5qAyfmAlUVPFqBEFz51ikZrGYw6cRsqQius
# https://arweave.net/jFtwyxLxhzbzP94LYl0HcBuODwJsGT7JLdsWxWXhZK8
# https://arweave.net/mXQ0uptvLKnMCc2LxqGxMwp6He2upfz_AeFV6MEgv8g
NOTE: If the upload fails, there is a good chance that the node for Bundlr hasn’t been fully funded and you may need to adjust the funds that need to be sent in order to pay for the upload.
If we take one of the Arweave links, we can see that our image has been successfully uploaded.
Creating NFT Metadata
Now that we have the image urls, we need to create Metadata for each item as a separate JSON file that references the Arweave image. We also need to make sure that each JSON file is stored as the token ID (as an integer) for the filename (Ex: 1.json, 2.json, …).
We’ll store all these JSON files into a folder called manifest
and we’ll use ChatGPT to create some descriptions.
Do this for all items, and create a JSON file for each in the following JSON format:
File: ./manifest/1.json
{
"description": "The Blade of Souls is a legendary weapon steeped in mystery and lore. It is said to have been created by the gods themselves, imbued with the power to vanquish even the most malevolent of beings.",
"image": "https://arweave.net/h3kkGN_QRfhYWbZlQb5N2bSaqj6HNB4_pGxLfsdhWuo",
"name": "Blade Of Souls",
"attributes": [
{
"trait_type": "Damage",
"value": 12
}
]
}
In that same folder, we’ll also add a contract.json
file with metadata for the contract itself.
File: ./manifest/contract.json
{
"name": "zkMythicCards",
"description": "zkMythicCards is a mystical card game played by the gods themselves, where players use their elemental powers to summon creatures and cast spells to defeat their opponents and claim ultimate victory"
}
Uploading Folder To Arweave
With JSON files 1–4 created and referencing their respective items and descriptions, we can now use Arweave to upload the folder so that it references an integer which we can use from our token ID in our ERC1155 contract.
In our upload
folder, we’ll create a new file called uploadFolder.ts
.
File: ./upload/arweaveFolder.ts
// Imports
// ========================================================
import Bundlr from "@bundlr-network/client";
import fs from "fs";
import path from 'path';
import dotenv from 'dotenv';
// Config
// ========================================================
dotenv.config();
const ARWEAVE_TX_URL = "https://arweave.net/";
const privateKey = process.env.WALLET_PRIVATE_KEY;
const bundlr = new Bundlr("http://node1.bundlr.network", "matic", privateKey);
// Main Upload Script
// ========================================================
(async () => {
console.group('Uploading folder...');
try {
const folderPath = path.join(__dirname, '..', 'manifest');
const folder = fs.existsSync(folderPath);
console.log({ folderExists: folder });
if (!folder) {
throw new Error('Folder doesn\'t exist');
}
const response = await bundlr.uploadFolder(folderPath, {
indexFile: "", // optional index file (file the user will load when accessing the manifest)
batchSize: 4, //number of items to upload at once
keepDeleted: false, // whether to keep now deleted items from previous uploads
}); //returns the manifest ID
console.log({ response });
console.log({ URL: `${ARWEAVE_TX_URL}${response?.id}`});
} catch (e) {
console.error("Error uploading file ", e);
}
console.groupEnd();
})();
We’ll easier on ourselves again by adding the upload command to our package.json
.
File: ./package.json
// ...
"scripts": {
"uploadFiles": "./node_modules/.bin/ts-node upload/arweaveFiles.ts",
"uploadFolder": "./node_modules/.bin/ts-node upload/arweaveFolder.ts",
},
// ...
Now if we run uploadFolder
, we should get the following result:
# FROM ./zkevm-erc1155
npm run uploadFolder;
# Expected Output:
# Uploading folder...
# { folderExists: true }
# {
# response: {
# id: 'l9LANRyWrOOOgnaDrOG8QKBRbxTNf7mMeANw781y4bs',
# timestamp: 1679943617898
# }
# }
# {
# URL: 'https://arweave.net/l9LANRyWrOOOgnaDrOG8QKBRbxTNf7mMeANw781y4bs'
# }
Using the URL
, paste it into your browser and append any of the numbers from 1 to 4 or contract as /1.json
to it.
With the URL we’ll also add it to our .env
file as the following:
File: ./env
WALLET_PRIVATE_KEY="<YOUR-WALLET-PRIVATE-KEY>"
BASE_URL="<YOUR-UNIQUE-BASE-URL-FOR-HOLDING-JSON-FILES-WITH-SLASH-AT-THE-END>"
We now have all our design assets and files ready to create the NFT project.
Building An ERC1155 NFT Collection On Polygon zkEVM
The next step is to create our ERC1155 contract with Hardhat, make some custom modifications, and configure it for zkEVM Testnet deployment.
Setting Up Hardhat With ERC1155
Because we’re using npx hardhat
to create the template base, we’ll need to remove our previous package.json
and add those dependencies and some additional ones again.
# FROM: ./zkevm-erc1155
rm package.json;
rm tsconfig.json;
npx hardhat;
# Expeceted Output:
# ✔ What do you want to do? · Create a TypeScript project
# ✔ Hardhat project root: · /Users/username/path/to/zkevm-erc1155
# ✔ Do you want to add a .gitignore? (Y/n) · y
# ✔ Do you want to install this sample project's dependencies with npm (hardhat @nomicfoundation/hardhat-toolbox)? (Y/n) · y
npm install dotenv @bundlr-network/client bignumber.js @openzeppelin/contracts;
npm install -D @types/node ts-node typescript;
File: ./package.json
// ...
"scripts": {
"uploadFiles": "./node_modules/.bin/ts-node upload/arweaveFiles.ts",
"uploadFolder": "./node_modules/.bin/ts-node upload/arweaveFolder.ts",
},
// ...
We’ll rename the template solidity file generated from Hardhat and remove the test folder because they won’t apply to this new contract.
# FROM: ./zkevm-erc1155
mv contracts/Lock.sol contracts/ZkMythicCards.sol;
rm -rf test;
Creating Our ERC1155 Contract
Now that we have what we need, we’ll create our contract with some additional functionality for random number generations (VRF isn’t supported yet for zkEVM) and getting the contract URI for the contract metadata.
File: ./contracts/ZkMythicCards.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
// Imports
// ========================================================
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Burnable.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
// Contract
// ========================================================
contract ZkMythicCards is ERC1155, Ownable, Pausable, ERC1155Burnable, ERC1155Supply {
// Extending functionality
using Strings for uint256;
// Used to keep track of a random number generated for selecting one of the four items
uint256 public randomNumber;
/**
* Main constructor seting the the baseURI
* newuri - sets the base url for where we are storing our manifest JSON files
*/
constructor(string memory newuri)
ERC1155(newuri)
{}
/**
* @dev Sets a new URI for all token types, by relying on the token type ID
*/
function setURI(string memory newuri) public onlyOwner {
_setURI(newuri);
}
/**
* @dev Triggers stopped state.
*/
function pause() public onlyOwner {
_pause();
}
/**
* @dev Returns to normal state.
*/
function unpause() public onlyOwner {
_unpause();
}
/**
* @dev Creates random number based on number of loops to generate
* (This is before VRF is supported on zkEVM)
*/
function generateRandomNumber(uint256 loopCount) public {
for (uint256 i = 0; i < loopCount; i++) {
uint256 blockValue = uint256(blockhash(block.number - i));
randomNumber = uint256(keccak256(abi.encodePacked(blockValue, randomNumber, i)));
}
}
/**
* @dev Creates `amount` tokens of token type `id`, and assigns them to `msg.sender`.
*/
function mint(uint256 amount)
public
{
for (uint i = 0; i < amount; i++) {
// generateRandomNumber creates a new number everytime just before minting
generateRandomNumber(i);
// 4 is the max number and 1 is the minimum number
// _mint(to, tokenId, amount, data)
_mint(msg.sender, randomNumber % 4 + 1, 1, "");
}
}
/**
* @dev Hook that is called before any token transfer. This includes minting
* and burning, as well as batched variants.
*/
function _beforeTokenTransfer(address operator, address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data)
internal
whenNotPaused
override(ERC1155, ERC1155Supply)
{
super._beforeTokenTransfer(operator, from, to, ids, amounts, data);
}
/**
* @dev See {IERC1155MetadataURI-uri}.
* Metadata displayed for NFT tokenID - Ex: 1.json
*/
function uri(uint256 _tokenId) override public view returns (string memory) {
if(!exists(_tokenId)) {
revert("URI: nonexistent token");
}
return string(abi.encodePacked(super.uri(_tokenId), Strings.toString(_tokenId), ".json"));
}
/**
* @dev Contract-level metadata
*/
function contractURI() public view returns (string memory) {
return string(abi.encodePacked(super.uri(0), "contract.json"));
}
}
Deploying ERC1155 Card Collection To zkEVM Testnet
With out contract done, we’ll modify our deploy.ts
file.
File: ./scripts/deploy.ts
// Imports
// ========================================================
import { ethers } from "hardhat";
import dotenv from "dotenv";
// Config
// ========================================================
dotenv.config();
// Imports
// ========================================================
async function main() {
const Contract = await ethers.getContractFactory(`${process.env.CONTRACT_NAME}`);
const contract = await Contract.deploy(`${process.env.BASE_URL}`);
await contract.deployed();
console.log(
`${`${process.env.CONTRACT_NAME}`} deployed to ${contract.address}`
);
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
We’ll update our .env
file to reflect the CONTRACT_NAME
, add our zkEVM RPC, and specify how many NFT types exist.
File: ./env
CONTRACT_NAME="ZkMythicCards"
NUMBER_NFT_TYPES="4"
RPC_ZKEVM_URL="https://rpc.public.zkevm-test.net"
WALLET_PRIVATE_KEY="<YOUR-WALLET-PRIVATE-KEY>"
BASE_URL="<YOUR-UNIQUE-BASE-URL-FOR-HOLDING-JSON-FILES>"
With the new environment variables, we’ll modify our hardhat.config.ts
to support the RPC settings.
File: ./hardhat.config.ts
// Imports
// ========================================================
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import dotenv from "dotenv";
// Config
// ========================================================
dotenv.config();
// Hardhat Config
// ========================================================
const config: HardhatUserConfig = {
solidity: {
version: "0.8.18",
settings: {
optimizer: {
enabled: true,
runs: 200,
}
}
},
networks: {
zkevmTestnet: {
url: `${process.env.RPC_ZKEVM_URL || ''}`,
accounts: process.env.WALLET_PRIVATE_KEY
? [`0x${process.env.WALLET_PRIVATE_KEY}`]
: [],
}
},
};
// Exports
// ========================================================
export default config;
With everything configured, let’s deploy our contract.
NOTE: Make sure you have zkEVM Testnet tokens. You can bridge Goerli to zkEVM through https://wallet.polygon.technology.
# FROM: ./zkevm-erc1155
npx hardhat run scripts/deploy.ts --network zkevmTestnet;
# Expected Output:
# Compiled 1 Solidity file successfully
# ZkMythicCards deployed to 0x914B74867e49cd2a9cfb5928d14618B98Aa4FB13
zkEVM Testnet Contract Verification
NOTE: Unfortunately Polygonscan is having some issues with verification and for now we’ll be using https://explorer.public.zkevm-test.net instead.
Creating Standard-JSON-Input
In your compiled artifacts build-info
folder, copy the entire input section and save it as a file called verify.json
.
Verifying Contract On zkEVM Testnet Public Explorer
Go to your deployed contract at the following URL, select the Standard JSON Input method for verification, and upload the verify.json
file.
# https://explorer.public.zkevm-test.net/address/0xDeployedContractAddress
Minting zkEVM ERC1155 NFTs
Making sure that our wallet is set to the zkEVM Testnet network, connect our wallet to the site, mint 5 NFTs, and then read one of the token IDs to see the result.
Once the mint was successful, go to the Read Contract section and input one of the four token IDs in the uri
section.
Although OpenSea isn’t supporting zkEVM yet, if we deployed this contract to Polygon Mumbai, we can see that the NFT items can have multiple owners.
Full Code Git Repository
If you want to look at the full code repository on GitHub, check out https://github.com/codingwithmanny/zkevm-erc1155.
What’s Next?
Some next steps could be to add additional attributes to make this a fully functioning card game where people could create matches from deployed contracts.
If you want to learn how to deploy an Animated ERC721 Contract To zkEVM, make sure to check that article out.
If you got value from this, please give it some love, and please also follow me on twitter (where I’m quite active) @codingwithmanny and instagram at @codingwithmanny.