diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/record/SingleTransactionRecordBuilderImpl.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/record/SingleTransactionRecordBuilderImpl.java index 7b467a271b8e..70b0d1b8d997 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/record/SingleTransactionRecordBuilderImpl.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/record/SingleTransactionRecordBuilderImpl.java @@ -116,6 +116,7 @@ public class SingleTransactionRecordBuilderImpl FeeRecordBuilder, ContractDeleteRecordBuilder, GenesisAccountRecordBuilder { + // base transaction data private Transaction transaction; private Bytes transactionBytes = Bytes.EMPTY; 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 1e017b8628dd..23a5c06b469c 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 @@ -16,20 +16,41 @@ package com.hedera.node.app.service.contract.impl.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.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; +import static com.hedera.node.app.spi.validation.ExpiryMeta.NA; +import static com.hedera.node.app.spi.workflows.HandleException.validateFalse; +import static com.hedera.node.app.spi.workflows.HandleException.validateTrue; import static java.util.Objects.requireNonNull; 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.contract.ContractUpdateTransactionBody; +import com.hedera.hapi.node.state.token.Account; +import com.hedera.node.app.service.token.ReadableAccountStore; +import com.hedera.node.app.service.token.api.TokenServiceApi; +import com.hedera.node.app.spi.key.KeyUtils; +import com.hedera.node.app.spi.validation.ExpiryMeta; 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.app.spi.workflows.PreHandleContext; import com.hedera.node.app.spi.workflows.TransactionHandler; +import com.hedera.node.config.data.EntitiesConfig; +import com.hedera.node.config.data.LedgerConfig; +import com.hedera.node.config.data.TokensConfig; import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.Optional; import javax.inject.Inject; import javax.inject.Singleton; @@ -38,6 +59,7 @@ */ @Singleton public class ContractUpdateHandler implements TransactionHandler { + @Inject public ContractUpdateHandler() { // Exists for injection @@ -74,6 +96,130 @@ private boolean hasCryptoAdminKey(final ContractUpdateTransactionBody op) { @Override public void handle(@NonNull final HandleContext context) throws HandleException { - throw new UnsupportedOperationException("Not implemented"); + final var txn = requireNonNull(context).body(); + final var op = txn.contractUpdateInstanceOrThrow(); + final var target = op.contractIDOrThrow(); + + final var accountStore = context.readableStore(ReadableAccountStore.class); + final var toBeUpdated = accountStore.getContractById(target); + validateSemantics(toBeUpdated, context, op); + final var changed = update(toBeUpdated, context, op); + + context.serviceApi(TokenServiceApi.class).updateContract(changed); + } + + public Account update( + @NonNull final Account contract, + @NonNull final HandleContext context, + @NonNull final ContractUpdateTransactionBody op) { + final var builder = contract.copyBuilder(); + if (op.hasAdminKey()) { + if (EMPTY_KEY_LIST.equals(op.adminKey())) { + builder.key(contract.key()); + } else { + builder.key(op.adminKey()); + } + } + if (op.hasExpirationTime()) { + if (contract.expiredAndPendingRemoval()) { + builder.expiredAndPendingRemoval(false); + } + builder.expirationSecond(op.expirationTime().seconds()); + } + if (op.hasAutoRenewPeriod()) { + builder.autoRenewSeconds(op.autoRenewPeriod().seconds()); + } + if (affectsMemo(op)) { + final var newMemo = op.hasMemoWrapper() ? op.memoWrapper() : op.memo(); + context.attributeValidator().validateMemo(newMemo); + builder.memo(newMemo); + } + if (op.hasStakedAccountId()) { + if (SENTINEL_ACCOUNT_ID.equals(op.stakedAccountId())) { + builder.stakedAccountId((AccountID) null); + } else { + builder.stakedAccountId(op.stakedAccountId()); + } + } else if (op.hasStakedNodeId()) { + builder.stakedNodeId(op.stakedNodeId()); + } + if (op.hasDeclineReward()) { + builder.declineReward(op.declineReward()); + } + if (op.hasAutoRenewAccountId()) { + 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); + } + + 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-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 3027cfe3546b..95fd4d2fe95c 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 @@ -339,6 +339,11 @@ public long originalKvUsageFor(@NonNull final AccountID id) { return oldAccount == null ? 0 : oldAccount.contractKvPairsNumber(); } + @Override + public void updateContract(Account contract) { + accountStore.put(contract); + } + /** * A utility method that charges (debits) the payer up to the given total fee. If the payer account doesn't exist, * then an exception is thrown. 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 e8bc257ab9b0..c24096772b84 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 @@ -18,6 +18,7 @@ import com.hedera.hapi.node.base.AccountID; import com.hedera.hapi.node.base.ContractID; +import com.hedera.hapi.node.state.token.Account; import com.hedera.hapi.node.token.CryptoTransferTransactionBody; import com.hedera.node.app.service.token.ReadableAccountStore; import com.hedera.node.app.spi.fees.Fees; @@ -185,4 +186,10 @@ void chargeFees( * @return the number of storage slots used by the given account before any changes were made */ long originalKvUsageFor(@NonNull AccountID id); + + /** + * Updates the passed contract + * @param contract the contract that is updated + */ + void updateContract(Account contract); } 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 034c2ba8550d..b5e218530e0b 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 @@ -102,6 +102,7 @@ public List getSpecsInSuite() { updateStakingFieldsWorks()); } + @HapiTest private HapiSpec updateStakingFieldsWorks() { return defaultHapiSpec("updateStakingFieldsWorks") .given( @@ -207,6 +208,7 @@ private HapiSpec eip1014AddressAlwaysHasPriority() { })))))); } + @HapiTest private HapiSpec updateWithBothMemoSettersWorks() { final var firstMemo = "First"; final var secondMemo = "Second"; @@ -226,6 +228,7 @@ private HapiSpec updateWithBothMemoSettersWorks() { getContractInfo(CONTRACT).has(contractWith().memo(thirdMemo))); } + @HapiTest private HapiSpec updatingExpiryWorks() { final var newExpiry = Instant.now().getEpochSecond() + 5 * ONE_MONTH; return defaultHapiSpec("UpdatingExpiryWorks") @@ -234,6 +237,7 @@ private HapiSpec updatingExpiryWorks() { .then(getContractInfo(CONTRACT).has(contractWith().expiry(newExpiry))); } + @HapiTest private HapiSpec rejectsExpiryTooFarInTheFuture() { final var smallBuffer = 12_345L; final var excessiveExpiry = defaultMaxLifetime + Instant.now().getEpochSecond() + smallBuffer; @@ -244,6 +248,7 @@ private HapiSpec rejectsExpiryTooFarInTheFuture() { .then(contractUpdate(CONTRACT).newExpirySecs(excessiveExpiry).hasKnownStatus(INVALID_EXPIRATION_TIME)); } + @HapiTest private HapiSpec updateAutoRenewWorks() { return defaultHapiSpec("UpdateAutoRenewWorks") .given( @@ -254,6 +259,7 @@ private HapiSpec updateAutoRenewWorks() { .then(getContractInfo(CONTRACT).has(contractWith().autoRenew(THREE_MONTHS_IN_SECONDS + ONE_DAY))); } + @HapiTest private HapiSpec updateAutoRenewAccountWorks() { final var autoRenewAccount = "autoRenewAccount"; final var newAutoRenewAccount = "newAutoRenewAccount"; @@ -280,6 +286,7 @@ private HapiSpec updateAutoRenewAccountWorks() { .logged()); } + @HapiTest private HapiSpec updateAdminKeyWorks() { return defaultHapiSpec("UpdateAdminKeyWorks") .given( @@ -419,6 +426,7 @@ HapiSpec fridayThe13thSpec() { contractDelete(contract + suffix).payingWith(payer).hasKnownStatus(SUCCESS)); } + @HapiTest private HapiSpec updateDoesNotChangeBytecode() { // HSCS-DCPR-001 final var simpleStorageContract = "SimpleStorage";