From 7fb3ea170cc5a0954ee4eb67a953c10d408889a5 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Wed, 22 Oct 2025 11:22:38 +0800 Subject: [PATCH 1/5] feat: add Multicall3 client and ABI definition --- .../abi/Multicall3/Multicall3_client.py | 38 +++ .../abi/SPGNFTImpl/SPGNFTImpl_client.py | 3 + .../abi/jsons/Multicall3.json | 260 ++++++++++++++++++ .../scripts/config.json | 7 +- 4 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/story_protocol_python_sdk/abi/Multicall3/Multicall3_client.py create mode 100644 src/story_protocol_python_sdk/abi/jsons/Multicall3.json diff --git a/src/story_protocol_python_sdk/abi/Multicall3/Multicall3_client.py b/src/story_protocol_python_sdk/abi/Multicall3/Multicall3_client.py new file mode 100644 index 00000000..fb28b71a --- /dev/null +++ b/src/story_protocol_python_sdk/abi/Multicall3/Multicall3_client.py @@ -0,0 +1,38 @@ +import json +import os + +from web3 import Web3 + + +class Multicall3Client: + def __init__(self, web3: Web3): + self.web3 = web3 + # Assuming config.json is located at the root of the project + config_path = os.path.abspath( + os.path.join( + os.path.dirname(__file__), "..", "..", "scripts", "config.json" + ) + ) + with open(config_path, "r") as config_file: + config = json.load(config_file) + contract_address = None + for contract in config["contracts"]: + if contract["contract_name"] == "Multicall3": + contract_address = contract["contract_address"] + break + if not contract_address: + raise ValueError("Contract address for Multicall3 not found in config.json") + abi_path = os.path.join( + os.path.dirname(__file__), "..", "..", "abi", "jsons", "Multicall3.json" + ) + with open(abi_path, "r") as abi_file: + abi = json.load(abi_file) + self.contract = self.web3.eth.contract(address=contract_address, abi=abi) + + def aggregate3Value(self, calls): + return self.contract.functions.aggregate3Value(calls).transact() + + def build_aggregate3Value_transaction(self, calls, tx_params): + return self.contract.functions.aggregate3Value(calls).build_transaction( + tx_params + ) diff --git a/src/story_protocol_python_sdk/abi/SPGNFTImpl/SPGNFTImpl_client.py b/src/story_protocol_python_sdk/abi/SPGNFTImpl/SPGNFTImpl_client.py index 4490654e..e29db284 100644 --- a/src/story_protocol_python_sdk/abi/SPGNFTImpl/SPGNFTImpl_client.py +++ b/src/story_protocol_python_sdk/abi/SPGNFTImpl/SPGNFTImpl_client.py @@ -19,3 +19,6 @@ def mintFee(self): def mintFeeToken(self): return self.contract.functions.mintFeeToken().call() + + def publicMinting(self): + return self.contract.functions.publicMinting().call() diff --git a/src/story_protocol_python_sdk/abi/jsons/Multicall3.json b/src/story_protocol_python_sdk/abi/jsons/Multicall3.json new file mode 100644 index 00000000..b52a28f2 --- /dev/null +++ b/src/story_protocol_python_sdk/abi/jsons/Multicall3.json @@ -0,0 +1,260 @@ +[ + { + "type": "function", + "inputs": [ + { + "name": "calls", + "internalType": "struct Multicall3.Call[]", + "type": "tuple[]", + "components": [ + { "name": "target", "internalType": "address", "type": "address" }, + { "name": "callData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "name": "aggregate", + "outputs": [ + { "name": "blockNumber", "internalType": "uint256", "type": "uint256" }, + { "name": "returnData", "internalType": "bytes[]", "type": "bytes[]" } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "inputs": [ + { + "name": "calls", + "internalType": "struct Multicall3.Call3[]", + "type": "tuple[]", + "components": [ + { "name": "target", "internalType": "address", "type": "address" }, + { "name": "allowFailure", "internalType": "bool", "type": "bool" }, + { "name": "callData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "name": "aggregate3", + "outputs": [ + { + "name": "returnData", + "internalType": "struct Multicall3.Result[]", + "type": "tuple[]", + "components": [ + { "name": "success", "internalType": "bool", "type": "bool" }, + { "name": "returnData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "inputs": [ + { + "name": "calls", + "internalType": "struct Multicall3.Call3Value[]", + "type": "tuple[]", + "components": [ + { "name": "target", "internalType": "address", "type": "address" }, + { "name": "allowFailure", "internalType": "bool", "type": "bool" }, + { "name": "value", "internalType": "uint256", "type": "uint256" }, + { "name": "callData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "name": "aggregate3Value", + "outputs": [ + { + "name": "returnData", + "internalType": "struct Multicall3.Result[]", + "type": "tuple[]", + "components": [ + { "name": "success", "internalType": "bool", "type": "bool" }, + { "name": "returnData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "inputs": [ + { + "name": "calls", + "internalType": "struct Multicall3.Call[]", + "type": "tuple[]", + "components": [ + { "name": "target", "internalType": "address", "type": "address" }, + { "name": "callData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "name": "blockAndAggregate", + "outputs": [ + { "name": "blockNumber", "internalType": "uint256", "type": "uint256" }, + { "name": "blockHash", "internalType": "bytes32", "type": "bytes32" }, + { + "name": "returnData", + "internalType": "struct Multicall3.Result[]", + "type": "tuple[]", + "components": [ + { "name": "success", "internalType": "bool", "type": "bool" }, + { "name": "returnData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "inputs": [], + "name": "getBasefee", + "outputs": [ + { "name": "basefee", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [ + { "name": "blockNumber", "internalType": "uint256", "type": "uint256" } + ], + "name": "getBlockHash", + "outputs": [ + { "name": "blockHash", "internalType": "bytes32", "type": "bytes32" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [], + "name": "getBlockNumber", + "outputs": [ + { "name": "blockNumber", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [], + "name": "getChainId", + "outputs": [ + { "name": "chainid", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [], + "name": "getCurrentBlockCoinbase", + "outputs": [ + { "name": "coinbase", "internalType": "address", "type": "address" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [], + "name": "getCurrentBlockDifficulty", + "outputs": [ + { "name": "difficulty", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [], + "name": "getCurrentBlockGasLimit", + "outputs": [ + { "name": "gaslimit", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [], + "name": "getCurrentBlockTimestamp", + "outputs": [ + { "name": "timestamp", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [ + { "name": "addr", "internalType": "address", "type": "address" } + ], + "name": "getEthBalance", + "outputs": [ + { "name": "balance", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [], + "name": "getLastBlockHash", + "outputs": [ + { "name": "blockHash", "internalType": "bytes32", "type": "bytes32" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [ + { "name": "requireSuccess", "internalType": "bool", "type": "bool" }, + { + "name": "calls", + "internalType": "struct Multicall3.Call[]", + "type": "tuple[]", + "components": [ + { "name": "target", "internalType": "address", "type": "address" }, + { "name": "callData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "name": "tryAggregate", + "outputs": [ + { + "name": "returnData", + "internalType": "struct Multicall3.Result[]", + "type": "tuple[]", + "components": [ + { "name": "success", "internalType": "bool", "type": "bool" }, + { "name": "returnData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "inputs": [ + { "name": "requireSuccess", "internalType": "bool", "type": "bool" }, + { + "name": "calls", + "internalType": "struct Multicall3.Call[]", + "type": "tuple[]", + "components": [ + { "name": "target", "internalType": "address", "type": "address" }, + { "name": "callData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "name": "tryBlockAndAggregate", + "outputs": [ + { "name": "blockNumber", "internalType": "uint256", "type": "uint256" }, + { "name": "blockHash", "internalType": "bytes32", "type": "bytes32" }, + { + "name": "returnData", + "internalType": "struct Multicall3.Result[]", + "type": "tuple[]", + "components": [ + { "name": "success", "internalType": "bool", "type": "bool" }, + { "name": "returnData", "internalType": "bytes", "type": "bytes" } + ] + } + ], + "stateMutability": "payable" + } +] diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index e032bc36..fb6783e4 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -235,7 +235,7 @@ { "contract_name": "SPGNFTImpl", "contract_address": "0xc09e3788Fdfbd3dd8CDaa2aa481B52CcFAb74a42", - "functions": ["mintFeeToken", "mintFee"] + "functions": ["mintFeeToken", "mintFee", "publicMinting"] }, { "contract_name": "DerivativeWorkflows", @@ -246,6 +246,11 @@ "mintAndRegisterIpAndMakeDerivativeWithLicenseTokens", "registerIpAndMakeDerivativeWithLicenseTokens" ] + }, + { + "contract_name": "Multicall3", + "contract_address": "0xca11bde05977b3631167028862be2a173976ca11", + "functions": ["aggregate3Value"] } ] } From 3a11a675c5cc304531dc812fdef90509ab652eb3 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 23 Oct 2025 15:00:31 +0800 Subject: [PATCH 2/5] feat: implement batch minting and registration of IP assets --- src/story_protocol_python_sdk/__init__.py | 6 + .../resources/IPAsset.py | 120 ++++++++++++++---- .../scripts/config.json | 2 +- .../types/resource/IPAsset.py | 52 +++++++- 4 files changed, 152 insertions(+), 28 deletions(-) diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index 0211344f..728fe626 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -14,9 +14,12 @@ CollectRoyaltiesResponse, ) from .types.resource.IPAsset import ( + BatchMintAndRegisterIPInput, + BatchMintAndRegisterIPResponse, LicenseTermsDataInput, RegisterAndAttachAndDistributeRoyaltyTokensResponse, RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse, + RegisteredIP, RegisterPILTermsAndAttachResponse, RegistrationResponse, RegistrationWithRoyaltyVaultAndLicenseTermsResponse, @@ -55,6 +58,9 @@ "RegisterAndAttachAndDistributeRoyaltyTokensResponse", "RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse", "LicenseTermsDataInput", + "BatchMintAndRegisterIPInput", + "BatchMintAndRegisterIPResponse", + "RegisteredIP", "ClaimRewardsResponse", "ClaimReward", "CollectRoyaltiesResponse", diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 802e69e2..114ce4c6 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -35,6 +35,7 @@ from story_protocol_python_sdk.abi.LicensingModule.LicensingModule_client import ( LicensingModuleClient, ) +from story_protocol_python_sdk.abi.Multicall3.Multicall3_client import Multicall3Client from story_protocol_python_sdk.abi.PILicenseTemplate.PILicenseTemplate_client import ( PILicenseTemplateClient, ) @@ -50,9 +51,12 @@ from story_protocol_python_sdk.abi.SPGNFTImpl.SPGNFTImpl_client import SPGNFTImplClient from story_protocol_python_sdk.types.common import AccessPermission from story_protocol_python_sdk.types.resource.IPAsset import ( + BatchMintAndRegisterIPInput, + BatchMintAndRegisterIPResponse, LicenseTermsDataInput, RegisterAndAttachAndDistributeRoyaltyTokensResponse, RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse, + RegisteredIP, RegisterPILTermsAndAttachResponse, RegistrationResponse, RegistrationWithRoyaltyVaultAndLicenseTermsResponse, @@ -111,7 +115,7 @@ def __init__(self, web3: Web3, account, chain_id: int): RoyaltyTokenDistributionWorkflowsClient(web3) ) self.royalty_module_client = RoyaltyModuleClient(web3) - + self.multicall3_client = Multicall3Client(web3) self.license_terms_util = LicenseTerms(web3) self.sign_util = Sign(web3, self.chain_id, self.account) @@ -250,7 +254,9 @@ def register( tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] return {"tx_hash": response["tx_hash"], "ip_id": ip_registered["ip_id"]} @@ -473,7 +479,9 @@ def mint_and_register_ip_asset_with_pil_terms( tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] license_terms_ids = self._parse_tx_license_terms_attached_event( response["tx_receipt"] ) @@ -546,7 +554,9 @@ def mint_and_register_ip( tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] return { "tx_hash": response["tx_hash"], @@ -557,6 +567,51 @@ def mint_and_register_ip( except Exception as e: raise ValueError(f"Failed to mint and register IP: {str(e)}") + def batch_mint_and_register_ip( + self, + requests: list[BatchMintAndRegisterIPInput], + tx_options: dict | None = None, + ): + """ + Batch mints NFTs from SPGNFT collections and registers them as IP assets. + Optimizes transaction processing by grouping requests and Uses `RegistrationWorkflows's multicall` for minting contracts. + + :param requests list[BatchMintAndRegisterIPInput]: The list of batch mint and register IP requests. + :param tx_options: [Optional] The transaction options. + :return BatchMintAndRegisterIPResponse: A response with transaction hash and list of IP registered. + """ + try: + encoded_data = [] + for request in requests: + encoded_data.append( + self.registration_workflows_client.contract.encode_abi( + abi_element_identifier="mintAndRegisterIp", + args=[ + validate_address(request.spg_nft_contract), + self._validate_recipient(request.recipient), + IPMetadata.from_input( + request.ip_metadata + ).get_validated_data(), + request.allow_duplicates, + ], + ) + ) + response = build_and_send_transaction( + self.web3, + self.account, + self.registration_workflows_client.build_multicall_transaction, + encoded_data, + tx_options=tx_options, + ) + registered_ips = self._parse_tx_ip_registered_event(response["tx_receipt"]) + return BatchMintAndRegisterIPResponse( + tx_hash=response["tx_hash"], + registered_ips=registered_ips, + ) + + except Exception as e: + raise ValueError(f"Failed to batch mint and register IP: {str(e)}") + def register_ip_and_attach_pil_terms( self, nft_contract: str, @@ -685,7 +740,9 @@ def register_ip_and_attach_pil_terms( tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] license_terms_ids = self._parse_tx_license_terms_attached_event( response["tx_receipt"] ) @@ -774,7 +831,9 @@ def register_derivative_ip( tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] return {"tx_hash": response["tx_hash"], "ip_id": ip_registered["ip_id"]} @@ -817,7 +876,9 @@ def mint_and_register_ip_and_make_derivative( allow_duplicates, tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] return RegistrationResponse( tx_hash=response["tx_hash"], ip_id=ip_registered["ip_id"], @@ -866,7 +927,9 @@ def mint_and_register_ip_and_make_derivative_with_license_tokens( allow_duplicates, tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] return RegistrationResponse( tx_hash=response["tx_hash"], ip_id=ip_registered["ip_id"], @@ -961,7 +1024,9 @@ def register_ip_and_make_derivative_with_license_tokens( tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] return RegistrationResponse( tx_hash=response["tx_hash"], @@ -1015,7 +1080,9 @@ def mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] license_terms_ids = self._parse_tx_license_terms_attached_event( response["tx_receipt"] ) @@ -1077,7 +1144,9 @@ def mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] royalty_vault = self.get_royalty_vault_address_by_ip_id( response["tx_receipt"], ip_registered["ip_id"], @@ -1169,7 +1238,9 @@ def register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens( tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] royalty_vault = self.get_royalty_vault_address_by_ip_id( response["tx_receipt"], ip_registered["ip_id"], @@ -1277,7 +1348,9 @@ def register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( }, tx_options=tx_options, ) - ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])[ + 0 + ] license_terms_ids = self._parse_tx_license_terms_attached_event( response["tx_receipt"] ) @@ -1558,7 +1631,7 @@ def _is_registered(self, ip_id: str) -> bool: """ return self.ip_asset_registry_client.isRegistered(ip_id) - def _parse_tx_ip_registered_event(self, tx_receipt: dict) -> dict: + def _parse_tx_ip_registered_event(self, tx_receipt: dict) -> list[RegisteredIP]: """ Parse the IPRegistered event from a transaction receipt. @@ -1568,16 +1641,19 @@ def _parse_tx_ip_registered_event(self, tx_receipt: dict) -> dict: event_signature = self.web3.keccak( text="IPRegistered(address,uint256,address,uint256,string,string,uint256)" ).hex() + registered_ips: list[RegisteredIP] = [] for log in tx_receipt["logs"]: if log["topics"][0].hex() == event_signature: - ip_id = "0x" + log["data"].hex()[24:64] - token_id = int(log["topics"][3].hex(), 16) - - return { - "ip_id": self.web3.to_checksum_address(ip_id), - "token_id": token_id, - } - raise ValueError("IPRegistered event not found in transaction receipt.") + event_result = self.ip_asset_registry_client.contract.events.IPRegistered.process_log( + log + ) + registered_ips.append( + RegisteredIP( + ip_id=event_result["args"]["ipId"], + token_id=event_result["args"]["tokenId"], + ) + ) + return registered_ips def _parse_tx_license_term_attached_event(self, tx_receipt: dict) -> int | None: """ diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index fb6783e4..3f60aa98 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -249,7 +249,7 @@ }, { "contract_name": "Multicall3", - "contract_address": "0xca11bde05977b3631167028862be2a173976ca11", + "contract_address": "0xcA11bde05977b3631167028862bE2a173976CA11", "functions": ["aggregate3Value"] } ] diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py index 6ec66f24..496227f9 100644 --- a/src/story_protocol_python_sdk/types/resource/IPAsset.py +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -4,22 +4,33 @@ from ens.ens import Address, HexStr from story_protocol_python_sdk.types.resource.License import LicenseTermsInput +from story_protocol_python_sdk.utils.ip_metadata import IPMetadataInput from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfig -class RegistrationResponse(TypedDict): +class RegisteredIP(TypedDict): + """ + Data structure for IP and token ID. + + Attributes: + ip_id: The IP ID of the registered IP asset. + token_id: The token ID of the registered IP asset + """ + + ip_id: Address + token_id: int + + +class RegistrationResponse(RegisteredIP): """ Response structure for IP asset registration operations. + Extends `RegisteredIP` with transaction hash. Attributes: - ip_id: The IP ID of the registered IP asset tx_hash: The transaction hash of the registration transaction - token_id: [Optional] The token ID of the registered IP asset """ - ip_id: Address tx_hash: HexStr - token_id: int class RegistrationWithRoyaltyVaultResponse(RegistrationResponse): @@ -107,3 +118,34 @@ class LicenseTermsDataInput: terms: LicenseTermsInput licensing_config: LicensingConfig + + +@dataclass +class BatchMintAndRegisterIPInput: + """ + Data structure for batch mint and register IP. + + Attributes: + spg_nft_contract: The address of the SPGNFT collection. + recipient: [Optional] The address of the recipient of the minted NFT, + ip_metadata: [Optional] The desired metadata for the newly minted NFT and newly registered IP. + allow_duplicates: [Optional] Set to true to allow minting an NFT with a duplicate metadata hash. (default: True) + """ + + spg_nft_contract: Address + ip_metadata: IPMetadataInput | None = None + allow_duplicates: bool = True + recipient: Address | None = None + + +class BatchMintAndRegisterIPResponse(TypedDict): + """ + Response structure for batch mint and register IP. + + Attributes: + tx_hash: The transaction hash of the batch mint and register IP transaction. + registered_ips: The list of registered IP. + """ + + tx_hash: HexStr + registered_ips: list[RegisteredIP] From 3247d6c0e95469ba3a45240eefce22f3151e5139 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 23 Oct 2025 16:20:49 +0800 Subject: [PATCH 3/5] feat: add test suite for batch minting and registering IP assets --- .../integration/test_integration_ip_asset.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 24e40f96..a36558bd 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -16,6 +16,7 @@ from story_protocol_python_sdk.abi.LicenseToken.LicenseToken_client import ( LicenseTokenClient, ) +from story_protocol_python_sdk.types.resource.IPAsset import BatchMintAndRegisterIPInput from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfig from tests.integration.config.test_config import account_2 from tests.integration.config.utils import approve @@ -1064,3 +1065,91 @@ def test_mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens and len(response["license_terms_ids"]) > 0 ) assert isinstance(response["royalty_vault"], str) and response["royalty_vault"] + + +class TestBatchMethods: + """Test suite for batch minting and registering IP assets""" + + @pytest.fixture(scope="class") + def public_nft_collection(self, story_client: StoryClient): + """Fixture for public minting NFT collection""" + tx_data = story_client.NFTClient.create_nft_collection( + name="test-public-batch-collection", + symbol="PUBATCH", + max_supply=100, + is_public_minting=True, + mint_open=True, + contract_uri="test-public-batch-uri", + mint_fee_recipient=account.address, + ) + return tx_data["nft_contract"] + + @pytest.fixture(scope="class") + def private_nft_collection(self, story_client: StoryClient): + """Fixture for private minting NFT collection""" + tx_data = story_client.NFTClient.create_nft_collection( + name="test-private-batch-collection", + symbol="private-batch", + max_supply=100, + is_public_minting=False, + mint_open=True, + contract_uri="test-private-batch-uri", + mint_fee_recipient=account.address, + ) + return tx_data["nft_contract"] + + def test_batch_mint_and_register_ip( + self, story_client: StoryClient, public_nft_collection, private_nft_collection + ): + """Test batch minting and registering IP with mixed public and private minting contracts""" + + requests = [ + # Public minting contract - default values + BatchMintAndRegisterIPInput( + spg_nft_contract=public_nft_collection, + ), + # Public minting contract - custom recipient and allow_duplicates + BatchMintAndRegisterIPInput( + spg_nft_contract=public_nft_collection, + recipient=account_2.address, + allow_duplicates=True, + ), + # Public minting contract - custom metadata + BatchMintAndRegisterIPInput( + spg_nft_contract=public_nft_collection, + recipient=account_2.address, + ip_metadata=IPMetadataInput( + nft_metadata_hash=web3.keccak(text="public-custom-nft-metadata"), + ), + allow_duplicates=False, + ), + # Private minting contract - default values + BatchMintAndRegisterIPInput( + spg_nft_contract=private_nft_collection, + ), + # Private minting contract - custom recipient + BatchMintAndRegisterIPInput( + spg_nft_contract=private_nft_collection, + recipient=account_2.address, + ip_metadata=IPMetadataInput( + nft_metadata_hash=web3.keccak(text="private-custom-nft-metadata1"), + ), + allow_duplicates=False, + ), + # Private minting contract - custom metadata + BatchMintAndRegisterIPInput( + spg_nft_contract=private_nft_collection, + ip_metadata=IPMetadataInput( + ip_metadata_hash=web3.keccak(text="private-custom-metadata2"), + ip_metadata_uri="https://example.com/private-metadata.json", + ), + ), + ] + response = story_client.IPAsset.batch_mint_and_register_ip(requests) + assert isinstance(response["tx_hash"], str) and response["tx_hash"] + assert isinstance(response["registered_ips"], list) and len( + response["registered_ips"] + ) == len(requests) + for ip_registered in response["registered_ips"]: + assert isinstance(ip_registered["ip_id"], str) and ip_registered["ip_id"] + assert isinstance(ip_registered["token_id"], int) From 18320c097c8c7c4ee1ba451b0d49f32c87b62f3b Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 23 Oct 2025 16:54:13 +0800 Subject: [PATCH 4/5] feat: add unit tests for batch minting and registration of IP assets with default and custom values --- tests/unit/resources/test_ip_asset.py | 197 +++++++++++++++++++++++++- 1 file changed, 196 insertions(+), 1 deletion(-) diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index 0564dcdb..4aa709d7 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -8,6 +8,7 @@ IPAccountImplClient, ) from story_protocol_python_sdk.resources.IPAsset import IPAsset +from story_protocol_python_sdk.types.resource.IPAsset import BatchMintAndRegisterIPInput from story_protocol_python_sdk.utils.derivative_data import DerivativeDataInput from story_protocol_python_sdk.utils.ip_metadata import IPMetadata, IPMetadataInput from story_protocol_python_sdk.utils.royalty import get_royalty_shares @@ -59,7 +60,10 @@ def _mock(): return patch.object( ip_asset, "_parse_tx_ip_registered_event", - return_value={"ip_id": IP_ID, "token_id": 3}, + return_value=[ + {"ip_id": IP_ID, "token_id": 3}, + {"ip_id": ADDRESS, "token_id": 4}, + ], ) return _mock @@ -1871,3 +1875,194 @@ def test_success_with_tx_options( tx_options=tx_options, ) assert mock_build_and_send.call_args[1]["tx_options"] == tx_options + + +class TestBatchMintAndRegisterIP: + """Test batch_mint_and_register_ip method with focus on encode_abi usage.""" + + def test_batch_mint_with_default_values( + self, ip_asset: IPAsset, mock_parse_ip_registered_event + ): + """Test batch mint with default values - verify encode_abi is called with correct default parameters.""" + requests = [ + BatchMintAndRegisterIPInput(spg_nft_contract=ADDRESS), + BatchMintAndRegisterIPInput(spg_nft_contract=ADDRESS), + ] + + with mock_parse_ip_registered_event(): + with patch.object( + ip_asset.registration_workflows_client.contract, + "encode_abi", + return_value=b"encoded_data", + ) as mock_encode_abi: + result = ip_asset.batch_mint_and_register_ip(requests=requests) + + # Verify encode_abi was called twice (once per request) + assert mock_encode_abi.call_count == 2 + + # Verify first call with default values + first_call_args = mock_encode_abi.call_args_list[0] + assert ( + first_call_args[1]["abi_element_identifier"] == "mintAndRegisterIp" + ) + args = first_call_args[1]["args"] + assert args[0] == ADDRESS # spg_nft_contract + assert args[1] == ACCOUNT_ADDRESS # recipient (default) + assert ( + args[2] == IPMetadata.from_input().get_validated_data() + ) # metadata (default) + assert args[3] is True # allow_duplicates (default) + + # Verify result + assert result["tx_hash"] == TX_HASH.hex() + assert len(result["registered_ips"]) == 2 + assert result["registered_ips"][0]["ip_id"] == IP_ID + assert result["registered_ips"][0]["token_id"] == 3 + assert result["registered_ips"][1]["ip_id"] == ADDRESS + assert result["registered_ips"][1]["token_id"] == 4 + + def test_batch_mint_with_custom_values( + self, ip_asset: IPAsset, mock_parse_ip_registered_event + ): + """Test batch mint with custom values - verify encode_abi is called with custom parameters.""" + custom_recipient = "0x9876543210987654321098765432109876543210" + + requests = [ + BatchMintAndRegisterIPInput( + spg_nft_contract=ADDRESS, + ip_metadata=IP_METADATA, + recipient=custom_recipient, + allow_duplicates=True, + ), + BatchMintAndRegisterIPInput( + spg_nft_contract=ADDRESS, + ip_metadata=IP_METADATA, + recipient=ADDRESS, + allow_duplicates=False, + ), + ] + + with mock_parse_ip_registered_event(): + with patch.object( + ip_asset.registration_workflows_client.contract, + "encode_abi", + return_value=b"encoded_data", + ) as mock_encode_abi: + result = ip_asset.batch_mint_and_register_ip(requests=requests) + + # Verify encode_abi was called twice + assert mock_encode_abi.call_count == 2 + second_call_args = mock_encode_abi.call_args_list[1] + assert ( + second_call_args[1]["abi_element_identifier"] == "mintAndRegisterIp" + ) + args = second_call_args[1]["args"] + assert args[0] == ADDRESS # spg_nft_contract + assert args[1] == ADDRESS # recipient + assert ( + args[2] == IPMetadata.from_input(IP_METADATA).get_validated_data() + ) # metadata + assert args[3] is False # allow_duplicates + + # Verify result + assert result["tx_hash"] == TX_HASH.hex() + assert len(result["registered_ips"]) == 2 + + def test_batch_mint_with_tx_options( + self, ip_asset: IPAsset, mock_parse_ip_registered_event + ): + """Test batch mint with transaction options.""" + tx_options = {"gas": 500000, "gasPrice": 1000000000} + requests = [ + BatchMintAndRegisterIPInput(spg_nft_contract=ADDRESS), + ] + + with mock_parse_ip_registered_event(): + with patch( + "story_protocol_python_sdk.resources.IPAsset.build_and_send_transaction" + ) as mock_build_and_send: + mock_build_and_send.return_value = { + "tx_hash": TX_HASH.hex(), + "tx_receipt": {"status": 1, "logs": []}, + } + result = ip_asset.batch_mint_and_register_ip( + requests=requests, tx_options=tx_options + ) + + # Verify tx_options were passed + assert mock_build_and_send.call_args[1]["tx_options"] == tx_options + assert result["tx_hash"] == TX_HASH.hex() + + def test_batch_mint_empty_requests(self, ip_asset: IPAsset): + """Test batch mint with empty requests list.""" + with patch.object( + ip_asset.registration_workflows_client.contract, + "encode_abi", + ) as mock_encode_abi: + with patch( + "story_protocol_python_sdk.resources.IPAsset.build_and_send_transaction", + return_value={ + "tx_hash": TX_HASH.hex(), + "tx_receipt": {"status": 1, "logs": []}, + }, + ): + result = ip_asset.batch_mint_and_register_ip(requests=[]) + + # encode_abi should not be called for empty requests + mock_encode_abi.assert_not_called() + assert result["tx_hash"] == TX_HASH.hex() + assert result["registered_ips"] == [] + + def test_batch_mint_transaction_failed(self, ip_asset: IPAsset): + """Test batch mint when transaction fails.""" + requests = [ + BatchMintAndRegisterIPInput(spg_nft_contract=ADDRESS), + ] + + with patch.object( + ip_asset.registration_workflows_client.contract, + "encode_abi", + return_value=b"encoded_data", + ): + with patch( + "story_protocol_python_sdk.resources.IPAsset.build_and_send_transaction", + side_effect=Exception("Transaction failed"), + ): + with pytest.raises( + ValueError, + match="Failed to batch mint and register IP: Transaction failed", + ): + ip_asset.batch_mint_and_register_ip(requests=requests) + + def test_batch_mint_transaction_success( + self, ip_asset: IPAsset, mock_parse_ip_registered_event + ): + """Test batch mint transaction success.""" + requests = [ + BatchMintAndRegisterIPInput(spg_nft_contract=ADDRESS), + BatchMintAndRegisterIPInput(spg_nft_contract=ADDRESS), + ] + + with mock_parse_ip_registered_event(): + with patch.object( + ip_asset.registration_workflows_client.contract, + "encode_abi", + return_value=b"encoded_data", + ): + with patch.object( + ip_asset.registration_workflows_client, + "build_multicall_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_multicall_transaction: + result = ip_asset.batch_mint_and_register_ip(requests=requests) + + print(mock_build_multicall_transaction.call_args[0][0]) + assert mock_build_multicall_transaction.call_args[0][0] == [ + b"encoded_data", + b"encoded_data", + ] + assert result["tx_hash"] == TX_HASH.hex() + assert len(result["registered_ips"]) == 2 + assert result["registered_ips"][0]["ip_id"] == IP_ID + assert result["registered_ips"][0]["token_id"] == 3 + assert result["registered_ips"][1]["ip_id"] == ADDRESS From 0030bd5f9e66d4487b391f1e4ea3dff14904f766 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 23 Oct 2025 17:06:02 +0800 Subject: [PATCH 5/5] refactor: update IPAsset documentation and remove unused function in config.json --- src/story_protocol_python_sdk/resources/IPAsset.py | 4 ++-- src/story_protocol_python_sdk/scripts/config.json | 2 +- src/story_protocol_python_sdk/types/resource/IPAsset.py | 2 +- tests/integration/test_integration_ip_asset.py | 6 +++--- tests/unit/resources/test_ip_asset.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 114ce4c6..855bce4d 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -571,14 +571,14 @@ def batch_mint_and_register_ip( self, requests: list[BatchMintAndRegisterIPInput], tx_options: dict | None = None, - ): + ) -> BatchMintAndRegisterIPResponse: """ Batch mints NFTs from SPGNFT collections and registers them as IP assets. Optimizes transaction processing by grouping requests and Uses `RegistrationWorkflows's multicall` for minting contracts. :param requests list[BatchMintAndRegisterIPInput]: The list of batch mint and register IP requests. :param tx_options: [Optional] The transaction options. - :return BatchMintAndRegisterIPResponse: A response with transaction hash and list of IP registered. + :return `BatchMintAndRegisterIPResponse`: A response with transaction hash and list of `RegisteredIP` which includes IP ID and token ID. """ try: encoded_data = [] diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index 3f60aa98..235b2111 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -235,7 +235,7 @@ { "contract_name": "SPGNFTImpl", "contract_address": "0xc09e3788Fdfbd3dd8CDaa2aa481B52CcFAb74a42", - "functions": ["mintFeeToken", "mintFee", "publicMinting"] + "functions": ["mintFeeToken", "mintFee"] }, { "contract_name": "DerivativeWorkflows", diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py index 496227f9..c325f927 100644 --- a/src/story_protocol_python_sdk/types/resource/IPAsset.py +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -144,7 +144,7 @@ class BatchMintAndRegisterIPResponse(TypedDict): Attributes: tx_hash: The transaction hash of the batch mint and register IP transaction. - registered_ips: The list of registered IP. + registered_ips: The list of `RegisteredIP` which includes IP ID and token ID. """ tx_hash: HexStr diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index a36558bd..8ad65477 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -3,10 +3,12 @@ from story_protocol_python_sdk import ( ZERO_ADDRESS, ZERO_HASH, + BatchMintAndRegisterIPInput, DerivativeDataInput, IPMetadataInput, LicenseTermsDataInput, LicenseTermsInput, + LicensingConfig, RoyaltyShareInput, StoryClient, ) @@ -16,8 +18,6 @@ from story_protocol_python_sdk.abi.LicenseToken.LicenseToken_client import ( LicenseTokenClient, ) -from story_protocol_python_sdk.types.resource.IPAsset import BatchMintAndRegisterIPInput -from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfig from tests.integration.config.test_config import account_2 from tests.integration.config.utils import approve @@ -1068,7 +1068,7 @@ def test_mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens class TestBatchMethods: - """Test suite for batch minting and registering IP assets""" + """Test suite for batch methods""" @pytest.fixture(scope="class") def public_nft_collection(self, story_client: StoryClient): diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index 4aa709d7..90ba00ba 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -1878,7 +1878,7 @@ def test_success_with_tx_options( class TestBatchMintAndRegisterIP: - """Test batch_mint_and_register_ip method with focus on encode_abi usage.""" + """Test batch_mint_and_register_ip method.""" def test_batch_mint_with_default_values( self, ip_asset: IPAsset, mock_parse_ip_registered_event