diff --git a/.github/workflows/sonarqube.yaml b/.github/workflows/sonarqube.yaml index b87ff0cf93..ffa0869015 100644 --- a/.github/workflows/sonarqube.yaml +++ b/.github/workflows/sonarqube.yaml @@ -17,18 +17,19 @@ jobs: - name: Create temporary global.json run: echo '{"sdk":{"version":"8.0.*"}}' > ./global.json - name: Set up JDK 17 - uses: actions/setup-java@v1 + uses: actions/setup-java@v4 with: + distribution: 'temurin' java-version: 17 - name: Cache SonarQube packages - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - name: Cache SonarQube scanner id: cache-sonar-scanner - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: ./.sonar/scanner key: ${{ runner.os }}-sonar-scanner diff --git a/contract/AElf.Contracts.MultiToken/TokenContractState.cs b/contract/AElf.Contracts.MultiToken/TokenContractState.cs index 2e613e2873..25629ca15c 100644 --- a/contract/AElf.Contracts.MultiToken/TokenContractState.cs +++ b/contract/AElf.Contracts.MultiToken/TokenContractState.cs @@ -38,6 +38,7 @@ public partial class TokenContractState : ContractState public SingletonState DeveloperFeeController { get; set; } public SingletonState SymbolToPayTxFeeController { get; set; } public SingletonState SideChainRentalController { get; set; } + public SingletonState TransferBlackListController { get; set; } /// /// symbol -> address -> is in white list. diff --git a/contract/AElf.Contracts.MultiToken/TokenContract_Actions.cs b/contract/AElf.Contracts.MultiToken/TokenContract_Actions.cs index 9169bf6c6a..71913eae4b 100644 --- a/contract/AElf.Contracts.MultiToken/TokenContract_Actions.cs +++ b/contract/AElf.Contracts.MultiToken/TokenContract_Actions.cs @@ -853,17 +853,63 @@ private void CheckTokenAlias(string alias, string collectionSymbol) public override Empty AddToTransferBlackList(Address input) { - AssertSenderAddressWith(GetDefaultParliamentController().OwnerAddress); + AssertControllerForTransferBlackList(); Assert(input != null && !input.Value.IsNullOrEmpty(), "Invalid address."); State.TransferBlackList[input] = true; return new Empty(); } + public override Empty BatchAddToTransferBlackList(BatchAddToTransferBlackListInput input) + { + AssertControllerForTransferBlackList(); + Assert(input != null && input.Addresses != null && input.Addresses.Count > 0, "Invalid input."); + + // Validate all addresses first + foreach (var address in input.Addresses) + { + Assert(address != null && !address.Value.IsNullOrEmpty(), "Invalid address."); + } + + // Remove duplicates and add to blacklist + var uniqueAddresses = input.Addresses.Distinct().ToList(); + foreach (var address in uniqueAddresses) + { + State.TransferBlackList[address] = true; + } + + return new Empty(); + } + public override Empty RemoveFromTransferBlackList(Address input) { + // Removing from transfer blacklist requires higher security and response speed is not critical, + // so it should be controlled by Parliament. AssertSenderAddressWith(GetDefaultParliamentController().OwnerAddress); Assert(input != null && !input.Value.IsNullOrEmpty(), "Invalid address."); State.TransferBlackList[input] = false; return new Empty(); } + + public override Empty BatchRemoveFromTransferBlackList(BatchRemoveFromTransferBlackListInput input) + { + // Removing from transfer blacklist requires higher security and response speed is not critical, + // so it should be controlled by Parliament. + AssertSenderAddressWith(GetDefaultParliamentController().OwnerAddress); + Assert(input != null && input.Addresses != null && input.Addresses.Count > 0, "Invalid input."); + + // Validate all addresses first + foreach (var address in input.Addresses) + { + Assert(address != null && !address.Value.IsNullOrEmpty(), "Invalid address."); + } + + // Remove duplicates and remove from blacklist + var uniqueAddresses = input.Addresses.Distinct().ToList(); + foreach (var address in uniqueAddresses) + { + State.TransferBlackList[address] = false; + } + + return new Empty(); + } } \ No newline at end of file diff --git a/contract/AElf.Contracts.MultiToken/TokenContract_Method_Authorization.cs b/contract/AElf.Contracts.MultiToken/TokenContract_Method_Authorization.cs index cc1833f49f..97cca9218d 100644 --- a/contract/AElf.Contracts.MultiToken/TokenContract_Method_Authorization.cs +++ b/contract/AElf.Contracts.MultiToken/TokenContract_Method_Authorization.cs @@ -87,6 +87,15 @@ public override Empty ChangeDeveloperController(AuthorityInfo input) return new Empty(); } + public override Empty ChangeTransferBlackListController(AuthorityInfo input) + { + AssertSenderAddressWith(GetDefaultParliamentController().OwnerAddress); + var organizationExist = CheckOrganizationExist(input); + Assert(organizationExist, "Invalid authority input."); + State.TransferBlackListController.Value = input; + return new Empty(); + } + private void CreateReferendumControllerForUserFee(Address parliamentAddress) { State.ReferendumContract.CreateOrganizationBySystemContract.Send( @@ -364,6 +373,13 @@ private AuthorityInfo GetDefaultSideChainRentalController(AuthorityInfo defaultP }; } + private AuthorityInfo GetTransferBlackListController() + { + if (State.TransferBlackListController.Value == null) + return GetDefaultParliamentController(); + return State.TransferBlackListController.Value; + } + private void AssertDeveloperFeeController() { Assert(State.DeveloperFeeController.Value != null, @@ -396,5 +412,11 @@ private void AssertControllerForSideChainRental() Assert(State.SideChainRentalController.Value.OwnerAddress == Context.Sender, "no permission"); } + private void AssertControllerForTransferBlackList() + { + var controller = GetTransferBlackListController(); + Assert(Context.Sender == controller.OwnerAddress, "No permission"); + } + #endregion } \ No newline at end of file diff --git a/contract/AElf.Contracts.MultiToken/TokenContract_Views.cs b/contract/AElf.Contracts.MultiToken/TokenContract_Views.cs index 5eb0d955fb..62d8f435e2 100644 --- a/contract/AElf.Contracts.MultiToken/TokenContract_Views.cs +++ b/contract/AElf.Contracts.MultiToken/TokenContract_Views.cs @@ -298,4 +298,10 @@ public override BoolValue IsInTransferBlackList(Address input) { return new BoolValue { Value = State.TransferBlackList[input] }; } + + [View] + public override AuthorityInfo GetTransferBlackListController(Empty input) + { + return GetTransferBlackListController(); + } } \ No newline at end of file diff --git a/protobuf/token_contract_impl.proto b/protobuf/token_contract_impl.proto index 26ea0014f9..ab92f62056 100644 --- a/protobuf/token_contract_impl.proto +++ b/protobuf/token_contract_impl.proto @@ -77,6 +77,10 @@ service TokenContractImpl { rpc ChangeDeveloperController (AuthorityInfo) returns (google.protobuf.Empty) { } + // Change the governance organization of the transfer blacklist management. + rpc ChangeTransferBlackListController (AuthorityInfo) returns (google.protobuf.Empty) { + } + rpc ConfigTransactionFeeFreeAllowances (ConfigTransactionFeeFreeAllowancesInput) returns (google.protobuf.Empty) { } @@ -136,6 +140,11 @@ service TokenContractImpl { option (aelf.is_view) = true; } + // Query the governance organization of the transfer blacklist management. + rpc GetTransferBlackListController (google.protobuf.Empty) returns (AuthorityInfo) { + option (aelf.is_view) = true; + } + // Compute the virtual address for locking. rpc GetVirtualAddressForLocking (GetVirtualAddressForLockingInput) returns (aelf.Address) { option (aelf.is_view) = true; @@ -186,12 +195,22 @@ service TokenContractImpl { rpc ExtendSeedExpirationTime (ExtendSeedExpirationTimeInput) returns (google.protobuf.Empty) { } - // Add an address to the transfer blacklist. Only parliament owner can call this method. + // Add an address to the transfer blacklist. rpc AddToTransferBlackList (aelf.Address) returns (google.protobuf.Empty) { } + + // Add multiple addresses to the transfer blacklist. + rpc BatchAddToTransferBlackList (BatchAddToTransferBlackListInput) returns (google.protobuf.Empty) { + } + // Remove an address from the transfer blacklist. Only parliament owner can call this method. rpc RemoveFromTransferBlackList (aelf.Address) returns (google.protobuf.Empty) { } + + // Remove multiple addresses from the transfer blacklist. Only parliament owner can call this method. + rpc BatchRemoveFromTransferBlackList (BatchRemoveFromTransferBlackListInput) returns (google.protobuf.Empty) { + } + // Check if an address is in the transfer blacklist. rpc IsInTransferBlackList (aelf.Address) returns (google.protobuf.BoolValue) { option (aelf.is_view) = true; @@ -471,4 +490,12 @@ message SeedExpirationTimeUpdated { string symbol = 2; int64 old_expiration_time = 3; int64 new_expiration_time = 4; +} + +message BatchAddToTransferBlackListInput { + repeated aelf.Address addresses = 1; +} + +message BatchRemoveFromTransferBlackListInput { + repeated aelf.Address addresses = 1; } \ No newline at end of file diff --git a/test/AElf.Contracts.MultiToken.Tests/BVT/TokenApplicationTests.cs b/test/AElf.Contracts.MultiToken.Tests/BVT/TokenApplicationTests.cs index 0b299f157a..78e0a2a28e 100644 --- a/test/AElf.Contracts.MultiToken.Tests/BVT/TokenApplicationTests.cs +++ b/test/AElf.Contracts.MultiToken.Tests/BVT/TokenApplicationTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -11,6 +12,10 @@ using Google.Protobuf.WellKnownTypes; using Shouldly; using Xunit; +using AElf.Contracts.Association; +using AElf.ContractTestBase.ContractTestKit; +using Google.Protobuf; +using AElf.Standards.ACS3; namespace AElf.Contracts.MultiToken; @@ -1913,14 +1918,14 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() var trafficToken = "TRAFFIC"; await CreateAndIssueCustomizeTokenAsync(DefaultAddress, trafficToken, 10000, 10000); - // 1. Non-owner cannot add to blacklist + // Non-owner cannot add to blacklist var addBlackListResult = await TokenContractStubUser.AddToTransferBlackList.SendWithExceptionAsync(DefaultAddress); addBlackListResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); - addBlackListResult.TransactionResult.Error.ShouldContain("Unauthorized behavior"); + addBlackListResult.TransactionResult.Error.ShouldContain("No permission"); var isInTransferBlackList = await TokenContractStubUser.IsInTransferBlackList.CallAsync(DefaultAddress); isInTransferBlackList.Value.ShouldBe(false); - // 2. Owner adds DefaultAddress to blacklist via parliament proposal + // Owner adds DefaultAddress to blacklist via parliament proposal var defaultParliament = await ParliamentContractStub.GetDefaultOrganizationAddress.CallAsync(new Empty()); var proposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, nameof(TokenContractStub.AddToTransferBlackList), DefaultAddress); await ApproveWithMinersAsync(proposalId); @@ -1928,7 +1933,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() isInTransferBlackList = await TokenContractStubUser.IsInTransferBlackList.CallAsync(DefaultAddress); isInTransferBlackList.Value.ShouldBe(true); - // 3. Transfer should fail when sender is in blacklist + // Transfer should fail when sender is in blacklist var transferResult = (await TokenContractStub.Transfer.SendWithExceptionAsync(new TransferInput { Amount = Amount, @@ -1939,7 +1944,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() transferResult.Status.ShouldBe(TransactionResultStatus.Failed); transferResult.Error.ShouldContain("From address is in transfer blacklist"); - // 4. TransferFrom should fail when from address is in blacklist + // TransferFrom should fail when from address is in blacklist var user1Stub = GetTester(TokenContractAddress, User1KeyPair); var transferFromResult = (await user1Stub.TransferFrom.SendWithExceptionAsync(new TransferFromInput { @@ -1952,7 +1957,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() transferFromResult.Status.ShouldBe(TransactionResultStatus.Failed); transferFromResult.Error.ShouldContain("From address is in transfer blacklist"); - // 5. CrossChainTransfer should fail when sender is in blacklist + // CrossChainTransfer should fail when sender is in blacklist var crossChainTransferResult = (await TokenContractStub.CrossChainTransfer.SendWithExceptionAsync(new CrossChainTransferInput { Symbol = AliceCoinTokenInfo.Symbol, @@ -1965,7 +1970,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() crossChainTransferResult.Status.ShouldBe(TransactionResultStatus.Failed); crossChainTransferResult.Error.ShouldContain("Sender is in transfer blacklist"); - // 6. Lock should fail when sender is in blacklist + // Lock should fail when sender is in blacklist var lockId = HashHelper.ComputeFrom("lockId"); var lockTokenResult = (await BasicFunctionContractStub.LockToken.SendWithExceptionAsync(new LockTokenInput { @@ -1978,7 +1983,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() lockTokenResult.Status.ShouldBe(TransactionResultStatus.Failed); lockTokenResult.Error.ShouldContain("From address is in transfer blacklist"); - // 7. Transfer to contract should fail when sender is in blacklist + // Transfer to contract should fail when sender is in blacklist var transferToContractResult = (await BasicFunctionContractStub.TransferTokenToContract.SendWithExceptionAsync( new TransferTokenToContractInput { @@ -1988,7 +1993,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() transferToContractResult.Status.ShouldBe(TransactionResultStatus.Failed); transferToContractResult.Error.ShouldContain("From address is in transfer blacklist"); - // 8. AdvanceResourceToken should fail when sender is in blacklist + // AdvanceResourceToken should fail when sender is in blacklist var advanceRet = await TokenContractStub.AdvanceResourceToken.SendWithExceptionAsync( new AdvanceResourceTokenInput { @@ -1999,19 +2004,19 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() advanceRet.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); advanceRet.TransactionResult.Error.ShouldContain("From address is in transfer blacklist"); - // 9. Non-owner cannot remove from blacklist + // Non-owner cannot remove from blacklist var removeBlackListResult = await TokenContractStubUser.RemoveFromTransferBlackList.SendWithExceptionAsync(DefaultAddress); removeBlackListResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); removeBlackListResult.TransactionResult.Error.ShouldContain("Unauthorized behavior"); - // 10. Owner removes DefaultAddress from blacklist via parliament proposal + // Owner removes DefaultAddress from blacklist via parliament proposal var removeProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, nameof(TokenContractStub.RemoveFromTransferBlackList), DefaultAddress); await ApproveWithMinersAsync(removeProposalId); await ParliamentContractStub.Release.SendAsync(removeProposalId); isInTransferBlackList = await TokenContractStubUser.IsInTransferBlackList.CallAsync(DefaultAddress); isInTransferBlackList.Value.ShouldBe(false); - // 11. Transfer should succeed after removing from blacklist + // Transfer should succeed after removing from blacklist var transferResult2 = await TokenContractStub.Transfer.SendAsync(new TransferInput { Amount = Amount, @@ -2021,7 +2026,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() }); transferResult2.TransactionResult.Status.ShouldBe(TransactionResultStatus.Mined); - // 12. TransferFrom should succeed after removing from blacklist + // TransferFrom should succeed after removing from blacklist transferFromResult = (await user1Stub.TransferFrom.SendAsync(new TransferFromInput { Amount = Amount, @@ -2032,7 +2037,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() })).TransactionResult; transferFromResult.Status.ShouldBe(TransactionResultStatus.Mined); - // 13. CrossChainTransfer should succeed after removing from blacklist + // CrossChainTransfer should succeed after removing from blacklist crossChainTransferResult = (await TokenContractStub.CrossChainTransfer.SendAsync(new CrossChainTransferInput { Symbol = AliceCoinTokenInfo.Symbol, @@ -2044,7 +2049,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() })).TransactionResult; crossChainTransferResult.Status.ShouldBe(TransactionResultStatus.Mined); - // 14. Lock should succeed after removing from blacklist + // Lock should succeed after removing from blacklist lockTokenResult = (await BasicFunctionContractStub.LockToken.SendAsync(new LockTokenInput { Address = DefaultAddress, @@ -2055,7 +2060,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() })).TransactionResult; lockTokenResult.Status.ShouldBe(TransactionResultStatus.Mined); - // 15. Transfer to contract should succeed after removing from blacklist + // Transfer to contract should succeed after removing from blacklist transferToContractResult = (await BasicFunctionContractStub.TransferTokenToContract.SendAsync(new TransferTokenToContractInput { Amount = Amount, @@ -2063,7 +2068,7 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() })).TransactionResult; transferToContractResult.Status.ShouldBe(TransactionResultStatus.Mined); - // 16. AdvanceResourceToken should succeed after removing from blacklist + // AdvanceResourceToken should succeed after removing from blacklist advanceRet = await TokenContractStub.AdvanceResourceToken.SendAsync( new AdvanceResourceTokenInput { @@ -2072,5 +2077,415 @@ public async Task MultiTokenContract_Transfer_BlackList_Test() ResourceTokenSymbol = trafficToken }); advanceRet.TransactionResult.Status.ShouldBe(TransactionResultStatus.Mined); + + // Test initial TransferBlackListController should fallback to Parliament + var initialController = await TokenContractStub.GetTransferBlackListController.CallAsync(new Empty()); + initialController.OwnerAddress.ShouldBe(defaultParliament); + + // Create Association organization for TransferBlackListController + var associationStub = GetTester(AssociationContractAddress, DefaultKeyPair); + var organizationCreated = await associationStub.CreateOrganization.SendAsync(new CreateOrganizationInput + { + ProposalReleaseThreshold = new ProposalReleaseThreshold + { + MinimalApprovalThreshold = 1, + MinimalVoteThreshold = 1, + MaximalAbstentionThreshold = 0, + MaximalRejectionThreshold = 0 + }, + ProposerWhiteList = new ProposerWhiteList + { + Proposers = { DefaultAddress } + }, + OrganizationMemberList = new OrganizationMemberList + { + OrganizationMembers = { DefaultAddress } + } + }); + organizationCreated.TransactionResult.Status.ShouldBe(TransactionResultStatus.Mined); + var organizationAddress = Address.Parser.ParseFrom(organizationCreated.TransactionResult.ReturnValue); + + // Only Parliament can change TransferBlackListController + var changeControllerResult = await TokenContractStubUser.ChangeTransferBlackListController.SendWithExceptionAsync(new AuthorityInfo + { + ContractAddress = AssociationContractAddress, + OwnerAddress = organizationAddress + }); + changeControllerResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); + changeControllerResult.TransactionResult.Error.ShouldContain("Unauthorized behavior"); + + // Test setting non-existent association organization address should fail + var nonExistentOrgAddress = SampleAddress.AddressList[9]; // Use a non-existent organization address + var setNonExistentControllerProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, + nameof(TokenContractStub.ChangeTransferBlackListController), new AuthorityInfo + { + ContractAddress = AssociationContractAddress, + OwnerAddress = nonExistentOrgAddress + }); + await ApproveWithMinersAsync(setNonExistentControllerProposalId); + var setNonExistentControllerResult = await ParliamentContractStub.Release.SendWithExceptionAsync(setNonExistentControllerProposalId); + setNonExistentControllerResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); + setNonExistentControllerResult.TransactionResult.Error.ShouldContain("Invalid authority input"); + + // Parliament changes TransferBlackListController to Association organization + var changeControllerProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, + nameof(TokenContractStub.ChangeTransferBlackListController), new AuthorityInfo + { + ContractAddress = AssociationContractAddress, + OwnerAddress = organizationAddress + }); + await ApproveWithMinersAsync(changeControllerProposalId); + await ParliamentContractStub.Release.SendAsync(changeControllerProposalId); + + // Verify TransferBlackListController has been changed + var newController = await TokenContractStub.GetTransferBlackListController.CallAsync(new Empty()); + newController.ContractAddress.ShouldBe(AssociationContractAddress); + newController.OwnerAddress.ShouldBe(organizationAddress); + + // Association organization can now add addresses to blacklist directly + var addToBlackListViaAssociation = await associationStub.CreateProposal.SendAsync(new CreateProposalInput + { + ContractMethodName = nameof(TokenContractStub.AddToTransferBlackList), + ToAddress = TokenContractAddress, + Params = User2Address.ToByteString(), + OrganizationAddress = organizationAddress, + ExpiredTime = TimestampHelper.GetUtcNow().AddHours(1) + }); + addToBlackListViaAssociation.TransactionResult.Status.ShouldBe(TransactionResultStatus.Mined); + var blacklistProposalId = ProposalCreated.Parser.ParseFrom(addToBlackListViaAssociation.TransactionResult.Logs + .First(l => l.Name == nameof(ProposalCreated)).NonIndexed).ProposalId; + + // Approve and release the proposal + await associationStub.Approve.SendAsync(blacklistProposalId); + await associationStub.Release.SendAsync(blacklistProposalId); + + // Verify User2Address is now in blacklist + var isUser2InBlackList = await TokenContractStub.IsInTransferBlackList.CallAsync(User2Address); + isUser2InBlackList.Value.ShouldBe(true); + + // User2 transfer should fail when in blacklist + var user2Stub = GetTester(TokenContractAddress, User2KeyPair); + await TokenContractStub.Transfer.SendAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = User2Address + }); + var user2TransferResult = await user2Stub.Transfer.SendWithExceptionAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = User1Address + }); + user2TransferResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); + user2TransferResult.TransactionResult.Error.ShouldContain("From address is in transfer blacklist"); + + // Parliament can still remove from blacklist (not affected by controller change) + var removeUser2ProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, + nameof(TokenContractStub.RemoveFromTransferBlackList), User2Address); + await ApproveWithMinersAsync(removeUser2ProposalId); + await ParliamentContractStub.Release.SendAsync(removeUser2ProposalId); + + // Verify User2Address is removed from blacklist + isUser2InBlackList = await TokenContractStub.IsInTransferBlackList.CallAsync(User2Address); + isUser2InBlackList.Value.ShouldBe(false); + + // User2 transfer should succeed after removal from blacklist + user2TransferResult = await user2Stub.Transfer.SendAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = User1Address + }); + user2TransferResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Mined); + } + + [Fact] + public async Task MultiTokenContract_BatchAddToTransferBlackList_Test() + { + // Create and issue token using existing test method + await MultiTokenContract_Approve_Test(); + + var defaultParliament = await ParliamentContractStub.GetDefaultOrganizationAddress.CallAsync(new Empty()); + + // Test BatchAddToTransferBlackList with Parliament when no controller is set (should succeed) + var parliamentBatchAddProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, + nameof(TokenContractStub.BatchAddToTransferBlackList), new BatchAddToTransferBlackListInput + { + Addresses = { User1Address } + }); + await ApproveWithMinersAsync(parliamentBatchAddProposalId); + await ParliamentContractStub.Release.SendAsync(parliamentBatchAddProposalId); + + // Verify User1Address is now in blacklist + var isUser1InBlackList = await TokenContractStub.IsInTransferBlackList.CallAsync(User1Address); + isUser1InBlackList.Value.ShouldBe(true); + + // Remove User1 from blacklist for later tests + var removeUser1ProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, + nameof(TokenContractStub.RemoveFromTransferBlackList), User1Address); + await ApproveWithMinersAsync(removeUser1ProposalId); + await ParliamentContractStub.Release.SendAsync(removeUser1ProposalId); + + // Setup Association contract and organization + var associationStub = GetTester(AssociationContractAddress, DefaultKeyPair); + var organizationCreated = await associationStub.CreateOrganization.SendAsync(new CreateOrganizationInput + { + ProposalReleaseThreshold = new ProposalReleaseThreshold + { + MinimalApprovalThreshold = 1, + MinimalVoteThreshold = 1, + MaximalAbstentionThreshold = 0, + MaximalRejectionThreshold = 0 + }, + ProposerWhiteList = new ProposerWhiteList + { + Proposers = { DefaultAddress } + }, + OrganizationMemberList = new OrganizationMemberList + { + OrganizationMembers = { DefaultAddress } + } + }); + organizationCreated.TransactionResult.Status.ShouldBe(TransactionResultStatus.Mined); + var organizationAddress = Address.Parser.ParseFrom(organizationCreated.TransactionResult.ReturnValue); + + // Set Association organization as TransferBlackListController via Parliament + var changeControllerProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, + nameof(TokenContractStub.ChangeTransferBlackListController), new AuthorityInfo + { + ContractAddress = AssociationContractAddress, + OwnerAddress = organizationAddress + }); + await ApproveWithMinersAsync(changeControllerProposalId); + await ParliamentContractStub.Release.SendAsync(changeControllerProposalId); + + // Test BatchAddToTransferBlackList with empty input via Association organization should fail + var emptyInputProposalId = await associationStub.CreateProposal.SendAsync(new CreateProposalInput + { + ContractMethodName = nameof(TokenContractStub.BatchAddToTransferBlackList), + ToAddress = TokenContractAddress, + Params = new BatchAddToTransferBlackListInput().ToByteString(), + OrganizationAddress = organizationAddress, + ExpiredTime = TimestampHelper.GetUtcNow().AddHours(1) + }); + var emptyInputProposalHash = Hash.Parser.ParseFrom(emptyInputProposalId.TransactionResult.ReturnValue); + await associationStub.Approve.SendAsync(emptyInputProposalHash); + var emptyInputReleaseResult = await associationStub.Release.SendWithExceptionAsync(emptyInputProposalHash); + emptyInputReleaseResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); + emptyInputReleaseResult.TransactionResult.Error.ShouldContain("Invalid input"); + + // Test BatchAddToTransferBlackList with unauthorized user should fail + var batchAddUnauthorizedResult = await TokenContractStubUser.BatchAddToTransferBlackList.SendWithExceptionAsync(new BatchAddToTransferBlackListInput + { + Addresses = { User1Address, User2Address } + }); + batchAddUnauthorizedResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); + batchAddUnauthorizedResult.TransactionResult.Error.ShouldContain("No permission"); + + // Transfer some tokens to user accounts first for testing + await TokenContractStub.Transfer.SendAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = User1Address + }); + + await TokenContractStub.Transfer.SendAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = User2Address + }); + + // Test BatchAddToTransferBlackList with Association organization controller + var batchAddProposalId = await associationStub.CreateProposal.SendAsync(new CreateProposalInput + { + ContractMethodName = nameof(TokenContractStub.BatchAddToTransferBlackList), + ToAddress = TokenContractAddress, + Params = new BatchAddToTransferBlackListInput + { + Addresses = { User1Address, User2Address, DefaultAddress } + }.ToByteString(), + OrganizationAddress = organizationAddress, + ExpiredTime = TimestampHelper.GetUtcNow().AddHours(1) + }); + var batchProposalHash = Hash.Parser.ParseFrom(batchAddProposalId.TransactionResult.ReturnValue); + await associationStub.Approve.SendAsync(batchProposalHash); + await associationStub.Release.SendAsync(batchProposalHash); + + // Verify all addresses are now in blacklist + isUser1InBlackList = await TokenContractStub.IsInTransferBlackList.CallAsync(User1Address); + isUser1InBlackList.Value.ShouldBe(true); + var isUser2InBlackList = await TokenContractStub.IsInTransferBlackList.CallAsync(User2Address); + isUser2InBlackList.Value.ShouldBe(true); + var isDefaultInBlackList = await TokenContractStub.IsInTransferBlackList.CallAsync(DefaultAddress); + isDefaultInBlackList.Value.ShouldBe(true); + + // Test that transfers from blacklisted addresses should fail + var user1Stub = GetTester(TokenContractAddress, User1KeyPair); + var user1TransferResult = await user1Stub.Transfer.SendWithExceptionAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = DefaultAddress + }); + user1TransferResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); + user1TransferResult.TransactionResult.Error.ShouldContain("From address is in transfer blacklist"); + + var user2Stub = GetTester(TokenContractAddress, User2KeyPair); + var user2TransferResult = await user2Stub.Transfer.SendWithExceptionAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = DefaultAddress + }); + user2TransferResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); + user2TransferResult.TransactionResult.Error.ShouldContain("From address is in transfer blacklist"); + + // Test Parliament can still remove from blacklist (RemoveFromTransferBlackList is not using new controller) + var removeProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, nameof(TokenContractStub.RemoveFromTransferBlackList), User2Address); + await ApproveWithMinersAsync(removeProposalId); + await ParliamentContractStub.Release.SendAsync(removeProposalId); + + // Verify User2 is removed from blacklist + isUser2InBlackList = await TokenContractStub.IsInTransferBlackList.CallAsync(User2Address); + isUser2InBlackList.Value.ShouldBe(false); + + // User2 transfer should succeed after removal from blacklist + user2TransferResult = await user2Stub.Transfer.SendAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = User1Address + }); + user2TransferResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Mined); + } + + [Fact] + public async Task MultiTokenContract_BatchRemoveFromTransferBlackList_Test() + { + // Create and issue token using existing test method + await MultiTokenContract_Approve_Test(); + + var defaultParliament = await ParliamentContractStub.GetDefaultOrganizationAddress.CallAsync(new Empty()); + + // Transfer some tokens to user accounts for testing + await TokenContractStub.Transfer.SendAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = User1Address + }); + + await TokenContractStub.Transfer.SendAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = User2Address + }); + + // First, add multiple addresses to blacklist using BatchAddToTransferBlackList + var batchAddProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, + nameof(TokenContractStub.BatchAddToTransferBlackList), new BatchAddToTransferBlackListInput + { + Addresses = { User1Address, User2Address, DefaultAddress } + }); + await ApproveWithMinersAsync(batchAddProposalId); + await ParliamentContractStub.Release.SendAsync(batchAddProposalId); + + // Verify all addresses are in blacklist + var user1InBlackListStatus = await TokenContractStub.IsInTransferBlackList.CallAsync(User1Address); + user1InBlackListStatus.Value.ShouldBe(true); + var user2InBlackListStatus = await TokenContractStub.IsInTransferBlackList.CallAsync(User2Address); + user2InBlackListStatus.Value.ShouldBe(true); + var defaultInBlackListStatus = await TokenContractStub.IsInTransferBlackList.CallAsync(DefaultAddress); + defaultInBlackListStatus.Value.ShouldBe(true); + + // Test BatchRemoveFromTransferBlackList with empty input via Parliament should fail + var emptyInputProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, + nameof(TokenContractStub.BatchAddToTransferBlackList), new BatchRemoveFromTransferBlackListInput()); + await ApproveWithMinersAsync(emptyInputProposalId); + var emptyInputReleaseResult = await ParliamentContractStub.Release.SendWithExceptionAsync(emptyInputProposalId); + emptyInputReleaseResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); + emptyInputReleaseResult.TransactionResult.Error.ShouldContain("Invalid input"); + + // Test BatchRemoveFromTransferBlackList with unauthorized user should fail + var unauthorizedRemoveResult = await TokenContractStubUser.BatchRemoveFromTransferBlackList.SendWithExceptionAsync(new BatchRemoveFromTransferBlackListInput + { + Addresses = { User1Address, User2Address } + }); + unauthorizedRemoveResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); + unauthorizedRemoveResult.TransactionResult.Error.ShouldContain("Unauthorized behavior"); + + // Test BatchRemoveFromTransferBlackList with Parliament authority (should succeed) + var batchRemoveProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, + nameof(TokenContractStub.BatchRemoveFromTransferBlackList), new BatchRemoveFromTransferBlackListInput + { + Addresses = { User1Address, User2Address } + }); + await ApproveWithMinersAsync(batchRemoveProposalId); + await ParliamentContractStub.Release.SendAsync(batchRemoveProposalId); + + // Verify User1 and User2 are removed from blacklist + var user1BlackListStatusAfterRemove = await TokenContractStub.IsInTransferBlackList.CallAsync(User1Address); + user1BlackListStatusAfterRemove.Value.ShouldBe(false); + var user2BlackListStatusAfterRemove = await TokenContractStub.IsInTransferBlackList.CallAsync(User2Address); + user2BlackListStatusAfterRemove.Value.ShouldBe(false); + + // Verify DefaultAddress is still in blacklist (not removed) + var defaultBlackListStatusAfterRemove = await TokenContractStub.IsInTransferBlackList.CallAsync(DefaultAddress); + defaultBlackListStatusAfterRemove.Value.ShouldBe(true); + + // Test that transfers from removed addresses should succeed + var user1Stub = GetTester(TokenContractAddress, User1KeyPair); + var user1TransferResult = await user1Stub.Transfer.SendAsync(new TransferInput + { + Amount = Amount / 2, + Symbol = AliceCoinTokenInfo.Symbol, + To = DefaultAddress + }); + user1TransferResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Mined); + + var user2Stub = GetTester(TokenContractAddress, User2KeyPair); + var user2TransferResult = await user2Stub.Transfer.SendAsync(new TransferInput + { + Amount = Amount / 2, + Symbol = AliceCoinTokenInfo.Symbol, + To = User1Address + }); + user2TransferResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Mined); + + // Test that transfer from DefaultAddress (still in blacklist) should fail + var defaultTransferResult = await TokenContractStub.Transfer.SendWithExceptionAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = User1Address + }); + defaultTransferResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Failed); + defaultTransferResult.TransactionResult.Error.ShouldContain("From address is in transfer blacklist"); + + // Test BatchRemoveFromTransferBlackList with duplicate addresses (should handle gracefully) + var duplicateRemoveProposalId = await CreateProposalAsync(TokenContractAddress, defaultParliament, + nameof(TokenContractStub.BatchRemoveFromTransferBlackList), new BatchRemoveFromTransferBlackListInput + { + Addresses = { DefaultAddress, DefaultAddress, DefaultAddress } // Duplicate addresses + }); + await ApproveWithMinersAsync(duplicateRemoveProposalId); + await ParliamentContractStub.Release.SendAsync(duplicateRemoveProposalId); + + // Verify DefaultAddress is removed from blacklist + var defaultBlackListStatusFinal = await TokenContractStub.IsInTransferBlackList.CallAsync(DefaultAddress); + defaultBlackListStatusFinal.Value.ShouldBe(false); + + // Test that transfer from DefaultAddress should now succeed + defaultTransferResult = await TokenContractStub.Transfer.SendAsync(new TransferInput + { + Amount = Amount, + Symbol = AliceCoinTokenInfo.Symbol, + To = User1Address + }); + defaultTransferResult.TransactionResult.Status.ShouldBe(TransactionResultStatus.Mined); } } \ No newline at end of file