diff --git a/src/IPNFT.sol b/src/IPNFT.sol index 76ef64a0..78416111 100644 --- a/src/IPNFT.sol +++ b/src/IPNFT.sol @@ -21,9 +21,9 @@ import { IReservable } from "./IReservable.sol"; _| ▓▓_| ▓▓ | ▓▓ \▓▓▓▓ ▓▓ | ▓▓ | ▓▓ \ ▓▓ | ▓▓ \▓▓▓ ▓▓ | ▓▓ \▓▓▓▓▓▓\▓▓ \▓▓ \▓▓\▓▓ \▓▓ - */ +*/ -/// @title IPNFT V2.5 +/// @title IPNFT V2.5.1 /// @author molecule.to /// @notice IP-NFTs capture intellectual property to be traded and synthesized contract IPNFT is ERC721URIStorageUpgradeable, ERC721BurnableUpgradeable, IReservable, UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable { @@ -44,6 +44,9 @@ contract IPNFT is ERC721URIStorageUpgradeable, ERC721BurnableUpgradeable, IReser /// @notice an IPNFT's base symbol, to be determined by the minter / owner, e.g. BIO-00001 mapping(uint256 => string) public symbol; + /// @dev the highest possible reservation id + uint256 private constant MAX_RESERVATION_ID = type(uint128).max; + event Reserved(address indexed reserver, uint256 indexed reservationId); event IPNFTMinted(address indexed owner, uint256 indexed tokenId, string tokenURI, string symbol); event ReadAccessGranted(uint256 indexed tokenId, address indexed reader, uint256 until); @@ -103,15 +106,17 @@ contract IPNFT is ERC721URIStorageUpgradeable, ERC721BurnableUpgradeable, IReser } /** - * @notice mints an IPNFT with `tokenURI` as source of metadata. Invalidates the reservation. Redeems `mintpassId` on the authorizer contract - * @notice We are charging a nominal fee to symbolically represent the transfer of ownership rights, for a price of .001 ETH (<$2USD at current prices). This helps the ensure the protocol is affordable to almost all projects, but discourages frivolous IP-NFT minting. + * @notice mints an IPNFT with `tokenURI` as source of metadata. + * Minting the IPNFT can happen either with a reservation id or poi hash (Proof of Idea). + * if the tokenId is a reservationId then it invalidates the reservation. + * @notice We are charging a nominal fee to symbolically represent the transfer of ownership rights, for a price of .001 ETH (<$2USD at current prices). This helps ensure the protocol is affordable to almost all projects, but discourages frivolous IP-NFT minting. * * @param to the recipient of the NFT - * @param reservationId the reserved token id that has been reserved with `reserve()` + * @param reservationId the reserved token id that has been reserved with `reserve()` / or the poi hash * @param _tokenURI a location that resolves to a valid IP-NFT metadata structure * @param _symbol a symbol that represents the IPNFT's derivatives. Can be changed by the owner * @param authorization a bytes encoded parameter that's handed to the current authorizer - * @return the `reservationId` + * @return the `tokenId` */ function mintReservation(address to, uint256 reservationId, string calldata _tokenURI, string calldata _symbol, bytes calldata authorization) external @@ -120,7 +125,8 @@ contract IPNFT is ERC721URIStorageUpgradeable, ERC721BurnableUpgradeable, IReser whenNotPaused returns (uint256) { - if (reservations[reservationId] != _msgSender()) { + bool _isPoi = isPoi(reservationId); + if (!_isPoi && reservations[reservationId] != _msgSender()) { revert NotOwningReservation(reservationId); } @@ -131,8 +137,10 @@ contract IPNFT is ERC721URIStorageUpgradeable, ERC721BurnableUpgradeable, IReser if (!mintAuthorizer.authorizeMint(_msgSender(), to, abi.encode(SignedMintAuthorization(reservationId, _tokenURI, authorization)))) { revert Unauthorized(); } + if (!_isPoi) { + delete reservations[reservationId]; + } - delete reservations[reservationId]; symbol[reservationId] = _symbol; mintAuthorizer.redeem(authorization); @@ -188,7 +196,7 @@ contract IPNFT is ERC721URIStorageUpgradeable, ERC721BurnableUpgradeable, IReser (bool success,) = _msgSender().call{ value: address(this).balance }(""); require(success, "transfer failed"); } - + /// @inheritdoc UUPSUpgradeable function _authorizeUpgrade(address /*newImplementation*/ ) internal @@ -201,6 +209,12 @@ contract IPNFT is ERC721URIStorageUpgradeable, ERC721BurnableUpgradeable, IReser super._burn(tokenId); } + /// @notice checks whether a token id is a Proof of Idea + /// @param tokenId the token id, either a reserved id or a poi hash + function isPoi(uint256 tokenId) public pure returns (bool) { + return tokenId > MAX_RESERVATION_ID; + } + /// @inheritdoc ERC721Upgradeable function tokenURI(uint256 tokenId) public view virtual override(ERC721URIStorageUpgradeable, ERC721Upgradeable) returns (string memory) { return super.tokenURI(tokenId); diff --git a/subgraph/src/ipnftMapping.ts b/subgraph/src/ipnftMapping.ts index d97ad381..7a257b3e 100644 --- a/subgraph/src/ipnftMapping.ts +++ b/subgraph/src/ipnftMapping.ts @@ -75,17 +75,21 @@ export function handleReservation(event: ReservedEvent): void { reservation.save() } -function updateIpnftMetadata(ipnft: Ipnft, uri: string, timestamp: BigInt): void { - let ipfsLocation = uri.replace('ipfs://', ''); - if (!ipfsLocation || ipfsLocation == uri) { - log.error("Invalid URI format for tokenId {}: {}", [ipnft.id, uri]) - return - } +function updateIpnftMetadata( + ipnft: Ipnft, + uri: string, + timestamp: BigInt +): void { + let ipfsLocation = uri.replace('ipfs://', '') + if (!ipfsLocation || ipfsLocation == uri) { + log.error('Invalid URI format for tokenId {}: {}', [ipnft.id, uri]) + return + } - ipnft.tokenURI = uri - ipnft.metadata = ipfsLocation - ipnft.updatedAtTimestamp = timestamp - IpnftMetadataTemplate.create(ipfsLocation) + ipnft.tokenURI = uri + ipnft.metadata = ipfsLocation + ipnft.updatedAtTimestamp = timestamp + IpnftMetadataTemplate.create(ipfsLocation) } //the underlying parameter arrays are misaligned, hence we cannot cast or unify both events @@ -97,7 +101,6 @@ export function handleMint(event: IPNFTMintedEvent): void { updateIpnftMetadata(ipnft, event.params.tokenURI, event.block.timestamp) store.remove('Reservation', event.params.tokenId.toString()) ipnft.save() - } export function handleMetadataUpdated(event: MetadataUpdateEvent): void { @@ -108,13 +111,14 @@ export function handleMetadataUpdated(event: MetadataUpdateEvent): void { } //erc4906 is not emitting the new url, we must query it ourselves - let _ipnftContract = IPNFTContract.bind(event.params._event.address); + let _ipnftContract = IPNFTContract.bind(event.params._event.address) let newUri = _ipnftContract.tokenURI(event.params._tokenId) - if (!newUri || newUri == "") { - log.debug("no new uri found for token, likely just minted {}", [event.params._tokenId.toString()]) - return + if (!newUri || newUri == '') { + log.debug('no new uri found for token, likely just minted {}', [ + event.params._tokenId.toString() + ]) + return } - updateIpnftMetadata(ipnft, newUri, event.block.timestamp) + updateIpnftMetadata(ipnft, newUri, event.block.timestamp) ipnft.save() } - diff --git a/test/IPNFT.t.sol b/test/IPNFT.t.sol index 19facf23..5d4ebdf3 100644 --- a/test/IPNFT.t.sol +++ b/test/IPNFT.t.sol @@ -72,6 +72,36 @@ contract IPNFTTest is IPNFTMintHelper { assertEq(ipnft.reservations(2), bob); } + function testVerifyPoi() public { + uint256 tokenId = uint256(0x073cb54264ef688e56531a2d09ab47b14086b5c7813e3a23a2bd7b1bb6458a52); + bool isPoi = ipnft.isPoi(tokenId); + assertEq(isPoi, true); + } + + function testMintWithPoi() public { + bytes32 poiHash = 0x073cb54264ef688e56531a2d09ab47b14086b5c7813e3a23a2bd7b1bb6458a52; + uint256 tokenId = uint256(poiHash); + bytes32 authMessageHash = ECDSA.toEthSignedMessageHash(keccak256(abi.encodePacked(alice, alice, tokenId, ipfsUri))); + + vm.startPrank(deployer); + ipnft.setAuthorizer(new SignedMintAuthorizer(deployer)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(deployerPk, authMessageHash); + bytes memory authorization = abi.encodePacked(r, s, v); + + vm.startPrank(alice); + vm.expectRevert(IPNFT.Unauthorized.selector); + ipnft.mintReservation{ value: MINTING_FEE }(alice, tokenId, ipfsUri, DEFAULT_SYMBOL, bytes("abcde")); + + vm.expectEmit(true, true, false, true); + emit IPNFTMinted(alice, tokenId, ipfsUri, DEFAULT_SYMBOL); + ipnft.mintReservation{ value: MINTING_FEE }(alice, tokenId, ipfsUri, DEFAULT_SYMBOL, authorization); + assertEq(ipnft.ownerOf(tokenId), alice); + assertEq(ipnft.tokenURI(tokenId), ipfsUri); + assertEq(ipnft.symbol(tokenId), DEFAULT_SYMBOL); + + vm.stopPrank(); + } + function testMintFromReservation() public { vm.startPrank(deployer); ipnft.setAuthorizer(new SignedMintAuthorizer(deployer)); @@ -101,8 +131,6 @@ contract IPNFTTest is IPNFTMintHelper { assertEq(ipnft.tokenURI(1), ipfsUri); assertEq(ipnft.symbol(reservationId), DEFAULT_SYMBOL); - assertEq(ipnft.reservations(1), address(0)); - vm.stopPrank(); } @@ -145,7 +173,6 @@ contract IPNFTTest is IPNFTMintHelper { /** * ... but when set as heir of a self destruct operation the contract accepts the money. */ - function testOwnerCanWithdrawEthFunds() public { vm.deal(address(bob), 10 ether); vm.startPrank(bob); @@ -232,15 +259,13 @@ contract IPNFTTest is IPNFTMintHelper { //the signoff only allows alice to call this vm.startPrank(charlie); vm.expectRevert(IPNFT.Unauthorized.selector); - ipnft.amendMetadata(1, "ipfs://QmNewUri", authorization); + ipnft.amendMetadata(1, "ipfs://QmNewUri", authorization); vm.startPrank(alice); vm.expectEmit(true, true, false, false); emit MetadataUpdate(1); - ipnft.amendMetadata(1, "ipfs://QmNewUri", authorization); + ipnft.amendMetadata(1, "ipfs://QmNewUri", authorization); assertEq(ipnft.tokenURI(1), "ipfs://QmNewUri"); vm.stopPrank(); } - - }