├── .gitignore ├── .solhint.json ├── README.md ├── addresses └── CALM721.json ├── contracts ├── CALM1155.sol ├── CALM721.sol └── ICALMNFT.sol ├── docs └── lazyminting.md ├── hardhat.config.ts ├── package-lock.json ├── package.json ├── tasks ├── deploy.ts └── faucet.js ├── test ├── CALM721.ts └── fixtures │ └── NFTmetatada.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | cache/ 3 | build/ 4 | typechain/ 5 | artifacts/ 6 | coverage* 7 | .env 8 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /addresses/CALM721.json: -------------------------------------------------------------------------------- 1 | { 2 | "1": "", 3 | "4": "", 4 | "137": "0xa9cC685d44d083E43f19B041931ABA04995df0db" 5 | } 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | ``` -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | }); -------------------------------------------------------------------------------- /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 | }); -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------