// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "vendor/github.com/pascaldekloe/enft/contracts/ERC165.sol"; import "vendor/github.com/pascaldekloe/enft/contracts/ERC20.sol"; import "vendor/github.com/pascaldekloe/enft/contracts/ERC721Metadata.sol"; import "vendor/github.com/pascaldekloe/enft/contracts/FixedNFTSet.sol"; // QualifiedPrice provides an (ERC20) unit to the quanty. struct QualifiedPrice { uint96 amount; // currency quantity address currency; // token contract } // CollectiblesSale represents one product on Montro Collectibles. contract CollectiblesSale is FixedNFTSet, ERC721Metadata { // CollectiblesPurchaseOffer signals an option to purchase tokens. For any given // seller, each CollectiblesPurchaseOffer emission overrides the previous one, // if any. A zero QualifiedPrice amount terminates the offer from seller. event CollectiblesPurchaseOffer(QualifiedPrice perToken, address seller); // Name labels the item for sale. string public override(ERC721Metadata) name; // SerialCode identifies the item for sale. string public serialCode; // The 32-byte SHA2-256 from IPFS is split over two hexadecimal words. // Use all lower-case for valid IPFS URI composition. bytes32 immutable FSHashHex1; bytes32 immutable FSHashHex2; // BoostConfig is packed into one immutable word for gas efficiency. uint256 immutable boosts; mapping(address => QualifiedPrice) purchaseOffers; // BoostConfig packs boosts as base–ramp pairs. struct BoostConfig { int16 stakeBase; int16 stakeRamp; int16 weightBase; int16 weightRamp; } // All tokens are assigned to productHolder, with an CollectiblesPurchaseOffer // emission as per QualifiedPrice. The initial ERC721 Transfer events are // omitted because they are implied by the CollectiblesPurchaseOffer already. constructor(address productHolder, string memory productName, string memory productSerialCode, uint256 partCount, QualifiedPrice memory perToken, bytes32 IPFSHashHex1, bytes32 IPFSHashHex2, BoostConfig memory bc) FixedNFTSet(partCount, productHolder) { name = productName; serialCode = productSerialCode; FSHashHex1 = IPFSHashHex1; FSHashHex2 = IPFSHashHex2; boosts = uint256(uint16(bc.stakeRamp)) << 32 | uint256(uint16(bc.stakeBase)) << 48 | uint256(uint16(bc.weightRamp)) << 64 | uint256(uint16(bc.weightBase)) << 80; // initial Transfer emission is optional but needed by OpenSea for (uint256 tokenID; tokenID < partCount; tokenID++) { emit Transfer(address(0), productHolder, tokenID); } // initial product offering purchaseOffers[productHolder] = perToken; emit CollectiblesPurchaseOffer(perToken, productHolder); } function supportsInterface(bytes4 interfaceID) public override(FixedNFTSet) pure returns (bool) { return super.supportsInterface(interfaceID) || interfaceID == 0x5b5e139f; // ERC721Metadata } function symbol() override(ERC721Metadata) public pure returns (string memory) { return "PART"; } // The CID header consists of the following (multiformat) prefixes: // 'f': lower-case hexadecimal (for all what follows) // '01': CID version 1 (in hexadecimal) // '70': MerkleDAG ProtoBuf (in hexadecimal) // '12': SHA2-256 (in hexadecimal) // '20': hash bit-length (in hexadecimal) bytes16 constant FSURIPrefix = "ipfs://f01701220"; function tokenURI(uint256 tokenID) override(ERC721Metadata) public view returns (string memory) { requireToken(tokenID); // "/part-000.json" uint256 path = 0x2f706172742d3030302e6a736f6e; path += tokenID % 10 << 40; // digit path += ((tokenID / 10) % 10) << 48; // deci digit path += ((tokenID / 100) % 10) << 56; // centi digit return string(bytes.concat(FSURIPrefix, FSHashHex1, FSHashHex2, // both hex parts bytes14(uint112(path)) // convert to bytes )); } // TokenStake returns the relative share in the sale-execution. All boost values // combined represent a payout in full. // // ⚠️ Note that the stake is duplacated in the metadata from tokenURI. function tokenStake(uint256 tokenID) public view returns (int256 boost) { uint n = totalSupply(); if (tokenID >= n) return 0; uint256 b = boosts; return int256(tokenID) * int16(uint16(b >> 32)) + int16(uint16(b >> 48)); } // TokenWeight returns the relative momentum for voting. All boost values // combined represent a vote in full. // // ⚠️ Note that the weight is duplacated in the metadata from tokenURI. function tokenWeight(uint256 tokenID) public view returns (int256 boost) { uint n = totalSupply(); if (tokenID >= n) return 0; uint256 b = boosts; return int256(tokenID) * int16(uint16(b >> 64)) + int16(uint16(b >> 80)); } // PurchaseOffer allows anyone to purchase tokens from msg.sender at the given // QualifiedPrice. PurchaseOffer overwrites the previous QualifiedPrice, if any. // QualifiedPrice amount zero terminates any PurchaseOffer from msg.sender. function purchaseOffer(QualifiedPrice memory perToken) public payable { if (perToken.amount == 0) { delete purchaseOffers[msg.sender]; } else { tokenOfOwnerByIndex(msg.sender, 0); // address & balance check require(isApprovedForAll(msg.sender, address(this)), "contract needs operator approval"); purchaseOffers[msg.sender] = perToken; } emit CollectiblesPurchaseOffer(perToken, msg.sender); } // PurchaseFrom aquires a token from seller if, and only if, a matching purchase // offer is found. function purchaseFrom(address seller, uint256 tokenID) public payable { QualifiedPrice memory offer = purchaseOffers[seller]; require(offer.amount != 0, "no offer"); // verify price expectency of buyer against current offer require(ERC20(offer.currency).allowance(msg.sender, address(this)) == offer.amount, "allowance mismatch"); // pay require(ERC20(offer.currency).transferFrom(msg.sender, seller, offer.amount), "no pay"); // redeem this.transferFrom(seller, msg.sender, tokenID); } }