From df3dcf34101073cf8232ef0a8d624e44beed5858 Mon Sep 17 00:00:00 2001 From: mme <9083787+0xmme@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:58:50 +0200 Subject: [PATCH 1/7] feat(tokenizer): allow erc20 attachment as ipt - drops stoneage Synthesizer - extracts IIPToken interface to be reused on Tokenizer and Permissioner - fixes linearization of dependencies on IPToken - adds general erc20 metadata fields to shared IPT interface - wrapping test case - rollout script and disable fork test - drops size checks on builds - add sanity checks for ipt attachment on tokenizer --- .github/workflows/test.yml | 3 +- README.md | 8 +- script/dev/Synthesizer.s.sol | 100 ----- script/dev/Tokenizer.s.sol | 9 +- script/prod/RolloutTokenizerV13.s.sol | 24 - script/prod/RolloutTokenizerV14.s.sol | 31 ++ src/IIPToken.sol | 21 + src/IPToken.sol | 32 +- src/Permissioner.sol | 14 +- src/Tokenizer.sol | 114 ++++- src/WrappedIPToken.sol | 109 +++++ src/helpers/test-upgrades/IPToken13.sol | 120 +++++ .../{Tokenizer12.sol => Tokenizer13.sol} | 103 +++-- test/CrowdSalePermissioned.t.sol | 4 +- test/Forking/Tokenizer13UpgradeForkTest.t.sol | 238 ---------- test/Forking/Tokenizer14UpgradeForkTest.t.sol | 422 ++++++++++++++++++ test/Mintpass.t.sol | 4 - test/Permissioner.t.sol | 4 +- test/Tokenizer.t.sol | 32 +- test/TokenizerWrapped.t.sol | 177 ++++++++ 20 files changed, 1134 insertions(+), 435 deletions(-) delete mode 100644 script/dev/Synthesizer.s.sol delete mode 100644 script/prod/RolloutTokenizerV13.s.sol create mode 100644 script/prod/RolloutTokenizerV14.s.sol create mode 100644 src/IIPToken.sol create mode 100644 src/WrappedIPToken.sol create mode 100644 src/helpers/test-upgrades/IPToken13.sol rename src/helpers/test-upgrades/{Tokenizer12.sol => Tokenizer13.sol} (57%) delete mode 100644 test/Forking/Tokenizer13UpgradeForkTest.t.sol create mode 100644 test/Forking/Tokenizer14UpgradeForkTest.t.sol create mode 100644 test/TokenizerWrapped.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0d9d4f43..a080efa9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,10 +25,11 @@ jobs: - name: Install node dependencies run: yarn --frozen-lockfile + #todo: forge build --sizes - name: Run Forge build run: | forge --version - forge build --sizes + forge build id: build - name: Run Forge tests diff --git a/README.md b/README.md index 3b220a73..ddc5334a 100644 --- a/README.md +++ b/README.md @@ -100,12 +100,12 @@ VDAO_TOKEN_ADDRESS=0x19A3036b828bffB5E14da2659E950E76f8e6BAA2 --- -### upgrading to Tokenizer 1.3 +### upgrading to Tokenizer 1.4 -forge script --private-key=$PRIVATE_KEY --rpc-url=$RPC_URL script/prod/RolloutTokenizerV13.s.sol --broadcast +forge script --private-key=$PRIVATE_KEY --rpc-url=$RPC_URL script/prod/RolloutTokenizerV14.s.sol --broadcast -// 0xTokenizer 0xNewImpl 0xNewTokenImpl -cast send --rpc-url=$RPC_URL --private-key=$PRIVATE_KEY 0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e "upgradeToAndCall(address,bytes)" 0x70e0bA845a1A0F2DA3359C97E0285013525FFC49 0x84646c1f000000000000000000000000998abeb3e57409262ae5b751f60747921b33613e +// 0xTokenizer (address, bytes)(0xNewImpl, 0xNewWrappedIPTokenImpl 0xNewIPTokenImpl) +cast send --rpc-url=$RPC_URL --private-key=$PRIVATE_KEY 0x58EB89C69CB389DBef0c130C6296ee271b82f436 "upgradeToAndCall(address,bytes)" 0x34A1D3fff3958843C43aD80F30b94c510645C316 0x8b3d19bb0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000005b73c5498c1e3b4dba84de0f1833c4a029d90519 ### Timelocked Tokens diff --git a/script/dev/Synthesizer.s.sol b/script/dev/Synthesizer.s.sol deleted file mode 100644 index eb8e8627..00000000 --- a/script/dev/Synthesizer.s.sol +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.18; - -import "forge-std/Script.sol"; - -import "forge-std/console.sol"; -import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; -import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import { TokenVesting } from "@moleculeprotocol/token-vesting/TokenVesting.sol"; - -import { IPNFT } from "../../src/IPNFT.sol"; -import { Tokenizer } from "../../src/Tokenizer.sol"; -import { Metadata, IPToken } from "../../src/IPToken.sol"; -import { IPermissioner, TermsAcceptedPermissioner } from "../../src/Permissioner.sol"; -import { StakedLockingCrowdSale } from "../../src/crowdsale/StakedLockingCrowdSale.sol"; - -import { FakeERC20 } from "../../src/helpers/FakeERC20.sol"; -import { Synthesizer } from "../../src/helpers/test-upgrades/Synthesizer.sol"; - -import { Metadata as MolMetadata, Molecules } from "../../src/helpers/test-upgrades/Molecules.sol"; -import { - IPermissioner as IMolPermissioner, - TermsAcceptedPermissioner as MolTermsAcceptedPermissioner -} from "../../src/helpers/test-upgrades/SynthPermissioner.sol"; - -import { CommonScript } from "./Common.sol"; - -/** - * @title DeploySynthesizer - * @notice only used for local testing. The "Synthesizer" is the old name for `Tokenizer`. - */ -contract DeploySynthesizer is CommonScript { - function run() public { - prepareAddresses(); - vm.startBroadcast(deployer); - Synthesizer synthesizer = Synthesizer(address(new ERC1967Proxy(address(new Synthesizer()), ""))); - MolTermsAcceptedPermissioner oldPermissioner = new MolTermsAcceptedPermissioner(); - - synthesizer.initialize(IPNFT(vm.envAddress("IPNFT_ADDRESS")), oldPermissioner); - vm.stopBroadcast(); - console.log("TERMS_ACCEPTED_PERMISSIONER_ADDRESS=%s", address(oldPermissioner)); - console.log("SYNTHESIZER_ADDRESS=%s", address(synthesizer)); - } -} - -/** - * @title FixtureSynthesizer - * @author - * @notice execute Ipnft.s.sol && DeploySynthesizer first - * @notice assumes that bob (hh1) owns IPNFT#1 - */ -contract FixtureSynthesizer is CommonScript { - Synthesizer synthesizer; - MolTermsAcceptedPermissioner oldPermissioner; - - function prepareAddresses() internal override { - super.prepareAddresses(); - synthesizer = Synthesizer(vm.envAddress("SYNTHESIZER_ADDRESS")); - oldPermissioner = MolTermsAcceptedPermissioner(vm.envAddress("TERMS_ACCEPTED_PERMISSIONER_ADDRESS")); - } - - function run() public { - prepareAddresses(); - - string memory terms = oldPermissioner.specificTermsV1(MolMetadata(1, bob, "bafkreigk5dvqblnkdniges6ft5kmuly47ebw4vho6siikzmkaovq6sjstq")); - - vm.startBroadcast(bob); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(bobPk, ECDSA.toEthSignedMessageHash(abi.encodePacked(terms))); - bytes memory signedTerms = abi.encodePacked(r, s, v); - Molecules tokenContract = - synthesizer.synthesizeIpnft(1, 1_000_000 ether, "MOLE", "bafkreigk5dvqblnkdniges6ft5kmuly47ebw4vho6siikzmkaovq6sjstq", signedTerms); - vm.stopBroadcast(); - - console.log("IPTS_ADDRESS=%s", address(tokenContract)); - console.log("ipts (molecules) round hash: %s", tokenContract.hash()); - } -} - -/** - * @notice allows testing contract upgrades on the frontend in a controlled way - */ -contract UpgradeSynthesizerToTokenizer is CommonScript { - function run() public { - prepareAddresses(); - Synthesizer synthesizer = Synthesizer(vm.envAddress("SYNTHESIZER_ADDRESS")); - - vm.startBroadcast(deployer); - Tokenizer tokenizerImpl = new Tokenizer(); - synthesizer.upgradeTo(address(tokenizerImpl)); - Tokenizer tokenizer = Tokenizer(address(synthesizer)); - - TermsAcceptedPermissioner newTermsPermissioner = new TermsAcceptedPermissioner(); - //todo tokenizer.reinit(newTermsPermissioner); - vm.stopBroadcast(); - - console.log("TOKENIZER_ADDRESS=%s", address(tokenizer)); //should equal synthesizer - console.log("NEW_TERMS_ACCEPTED_PERMISSIONER_ADDRESS=%s", address(newTermsPermissioner)); - } -} diff --git a/script/dev/Tokenizer.s.sol b/script/dev/Tokenizer.s.sol index 9531fa16..11b26564 100644 --- a/script/dev/Tokenizer.s.sol +++ b/script/dev/Tokenizer.s.sol @@ -5,7 +5,10 @@ import "forge-std/Script.sol"; import "forge-std/console.sol"; import { IPNFT } from "../../src/IPNFT.sol"; import { Tokenizer } from "../../src/Tokenizer.sol"; -import { Metadata, IPToken } from "../../src/IPToken.sol"; +import { Metadata } from "../../src/IIPToken.sol"; +import { IPToken } from "../../src/IPToken.sol"; +import { WrappedIPToken } from "../../src/WrappedIPToken.sol"; + import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { IPermissioner, TermsAcceptedPermissioner } from "../../src/Permissioner.sol"; import { CommonScript } from "./Common.sol"; @@ -23,9 +26,13 @@ contract DeployTokenizer is CommonScript { IPToken initialIpTokenImplementation = new IPToken(); tokenizer.setIPTokenImplementation(initialIpTokenImplementation); + WrappedIPToken initialWrappedIpTokenImplementation = new WrappedIPToken(); + tokenizer.setWrappedIPTokenImplementation(initialWrappedIpTokenImplementation); + vm.stopBroadcast(); console.log("TOKENIZER_ADDRESS=%s", address(tokenizer)); console.log("iptoken implementation=%s", address(initialIpTokenImplementation)); + console.log("wrapped iptoken implementation=%s", address(initialWrappedIpTokenImplementation)); } } diff --git a/script/prod/RolloutTokenizerV13.s.sol b/script/prod/RolloutTokenizerV13.s.sol deleted file mode 100644 index 35aa5f4e..00000000 --- a/script/prod/RolloutTokenizerV13.s.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.18; - -import "forge-std/Script.sol"; -import { Tokenizer } from "../../src/Tokenizer.sol"; -import { IPToken } from "../../src/IPToken.sol"; -import { console } from "forge-std/console.sol"; - -contract RolloutTokenizerV13 is Script { - function run() public { - vm.startBroadcast(); - - IPToken newIpTokenImplementation = new IPToken(); - Tokenizer newTokenizerImplementation = new Tokenizer(); - - bytes memory upgradeCallData = abi.encodeWithSelector(Tokenizer.reinit.selector, address(newIpTokenImplementation)); - - console.log("NEWTOKENIMPLEMENTATION=%s", address(newIpTokenImplementation)); - console.log("NEWTOKENIZER=%s", address(newTokenizerImplementation)); - console.logBytes(upgradeCallData); - - vm.stopBroadcast(); - } -} diff --git a/script/prod/RolloutTokenizerV14.s.sol b/script/prod/RolloutTokenizerV14.s.sol new file mode 100644 index 00000000..9a67483f --- /dev/null +++ b/script/prod/RolloutTokenizerV14.s.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import "forge-std/Script.sol"; +import { Tokenizer } from "../../src/Tokenizer.sol"; +import { IPToken } from "../../src/IPToken.sol"; +import { WrappedIPToken } from "../../src/WrappedIPToken.sol"; +import { console } from "forge-std/console.sol"; + +contract RolloutTokenizerV14 is Script { + function run() public { + vm.startBroadcast(); + + // Deploy new implementations + IPToken ipTokenImplementation = new IPToken(); + WrappedIPToken wrappedIpTokenImplementation = new WrappedIPToken(); + Tokenizer newTokenizerImplementation = new Tokenizer(); + + // Prepare upgrade call data using reinit function + bytes memory upgradeCallData = + abi.encodeWithSelector(Tokenizer.reinit.selector, address(wrappedIpTokenImplementation), address(ipTokenImplementation)); + + console.log("IPTOKENIMPLEMENTATION=%s", address(ipTokenImplementation)); + console.log("WRAPPEDTOKENIMPLEMENTATION=%s", address(wrappedIpTokenImplementation)); + console.log("NEWTOKENIZER=%s", address(newTokenizerImplementation)); + console.log("UpgradeCallData:"); + console.logBytes(upgradeCallData); + + vm.stopBroadcast(); + } +} diff --git a/src/IIPToken.sol b/src/IIPToken.sol new file mode 100644 index 00000000..a47d6ce7 --- /dev/null +++ b/src/IIPToken.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8; + +struct Metadata { + uint256 ipnftId; + address originalOwner; + string agreementCid; +} + +interface IIPToken { + /// @notice the amount of tokens that ever have been issued (not necessarily == supply) + function totalIssued() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function metadata() external view returns (Metadata memory); + function issue(address, uint256) external; + function cap() external; + function uri() external view returns (string memory); + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); +} diff --git a/src/IPToken.sol b/src/IPToken.sol index 84a0d19f..ca8c4a7a 100644 --- a/src/IPToken.sol +++ b/src/IPToken.sol @@ -1,18 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.18; +import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import { ERC20BurnableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; import { Tokenizer, MustControlIpnft } from "./Tokenizer.sol"; import { IControlIPTs } from "./IControlIPTs.sol"; - -struct Metadata { - uint256 ipnftId; - address originalOwner; - string agreementCid; -} +import { IIPToken, Metadata } from "./IIPToken.sol"; error TokenCapped(); @@ -24,7 +20,7 @@ error TokenCapped(); * The owner can increase the token supply as long as it's not explicitly capped. * @dev formerly known as "molecules" */ -contract IPToken is ERC20BurnableUpgradeable, OwnableUpgradeable { +contract IPToken is IIPToken, ERC20Upgradeable, ERC20BurnableUpgradeable, OwnableUpgradeable { event Capped(uint256 atSupply); /// @notice the amount of tokens that ever have been issued (not necessarily == supply) @@ -35,12 +31,12 @@ contract IPToken is ERC20BurnableUpgradeable, OwnableUpgradeable { Metadata internal _metadata; - function initialize(uint256 ipnftId, string calldata name, string calldata symbol, address originalOwner, string memory agreementCid) + function initialize(uint256 ipnftId, string calldata name_, string calldata symbol_, address originalOwner, string memory agreementCid) external initializer { __Ownable_init(); - __ERC20_init(name, symbol); + __ERC20_init(name_, symbol_); _metadata = Metadata({ ipnftId: ipnftId, originalOwner: originalOwner, agreementCid: agreementCid }); } @@ -59,11 +55,27 @@ contract IPToken is ERC20BurnableUpgradeable, OwnableUpgradeable { return _metadata; } + function balanceOf(address account) public view override(ERC20Upgradeable, IIPToken) returns (uint256) { + return ERC20Upgradeable.balanceOf(account); + } + + function name() public view override(ERC20Upgradeable, IIPToken) returns (string memory) { + return ERC20Upgradeable.name(); + } + + function symbol() public view override(ERC20Upgradeable, IIPToken) returns (string memory) { + return ERC20Upgradeable.symbol(); + } + + function decimals() public view override(ERC20Upgradeable, IIPToken) returns (uint8) { + return ERC20Upgradeable.decimals(); + } /** * @notice the supply of IP Tokens is controlled by the tokenizer contract. * @param receiver address * @param amount uint256 */ + function issue(address receiver, uint256 amount) external onlyTokenizerOrIPNFTController { if (capped) { revert TokenCapped(); @@ -109,7 +121,7 @@ contract IPToken is ERC20BurnableUpgradeable, OwnableUpgradeable { string.concat( '{"name": "IP Tokens of IPNFT #', tokenId, - '","description": "IP Tokens, derived from IP-NFTs, are ERC-20 tokens governing IP pools.","decimals": 18,"external_url": "https://molecule.to","image": "",', + '","description": "IP Tokens, derived from IP-NFTs, are ERC-20 tokens governing IP pools.","decimals": 18,"external_url": "https://molecule.xyz","image": "",', props, "}" ) diff --git a/src/Permissioner.sol b/src/Permissioner.sol index fbb68031..622d1c3f 100644 --- a/src/Permissioner.sol +++ b/src/Permissioner.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.18; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; -import { IPToken, Metadata } from "./IPToken.sol"; +import { IIPToken, Metadata } from "./IIPToken.sol"; error InvalidSignature(); error Denied(); @@ -16,17 +16,17 @@ interface IPermissioner { * @param _for address * @param data bytes */ - function accept(IPToken tokenContract, address _for, bytes calldata data) external; + function accept(IIPToken tokenContract, address _for, bytes calldata data) external; } contract BlindPermissioner is IPermissioner { - function accept(IPToken tokenContract, address _for, bytes calldata data) external { + function accept(IIPToken tokenContract, address _for, bytes calldata data) external { //empty } } contract ForbidAllPermissioner is IPermissioner { - function accept(IPToken, address, bytes calldata) external pure { + function accept(IIPToken, address, bytes calldata) external pure { revert Denied(); } } @@ -44,7 +44,7 @@ contract TermsAcceptedPermissioner is IPermissioner { * @param _for address the account that has created `signature` * @param signature bytes encoded signature, for eip155: `abi.encodePacked(r, s, v)` */ - function accept(IPToken tokenContract, address _for, bytes calldata signature) external { + function accept(IIPToken tokenContract, address _for, bytes calldata signature) external { if (!isValidSignature(tokenContract, _for, signature)) { revert InvalidSignature(); } @@ -55,7 +55,7 @@ contract TermsAcceptedPermissioner is IPermissioner { * @notice checks whether `signer`'s `signature` of `specificTermsV1` on `tokenContract.metadata.ipnftId` is valid * @param tokenContract IPToken */ - function isValidSignature(IPToken tokenContract, address signer, bytes calldata signature) public view returns (bool) { + function isValidSignature(IIPToken tokenContract, address signer, bytes calldata signature) public view returns (bool) { bytes32 termsHash = ECDSA.toEthSignedMessageHash(bytes(specificTermsV1(tokenContract))); return SignatureChecker.isValidSignatureNow(signer, termsHash, signature); } @@ -78,7 +78,7 @@ contract TermsAcceptedPermissioner is IPermissioner { * @notice this yields the message text that claimers must present to proof they have accepted all terms * @param tokenContract IPToken */ - function specificTermsV1(IPToken tokenContract) public view returns (string memory) { + function specificTermsV1(IIPToken tokenContract) public view returns (string memory) { return (specificTermsV1(tokenContract.metadata())); } } diff --git a/src/Tokenizer.sol b/src/Tokenizer.sol index a6d9b38d..feed877e 100644 --- a/src/Tokenizer.sol +++ b/src/Tokenizer.sol @@ -3,9 +3,13 @@ pragma solidity 0.8.18; import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { Clones } from "@openzeppelin/contracts/proxy/Clones.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; -import { IPToken, Metadata as TokenMetadata } from "./IPToken.sol"; +import { IIPToken, Metadata as TokenMetadata } from "./IIPToken.sol"; +import { IPToken } from "./IPToken.sol"; +import { WrappedIPToken } from "./WrappedIPToken.sol"; + import { IPermissioner } from "./Permissioner.sol"; import { IPNFT } from "./IPNFT.sol"; import { IControlIPTs } from "./IControlIPTs.sol"; @@ -14,9 +18,11 @@ error MustControlIpnft(); error AlreadyTokenized(); error ZeroAddress(); error IPTNotControlledByTokenizer(); +error InvalidTokenContract(); +error InvalidTokenDecimals(); -/// @title Tokenizer 1.3 -/// @author molecule.to +/// @title Tokenizer 1.4 +/// @author molecule.xyz /// @notice tokenizes an IPNFT to an ERC20 token (called IPToken or IPT) and controls its supply. contract Tokenizer is UUPSUpgradeable, OwnableUpgradeable, IControlIPTs { event TokensCreated( @@ -30,13 +36,15 @@ contract Tokenizer is UUPSUpgradeable, OwnableUpgradeable, IControlIPTs { string symbol ); - event IPTokenImplementationUpdated(IPToken indexed old, IPToken indexed _new); + event TokenWrapped(IERC20Metadata tokenContract, IIPToken wrappedIpt); + event IPTokenImplementationUpdated(IIPToken indexed old, IIPToken indexed _new); + event WrappedIPTokenImplementationUpdated(WrappedIPToken indexed old, WrappedIPToken indexed _new); event PermissionerUpdated(IPermissioner indexed old, IPermissioner indexed _new); IPNFT internal ipnft; /// @dev a map of all IPTs. We're staying with the the initial term "synthesized" to keep the storage layout intact - mapping(uint256 => IPToken) public synthesized; + mapping(uint256 => IIPToken) public synthesized; /// @dev not used, needed to ensure that storage slots are still in order after 1.1 -> 1.2, use ipTokenImplementation /// @custom:oz-upgrades-unsafe-allow state-variable-immutable @@ -48,6 +56,9 @@ contract Tokenizer is UUPSUpgradeable, OwnableUpgradeable, IControlIPTs { /// @notice the IPToken implementation this Tokenizer clones from IPToken public ipTokenImplementation; + /// @notice a WrappedIPToken implementation, used for attaching existing ERC-20 contracts as metadata bearing IPTs + WrappedIPToken public wrappedTokenImplementation; + /** * @param _ipnft the IPNFT contract * @param _permissioner a permissioning contract that checks if callers have agreed to the tokenized token's legal agreements @@ -99,14 +110,27 @@ contract Tokenizer is UUPSUpgradeable, OwnableUpgradeable, IControlIPTs { } /** - * @dev sets legacy IPTs on the tokenized mapping + * @notice sets the new implementation address of the WrappedIPToken + * @param _wrappedIpTokenImplementation address pointing to the new implementation */ - function reinit(IPToken _ipTokenImplementation) public onlyOwner reinitializer(5) { - synthesized[2] = IPToken(0x6034e0d6999741f07cb6Fb1162cBAA46a1D33d36); - synthesized[28] = IPToken(0x7b66E84Be78772a3afAF5ba8c1993a1B5D05F9C2); - synthesized[37] = IPToken(0xBcE56276591128047313e64744b3EBE03998783f); + function setWrappedIPTokenImplementation(WrappedIPToken _wrappedIpTokenImplementation) public onlyOwner { + /* + could call some functions on old contract to make sure its tokenizer not another contract behind a proxy for safety + */ + if (address(_wrappedIpTokenImplementation) == address(0)) { + revert ZeroAddress(); + } + + emit WrappedIPTokenImplementationUpdated(wrappedTokenImplementation, _wrappedIpTokenImplementation); + wrappedTokenImplementation = _wrappedIpTokenImplementation; + } + /** + * @dev sets legacy IPTs on the tokenized mapping + */ + function reinit(WrappedIPToken _wrappedIpTokenImplementation, IPToken _ipTokenImplementation) public onlyOwner reinitializer(6) { setIPTokenImplementation(_ipTokenImplementation); + setWrappedIPTokenImplementation(_wrappedIpTokenImplementation); } /** @@ -155,6 +179,47 @@ contract Tokenizer is UUPSUpgradeable, OwnableUpgradeable, IControlIPTs { token.issue(_msgSender(), tokenAmount); } + /** + * @notice since 1.4 allows attaching an existing ERC20 contract as IPT + * @param ipnftId the token id on the underlying nft collection + * @param agreementCid a content hash that contains legal terms for IP token owners + * @param signedAgreement the sender's signature over the signed agreemeent text (must be created on the client) + * @param tokenContract the ERC20 token contract to wrap + * @return IPToken a wrapped IPToken that represents the tokenized ipnft for permissioners and carries metadata + */ + function attachIpt(uint256 ipnftId, string memory agreementCid, bytes calldata signedAgreement, IERC20Metadata tokenContract) + external + returns (IIPToken) + { + if (_msgSender() != controllerOf(ipnftId)) { + revert MustControlIpnft(); + } + if (address(synthesized[ipnftId]) != address(0)) { + revert AlreadyTokenized(); + } + + // Sanity checks for token properties + _validateTokenContract(tokenContract); + + WrappedIPToken wrappedIpt = WrappedIPToken(Clones.clone(address(wrappedTokenImplementation))); + wrappedIpt.initialize(ipnftId, _msgSender(), agreementCid, tokenContract); + synthesized[ipnftId] = wrappedIpt; + + emit TokensCreated( + uint256(keccak256(abi.encodePacked(ipnftId))), + ipnftId, + address(tokenContract), + _msgSender(), + tokenContract.totalSupply(), + agreementCid, + tokenContract.name(), + tokenContract.symbol() + ); + emit TokenWrapped(tokenContract, wrappedIpt); + permissioner.accept(wrappedIpt, _msgSender(), signedAgreement); + return wrappedIpt; + } + /** * @notice issues more IPTs when not capped. This can be used for new owners of legacy IPTs that otherwise wouldn't be able to pass their `onlyIssuerOrOwner` gate * @param ipToken The ip token to control @@ -179,6 +244,35 @@ contract Tokenizer is UUPSUpgradeable, OwnableUpgradeable, IControlIPTs { return ipnft.ownerOf(ipnftId); } + /** + * @notice Validates token contract properties before wrapping + * @param tokenContract The ERC20 token contract to validate + */ + function _validateTokenContract(IERC20Metadata tokenContract) internal view { + // Check if contract address is valid + if (address(tokenContract) == address(0)) { + revert ZeroAddress(); + } + + // Check if it's a contract (has code) + uint256 codeSize; + assembly { + codeSize := extcodesize(tokenContract) + } + if (codeSize == 0) { + revert InvalidTokenContract(); + } + + // Validate decimals - should be reasonable (0-18) + try tokenContract.decimals() returns (uint8 decimals) { + if (decimals > 18) { + revert InvalidTokenDecimals(); + } + } catch { + revert InvalidTokenDecimals(); + } + } + /// @notice upgrade authorization logic function _authorizeUpgrade(address /*newImplementation*/ ) internal diff --git a/src/WrappedIPToken.sol b/src/WrappedIPToken.sol new file mode 100644 index 00000000..1397d8a0 --- /dev/null +++ b/src/WrappedIPToken.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { IIPToken, Metadata } from "./IIPToken.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +/** + * @title WrappedIPToken + * @author molecule.xyz + * @notice this is a template contract that's cloned by the Tokenizer + * @notice this contract is used to wrap an ERC20 token and extend its metadata + */ +contract WrappedIPToken is IIPToken, Initializable { + IERC20Metadata public wrappedToken; + + Metadata internal _metadata; + + /** + * @dev Initialize the contract with the provided parameters. + * @param ipnftId the token id on the underlying nft collection + * @param originalOwner the original owner of the ipnft + * @param agreementCid a content hash that contains legal terms for IP token owners + * @param wrappedToken_ the ERC20 token contract to wrap + */ + function initialize(uint256 ipnftId, address originalOwner, string memory agreementCid, IERC20Metadata wrappedToken_) external initializer { + _metadata = Metadata({ ipnftId: ipnftId, originalOwner: originalOwner, agreementCid: agreementCid }); + wrappedToken = wrappedToken_; + } + + function metadata() external view override returns (Metadata memory) { + return _metadata; + } + + /** + * @dev Returns the name of the token. + */ + function name() public view override returns (string memory) { + return wrappedToken.name(); + } + + /** + * @dev Returns the symbol of the token. + */ + function symbol() public view override returns (string memory) { + return wrappedToken.symbol(); + } + + /** + * @dev Returns the decimals places of the token. + */ + function decimals() public view override returns (uint8) { + return wrappedToken.decimals(); + } + + function totalIssued() public view override returns (uint256) { + return wrappedToken.totalSupply(); + } + + function balanceOf(address account) public view override returns (uint256) { + return wrappedToken.balanceOf(account); + } + + function issue(address, uint256) public virtual override { + revert("WrappedIPToken: cannot issue"); + } + + function cap() public virtual override { + revert("WrappedIPToken: cannot cap"); + } + + function uri() external view override returns (string memory) { + string memory tokenId = Strings.toString(_metadata.ipnftId); + + string memory props = string.concat( + '"properties": {', + '"ipnft_id": ', + tokenId, + ',"agreement_content": "ipfs://', + _metadata.agreementCid, + '","original_owner": "', + Strings.toHexString(_metadata.originalOwner), + '","erc20_contract": "', + Strings.toHexString(address(wrappedToken)), + '","supply": "', + Strings.toString(wrappedToken.totalSupply()), + '"}' + ); + + return string.concat( + "data:application/json;base64,", + Base64.encode( + bytes( + string.concat( + '{"name": "IP Tokens of IPNFT #', + tokenId, + '","description": "IP Tokens, derived from IP-NFTs, are ERC-20 tokens governing IP pools.","decimals": ', + Strings.toString(wrappedToken.decimals()), + ',"external_url": "https://molecule.xyz","image": "",', + props, + "}" + ) + ) + ) + ); + } +} diff --git a/src/helpers/test-upgrades/IPToken13.sol b/src/helpers/test-upgrades/IPToken13.sol new file mode 100644 index 00000000..e2545986 --- /dev/null +++ b/src/helpers/test-upgrades/IPToken13.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import { ERC20BurnableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; +import { Tokenizer, MustControlIpnft } from "../../Tokenizer.sol"; +import { IControlIPTs } from "../../IControlIPTs.sol"; + +struct Metadata { + uint256 ipnftId; + address originalOwner; + string agreementCid; +} + +error TokenCapped(); + +/** + * @title IPToken 1.3 + * @author molecule.xyz + * @notice this is a template contract that's cloned by the Tokenizer + * @notice the owner of this contract is always the Tokenizer contract which enforces IPNFT holdership rules. + * The owner can increase the token supply as long as it's not explicitly capped. + * @dev formerly known as "molecules" + */ +contract IPToken13 is ERC20BurnableUpgradeable, OwnableUpgradeable { + event Capped(uint256 atSupply); + + /// @notice the amount of tokens that ever have been issued (not necessarily == supply) + uint256 public totalIssued; + + /// @notice when true, no one can ever mint tokens again. + bool public capped; + + Metadata internal _metadata; + + function initialize(uint256 ipnftId, string calldata name, string calldata symbol, address originalOwner, string memory agreementCid) + external + initializer + { + __Ownable_init(); + __ERC20_init(name, symbol); + _metadata = Metadata({ ipnftId: ipnftId, originalOwner: originalOwner, agreementCid: agreementCid }); + } + + constructor() { + _disableInitializers(); + } + + modifier onlyTokenizerOrIPNFTController() { + if (_msgSender() != owner() && _msgSender() != IControlIPTs(owner()).controllerOf(_metadata.ipnftId)) { + revert MustControlIpnft(); + } + _; + } + + function metadata() external view returns (Metadata memory) { + return _metadata; + } + + /** + * @notice the supply of IP Tokens is controlled by the tokenizer contract. + * @param receiver address + * @param amount uint256 + */ + function issue(address receiver, uint256 amount) external onlyTokenizerOrIPNFTController { + if (capped) { + revert TokenCapped(); + } + totalIssued += amount; + _mint(receiver, amount); + } + + /** + * @notice mark this token as capped. After calling this, no new tokens can be `issue`d + */ + function cap() external onlyTokenizerOrIPNFTController { + capped = true; + emit Capped(totalIssued); + } + + /** + * @notice contract metadata, compatible to ERC1155 + * @return string base64 encoded data url + */ + function uri() external view returns (string memory) { + string memory tokenId = Strings.toString(_metadata.ipnftId); + + string memory props = string.concat( + '"properties": {', + '"ipnft_id": ', + tokenId, + ',"agreement_content": "ipfs://', + _metadata.agreementCid, + '","original_owner": "', + Strings.toHexString(_metadata.originalOwner), + '","erc20_contract": "', + Strings.toHexString(address(this)), + '","supply": "', + Strings.toString(totalIssued), + '"}' + ); + + return string.concat( + "data:application/json;base64,", + Base64.encode( + bytes( + string.concat( + '{"name": "IP Tokens of IPNFT #', + tokenId, + '","description": "IP Tokens, derived from IP-NFTs, are ERC-20 tokens governing IP pools.","decimals": 18,"external_url": "https://molecule.to","image": "",', + props, + "}" + ) + ) + ) + ); + } +} diff --git a/src/helpers/test-upgrades/Tokenizer12.sol b/src/helpers/test-upgrades/Tokenizer13.sol similarity index 57% rename from src/helpers/test-upgrades/Tokenizer12.sol rename to src/helpers/test-upgrades/Tokenizer13.sol index bd717787..ae811431 100644 --- a/src/helpers/test-upgrades/Tokenizer12.sol +++ b/src/helpers/test-upgrades/Tokenizer13.sol @@ -5,20 +5,21 @@ import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import { Clones } from "@openzeppelin/contracts/proxy/Clones.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; -import { IPToken12 as IPToken, Metadata as TokenMetadata } from "./IPToken12.sol"; +import { IPToken } from "../../IPToken.sol"; +import { Metadata as TokenMetadata } from "../../IIPToken.sol"; import { IPermissioner } from "../../Permissioner.sol"; import { IPNFT } from "../../IPNFT.sol"; +import { IControlIPTs } from "../../IControlIPTs.sol"; -import { IPToken as NewIPtoken } from "../../IPToken.sol"; - -error MustOwnIpnft(); +error MustControlIpnft(); error AlreadyTokenized(); error ZeroAddress(); +error IPTNotControlledByTokenizer(); -/// @title Tokenizer 1.2 +/// @title Tokenizer 1.3 /// @author molecule.to /// @notice tokenizes an IPNFT to an ERC20 token (called IPToken or IPT) and controls its supply. -contract Tokenizer12 is UUPSUpgradeable, OwnableUpgradeable { +contract Tokenizer13 is UUPSUpgradeable, OwnableUpgradeable, IControlIPTs { event TokensCreated( uint256 indexed moleculesId, uint256 indexed ipnftId, @@ -45,7 +46,7 @@ contract Tokenizer12 is UUPSUpgradeable, OwnableUpgradeable { /// @dev the permissioner checks if senders have agreed to legal requirements IPermissioner public permissioner; - /// @notice the IPToken implementation this Tokenizer spawns + /// @notice the IPToken implementation this Tokenizer clones from IPToken public ipTokenImplementation; /** @@ -65,11 +66,28 @@ contract Tokenizer12 is UUPSUpgradeable, OwnableUpgradeable { _disableInitializers(); } + function getIPNFTContract() public view returns (IPNFT) { + return ipnft; + } + + modifier onlyController(IPToken ipToken) { + TokenMetadata memory metadata = ipToken.metadata(); + + if (address(synthesized[metadata.ipnftId]) != address(ipToken)) { + revert IPTNotControlledByTokenizer(); + } + + if (_msgSender() != controllerOf(metadata.ipnftId)) { + revert MustControlIpnft(); + } + _; + } + /** * @notice sets the new implementation address of the IPToken * @param _ipTokenImplementation address pointing to the new implementation */ - function setIPTokenImplementation(IPToken _ipTokenImplementation) external onlyOwner { + function setIPTokenImplementation(IPToken _ipTokenImplementation) public onlyOwner { /* could call some functions on old contract to make sure its tokenizer not another contract behind a proxy for safety */ @@ -82,16 +100,18 @@ contract Tokenizer12 is UUPSUpgradeable, OwnableUpgradeable { } /** - * @dev called after an upgrade to reinitialize a new permissioner impl. - * @param _permissioner the new TermsPermissioner + * @dev sets legacy IPTs on the tokenized mapping */ - function reinit(IPermissioner _permissioner) public onlyOwner reinitializer(4) { - permissioner = _permissioner; + function reinit(IPToken _ipTokenImplementation) public onlyOwner reinitializer(5) { + synthesized[2] = IPToken(0x6034e0d6999741f07cb6Fb1162cBAA46a1D33d36); + synthesized[28] = IPToken(0x7b66E84Be78772a3afAF5ba8c1993a1B5D05F9C2); + synthesized[37] = IPToken(0xBcE56276591128047313e64744b3EBE03998783f); + + setIPTokenImplementation(_ipTokenImplementation); } /** - * @notice initializes synthesis on ipnft#id for the current asset holder. - * IPTokens are identified by the original token holder and the token id + * @notice tokenizes ipnft#id for the current asset holder. * @param ipnftId the token id on the underlying nft collection * @param tokenAmount the initially issued supply of IP tokens * @param tokenSymbol the ip token's ticker symbol @@ -106,29 +126,60 @@ contract Tokenizer12 is UUPSUpgradeable, OwnableUpgradeable { string memory agreementCid, bytes calldata signedAgreement ) external returns (IPToken token) { - if (ipnft.ownerOf(ipnftId) != _msgSender()) { - revert MustOwnIpnft(); + if (_msgSender() != controllerOf(ipnftId)) { + revert MustControlIpnft(); + } + if (address(synthesized[ipnftId]) != address(0)) { + revert AlreadyTokenized(); } // https://github.com/OpenZeppelin/workshops/tree/master/02-contracts-clone token = IPToken(Clones.clone(address(ipTokenImplementation))); string memory name = string.concat("IP Tokens of IPNFT #", Strings.toString(ipnftId)); - token.initialize(name, tokenSymbol, TokenMetadata(ipnftId, _msgSender(), agreementCid)); + token.initialize(ipnftId, name, tokenSymbol, _msgSender(), agreementCid); - uint256 tokenHash = token.hash(); - // ensure we can only call this once per sales cycle - if (address(synthesized[tokenHash]) != address(0)) { - revert AlreadyTokenized(); - } - - synthesized[tokenHash] = token; + synthesized[ipnftId] = token; //this has been called MoleculesCreated before - emit TokensCreated(tokenHash, ipnftId, address(token), _msgSender(), tokenAmount, agreementCid, name, tokenSymbol); - permissioner.accept(NewIPtoken(address(token)), _msgSender(), signedAgreement); + emit TokensCreated( + //upwards compatibility: signaling a unique "Molecules ID" as first parameter ("sales cycle id"). This is unused and not interpreted. + uint256(keccak256(abi.encodePacked(ipnftId))), + ipnftId, + address(token), + _msgSender(), + tokenAmount, + agreementCid, + name, + tokenSymbol + ); + permissioner.accept(token, _msgSender(), signedAgreement); token.issue(_msgSender(), tokenAmount); } + /** + * @notice issues more IPTs when not capped. This can be used for new owners of legacy IPTs that otherwise wouldn't be able to pass their `onlyIssuerOrOwner` gate + * @param ipToken The ip token to control + * @param amount the amount of tokens to issue + * @param receiver the address that receives the tokens + */ + function issue(IPToken ipToken, uint256 amount, address receiver) external onlyController(ipToken) { + ipToken.issue(receiver, amount); + } + + /** + * @notice caps the supply of an IPT. After calling this, no new tokens can be `issue`d + * @dev you must compute the ipt hash externally. + * @param ipToken the IPToken to cap. + */ + function cap(IPToken ipToken) external onlyController(ipToken) { + ipToken.cap(); + } + + /// @dev this will be called by IPTs. Right now the controller is the IPNFT's current owner, it can be a Governor in the future. + function controllerOf(uint256 ipnftId) public view override returns (address) { + return ipnft.ownerOf(ipnftId); + } + /// @notice upgrade authorization logic function _authorizeUpgrade(address /*newImplementation*/ ) internal diff --git a/test/CrowdSalePermissioned.t.sol b/test/CrowdSalePermissioned.t.sol index 84c2ac6e..5665f371 100644 --- a/test/CrowdSalePermissioned.t.sol +++ b/test/CrowdSalePermissioned.t.sol @@ -8,8 +8,8 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; - -import { IPToken, Metadata } from "../src/IPToken.sol"; +import { Metadata } from "../src/IIPToken.sol"; +import { IPToken } from "../src/IPToken.sol"; import { CrowdSale, Sale, SaleInfo, SaleState, BadDecimals } from "../src/crowdsale/CrowdSale.sol"; import { StakedLockingCrowdSale, BadPrice } from "../src/crowdsale/StakedLockingCrowdSale.sol"; import { IPermissioner, TermsAcceptedPermissioner, InvalidSignature, BlindPermissioner } from "../src/Permissioner.sol"; diff --git a/test/Forking/Tokenizer13UpgradeForkTest.t.sol b/test/Forking/Tokenizer13UpgradeForkTest.t.sol deleted file mode 100644 index 15fccfa4..00000000 --- a/test/Forking/Tokenizer13UpgradeForkTest.t.sol +++ /dev/null @@ -1,238 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.18; - -import "forge-std/Test.sol"; -import { console } from "forge-std/console.sol"; - -import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; - -import { IPNFT } from "../../src/IPNFT.sol"; - -import { MustControlIpnft, AlreadyTokenized, Tokenizer } from "../../src/Tokenizer.sol"; -import { Tokenizer12 } from "../../src/helpers/test-upgrades/Tokenizer12.sol"; -import { IPToken12, OnlyIssuerOrOwner } from "../../src/helpers/test-upgrades/IPToken12.sol"; -import { IPToken, TokenCapped, Metadata } from "../../src/IPToken.sol"; -import { IPermissioner, BlindPermissioner } from "../../src/Permissioner.sol"; - -contract Tokenizer13UpgradeForkTest is Test { - using SafeERC20Upgradeable for IPToken; - - uint256 mainnetFork; - - string ipfsUri = "ipfs://bafkreiankqd3jvpzso6khstnaoxovtyezyatxdy7t2qzjoolqhltmasqki"; - string agreementCid = "bafkreigk5dvqblnkdniges6ft5kmuly47ebw4vho6siikzmkaovq6sjstq"; - uint256 MINTING_FEE = 0.001 ether; - string DEFAULT_SYMBOL = "IPT-0001"; - - address mainnetDeployer = 0x34021576F01275A429163a56908Bd02b43e2B7e1; - address mainnetOwner = 0xCfA0F84660fB33bFd07C369E5491Ab02C449f71B; - address mainnetTokenizer = 0x58EB89C69CB389DBef0c130C6296ee271b82f436; - address mainnetIPNFT = 0xcaD88677CA87a7815728C72D74B4ff4982d54Fc1; - - address vitaDaoTreasury = 0xF5307a74d1550739ef81c6488DC5C7a6a53e5Ac2; - - // paulhaas.eth - address paulhaas = 0x45602BFBA960277bF917C1b2007D1f03d7bd29e4; - - IPNFT ipnft = IPNFT(mainnetIPNFT); - Tokenizer tokenizer13; - IPToken newIPTokenImplementation; - - address alice = makeAddr("alice"); - - function setUp() public { - mainnetFork = vm.createFork(vm.envString("MAINNET_RPC_URL"), 20240430); - vm.selectFork(mainnetFork); - } - - function upgradeToTokenizer13() public { - vm.startPrank(mainnetDeployer); - Tokenizer newTokenizerImplementation = new Tokenizer(); - newIPTokenImplementation = new IPToken(); - vm.stopPrank(); - - vm.startPrank(mainnetOwner); - Tokenizer12 tokenizer12 = Tokenizer12(mainnetTokenizer); - //todo: make sure that the legacy IPTs are indexed now - bytes memory upgradeCallData = abi.encodeWithSelector(Tokenizer.reinit.selector, address(newIPTokenImplementation)); - tokenizer12.upgradeToAndCall(address(newTokenizerImplementation), upgradeCallData); - tokenizer13 = Tokenizer(mainnetTokenizer); - } - - function testCanUpgradeToV13() public { - upgradeToTokenizer13(); - assertEq(address(tokenizer13.ipTokenImplementation()), address(newIPTokenImplementation)); - assertEq(address(tokenizer13.permissioner()), 0xC837E02982992B701A1B5e4E21fA01cEB0a628fA); - - vm.startPrank(alice); - vm.expectRevert("Initializable: contract is already initialized"); - tokenizer13.initialize(IPNFT(address(0)), BlindPermissioner(address(0))); - - vm.expectRevert("Initializable: contract is already initialized"); - newIPTokenImplementation.initialize(2, "Foo", "Bar", alice, "abcde"); - - vm.startPrank(mainnetOwner); - vm.expectRevert("Initializable: contract is already initialized"); - tokenizer13.initialize(IPNFT(address(0)), BlindPermissioner(address(0))); - - vm.expectRevert("Initializable: contract is already initialized"); - tokenizer13.reinit(newIPTokenImplementation); - } - - function testOldIPTsAreMigratedAndCantBeReminted() public { - upgradeToTokenizer13(); - - assertEq(address(tokenizer13.synthesized(2)), 0x6034e0d6999741f07cb6Fb1162cBAA46a1D33d36); - assertEq(address(tokenizer13.synthesized(28)), 0x7b66E84Be78772a3afAF5ba8c1993a1B5D05F9C2); - assertEq(address(tokenizer13.synthesized(37)), 0xBcE56276591128047313e64744b3EBE03998783f); - assertEq(address(tokenizer13.synthesized(31415269)), address(0)); - - deployCodeTo("Permissioner.sol:BlindPermissioner", "", address(tokenizer13.permissioner())); - - address vitaFASTMultisig = 0xf7990CD398daFB4fe5Fd6B9228B8e6f72b296555; - - vm.startPrank(vitaFASTMultisig); - vm.expectRevert(AlreadyTokenized.selector); - tokenizer13.tokenizeIpnft(2, 1_000_000 ether, "VITA-FAST", "bafkreig274nfj7srmtnb5wd5wlwm3ig2s63wovlz7i3noodjlfz2tm3n5q", bytes("")); - - vm.startPrank(alice); - vm.expectRevert(MustControlIpnft.selector); - tokenizer13.tokenizeIpnft(2, 1_000_000 ether, "VITA-FAST", "bafkreig274nfj7srmtnb5wd5wlwm3ig2s63wovlz7i3noodjlfz2tm3n5q", bytes("")); - } - - function testTokenizeNewIPTs() public { - upgradeToTokenizer13(); - address valleyDaoMultisig = 0xD920E60b798A2F5a8332799d8a23075c9E77d5F8; - uint256 valleyDaoIpnftId = 3; //hasnt been tokenized yet - deployCodeTo("Permissioner.sol:BlindPermissioner", "", address(tokenizer13.permissioner())); - - assertEq(ipnft.ownerOf(valleyDaoIpnftId), valleyDaoMultisig); - - vm.startPrank(valleyDaoMultisig); - IPToken ipt = tokenizer13.tokenizeIpnft(valleyDaoIpnftId, 1_000_000 ether, "VALLEY", agreementCid, ""); - assertEq(ipt.balanceOf(valleyDaoMultisig), 1_000_000 ether); - ipt.transfer(alice, 100_000 ether); - assertEq(ipt.balanceOf(valleyDaoMultisig), 900_000 ether); - assertEq(ipt.balanceOf(alice), 100_000 ether); - - //controlling the IPT from its own interface - ipt.issue(valleyDaoMultisig, 1_000_000 ether); - assertEq(ipt.totalSupply(), 2_000_000 ether); - assertEq(ipt.balanceOf(valleyDaoMultisig), 1_900_000 ether); - - ipt.cap(); - - vm.expectRevert(TokenCapped.selector); - ipt.issue(valleyDaoMultisig, 100); - - vm.stopPrank(); - } - - function testOldTokensAreStillControllable() public { - upgradeToTokenizer13(); - address vitaFASTAddress = 0x6034e0d6999741f07cb6Fb1162cBAA46a1D33d36; - address vitaFASTMultisig = 0xf7990CD398daFB4fe5Fd6B9228B8e6f72b296555; - - IPToken vitaFast = IPToken(vitaFASTAddress); - - assertEq(vitaFast.balanceOf(paulhaas), 16942857059768483219100); - assertEq(vitaFast.balanceOf(alice), 0); - - vm.startPrank(paulhaas); - vitaFast.transfer(alice, 100); - assertEq(vitaFast.balanceOf(paulhaas), 16942857059768483219000); - assertEq(vitaFast.balanceOf(alice), 100); - vm.stopPrank(); - - vm.startPrank(vitaFASTMultisig); - assertEq(vitaFast.totalSupply(), 1_029_555 ether); - assertEq(vitaFast.balanceOf(vitaFASTMultisig), 13390539642731621592709); - //the old IPTs allow their original owner to issue more tokens - vitaFast.issue(vitaFASTMultisig, 100_000 ether); - assertEq(vitaFast.totalSupply(), 1_129_555 ether); - assertEq(vitaFast.balanceOf(vitaFASTMultisig), 113390539642731621592709); - - vitaFast.cap(); - vm.expectRevert(TokenCapped.selector); - vitaFast.issue(vitaFASTMultisig, 100_000 ether); - - /// --- same for VitaRNA, better safe than sorry. - address vitaRNAAddress = 0x7b66E84Be78772a3afAF5ba8c1993a1B5D05F9C2; - address vitaRNAMultisig = 0x452f3b60129FdB3cdc78178848c63eC23f38C80d; - IPToken vitaRna = IPToken(vitaRNAAddress); - - assertEq(vitaRna.balanceOf(paulhaas), 514.411456805927582924 ether); - assertEq(vitaRna.balanceOf(alice), 0); - - vm.startPrank(paulhaas); - vitaRna.transfer(alice, 100 ether); - assertEq(vitaRna.balanceOf(paulhaas), 414.411456805927582924 ether); - assertEq(vitaRna.balanceOf(alice), 100 ether); - vm.stopPrank(); - - vm.startPrank(vitaRNAMultisig); - assertEq(vitaRna.totalSupply(), 5_000_000 ether); - assertEq(vitaRna.balanceOf(vitaRNAMultisig), 200_000 ether); - vitaRna.issue(vitaRNAMultisig, 100_000 ether); - assertEq(vitaRna.totalSupply(), 5_100_000 ether); - assertEq(vitaRna.balanceOf(vitaRNAMultisig), 300_000 ether); - - vitaRna.cap(); - vm.expectRevert(TokenCapped.selector); - vitaRna.issue(vitaRNAMultisig, 100_000 ether); - } - - // IPN-21: and the main reason why we're doing all the above - function testOldTokensCanBeIssuedByNewIPNFTHolder() public { - upgradeToTokenizer13(); - - deployCodeTo("Permissioner.sol:BlindPermissioner", "", address(tokenizer13.permissioner())); - - address bob = makeAddr("bob"); - address vitaFASTMultisig = 0xf7990CD398daFB4fe5Fd6B9228B8e6f72b296555; - //we're using vita fast's original abi here. It actually is call-compatible to IPToken, but this is the ultimate legacy test - IPToken12 vitaFAST12 = IPToken12(0x6034e0d6999741f07cb6Fb1162cBAA46a1D33d36); - IPToken vitaFAST13 = IPToken(0x6034e0d6999741f07cb6Fb1162cBAA46a1D33d36); - - vm.startPrank(vitaFASTMultisig); - ipnft.transferFrom(vitaFASTMultisig, alice, 2); - assertEq(ipnft.ownerOf(2), alice); - - vm.startPrank(alice); - // This is new: originally Alice *would* indeed have been able to do this: - vm.expectRevert(AlreadyTokenized.selector); - tokenizer13.tokenizeIpnft(2, 1_000_000 ether, "VITA-FAST", "imfeelingfunny", bytes("")); - - assertEq(vitaFAST12.balanceOf(alice), 0); - - //this *should* be possible but can't work due to the old implementation - vm.expectRevert(OnlyIssuerOrOwner.selector); - vitaFAST12.issue(alice, 1_000_000 ether); - //the selector of course doesnt exist on the new interface, but the implementation reverts with it: - vm.expectRevert(OnlyIssuerOrOwner.selector); - vitaFAST13.issue(alice, 1_000_000 ether); - - //to issue new tokens, alice uses the Tokenizer instead: - tokenizer13.issue(vitaFAST13, 1_000_000 ether, alice); - assertEq(vitaFAST12.balanceOf(alice), 1_000_000 ether); - - //due to the original implementation, the original owner can still issue tokens and we cannot do anything about it: - vm.startPrank(vitaFASTMultisig); - vitaFAST12.issue(bob, 1_000_000 ether); - assertEq(vitaFAST12.balanceOf(bob), 1_000_000 ether); - assertEq(vitaFAST13.balanceOf(bob), 1_000_000 ether); - - // but they cannot do that using the tokenizer: - vm.expectRevert(MustControlIpnft.selector); - tokenizer13.issue(vitaFAST13, 1_000_000 ether, alice); - vm.expectRevert(MustControlIpnft.selector); - tokenizer13.cap(vitaFAST13); - - //but they unfortunately also can cap the token: - vitaFAST12.cap(); - - vm.startPrank(alice); - vm.expectRevert(TokenCapped.selector); - tokenizer13.issue(vitaFAST13, 1_000_000 ether, alice); - } -} diff --git a/test/Forking/Tokenizer14UpgradeForkTest.t.sol b/test/Forking/Tokenizer14UpgradeForkTest.t.sol new file mode 100644 index 00000000..8c36f425 --- /dev/null +++ b/test/Forking/Tokenizer14UpgradeForkTest.t.sol @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; + +import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; + +import { IPNFT } from "../../src/IPNFT.sol"; + +import { MustControlIpnft, AlreadyTokenized, Tokenizer } from "../../src/Tokenizer.sol"; +import { Tokenizer13 } from "../../src/helpers/test-upgrades/Tokenizer13.sol"; +import { IPToken13 } from "../../src/helpers/test-upgrades/IPToken13.sol"; +import { IPToken, TokenCapped } from "../../src/IPToken.sol"; +import { Metadata } from "../../src/IIPToken.sol"; +import { OnlyIssuerOrOwner } from "../../src/helpers/test-upgrades/IPToken12.sol"; +import { IIPToken } from "../../src/IIPToken.sol"; +import { WrappedIPToken } from "../../src/WrappedIPToken.sol"; +import { IPermissioner, BlindPermissioner } from "../../src/Permissioner.sol"; +import { AcceptAllAuthorizer } from "../helpers/AcceptAllAuthorizer.sol"; +import { FakeERC20 } from "../../src/helpers/FakeERC20.sol"; + +contract Tokenizer14UpgradeForkTest is Test { + using SafeERC20Upgradeable for IPToken; + + uint256 mainnetFork; + + string ipfsUri = "ipfs://bafkreiankqd3jvpzso6khstnaoxovtyezyatxdy7t2qzjoolqhltmasqki"; + string agreementCid = "bafkreigk5dvqblnkdniges6ft5kmuly47ebw4vho6siikzmkaovq6sjstq"; + uint256 MINTING_FEE = 0.001 ether; + string DEFAULT_SYMBOL = "IPT-0001"; + + address mainnetDeployer = 0x34021576F01275A429163a56908Bd02b43e2B7e1; + address mainnetOwner = 0xCfA0F84660fB33bFd07C369E5491Ab02C449f71B; + address mainnetTokenizer = 0x58EB89C69CB389DBef0c130C6296ee271b82f436; + address mainnetIPNFT = 0xcaD88677CA87a7815728C72D74B4ff4982d54Fc1; + + address vitaDaoTreasury = 0xF5307a74d1550739ef81c6488DC5C7a6a53e5Ac2; + + // paulhaas.eth + address paulhaas = 0x45602BFBA960277bF917C1b2007D1f03d7bd29e4; + + IPNFT ipnft = IPNFT(mainnetIPNFT); + Tokenizer tokenizer14; + IPToken newIPTokenImplementation; + WrappedIPToken newWrappedIPTokenImplementation; + + address alice = makeAddr("alice"); + + function setUp() public { + mainnetFork = vm.createFork(vm.envString("MAINNET_RPC_URL"), 23367545); + vm.selectFork(mainnetFork); + } + + function upgradeToTokenizer14() public { + vm.startPrank(mainnetDeployer); + Tokenizer newTokenizerImplementation = new Tokenizer(); + newIPTokenImplementation = new IPToken(); + newWrappedIPTokenImplementation = new WrappedIPToken(); + vm.stopPrank(); + + vm.startPrank(mainnetOwner); + Tokenizer13 tokenizer13 = Tokenizer13(mainnetTokenizer); + // Updated for V14: reinit now takes both IPToken and WrappedIPToken implementations + bytes memory upgradeCallData = + abi.encodeWithSelector(Tokenizer.reinit.selector, address(newWrappedIPTokenImplementation), address(newIPTokenImplementation)); + tokenizer13.upgradeToAndCall(address(newTokenizerImplementation), upgradeCallData); + tokenizer14 = Tokenizer(mainnetTokenizer); + + // Set up AcceptAllAuthorizer for IPNFT minting in tests + AcceptAllAuthorizer authorizer = new AcceptAllAuthorizer(); + ipnft.setAuthorizer(authorizer); + vm.stopPrank(); + } + + function testCanUpgradeToV14() public { + upgradeToTokenizer14(); + assertEq(address(tokenizer14.ipTokenImplementation()), address(newIPTokenImplementation)); + assertEq(address(tokenizer14.wrappedTokenImplementation()), address(newWrappedIPTokenImplementation)); + assertEq(address(tokenizer14.permissioner()), 0xC837E02982992B701A1B5e4E21fA01cEB0a628fA); + + vm.startPrank(alice); + vm.expectRevert("Initializable: contract is already initialized"); + tokenizer14.initialize(IPNFT(address(0)), BlindPermissioner(address(0))); + + vm.expectRevert("Initializable: contract is already initialized"); + newIPTokenImplementation.initialize(2, "Foo", "Bar", alice, "abcde"); + + vm.startPrank(mainnetOwner); + vm.expectRevert("Initializable: contract is already initialized"); + tokenizer14.initialize(IPNFT(address(0)), BlindPermissioner(address(0))); + + vm.expectRevert("Initializable: contract is already initialized"); + tokenizer14.reinit(newWrappedIPTokenImplementation, newIPTokenImplementation); + } + + function testOldIPTsAreMigratedAndCantBeReminted() public { + upgradeToTokenizer14(); + + assertEq(address(tokenizer14.synthesized(2)), 0x6034e0d6999741f07cb6Fb1162cBAA46a1D33d36); + assertEq(address(tokenizer14.synthesized(28)), 0x7b66E84Be78772a3afAF5ba8c1993a1B5D05F9C2); + assertEq(address(tokenizer14.synthesized(37)), 0xBcE56276591128047313e64744b3EBE03998783f); + assertEq(address(tokenizer14.synthesized(31415269)), address(0)); + + deployCodeTo("Permissioner.sol:BlindPermissioner", "", address(tokenizer14.permissioner())); + + // Check who actually owns IPNFT #2 at this block + address currentOwner = ipnft.ownerOf(2); + + vm.startPrank(currentOwner); + vm.expectRevert(AlreadyTokenized.selector); + tokenizer14.tokenizeIpnft(2, 1_000_000 ether, "VITA-FAST", "bafkreig274nfj7srmtnb5wd5wlwm3ig2s63wovlz7i3noodjlfz2tm3n5q", bytes("")); + + vm.startPrank(alice); + vm.expectRevert(MustControlIpnft.selector); + tokenizer14.tokenizeIpnft(2, 1_000_000 ether, "VITA-FAST", "bafkreig274nfj7srmtnb5wd5wlwm3ig2s63wovlz7i3noodjlfz2tm3n5q", bytes("")); + } + + function testOldTokensCanBeUsedAfterUpgrade() public { + upgradeToTokenizer14(); + + deployCodeTo("Permissioner.sol:BlindPermissioner", "", address(tokenizer14.permissioner())); + + IPToken ipt = IPToken(0xBcE56276591128047313e64744b3EBE03998783f); + + // Note: At this block, the token distribution may be different + // Let's check the actual total supply and who has balances + assertEq(ipt.totalSupply(), 1_000_000 ether); + + // Find someone with balance to test transfers + // Check if paulhaas has any balance in this token as well + uint256 paulhaasBalance = ipt.balanceOf(paulhaas); + if (paulhaasBalance >= 100_000 ether) { + vm.startPrank(paulhaas); + assertEq(ipt.balanceOf(alice), 0); + + ipt.transfer(alice, 100_000 ether); + assertEq(ipt.balanceOf(paulhaas), paulhaasBalance - 100_000 ether); + assertEq(ipt.balanceOf(alice), 100_000 ether); + vm.stopPrank(); + } + + /// --- same for VitaFAST, better safe than sorry. + address vitaFASTAddress = 0x6034e0d6999741f07cb6Fb1162cBAA46a1D33d36; + address vitaFASTMultisig = 0xf7990CD398daFB4fe5Fd6B9228B8e6f72b296555; + IPToken vitaFast = IPToken(vitaFASTAddress); + + uint256 paulhaasInitialBalance = vitaFast.balanceOf(paulhaas); + assertEq(vitaFast.balanceOf(alice), 0); + + // Only test transfer if paulhaas has enough balance + if (paulhaasInitialBalance >= 100_000 ether) { + vm.startPrank(paulhaas); + vitaFast.transfer(alice, 100_000 ether); + assertEq(vitaFast.balanceOf(paulhaas), paulhaasInitialBalance - 100_000 ether); + assertEq(vitaFast.balanceOf(alice), 100_000 ether); + vm.stopPrank(); + } + + vm.startPrank(vitaFASTMultisig); + uint256 initialTotalSupply = vitaFast.totalSupply(); + uint256 multisigInitialBalance = vitaFast.balanceOf(vitaFASTMultisig); + + //the old IPTs allow their original owner to issue more tokens + vitaFast.issue(vitaFASTMultisig, 100_000 ether); + assertEq(vitaFast.totalSupply(), initialTotalSupply + 100_000 ether); + assertEq(vitaFast.balanceOf(vitaFASTMultisig), multisigInitialBalance + 100_000 ether); + + vitaFast.cap(); + vm.expectRevert(TokenCapped.selector); + vitaFast.issue(vitaFASTMultisig, 100_000 ether); + + /// --- same for VitaRNA, better safe than sorry. + address vitaRNAAddress = 0x7b66E84Be78772a3afAF5ba8c1993a1B5D05F9C2; + address vitaRNAMultisig = 0x452f3b60129FdB3cdc78178848c63eC23f38C80d; + IPToken vitaRna = IPToken(vitaRNAAddress); + + uint256 paulhaasRnaBalance = vitaRna.balanceOf(paulhaas); + assertEq(vitaRna.balanceOf(alice), 0); + + // Only test transfer if paulhaas has enough balance + if (paulhaasRnaBalance >= 100 ether) { + vm.startPrank(paulhaas); + vitaRna.transfer(alice, 100 ether); + assertEq(vitaRna.balanceOf(paulhaas), paulhaasRnaBalance - 100 ether); + assertEq(vitaRna.balanceOf(alice), 100 ether); + vm.stopPrank(); + } + + vm.startPrank(vitaRNAMultisig); + uint256 rnaInitialTotalSupply = vitaRna.totalSupply(); + uint256 rnaMultisigInitialBalance = vitaRna.balanceOf(vitaRNAMultisig); + + vitaRna.issue(vitaRNAMultisig, 100_000 ether); + assertEq(vitaRna.totalSupply(), rnaInitialTotalSupply + 100_000 ether); + assertEq(vitaRna.balanceOf(vitaRNAMultisig), rnaMultisigInitialBalance + 100_000 ether); + + vitaRna.cap(); + vm.expectRevert(TokenCapped.selector); + vitaRna.issue(vitaRNAMultisig, 100_000 ether); + } + + // IPN-21: and the main reason why we're doing all the above + function testOldTokensCanBeIssuedByNewIPNFTHolder() public { + upgradeToTokenizer14(); + + deployCodeTo("Permissioner.sol:BlindPermissioner", "", address(tokenizer14.permissioner())); + + address bob = makeAddr("bob"); + //we're using vita fast's original interface here for legacy compatibility testing + IPToken vitaFAST = IPToken(0x6034e0d6999741f07cb6Fb1162cBAA46a1D33d36); + + // Check who currently owns IPNFT #2 at this block + address currentOwner = ipnft.ownerOf(2); + + vm.startPrank(currentOwner); + ipnft.transferFrom(currentOwner, alice, 2); + assertEq(ipnft.ownerOf(2), alice); + + vm.startPrank(alice); + // This is new: originally Alice *would* indeed have been able to do this: + vm.expectRevert(AlreadyTokenized.selector); + tokenizer14.tokenizeIpnft(2, 1_000_000 ether, "VITA-FAST", "imfeelingfunny", bytes("")); + + assertEq(vitaFAST.balanceOf(alice), 0); + + //this *should* be possible but can't work due to the old implementation + vm.expectRevert(OnlyIssuerOrOwner.selector); + vitaFAST.issue(alice, 1_000_000 ether); + + //to issue new tokens, alice uses the Tokenizer instead: + tokenizer14.issue(vitaFAST, 1_000_000 ether, alice); + assertEq(vitaFAST.balanceOf(alice), 1_000_000 ether); + + //due to the original implementation, the current owner (who is not the original issuer) cannot issue tokens directly: + vm.startPrank(currentOwner); + vm.expectRevert(OnlyIssuerOrOwner.selector); + vitaFAST.issue(bob, 1_000_000 ether); + + // but they cannot do that using the tokenizer (since they're no longer the IPNFT owner): + vm.expectRevert(MustControlIpnft.selector); + tokenizer14.issue(vitaFAST, 1_000_000 ether, alice); + vm.expectRevert(MustControlIpnft.selector); + tokenizer14.cap(vitaFAST); + + // Alice (new IPNFT owner) can use tokenizer to cap the token: + vm.startPrank(alice); + tokenizer14.cap(vitaFAST); + + vm.expectRevert(TokenCapped.selector); + tokenizer14.issue(vitaFAST, 1_000_000 ether, alice); + } + + function testNewIPTokensHaveEnhancedInterface() public { + upgradeToTokenizer14(); + + deployCodeTo("Permissioner.sol:BlindPermissioner", "", address(tokenizer14.permissioner())); + + // Mint a new IPNFT for testing + vm.startPrank(alice); + vm.deal(alice, MINTING_FEE); + uint256 reservationId = ipnft.reserve(); + ipnft.mintReservation{ value: MINTING_FEE }(alice, reservationId, ipfsUri, DEFAULT_SYMBOL, bytes("")); + uint256 newIpnftId = reservationId; + + // Tokenize it with the new implementation + IPToken newToken = tokenizer14.tokenizeIpnft(newIpnftId, 1_000_000 ether, "NEW-IPT", agreementCid, bytes("")); + + // Test that the new token implements IIPToken interface functions + IIPToken iiptToken = IIPToken(address(newToken)); + + // Test interface functions work + assertEq(iiptToken.name(), string.concat("IP Tokens of IPNFT #", vm.toString(newIpnftId))); + assertEq(iiptToken.symbol(), "NEW-IPT"); + assertEq(iiptToken.decimals(), 18); + assertEq(iiptToken.balanceOf(alice), 1_000_000 ether); + assertEq(iiptToken.totalIssued(), 1_000_000 ether); + + // Test that metadata is accessible through interface + Metadata memory metadata = iiptToken.metadata(); + assertEq(metadata.ipnftId, newIpnftId); + assertEq(metadata.originalOwner, alice); + assertEq(metadata.agreementCid, agreementCid); + + // Test URI function + string memory uri = iiptToken.uri(); + assertTrue(bytes(uri).length > 0); + + vm.stopPrank(); + } + + function testLegacyTokensStillWorkWithoutFullInterface() public { + upgradeToTokenizer14(); + + deployCodeTo("Permissioner.sol:BlindPermissioner", "", address(tokenizer14.permissioner())); + + // Use existing legacy token (VitaFAST) + IPToken legacyToken = IPToken(0x6034e0d6999741f07cb6Fb1162cBAA46a1D33d36); + + // Check who currently owns IPNFT #2 at this block + address currentOwner = ipnft.ownerOf(2); + + // Basic ERC20 functions should work (legacy tokens use "Molecules" naming) + assertEq(legacyToken.name(), "Molecules of IPNFT #2"); + assertEq(legacyToken.symbol(), "VITA-FAST"); + assertEq(legacyToken.decimals(), 18); + assertTrue(legacyToken.balanceOf(currentOwner) > 0); + + // Test that we can still control legacy tokens through the tokenizer + vm.startPrank(currentOwner); + // Transfer IPNFT to alice first + ipnft.transferFrom(currentOwner, alice, 2); + + vm.startPrank(alice); + // Alice should be able to issue more tokens through the tokenizer + uint256 beforeBalance = legacyToken.balanceOf(alice); + tokenizer14.issue(legacyToken, 100_000 ether, alice); + assertEq(legacyToken.balanceOf(alice), beforeBalance + 100_000 ether); + + vm.stopPrank(); + } + + function testInterfaceCompatibilityForNewTokens() public { + upgradeToTokenizer14(); + + deployCodeTo("Permissioner.sol:BlindPermissioner", "", address(tokenizer14.permissioner())); + + // Create a new token + vm.startPrank(alice); + vm.deal(alice, MINTING_FEE); + uint256 reservationId2 = ipnft.reserve(); + ipnft.mintReservation{ value: MINTING_FEE }(alice, reservationId2, ipfsUri, DEFAULT_SYMBOL, bytes("")); + uint256 newIpnftId = reservationId2; + + IPToken newToken = tokenizer14.tokenizeIpnft(newIpnftId, 500_000 ether, "COMPAT-TEST", agreementCid, bytes("")); + + // Test that both IPToken and IIPToken interfaces work identically + IIPToken interfaceToken = IIPToken(address(newToken)); + + // Compare results from both interfaces + assertEq(newToken.name(), interfaceToken.name()); + assertEq(newToken.symbol(), interfaceToken.symbol()); + assertEq(newToken.decimals(), interfaceToken.decimals()); + assertEq(newToken.balanceOf(alice), interfaceToken.balanceOf(alice)); + assertEq(newToken.totalIssued(), interfaceToken.totalIssued()); + + // Test metadata access + Metadata memory directMetadata = newToken.metadata(); + Metadata memory interfaceMetadata = interfaceToken.metadata(); + + assertEq(directMetadata.ipnftId, interfaceMetadata.ipnftId); + assertEq(directMetadata.originalOwner, interfaceMetadata.originalOwner); + assertEq(directMetadata.agreementCid, interfaceMetadata.agreementCid); + + vm.stopPrank(); + } + + function testAttachExistingERC20AsWrappedIPToken() public { + upgradeToTokenizer14(); + + deployCodeTo("Permissioner.sol:BlindPermissioner", "", address(tokenizer14.permissioner())); + + // Mint a new IPNFT for testing the attach functionality + vm.startPrank(alice); + vm.deal(alice, MINTING_FEE); + uint256 reservationId = ipnft.reserve(); + ipnft.mintReservation{ value: MINTING_FEE }(alice, reservationId, ipfsUri, DEFAULT_SYMBOL, bytes("")); + uint256 newIpnftId = reservationId; + + // Deploy a test ERC20 token to attach + FakeERC20 testToken = new FakeERC20("Test Wrapped Token", "TWT"); + testToken.mint(alice, 5_000_000 ether); + + // Verify initial state + assertEq(testToken.balanceOf(alice), 5_000_000 ether); + assertEq(testToken.name(), "Test Wrapped Token"); + assertEq(testToken.symbol(), "TWT"); + assertEq(testToken.decimals(), 18); + + // Attach the ERC20 token to the IPNFT using the new V14 functionality + IIPToken wrappedToken = tokenizer14.attachIpt(newIpnftId, agreementCid, bytes(""), testToken); + + // Verify the wrapped token was created correctly + assertTrue(address(wrappedToken) != address(0)); + assertTrue(address(wrappedToken) != address(testToken)); + assertEq(address(tokenizer14.synthesized(newIpnftId)), address(wrappedToken)); + + // Test the wrapped token implements IIPToken interface + assertEq(wrappedToken.name(), "Test Wrapped Token"); + assertEq(wrappedToken.symbol(), "TWT"); + assertEq(wrappedToken.decimals(), 18); + assertEq(wrappedToken.balanceOf(alice), 5_000_000 ether); + assertEq(wrappedToken.totalIssued(), 5_000_000 ether); + + // Test metadata is accessible through interface + Metadata memory metadata = wrappedToken.metadata(); + assertEq(metadata.ipnftId, newIpnftId); + assertEq(metadata.originalOwner, alice); + assertEq(metadata.agreementCid, agreementCid); + + // Test URI function + string memory uri = wrappedToken.uri(); + assertTrue(bytes(uri).length > 0); + + // Test that the underlying ERC20 token is still functional + assertEq(testToken.balanceOf(alice), 5_000_000 ether); + + // Test that wrapped tokens cannot issue or cap (they should delegate to underlying token) + vm.expectRevert(); // WrappedIPToken should not allow issue/cap operations + WrappedIPToken(address(wrappedToken)).issue(alice, 1000 ether); + + vm.expectRevert(); // WrappedIPToken should not allow cap operations + WrappedIPToken(address(wrappedToken)).cap(); + + // Test that we cannot attach another token to the same IPNFT + FakeERC20 anotherToken = new FakeERC20("Another Token", "ANT"); + vm.expectRevert(AlreadyTokenized.selector); + tokenizer14.attachIpt(newIpnftId, agreementCid, bytes(""), anotherToken); + + vm.stopPrank(); + } +} diff --git a/test/Mintpass.t.sol b/test/Mintpass.t.sol index 8b8bc645..23849483 100644 --- a/test/Mintpass.t.sol +++ b/test/Mintpass.t.sol @@ -171,8 +171,4 @@ contract MintpassTest is Test { assertEq(mintPass.isRedeemable(1), true); vm.stopPrank(); } - - function testFailTokenUri0() public view { - mintPass.tokenURI(0); - } } diff --git a/test/Permissioner.t.sol b/test/Permissioner.t.sol index b5400a0e..2f169487 100644 --- a/test/Permissioner.t.sol +++ b/test/Permissioner.t.sol @@ -3,7 +3,9 @@ pragma solidity ^0.8.18; import "forge-std/Test.sol"; import { InvalidSignature, IPermissioner, TermsAcceptedPermissioner, BlindPermissioner } from "../src/Permissioner.sol"; -import { IPToken, Metadata } from "../src/IPToken.sol"; +import { IPToken } from "../src/IPToken.sol"; +import { Metadata } from "../src/IIPToken.sol"; + import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { AcceptAllAuthorizer } from "./helpers/AcceptAllAuthorizer.sol"; diff --git a/test/Tokenizer.t.sol b/test/Tokenizer.t.sol index 3a3300c3..11187289 100644 --- a/test/Tokenizer.t.sol +++ b/test/Tokenizer.t.sol @@ -2,11 +2,9 @@ pragma solidity ^0.8.18; import "forge-std/Test.sol"; -import { console } from "forge-std/console.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; import { Safe } from "safe-global/safe-contracts/Safe.sol"; @@ -19,14 +17,13 @@ import { AcceptAllAuthorizer } from "./helpers/AcceptAllAuthorizer.sol"; import { FakeERC20 } from "../src/helpers/FakeERC20.sol"; import { MustControlIpnft, AlreadyTokenized, Tokenizer, ZeroAddress, IPTNotControlledByTokenizer } from "../src/Tokenizer.sol"; -import { IPToken, TokenCapped, Metadata as TokenMetadata } from "../src/IPToken.sol"; +import { Metadata as TokenMetadata } from "../src/IIPToken.sol"; +import { IPToken, TokenCapped } from "../src/IPToken.sol"; import { IControlIPTs } from "../src/IControlIPTs.sol"; -import { Molecules } from "../src/helpers/test-upgrades/Molecules.sol"; -import { Synthesizer } from "../src/helpers/test-upgrades/Synthesizer.sol"; import { IPermissioner, BlindPermissioner } from "../src/Permissioner.sol"; contract GovernorOfTheFuture is IControlIPTs { - function controllerOf(uint256) external view override returns (address) { + function controllerOf(uint256) external pure override returns (address) { return address(0); //no one but me controls IPTs! } @@ -221,7 +218,7 @@ contract TokenizerTest is Test { function testCannotBypassModifiersWithFakeTokens() public { address attacker = makeAddr("attacker"); vm.startPrank(originalOwner); - IPToken realTokenContract = tokenizer.tokenizeIpnft(1, 100_000, "IPT", agreementCid, ""); + tokenizer.tokenizeIpnft(1, 100_000, "IPT", agreementCid, ""); vm.startPrank(attacker); IPToken fakeIpt = new FakeIPT(1); @@ -297,4 +294,25 @@ contract TokenizerTest is Test { vm.expectRevert(MustControlIpnft.selector); htokenizer.issue(tokenContract, 50_000, bob); } + + function testPreviousOwnerCannotIssueTokensAfterIPNFTTransfer() public { + vm.startPrank(originalOwner); + IPToken tokenContract = tokenizer.tokenizeIpnft(1, 100_000, "IPT", agreementCid, ""); + + // Original owner can initially issue tokens + tokenContract.issue(alice, 50_000); + assertEq(tokenContract.balanceOf(alice), 50_000); + + // Transfer IPNFT to bob + ipnft.transferFrom(originalOwner, bob, 1); + + // Original owner can no longer issue tokens + vm.expectRevert(MustControlIpnft.selector); + tokenContract.issue(alice, 25_000); + + // But new owner (bob) can issue tokens + vm.startPrank(bob); + tokenContract.issue(alice, 25_000); + assertEq(tokenContract.balanceOf(alice), 75_000); + } } diff --git a/test/TokenizerWrapped.t.sol b/test/TokenizerWrapped.t.sol new file mode 100644 index 00000000..ff988280 --- /dev/null +++ b/test/TokenizerWrapped.t.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; + +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import { IPNFT } from "../src/IPNFT.sol"; +import { AcceptAllAuthorizer } from "./helpers/AcceptAllAuthorizer.sol"; + +import { FakeERC20 } from "../src/helpers/FakeERC20.sol"; +import { Tokenizer, ZeroAddress, InvalidTokenContract, InvalidTokenDecimals } from "../src/Tokenizer.sol"; +import { IIPToken } from "../src/IIPToken.sol"; +import { IPToken } from "../src/IPToken.sol"; +import { WrappedIPToken } from "../src/WrappedIPToken.sol"; +import { IPermissioner, BlindPermissioner } from "../src/Permissioner.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +// Test helper contract with invalid decimals +contract FakeERC20WithInvalidDecimals { + function decimals() external pure returns (uint8) { + return 25; // Invalid: > 18 + } + + function name() external pure returns (string memory) { + return "Invalid Token"; + } + + function symbol() external pure returns (string memory) { + return "INVALID"; + } + + function totalSupply() external pure returns (uint256) { + return 1000000 ether; + } +} + +contract TokenizerWrappedTest is Test { + string ipfsUri = "ipfs://bafkreiankqd3jvpzso6khstnaoxovtyezyatxdy7t2qzjoolqhltmasqki"; + string agreementCid = "bafkreigk5dvqblnkdniges6ft5kmuly47ebw4vho6siikzmkaovq6sjstq"; + uint256 MINTING_FEE = 0.001 ether; + string DEFAULT_SYMBOL = "IPT-0001"; + + address deployer = makeAddr("chucknorris"); + address protocolOwner = makeAddr("protocolOwner"); + address originalOwner = makeAddr("daoMultisig"); + address ipnftBuyer = makeAddr("ipnftbuyer"); + + //Alice, Bob and Charlie are molecules holders + address alice = makeAddr("alice"); + uint256 alicePk; + + address bob = makeAddr("bob"); + uint256 bobPk; + + IPNFT internal ipnft; + Tokenizer internal tokenizer; + + IPermissioner internal blindPermissioner; + FakeERC20 internal erc20; + + function setUp() public { + (alice, alicePk) = makeAddrAndKey("alice"); + (bob, bobPk) = makeAddrAndKey("bob"); + vm.startPrank(deployer); + + ipnft = IPNFT(address(new ERC1967Proxy(address(new IPNFT()), ""))); + ipnft.initialize(); + ipnft.setAuthorizer(new AcceptAllAuthorizer()); + blindPermissioner = new BlindPermissioner(); + + tokenizer = Tokenizer(address(new ERC1967Proxy(address(new Tokenizer()), ""))); + tokenizer.initialize(ipnft, blindPermissioner); + tokenizer.setIPTokenImplementation(new IPToken()); + tokenizer.setWrappedIPTokenImplementation(new WrappedIPToken()); + + vm.stopPrank(); + + vm.deal(originalOwner, MINTING_FEE); + vm.startPrank(originalOwner); + uint256 reservationId = ipnft.reserve(); + ipnft.mintReservation{ value: MINTING_FEE }(originalOwner, reservationId, ipfsUri, DEFAULT_SYMBOL, ""); + } + + function testAdoptERC20AsWrappedIPToken() public { + vm.startPrank(originalOwner); + erc20 = new FakeERC20("URORiif", "UROR"); + erc20.mint(originalOwner, 1_000_000 ether); + + IIPToken tokenContract = tokenizer.attachIpt(1, agreementCid, "", erc20); + + assertEq(tokenContract.balanceOf(originalOwner), 1_000_000 ether); + assertNotEq(address(tokenizer.synthesized(1)), address(erc20)); // the synthesized member tracks the wrapped ipt + assertEq(tokenContract.totalIssued(), 1_000_000 ether); + assertEq(tokenContract.name(), "URORiif"); + } + + function testCannotAttachInvalidTokenContract() public { + vm.startPrank(originalOwner); + + // Test with zero address + vm.expectRevert(ZeroAddress.selector); + tokenizer.attachIpt(1, agreementCid, "", IERC20Metadata(address(0))); + + // Test with non-contract address + vm.expectRevert(InvalidTokenContract.selector); + tokenizer.attachIpt(1, agreementCid, "", IERC20Metadata(alice)); + } + + function testCannotAttachTokenWithInvalidDecimals() public { + vm.startPrank(originalOwner); + + // Deploy a token with invalid decimals (>18) + FakeERC20WithInvalidDecimals invalidToken = new FakeERC20WithInvalidDecimals(); + + vm.expectRevert(InvalidTokenDecimals.selector); + tokenizer.attachIpt(1, agreementCid, "", IERC20Metadata(address(invalidToken))); + } + + function testWrappedTokenProperties() public { + vm.startPrank(originalOwner); + erc20 = new FakeERC20("TestToken", "TEST"); + erc20.mint(originalOwner, 1_000_000 ether); + + IIPToken tokenContract = tokenizer.attachIpt(1, agreementCid, "", erc20); + WrappedIPToken wrappedToken = WrappedIPToken(address(tokenContract)); + + // Verify wrapped token properties + assertEq(address(wrappedToken.wrappedToken()), address(erc20)); + assertEq(tokenContract.balanceOf(originalOwner), 1_000_000 ether); + assertEq(tokenContract.totalIssued(), 1_000_000 ether); + assertEq(tokenContract.name(), "TestToken"); + assertEq(tokenContract.symbol(), "TEST"); + } + + function testWrappedTokenCannotIssueOrCap() public { + vm.startPrank(originalOwner); + erc20 = new FakeERC20("TestToken", "TEST"); + erc20.mint(originalOwner, 1_000_000 ether); + + IIPToken tokenContract = tokenizer.attachIpt(1, agreementCid, "", erc20); + + // Wrapped tokens should not be able to issue or cap + vm.expectRevert("WrappedIPToken: cannot issue"); + tokenContract.issue(alice, 1000); + + vm.expectRevert("WrappedIPToken: cannot cap"); + tokenContract.cap(); + } + + // Helper function to check if a string contains a substring + function contains(string memory source, string memory search) internal pure returns (bool) { + bytes memory sourceBytes = bytes(source); + bytes memory searchBytes = bytes(search); + + if (searchBytes.length > sourceBytes.length) { + return false; + } + + for (uint256 i = 0; i <= sourceBytes.length - searchBytes.length; i++) { + bool found = true; + + for (uint256 j = 0; j < searchBytes.length; j++) { + if (sourceBytes[i + j] != searchBytes[j]) { + found = false; + break; + } + } + + if (found) { + return true; + } + } + + return false; + } +} From af9f6075925c5d066ddda588a97aec1d7e4f12ba Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Tue, 23 Sep 2025 11:20:04 +0200 Subject: [PATCH 2/7] feat(tokenizer): add in the local deployment script a script to attach an existing ERC20 as an IPT --- foundry.lock | 38 ++++++++++++++++++++++++++++++++++++++ foundry.toml | 2 ++ script/dev/Ipnft.s.sol | 5 +++-- script/dev/Tokenizer.s.sol | 26 ++++++++++++++++++++------ 4 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 foundry.lock diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 00000000..07a90158 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,38 @@ +{ + "lib/ERC721B": { + "branch": { + "name": "0.2.1", + "rev": "254530019373cfb2aa3cb6e975768857f829823d" + } + }, + "lib/forge-std": { + "rev": "87a2a0afc5fafd6297538a45a52ac19e71a84562" + }, + "lib/openzeppelin-contracts": { + "branch": { + "name": "release-v4.8", + "rev": "281550b71c3df9a83e6b80ceefc700852c287570" + } + }, + "lib/openzeppelin-contracts-upgradeable": { + "rev": "43b82754979c35abcd3ccad7b795754146c62ade" + }, + "lib/safe-contracts": { + "branch": { + "name": "v1.4.0", + "rev": "e870f514ad34cd9654c72174d6d4a839e3c6639f" + } + }, + "lib/solidity-base64": { + "rev": "537095980643f59e9e00b7b76d341bb9da438eaf" + }, + "lib/solmate": { + "branch": { + "name": "v7", + "rev": "ed67feda67b24fdeff8ad1032360f0ee6047ba0a" + } + }, + "lib/token-vesting-contract": { + "rev": "b1f3e05bb6f07bec70d2b7b588d622c06f70ae39" + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index a9eec11e..4f08491e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,6 +4,8 @@ out = 'out' libs = ['lib'] test = 'test' solc_version = "0.8.18" +optimizer = true +optimizer_runs = 200 gas_reports = [ "IPNFT", "IPNFTV2", diff --git a/script/dev/Ipnft.s.sol b/script/dev/Ipnft.s.sol index adf1c920..8891c4c7 100644 --- a/script/dev/Ipnft.s.sol +++ b/script/dev/Ipnft.s.sol @@ -85,12 +85,13 @@ contract FixtureIpnft is CommonScript { function run() public { prepareAddresses(); - uint256 tokenId = mintIpnft(bob, bob); + uint256 token1Id = mintIpnft(bob, bob); + uint256 token2Id = mintIpnft(bob, bob); dealERC20(bob, 1000 ether, usdc); dealERC20(alice, 1000 ether, usdc); - uint256 listingId = createListing(bob, tokenId, 1 ether, usdc); + uint256 listingId = createListing(bob, token1Id, 1 ether, usdc); //we're *NOT* accepting the listing here because of inconsistent listing ids on anvil //execute ApproveAndBuy.s.sol if you want to do that. console.log("listing id %s", listingId); diff --git a/script/dev/Tokenizer.s.sol b/script/dev/Tokenizer.s.sol index 11b26564..ca3af6c3 100644 --- a/script/dev/Tokenizer.s.sol +++ b/script/dev/Tokenizer.s.sol @@ -5,9 +5,11 @@ import "forge-std/Script.sol"; import "forge-std/console.sol"; import { IPNFT } from "../../src/IPNFT.sol"; import { Tokenizer } from "../../src/Tokenizer.sol"; +import { IIPToken } from "../../src/IIPToken.sol"; import { Metadata } from "../../src/IIPToken.sol"; import { IPToken } from "../../src/IPToken.sol"; import { WrappedIPToken } from "../../src/WrappedIPToken.sol"; +import { FakeERC20 } from "../../src/helpers/FakeERC20.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { IPermissioner, TermsAcceptedPermissioner } from "../../src/Permissioner.sol"; @@ -52,18 +54,30 @@ contract FixtureTokenizer is CommonScript { permissioner = TermsAcceptedPermissioner(vm.envAddress("TERMS_ACCEPTED_PERMISSIONER_ADDRESS")); } + function prepareAndSignTerms(uint256 tokenId) internal returns (bytes memory) { + string memory terms = permissioner.specificTermsV1(Metadata(tokenId, bob, "bafkreigk5dvqblnkdniges6ft5kmuly47ebw4vho6siikzmkaovq6sjstq")); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(bobPk, ECDSA.toEthSignedMessageHash(abi.encodePacked(terms))); + return abi.encodePacked(r, s, v); + } + function run() public { prepareAddresses(); - string memory terms = permissioner.specificTermsV1(Metadata(1, bob, "bafkreigk5dvqblnkdniges6ft5kmuly47ebw4vho6siikzmkaovq6sjstq")); vm.startBroadcast(bob); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(bobPk, ECDSA.toEthSignedMessageHash(abi.encodePacked(terms))); - bytes memory signedTerms = abi.encodePacked(r, s, v); - IPToken tokenContract = - tokenizer.tokenizeIpnft(1, 1_000_000 ether, "MOLE", "bafkreigk5dvqblnkdniges6ft5kmuly47ebw4vho6siikzmkaovq6sjstq", signedTerms); + bytes memory signedToken1Terms = prepareAndSignTerms(1); + FakeERC20 usdc = FakeERC20(vm.envAddress("USDC_ADDRESS")); + // Attach an already existing token as an IPT + IIPToken token1Contract = tokenizer.attachIpt(1, "bafkreigk5dvqblnkdniges6ft5kmuly47ebw4vho6siikzmkaovq6sjstq", signedToken1Terms, usdc); + + bytes memory signedToken2Terms = prepareAndSignTerms(2); + + // Mmint a new IPT + IPToken token2Contract = + tokenizer.tokenizeIpnft(2, 1_000_000 ether, "MOLE", "bafkreigk5dvqblnkdniges6ft5kmuly47ebw4vho6siikzmkaovq6sjstq", signedToken2Terms); vm.stopBroadcast(); - console.log("IPTS_ADDRESS=%s", address(tokenContract)); + console.log("ATTACHED_IPT_ADDRESS=%s", address(token1Contract)); + console.log("IPT_ADDRESS=%s", address(token2Contract)); } } From e5ee5579ee12f61f604ddef0a640eb21f67c2efe Mon Sep 17 00:00:00 2001 From: mme <9083787+0xmme@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:18:38 +0200 Subject: [PATCH 3/7] chore: forge fmt import order --- foundry.toml | 1 + script/DeployTokenizer.s.sol | 13 +++++++------ script/prod/RolloutTokenizerV14.s.sol | 4 ++-- src/Tokenizer.sol | 15 ++++++++------- src/WrappedIPToken.sol | 7 ++++--- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/foundry.toml b/foundry.toml index 4f08491e..2f5b8a40 100644 --- a/foundry.toml +++ b/foundry.toml @@ -31,6 +31,7 @@ tab_width = 4 quote_style = 'double' func_attrs_with_params_multiline = true override_spacing = false +sort_imports = true [rpc_endpoints] optimism = "https://optimism-goerli.infura.io/v3/${INFURA_KEY}" diff --git a/script/DeployTokenizer.s.sol b/script/DeployTokenizer.s.sol index 2469ea56..818cb83c 100644 --- a/script/DeployTokenizer.s.sol +++ b/script/DeployTokenizer.s.sol @@ -1,17 +1,18 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.18; -import "forge-std/Script.sol"; -import "forge-std/console.sol"; +import { BioPriceFeed } from "../src/BioPriceFeed.sol"; import { IPNFT } from "../src/IPNFT.sol"; -import { Tokenizer } from "../src/Tokenizer.sol"; import { IPToken } from "../src/IPToken.sol"; -import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import { BioPriceFeed } from "../src/BioPriceFeed.sol"; import { IPermissioner, TermsAcceptedPermissioner } from "../src/Permissioner.sol"; + +import { TimelockedToken } from "../src/TimelockedToken.sol"; +import { Tokenizer } from "../src/Tokenizer.sol"; import { CrowdSale } from "../src/crowdsale/CrowdSale.sol"; import { StakedLockingCrowdSale } from "../src/crowdsale/StakedLockingCrowdSale.sol"; -import { TimelockedToken } from "../src/TimelockedToken.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "forge-std/Script.sol"; +import "forge-std/console.sol"; contract DeployTokenizerInfrastructure is Script { function run() public { diff --git a/script/prod/RolloutTokenizerV14.s.sol b/script/prod/RolloutTokenizerV14.s.sol index 9a67483f..9a720b0a 100644 --- a/script/prod/RolloutTokenizerV14.s.sol +++ b/script/prod/RolloutTokenizerV14.s.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.18; -import "forge-std/Script.sol"; -import { Tokenizer } from "../../src/Tokenizer.sol"; import { IPToken } from "../../src/IPToken.sol"; +import { Tokenizer } from "../../src/Tokenizer.sol"; import { WrappedIPToken } from "../../src/WrappedIPToken.sol"; +import "forge-std/Script.sol"; import { console } from "forge-std/console.sol"; contract RolloutTokenizerV14 is Script { diff --git a/src/Tokenizer.sol b/src/Tokenizer.sol index feed877e..6dcc351a 100644 --- a/src/Tokenizer.sol +++ b/src/Tokenizer.sol @@ -1,18 +1,19 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.18; -import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; -import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { Clones } from "@openzeppelin/contracts/proxy/Clones.sol"; -import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IIPToken, Metadata as TokenMetadata } from "./IIPToken.sol"; import { IPToken } from "./IPToken.sol"; import { WrappedIPToken } from "./WrappedIPToken.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import { Clones } from "@openzeppelin/contracts/proxy/Clones.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; -import { IPermissioner } from "./Permissioner.sol"; -import { IPNFT } from "./IPNFT.sol"; import { IControlIPTs } from "./IControlIPTs.sol"; +import { IPNFT } from "./IPNFT.sol"; +import { IPermissioner } from "./Permissioner.sol"; error MustControlIpnft(); error AlreadyTokenized(); diff --git a/src/WrappedIPToken.sol b/src/WrappedIPToken.sol index 1397d8a0..6ca9fcd5 100644 --- a/src/WrappedIPToken.sol +++ b/src/WrappedIPToken.sol @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.18; -import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { IIPToken, Metadata } from "./IIPToken.sol"; -import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; -import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; + import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; /** * @title WrappedIPToken From af5d4c959789b61aca61f1938ac444d2b1011cdf Mon Sep 17 00:00:00 2001 From: mme <9083787+0xmme@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:26:07 +0200 Subject: [PATCH 4/7] chore: add docs to IIPToken --- src/IIPToken.sol | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/IIPToken.sol b/src/IIPToken.sol index a47d6ce7..56eea7be 100644 --- a/src/IIPToken.sol +++ b/src/IIPToken.sol @@ -1,21 +1,40 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8; +/// @title IP Token Metadata Structure +/// @notice Metadata associated with an IP Token, linking it to its originating IPNFT struct Metadata { + /// @notice The ID of the IPNFT that this IP token is derived from uint256 ipnftId; + /// @notice The original owner of the IPNFT at the time of token creation address originalOwner; + /// @notice IPFS CID of the agreement governing this IP token string agreementCid; } +/// @title IP Token Interface +/// @notice Interface for IP tokens that represent fractionalized intellectual property rights +/// @dev IP tokens are created from IPNFTs and represent transferable shares of IP ownership interface IIPToken { - /// @notice the amount of tokens that ever have been issued (not necessarily == supply) + /// @notice Returns the total amount of tokens that have ever been issued + /// @dev This may differ from current supply due to potential burning mechanisms + /// @return The total number of tokens issued since contract deployment function totalIssued() external view returns (uint256); - function balanceOf(address account) external view returns (uint256); + + /// @notice Returns the metadata associated with this IP token + /// @return The metadata struct containing IPNFT ID, original owner, and agreement CID function metadata() external view returns (Metadata memory); - function issue(address, uint256) external; + + /// @notice Issues new tokens to a specified address + /// @param to The address to receive the newly issued tokens + /// @param amount The number of tokens to issue + function issue(address to, uint256 amount) external; + + /// @notice Returns or sets the maximum supply cap for this token + /// @dev Implementation may vary - could be a getter or setter depending on context function cap() external; + + /// @notice Returns the URI for token metadata (typically IPFS) + /// @return The URI string pointing to token metadata function uri() external view returns (string memory); - function name() external view returns (string memory); - function symbol() external view returns (string memory); - function decimals() external view returns (uint8); } From 2fec1e19e69d89db916380fca27373bc60fa0fee Mon Sep 17 00:00:00 2001 From: mme <9083787+0xmme@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:10:48 +0200 Subject: [PATCH 5/7] refactor: shared ipt functions into lib --- src/IPToken.sol | 82 ++++++++----------- src/WrappedIPToken.sol | 68 ++++++--------- src/libraries/IPTokenUtils.sol | 54 ++++++++++++ test/Forking/Tokenizer14UpgradeForkTest.t.sol | 42 +++++----- test/TokenizerWrapped.t.sol | 19 +++-- 5 files changed, 150 insertions(+), 115 deletions(-) create mode 100644 src/libraries/IPTokenUtils.sol diff --git a/src/IPToken.sol b/src/IPToken.sol index ca8c4a7a..1a0ba74f 100644 --- a/src/IPToken.sol +++ b/src/IPToken.sol @@ -1,14 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.18; -import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import { ERC20BurnableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; -import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; -import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; -import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; -import { Tokenizer, MustControlIpnft } from "./Tokenizer.sol"; import { IControlIPTs } from "./IControlIPTs.sol"; import { IIPToken, Metadata } from "./IIPToken.sol"; +import { MustControlIpnft, Tokenizer } from "./Tokenizer.sol"; +import { IPTokenUtils } from "./libraries/IPTokenUtils.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { ERC20BurnableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; error TokenCapped(); @@ -51,32 +50,53 @@ contract IPToken is IIPToken, ERC20Upgradeable, ERC20BurnableUpgradeable, Ownabl _; } - function metadata() external view returns (Metadata memory) { + function metadata() external view override returns (Metadata memory) { return _metadata; } - function balanceOf(address account) public view override(ERC20Upgradeable, IIPToken) returns (uint256) { + // Override ERC20 functions to resolve diamond inheritance + function totalSupply() public view override returns (uint256) { + return ERC20Upgradeable.totalSupply(); + } + + function balanceOf(address account) public view override returns (uint256) { return ERC20Upgradeable.balanceOf(account); } - function name() public view override(ERC20Upgradeable, IIPToken) returns (string memory) { + function transfer(address to, uint256 amount) public override returns (bool) { + return ERC20Upgradeable.transfer(to, amount); + } + + function allowance(address owner, address spender) public view override returns (uint256) { + return ERC20Upgradeable.allowance(owner, spender); + } + + function approve(address spender, uint256 amount) public override returns (bool) { + return ERC20Upgradeable.approve(spender, amount); + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + return ERC20Upgradeable.transferFrom(from, to, amount); + } + + function name() public view override returns (string memory) { return ERC20Upgradeable.name(); } - function symbol() public view override(ERC20Upgradeable, IIPToken) returns (string memory) { + function symbol() public view override returns (string memory) { return ERC20Upgradeable.symbol(); } - function decimals() public view override(ERC20Upgradeable, IIPToken) returns (uint8) { + function decimals() public view override returns (uint8) { return ERC20Upgradeable.decimals(); } + /** * @notice the supply of IP Tokens is controlled by the tokenizer contract. * @param receiver address * @param amount uint256 */ - - function issue(address receiver, uint256 amount) external onlyTokenizerOrIPNFTController { + function issue(address receiver, uint256 amount) external override onlyTokenizerOrIPNFTController { if (capped) { revert TokenCapped(); } @@ -87,7 +107,7 @@ contract IPToken is IIPToken, ERC20Upgradeable, ERC20BurnableUpgradeable, Ownabl /** * @notice mark this token as capped. After calling this, no new tokens can be `issue`d */ - function cap() external onlyTokenizerOrIPNFTController { + function cap() external override onlyTokenizerOrIPNFTController { capped = true; emit Capped(totalIssued); } @@ -96,37 +116,7 @@ contract IPToken is IIPToken, ERC20Upgradeable, ERC20BurnableUpgradeable, Ownabl * @notice contract metadata, compatible to ERC1155 * @return string base64 encoded data url */ - function uri() external view returns (string memory) { - string memory tokenId = Strings.toString(_metadata.ipnftId); - - string memory props = string.concat( - '"properties": {', - '"ipnft_id": ', - tokenId, - ',"agreement_content": "ipfs://', - _metadata.agreementCid, - '","original_owner": "', - Strings.toHexString(_metadata.originalOwner), - '","erc20_contract": "', - Strings.toHexString(address(this)), - '","supply": "', - Strings.toString(totalIssued), - '"}' - ); - - return string.concat( - "data:application/json;base64,", - Base64.encode( - bytes( - string.concat( - '{"name": "IP Tokens of IPNFT #', - tokenId, - '","description": "IP Tokens, derived from IP-NFTs, are ERC-20 tokens governing IP pools.","decimals": 18,"external_url": "https://molecule.xyz","image": "",', - props, - "}" - ) - ) - ) - ); + function uri() external view override returns (string memory) { + return IPTokenUtils.generateURI(_metadata, address(this), totalIssued); } } diff --git a/src/WrappedIPToken.sol b/src/WrappedIPToken.sol index 6ca9fcd5..447cbd95 100644 --- a/src/WrappedIPToken.sol +++ b/src/WrappedIPToken.sol @@ -2,11 +2,9 @@ pragma solidity 0.8.18; import { IIPToken, Metadata } from "./IIPToken.sol"; - +import { IPTokenUtils } from "./libraries/IPTokenUtils.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; -import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; /** * @title WrappedIPToken @@ -38,32 +36,52 @@ contract WrappedIPToken is IIPToken, Initializable { /** * @dev Returns the name of the token. */ - function name() public view override returns (string memory) { + function name() public view returns (string memory) { return wrappedToken.name(); } /** * @dev Returns the symbol of the token. */ - function symbol() public view override returns (string memory) { + function symbol() public view returns (string memory) { return wrappedToken.symbol(); } /** * @dev Returns the decimals places of the token. */ - function decimals() public view override returns (uint8) { + function decimals() public view returns (uint8) { return wrappedToken.decimals(); } - function totalIssued() public view override returns (uint256) { + function totalSupply() public view returns (uint256) { return wrappedToken.totalSupply(); } - function balanceOf(address account) public view override returns (uint256) { + function balanceOf(address account) public view returns (uint256) { return wrappedToken.balanceOf(account); } + function transfer(address to, uint256 amount) public returns (bool) { + return wrappedToken.transfer(to, amount); + } + + function allowance(address owner, address spender) public view returns (uint256) { + return wrappedToken.allowance(owner, spender); + } + + function approve(address spender, uint256 amount) public returns (bool) { + return wrappedToken.approve(spender, amount); + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + return wrappedToken.transferFrom(from, to, amount); + } + + function totalIssued() public view override returns (uint256) { + return wrappedToken.totalSupply(); + } + function issue(address, uint256) public virtual override { revert("WrappedIPToken: cannot issue"); } @@ -73,38 +91,6 @@ contract WrappedIPToken is IIPToken, Initializable { } function uri() external view override returns (string memory) { - string memory tokenId = Strings.toString(_metadata.ipnftId); - - string memory props = string.concat( - '"properties": {', - '"ipnft_id": ', - tokenId, - ',"agreement_content": "ipfs://', - _metadata.agreementCid, - '","original_owner": "', - Strings.toHexString(_metadata.originalOwner), - '","erc20_contract": "', - Strings.toHexString(address(wrappedToken)), - '","supply": "', - Strings.toString(wrappedToken.totalSupply()), - '"}' - ); - - return string.concat( - "data:application/json;base64,", - Base64.encode( - bytes( - string.concat( - '{"name": "IP Tokens of IPNFT #', - tokenId, - '","description": "IP Tokens, derived from IP-NFTs, are ERC-20 tokens governing IP pools.","decimals": ', - Strings.toString(wrappedToken.decimals()), - ',"external_url": "https://molecule.xyz","image": "",', - props, - "}" - ) - ) - ) - ); + return IPTokenUtils.generateURI(_metadata, address(wrappedToken), wrappedToken.totalSupply()); } } diff --git a/src/libraries/IPTokenUtils.sol b/src/libraries/IPTokenUtils.sol new file mode 100644 index 00000000..c2efb859 --- /dev/null +++ b/src/libraries/IPTokenUtils.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8; + +import { Metadata } from "../IIPToken.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +/// @title IP Token Utilities Library +/// @notice Shared utilities for IP Token contracts +/// @dev Contains common functions used by both IPToken and WrappedIPToken +library IPTokenUtils { + /// @notice Generates a base64-encoded data URL containing token metadata + /// @param metadata_ The metadata struct containing IPNFT information + /// @param tokenContract The ERC20 token contract address + /// @param supply The token supply to include in metadata + /// @return The complete data URL string + function generateURI(Metadata memory metadata_, address tokenContract, uint256 supply) internal view returns (string memory) { + string memory tokenId = Strings.toString(metadata_.ipnftId); + + string memory props = string.concat( + '"properties": {', + '"ipnft_id": ', + tokenId, + ',"agreement_content": "ipfs://', + metadata_.agreementCid, + '","original_owner": "', + Strings.toHexString(metadata_.originalOwner), + '","erc20_contract": "', + Strings.toHexString(tokenContract), + '","supply": "', + Strings.toString(supply), + '"}' + ); + + return string.concat( + "data:application/json;base64,", + Base64.encode( + bytes( + string.concat( + '{"name": "IP Tokens of IPNFT #', + tokenId, + '","description": "IP Tokens, derived from IP-NFTs, are ERC-20 tokens governing IP pools.","decimals": ', + Strings.toString(IERC20Metadata(tokenContract).decimals()), + ',"external_url": "https://molecule.xyz","image": "",', + props, + "}" + ) + ) + ) + ); + } +} diff --git a/test/Forking/Tokenizer14UpgradeForkTest.t.sol b/test/Forking/Tokenizer14UpgradeForkTest.t.sol index 8c36f425..1cb16bec 100644 --- a/test/Forking/Tokenizer14UpgradeForkTest.t.sol +++ b/test/Forking/Tokenizer14UpgradeForkTest.t.sol @@ -8,17 +8,21 @@ import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ import { IPNFT } from "../../src/IPNFT.sol"; -import { MustControlIpnft, AlreadyTokenized, Tokenizer } from "../../src/Tokenizer.sol"; -import { Tokenizer13 } from "../../src/helpers/test-upgrades/Tokenizer13.sol"; -import { IPToken13 } from "../../src/helpers/test-upgrades/IPToken13.sol"; -import { IPToken, TokenCapped } from "../../src/IPToken.sol"; import { Metadata } from "../../src/IIPToken.sol"; -import { OnlyIssuerOrOwner } from "../../src/helpers/test-upgrades/IPToken12.sol"; + import { IIPToken } from "../../src/IIPToken.sol"; +import { IPToken, TokenCapped } from "../../src/IPToken.sol"; + +import { BlindPermissioner, IPermissioner } from "../../src/Permissioner.sol"; +import { AlreadyTokenized, MustControlIpnft, Tokenizer } from "../../src/Tokenizer.sol"; import { WrappedIPToken } from "../../src/WrappedIPToken.sol"; -import { IPermissioner, BlindPermissioner } from "../../src/Permissioner.sol"; -import { AcceptAllAuthorizer } from "../helpers/AcceptAllAuthorizer.sol"; + import { FakeERC20 } from "../../src/helpers/FakeERC20.sol"; +import { OnlyIssuerOrOwner } from "../../src/helpers/test-upgrades/IPToken12.sol"; +import { IPToken13 } from "../../src/helpers/test-upgrades/IPToken13.sol"; +import { Tokenizer13 } from "../../src/helpers/test-upgrades/Tokenizer13.sol"; + +import { AcceptAllAuthorizer } from "../helpers/AcceptAllAuthorizer.sol"; contract Tokenizer14UpgradeForkTest is Test { using SafeERC20Upgradeable for IPToken; @@ -270,10 +274,10 @@ contract Tokenizer14UpgradeForkTest is Test { IIPToken iiptToken = IIPToken(address(newToken)); // Test interface functions work - assertEq(iiptToken.name(), string.concat("IP Tokens of IPNFT #", vm.toString(newIpnftId))); - assertEq(iiptToken.symbol(), "NEW-IPT"); - assertEq(iiptToken.decimals(), 18); - assertEq(iiptToken.balanceOf(alice), 1_000_000 ether); + assertEq(newToken.name(), string.concat("IP Tokens of IPNFT #", vm.toString(newIpnftId))); + assertEq(newToken.symbol(), "NEW-IPT"); + assertEq(newToken.decimals(), 18); + assertEq(newToken.balanceOf(alice), 1_000_000 ether); assertEq(iiptToken.totalIssued(), 1_000_000 ether); // Test that metadata is accessible through interface @@ -337,11 +341,8 @@ contract Tokenizer14UpgradeForkTest is Test { // Test that both IPToken and IIPToken interfaces work identically IIPToken interfaceToken = IIPToken(address(newToken)); - // Compare results from both interfaces - assertEq(newToken.name(), interfaceToken.name()); - assertEq(newToken.symbol(), interfaceToken.symbol()); - assertEq(newToken.decimals(), interfaceToken.decimals()); - assertEq(newToken.balanceOf(alice), interfaceToken.balanceOf(alice)); + // Compare results from both interfaces (only IP-specific functions available on interface) + assertEq(newToken.totalIssued(), interfaceToken.totalIssued()); assertEq(newToken.totalIssued(), interfaceToken.totalIssued()); // Test metadata access @@ -386,10 +387,11 @@ contract Tokenizer14UpgradeForkTest is Test { assertEq(address(tokenizer14.synthesized(newIpnftId)), address(wrappedToken)); // Test the wrapped token implements IIPToken interface - assertEq(wrappedToken.name(), "Test Wrapped Token"); - assertEq(wrappedToken.symbol(), "TWT"); - assertEq(wrappedToken.decimals(), 18); - assertEq(wrappedToken.balanceOf(alice), 5_000_000 ether); + WrappedIPToken wrappedImpl = WrappedIPToken(address(wrappedToken)); + assertEq(wrappedImpl.name(), "Test Wrapped Token"); + assertEq(wrappedImpl.symbol(), "TWT"); + assertEq(wrappedImpl.decimals(), 18); + assertEq(wrappedImpl.balanceOf(alice), 5_000_000 ether); assertEq(wrappedToken.totalIssued(), 5_000_000 ether); // Test metadata is accessible through interface diff --git a/test/TokenizerWrapped.t.sol b/test/TokenizerWrapped.t.sol index ff988280..af769f43 100644 --- a/test/TokenizerWrapped.t.sol +++ b/test/TokenizerWrapped.t.sol @@ -8,12 +8,14 @@ import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy import { IPNFT } from "../src/IPNFT.sol"; import { AcceptAllAuthorizer } from "./helpers/AcceptAllAuthorizer.sol"; -import { FakeERC20 } from "../src/helpers/FakeERC20.sol"; -import { Tokenizer, ZeroAddress, InvalidTokenContract, InvalidTokenDecimals } from "../src/Tokenizer.sol"; import { IIPToken } from "../src/IIPToken.sol"; import { IPToken } from "../src/IPToken.sol"; + +import { BlindPermissioner, IPermissioner } from "../src/Permissioner.sol"; +import { InvalidTokenContract, InvalidTokenDecimals, Tokenizer, ZeroAddress } from "../src/Tokenizer.sol"; import { WrappedIPToken } from "../src/WrappedIPToken.sol"; -import { IPermissioner, BlindPermissioner } from "../src/Permissioner.sol"; +import { FakeERC20 } from "../src/helpers/FakeERC20.sol"; + import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; // Test helper contract with invalid decimals @@ -89,10 +91,11 @@ contract TokenizerWrappedTest is Test { IIPToken tokenContract = tokenizer.attachIpt(1, agreementCid, "", erc20); - assertEq(tokenContract.balanceOf(originalOwner), 1_000_000 ether); + WrappedIPToken wrappedImpl = WrappedIPToken(address(tokenContract)); + assertEq(wrappedImpl.balanceOf(originalOwner), 1_000_000 ether); assertNotEq(address(tokenizer.synthesized(1)), address(erc20)); // the synthesized member tracks the wrapped ipt assertEq(tokenContract.totalIssued(), 1_000_000 ether); - assertEq(tokenContract.name(), "URORiif"); + assertEq(wrappedImpl.name(), "URORiif"); } function testCannotAttachInvalidTokenContract() public { @@ -127,10 +130,10 @@ contract TokenizerWrappedTest is Test { // Verify wrapped token properties assertEq(address(wrappedToken.wrappedToken()), address(erc20)); - assertEq(tokenContract.balanceOf(originalOwner), 1_000_000 ether); + assertEq(wrappedToken.balanceOf(originalOwner), 1_000_000 ether); assertEq(tokenContract.totalIssued(), 1_000_000 ether); - assertEq(tokenContract.name(), "TestToken"); - assertEq(tokenContract.symbol(), "TEST"); + assertEq(wrappedToken.name(), "TestToken"); + assertEq(wrappedToken.symbol(), "TEST"); } function testWrappedTokenCannotIssueOrCap() public { From b6adf2bd717242926bd7b8c1162873a4aa3f08c6 Mon Sep 17 00:00:00 2001 From: mme <9083787+0xmme@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:45:58 +0200 Subject: [PATCH 6/7] refactor: remove state changing methods from wrappedipt --- src/Tokenizer.sol | 1 + src/WrappedIPToken.sol | 27 ++------ test/Forking/Tokenizer14UpgradeForkTest.t.sol | 3 + test/TokenizerWrapped.t.sol | 68 ++++++++++++++++++- 4 files changed, 77 insertions(+), 22 deletions(-) diff --git a/src/Tokenizer.sol b/src/Tokenizer.sol index 6dcc351a..bef6c8f8 100644 --- a/src/Tokenizer.sol +++ b/src/Tokenizer.sol @@ -37,6 +37,7 @@ contract Tokenizer is UUPSUpgradeable, OwnableUpgradeable, IControlIPTs { string symbol ); + // @dev @TODO: index these topics event TokenWrapped(IERC20Metadata tokenContract, IIPToken wrappedIpt); event IPTokenImplementationUpdated(IIPToken indexed old, IIPToken indexed _new); event WrappedIPTokenImplementationUpdated(WrappedIPToken indexed old, WrappedIPToken indexed _new); diff --git a/src/WrappedIPToken.sol b/src/WrappedIPToken.sol index 447cbd95..1c96abb6 100644 --- a/src/WrappedIPToken.sol +++ b/src/WrappedIPToken.sol @@ -9,8 +9,11 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I /** * @title WrappedIPToken * @author molecule.xyz - * @notice this is a template contract that's cloned by the Tokenizer - * @notice this contract is used to wrap an ERC20 token and extend its metadata + * @notice A read-only wrapper that extends existing ERC20 tokens with IP metadata + * @dev This contract provides IP metadata for an existing ERC20 token without proxying + * state-changing operations. It only implements IIPToken interface functions and + * read-only ERC20 view functions. Users must interact with the underlying token + * directly for transfers, approvals, and other state changes. */ contract WrappedIPToken is IIPToken, Initializable { IERC20Metadata public wrappedToken; @@ -62,32 +65,16 @@ contract WrappedIPToken is IIPToken, Initializable { return wrappedToken.balanceOf(account); } - function transfer(address to, uint256 amount) public returns (bool) { - return wrappedToken.transfer(to, amount); - } - - function allowance(address owner, address spender) public view returns (uint256) { - return wrappedToken.allowance(owner, spender); - } - - function approve(address spender, uint256 amount) public returns (bool) { - return wrappedToken.approve(spender, amount); - } - - function transferFrom(address from, address to, uint256 amount) public returns (bool) { - return wrappedToken.transferFrom(from, to, amount); - } - function totalIssued() public view override returns (uint256) { return wrappedToken.totalSupply(); } function issue(address, uint256) public virtual override { - revert("WrappedIPToken: cannot issue"); + revert("WrappedIPToken: read-only wrapper - use underlying token for minting"); } function cap() public virtual override { - revert("WrappedIPToken: cannot cap"); + revert("WrappedIPToken: read-only wrapper - use underlying token for cappping"); } function uri() external view override returns (string memory) { diff --git a/test/Forking/Tokenizer14UpgradeForkTest.t.sol b/test/Forking/Tokenizer14UpgradeForkTest.t.sol index 1cb16bec..e4fc2cf0 100644 --- a/test/Forking/Tokenizer14UpgradeForkTest.t.sol +++ b/test/Forking/Tokenizer14UpgradeForkTest.t.sol @@ -414,6 +414,9 @@ contract Tokenizer14UpgradeForkTest is Test { vm.expectRevert(); // WrappedIPToken should not allow cap operations WrappedIPToken(address(wrappedToken)).cap(); + // Test that WrappedIPToken only implements IIPToken state-changing operations (which revert) + // ERC20 state-changing functions are no longer implemented + // Test that we cannot attach another token to the same IPNFT FakeERC20 anotherToken = new FakeERC20("Another Token", "ANT"); vm.expectRevert(AlreadyTokenized.selector); diff --git a/test/TokenizerWrapped.t.sol b/test/TokenizerWrapped.t.sol index af769f43..bd2e45a7 100644 --- a/test/TokenizerWrapped.t.sol +++ b/test/TokenizerWrapped.t.sol @@ -144,13 +144,77 @@ contract TokenizerWrappedTest is Test { IIPToken tokenContract = tokenizer.attachIpt(1, agreementCid, "", erc20); // Wrapped tokens should not be able to issue or cap - vm.expectRevert("WrappedIPToken: cannot issue"); + vm.expectRevert("WrappedIPToken: read-only wrapper - use underlying token for minting"); tokenContract.issue(alice, 1000); - vm.expectRevert("WrappedIPToken: cannot cap"); + vm.expectRevert("WrappedIPToken: read-only wrapper - use underlying token for cappping"); tokenContract.cap(); } + function testWrappedTokenStateChangingOperationsRevert() public { + vm.startPrank(originalOwner); + erc20 = new FakeERC20("TestToken", "TEST"); + erc20.mint(originalOwner, 1_000_000 ether); + + IIPToken tokenContract = tokenizer.attachIpt(1, agreementCid, "", erc20); + + // Test that IIPToken state-changing operations revert with proper messages + vm.expectRevert("WrappedIPToken: read-only wrapper - use underlying token for minting"); + tokenContract.issue(alice, 1000); + + vm.expectRevert("WrappedIPToken: read-only wrapper - use underlying token for cappping"); + tokenContract.cap(); + } + + function testWrappedTokenReadOnlyOperationsWork() public { + vm.startPrank(originalOwner); + erc20 = new FakeERC20("TestToken", "TEST"); + erc20.mint(originalOwner, 1_000_000 ether); + + IIPToken tokenContract = tokenizer.attachIpt(1, agreementCid, "", erc20); + WrappedIPToken wrappedToken = WrappedIPToken(address(tokenContract)); + + // Test that read-only operations still work + assertEq(wrappedToken.balanceOf(originalOwner), 1_000_000 ether); + assertEq(wrappedToken.totalSupply(), 1_000_000 ether); + assertEq(wrappedToken.name(), "TestToken"); + assertEq(wrappedToken.symbol(), "TEST"); + assertEq(wrappedToken.decimals(), 18); + assertEq(wrappedToken.totalIssued(), 1_000_000 ether); + + // Test IP metadata functions work + assertEq(wrappedToken.metadata().ipnftId, 1); + assertEq(wrappedToken.metadata().originalOwner, originalOwner); + assertEq(wrappedToken.metadata().agreementCid, agreementCid); + + // Test URI function works + string memory uri = wrappedToken.uri(); + assertTrue(bytes(uri).length > 0); + } + + function testUnderlyingTokenOperationsStillWork() public { + vm.startPrank(originalOwner); + erc20 = new FakeERC20("TestToken", "TEST"); + erc20.mint(originalOwner, 1_000_000 ether); + + IIPToken tokenContract = tokenizer.attachIpt(1, agreementCid, "", erc20); + + // Users can still use the underlying token directly for transfers + assertTrue(erc20.transfer(alice, 100_000 ether)); + assertEq(erc20.balanceOf(alice), 100_000 ether); + assertEq(erc20.balanceOf(originalOwner), 900_000 ether); + + // Approvals work on the underlying token + assertTrue(erc20.approve(alice, 50_000 ether)); + assertEq(erc20.allowance(originalOwner, alice), 50_000 ether); + + // Transfer from works on the underlying token + vm.startPrank(alice); + assertTrue(erc20.transferFrom(originalOwner, alice, 25_000 ether)); + assertEq(erc20.balanceOf(alice), 125_000 ether); + assertEq(erc20.balanceOf(originalOwner), 875_000 ether); + } + // Helper function to check if a string contains a substring function contains(string memory source, string memory search) internal pure returns (bool) { bytes memory sourceBytes = bytes(source); From 20e890862f2e0f1f3578e15141e15dc8cf8cf2dd Mon Sep 17 00:00:00 2001 From: mme <9083787+0xmme@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:21:54 +0200 Subject: [PATCH 7/7] chore: update readme --- README.md | 43 +++++++++++-------- test/Forking/Tokenizer14UpgradeForkTest.t.sol | 1 - 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ddc5334a..5c331c63 100644 --- a/README.md +++ b/README.md @@ -17,21 +17,24 @@ IP-NFTs allow their users to tokenize intellectual property. This repo contains | Locking Crowdsale | [0xfbfd266bf3b49Db8746155AA318D4533Cc66DB26](https://etherscan.io/address/0xfbfd266bf3b49Db8746155AA318D4533Cc66DB26#code) | View contract | | StakedLockingCrowdSale | [0x35Bce29F52f51f547998717CD598068Afa2B29B7](https://etherscan.io/address/0x35Bce29F52f51f547998717CD598068Afa2B29B7#code) | View contract | -timelocked token implementation=0x625ed621d814645AA81C50c4f333D4a407576e8F - +timelocked token implementation=0x625ed621d814645AA81C50c4f333D4a407576e8F #### Subgraph API: https://subgraph.satsuma-prod.com/742d8952ab24/molecule--4039244/ip-nft-mainnet/api Playground: https://subgraph.satsuma-prod.com/molecule--4039244/ip-nft-mainnet/playground -tokenizer implementation 1.3: 0x6517DD48908F4C1FF4eD74FfD780908241a3654C +tokenizer implementation 1.4: 0x0d781edf9c75cf9136aac6600873d0a20a6dd43f +tokenizer implementation 1.3: 0x6517DD48908F4C1FF4eD74FfD780908241a3654C tokenizer implementation 1.2: 0xE8701330F196FeFe415b28dAA767AB076F42557A tokenizer implementation 1.1: 0x9C70FA8c87D7e94Fd63eeCCcA657D5c4224a36f3 +iptoken implementation 1.4: 0xd79fe2c4879b3a3d732df11294329a60cff3a0a9 iptoken implementation 1.3: 0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B iptoken implementation: 0x9E4fc6E6d1A64e3429aB852d3CB31AD7aa06997A +wrapped iptoken implementation: 0x0ca5f50a8a59a59ef8c8d610f5ebf99e41f1352f + ipnft implementation 2.4: 0x6B179Dffac5E190c670176606f552cB792847f80 #### Defender Relayer @@ -42,18 +45,24 @@ Deprecated after migrating to Defender 2 (was 0x3D30452c48F2448764d5819a9A2b684A ### Sepolia -| Contract | Address | Explorer | -| ------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| IPNFT | 0x152B444e60C526fe4434C721561a077269FcF61a | View contract | -| Swap | 0x9e4c638e703d0Af3a3B9eb488dE79A16d402698f | View contract | -| Authorizer | 0x7a9F3773352e4ee0Da6307Cd32C45fE89602129A | View contract | -| Terms Permissioner | 0xC05D649368d8A5e2E98CAa205d47795de5fCB599 | View contract | -| Tokenizer | 0xca63411FF5187431028d003eD74B57531408d2F9 | View contract | -| Crowdsale | 0x8cA737E2cdaE1Ceb332bEf7ba9eA711a3a2f8037 | View contract | -| Locking Crowdsale | 0x0Da77f361bB56f065Aa21647d885685eb7cAE10F | View contract | +| Contract | Address | Explorer | +| ------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| IPNFT | 0x152B444e60C526fe4434C721561a077269FcF61a | View contract | +| Swap | 0x9e4c638e703d0Af3a3B9eb488dE79A16d402698f | View contract | +| Authorizer | 0x7a9F3773352e4ee0Da6307Cd32C45fE89602129A | View contract | +| Terms Permissioner | 0xC05D649368d8A5e2E98CAa205d47795de5fCB599 | View contract | +| Tokenizer | 0xca63411FF5187431028d003eD74B57531408d2F9 | View contract | +| Crowdsale | 0x8cA737E2cdaE1Ceb332bEf7ba9eA711a3a2f8037 | View contract | +| Locking Crowdsale | 0x0Da77f361bB56f065Aa21647d885685eb7cAE10F | View contract | | Staked Crowdsale | 0xd1cE2EA7d3b0C9cAB025A4aD762FC00315141ad7 | View contract | -timelocked token implementation=0xF8F79c1E02387b0Fc9DE0945cD9A2c06F127D851 +timelocked token implementation=0xF8F79c1E02387b0Fc9DE0945cD9A2c06F127D851 + +tokenizer implementation 1.4: 0x4166362c3b9fb7d43c79ae8668e4517799aed0e0 + +iptoken implementation 1.4: 0xacadd6dd9e7af053f42425a03f68da9920287d5b + +wrapped iptoken implementation: 0xa3b844450e31e541e604217b11d48c111419a6a6 new SLCS with support for verifiable timelocks & distinctly configurable staking / locking periods: https://sepolia.etherscan.io/address/0x2d309CF13dC3872f9c9B1B06Ebf6F60caDe08d55#code @@ -105,13 +114,13 @@ VDAO_TOKEN_ADDRESS=0x19A3036b828bffB5E14da2659E950E76f8e6BAA2 forge script --private-key=$PRIVATE_KEY --rpc-url=$RPC_URL script/prod/RolloutTokenizerV14.s.sol --broadcast // 0xTokenizer (address, bytes)(0xNewImpl, 0xNewWrappedIPTokenImpl 0xNewIPTokenImpl) -cast send --rpc-url=$RPC_URL --private-key=$PRIVATE_KEY 0x58EB89C69CB389DBef0c130C6296ee271b82f436 "upgradeToAndCall(address,bytes)" 0x34A1D3fff3958843C43aD80F30b94c510645C316 0x8b3d19bb0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000005b73c5498c1e3b4dba84de0f1833c4a029d90519 +cast send --rpc-url=$RPC_URL --private-key=$PRIVATE_KEY 0x58EB89C69CB389DBef0c130C6296ee271b82f436 "upgradeToAndCall(address,bytes)" 0x4166362c3b9fb7d43c79ae8668e4517799aed0e0 0x8b3d19bb0000000000000000000000000ca5f50a8a59a59ef8c8d610f5ebf99e41f1352f000000000000000000000000d79fe2c4879b3a3d732df11294329a60cff3a0a9 ### Timelocked Tokens originally the "timelocked token" was an inline concept of the slcs. Timelock contracts weren't reusable among cs impls. This changes as of beginning of 2025. As a rather simple but not very elegant (and certainly not correct) solution we decided to "trust" external locking contracts so you can reuse them among crowdsale instances. This was needed for the VitaRNA crowdsale that's supposed to just support locks, no stakes - and hence required another crowdsale instance. During this upgrade we decided to externalize the timelock token template so upcoming instances can be verified on chain. ---- +--- ## Prerequisites @@ -168,8 +177,8 @@ You need Docker. #### Automatically -- `yarn localenv` sets up *everything* -- use `./setupLocal.sh` to deploy all contracts. Add the optional `-f` or `--fixture` flag to also run the fixture scripts to tokenize one IPNFT or `-fx` to create two crowdsale instances. +- `yarn localenv` sets up _everything_ +- use `./setupLocal.sh` to deploy all contracts. Add the optional `-f` or `--fixture` flag to also run the fixture scripts to tokenize one IPNFT or `-fx` to create two crowdsale instances. #### Manual diff --git a/test/Forking/Tokenizer14UpgradeForkTest.t.sol b/test/Forking/Tokenizer14UpgradeForkTest.t.sol index e4fc2cf0..7d1bad11 100644 --- a/test/Forking/Tokenizer14UpgradeForkTest.t.sol +++ b/test/Forking/Tokenizer14UpgradeForkTest.t.sol @@ -343,7 +343,6 @@ contract Tokenizer14UpgradeForkTest is Test { // Compare results from both interfaces (only IP-specific functions available on interface) assertEq(newToken.totalIssued(), interfaceToken.totalIssued()); - assertEq(newToken.totalIssued(), interfaceToken.totalIssued()); // Test metadata access Metadata memory directMetadata = newToken.metadata();