Skip to content

Commit 3f3a9da

Browse files
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 eebc0f1 commit 3f3a9da

File tree

6 files changed

+214
-6
lines changed

6 files changed

+214
-6
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/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: 37 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)
@@ -477,6 +485,23 @@ def add_ips_to_group(
477485
if not self.web3.is_address(ip_id):
478486
raise ValueError(f'IP ID "{ip_id}" is invalid.')
479487

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+
480505
max_allowed_reward_share = get_revenue_share(
481506
max_allowed_reward_share_percentage,
482507
type=RevShareType.MAX_ALLOWED_REWARD_SHARE,
@@ -522,6 +547,18 @@ def remove_ips_from_group(
522547
if not self.web3.is_address(ip_id):
523548
raise ValueError(f'IP ID "{ip_id}" is invalid.')
524549

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+
525562
response = build_and_send_transaction(
526563
self.web3,
527564
self.account,

tests/unit/resources/test_group.py

Lines changed: 163 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -456,14 +456,95 @@ def test_add_ips_to_group_invalid_ip_id(self, group: Group, mock_web3):
456456
ip_ids=[invalid_ip_id],
457457
)
458458

459+
def test_add_ips_to_group_max_reward_share_exceeds_100(
460+
self, group: Group, mock_web3_is_address
461+
):
462+
"""Test add_ips_to_group rejects max_allowed_reward_share_percentage > 100 (via get_revenue_share)."""
463+
with mock_web3_is_address():
464+
with patch.object(
465+
group.dispute_module_client, "isIpTagged", return_value=False
466+
), patch.object(
467+
group.ip_asset_registry_client,
468+
"isRegisteredGroup",
469+
return_value=False,
470+
):
471+
with pytest.raises(
472+
ValueError,
473+
match="must be between 0 and 100",
474+
):
475+
group.add_ips_to_group(
476+
group_ip_id=IP_ID,
477+
ip_ids=[IP_ID],
478+
max_allowed_reward_share_percentage=101,
479+
)
480+
481+
def test_add_ips_to_group_disputed_group(self, group: Group, mock_web3_is_address):
482+
"""Test add_ips_to_group rejects disputed group."""
483+
with mock_web3_is_address():
484+
with patch.object(
485+
group.dispute_module_client, "isIpTagged", return_value=True
486+
):
487+
with pytest.raises(
488+
ValueError,
489+
match="Disputed group cannot add IP",
490+
):
491+
group.add_ips_to_group(
492+
group_ip_id=IP_ID,
493+
ip_ids=[IP_ID],
494+
)
495+
496+
def test_add_ips_to_group_disputed_ip(self, group: Group, mock_web3_is_address):
497+
"""Test add_ips_to_group rejects disputed IP in ip_ids."""
498+
with mock_web3_is_address():
499+
with patch.object(
500+
group.dispute_module_client,
501+
"isIpTagged",
502+
side_effect=[False, True], # group ok, first ip disputed
503+
):
504+
with pytest.raises(
505+
ValueError,
506+
match="Cannot add disputed IP to group",
507+
):
508+
group.add_ips_to_group(
509+
group_ip_id=IP_ID,
510+
ip_ids=[IP_ID, ADDRESS],
511+
)
512+
513+
def test_add_ips_to_group_ip_is_registered_group(
514+
self, group: Group, mock_web3_is_address
515+
):
516+
"""Test add_ips_to_group rejects IP that is a registered group."""
517+
with mock_web3_is_address():
518+
with patch.object(
519+
group.dispute_module_client, "isIpTagged", return_value=False
520+
), patch.object(
521+
group.ip_asset_registry_client,
522+
"isRegisteredGroup",
523+
side_effect=[False, True], # first ip ok, second ip is group
524+
):
525+
with pytest.raises(
526+
ValueError,
527+
match="Cannot add group to group",
528+
):
529+
group.add_ips_to_group(
530+
group_ip_id=IP_ID,
531+
ip_ids=[IP_ID, ADDRESS],
532+
)
533+
459534
def test_add_ips_to_group_success(
460535
self,
461536
group: Group,
462537
mock_web3_is_address,
463538
):
464539
"""Test successful add_ips_to_group operation."""
465540
with mock_web3_is_address():
466-
with patch(
541+
with patch.object(
542+
group.dispute_module_client, "isIpTagged", return_value=False
543+
), patch.object(
544+
group.ip_asset_registry_client,
545+
"isRegisteredGroup",
546+
return_value=False,
547+
), patch(
467548
"story_protocol_python_sdk.resources.Group.build_and_send_transaction",
468549
return_value={"tx_hash": TX_HASH, "tx_receipt": {}},
469550
):
@@ -482,7 +563,13 @@ def test_add_ips_to_group_default_max_allowed_reward_share_percentage(
482563
):
483564
"""Test add_ips_to_group uses default max_allowed_reward_share_percentage 100."""
484565
with mock_web3_is_address():
485-
with patch(
566+
with patch.object(
567+
group.dispute_module_client, "isIpTagged", return_value=False
568+
), patch.object(
569+
group.ip_asset_registry_client,
570+
"isRegisteredGroup",
571+
return_value=False,
572+
), patch(
486573
"story_protocol_python_sdk.resources.Group.build_and_send_transaction",
487574
return_value={"tx_hash": TX_HASH, "tx_receipt": {}},
488575
) as mock_build:
@@ -501,7 +588,13 @@ def test_add_ips_to_group_max_allowed_reward_share_percentage_zero(
501588
):
502589
"""Test add_ips_to_group with max_allowed_reward_share_percentage 0."""
503590
with mock_web3_is_address():
504-
with patch(
591+
with patch.object(
592+
group.dispute_module_client, "isIpTagged", return_value=False
593+
), patch.object(
594+
group.ip_asset_registry_client,
595+
"isRegisteredGroup",
596+
return_value=False,
597+
), patch(
505598
"story_protocol_python_sdk.resources.Group.build_and_send_transaction",
506599
return_value={"tx_hash": TX_HASH, "tx_receipt": {}},
507600
) as mock_build:
@@ -518,7 +611,13 @@ def test_add_ips_to_group_transaction_fails(
518611
):
519612
"""Test add_ips_to_group when transaction build/send fails."""
520613
with mock_web3_is_address():
521-
with patch(
614+
with patch.object(
615+
group.dispute_module_client, "isIpTagged", return_value=False
616+
), patch.object(
617+
group.ip_asset_registry_client,
618+
"isRegisteredGroup",
619+
return_value=False,
620+
), patch(
522621
"story_protocol_python_sdk.resources.Group.build_and_send_transaction",
523622
side_effect=Exception("Transaction build failed"),
524623
):
@@ -550,6 +649,48 @@ def test_remove_ips_from_group_invalid_group_ip_id(
550649
ip_ids=[IP_ID],
551650
)
552651

652+
def test_remove_ips_from_group_group_has_derivative_ips(
653+
self, group: Group, mock_web3_is_address
654+
):
655+
"""Test remove_ips_from_group rejects when group has derivative IPs."""
656+
with mock_web3_is_address():
657+
with patch.object(
658+
group.license_registry_client,
659+
"hasDerivativeIps",
660+
return_value=True,
661+
):
662+
with pytest.raises(
663+
ValueError,
664+
match="Group frozen:.*has derivative IPs",
665+
):
666+
group.remove_ips_from_group(
667+
group_ip_id=IP_ID,
668+
ip_ids=[IP_ID],
669+
)
670+
671+
def test_remove_ips_from_group_group_has_minted_license_tokens(
672+
self, group: Group, mock_web3_is_address
673+
):
674+
"""Test remove_ips_from_group rejects when group has minted license tokens."""
675+
with mock_web3_is_address():
676+
with patch.object(
677+
group.license_registry_client,
678+
"hasDerivativeIps",
679+
return_value=False,
680+
), patch.object(
681+
group.license_token_client,
682+
"getTotalTokensByLicensor",
683+
return_value=10,
684+
):
685+
with pytest.raises(
686+
ValueError,
687+
match="Group frozen:.*has already minted license tokens",
688+
):
689+
group.remove_ips_from_group(
690+
group_ip_id=IP_ID,
691+
ip_ids=[IP_ID],
692+
)
693+
553694
def test_remove_ips_from_group_invalid_ip_id(self, group: Group, mock_web3):
554695
"""Test remove_ips_from_group with invalid IP ID."""
555696
invalid_ip_id = "invalid_ip_id"
@@ -571,7 +712,15 @@ def test_remove_ips_from_group_success(
571712
):
572713
"""Test successful remove_ips_from_group operation."""
573714
with mock_web3_is_address():
574-
with patch(
715+
with patch.object(
716+
group.license_registry_client,
717+
"hasDerivativeIps",
718+
return_value=False,
719+
), patch.object(
720+
group.license_token_client,
721+
"getTotalTokensByLicensor",
722+
return_value=0,
723+
), patch(
575724
"story_protocol_python_sdk.resources.Group.build_and_send_transaction",
576725
return_value={"tx_hash": TX_HASH, "tx_receipt": {}},
577726
):
@@ -588,7 +737,15 @@ def test_remove_ips_from_group_transaction_fails(
588737
):
589738
"""Test remove_ips_from_group when transaction build/send fails."""
590739
with mock_web3_is_address():
591-
with patch(
740+
with patch.object(
741+
group.license_registry_client,
742+
"hasDerivativeIps",
743+
return_value=False,
744+
), patch.object(
745+
group.license_token_client,
746+
"getTotalTokensByLicensor",
747+
return_value=0,
748+
), patch(
592749
"story_protocol_python_sdk.resources.Group.build_and_send_transaction",
593750
side_effect=Exception("Transaction build failed"),
594751
):

0 commit comments

Comments
 (0)