├── .gitignore
├── addresses
└── CALM721.json
├── tsconfig.json
├── test
├── fixtures
│ └── NFTmetatada.json
└── CALM721.ts
├── .solhint.json
├── tasks
├── faucet.js
└── deploy.ts
├── contracts
├── ICALMNFT.sol
├── CALM721.sol
└── CALM1155.sol
├── package.json
├── hardhat.config.ts
├── docs
└── lazyminting.md
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | cache/
3 | build/
4 | typechain/
5 | artifacts/
6 | coverage*
7 | .env
8 |
--------------------------------------------------------------------------------
/addresses/CALM721.json:
--------------------------------------------------------------------------------
1 | {
2 | "1": "",
3 | "4": "",
4 | "137": "0xa9cC685d44d083E43f19B041931ABA04995df0db"
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 | "strict": true,
6 | "esModuleInterop": true,
7 | "outDir": "dist",
8 | "downlevelIteration" : true,
9 | "resolveJsonModule": true
10 | },
11 | "include": ["./scripts", "./test"],
12 | "files": ["./hardhat.config.ts"]
13 | }
14 |
--------------------------------------------------------------------------------
/test/fixtures/NFTmetatada.json:
--------------------------------------------------------------------------------
1 |
2 | [
3 | {
4 | "name": "CALM NFT",
5 | "description": "Content Addressed Lazy Minting (CALM) Green NFT solution submission\n\nhttps://ipfs.io/ipfs/Qmdh4HogKbpzAYFXSJjr7FB1eFJLtZrn5cwzwzeXYdBk15/CALM_NFTS.zip",
6 | "image": "https://ipfs.io/ipfs/QmY6uB34eTv4dptqt6RX22yNmSGYgqm44T7q6dNiRfhFYX"
7 | }
8 | ]
9 |
--------------------------------------------------------------------------------
/.solhint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "solhint:recommended",
3 | "rules": {
4 | "func-order": "off",
5 | "mark-callable-contracts": "off",
6 | "no-empty-blocks": "off",
7 | "compiler-version": "off",
8 | "private-vars-leading-underscore": "error",
9 | "reason-string": "off",
10 | "func-visibility": ["error", { "ignoreConstructors": true }]
11 | }
12 | }
--------------------------------------------------------------------------------
/tasks/faucet.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 |
3 | // This file is only here to make interacting with the Dapp easier,
4 | // feel free to ignore it if you don't need it.
5 |
6 | task("faucet", "Sends ETH to an address")
7 | .addPositionalParam("receiver", "The address that will receive them")
8 | .setAction(async ({ receiver }) => {
9 | if (network.name === "hardhat") {
10 | console.warn(
11 | "You are running the faucet task with Hardhat network, which" +
12 | "gets automatically created and destroyed every time. Use the Hardhat" +
13 | " option '--network localhost'"
14 | );
15 | }
16 |
17 | const [sender] = await ethers.getSigners();
18 |
19 | const tx = await sender.sendTransaction({
20 | to: receiver,
21 | value: ethers.constants.WeiPerEther,
22 | });
23 | await tx.wait();
24 |
25 | console.log(`Transferred 1 ETH to ${receiver}`);
26 | });
--------------------------------------------------------------------------------
/tasks/deploy.ts:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 | import { HardhatUpgrades } from "@openzeppelin/hardhat-upgrades/src"
4 | import { task } from "hardhat/config"
5 | import { HardhatRuntimeEnvironment } from "hardhat/types";
6 |
7 | task("deploy", "Deploy contract")
8 | .addPositionalParam("name", "The contract name")
9 | .addPositionalParam("symbol", "The token symbol")
10 | .setAction(async ({ name, symbol }: { name: string, symbol: string }, env: HardhatRuntimeEnvironment) => {
11 | const { network, ethers, upgrades } = env as HardhatRuntimeEnvironment & { upgrades: HardhatUpgrades };
12 |
13 | if (network.name === "hardhat") {
14 | console.warn(
15 | "You are running the faucet task with Hardhat network, which" +
16 | "gets automatically created and destroyed every time. Use the Hardhat" +
17 | " option '--network localhost'"
18 | );
19 | }
20 |
21 | const factory = await ethers.getContractFactory("CALM721");
22 | const CALM1155Deployment = await factory.deploy(name, symbol);
23 | const { address, deployTransaction } = await CALM1155Deployment.deployed();
24 |
25 |
26 | const { gasUsed } = await deployTransaction.wait()
27 |
28 | console.log(`Deployed contract ${name} at address ${address} (tx hash ${deployTransaction.hash})
29 | Gas used ${gasUsed}`);
30 | });
--------------------------------------------------------------------------------
/contracts/ICALMNFT.sol:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MIT
2 |
3 | pragma solidity ^0.8.0;
4 |
5 | /**
6 | * @title Content addressed NFT lazy minting standard (CALM)
7 | * Note: The ERC-165 identifier for this interface is 0x105ce913.
8 | */
9 | interface ICALMNFT /* is ERC165 */ {
10 | struct MintPermit {
11 | uint256 tokenId;
12 | uint256 nonce;
13 | address currency; // using the zero address means Ether
14 | uint256 minimumPrice;
15 | address payee;
16 | uint256 kickoff;
17 | uint256 deadline;
18 | address recipient; // using the zero address means anyone can claim
19 | bytes data;
20 | }
21 |
22 | /**
23 | * @dev Call this function to buy a not yet minted NFT
24 | * @param permit The MintPermit signed by the NFT creator
25 | * @param recipient The address that will receive the newly minted NFT
26 | * @param v The v portion of the secp256k1 permit signature
27 | * @param r The r portion of the secp256k1 permit signature
28 | * @param s The s portion of the secp256k1 permit signature
29 | */
30 | function claim(
31 | MintPermit calldata permit,
32 | address recipient,
33 | uint8 v,
34 | bytes32 r,
35 | bytes32 s
36 | ) external payable;
37 |
38 | /**
39 | * @dev this function should revoke all permits issued for token ID `tokenId` with nonce lower than `nonce`
40 | * @param tokenId the token ID for which to revoke permits
41 | * @param nonce to cancel a permit for a given tokenId we suggest passing the account transaction count as `nonce`
42 | */
43 | function revokeMintPermitsUnderNonce(uint256 tokenId, uint256 nonce)
44 | external;
45 | }
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "CALM",
3 | "version": "0.0.2",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "build": "npm run clean && npm run compile",
8 | "clean": "hardhat clean",
9 | "compile": "hardhat compile",
10 | "node": "hardhat node --hostname 0.0.0.0",
11 | "deploy": "hardhat --network localhost deploy",
12 | "task": "hardhat --network localhost",
13 | "test": "hardhat test",
14 | "coverage": "npm run build && npx hardhat coverage --temp artifacts --network coverage"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/nftstory/CALM/issues"
19 | },
20 | "license": "GPL-3.0",
21 | "bugs": {
22 | "url": "https://github.com/nftstory/CALM/issues"
23 | },
24 | "homepage": "https://github.com/nftstory/CALM#readme",
25 | "devDependencies": {
26 | "@nomiclabs/hardhat-ethers": "^2.0.2",
27 | "@nomiclabs/hardhat-etherscan": "^2.1.0",
28 | "@nomiclabs/hardhat-waffle": "^2.0.1",
29 | "@openzeppelin/contracts-upgradeable": "^4.1.0",
30 | "@openzeppelin/hardhat-upgrades": "^1.7.0",
31 | "@typechain/ethers-v5": "^5.0.0",
32 | "@types/bs58": "^4.0.1",
33 | "@types/chai": "^4.2.14",
34 | "@types/chai-as-promised": "^7.1.3",
35 | "@types/isomorphic-fetch": "0.0.35",
36 | "@types/mocha": "^8.2.0",
37 | "@types/node": "^14.14.14",
38 | "base64url": "^3.0.1",
39 | "chai": "^4.2.0",
40 | "chai-as-promised": "^7.1.1",
41 | "cids": "^1.1.5",
42 | "dotenv": "^8.2.0",
43 | "ethereum-waffle": "^3.2.1",
44 | "hardhat": "^2.2.1",
45 | "hardhat-contract-sizer": "^2.0.3",
46 | "hardhat-gas-reporter": "^1.0.4",
47 | "hardhat-typechain": "^0.3.5",
48 | "multihashes": "^3.1.2",
49 | "ts-generator": "^0.1.1",
50 | "ts-node": "^9.1.1",
51 | "typechain": "^4.0.3",
52 | "typescript": "^4.1.3"
53 | },
54 | "dependencies": {
55 | "@openzeppelin/contracts": "^4.1.0",
56 | "bs58": "^4.0.1",
57 | "ethers": "^5.0.29",
58 | "ipfs-http-client": "^49.0.2",
59 | "isomorphic-fetch": "^3.0.0",
60 | "wait-for-sigint": "^0.1.0"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/hardhat.config.ts:
--------------------------------------------------------------------------------
1 | import { config as dotEnvConfig } from "dotenv";
2 | dotEnvConfig();
3 |
4 | import { HardhatUserConfig } from "hardhat/types";
5 |
6 | require("./tasks/faucet");
7 | require("./tasks/deploy");
8 |
9 | import "@nomiclabs/hardhat-etherscan";
10 |
11 | import "@nomiclabs/hardhat-waffle";
12 | import "hardhat-typechain";
13 | require('@openzeppelin/hardhat-upgrades');
14 | import "hardhat-contract-sizer"
15 | import "hardhat-gas-reporter";
16 |
17 | const INFURA_API_KEY = process.env.INFURA_API_KEY || "";
18 | const RINKEBY_PRIVATE_KEY =
19 | process.env.RINKEBY_PRIVATE_KEY! ||
20 | "0xc87509a1c067bbde78beb793e6fa76530b6382a4c0241e5e4a9ec0a0f44dc0d3"; // well known private key
21 |
22 | const MATIC_PRIVATE_KEY =
23 | process.env.MATIC_PRIVATE_KEY! || "0xc87509a1c067bbde78beb793e6fa76530b6382a4c0241e5e4a9ec0a0f44dc0d3"
24 | const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY;
25 |
26 | const config: HardhatUserConfig = {
27 | defaultNetwork: "hardhat",
28 | solidity: {
29 | compilers: [{
30 | version: "0.8.4", settings: {
31 | optimizer: {
32 | enabled: false,
33 | runs: 200
34 | }
35 | }
36 | }],
37 | },
38 | networks: {
39 | hardhat: {
40 | chainId: 1337
41 | },
42 | mainnet: {
43 | url: `https://mainnet.infura.io/v3/${INFURA_API_KEY}`,
44 | accounts: { mnemonic: process.env.MAINNET_MNEMONIC || "" },
45 | gasPrice: 90000000000
46 | },
47 | localhost: {},
48 | rinkeby: {
49 | url: `https://rinkeby.infura.io/v3/${INFURA_API_KEY}`,
50 | accounts: [RINKEBY_PRIVATE_KEY],
51 | },
52 | matic: {
53 | url: "https://rpc-mainnet.matic.network",
54 | chainId: 137,
55 | accounts: [MATIC_PRIVATE_KEY],
56 | gasPrice: 1000000000
57 | },
58 | coverage: {
59 | url: "http://127.0.0.1:8555", // Coverage launches its own ganache-cli client
60 | },
61 | },
62 | etherscan: {
63 | // Your API key for Etherscan
64 | // Obtain one at https://etherscan.io/
65 | apiKey: ETHERSCAN_API_KEY,
66 | },
67 | contractSizer: {
68 | alphaSort: true,
69 | runOnCompile: true,
70 | disambiguatePaths: false,
71 | }
72 | };
73 |
74 | export default config;
75 |
--------------------------------------------------------------------------------
/docs/lazyminting.md:
--------------------------------------------------------------------------------
1 | # Lazy Minting Content-Addressed NFTs
2 |
3 | Our lazy minted NFTs employ content address token IDs to permit the future minting of a given NFT. We achieve this by concatenating a shortened identifier of the creator's Ethereum address and the SHA1 digest (the hash) of the NFT JSON metadata to obtain a `tokenId`.
4 |
5 | We call these content-addressed NFTs because a given token ID created using this method is provably unique to its JSON metadata and creator.
6 |
7 | A contract using this strategy would only mint tokens with IDs that pack a a certain data structure. In the example below we demonstrate Solidity code that makes use of this structure.
8 |
9 | The code to get the id of a to-be-minted NFT looks like this, given a JSON metadata SHA1 digest `metadataSHA1` and a creator address `msg.sender`.
10 |
11 | ```solidity
12 | function computeTokenId(uint160 metadataSHA1) external pure returns (uint256 tokenId) {
13 |
14 | //compute a 96bit (12 bytes) id for the creator based on ther Ethereum address (160 bits / 20 bytes) and the metadata SHA1 digest
15 | bytes12 tokenSpecificCreatorIdentifier = bytes12(keccak256(abi.encode(msg.sender)));
16 |
17 | //pack `metadataSHA1` (160bit) and `tokenSpecificCreatorIdentifier` (96bit) into a 256bit uint that will be our token id
18 | uint256 tokenId =
19 | bytesToUint256(
20 | abi.encodePacked(metadataSHA1, tokenSpecificCreatorIdentifier)
21 | );
22 |
23 | return tokenId;
24 | }
25 | ```
26 |
27 |
28 | Example token ID:
29 | ```
30 | 0x7c54dd4d58f49026d084c3edd77bcccb8d08c9e4029fa8c2b3aeba73ac39ba1f
31 | --|----------------------160bit------------------|-----96bit-----|
32 | | |
33 | | |
34 | | |
35 | | |
36 | | |
37 | SHA1 digest of JSON metadata token specific creator identifier
38 |
39 | (truncated keccak256 digest of
40 | metadata SHA1 and ethereum address)
41 |
42 | ```
43 |
44 | `computeTokenId` is a pure view function so it's free to call. It doesn't save anything on the blockchain.
45 |
46 |
47 | Mint needs to be called to save the token ownership on-chain. For example:
48 |
49 |
50 | ```solidity
51 | //we need to pass creatorAddress as an argument because the id only contains a hash of it
52 | function mint(uint256 tokenId, address creatorAddress, address recipient) {
53 | //verify that the truncated keccak256 digest of the creatorAddress (tokenSpecificCreatorIdentifier) passed as an argument matches the last 96 bits in the tokenId
54 | require(tokenIdMatchesCreator(tokenId, creatorAddress), "lazy-mint/creator-does-not-correspond-to-id");
55 |
56 | //mint happens here
57 | //_mintOne is implementation specific, see https://eips.ethereum.org/EIPS/eip-721 or https://eips.ethereum.org/EIPS/eip-1155
58 | _mintOne(creatorAddress, tokenId);
59 |
60 | //the person who pays for gas can decide who will receive the freshly minted nft
61 | //transferFrom is implementation specific, see https://eips.ethereum.org/EIPS/eip-721 or https://eips.ethereum.org/EIPS/eip-1155
62 | transferFrom(creatorAddress, recipient, tokenId);
63 | }
64 | ```
65 |
66 |
67 |
68 | # Notes on IPFS compatibility
69 | IPFS can be used to retrieve files with their SHA1 digest if those were uploaded to the network as raw leaves
70 |
71 | This can be done with the following command
72 |
73 | ```shell=
74 | ipfs add --raw-leaves --hash=sha1
75 | ```
76 |
77 | An IPFS CID can also be constructed from a SHA1 digest
78 |
79 | Javascript example :
80 | ```javascript
81 | import CID from 'cids'
82 | import multihashes from 'multihashes'
83 |
84 | const SHA1_DIGEST = '2fd4e1c67a2d28fced849ee1bb76e7391b93eb12'
85 | const sha1Buffer = Buffer.from(SHA1_DIGEST, 'hex')
86 |
87 | const multihash = multihashes.encode(sha1Buffer, 'sha1')
88 |
89 | const cid = new CID(1, 'raw', multihash)
90 | ```
91 |
92 | Or more succintly, taking advantage of the base16 encoding of CIDs
93 | ```javascript
94 | const SHA1_DIGEST = '2fd4e1c67a2d28fced849ee1bb76e7391b93eb12'
95 |
96 | //IPFS v1 CIDS that are pointing to SHA1 raw leaves always start with f01551114 in base16 (hex) form
97 | const cid = `f01551114${SHA1_DIGEST}`
98 | ```
--------------------------------------------------------------------------------
/test/CALM721.ts:
--------------------------------------------------------------------------------
1 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address";
2 | import { expect } from "chai";
3 | import { BigNumberish, Bytes } from "ethers";
4 | import { ethers } from "hardhat";
5 | import { CALM721, CALM721__factory } from "../typechain"
6 | import crypto from "crypto"
7 | import NFTMetadataJSON from "./fixtures/NFTmetatada.json"
8 |
9 | const NFTMetadata = NFTMetadataJSON as any[]
10 |
11 | export function getSHA1(input: Buffer) {
12 | // for ipfs : ipfs.add(metadata, { hashAlg: 'sha1', rawLeaves: true })
13 | return `0x${crypto.createHash('sha1').update(input).digest('hex')}`
14 | }
15 |
16 | function computeTokenIdFromMetadata(metadata: Buffer, creatorAddress: string) {
17 | const sha1 = getSHA1(metadata);
18 | const creatorAddrHash = ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(['uint160', 'address'], [sha1, creatorAddress]));
19 |
20 | return ethers.BigNumber.from(sha1).toHexString() + creatorAddrHash.slice(2).substr(0, 24);
21 | }
22 |
23 | export async function getMintPermitForId(tokenId: BigNumberish,
24 | signer: SignerWithAddress,
25 | contract: CALM721,
26 | permitFields: {
27 | nonce?: number,
28 | currency?: string,
29 | minimumPrice?: string,
30 | payee?: string,
31 | kickoff?: number,
32 | deadline?: number,
33 | recipient?: string,
34 | data?: Bytes
35 | }
36 | ) {
37 | const signedData = {
38 | EIP712Version: '4',
39 | domain: {
40 | name: await contract.name(),
41 | version: '1',
42 | chainId: ethers.BigNumber.from(await signer.getChainId()),
43 | verifyingContract: contract.address
44 | },
45 | types: {
46 | MintPermit: [
47 | { name: 'tokenId', type: 'uint256' },
48 | { name: 'nonce', type: 'uint256' },
49 | { name: 'currency', type: 'address' },
50 | { name: 'minimumPrice', type: 'uint256' },
51 | { name: 'payee', type: 'address' },
52 | { name: 'kickoff', type: 'uint256' },
53 | { name: 'deadline', type: 'uint256' },
54 | { name: 'recipient', type: 'address' },
55 | { name: 'data', type: 'bytes' },
56 | ],
57 | },
58 | primaryType: 'MintPermit',
59 | message: {
60 | tokenId,
61 | nonce: permitFields?.nonce ?? await signer.getTransactionCount(),
62 | currency: permitFields?.currency ?? "0x0000000000000000000000000000000000000000", //using the zero address means Ether
63 | minimumPrice: permitFields?.minimumPrice ?? "0",
64 | payee: permitFields?.payee ?? signer.address,
65 | kickoff: permitFields?.kickoff ?? Math.floor(Date.now() / 1000),
66 | deadline: permitFields?.deadline ?? Math.floor((Date.now() + 31622400) / 1000), // 1 year late
67 | recipient: permitFields?.currency ?? "0x0000000000000000000000000000000000000000", // using the zero address means anyone can claim
68 | data: permitFields?.data ?? []
69 | }
70 | };
71 |
72 | const signature = await signer._signTypedData(signedData.domain, signedData.types as any, signedData.message)
73 |
74 | return { signedData, signature };
75 | }
76 |
77 |
78 | describe("CALM 721", function () {
79 | let contract: CALM721
80 | let signer: SignerWithAddress
81 |
82 | before(async () => {
83 | signer = (await ethers.getSigners())[0]
84 |
85 | const CALM721Factory = await ethers.getContractFactory("CALM721");
86 | const CALM721Deployment = await CALM721Factory.deploy("CALM", "$CALM");
87 | const { address } = await CALM721Deployment.deployed();
88 |
89 |
90 | contract = CALM721__factory.connect(address, signer);
91 | });
92 |
93 | it("should be able to claim a lazy mint", async () => {
94 |
95 | const metadata = Buffer.from(JSON.stringify(NFTMetadata[0]), 'utf-8')
96 |
97 | const tokenId = computeTokenIdFromMetadata(metadata, signer.address)
98 |
99 | const minimumPrice = ethers.utils.parseEther("0").toString()
100 |
101 | const permit = await getMintPermitForId(tokenId, signer, contract, { minimumPrice: minimumPrice })
102 |
103 | const { r, s, v } = ethers.utils.splitSignature(permit.signature)
104 |
105 | const buyer = (await ethers.getSigners())[1];
106 |
107 | const buyerContract = CALM721__factory.connect(contract.address, buyer);
108 |
109 |
110 |
111 | try {
112 | const creatorEtherBalanceBeforeClaim = await signer.getBalance()
113 | const buyerEtherBalanceBeforeClaim = await buyer.getBalance()
114 |
115 | const tx = await buyerContract.claim(permit.signedData.message, buyer.address, v, r, s, { value: minimumPrice });
116 |
117 | const { gasPrice } = tx;
118 |
119 | const { events, gasUsed } = await tx.wait();
120 |
121 | const claimGasUsedInEther = ethers.BigNumber.from(gasUsed).mul(gasPrice)
122 |
123 | const transfers = events!.filter((e: any) => e.event === 'Transfer')
124 |
125 | expect(transfers.length).to.eq(2);
126 |
127 | expect(transfers[0].args!.tokenId.toHexString()).to.eq(tokenId)
128 | expect(transfers[1].args!.tokenId.toHexString()).to.eq(tokenId)
129 |
130 | const creatorEtherBalanceAfterClaim = await signer.getBalance()
131 | const buyerEtherBalanceAfterClaim = await buyer.getBalance()
132 |
133 | expect(creatorEtherBalanceAfterClaim.toString()).to.equal(creatorEtherBalanceBeforeClaim.add(minimumPrice).toString())
134 | expect(buyerEtherBalanceAfterClaim.toString()).to.equal(buyerEtherBalanceBeforeClaim.sub(minimumPrice).sub(claimGasUsedInEther).toString())
135 | } catch (e) {
136 | if (e.message.includes("permit period invalid")) {
137 | throw new Error("Hardhat test seems to fail when ran without lauching a node, try launching a node in a new terminal window with npm run node and then run npx hardhat --network localhost test");
138 | }
139 | }
140 | })
141 | });
142 |
--------------------------------------------------------------------------------
/contracts/CALM721.sol:
--------------------------------------------------------------------------------
1 | pragma solidity 0.8.4;
2 |
3 | import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
4 |
5 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6 |
7 | import {
8 | SafeERC20
9 | } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
10 |
11 | import {ICALMNFT} from "./ICALMNFT.sol";
12 |
13 | contract CALM721 is ERC721, ICALMNFT {
14 | modifier onlyCreator(uint256 tokenId) {
15 | require(
16 | tokenIdMatchesCreator(tokenId, _msgSender()),
17 | "CALM: message sender does not own token id"
18 | );
19 |
20 | _;
21 | }
22 |
23 | using SafeERC20 for IERC20;
24 |
25 | /*=========== EIP-712 types ============*/
26 |
27 | struct EIP712Domain {
28 | string name;
29 | string version;
30 | uint256 chainId;
31 | address verifyingContract;
32 | }
33 |
34 | //keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
35 | bytes32 public constant EIP712DOMAIN_TYPEHASH =
36 | 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f;
37 |
38 | //keccak256("MintPermit(uint256 tokenId,uint256 nonce,address currency,uint256 minimumPrice,address payee,uint256 kickoff,uint256 deadline,address recipient,bytes data)");
39 | bytes32 public constant MINT_PERMIT_TYPEHASH =
40 | 0x44de264c48147fa7ed15dd168260e2e4cdf0378584f33f1a4428c7aed9658aa8;
41 |
42 | // Mapping from token ID to minimum nonce accepted for MintPermits to mint this token
43 | mapping(uint256 => uint256) private _mintPermitMinimumNonces;
44 |
45 | // The bitmask to apply to a token ID to get the creator short address
46 | uint256 public constant TOKEN_ID_CREATOR_SHORT_IDENTIFIER_BITMASK =
47 | 0x0000000000000000000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF;
48 |
49 | // The EIP-712 Domain separator for this contract
50 | // solhint-disable-next-line private-vars-leading-underscore, var-name-mixedcase
51 | bytes32 private DOMAIN_SEPARATOR;
52 |
53 | //when we call functions on _thisAsOperator we can change _msgSender() to be this contract, making sure isApprovedForAll passes when transfering tokens
54 | //see {IERC721-safeTransferFrom}
55 | CALM721 private _thisAsOperator;
56 |
57 | constructor(string memory name_, string memory symbol_)
58 | public
59 | ERC721(name_, symbol_)
60 | {
61 | uint256 chainId;
62 |
63 | // solhint-disable-next-line
64 | assembly {
65 | chainId := chainid()
66 | }
67 |
68 | DOMAIN_SEPARATOR = _hash(
69 | EIP712Domain({
70 | name: name_,
71 | version: "1",
72 | chainId: chainId,
73 | verifyingContract: address(this)
74 | })
75 | );
76 |
77 | _thisAsOperator = CALM721(address(this));
78 | }
79 |
80 | /**
81 | * @dev See {IERC721-isApprovedForAll}.
82 | */
83 | function isApprovedForAll(address owner, address operator)
84 | public
85 | view
86 | virtual
87 | override
88 | returns (bool)
89 | {
90 | if (operator == address(this)) {
91 | return true;
92 | }
93 |
94 | return ERC721.isApprovedForAll(owner, operator);
95 | }
96 |
97 | function supportsInterface(bytes4 interfaceId)
98 | public
99 | view
100 | virtual
101 | override(ERC721)
102 | returns (bool)
103 | {
104 | return
105 | interfaceId == type(ICALMNFT).interfaceId ||
106 | ERC721.supportsInterface(interfaceId);
107 | }
108 |
109 | /*============================ EIP-712 encoding functions ================================*/
110 |
111 | /**
112 | * @dev see https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator
113 | */
114 | function _hash(EIP712Domain memory eip712Domain)
115 | internal
116 | pure
117 | returns (bytes32)
118 | {
119 | return
120 | keccak256(
121 | abi.encode(
122 | EIP712DOMAIN_TYPEHASH,
123 | keccak256(bytes(eip712Domain.name)),
124 | keccak256(bytes(eip712Domain.version)),
125 | eip712Domain.chainId,
126 | eip712Domain.verifyingContract
127 | )
128 | );
129 | }
130 |
131 | /**
132 | * @dev see https://eips.ethereum.org/EIPS/eip-712#definition-of-encodedata
133 | */
134 | function _hash(MintPermit memory permit)
135 | internal
136 | pure
137 | returns (bytes32 hash)
138 | {
139 | return
140 | keccak256(
141 | abi.encode(
142 | MINT_PERMIT_TYPEHASH,
143 | permit.tokenId,
144 | permit.nonce,
145 | permit.currency,
146 | permit.minimumPrice,
147 | permit.payee,
148 | permit.kickoff,
149 | permit.deadline,
150 | permit.recipient,
151 | keccak256(permit.data)
152 | )
153 | );
154 | }
155 |
156 | /*========================================================================================*/
157 |
158 | /**
159 | * @notice revoke all MintPermits issued for token ID `tokenId` with nonce lower than `nonce`
160 | * @param tokenId the token ID for which to revoke permits
161 | * @param nonce to cancel a permit for a given tokenId we suggest passing the account transaction count as `nonce`
162 | */
163 | function revokeMintPermitsUnderNonce(uint256 tokenId, uint256 nonce)
164 | external
165 | override
166 | onlyCreator(tokenId)
167 | {
168 | _mintPermitMinimumNonces[tokenId] = nonce + 1;
169 | }
170 |
171 | /**
172 | * @dev verifies a signed MintPermit against its token ID for validity (see "../docs/lazyminting.md" or {computeTokenId})
173 | * also checks that the permit is still valid
174 | * throws errors on invalid permit
175 | */
176 | function requireValidMintPermit(
177 | MintPermit memory permit,
178 | uint8 v,
179 | bytes32 r,
180 | bytes32 s
181 | ) public view returns (address) {
182 | // EIP712 encoded
183 | bytes32 digest =
184 | keccak256(
185 | abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, _hash(permit))
186 | );
187 |
188 | address signer = ecrecover(digest, v, r, s);
189 |
190 | require(
191 | tokenIdMatchesCreator(permit.tokenId, signer),
192 | "CALM: message sender does not own token id"
193 | );
194 |
195 | require(
196 | permit.nonce >= _mintPermitMinimumNonces[permit.tokenId],
197 | "CALM: permit revoked"
198 | );
199 |
200 | return signer;
201 | }
202 |
203 | /**
204 | * @dev see "../docs/lazyminting.md" or {computeTokenId})
205 | */
206 | function tokenIdMatchesCreator(uint256 tokenId, address creatorAddress)
207 | public
208 | pure
209 | returns (bool isCreator)
210 | {
211 | uint160 metadataSHA1 = (uint160)(tokenId >> 96);
212 |
213 | uint256 tokenSpecificCreatorIdentifier =
214 | (uint256)(keccak256(abi.encode(metadataSHA1, creatorAddress)));
215 |
216 | return
217 | tokenId & TOKEN_ID_CREATOR_SHORT_IDENTIFIER_BITMASK ==
218 | (tokenSpecificCreatorIdentifier >> 160) &
219 | TOKEN_ID_CREATOR_SHORT_IDENTIFIER_BITMASK;
220 | }
221 |
222 | /**
223 | * @notice Call this function to buy a not yet minted NFT
224 | * @param permit The MintPermit signed by the NFT creator
225 | * @param recipient The address that will receive the newly minted NFT
226 | * @param v The v portion of the secp256k1 permit signature
227 | * @param r The r portion of the secp256k1 permit signature
228 | * @param s The s portion of the secp256k1 permit signature
229 | */
230 | function claim(
231 | MintPermit calldata permit,
232 | address recipient,
233 | uint8 v,
234 | bytes32 r,
235 | bytes32 s
236 | ) external payable override {
237 | require(
238 | permit.kickoff <= block.timestamp &&
239 | permit.deadline >= block.timestamp,
240 | "CALM: permit period invalid"
241 | );
242 |
243 | //address 0 as recipient in the permit means anyone can claim it
244 | if (permit.recipient != address(0)) {
245 | require(
246 | recipient == permit.recipient,
247 | "CALM: recipient does not match permit"
248 | );
249 | }
250 |
251 | address signer = requireValidMintPermit(permit, v, r, s);
252 |
253 | if (permit.currency == address(0)) {
254 | require(
255 | msg.value >= permit.minimumPrice,
256 | "CALM: transaction value under minimum price"
257 | );
258 |
259 | (bool success, ) = permit.payee.call{value: msg.value}("");
260 | require(success, "Transfer failed.");
261 | } else {
262 | IERC20 token = IERC20(permit.currency);
263 | token.safeTransferFrom(msg.sender, signer, permit.minimumPrice);
264 | }
265 |
266 | _mint(signer, permit.tokenId);
267 |
268 | _thisAsOperator.safeTransferFrom(signer, recipient, permit.tokenId);
269 | }
270 |
271 | /**
272 | * @dev See {IERC721Metadata-uri}.
273 | */
274 | function tokenURI(uint256 tokenId)
275 | public
276 | view
277 | override
278 | returns (string memory)
279 | {
280 | /*
281 | extract the JSON metadata sha1 digest from `tokenId` and convert to hex string
282 | */
283 | bytes32 value = bytes32(tokenId >> 96);
284 | bytes memory alphabet = "0123456789abcdef";
285 |
286 | bytes memory sha1Hex = new bytes(40);
287 | for (uint256 i = 0; i < 20; i++) {
288 | sha1Hex[i * 2] = alphabet[(uint8)(value[i + 12] >> 4)];
289 | sha1Hex[1 + i * 2] = alphabet[(uint8)(value[i + 12] & 0x0f)];
290 | }
291 |
292 | //with IPFS we can retrieve a SHA1 hashed file with a CID of the following format : "f01551114{_sha1}"
293 | //only works if the file has been uploaded with "ipfs add --raw-leaves --hash=sha1 "
294 | // see {#../docs/lazyminting.md#Notes-on-IPFS-compatibility}
295 | return string(abi.encodePacked("ipfs://", "f01551114", sha1Hex));
296 | }
297 | }
298 |
--------------------------------------------------------------------------------
/contracts/CALM1155.sol:
--------------------------------------------------------------------------------
1 | pragma solidity 0.8.4;
2 |
3 | import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
4 |
5 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6 |
7 | import {
8 | SafeERC20
9 | } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
10 |
11 | import {ICALMNFT} from "./ICALMNFT.sol";
12 |
13 | /**
14 | * @notice this implementation of https://eips.ethereum.org/EIPS/eip-1155 does not support multiple issuance (fungible tokens)
15 | */
16 | contract CALM1155 is ERC1155, ICALMNFT {
17 | modifier onlyCreator(uint256 tokenId) {
18 | require(
19 | tokenIdMatchesCreator(tokenId, _msgSender()),
20 | "CALM: message sender does not own token id"
21 | );
22 |
23 | _;
24 | }
25 |
26 | using SafeERC20 for IERC20;
27 |
28 | /*=========== EIP-712 types ============*/
29 |
30 | struct EIP712Domain {
31 | string name;
32 | string version;
33 | uint256 chainId;
34 | address verifyingContract;
35 | }
36 |
37 | //keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
38 | bytes32 public constant EIP712DOMAIN_TYPEHASH =
39 | 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f;
40 |
41 | //keccak256("MintPermit(uint256 tokenId,uint256 nonce,address currency,uint256 minimumPrice,address payee,uint256 kickoff,uint256 deadline,address recipient,bytes data)");
42 | bytes32 public constant MINT_PERMIT_TYPEHASH =
43 | 0x44de264c48147fa7ed15dd168260e2e4cdf0378584f33f1a4428c7aed9658aa8;
44 |
45 | // Mapping from token ID to minimum nonce accepted for MintPermits to mint this token
46 | mapping(uint256 => uint256) private _mintPermitMinimumNonces;
47 |
48 | // The bitmask to apply to a token ID to get the creator short address
49 | uint256 public constant TOKEN_ID_CREATOR_SHORT_IDENTIFIER_BITMASK =
50 | 0x0000000000000000000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF;
51 |
52 | // The EIP-712 Domain separator for this contract
53 | // solhint-disable-next-line private-vars-leading-underscore, var-name-mixedcase
54 | bytes32 private DOMAIN_SEPARATOR;
55 |
56 | //when we call functions on _thisAsOperator we can change _msgSender() to be this contract, making sure isApprovedForAll passes when transfering tokens
57 | //see {IERC1155-safeTransferFrom}
58 | CALM1155 private _thisAsOperator;
59 |
60 | constructor() public ERC1155("ipfs://") {
61 | uint256 chainId;
62 |
63 | // solhint-disable-next-line
64 | assembly {
65 | chainId := chainid()
66 | }
67 |
68 | DOMAIN_SEPARATOR = _hash(
69 | EIP712Domain({
70 | name: "CALM",
71 | version: "1",
72 | chainId: chainId,
73 | verifyingContract: address(this)
74 | })
75 | );
76 | }
77 |
78 | /**
79 | * @dev we override isApprovedForAll to return true if the operator is this contract
80 | */
81 | function isApprovedForAll(address account, address operator)
82 | public
83 | view
84 | override
85 | returns (bool)
86 | {
87 | if (operator == address(this)) {
88 | return true;
89 | }
90 |
91 | return ERC1155.isApprovedForAll(account, operator);
92 | }
93 |
94 | function supportsInterface(bytes4 interfaceId)
95 | public
96 | view
97 | virtual
98 | override(ERC1155)
99 | returns (bool)
100 | {
101 | return
102 | interfaceId == type(ICALMNFT).interfaceId ||
103 | ERC1155.supportsInterface(interfaceId);
104 | }
105 |
106 | /*============================ EIP-712 encoding functions ================================*/
107 |
108 | /**
109 | * @dev see https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator
110 | */
111 | function _hash(EIP712Domain memory eip712Domain)
112 | internal
113 | pure
114 | returns (bytes32)
115 | {
116 | return
117 | keccak256(
118 | abi.encode(
119 | EIP712DOMAIN_TYPEHASH,
120 | keccak256(bytes(eip712Domain.name)),
121 | keccak256(bytes(eip712Domain.version)),
122 | eip712Domain.chainId,
123 | eip712Domain.verifyingContract
124 | )
125 | );
126 | }
127 |
128 | /**
129 | * @dev see https://eips.ethereum.org/EIPS/eip-712#definition-of-encodedata
130 | */
131 | function _hash(MintPermit memory permit)
132 | internal
133 | pure
134 | returns (bytes32 hash)
135 | {
136 | return
137 | keccak256(
138 | abi.encode(
139 | MINT_PERMIT_TYPEHASH,
140 | permit.tokenId,
141 | permit.nonce,
142 | permit.currency,
143 | permit.minimumPrice,
144 | permit.payee,
145 | permit.kickoff,
146 | permit.deadline,
147 | permit.recipient,
148 | keccak256(permit.data)
149 | )
150 | );
151 | }
152 |
153 | /*========================================================================================*/
154 |
155 | /**
156 | * @notice revoke all MintPermits issued for token ID `tokenId` with nonce lower than `nonce`
157 | * @param tokenId the token ID for which to revoke permits
158 | * @param nonce to cancel a permit for a given tokenId we suggest passing the account transaction count as `nonce`
159 | */
160 | function revokeMintPermitsUnderNonce(uint256 tokenId, uint256 nonce)
161 | external
162 | override
163 | onlyCreator(tokenId)
164 | {
165 | _mintPermitMinimumNonces[tokenId] = nonce + 1;
166 | }
167 |
168 | /**
169 | * @dev verifies a signed MintPermit against its token ID for validity (see "../docs/lazyminting.md" or {computeTokenId})
170 | * also checks that the permit is still valid
171 | * throws errors on invalid permit
172 | */
173 | function requireValidMintPermit(
174 | MintPermit memory permit,
175 | uint8 v,
176 | bytes32 r,
177 | bytes32 s
178 | ) public view returns (address) {
179 | // EIP712 encoded
180 | bytes32 digest =
181 | keccak256(
182 | abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, _hash(permit))
183 | );
184 |
185 | address signer = ecrecover(digest, v, r, s);
186 |
187 | require(
188 | tokenIdMatchesCreator(permit.tokenId, signer),
189 | "CALM: message sender does not own token id"
190 | );
191 |
192 | require(
193 | permit.nonce >= _mintPermitMinimumNonces[permit.tokenId],
194 | "CALM: permit revoked"
195 | );
196 |
197 | return signer;
198 | }
199 |
200 | /**
201 | * @dev see "../docs/lazyminting.md" or {computeTokenId})
202 | */
203 | function tokenIdMatchesCreator(uint256 tokenId, address creatorAddress)
204 | public
205 | pure
206 | returns (bool isCreator)
207 | {
208 | uint160 metadataSHA1 = (uint160)(tokenId >> 96);
209 |
210 | uint256 tokenSpecificCreatorIdentifier =
211 | (uint256)(keccak256(abi.encode(metadataSHA1, creatorAddress)));
212 |
213 | return
214 | tokenId & TOKEN_ID_CREATOR_SHORT_IDENTIFIER_BITMASK ==
215 | (tokenSpecificCreatorIdentifier >> 160) &
216 | TOKEN_ID_CREATOR_SHORT_IDENTIFIER_BITMASK;
217 | }
218 |
219 | /**
220 | * @notice Call this function to buy a not yet minted NFT
221 | * @param permit The MintPermit signed by the NFT creator
222 | * @param recipient The address that will receive the newly minted NFT
223 | * @param v The v portion of the secp256k1 permit signature
224 | * @param r The r portion of the secp256k1 permit signature
225 | * @param s The s portion of the secp256k1 permit signature
226 | */
227 | function claim(
228 | MintPermit calldata permit,
229 | address recipient,
230 | uint8 v,
231 | bytes32 r,
232 | bytes32 s
233 | ) external payable override {
234 | require(
235 | permit.kickoff <= block.timestamp &&
236 | permit.deadline >= block.timestamp,
237 | "CALM: permit period invalid"
238 | );
239 |
240 | //address 0 as recipient in the permit means anyone can claim it
241 | if (permit.recipient != address(0)) {
242 | require(recipient == permit.recipient, "CALM: recipient does not match permit");
243 | }
244 |
245 | address signer = requireValidMintPermit(permit, v, r, s);
246 |
247 | if (permit.currency == address(0)) {
248 | require(
249 | msg.value >= permit.minimumPrice,
250 | "CALM: transaction value under minimum price"
251 | );
252 |
253 | (bool success, ) = permit.payee.call{value: msg.value}("");
254 | require(success, "Transfer failed.");
255 | } else {
256 | IERC20 token = IERC20(permit.currency);
257 | token.safeTransferFrom(msg.sender, permit.payee, permit.minimumPrice);
258 | }
259 |
260 | _mint(signer, permit.tokenId, 1, "");
261 | _thisAsOperator.safeTransferFrom(
262 | signer,
263 | recipient,
264 | permit.tokenId,
265 | 1,
266 | ""
267 | );
268 | }
269 |
270 | /**
271 | * @dev See {IERC1155MetadataURI-uri}.
272 | */
273 | function uri(uint256 tokenId) public view override returns (string memory) {
274 | /*
275 | extract the JSON metadata sha1 digest from `tokenId` and convert to hex string
276 | */
277 | bytes32 value = bytes32(tokenId >> 96);
278 | bytes memory alphabet = "0123456789abcdef";
279 |
280 | bytes memory sha1Hex = new bytes(40);
281 | for (uint256 i = 0; i < 20; i++) {
282 | sha1Hex[i * 2] = alphabet[(uint8)(value[i + 12] >> 4)];
283 | sha1Hex[1 + i * 2] = alphabet[(uint8)(value[i + 12] & 0x0f)];
284 | }
285 |
286 | //with IPFS we can retrieve a SHA1 hashed file with a CID of the following format : "f01551114{_sha1}"
287 | //only works if the file has been uploaded with "ipfs add --raw-leaves --hash=sha1 "
288 | // see {#../docs/lazyminting.md#Notes-on-IPFS-compatibility}
289 | return string(abi.encodePacked(ERC1155.uri(0), "f01551114", sha1Hex));
290 | }
291 | }
292 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [Solutions Bounty] Content Addressed Lazy Minting (CALM)
2 |
3 | [Bounty](https://gitcoin.co/issue/GreenNFT/GreenNFTs/1/)
4 |
5 | [Github Repo](https://github.com/nftstory/CALM)
6 |
7 | [MATIC CALM721 Contract](https://explorer-mainnet.maticvigil.com/address/0xa9cC685d44d083E43f19B041931ABA04995df0db)
8 |
9 | ## Summary
10 | In the interest of making NFTs as ecologically responsible as possible, we propose an open source lazy minting standard called Content Addressed Lazy Minting (CALM) and an open source reference implementation. We also provide access to a deployed version of the contract on Matic.
11 |
12 | ## Rationale
13 |
14 | The ecological impact of NFTs has become a matter of public interest and concern since NFTs achieved mainstream awareness in early 2021.
15 |
16 | In the interest of making NFTs as ecologically responsible as possible, in the first section, we propose an open source lazy minting standard called Content Addressed Lazy Minting (CALM), and an open source reference implementation.
17 |
18 | Together, the CALM standard and reference implementation aim to make gas-efficient NFT minting accessible to all, so that present and future platforms may enable more participants to enter the NFT space on the most trustworthy blockchain, while also reducing block space consumed by NFTs that are never purchased or transferred.
19 |
20 | In the second section, we present a deployment of the CALM standard on Matic, the Layer 2 EVM blockchain. This section demonstrates that the ecological advantages of NFTs on Proof of Stake (PoS) blockchains are available today. We assert that EVM-based Layer 2 solutions provide a superior compromise between security and ecological cost than non-EVM chains such as Tezos and Flow, while also maintaining compatibility with popular ecosystem tooling such as MetaMask, OpenSea, and Hardhat.
21 |
22 | ## Layer 1 Scaling Solution: Content Addressed Lazy Minting (CALM)
23 |
24 | ### Lazy Minting
25 | Content Addressed Lazy Minting is an extension and improvement upon the lazy minting technique [introduced by OpenSea on December 29, 2020](https://opensea.io/blog/announcements/introducing-the-collection-manager/). When lazy minting, the creator signs a permit stating their willingness to create a given NFT, and uploads it to the minting platform off-chain. The platform serves this permit to potential buyers through their website. Should a buyer choose to purchase the NFT, they execute an on-chain transaction including the signed permit. The lazy minting contract confirms that the permit is legitimate, then mints the token and immediately transfers it to the buyer. The token's on-chain provenance correctly identifies the NFT creator as the minter.
26 |
27 | OpenSea explains the mechanism of their [presently closed-source lazy minting implementation](https://etherscan.io/address/0x495f947276749ce646f68ac8c248420045cb7b5e#code) as follows. "When you create an NFT, you encode your address and its total supply in the token’s ID. That way, no one except you can mint more of them, and buyers can count on a hard cap on supply that’s enforced by code." ([OpenSea](https://opensea.io/blog/announcements/introducing-the-collection-manager/)).
28 |
29 | Mintable's ["gasless" lazy minting contract](https://etherscan.io/address/0x8c5aCF6dBD24c66e6FD44d4A4C3d7a2D955AAad2#code) is also to our knowledge closed source at present.
30 |
31 | In addition to its gas saving environmental benefits, by dint of being open source, CALM enables NFT creators to deploy their own minting contracts to Ethereum. We believe that enabling NFT creators to deploy their own contracts will increase their participation in network governance. If NFT creators express their concerns, such as their interest in the environmental impact of consensus mechanisms to the core development community, this will positively affect the prioritization of more ecological solutions.
32 |
33 | Accomplished NFT artists such as Murat Pak have [appealed to NFT platforms on Twitter](https://twitter.com/muratpak/status/1362900587247992833) to broaden support for lazy minting for its ecological and cost-saving advantages. In the next subsection, we explain in detail how CALM NFTs answer the call for an open source lazy minting standard, while also introducing guaranteed NFT immutability, thus eliminating the risk of [NFT rug pulls](https://twitter.com/neitherconfirm/status/1369285946198396928).
34 |
35 | ### Content Addressed Lazy Minting (CALM) Technical Explanation
36 |
37 | Content Addressed Lazy Minted (CALM) NFTs employ content address token IDs to permit the future minting of a given NFT with additional security affordances beyond existing implementations. We achieve this by concatenating a shortened identifier of the creator's Ethereum address and the SHA1 digest (the hash) of the NFT JSON metadata to obtain a `tokenId`.
38 |
39 | Complete CALM implementations for both ERC-721 and ERC-1155 are [available on Github](https://github.com/nftstory/CALM).
40 |
41 | We call these content-addressed NFTs because a given token ID created using this method is provably unique to its JSON metadata and creator.
42 |
43 | A contract using this strategy would only mint tokens with IDs that pack a certain data structure. In the example below we demonstrate Solidity code that makes use of this structure.
44 |
45 | The following code gets the id of a CALM NFT given a JSON metadata SHA1 digest `metadataSHA1` and a creator address `msg.sender`.
46 |
47 | ```solidity
48 | function computeTokenId(uint160 metadataSHA1) external pure returns (uint256 tokenId) {
49 |
50 | // Compute a 96bit (12 bytes) id for the creator based on ther Ethereum address (160 bits / 20 bytes) and the metadata SHA1 digest
51 | bytes12 tokenSpecificCreatorIdentifier = bytes12(keccak256(abi.encode(msg.sender)));
52 |
53 | // Pack `metadataSHA1` (160bit) and `tokenSpecificCreatorIdentifier` (96bit) into a 256bit uint that will be our token id
54 | uint256 tokenId =
55 | bytesToUint256(
56 | abi.encodePacked(metadataSHA1, tokenSpecificCreatorIdentifier)
57 | );
58 |
59 | return tokenId;
60 | }
61 | ```
62 |
63 | Example token ID:
64 |
65 | ```
66 | 0x7c54dd4d58f49026d084c3edd77bcccb8d08c9e4029fa8c2b3aeba73ac39ba1f
67 | --|----------------------160bit------------------|-----96bit-----|
68 | | |
69 | | |
70 | | |
71 | | |
72 | | |
73 | SHA1 digest of JSON metadata Token specific creator identifier
74 |
75 | (truncated keccak256 digest of
76 | metadata SHA1 and ethereum address)
77 |
78 | ```
79 |
80 | `computeTokenId` is a pure view function so it may be called without executing a transaction on-chain.
81 |
82 | Mint must be called to save the token ownership on-chain. For example:
83 |
84 | ```solidity
85 | function mint(uint256 tokenId, address creatorAddress, address recipient) {
86 | // Verify that the truncated keccak256 digest of the creatorAddress (tokenSpecificCreatorIdentifier) passed as an argument matches the last 96 bits in the tokenId
87 | require(tokenIdMatchesCreator(tokenId, creatorAddress), "lazy-mint/creator-does-not-correspond-to-id");
88 |
89 | // Mint happens here
90 | // _mintOne is implementation specific, see https://eips.ethereum.org/EIPS/eip-721 or https://eips.ethereum.org/EIPS/eip-1155
91 | _mintOne(creatorAddress, tokenId);
92 |
93 | // The `msg.sender` can choose who will receive the NFT
94 | // TransferFrom is implementation specific, see https://eips.ethereum.org/EIPS/eip-721 or https://eips.ethereum.org/EIPS/eip-1155
95 | transferFrom(creatorAddress, recipient, tokenId);
96 | }
97 | ```
98 |
99 | ### Notes on IPFS compatibility
100 | IPFS can be used to retrieve files with their SHA1 digest if those were uploaded to the network as raw leaves. This can be done with the following command.
101 |
102 | ```shell=
103 | ipfs add --raw-leaves --hash=sha1
104 | ```
105 |
106 | An IPFS CID can also be constructed from a SHA1 digest.
107 |
108 | JavaScript example:
109 |
110 | ```Javascript
111 | import CID from 'cids'
112 | import multihashes from 'multihashes'
113 |
114 | const SHA1_DIGEST = '2fd4e1c67a2d28fced849ee1bb76e7391b93eb12'
115 | const sha1Buffer = Buffer.from(SHA1_DIGEST, 'hex')
116 |
117 | const multihash = multihashes.encode(sha1Buffer, 'sha1')
118 |
119 | const cid = new CID(1, 'raw', multihash)
120 | ```
121 |
122 | Or more succintly, taking advantage of the base16 encoding of CIDs:
123 |
124 | ```Javascript
125 | const SHA1_DIGEST = '2fd4e1c67a2d28fced849ee1bb76e7391b93eb12'
126 |
127 | //IPFS v1 CIDS that are pointing to SHA1 raw leaves always start with f01551114 in base16 (hex) form
128 | const cid = `f01551114${SHA1_DIGEST}`
129 | ```
130 |
131 | ## Layer 2 Scaling Solution: CALM on Matic
132 |
133 | ### Why Layer 2?
134 |
135 | While CALM NFTs reduce the ecological impact of NFT creation, all subsequent transaction activity (e.g., minting, selling, transferring) remains on the blockchain.
136 |
137 | Ethereum's Mainnet (L1) uses a Proof of Work (PoW) consensus mechanism at present ([Ethereum Foundation](https://ethereum.org/en/developers/docs/consensus-mechanisms/pow/)). PoW blockchain mining is the energy intensive process responsible for the ecological concerns surrounding NFTs ([NYTimes](https://www.nytimes.com/2021/04/14/climate/coinbase-cryptocurrency-energy.html)). Eth2, the upcoming Ethereum protocol upgrade, will transition the blockchain from PoW to Proof of Stake (PoS), a consensus mechanism with a negligible ecological impact ([Ethereum Foundation](https://ethereum.org/en/eth2/merge/)). The Eth2 upgrade is planned to arrive in 2021 or 2022.
138 |
139 | Until Eth2 arrives, NFT activity on Ethereum can be argued to incentivize PoW mining by consuming L1 block space, thus adding congestion to the network, driving up gas prices, and increasing miner rewards. NFT critics argue that planned upgrades are an insufficient argument to justify NFT minting on Ethereum's PoW L1 today ([Memo Akten](https://memoakten.medium.com/the-unreasonable-ecological-cost-of-cryptoart-2221d3eb2053)).
140 |
141 | In the absence of PoS Ethereum Mainnet, some NFT artists have migrated their practices to alternative L1 PoS blockchains such as Tezos and Flow (see [Hic et Nunc](https://www.hicetnunc.xyz/) and [Versus](https://www.versus-flow.art/) platforms). These blockchains exhibit inferior security due to [relatively centralized token ownership](https://www.onflow.org/token-distribution#:~:text=phase%20ii%3A%20token%20generation%20and%20distribution) and [governance uncertainty](https://www.coindesk.com/tezos-investors-win-25m-settlement-in-court-case-over-230m-ico). Moreover, these blockchains fracture the NFT marketplace because they are not [Ethereum Virtual Machine (EVM)](https://ethereum.org/en/developers/docs/evm/) based. This makes them incompatible with existing ecosystem tools and platforms such as OpenSea marketplace, MetaMask and other Ethereum-compatible wallets, and development tooling such as Hardhat.
142 |
143 | To further reduce the ecological impact of NFTs while delivering creators high security NFTs, we present a deployment of the CALM standard to the Matic PoS chain, a Layer 2 EVM network ([Matic PoS Chain](https://docs.matic.network/docs/develop/ethereum-matic/pos/getting-started/)). Matic PoS chain delivers the ecological and EVM gas saving advantages of Eth2, today, while maintaining compatibility with existing Ethereum wallets, NFT EIP standards, development languages, and tooling ([Bankless](https://www.youtube.com/watch?v=rCJUBUTFElE)). Matic's Ethereum-Matic Bridge also enables NFTs to be transferred between Ethereum L1, Matic, and future EVM chains with the help of Polygon and equivalent multichain infrastructure ([Matic Bridge](https://docs.matic.network/docs/develop/ethereum-matic/getting-started/)).
144 |
145 | In addition to Matic, CALM is natively compatible with all EVM Layer 1 and Layer 2 blockchains, such as xDai, Fantom, and Binance Smart Chain. CALM will also be relevant for use in conjunction with forthcoming rollups such as Optimism's OVM and Arbitrum. Rapid adoption of nascent rollup technology has even accelerated the Eth2 PoS transition timeline ([Consensys](https://consensys.net/blog/ethereum-2-0/proof-of-stake-is-coming-to-ethereum-sooner-than-we-think/#:~:text=this%20also%20means%20that%20moving%20ethereum%20off%20proof%20of%20work%20and%20onto%20proof%20of%20stake%20can%20happen%20even%20sooner%2C%20perhaps%20this%20year.%20)).
146 |
147 | ### CALM on Matic
148 |
149 | CALM is deployed to the Matic chain (see contract address [here](https://github.com/nftstory/CALM/blob/main/addresses/CALM721.json)). Instructions for interacting with the contract are [available on Github](https://github.com/nftstory/CALM).
150 |
151 | We will be deploying an interface to interact with an extended version of the CALM on Matic contract on [nftstory.life](https://nftstory.life) later this month (May 2021). An alpha version of that interface is currently available on Rinkeby at [rinkeby.nftstory.life](https://rinkeby.nftstory.life). Access to the Rinkeby alpha is currently restricted to whitelisted accounts. We invite you to send us your Rinkeby wallet address so that we may add you to the whitelist. Please contact dev at nftstory.life.
152 |
153 | ## Next Steps
154 |
155 | If there is interest amongst the developer community, we would be interested in formalizing and refining the CALM standard through the EIP process.
156 |
157 | Should this submission win the GreenNFT bounty, we intend to use the reward to refine our existing submission with a professional contract audit.
158 |
159 | If you have additional ideas for funding the auditing and development of this contract and related NFT minting tools, please contact us at dev at nftstory.life or https://twitter.com/nnnnicholas.
160 |
161 | # Project structures
162 |
163 | `contracts/` contains both the CALM solidity interface and ERC721 as well as ERC1155 reference implementations
164 |
165 | `docs/` contains minimal documentation for content addressed token IDs, the contract expands on this with EIP-712 mint permits
166 |
167 | `test` minimal tests, can be used as a reference on how to talk to the contracts
--------------------------------------------------------------------------------