Skip to content

Commit de678f5

Browse files
feat(Group): add add_ips_to_group and remove_ips_from_group (#196)
* feat(Group): add add_ips_to_group and remove_ips_from_group - GroupingModule client: add removeIp and build_removeIp_transaction - Group: add add_ips_to_group(group_ip_id, ip_ids, max_allowed_reward_share_percentage=100, tx_options) - Group: add remove_ips_from_group(group_ip_id, ip_ids, tx_options) - Unit tests for both methods (invalid args, success, default/zero reward share, tx failure) - Integration tests: add_ips_to_group, remove_ips_from_group, add then remove * refactor: strict integration tests + tx utils refactor - Group: return tx_receipt in add_ips_to_group/remove_ips_from_group; add get_added_ip_to_group_events and get_removed_ip_from_group_events for chain verification - Integration tests: verify on-chain state via AddedIpToGroup/RemovedIpFromGroup events and get_claimable_reward - transaction_utils: refactor build_and_send_transaction (extract helpers, retry on replacement underpriced/nonce too low with same nonce + higher gas); add nonce validation in encodedTxDataOnly path * feat(Group): add contract-level validations for addIp and removeIp - addIp: validate groupId/ipIds not disputed, ipIds not registered groups - DisputeModuleClient.isIpTagged, IPAssetRegistryClient.isRegisteredGroup - removeIp: validate group not frozen (no derivative IPs, no minted license tokens) - LicenseRegistryClient.hasDerivativeIps, LicenseTokenClient.getTotalTokensByLicensor - Add LicenseTokenClient and DisputeModuleClient to Group - Unit tests for new validations
1 parent 0e6b2d8 commit de678f5

File tree

9 files changed

+860
-43
lines changed

9 files changed

+860
-43
lines changed

src/story_protocol_python_sdk/abi/DisputeModule/DisputeModule_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,8 @@ def build_tagIfRelatedIpInfringed_transaction(
7171
ipIdToTag, infringerDisputeId
7272
).build_transaction(tx_params)
7373

74+
def isIpTagged(self, ipId):
75+
return self.contract.functions.isIpTagged(ipId).call()
76+
7477
def isWhitelistedDisputeTag(self, tag):
7578
return self.contract.functions.isWhitelistedDisputeTag(tag).call()

src/story_protocol_python_sdk/abi/GroupingModule/GroupingModule_client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ def build_addIp_transaction(
4343
groupIpId, ipIds, maxAllowedRewardShare
4444
).build_transaction(tx_params)
4545

46+
def removeIp(self, groupIpId, ipIds):
47+
return self.contract.functions.removeIp(groupIpId, ipIds).transact()
48+
49+
def build_removeIp_transaction(self, groupIpId, ipIds, tx_params):
50+
return self.contract.functions.removeIp(
51+
groupIpId, ipIds
52+
).build_transaction(tx_params)
53+
4654
def claimReward(self, groupId, token, ipIds):
4755
return self.contract.functions.claimReward(groupId, token, ipIds).transact()
4856

src/story_protocol_python_sdk/abi/IPAssetRegistry/IPAssetRegistry_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,6 @@ def ipId(self, chainId, tokenContract, tokenId):
5151

5252
def isRegistered(self, id):
5353
return self.contract.functions.isRegistered(id).call()
54+
55+
def isRegisteredGroup(self, groupId):
56+
return self.contract.functions.isRegisteredGroup(groupId).call()

src/story_protocol_python_sdk/abi/LicenseRegistry/LicenseRegistry_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ def getRoyaltyPercent(self, ipId, licenseTemplate, licenseTermsId):
4949
ipId, licenseTemplate, licenseTermsId
5050
).call()
5151

