Last active
May 29, 2022 23:51
-
-
Save samkingco/b7f55eb645aee7bbc7dc07b12d30c173 to your computer and use it in GitHub Desktop.
Off and on-chain photo collection contracts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // SPDX-License-Identifier: CC0-1.0 | |
| pragma solidity ^0.8.14; | |
| interface IPhotoCollection { | |
| function getOriginalsBaseURI() external view returns (string memory); | |
| function getOriginalTokenId(uint256 editionId) external pure returns (uint256); | |
| function getRawEditionPhotoData(uint256 id) external view returns (bytes memory); | |
| function getEditionTokenId(uint256 id) external pure returns (uint256); | |
| function getMaxEditions() external view returns (uint256); | |
| function isEdition(uint256 id) external pure returns (bool); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // SPDX-License-Identifier: CC0-1.0 | |
| pragma solidity ^0.8.14; | |
| interface IPhotoCollectionRenderer { | |
| function drawSVGToString(bytes memory data) external pure returns (string memory); | |
| function drawSVGToBytes(bytes memory data) external pure returns (bytes memory); | |
| function tokenURI(uint256 id) external view returns (string memory); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // SPDX-License-Identifier: CC0-1.0 | |
| pragma solidity ^0.8.14; | |
| import {Owned} from "@rari-capital/solmate/src/auth/Owned.sol"; | |
| import {ERC1155} from "@rari-capital/solmate/src/tokens/ERC1155.sol"; | |
| import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; | |
| import {SSTORE2} from "@0xsequence/sstore2/contracts/SSTORE2.sol"; | |
| import {DynamicBuffer} from "@divergencetech/ethier/contracts/utils/DynamicBuffer.sol"; | |
| import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | |
| import {IPhotoCollectionRenderer} from "./interfaces/IPhotoCollectionRenderer.sol"; | |
| import {IPhotoCollection} from "./interfaces/IPhotoCollection.sol"; | |
| /// @title "to be named", a photo collection by Sam King | |
| /// @author Sam King (samkingstudio.eth) | |
| contract PhotoCollection is ERC1155, Owned, IPhotoCollection { | |
| using Strings for uint256; | |
| using DynamicBuffer for bytes; | |
| /* ------------------------------------------------------------------------ | |
| S T O R A G E | |
| ------------------------------------------------------------------------ */ | |
| /* DEPENDENCIES -------------------------------------------------------- */ | |
| IPhotoCollectionRenderer public metadata; | |
| /* SALE DATA ----------------------------------------------------------- */ | |
| uint256 private immutable _maxTokenId = 40; | |
| uint256 private immutable _editionStartId = 100; | |
| uint256 private immutable _maxEditions = 25; | |
| uint256 public immutable priceOriginal = 0.001 ether; | |
| uint256 public immutable priceEdition = 0.001 ether; | |
| mapping(uint256 => uint256) private _salesCount; | |
| /* IMAGE DATA ---------------------------------------------------------- */ | |
| string private _originalsBaseURI; | |
| uint256 private immutable _photoDataByteSize = 2120; | |
| uint256 private immutable _photoDataChunkSize = 5; | |
| mapping(uint256 => address) private _photoDataChunks; | |
| /* ------------------------------------------------------------------------ | |
| E R R O R S | |
| ------------------------------------------------------------------------ */ | |
| /* MINT ---------------------------------------------------------------- */ | |
| error AlreadyOwnerOfEdition(); | |
| error IncorrectEthAmount(); | |
| error SoldOut(); | |
| error EditionForOriginalHolderStillReserved(); | |
| error NotOwnerOfOriginal(); | |
| error ReservedEditionAlreadyClaimed(); | |
| /* ADMIN --------------------------------------------------------------- */ | |
| error InvalidPhotoData(); | |
| error InvalidToken(); | |
| error NoMetadataYet(); | |
| error PaymentFailed(); | |
| /* BURN ---------------------------------------------------------------- */ | |
| error NotOwner(); | |
| /* ------------------------------------------------------------------------ | |
| M O D I F I E R S | |
| ------------------------------------------------------------------------ */ | |
| /// @dev Limits purchases etc to a certain range of token ids | |
| /// @param id The id of the token to check | |
| modifier onlyValidToken(uint256 id) { | |
| if (id == 0 || id > _maxTokenId) revert InvalidToken(); | |
| _; | |
| } | |
| /// @dev Checks the payment amount matches exactly (no more, no less) | |
| /// @param cost The amount that should be checked against | |
| modifier onlyCorrectPayment(uint256 cost) { | |
| if (msg.value != cost) revert IncorrectEthAmount(); | |
| _; | |
| } | |
| /// @dev Require the metadata address to be set | |
| modifier onlyWithMetadata() { | |
| if (address(metadata) == address(0)) revert NoMetadataYet(); | |
| _; | |
| } | |
| /* ------------------------------------------------------------------------ | |
| I N I T | |
| ------------------------------------------------------------------------ */ | |
| /// @param owner The owner of the contract upon deployment | |
| /// @param originalsBaseURI_ The base URI for original photos (usually arweave or ipfs) | |
| constructor(address owner, string memory originalsBaseURI_) ERC1155() Owned(owner) { | |
| _originalsBaseURI = originalsBaseURI_; | |
| } | |
| /// @notice Sets the rendering/metadata contract address | |
| /// @dev The metadata address handles on-chain images and construction of baseURI for originals | |
| /// @param metadataAddr The address of the metadata contract | |
| function setMetadata(IPhotoCollectionRenderer metadataAddr) external onlyOwner { | |
| metadata = metadataAddr; | |
| } | |
| /* ------------------------------------------------------------------------ | |
| P U R C H A S I N G | |
| ------------------------------------------------------------------------ */ | |
| /// @notice Purchase an original 1/1 photo and an on-chain edition | |
| /// @dev Mints a 1/1 and an on-chain edition of the same token, but only if the buyer | |
| /// doesn't already own an edition | |
| /// @param id The id of the photo to purchase | |
| function purchaseOriginal(uint256 id) | |
| external | |
| payable | |
| onlyValidToken(id) | |
| onlyCorrectPayment(priceOriginal) | |
| { | |
| (uint256 originalsSold, , ) = _decodeSalesCount(_salesCount[id]); | |
| if (originalsSold > 0) revert SoldOut(); | |
| uint256 editionId = getEditionTokenId(id); | |
| if (balanceOf[msg.sender][editionId] > 0) { | |
| // Already owner of an edition so just mint an original and mark the | |
| // reserved edition as claimed | |
| _mint(msg.sender, id, 1, ""); | |
| _addSalesCount(id, 1, 0, true); | |
| } else { | |
| // Else mint both the original and the reserved edition | |
| uint256[] memory ids = new uint256[](2); | |
| ids[0] = id; | |
| ids[1] = editionId; | |
| uint256[] memory amounts = new uint256[](2); | |
| amounts[0] = 1; | |
| amounts[1] = 1; | |
| _batchMint(msg.sender, ids, amounts, ""); | |
| _addSalesCount(id, 1, 1, true); | |
| } | |
| } | |
| /// @notice Purchase an edition of a photo, rendered as a 64x64px on-chain SVG | |
| /// @dev Editions are sold out when `_maxEditions` editions have been minted, less one reserved | |
| /// token for the holder of an original photo | |
| /// @param id The id of the edition to purchase (use original photo's id e.g. 1-`_maxTokenId`) | |
| function purchaseEdition(uint256 id) | |
| external | |
| payable | |
| onlyValidToken(id) | |
| onlyCorrectPayment(priceEdition) | |
| { | |
| uint256 editionId = getEditionTokenId(id); | |
| (, uint256 editionsSold, bool reservedEditionClaimed) = _decodeSalesCount(_salesCount[id]); | |
| uint256 editionsAvailable = reservedEditionClaimed ? _maxEditions : _maxEditions - 1; | |
| if (balanceOf[msg.sender][editionId] > 0) revert AlreadyOwnerOfEdition(); | |
| if (editionsSold == editionsAvailable) { | |
| if (reservedEditionClaimed) { | |
| revert SoldOut(); | |
| } else { | |
| revert EditionForOriginalHolderStillReserved(); | |
| } | |
| } | |
| _mint(msg.sender, editionId, 1, ""); | |
| _addSalesCount(id, 0, 1, reservedEditionClaimed); | |
| } | |
| /// @dev Increments sales data for a given id | |
| /// @param id The id of the photo to add sales data for | |
| /// @param originalsSold_ The number of originals sold for this given call | |
| /// @param editionsSold_ The number of editions sold for this given call | |
| /// @param reservedEditionClaimed_ Whether the original photo has claimed the reserved edition | |
| function _addSalesCount( | |
| uint256 id, | |
| uint256 originalsSold_, | |
| uint256 editionsSold_, | |
| bool reservedEditionClaimed_ | |
| ) internal { | |
| (uint256 originalsSold, uint256 editionsSold, ) = _decodeSalesCount(_salesCount[id]); | |
| _salesCount[id] = _encodeSalesCount( | |
| originalsSold + originalsSold_, | |
| editionsSold + editionsSold_, | |
| reservedEditionClaimed_ | |
| ); | |
| } | |
| /// @dev Encodes sales data into a single uint256 for cheaper storage updates | |
| /// @param originalsSoldCount The number of originals sold | |
| /// @param editionsSoldCount The number of editions sold | |
| /// @param reservedEditionClaimed Whether the original photo has claimed the reserved edition | |
| /// @return salesCount A packed uint256 | |
| function _encodeSalesCount( | |
| uint256 originalsSoldCount, | |
| uint256 editionsSoldCount, | |
| bool reservedEditionClaimed | |
| ) internal pure returns (uint256 salesCount) { | |
| salesCount = salesCount | (originalsSoldCount << 0); | |
| salesCount = salesCount | (editionsSoldCount << 120); | |
| if (reservedEditionClaimed) { | |
| salesCount = salesCount | (1 << 250); | |
| } else { | |
| salesCount = salesCount | (0 << 250); | |
| } | |
| } | |
| /// @dev Decodes sales data from a single uint256 | |
| /// @param salesCount The packed uint256 to decode | |
| /// @return originalsSoldCount The number of originals sold | |
| /// @return editionsSoldCount The number of editions sold | |
| /// @return reservedEditionClaimed Whether the original photo has claimed the reserved edition | |
| function _decodeSalesCount(uint256 salesCount) | |
| internal | |
| pure | |
| returns ( | |
| uint256 originalsSoldCount, | |
| uint256 editionsSoldCount, | |
| bool reservedEditionClaimed | |
| ) | |
| { | |
| originalsSoldCount = uint120(salesCount >> 0); | |
| editionsSoldCount = uint120(salesCount >> 120); | |
| reservedEditionClaimed = uint16(salesCount >> 250) > 0; | |
| } | |
| /* ------------------------------------------------------------------------ | |
| O R I G I N A L S | |
| ------------------------------------------------------------------------ */ | |
| /// @notice Admin function to set the baseURI for original photos (arweave or ipfs) | |
| /// @param baseURI The new baseURI to set | |
| function setOriginalsBaseURI(string memory baseURI) external onlyOwner { | |
| _originalsBaseURI = baseURI; | |
| } | |
| /// @notice Retrieve the currently set baseURI | |
| /// @dev Used by the metadata contract to construct the tokenURI | |
| function getOriginalsBaseURI() external view returns (string memory) { | |
| return _originalsBaseURI; | |
| } | |
| /// @notice Gets the original token id from an edition token id | |
| /// @param editionId The token id of the edition | |
| function getOriginalTokenId(uint256 editionId) public pure returns (uint256) { | |
| return editionId - _editionStartId; | |
| } | |
| /// @notice Checks if an original photo has been sold | |
| /// @param id The id of the photo | |
| function getOriginalSold(uint256 id) external view returns (bool) { | |
| (uint256 originalsSold, , ) = _decodeSalesCount(_salesCount[id]); | |
| return originalsSold > 0; | |
| } | |
| /* ------------------------------------------------------------------------ | |
| E D I T I O N S | |
| ------------------------------------------------------------------------ */ | |
| /// @notice Admin function to store chunked photo data for on-chain editions | |
| /// @dev Stores the data in chunks for more efficient storage and costs | |
| /// @param chunkId The chunk id to save data for | |
| /// @param data The packed data in .xqst format | |
| function storeChunkedEditionPhotoData(uint256 chunkId, bytes calldata data) external onlyOwner { | |
| if (data.length != _photoDataByteSize * _photoDataChunkSize) revert InvalidPhotoData(); | |
| _photoDataChunks[chunkId] = SSTORE2.write(data); | |
| } | |
| /// @notice Gets the raw .xqst data for a given photo | |
| /// @dev Used by the metadata contract to read data from storage | |
| /// @param id The id of the photo to get data for | |
| function getRawEditionPhotoData(uint256 id) | |
| external | |
| view | |
| onlyValidToken(id) | |
| returns (bytes memory) | |
| { | |
| return _getRawEditionPhotoData(id); | |
| } | |
| /// @notice Gets a photo in SVG format | |
| /// @dev Calls out to the metadata contract for rendering | |
| /// @param id The id of the photo to render | |
| function getEditionPhotoSVG(uint256 id) | |
| external | |
| view | |
| onlyValidToken(id) | |
| onlyWithMetadata | |
| returns (string memory) | |
| { | |
| bytes memory data = _getRawEditionPhotoData(id); | |
| return metadata.drawSVGToString(data); | |
| } | |
| /// @dev Gets the raw photo data from storage | |
| /// @param id The id of the photo to render | |
| function _getRawEditionPhotoData(uint256 id) internal view returns (bytes memory) { | |
| uint256 chunkId = ((id - 1) / _photoDataChunkSize) + 1; | |
| uint256 chunkIndex = (id - 1) % _photoDataChunkSize; | |
| uint256 startBytes = chunkIndex * _photoDataByteSize; | |
| return SSTORE2.read(_photoDataChunks[chunkId], startBytes, startBytes + _photoDataByteSize); | |
| } | |
| /// @notice Gets the edition token id from the original token id | |
| /// @param id The id of the original photo | |
| function getEditionTokenId(uint256 id) public pure returns (uint256) { | |
| return id + _editionStartId; | |
| } | |
| /// @notice Gets the total number of editions that have been sold for a photo | |
| /// @param id The id of the photo to get the number of editions sold | |
| function getEditionsSold(uint256 id) external view returns (uint256) { | |
| (, uint256 editionsSold, ) = _decodeSalesCount(_salesCount[id]); | |
| return editionsSold; | |
| } | |
| /// @notice Gets the maximum number of editions per photo | |
| function getMaxEditions() external pure returns (uint256) { | |
| return _maxEditions; | |
| } | |
| /// @notice Checks if a token id is an original or an edition | |
| /// @param id The token id to check | |
| function isEdition(uint256 id) public pure returns (bool) { | |
| return id > _editionStartId; | |
| } | |
| /* ------------------------------------------------------------------------ | |
| E R C - 1 1 5 5 | |
| ------------------------------------------------------------------------ */ | |
| /// @notice Burn your token :( | |
| /// @param id The id of the token you want to burn | |
| function burn(uint256 id) external { | |
| if (balanceOf[msg.sender][id] == 0) revert NotOwner(); | |
| _burn(msg.sender, id, 1); | |
| } | |
| /// @notice Standard URI function to get the token metadata | |
| /// @param id The token id to get metadata for | |
| function uri(uint256 id) public view virtual override onlyWithMetadata returns (string memory) { | |
| return metadata.tokenURI(id); | |
| } | |
| /* ------------------------------------------------------------------------ | |
| W I T H D R A W | |
| ------------------------------------------------------------------------ */ | |
| /// @notice Withdraw the contracts ETH balance to the admin wallet | |
| function withdrawBalance() external { | |
| (bool success, ) = payable(owner).call{value: address(this).balance}(""); | |
| if (!success) revert PaymentFailed(); | |
| } | |
| /// @notice Withdraw all tokens for a given contract to the admin wallet | |
| function withdrawToken(IERC20 tokenAddress) external { | |
| tokenAddress.transfer(owner, tokenAddress.balanceOf(address(this))); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // SPDX-License-Identifier: CC0-1.0 | |
| pragma solidity ^0.8.14; | |
| import {Owned} from "@rari-capital/solmate/src/auth/Owned.sol"; | |
| import {XQSTGFX} from "@exquisite-graphics/contracts/contracts/XQSTGFX.sol"; | |
| import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; | |
| import {DynamicBuffer} from "@divergencetech/ethier/contracts/utils/DynamicBuffer.sol"; | |
| import {Base64} from "./Base64.sol"; | |
| import {IPhotoCollection} from "./interfaces/IPhotoCollection.sol"; | |
| /// @title "to be named", photo collection metadata & renderer | |
| /// @author Sam King (samkingstudio.eth) | |
| contract PhotoCollectionRenderer is Owned, XQSTGFX { | |
| using Strings for uint256; | |
| using DynamicBuffer for bytes; | |
| /* ------------------------------------------------------------------------ | |
| S T O R A G E | |
| ------------------------------------------------------------------------ */ | |
| /// @dev The address of the storage/minting contract | |
| IPhotoCollection public photoCollection; | |
| /* ------------------------------------------------------------------------ | |
| I N I T | |
| ------------------------------------------------------------------------ */ | |
| /// @param owner The owner of the contract upon deployment | |
| /// @param photoCollection_ The address of the storage/minting contract | |
| constructor(address owner, IPhotoCollection photoCollection_) Owned(owner) { | |
| photoCollection = photoCollection_; | |
| } | |
| /* ------------------------------------------------------------------------ | |
| R E N D E R I N G | |
| ------------------------------------------------------------------------ */ | |
| /// @notice Draws an SVG from data in the .xqst format to a string | |
| /// @param data The photo data in .xqst format | |
| function drawSVGToString(bytes memory data) public pure returns (string memory) { | |
| return string(drawSVGToBytes(data)); | |
| } | |
| /// @notice Draws an SVG from data in the .xqst format to bytes | |
| /// @param data The photo data in .xqst format | |
| function drawSVGToBytes(bytes memory data) public pure returns (bytes memory) { | |
| string memory rects = drawRectsUnsafe(data); | |
| bytes memory svg = DynamicBuffer.allocate(2**19); | |
| svg.appendSafe( | |
| abi.encodePacked( | |
| '<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges" width="100%" height="100%" version="1.1" viewBox="0 0 128 128" fill="#fff"><rect<g transform="translate(32,32)">', | |
| rects, | |
| "</g></svg>" | |
| ) | |
| ); | |
| return svg; | |
| } | |
| /// @notice Renders metadata for a given token id | |
| /// @dev If the photo is an edition, then render an SVG, otherwise return the constructed URI | |
| /// @param id The token id to render | |
| function tokenURI(uint256 id) public view returns (string memory) { | |
| if (!photoCollection.isEdition(id)) { | |
| return string(abi.encodePacked(photoCollection.getOriginalsBaseURI(), id.toString())); | |
| } | |
| bytes memory data = photoCollection.getRawEditionPhotoData(id); | |
| string memory idString = photoCollection.getOriginalTokenId(id).toString(); | |
| bytes memory json = DynamicBuffer.allocate(2**19); | |
| bytes memory jsonBase64 = DynamicBuffer.allocate(2**19); | |
| json.appendSafe( | |
| abi.encodePacked( | |
| '{"symbol":"PHOTO","name":"Photo Collection #', | |
| idString, | |
| ' Edition","description":"A tiny edition of Photo Collection #', | |
| idString, | |
| ". Edition of ", | |
| photoCollection.getMaxEditions().toString(), | |
| ', 64x64px in size, stored fully on-chain.","image":"data:image/svg+xml;base64,', | |
| bytes(Base64.encode(drawSVGToBytes(data))), | |
| '","external_url":"https://samking.photo/photo/', | |
| idString, | |
| '","attributes":[]}' | |
| ) | |
| ); | |
| jsonBase64.appendSafe("data:application/json;base64,"); | |
| jsonBase64.appendSafe(bytes(Base64.encode(json))); | |
| return string(jsonBase64); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment