diff --git a/hedera-node/hedera-smart-contract-service-impl/src/main/java/com/hedera/node/app/service/contract/impl/handlers/ContractUpdateHandler.java b/hedera-node/hedera-smart-contract-service-impl/src/main/java/com/hedera/node/app/service/contract/impl/handlers/ContractUpdateHandler.java index 9aa0fbb90cfb..451b411a7c29 100644 --- a/hedera-node/hedera-smart-contract-service-impl/src/main/java/com/hedera/node/app/service/contract/impl/handlers/ContractUpdateHandler.java +++ b/hedera-node/hedera-smart-contract-service-impl/src/main/java/com/hedera/node/app/service/contract/impl/handlers/ContractUpdateHandler.java @@ -23,6 +23,7 @@ import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_AUTORENEW_ACCOUNT; import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_CONTRACT_ID; import static com.hedera.hapi.node.base.ResponseCodeEnum.MODIFYING_IMMUTABLE_CONTRACT; +import static com.hedera.hapi.node.base.ResponseCodeEnum.NOT_SUPPORTED; import static com.hedera.hapi.node.base.ResponseCodeEnum.REQUESTED_NUM_AUTOMATIC_ASSOCIATIONS_EXCEEDS_ASSOCIATION_LIMIT; import static com.hedera.node.app.service.token.api.AccountSummariesApi.SENTINEL_ACCOUNT_ID; import static com.hedera.node.app.spi.HapiUtils.EMPTY_KEY_LIST; @@ -34,7 +35,7 @@ import com.hedera.hapi.node.base.AccountID; import com.hedera.hapi.node.base.ContractID; import com.hedera.hapi.node.base.HederaFunctionality; -import com.hedera.hapi.node.base.Key.KeyOneOfType; +import com.hedera.hapi.node.base.Key; import com.hedera.hapi.node.contract.ContractUpdateTransactionBody; import com.hedera.hapi.node.state.token.Account; import com.hedera.node.app.service.token.ReadableAccountStore; @@ -46,8 +47,10 @@ import com.hedera.node.app.spi.workflows.PreCheckException; import com.hedera.node.app.spi.workflows.PreHandleContext; import com.hedera.node.app.spi.workflows.TransactionHandler; +import com.hedera.node.config.data.ContractsConfig; import com.hedera.node.config.data.EntitiesConfig; import com.hedera.node.config.data.LedgerConfig; +import com.hedera.node.config.data.StakingConfig; import com.hedera.node.config.data.TokensConfig; import edu.umd.cs.findbugs.annotations.NonNull; import java.util.Optional; @@ -102,12 +105,112 @@ public void handle(@NonNull final HandleContext context) throws HandleException final var accountStore = context.readableStore(ReadableAccountStore.class); final var toBeUpdated = accountStore.getContractById(target); - validateSemantics(toBeUpdated, context, op); + validateSemantics(toBeUpdated, context, op, accountStore); final var changed = update(toBeUpdated, context, op); context.serviceApi(TokenServiceApi.class).updateContract(changed); } + private void validateSemantics( + Account contract, + HandleContext context, + ContractUpdateTransactionBody op, + ReadableAccountStore accountStore) { + validateTrue(contract != null, INVALID_CONTRACT_ID); + + if (op.hasAdminKey() && processAdminKey(op)) { + throw new HandleException(INVALID_ADMIN_KEY); + } + + if (op.hasExpirationTime()) { + try { + context.attributeValidator().validateExpiry(op.expirationTime().seconds()); + } catch (HandleException e) { + validateFalse(contract.expiredAndPendingRemoval(), CONTRACT_EXPIRED_AND_PENDING_REMOVAL); + throw e; + } + } + + validateFalse(!onlyAffectsExpiry(op) && !isMutable(contract), MODIFYING_IMMUTABLE_CONTRACT); + validateFalse(reducesExpiry(op, contract.expirationSecond()), EXPIRATION_REDUCTION_NOT_ALLOWED); + + if (op.hasMaxAutomaticTokenAssociations()) { + final var ledgerConfig = context.configuration().getConfigData(LedgerConfig.class); + final var entitiesConfig = context.configuration().getConfigData(EntitiesConfig.class); + final var tokensConfig = context.configuration().getConfigData(TokensConfig.class); + final var contractsConfig = context.configuration().getConfigData(ContractsConfig.class); + + final long newMax = op.maxAutomaticTokenAssociations(); + + validateFalse( + newMax > ledgerConfig.maxAutoAssociations(), + REQUESTED_NUM_AUTOMATIC_ASSOCIATIONS_EXCEEDS_ASSOCIATION_LIMIT); + + validateFalse(newMax < contract.maxAutoAssociations(), EXISTING_AUTOMATIC_ASSOCIATIONS_EXCEED_GIVEN_LIMIT); + validateFalse( + entitiesConfig.limitTokenAssociations() && newMax > tokensConfig.maxPerAccount(), + REQUESTED_NUM_AUTOMATIC_ASSOCIATIONS_EXCEEDS_ASSOCIATION_LIMIT); + + validateTrue(contractsConfig.allowAutoAssociations(), NOT_SUPPORTED); + } + + // validate expiry metadata + final var currentMetadata = + new ExpiryMeta(contract.expirationSecond(), contract.autoRenewSeconds(), contract.autoRenewAccountId()); + final var updateMeta = new ExpiryMeta( + op.hasExpirationTime() ? op.expirationTime().seconds() : NA, + op.hasAutoRenewPeriod() ? op.autoRenewPeriod().seconds() : NA, + null); + context.expiryValidator().resolveUpdateAttempt(currentMetadata, updateMeta, false); + + context.serviceApi(TokenServiceApi.class) + .assertValidStakingElectionForUpdate( + context.configuration() + .getConfigData(StakingConfig.class) + .isEnabled(), + contract.declineReward(), + contract.stakedId().kind().name(), + contract.stakedAccountId(), + contract.stakedNodeId(), + accountStore, + context.networkInfo()); + } + + private boolean processAdminKey(ContractUpdateTransactionBody op) { + if (EMPTY_KEY_LIST.equals(op.adminKey())) { + return false; + } + return keyIfAcceptable(op.adminKey()); + } + + private boolean keyIfAcceptable(Key candidate) { + boolean keyIsNotValid = !KeyUtils.isValid(candidate); + return keyIsNotValid || candidate.contractID() != null; + } + + private boolean onlyAffectsExpiry(ContractUpdateTransactionBody op) { + return !(op.hasProxyAccountID() + || op.hasFileID() + || affectsMemo(op) + || op.hasAutoRenewPeriod() + || op.hasAdminKey()) + || op.hasMaxAutomaticTokenAssociations(); + } + + private boolean affectsMemo(ContractUpdateTransactionBody op) { + return op.hasMemoWrapper() || (op.memo() != null && op.memo().length() > 0); + } + + private boolean isMutable(final Account contract) { + return Optional.ofNullable(contract.key()) + .map(key -> !key.hasContractID()) + .orElse(false); + } + + private boolean reducesExpiry(ContractUpdateTransactionBody op, long curExpiry) { + return op.hasExpirationTime() && op.expirationTime().seconds() < curExpiry; + } + public Account update( @NonNull final Account contract, @NonNull final HandleContext context, @@ -150,76 +253,8 @@ public Account update( builder.autoRenewAccountId(op.autoRenewAccountId()); } if (op.hasMaxAutomaticTokenAssociations()) { - final var ledgerConfig = context.configuration().getConfigData(LedgerConfig.class); - final var entitiesConfig = context.configuration().getConfigData(EntitiesConfig.class); - final var tokensConfig = context.configuration().getConfigData(TokensConfig.class); - - validateFalse( - op.maxAutomaticTokenAssociations() > ledgerConfig.maxAutoAssociations(), - REQUESTED_NUM_AUTOMATIC_ASSOCIATIONS_EXCEEDS_ASSOCIATION_LIMIT); - - final long newMax = op.maxAutomaticTokenAssociations(); - validateFalse(newMax < contract.maxAutoAssociations(), EXISTING_AUTOMATIC_ASSOCIATIONS_EXCEED_GIVEN_LIMIT); - validateFalse( - entitiesConfig.limitTokenAssociations() && newMax > tokensConfig.maxPerAccount(), - REQUESTED_NUM_AUTOMATIC_ASSOCIATIONS_EXCEEDS_ASSOCIATION_LIMIT); - builder.maxAutoAssociations(op.maxAutomaticTokenAssociations()); } return builder.build(); } - - private void validateSemantics(Account contract, HandleContext context, ContractUpdateTransactionBody op) { - validateTrue(contract != null, INVALID_CONTRACT_ID); - - if (op.hasAdminKey()) { - boolean keyNotSentinel = !EMPTY_KEY_LIST.equals(op.adminKey()); - boolean keyIsUnset = op.adminKey().key().kind() == KeyOneOfType.UNSET; - boolean keyIsNotValid = !KeyUtils.isValid(op.adminKey()); - validateFalse(keyNotSentinel && (keyIsUnset || keyIsNotValid), INVALID_ADMIN_KEY); - } - - if (op.hasExpirationTime()) { - try { - context.attributeValidator().validateExpiry(op.expirationTime().seconds()); - } catch (HandleException e) { - validateFalse(contract.expiredAndPendingRemoval(), CONTRACT_EXPIRED_AND_PENDING_REMOVAL); - throw e; - } - } - - validateFalse(!onlyAffectsExpiry(op) && !isMutable(contract), MODIFYING_IMMUTABLE_CONTRACT); - validateFalse(reducesExpiry(op, contract.expirationSecond()), EXPIRATION_REDUCTION_NOT_ALLOWED); - - // validate expiry metadata - final var currentMetadata = - new ExpiryMeta(contract.expirationSecond(), contract.autoRenewSeconds(), contract.autoRenewAccountId()); - final var updateMeta = new ExpiryMeta( - op.hasExpirationTime() ? op.expirationTime().seconds() : NA, - op.hasAutoRenewPeriod() ? op.autoRenewPeriod().seconds() : NA, - null); - context.expiryValidator().resolveUpdateAttempt(currentMetadata, updateMeta, false); - } - - boolean onlyAffectsExpiry(ContractUpdateTransactionBody op) { - return !(op.hasProxyAccountID() - || op.hasFileID() - || affectsMemo(op) - || op.hasAutoRenewPeriod() - || op.hasAdminKey()); - } - - boolean affectsMemo(ContractUpdateTransactionBody op) { - return op.hasMemoWrapper() || (op.memo() != null && op.memo().length() > 0); - } - - boolean isMutable(final Account contract) { - return Optional.ofNullable(contract.key()) - .map(key -> !key.hasContractID()) - .orElse(false); - } - - private boolean reducesExpiry(ContractUpdateTransactionBody op, long curExpiry) { - return op.hasExpirationTime() && op.expirationTime().seconds() < curExpiry; - } } diff --git a/hedera-node/hedera-smart-contract-service-impl/src/main/java/com/hedera/node/app/service/contract/impl/infra/HevmTransactionFactory.java b/hedera-node/hedera-smart-contract-service-impl/src/main/java/com/hedera/node/app/service/contract/impl/infra/HevmTransactionFactory.java index 08e55eaaf4b2..a9ddbe1c1c50 100644 --- a/hedera-node/hedera-smart-contract-service-impl/src/main/java/com/hedera/node/app/service/contract/impl/infra/HevmTransactionFactory.java +++ b/hedera-node/hedera-smart-contract-service-impl/src/main/java/com/hedera/node/app/service/contract/impl/infra/HevmTransactionFactory.java @@ -269,7 +269,7 @@ private void assertValidCreation(@NonNull final ContractCreateTransactionBody bo REQUESTED_NUM_AUTOMATIC_ASSOCIATIONS_EXCEEDS_ASSOCIATION_LIMIT); final var usesNonDefaultProxyId = body.hasProxyAccountID() && !AccountID.DEFAULT.equals(body.proxyAccountID()); validateFalse(usesNonDefaultProxyId, PROXY_ACCOUNT_ID_FIELD_IS_DEPRECATED); - tokenServiceApi.assertValidStakingElection( + tokenServiceApi.assertValidStakingElectionForCreation( stakingConfig.isEnabled(), body.declineReward(), body.stakedId().kind().name(), diff --git a/hedera-node/hedera-smart-contract-service-impl/src/test/java/com/hedera/node/app/service/contract/impl/test/handlers/ContractUpdateHandlerTest.java b/hedera-node/hedera-smart-contract-service-impl/src/test/java/com/hedera/node/app/service/contract/impl/test/handlers/ContractUpdateHandlerTest.java new file mode 100644 index 000000000000..08fd05990f71 --- /dev/null +++ b/hedera-node/hedera-smart-contract-service-impl/src/test/java/com/hedera/node/app/service/contract/impl/test/handlers/ContractUpdateHandlerTest.java @@ -0,0 +1,630 @@ +/* + * Copyright (C) 2023 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hedera.node.app.service.contract.impl.test.handlers; + +import static com.hedera.hapi.node.base.ResponseCodeEnum.CONTRACT_EXPIRED_AND_PENDING_REMOVAL; +import static com.hedera.hapi.node.base.ResponseCodeEnum.EXISTING_AUTOMATIC_ASSOCIATIONS_EXCEED_GIVEN_LIMIT; +import static com.hedera.hapi.node.base.ResponseCodeEnum.EXPIRATION_REDUCTION_NOT_ALLOWED; +import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_ADMIN_KEY; +import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_AUTORENEW_ACCOUNT; +import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_CONTRACT_ID; +import static com.hedera.hapi.node.base.ResponseCodeEnum.MODIFYING_IMMUTABLE_CONTRACT; +import static com.hedera.hapi.node.base.ResponseCodeEnum.NOT_SUPPORTED; +import static com.hedera.hapi.node.base.ResponseCodeEnum.REQUESTED_NUM_AUTOMATIC_ASSOCIATIONS_EXCEEDS_ASSOCIATION_LIMIT; +import static com.hedera.node.app.service.contract.impl.test.TestHelpers.assertFailsWith; +import static com.hedera.node.app.service.token.api.AccountSummariesApi.SENTINEL_ACCOUNT_ID; +import static com.hedera.node.app.spi.HapiUtils.EMPTY_KEY_LIST; +import static com.hedera.node.app.spi.fixtures.Assertions.assertThrowsPreCheck; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.hedera.hapi.node.base.AccountID; +import com.hedera.hapi.node.base.ContractID; +import com.hedera.hapi.node.base.Duration; +import com.hedera.hapi.node.base.Key; +import com.hedera.hapi.node.base.Timestamp; +import com.hedera.hapi.node.base.TransactionID; +import com.hedera.hapi.node.contract.ContractUpdateTransactionBody; +import com.hedera.hapi.node.state.token.Account; +import com.hedera.hapi.node.state.token.Account.Builder; +import com.hedera.hapi.node.state.token.Account.StakedIdOneOfType; +import com.hedera.hapi.node.transaction.TransactionBody; +import com.hedera.node.app.service.contract.impl.handlers.ContractUpdateHandler; +import com.hedera.node.app.service.token.ReadableAccountStore; +import com.hedera.node.app.service.token.api.TokenServiceApi; +import com.hedera.node.app.spi.fixtures.workflows.FakePreHandleContext; +import com.hedera.node.app.spi.validation.AttributeValidator; +import com.hedera.node.app.spi.validation.ExpiryValidator; +import com.hedera.node.app.spi.workflows.HandleContext; +import com.hedera.node.app.spi.workflows.HandleException; +import com.hedera.node.app.spi.workflows.PreCheckException; +import com.hedera.node.config.data.ContractsConfig; +import com.hedera.node.config.data.EntitiesConfig; +import com.hedera.node.config.data.LedgerConfig; +import com.hedera.node.config.data.StakingConfig; +import com.hedera.node.config.data.TokensConfig; +import com.hedera.pbj.runtime.OneOf; +import com.hedera.test.utils.KeyUtils; +import com.swirlds.config.api.Configuration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ContractUpdateHandlerTest extends ContractHandlerTestBase { + + private final TransactionID transactionID = TransactionID.newBuilder() + .accountID(payer) + .transactionValidStart(consensusTimestamp) + .build(); + + @Mock + private HandleContext context; + + @Mock + private Account contract; + + @Mock + private AttributeValidator attributeValidator; + + @Mock + private ExpiryValidator expiryValidator; + + @Mock + private TokenServiceApi tokenServiceApi; + + @Mock + private Configuration configuration; + + @Mock + private StakingConfig stakingConfig; + + @Mock + private LedgerConfig ledgerConfig; + + @Mock + private EntitiesConfig entitiesConfig; + + @Mock + private TokensConfig tokensConfig; + + @Mock + private ContractsConfig contractsConfig; + + private ContractUpdateHandler subject; + + @BeforeEach + public void setUp() { + subject = new ContractUpdateHandler(); + } + + @Test + void sigRequiredWithoutKeyFails() throws PreCheckException { + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder()) + .transactionID(transactionID) + .build(); + final var context = new FakePreHandleContext(accountStore, txn); + + assertThrowsPreCheck(() -> subject.preHandle(context), INVALID_CONTRACT_ID); + } + + @Test + void invalidAutoRenewAccountIdFails() throws PreCheckException { + when(accountStore.getContractById(targetContract)).thenReturn(payerAccount); + + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance( + ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .autoRenewAccountId(asAccount("0.0.11111")) // invalid account + ) + .transactionID(transactionID) + .build(); + final var context = new FakePreHandleContext(accountStore, txn); + + assertThrowsPreCheck(() -> subject.preHandle(context), INVALID_AUTORENEW_ACCOUNT); + } + + @Test + void handleWithNullContextFails() { + final HandleContext context = null; + assertThrows(NullPointerException.class, () -> subject.handle(context)); + } + + @Test + void handleWithNullContractUpdateTransactionBodyFails() { + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance((ContractUpdateTransactionBody) null) + .transactionID(transactionID) + .build(); + when(context.body()).thenReturn(txn); + + assertThrows(NullPointerException.class, () -> subject.handle(context)); + } + + @Test + void handleWithNoContractIdFails() { + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance( + ContractUpdateTransactionBody.newBuilder().contractID((ContractID) null)) + .transactionID(transactionID) + .build(); + when(context.body()).thenReturn(txn); + + assertThrows(NullPointerException.class, () -> subject.handle(context)); + } + + @Test + void handleWithNonExistingContractIdFails() { + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance( + ContractUpdateTransactionBody.newBuilder().contractID(targetContract)) + .transactionID(transactionID) + .build(); + when(context.body()).thenReturn(txn); + + assertFailsWith(INVALID_CONTRACT_ID, () -> subject.handle(context)); + } + + @Test + void handleWithInvalidKeyFails() { + when(accountStore.getContractById(targetContract)).thenReturn(contract); + + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(Key.newBuilder())) + .transactionID(transactionID) + .build(); + when(context.body()).thenReturn(txn); + + assertFailsWith(INVALID_ADMIN_KEY, () -> subject.handle(context)); + } + + @Test + void handleWithInvalidContractIdKeyFails() { + when(accountStore.getContractById(targetContract)).thenReturn(contract); + + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(Key.newBuilder().contractID(ContractID.DEFAULT))) + .transactionID(transactionID) + .build(); + when(context.body()).thenReturn(txn); + + assertFailsWith(INVALID_ADMIN_KEY, () -> subject.handle(context)); + } + + @Test + void handleWithAValidContractIdKeyFails() { + when(accountStore.getContractById(targetContract)).thenReturn(contract); + + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(Key.newBuilder() + .contractID(ContractID.newBuilder().contractNum(100)))) + .transactionID(transactionID) + .build(); + when(context.body()).thenReturn(txn); + + assertFailsWith(INVALID_ADMIN_KEY, () -> subject.handle(context)); + } + + @Test + void handleWithInvalidExpirationTimeAndExpiredAndPendingRemovalTrueFails() { + final var expirationTime = 1L; + + when(accountStore.getContractById(targetContract)).thenReturn(contract); + doReturn(attributeValidator).when(context).attributeValidator(); + doThrow(HandleException.class).when(attributeValidator).validateExpiry(expirationTime); + when(contract.expiredAndPendingRemoval()).thenReturn(true); + + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(adminKey) + .expirationTime(Timestamp.newBuilder().seconds(expirationTime))) + .transactionID(transactionID) + .build(); + + when(context.body()).thenReturn(txn); + + assertFailsWith(CONTRACT_EXPIRED_AND_PENDING_REMOVAL, () -> subject.handle(context)); + } + + @Test + void handleModifyImmutableContract() { + when(accountStore.getContractById(targetContract)).thenReturn(contract); + + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(adminKey)) + .transactionID(transactionID) + .build(); + + when(context.body()).thenReturn(txn); + + assertFailsWith(MODIFYING_IMMUTABLE_CONTRACT, () -> subject.handle(context)); + } + + @Test + void handleWithExpirationTimeLesserThenExpirationSecondsFails() { + final var expirationTime = 1L; + + doReturn(attributeValidator).when(context).attributeValidator(); + when(accountStore.getContractById(targetContract)).thenReturn(contract); + when(contract.key()).thenReturn(Key.newBuilder().build()); + when(contract.expirationSecond()).thenReturn(expirationTime + 1); + + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(adminKey) + .memo("memo") + .expirationTime(Timestamp.newBuilder().seconds(expirationTime))) + .transactionID(transactionID) + .build(); + + when(context.body()).thenReturn(txn); + + assertFailsWith(EXPIRATION_REDUCTION_NOT_ALLOWED, () -> subject.handle(context)); + } + + @Test + void maxAutomaticTokenAssociationsBiggerThenAllowedFails() { + final var maxAutomaticTokenAssociations = 10; + + when(configuration.getConfigData(LedgerConfig.class)).thenReturn(ledgerConfig); + when(ledgerConfig.maxAutoAssociations()).thenReturn(maxAutomaticTokenAssociations - 1); + when(context.configuration()).thenReturn(configuration); + + when(accountStore.getContractById(targetContract)).thenReturn(contract); + + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(adminKey) + .memo("memo") + .maxAutomaticTokenAssociations(maxAutomaticTokenAssociations)) + .transactionID(transactionID) + .build(); + + when(context.body()).thenReturn(txn); + + assertFailsWith(REQUESTED_NUM_AUTOMATIC_ASSOCIATIONS_EXCEEDS_ASSOCIATION_LIMIT, () -> subject.handle(context)); + } + + @Test + void maxAutomaticTokenAssociationsSmallerThenContractLimitFails() { + final var maxAutomaticTokenAssociations = 10; + + when(configuration.getConfigData(LedgerConfig.class)).thenReturn(ledgerConfig); + when(ledgerConfig.maxAutoAssociations()).thenReturn(maxAutomaticTokenAssociations + 1); + when(context.configuration()).thenReturn(configuration); + + when(accountStore.getContractById(targetContract)).thenReturn(contract); + when(contract.maxAutoAssociations()).thenReturn(maxAutomaticTokenAssociations + 1); + + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(adminKey) + .memo("memo") + .maxAutomaticTokenAssociations(maxAutomaticTokenAssociations)) + .transactionID(transactionID) + .build(); + + when(context.body()).thenReturn(txn); + + assertFailsWith(EXISTING_AUTOMATIC_ASSOCIATIONS_EXCEED_GIVEN_LIMIT, () -> subject.handle(context)); + } + + @Test + void maxAutomaticTokenAssociationsBiggerThenMaxConfigFails() { + final var maxAutomaticTokenAssociations = 10; + + when(configuration.getConfigData(LedgerConfig.class)).thenReturn(ledgerConfig); + when(configuration.getConfigData(EntitiesConfig.class)).thenReturn(entitiesConfig); + when(configuration.getConfigData(TokensConfig.class)).thenReturn(tokensConfig); + when(ledgerConfig.maxAutoAssociations()).thenReturn(maxAutomaticTokenAssociations + 1); + when(entitiesConfig.limitTokenAssociations()).thenReturn(true); + when(tokensConfig.maxPerAccount()).thenReturn(maxAutomaticTokenAssociations - 1); + when(context.configuration()).thenReturn(configuration); + + when(contract.maxAutoAssociations()).thenReturn(maxAutomaticTokenAssociations - 1); + when(accountStore.getContractById(targetContract)).thenReturn(contract); + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(adminKey) + .memo("memo") + .maxAutomaticTokenAssociations(maxAutomaticTokenAssociations)) + .transactionID(transactionID) + .build(); + + when(context.body()).thenReturn(txn); + + assertFailsWith(REQUESTED_NUM_AUTOMATIC_ASSOCIATIONS_EXCEEDS_ASSOCIATION_LIMIT, () -> subject.handle(context)); + } + + @Test + void maxAutomaticTokenAssociationsWhenItIsNotAllowedFails() { + final var maxAutomaticTokenAssociations = 10; + + when(configuration.getConfigData(LedgerConfig.class)).thenReturn(ledgerConfig); + when(configuration.getConfigData(EntitiesConfig.class)).thenReturn(entitiesConfig); + when(configuration.getConfigData(TokensConfig.class)).thenReturn(tokensConfig); + when(configuration.getConfigData(ContractsConfig.class)).thenReturn(contractsConfig); + when(ledgerConfig.maxAutoAssociations()).thenReturn(maxAutomaticTokenAssociations + 1); + when(entitiesConfig.limitTokenAssociations()).thenReturn(true); + when(tokensConfig.maxPerAccount()).thenReturn(maxAutomaticTokenAssociations + 1); + when(contractsConfig.allowAutoAssociations()).thenReturn(false); + when(context.configuration()).thenReturn(configuration); + + when(contract.maxAutoAssociations()).thenReturn(maxAutomaticTokenAssociations - 1); + when(accountStore.getContractById(targetContract)).thenReturn(contract); + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(adminKey) + .memo("memo") + .maxAutomaticTokenAssociations(maxAutomaticTokenAssociations)) + .transactionID(transactionID) + .build(); + + when(context.body()).thenReturn(txn); + + assertFailsWith(NOT_SUPPORTED, () -> subject.handle(context)); + } + + @Test + void verifyTheCorrectOutsideValidatorsAndUpdateContractAPIAreCalled() { + doReturn(attributeValidator).when(context).attributeValidator(); + when(accountStore.getContractById(targetContract)).thenReturn(contract); + when(contract.key()).thenReturn(Key.newBuilder().build()); + when(contract.stakedId()).thenReturn(new OneOf<>(StakedIdOneOfType.STAKED_ACCOUNT_ID, null)); + when(context.expiryValidator()).thenReturn(expiryValidator); + when(context.serviceApi(TokenServiceApi.class)).thenReturn(tokenServiceApi); + given(context.readableStore(ReadableAccountStore.class)).willReturn(accountStore); + final var txn = TransactionBody.newBuilder() + .contractUpdateInstance(ContractUpdateTransactionBody.newBuilder() + .contractID(targetContract) + .adminKey(adminKey) + .memo("memo")) + .transactionID(transactionID) + .build(); + when(context.body()).thenReturn(txn); + when(context.configuration()).thenReturn(configuration); + when(configuration.getConfigData(StakingConfig.class)).thenReturn(stakingConfig); + when(stakingConfig.isEnabled()).thenReturn(true); + when(contract.copyBuilder()).thenReturn(mock(Builder.class)); + + subject.handle(context); + + verify(expiryValidator, times(1)).resolveUpdateAttempt(any(), any(), anyBoolean()); + verify(tokenServiceApi, times(1)) + .assertValidStakingElectionForUpdate(anyBoolean(), anyBoolean(), any(), any(), any(), any(), any()); + verify(tokenServiceApi, times(1)).updateContract(any()); + } + + @Test + void adminKeyUpdated() { + final var contract = Account.newBuilder().build(); + final var op = ContractUpdateTransactionBody.newBuilder() + .adminKey(KeyUtils.A_COMPLEX_KEY) + .build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(op.adminKey(), updatedContract.key()); + } + + @Test + void adminKeyNotUpdatedWhenKeyIsEmpty() { + final var contract = Account.newBuilder().key(KeyUtils.A_COMPLEX_KEY).build(); + final var op = ContractUpdateTransactionBody.newBuilder() + .adminKey(EMPTY_KEY_LIST) + .build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(contract.key(), updatedContract.key()); + } + + @Test + void expirationTimeUpdated() { + final var contract = Account.newBuilder().build(); + final var op = ContractUpdateTransactionBody.newBuilder() + .expirationTime(Timestamp.newBuilder().seconds(10)) + .build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(op.expirationTime().seconds(), updatedContract.expirationSecond()); + assertFalse(updatedContract.expiredAndPendingRemoval()); + } + + @Test + void autoRenewSecondsUpdated() { + final var contract = Account.newBuilder().build(); + final var op = ContractUpdateTransactionBody.newBuilder() + .autoRenewPeriod(Duration.newBuilder().seconds(10)) + .build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(op.autoRenewPeriod().seconds(), updatedContract.autoRenewSeconds()); + } + + @Test + void memoUpdatedPassingMemoField() { + when(context.attributeValidator()).thenReturn(attributeValidator); + + final var contract = Account.newBuilder().build(); + final var op = ContractUpdateTransactionBody.newBuilder().memo("memo").build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(op.memo(), updatedContract.memo()); + verify(attributeValidator, times(1)).validateMemo(op.memo()); + } + + @Test + void memoUpdatedPassingMemoWrapperField() { + when(context.attributeValidator()).thenReturn(attributeValidator); + + final var contract = Account.newBuilder().build(); + final var op = + ContractUpdateTransactionBody.newBuilder().memoWrapper("memo").build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(op.memoWrapper(), updatedContract.memo()); + verify(attributeValidator, times(1)).validateMemo(op.memoWrapper()); + } + + @Test + void stakedAccountIdUpdated() { + final var contract = Account.newBuilder().build(); + final var op = ContractUpdateTransactionBody.newBuilder() + .stakedAccountId(AccountID.newBuilder().accountNum(1)) + .build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(op.stakedAccountId(), updatedContract.stakedAccountId()); + } + + @Test + void stakedAccountIdWithSentinelAccountID() { + final var contract = Account.newBuilder().build(); + final var op = ContractUpdateTransactionBody.newBuilder() + .stakedAccountId(SENTINEL_ACCOUNT_ID) + .build(); + + final var updatedContract = subject.update(contract, context, op); + + assertNull(updatedContract.stakedAccountId()); + } + + @Test + void stakedNodeIdUpdated() { + final var contract = Account.newBuilder().build(); + final var op = + ContractUpdateTransactionBody.newBuilder().stakedNodeId(10).build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(op.stakedNodeId(), updatedContract.stakedNodeId()); + } + + @Test + void declineRewardUpdated() { + final var contract = Account.newBuilder().build(); + final var op = + ContractUpdateTransactionBody.newBuilder().declineReward(true).build(); + + final var updatedContract = subject.update(contract, context, op); + + assertTrue(updatedContract.declineReward()); + } + + @Test + void autoRenewAccountIdUpdated() { + final var contract = Account.newBuilder().build(); + final var op = ContractUpdateTransactionBody.newBuilder() + .autoRenewAccountId(AccountID.newBuilder().accountNum(10)) + .build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(op.autoRenewAccountId(), updatedContract.autoRenewAccountId()); + } + + @Test + void maxAutomaticTokenAssociationsUpdated() { + final var contract = Account.newBuilder().build(); + final var op = ContractUpdateTransactionBody.newBuilder() + .maxAutomaticTokenAssociations(10) + .build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(op.maxAutomaticTokenAssociations(), updatedContract.maxAutoAssociations()); + } + + @Test + void updateAllFields() { + when(context.attributeValidator()).thenReturn(attributeValidator); + + final var contract = Account.newBuilder().build(); + final var op = ContractUpdateTransactionBody.newBuilder() + .adminKey(KeyUtils.A_COMPLEX_KEY) + .expirationTime(Timestamp.newBuilder().seconds(10)) + .autoRenewPeriod(Duration.newBuilder().seconds(10)) + .memo("memo") + .stakedAccountId(AccountID.newBuilder().accountNum(1)) + .stakedNodeId(10) + .declineReward(true) + .autoRenewAccountId(AccountID.newBuilder().accountNum(10)) + .maxAutomaticTokenAssociations(10) + .build(); + + final var updatedContract = subject.update(contract, context, op); + + assertEquals(op.adminKey(), updatedContract.key()); + assertEquals(op.expirationTime().seconds(), updatedContract.expirationSecond()); + assertFalse(updatedContract.expiredAndPendingRemoval()); + assertEquals(op.autoRenewPeriod().seconds(), updatedContract.autoRenewSeconds()); + assertEquals(op.memo(), updatedContract.memo()); + assertEquals(op.stakedAccountId(), updatedContract.stakedAccountId()); + assertEquals(op.stakedNodeId(), updatedContract.stakedNodeId()); + assertTrue(updatedContract.declineReward()); + assertEquals(op.autoRenewAccountId(), updatedContract.autoRenewAccountId()); + assertEquals(op.maxAutomaticTokenAssociations(), updatedContract.maxAutoAssociations()); + verify(attributeValidator, times(1)).validateMemo(op.memo()); + } +} diff --git a/hedera-node/hedera-smart-contract-service-impl/src/test/java/com/hedera/node/app/service/contract/impl/test/infra/HevmTransactionFactoryTest.java b/hedera-node/hedera-smart-contract-service-impl/src/test/java/com/hedera/node/app/service/contract/impl/test/infra/HevmTransactionFactoryTest.java index 8499812a1523..de9d6ada2b39 100644 --- a/hedera-node/hedera-smart-contract-service-impl/src/test/java/com/hedera/node/app/service/contract/impl/test/infra/HevmTransactionFactoryTest.java +++ b/hedera-node/hedera-smart-contract-service-impl/src/test/java/com/hedera/node/app/service/contract/impl/test/infra/HevmTransactionFactoryTest.java @@ -282,7 +282,7 @@ void fromHapiCreationDoesNotPermitNonDefaultProxyField() { void fromHapiCreationValidatesStaking() { doThrow(new HandleException(INVALID_STAKING_ID)) .when(tokenServiceApi) - .assertValidStakingElection( + .assertValidStakingElectionForCreation( DEFAULT_STAKING_CONFIG.isEnabled(), false, "STAKED_NODE_ID", diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/api/TokenServiceApiImpl.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/api/TokenServiceApiImpl.java index 9991d2f10422..a92cd66dae8a 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/api/TokenServiceApiImpl.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/api/TokenServiceApiImpl.java @@ -106,7 +106,7 @@ public TokenServiceApiImpl( } @Override - public void assertValidStakingElection( + public void assertValidStakingElectionForCreation( final boolean isStakingEnabled, final boolean hasDeclineRewardChange, @NonNull final String stakedIdKind, @@ -124,6 +124,25 @@ public void assertValidStakingElection( networkInfo); } + @Override + public void assertValidStakingElectionForUpdate( + final boolean isStakingEnabled, + final boolean hasDeclineRewardChange, + @NonNull final String stakedIdKind, + @Nullable final AccountID stakedAccountIdInOp, + @Nullable final Long stakedNodeIdInOp, + @NonNull final ReadableAccountStore accountStore, + @NonNull final NetworkInfo networkInfo) { + stakingValidator.validateStakedIdForUpdate( + isStakingEnabled, + hasDeclineRewardChange, + stakedIdKind, + stakedAccountIdInOp, + stakedNodeIdInOp, + accountStore, + networkInfo); + } + /** * {@inheritDoc} */ diff --git a/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/api/TokenServiceApiImplTest.java b/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/api/TokenServiceApiImplTest.java index 30759249c0b8..0c737e94a782 100644 --- a/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/api/TokenServiceApiImplTest.java +++ b/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/api/TokenServiceApiImplTest.java @@ -120,7 +120,8 @@ void delegatesToCustomFeeTest() { @Test void delegatesStakingValidationAsExpected() { - subject.assertValidStakingElection(true, false, "STAKED_NODE_ID", null, 123L, accountStore, networkInfo); + subject.assertValidStakingElectionForCreation( + true, false, "STAKED_NODE_ID", null, 123L, accountStore, networkInfo); verify(stakingValidator) .validateStakedIdForCreation(true, false, "STAKED_NODE_ID", null, 123L, accountStore, networkInfo); diff --git a/hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/api/TokenServiceApi.java b/hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/api/TokenServiceApi.java index 58f4892d25e3..2abcdbb86df0 100644 --- a/hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/api/TokenServiceApi.java +++ b/hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/api/TokenServiceApi.java @@ -61,7 +61,7 @@ void deleteAndTransfer( @NonNull DeleteCapableTransactionRecordBuilder recordBuilder); /** - * Validates the given staking election relative to the given account store, network info, and staking config. + * Validates the creation of a given staking election relative to the given account store, network info, and staking config. * * @param isStakingEnabled if staking is enabled * @param hasDeclineRewardChange if the transaction body has decline reward field to be updated @@ -71,7 +71,27 @@ void deleteAndTransfer( * @param accountStore readable account store * @throws HandleException if the staking election is invalid */ - void assertValidStakingElection( + void assertValidStakingElectionForCreation( + boolean isStakingEnabled, + boolean hasDeclineRewardChange, + @NonNull String stakedIdKind, + @Nullable AccountID stakedAccountIdInOp, + @Nullable Long stakedNodeIdInOp, + @NonNull ReadableAccountStore accountStore, + @NonNull NetworkInfo networkInfo); + + /** + * Validates the update of a given staking election relative to the given account store, network info, and staking config. + * + * @param isStakingEnabled if staking is enabled + * @param hasDeclineRewardChange if the transaction body has decline reward field to be updated + * @param stakedIdKind staked id kind (account or node) + * @param stakedAccountIdInOp staked account id + * @param stakedNodeIdInOp staked node id + * @param accountStore readable account store + * @throws HandleException if the staking election is invalid + */ + void assertValidStakingElectionForUpdate( boolean isStakingEnabled, boolean hasDeclineRewardChange, @NonNull String stakedIdKind, diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/contract/hapi/ContractUpdateSuite.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/contract/hapi/ContractUpdateSuite.java index b5e218530e0b..ce3eb9566ea8 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/contract/hapi/ContractUpdateSuite.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/contract/hapi/ContractUpdateSuite.java @@ -151,6 +151,7 @@ private HapiSpec updateStakingFieldsWorks() { } // https://github.com/hashgraph/hedera-services/issues/2877 + @HapiTest private HapiSpec eip1014AddressAlwaysHasPriority() { final var contract = "VariousCreate2Calls"; final var creationTxn = "creationTxn"; @@ -323,6 +324,7 @@ private HapiSpec canMakeContractImmutableWithEmptyKeyList() { .then(contractUpdate(CONTRACT).newKey(NEW_ADMIN_KEY).hasKnownStatus(MODIFYING_IMMUTABLE_CONTRACT)); } + @HapiTest private HapiSpec givenAdminKeyMustBeValid() { final var contract = "BalanceLookup"; return defaultHapiSpec("GivenAdminKeyMustBeValid")