Skip to content

Commit 5ea0515

Browse files
feat(IPAsset): add batch_mint_and_register_ip_asset_with_pil_terms method (#201)
Implement batch minting and registering of IP assets with PIL terms attached, based on TypeScript SDK implementation. This method uses multicall to batch multiple mint and register operations in a single transaction. Changes: - Add batch_mint_and_register_ip_asset_with_pil_terms to IPAsset resource - Support encodedTxDataOnly option in mint_and_register_ip_asset_with_pil_terms for multicall batching - Add _parse_tx_license_terms_attached_event_for_ip helper to parse license terms for specific IPs - Add unit tests covering successful batch minting, metadata, recipient, and empty args scenarios - Add integration tests for batch minting with PIL terms and metadata/recipient options - Use allow_duplicates=True in tests to handle duplicate license terms in same NFT collection
1 parent 558e5cd commit 5ea0515

3 files changed

Lines changed: 600 additions & 0 deletions

File tree

src/story_protocol_python_sdk/resources/IPAsset.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,18 @@ def mint_and_register_ip_asset_with_pil_terms(
704704
}
705705
)
706706

707+
# Check if only encoded transaction data is requested
708+
if tx_options and tx_options.get("encodedTxDataOnly"):
709+
# Build transaction to get encoded data
710+
tx_data = self.license_attachment_workflows_client.contract.functions.mintAndRegisterIpAndAttachPILTerms(
711+
spg_nft_contract,
712+
self._validate_recipient(recipient),
713+
metadata,
714+
license_terms,
715+
allow_duplicates,
716+
).build_transaction({"from": self.account.address, "gas": 0})
717+
return {"encoded_tx_data": tx_data["data"]}
718+
707719
response = build_and_send_transaction(
708720
self.web3,
709721
self.account,
@@ -733,6 +745,120 @@ def mint_and_register_ip_asset_with_pil_terms(
733745
except Exception as e:
734746
raise e
735747

748+
def batch_mint_and_register_ip_asset_with_pil_terms(
749+
self,
750+
args: list[dict],
751+
tx_options: dict | None = None,
752+
) -> dict:
753+
"""
754+
Batch mint NFTs from collections and register them as IPs with PIL terms attached.
755+
756+
:param args list[dict]: List of mint and register configurations, each containing:
757+
:param spg_nft_contract str: The address of the NFT collection.
758+
:param terms list: An array of license terms to attach.
759+
:param terms dict: The license terms configuration.
760+
:param transferable bool: Transferability of the license.
761+
:param royalty_policy str: Address of the royalty policy contract.
762+
:param default_minting_fee int: Fee for minting a license.
763+
:param expiration int: License expiration.
764+
:param commercial_use bool: Whether commercial use is allowed.
765+
:param commercial_attribution bool: Whether attribution is needed for commercial use.
766+
:param commercializer_checker str: Allowed commercializers or zero address for none.
767+
:param commercializer_checker_data str: Data for checker contract.
768+
:param commercial_rev_share int: Percentage of revenue that must be shared with the licensor. Must be between 0 and 100 (where 100% represents 100,000,000).
769+
:param commercial_rev_ceiling int: Maximum commercial revenue.
770+
:param derivatives_allowed bool: Whether derivatives are allowed.
771+
:param derivatives_attribution bool: Whether attribution is needed for derivatives.
772+
:param derivatives_approval bool: Whether licensor approval is required for derivatives.
773+
:param derivatives_reciprocal bool: Whether derivatives must use the same license terms.
774+
:param derivative_rev_ceiling int: Max derivative revenue.
775+
:param currency str: ERC20 token for the minting fee.
776+
:param uri str: URI for offchain license terms.
777+
:param licensing_config dict: The configuration for the license.
778+
:param is_set bool: Whether the configuration is set or not.
779+
:param minting_fee int: The fee to be paid when minting tokens.
780+
:param hook_data str: The data used by the licensing hook.
781+
:param licensing_hook str: The licensing hook contract address or address(0) if none.
782+
:param commercial_rev_share int: Percentage of revenue that must be shared with the licensor. Must be between 0 and 100 (where 100% represents 100,000,000).
783+
:param disabled bool: Whether the license is disabled.
784+
:param expect_minimum_group_reward_share int: Minimum group reward share percentage. Must be between 0 and 100 (where 100% represents 100,000,000).
785+
:param expect_group_reward_pool str: Address of the expected group reward pool.
786+
:param ip_metadata dict: [Optional] NFT and IP metadata.
787+
:param ip_metadata_uri str: [Optional] IP metadata URI.
788+
:param ip_metadata_hash str: [Optional] IP metadata hash.
789+
:param nft_metadata_uri str: [Optional] NFT metadata URI.
790+
:param nft_metadata_hash str: [Optional] NFT metadata hash.
791+
:param recipient str: [Optional] Recipient address (defaults to caller).
792+
:param allow_duplicates bool: [Optional] Whether to allow duplicates.
793+
:param tx_options dict: [Optional] Transaction options.
794+
:return dict: Dictionary with tx hash and list of results for each minted IP.
795+
:return tx_hash str: The transaction hash.
796+
:return results list[dict]: List of results, each containing:
797+
:return ip_id str: The ID of the registered IP.
798+
:return token_id int: The ID of the minted NFT.
799+
:return spg_nft_contract str: The address of the NFT collection.
800+
:return license_terms_ids list[int]: The IDs of the attached license terms.
801+
"""
802+
try:
803+
# Encode all mint and register calls
804+
calldata = []
805+
for arg in args:
806+
# Use encodedTxDataOnly to get the encoded transaction data
807+
arg_with_encoded_option = arg.copy()
808+
arg_with_encoded_option["tx_options"] = {"encodedTxDataOnly": True}
809+
810+
result = self.mint_and_register_ip_asset_with_pil_terms(
811+
spg_nft_contract=arg["spg_nft_contract"],
812+
terms=arg["terms"],
813+
ip_metadata=arg.get("ip_metadata"),
814+
recipient=arg.get("recipient"),
815+
allow_duplicates=arg.get("allow_duplicates", False),
816+
tx_options=arg_with_encoded_option["tx_options"],
817+
)
818+
calldata.append(result["encoded_tx_data"])
819+
820+
# Send multicall transaction
821+
response = build_and_send_transaction(
822+
self.web3,
823+
self.account,
824+
self.license_attachment_workflows_client.build_multicall_transaction,
825+
calldata,
826+
tx_options=tx_options,
827+
)
828+
829+
# Parse IPRegistered events with full details
830+
event_signature = self.web3.keccak(
831+
text="IPRegistered(address,uint256,address,uint256,string,string,uint256)"
832+
).hex()
833+
834+
results = []
835+
for log in response["tx_receipt"]["logs"]:
836+
if log["topics"][0].hex() == event_signature:
837+
event_result = self.ip_asset_registry_client.contract.events.IPRegistered.process_log(log)
838+
ip_id = self.web3.to_checksum_address(event_result["args"]["ipId"])
839+
token_id = event_result["args"]["tokenId"]
840+
token_contract = self.web3.to_checksum_address(event_result["args"]["tokenContract"])
841+
842+
# Parse license terms for this IP
843+
license_terms_ids = self._parse_tx_license_terms_attached_event_for_ip(
844+
response["tx_receipt"], ip_id
845+
)
846+
847+
results.append({
848+
"ip_id": ip_id,
849+
"token_id": token_id,
850+
"spg_nft_contract": token_contract,
851+
"license_terms_ids": license_terms_ids,
852+
})
853+
854+
return {
855+
"tx_hash": response["tx_hash"],
856+
"results": results,
857+
}
858+
859+
except Exception as e:
860+
raise ValueError(f"Failed to batch mint and register IP and attach PIL terms: {str(e)}")
861+
736862
@deprecated("Use register_ip_asset() instead.")
737863
def mint_and_register_ip(
738864
self,
@@ -2361,6 +2487,31 @@ def _parse_tx_license_terms_attached_event(self, tx_receipt: dict) -> list[int]:
23612487

23622488
return license_terms_ids
23632489

2490+
def _parse_tx_license_terms_attached_event_for_ip(self, tx_receipt: dict, ip_id: str) -> list[int]:
2491+
"""
2492+
Parse the LicenseTermsAttached events for a specific IP from a transaction receipt.
2493+
2494+
:param tx_receipt dict: The transaction receipt.
2495+
:param ip_id str: The IP ID to filter events for.
2496+
:return list: A list of license terms IDs for the specified IP.
2497+
"""
2498+
event_signature = self.web3.keccak(
2499+
text="LicenseTermsAttached(address,address,address,uint256)"
2500+
).hex()
2501+
license_terms_ids = []
2502+
2503+
for log in tx_receipt["logs"]:
2504+
if log["topics"][0].hex() == event_signature:
2505+
# Parse the full event to get ipId
2506+
event_result = self.licensing_module_client.contract.events.LicenseTermsAttached.process_log(log)
2507+
log_ip_id = event_result["args"]["ipId"]
2508+
2509+
if log_ip_id.lower() == ip_id.lower():
2510+
license_terms_id = event_result["args"]["licenseTermsId"]
2511+
license_terms_ids.append(license_terms_id)
2512+
2513+
return license_terms_ids
2514+
23642515
def get_royalty_vault_address_by_ip_id(
23652516
self, tx_receipt: dict, ipId: Address
23662517
) -> Address:

tests/integration/test_integration_ip_asset.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,135 @@ def test_batch_mint_and_register_ip(
11861186
assert isinstance(ip_registered["ip_id"], str) and ip_registered["ip_id"]
11871187
assert isinstance(ip_registered["token_id"], int)
11881188

1189+
def test_batch_mint_and_register_ip_asset_with_pil_terms(
1190+
self, story_client: StoryClient, public_nft_collection
1191+
):
1192+
"""Test batch minting and registering IP with PIL terms"""
1193+
1194+
# Define license terms template
1195+
license_terms_template = {
1196+
"terms": {
1197+
"transferable": True,
1198+
"royalty_policy": ROYALTY_POLICY,
1199+
"default_minting_fee": 100,
1200+
"expiration": 0,
1201+
"commercial_use": True,
1202+
"commercial_attribution": False,
1203+
"commercializer_checker": ZERO_ADDRESS,
1204+
"commercializer_checker_data": ZERO_ADDRESS,
1205+
"commercial_rev_share": 10,
1206+
"commercial_rev_ceiling": 0,
1207+
"derivatives_allowed": True,
1208+
"derivatives_attribution": True,
1209+
"derivatives_approval": False,
1210+
"derivatives_reciprocal": True,
1211+
"derivative_rev_ceiling": 0,
1212+
"currency": WIP_TOKEN_ADDRESS,
1213+
"uri": "",
1214+
},
1215+
"licensing_config": {
1216+
"is_set": True,
1217+
"minting_fee": 100,
1218+
"hook_data": ZERO_ADDRESS,
1219+
"licensing_hook": ZERO_ADDRESS,
1220+
"commercial_rev_share": 0,
1221+
"disabled": False,
1222+
"expect_minimum_group_reward_share": 0,
1223+
"expect_group_reward_pool": ZERO_ADDRESS,
1224+
},
1225+
}
1226+
1227+
# Test with two IPs (use allow_duplicates=True to avoid duplicate license terms error)
1228+
response = story_client.IPAsset.batch_mint_and_register_ip_asset_with_pil_terms(
1229+
args=[
1230+
{
1231+
"spg_nft_contract": public_nft_collection,
1232+
"terms": [license_terms_template],
1233+
"allow_duplicates": True,
1234+
},
1235+
{
1236+
"spg_nft_contract": public_nft_collection,
1237+
"terms": [license_terms_template],
1238+
"allow_duplicates": True,
1239+
},
1240+
]
1241+
)
1242+
1243+
# Verify response structure
1244+
assert isinstance(response["tx_hash"], str) and response["tx_hash"]
1245+
assert isinstance(response["results"], list)
1246+
assert len(response["results"]) == 2
1247+
1248+
# Verify each result
1249+
for result in response["results"]:
1250+
assert isinstance(result["ip_id"], str) and result["ip_id"]
1251+
assert isinstance(result["token_id"], int)
1252+
assert isinstance(result["spg_nft_contract"], str)
1253+
assert isinstance(result["license_terms_ids"], list)
1254+
assert len(result["license_terms_ids"]) >= 1
1255+
1256+
# Verify IPs are registered
1257+
for result in response["results"]:
1258+
is_registered = story_client.IPAsset.is_registered(result["ip_id"])
1259+
assert is_registered is True
1260+
1261+
def test_batch_mint_with_metadata_and_recipient(
1262+
self, story_client: StoryClient, public_nft_collection
1263+
):
1264+
"""Test batch minting with metadata and custom recipient"""
1265+
1266+
license_terms_template = {
1267+
"terms": {
1268+
"transferable": True,
1269+
"royalty_policy": ROYALTY_POLICY,
1270+
"default_minting_fee": 100,
1271+
"expiration": 0,
1272+
"commercial_use": True,
1273+
"commercial_attribution": False,
1274+
"commercializer_checker": ZERO_ADDRESS,
1275+
"commercializer_checker_data": ZERO_ADDRESS,
1276+
"commercial_rev_share": 10,
1277+
"commercial_rev_ceiling": 0,
1278+
"derivatives_allowed": True,
1279+
"derivatives_attribution": True,
1280+
"derivatives_approval": False,
1281+
"derivatives_reciprocal": True,
1282+
"derivative_rev_ceiling": 0,
1283+
"currency": WIP_TOKEN_ADDRESS,
1284+
"uri": "",
1285+
},
1286+
"licensing_config": {
1287+
"is_set": True,
1288+
"minting_fee": 100,
1289+
"hook_data": ZERO_ADDRESS,
1290+
"licensing_hook": ZERO_ADDRESS,
1291+
"commercial_rev_share": 0,
1292+
"disabled": False,
1293+
"expect_minimum_group_reward_share": 0,
1294+
"expect_group_reward_pool": ZERO_ADDRESS,
1295+
},
1296+
}
1297+
1298+
response = story_client.IPAsset.batch_mint_and_register_ip_asset_with_pil_terms(
1299+
args=[
1300+
{
1301+
"spg_nft_contract": public_nft_collection,
1302+
"terms": [license_terms_template],
1303+
"ip_metadata": {
1304+
"ip_metadata_uri": "https://example.com/ip1",
1305+
"ip_metadata_hash": web3.keccak(text="ip1-metadata"),
1306+
},
1307+
"recipient": account_2.address,
1308+
"allow_duplicates": True,
1309+
}
1310+
]
1311+
)
1312+
1313+
assert isinstance(response["tx_hash"], str) and response["tx_hash"]
1314+
assert len(response["results"]) == 1
1315+
assert isinstance(response["results"][0]["ip_id"], str)
1316+
assert isinstance(response["results"][0]["license_terms_ids"], list)
1317+
11891318

11901319
class TestRegisterIpAsset:
11911320
"""Test suite for the unified register_ip_asset method that supports 6 different workflows"""

0 commit comments

Comments
 (0)