52+
def hasDerivativeIps(self, parentIpId):
53+
return self.contract.functions.hasDerivativeIps(parentIpId).call()
54+
5255
def hasIpAttachedLicenseTerms(self, ipId, licenseTemplate, licenseTermsId):
5356
return self.contract.functions.hasIpAttachedLicenseTerms(
5457
ipId, licenseTemplate, licenseTermsId

src/story_protocol_python_sdk/abi/LicenseToken/LicenseToken_client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,10 @@ def __init__(self, web3: Web3):
3131
abi = json.load(abi_file)
3232
self.contract = self.web3.eth.contract(address=contract_address, abi=abi)
3333

34+
def getTotalTokensByLicensor(self, licensorIpId):
35+
return self.contract.functions.getTotalTokensByLicensor(
36+
licensorIpId
37+
).call()
38+
3439
def ownerOf(self, tokenId):
3540
return self.contract.functions.ownerOf(tokenId).call()

src/story_protocol_python_sdk/resources/Group.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
from story_protocol_python_sdk.abi.CoreMetadataModule.CoreMetadataModule_client import (
55
CoreMetadataModuleClient,
66
)
7+
from story_protocol_python_sdk.abi.DisputeModule.DisputeModule_client import (
8+
DisputeModuleClient,
9+
)
710
from story_protocol_python_sdk.abi.GroupingModule.GroupingModule_client import (
811
GroupingModuleClient,
912
)
@@ -19,6 +22,9 @@
1922
from story_protocol_python_sdk.abi.LicenseRegistry.LicenseRegistry_client import (
2023
LicenseRegistryClient,
2124
)
25+
from story_protocol_python_sdk.abi.LicenseToken.LicenseToken_client import (
26+
LicenseTokenClient,
27+
)
2228
from story_protocol_python_sdk.abi.LicensingModule.LicensingModule_client import (
2329
LicensingModuleClient,
2430
)
@@ -58,9 +64,11 @@ def __init__(self, web3: Web3, account, chain_id: int):
5864
self.grouping_module_client = GroupingModuleClient(web3)
5965
self.grouping_workflows_client = GroupingWorkflowsClient(web3)
6066
self.ip_asset_registry_client = IPAssetRegistryClient(web3)
67+
self.dispute_module_client = DisputeModuleClient(web3)
6168
self.core_metadata_module_client = CoreMetadataModuleClient(web3)
6269
self.licensing_module_client = LicensingModuleClient(web3)
6370
self.license_registry_client = LicenseRegistryClient(web3)
71+
self.license_token_client = LicenseTokenClient(web3)
6472
self.pi_license_template_client = PILicenseTemplateClient(web3)
6573
self.module_registry_client = ModuleRegistryClient(web3)
6674
self.sign_util = Sign(web3, self.chain_id, self.account)
@@ -453,6 +461,121 @@ def register_group_and_attach_license_and_add_ips(
453461
f"Failed to register group and attach license and add IPs: {str(e)}"
454462
)
455463

464+
def add_ips_to_group(
465+
self,
466+
group_ip_id: str,
467+
ip_ids: list,
468+
max_allowed_reward_share_percentage: int = 100,
469+
tx_options: dict | None = None,
470+
) -> dict:
471+
"""
472+
Add IPs to an existing group IP.
473+
474+
:param group_ip_id str: The ID of the group IP.
475+
:param ip_ids list: List of IP IDs to add to the group.
476+
:param max_allowed_reward_share_percentage int: [Optional] Maximum allowed reward share percentage (0-100). Default is 100.
477+
:param tx_options dict: [Optional] The transaction options.
478+
:return dict: A dictionary with the transaction hash.
479+
"""
480+
try:
481+
if not self.web3.is_address(group_ip_id):
482+
raise ValueError(f'Group IP ID "{group_ip_id}" is invalid.')
483+
484+
for ip_id in ip_ids:
485+
if not self.web3.is_address(ip_id):
486+
raise ValueError(f'IP ID "{ip_id}" is invalid.')
487+
488+
# Contract-level validation: groupId must not be disputed
489+
if self.dispute_module_client.isIpTagged(group_ip_id):
490+
raise ValueError(
491+
f'Disputed group cannot add IP: group "{group_ip_id}" is tagged by dispute module.'
492+
)
493+
494+
# Contract-level validation: ipIds must not contain disputed IPs or groups
495+
for ip_id in ip_ids:
496+
if self.dispute_module_client.isIpTagged(ip_id):
497+
raise ValueError(
498+
f'Cannot add disputed IP to group: IP "{ip_id}" is tagged by dispute module.'
499+
)
500+
if self.ip_asset_registry_client.isRegisteredGroup(ip_id):
501+
raise ValueError(
502+
f'Cannot add group to group: IP "{ip_id}" is a registered group.'
503+
)
504+
505+
max_allowed_reward_share = get_revenue_share(
506+
max_allowed_reward_share_percentage,
507+
type=RevShareType.MAX_ALLOWED_REWARD_SHARE,
508+
)
509+
510+
response = build_and_send_transaction(
511+
self.web3,
512+
self.account,
513+
self.grouping_module_client.build_addIp_transaction,
514+
group_ip_id,
515+
ip_ids,
516+
max_allowed_reward_share,
517+
tx_options=tx_options,
518+
)
519+
520+
result = {"tx_hash": response["tx_hash"]}
521+
if "tx_receipt" in response:
522+
result["tx_receipt"] = response["tx_receipt"]
523+
return result
524+
525+
except Exception as e:
526+
raise ValueError(f"Failed to add IP to group: {str(e)}")
527+
528+
def remove_ips_from_group(
529+
self,
530+
group_ip_id: str,
531+
ip_ids: list,
532+
tx_options: dict | None = None,
533+
) -> dict:
534+
"""
535+
Remove IPs from a group IP.
536+
537+
:param group_ip_id str: The ID of the group IP.
538+
:param ip_ids list: List of IP IDs to remove from the group.
539+
:param tx_options dict: [Optional] The transaction options.
540+
:return dict: A dictionary with the transaction hash.
541+
"""
542+
try:
543+
if not self.web3.is_address(group_ip_id):
544+
raise ValueError(f'Group IP ID "{group_ip_id}" is invalid.')
545+
546+
for ip_id in ip_ids:
547+
if not self.web3.is_address(ip_id):
548+
raise ValueError(f'IP ID "{ip_id}" is invalid.')
549+
550+
# Contract-level validation: group must not have derivative IPs
551+
if self.license_registry_client.hasDerivativeIps(group_ip_id):
552+
raise ValueError(
553+
f'Group frozen: group "{group_ip_id}" has derivative IPs and cannot remove members.'
554+
)
555+
556+
# Contract-level validation: group must not have minted license tokens
557+
if self.license_token_client.getTotalTokensByLicensor(group_ip_id) > 0:
558+
raise ValueError(
559+
f'Group frozen: group "{group_ip_id}" has already minted license tokens and cannot remove members.'
560+
)
561+
562+
response = build_and_send_transaction(
563+
self.web3,
564+
self.account,
565+
self.grouping_module_client.build_removeIp_transaction,
566+
group_ip_id,
567+
ip_ids,
568+
tx_options=tx_options,
569+
)
570+
571+
result = {"tx_hash": response["tx_hash"]}
572+
if "tx_receipt" in response:
573+
result["tx_receipt"] = response["tx_receipt"]
574+
return result
575+
576+
except Exception as e:
577+
raise ValueError(f"Failed to remove IPs from group: {str(e)}")
578+
456579
def collect_and_distribute_group_royalties(
457580
self,
458581
group_ip_id: str,
@@ -847,3 +970,57 @@ def _parse_tx_royalty_paid_event(self, tx_receipt: dict) -> list:
847970
)
848971

849972
return royalties_distributed
973+
974+
def get_added_ip_to_group_events(self, tx_receipt: dict) -> list:
975+
"""
976+
Parse AddedIpToGroup events from a transaction receipt (for chain-state verification).
977+
978+
:param tx_receipt dict: The transaction receipt.
979+
:return list: List of dicts with groupId and ipIds (checksum addresses).
980+
"""
981+
events = []
982+
for log in tx_receipt["logs"]:
983+
try:
984+
event_result = self.grouping_module_client.contract.events.AddedIpToGroup.process_log(
985+
log
986+
)
987+
args = event_result["args"]
988+
events.append(
989+
{
990+
"groupId": self.web3.to_checksum_address(args["groupId"]),
991+
"ipIds": [
992+
self.web3.to_checksum_address(addr)
993+
for addr in args["ipIds"]
994+
],
995+
}
996+
)
997+
except Exception:
998+
continue
999+
return events
1000+
1001+
def get_removed_ip_from_group_events(self, tx_receipt: dict) -> list:
1002+
"""
1003+
Parse RemovedIpFromGroup events from a transaction receipt (for chain-state verification).
1004+
1005+
:param tx_receipt dict: The transaction receipt.
1006+
:return list: List of dicts with groupId and ipIds (checksum addresses).
1007+
"""
1008+
events = []
1009+
for log in tx_receipt["logs"]:
1010+
try:
1011+
event_result = self.grouping_module_client.contract.events.RemovedIpFromGroup.process_log(
1012+
log
1013+
)
1014+
args = event_result["args"]
1015+
events.append(
1016+
{
1017+
"groupId": self.web3.to_checksum_address(args["groupId"]),
1018+
"ipIds": [
1019+
self.web3.to_checksum_address(addr)
1020+
for addr in args["ipIds"]
1021+
],
1022+
}
1023+
)
1024+
except Exception:
1025+
continue
1026+
return events

0 commit comments

Comments
 (0)