Skip to content

Instantly share code, notes, and snippets.

@samkingco
Last active May 29, 2022 23:51
Show Gist options
  • Select an option

  • Save samkingco/b7f55eb645aee7bbc7dc07b12d30c173 to your computer and use it in GitHub Desktop.

Select an option

Save samkingco/b7f55eb645aee7bbc7dc07b12d30c173 to your computer and use it in GitHub Desktop.
Off and on-chain photo collection contracts
// 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);
}
// 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);
}
// 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)));
}
}
// 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