From 55bef50921e3c27a7829242e82eb90670550b385 Mon Sep 17 00:00:00 2001 From: Neeharika Sompalli <52669918+Neeharika-Sompalli@users.noreply.github.com> Date: Wed, 2 Aug 2023 13:32:36 -0500 Subject: [PATCH] Use `getOriginalValue` instead of using from readableStore (#7707) Signed-off-by: Matt Hess Signed-off-by: Neeharika-Sompalli Signed-off-by: Michael Heinrichs Signed-off-by: neeharika.sompalli Co-authored-by: Matt Hess Co-authored-by: Michael Heinrichs --- .../token/impl/RecordFinalizerBase.java | 214 ++++++ .../token/impl/WritableAccountStore.java | 36 + .../service/token/impl/WritableNftStore.java | 14 + .../token/impl/WritableStakingInfoStore.java | 12 + .../impl/WritableTokenRelationStore.java | 16 + .../token/impl/WritableTokenStore.java | 14 + .../impl/comparator/TokenComparators.java | 3 - .../handlers/FinalizeChildRecordHandler.java | 87 +++ .../handlers/FinalizeParentRecordHandler.java | 220 +----- .../staking/StakeRewardCalculator.java | 4 +- .../staking/StakeRewardCalculatorImpl.java | 6 +- .../staking/StakingRewardsDistributor.java | 6 +- .../staking/StakingRewardsHandlerImpl.java | 21 +- .../staking/StakingRewardsHelper.java | 17 +- .../test/comparator/TokenComparatorsTest.java | 34 - .../FinalizeChildRecordHandlerTest.java | 692 ++++++++++++++++++ .../FinalizeParentRecordHandlerTest.java | 261 ++++++- .../StakeRewardCalculatorImplTest.java | 6 +- .../token/records/ChildRecordFinalizer.java | 39 + .../token/records/ParentRecordFinalizer.java | 46 ++ .../src/main/java/module-info.java | 1 + 21 files changed, 1457 insertions(+), 292 deletions(-) create mode 100644 hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/RecordFinalizerBase.java create mode 100644 hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/FinalizeChildRecordHandler.java create mode 100644 hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/FinalizeChildRecordHandlerTest.java create mode 100644 hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/records/ChildRecordFinalizer.java create mode 100644 hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/records/ParentRecordFinalizer.java diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/RecordFinalizerBase.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/RecordFinalizerBase.java new file mode 100644 index 000000000000..e1c0a4b5d333 --- /dev/null +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/RecordFinalizerBase.java @@ -0,0 +1,214 @@ +/* + * 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.token.impl; + +import static com.hedera.hapi.node.base.ResponseCodeEnum.FAIL_INVALID; +import static com.hedera.node.app.service.token.impl.comparator.TokenComparators.ACCOUNT_AMOUNT_COMPARATOR; +import static com.hedera.node.app.service.token.impl.handlers.staking.StakingRewardsHelper.asAccountAmounts; +import static com.hedera.node.app.spi.workflows.HandleException.validateTrue; + +import com.hedera.hapi.node.base.AccountID; +import com.hedera.hapi.node.base.NftID; +import com.hedera.hapi.node.base.NftTransfer; +import com.hedera.hapi.node.base.TokenID; +import com.hedera.hapi.node.base.TokenTransferList; +import com.hedera.hapi.node.state.common.EntityIDPair; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Base class for both {@link com.hedera.node.app.service.token.records.ParentRecordFinalizer} and {@link + * com.hedera.node.app.service.token.records.ChildRecordFinalizer}. This contains methods that are common to both + * classes. + */ +public class RecordFinalizerBase { + private static final AccountID ZERO_ACCOUNT_ID = + AccountID.newBuilder().accountNum(0).build(); + + /** + * Gets all hbar changes for all modified accounts from the given {@link WritableAccountStore}. + * @param writableAccountStore the {@link WritableAccountStore} to get the hbar changes from + * @return a {@link Map} of {@link AccountID} to {@link Long} representing the hbar changes for all modified + */ + @NonNull + protected Map hbarChangesFrom(@NonNull final WritableAccountStore writableAccountStore) { + final var hbarChanges = new HashMap(); + var netHbarBalance = 0; + for (final AccountID modifiedAcctId : writableAccountStore.modifiedAccountsInState()) { + final var modifiedAcct = writableAccountStore.getAccountById(modifiedAcctId); + final var persistedAcct = writableAccountStore.getOriginalValue(modifiedAcctId); + // It's possible the modified account was created in this transaction, in which case the non-existent + // persisted account effectively has no balance (i.e. its prior balance is 0) + final var persistedBalance = persistedAcct != null ? persistedAcct.tinybarBalance() : 0; + + // Never allow an account's net hbar balance to be negative + validateTrue(modifiedAcct.tinybarBalance() >= 0, FAIL_INVALID); + + final var netHbarChange = modifiedAcct.tinybarBalance() - persistedBalance; + if (netHbarChange != 0) { + netHbarBalance += netHbarChange; + hbarChanges.put(modifiedAcctId, netHbarChange); + } + } + // Since this is a finalization handler, we should have already succeeded in handling the transaction in a + // handler before getting here. Therefore, if the sum is non-zero, something went wrong, and we'll respond with + // FAIL_INVALID + validateTrue(netHbarBalance == 0, FAIL_INVALID); + + return hbarChanges; + } + + /** + * Gets all fungible tokenRelation balances for all modified token relations from the given {@link WritableTokenRelationStore}. + * @param writableTokenRelStore the {@link WritableTokenRelationStore} to get the token relation balances from + * @return a {@link Map} of {@link EntityIDPair} to {@link Long} representing the token relation balances for all + * modified token relations + */ + @NonNull + protected Map fungibleChangesFrom( + @NonNull final WritableTokenRelationStore writableTokenRelStore) { + final var fungibleChanges = new HashMap(); + for (final EntityIDPair modifiedRel : writableTokenRelStore.modifiedTokens()) { + final var relAcctId = modifiedRel.accountId(); + final var relTokenId = modifiedRel.tokenId(); + final var modifiedTokenRel = writableTokenRelStore.get(relAcctId, relTokenId); + final var persistedTokenRel = writableTokenRelStore.getOriginalValue(relAcctId, relTokenId); + + // It's possible the modified token rel was created in this transaction. If so, use a persisted balance of 0 + // for the token rel that didn't exist + final var persistedBalance = persistedTokenRel != null ? persistedTokenRel.balance() : 0; + // It is possible that the account is dissociated with the token in this transaction. If so, use a + // balance of 0 for the token rel that didn't exist + final var modifiedTokenRelBalance = modifiedTokenRel != null ? modifiedTokenRel.balance() : 0; + // Never allow a fungible token's balance to be negative + validateTrue(modifiedTokenRelBalance >= 0, FAIL_INVALID); + + // If the token rel's balance has changed, add it to the list of changes + final var netFungibleChange = modifiedTokenRelBalance - persistedBalance; + if (netFungibleChange != 0) { + fungibleChanges.put(modifiedRel, netFungibleChange); + } + } + + return fungibleChanges; + } + + /** + * Given a map of {@link EntityIDPair} to {@link Long} representing the changes to the balances of the token + * relations, returns a list of {@link TokenTransferList} representing the changes to the token relations. + * @param fungibleChanges the map of {@link EntityIDPair} to {@link Long} representing the changes to the balances + * @return a list of {@link TokenTransferList} representing the changes to the token relations + */ + @NonNull + protected List asTokenTransferListFrom(@NonNull final Map fungibleChanges) { + final var fungibleTokenTransferLists = new ArrayList(); + final var acctAmountsByTokenId = new HashMap>(); + for (final var fungibleChange : fungibleChanges.entrySet()) { + final var tokenIdOfAcctAmountChange = fungibleChange.getKey().tokenId(); + final var accountIdOfAcctAmountChange = fungibleChange.getKey().accountId(); + if (!acctAmountsByTokenId.containsKey(tokenIdOfAcctAmountChange)) { + acctAmountsByTokenId.put(tokenIdOfAcctAmountChange, new HashMap<>()); + } + if (fungibleChange.getValue() != 0) { + final var tokenIdMap = acctAmountsByTokenId.get(tokenIdOfAcctAmountChange); + tokenIdMap.merge(accountIdOfAcctAmountChange, fungibleChange.getValue(), Long::sum); + } + } + // Mold the fungible changes into a transfer ordered by (token ID, account ID). The fungible pairs are ordered + // by (accountId, tokenId), so we need to group by each token ID + for (final var acctAmountsForToken : acctAmountsByTokenId.entrySet()) { + final var singleTokenTransfers = acctAmountsForToken.getValue(); + if (!singleTokenTransfers.isEmpty()) { + final var aaList = asAccountAmounts(singleTokenTransfers); + aaList.sort(ACCOUNT_AMOUNT_COMPARATOR); + fungibleTokenTransferLists.add(TokenTransferList.newBuilder() + .token(acctAmountsForToken.getKey()) + .transfers(aaList) + .build()); + } + } + + return fungibleTokenTransferLists; + } + + /** + * Gets all nft ownership changes for all modified nfts from the given {@link WritableNftStore}. + * @param writableNftStore the {@link WritableNftStore} to get the nft ownership changes from + * @return a {@link Map} of {@link TokenID} to {@link List} of {@link NftTransfer} representing the nft ownership + */ + @NonNull + protected Map> nftChangesFrom(@NonNull final WritableNftStore writableNftStore) { + final var nftChanges = new HashMap>(); + for (final NftID nftId : writableNftStore.modifiedNfts()) { + final var modifiedNft = writableNftStore.get(nftId); + final var persistedNft = writableNftStore.getOriginalValue(nftId); + + // The NFT may not have existed before, in which case we'll use a null sender account ID + final var senderAccountId = persistedNft != null ? persistedNft.ownerId() : null; + // If the NFT has been burned or wiped, modifiedNft will be null. In that case the receiverId + // will be explicitly set as 0.0.0 + final var builder = NftTransfer.newBuilder(); + if (modifiedNft != null) { + builder.receiverAccountID(modifiedNft.ownerId()); + } else { + builder.receiverAccountID(ZERO_ACCOUNT_ID); + } + final var nftTransfer = builder.serialNumber(nftId.serialNumber()) + .senderAccountID(senderAccountId) + .build(); + + if (!nftChanges.containsKey(nftId.tokenId())) { + nftChanges.put(nftId.tokenId(), new ArrayList<>()); + } + + final var currentNftChanges = nftChanges.get(nftId.tokenId()); + currentNftChanges.add(nftTransfer); + nftChanges.put(nftId.tokenId(), currentNftChanges); + } + return nftChanges; + } + + /** + * Given a {@link Map} of {@link TokenID} to {@link List} of {@link NftTransfer} representing the nft ownership + * changes, returns a list of {@link TokenTransferList} representing the changes to the nft ownership. + * @param nftChanges the {@link Map} of {@link TokenID} to {@link List} of {@link NftTransfer} representing the nft + * @return a list of {@link TokenTransferList} representing the changes to the nft ownership + */ + protected List asTokenTransferListFromNftChanges( + final Map> nftChanges) { + + // Create a new transfer list for each token ID + final var nftTokenTransferLists = new ArrayList(); + for (final var nftsForTokenId : nftChanges.entrySet()) { + if (!nftsForTokenId.getValue().isEmpty()) { + // This var is the collection of all NFT transfers _for a single token ID_ + // NFT serial numbers will not be sorted, instead will be displayed in the order they were added in + // transaction + final var nftTransfersForTokenId = nftsForTokenId.getValue(); + nftTokenTransferLists.add(TokenTransferList.newBuilder() + .token(nftsForTokenId.getKey()) + .nftTransfers(nftTransfersForTokenId) + .build()); + } + } + + return nftTokenTransferLists; + } +} diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableAccountStore.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableAccountStore.java index b476dda445ab..adbe757eecfc 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableAccountStore.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableAccountStore.java @@ -17,6 +17,7 @@ package com.hedera.node.app.service.token.impl; import static com.hedera.node.app.service.evm.accounts.HederaEvmContractAliases.EVM_ADDRESS_LEN; +import static com.hedera.node.app.service.mono.utils.EntityIdUtils.isOfEvmAddressSize; import static java.util.Objects.requireNonNull; import com.hedera.hapi.node.base.AccountID; @@ -213,4 +214,39 @@ public Set modifiedAccountsInState() { public Set modifiedAliasesInState() { return aliases().modifiedKeys(); } + + /** + * Gets the original value associated with the given accountId before any modifications were made to + * it. The returned value will be {@code null} if the accountId does not exist. + * + * @param id The accountId. Cannot be null, otherwise an exception is thrown. + * @return The original value, or null if there is no such accountId in the state + * @throws NullPointerException if the accountId is null. + */ + @Nullable + public Account getOriginalValue(@NonNull final AccountID id) { + requireNonNull(id); + // Get the account number based on the account identifier. It may be null. + final var accountOneOf = id.account(); + final Long accountNum = + switch (accountOneOf.kind()) { + case ACCOUNT_NUM -> accountOneOf.as(); + case ALIAS -> { + final Bytes alias = accountOneOf.as(); + if (isOfEvmAddressSize(alias) && isMirror(alias)) { + yield fromMirror(alias); + } else { + final var entityNum = aliases().getOriginalValue(alias); + yield entityNum == null ? 0L : entityNum.accountNum(); + } + } + case UNSET -> 0L; + }; + + return accountNum == null + ? null + : accountState() + .getOriginalValue( + AccountID.newBuilder().accountNum(accountNum).build()); + } } diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableNftStore.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableNftStore.java index 73bae3ec372d..501ba579a966 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableNftStore.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableNftStore.java @@ -118,4 +118,18 @@ public void remove(final @NonNull NftID serialNum) { public void remove(final @NonNull TokenID tokenId, final long serialNum) { remove(NftID.newBuilder().tokenId(tokenId).serialNumber(serialNum).build()); } + + /** + * Gets the original value associated with the given nftId before any modifications were made to + * it. The returned value will be {@code null} if the nftId does not exist. + * + * @param nftId The nftId. Cannot be null, otherwise an exception is thrown. + * @return The original value, or null if there is no such nftId in the state + * @throws NullPointerException if the accountId is null. + */ + @Nullable + public Nft getOriginalValue(@NonNull final NftID nftId) { + requireNonNull(nftId); + return nftState.getOriginalValue(nftId); + } } diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableStakingInfoStore.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableStakingInfoStore.java index 69077ccde899..ed9c9a310549 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableStakingInfoStore.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableStakingInfoStore.java @@ -67,4 +67,16 @@ public void put(final long nodeId, @NonNull final StakingNodeInfo stakingNodeInf requireNonNull(stakingNodeInfo); stakingInfoState.put(nodeId, stakingNodeInfo); } + + /** + * Gets the original value associated with the given nodeId before any modifications were made to + * it. The returned value will be {@code null} if the nodeId does not exist. + * + * @param nodeId The nftId. + * @return The original value, or null if there is no such nftId in the state + */ + @Nullable + public StakingNodeInfo getOriginalValue(final long nodeId) { + return stakingInfoState.getOriginalValue(nodeId); + } } diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableTokenRelationStore.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableTokenRelationStore.java index 40f269d07a33..8d14b8d21889 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableTokenRelationStore.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableTokenRelationStore.java @@ -94,6 +94,22 @@ public TokenRelation getForModify(@NonNull final AccountID accountId, @NonNull f EntityIDPair.newBuilder().accountId(accountId).tokenId(tokenId).build()); } + /** + * Gets the original value associated with the given tokenRelation before any modifications were made to + * it. The returned value will be {@code null} if the tokenRelation does not exist. + * + * @param accountId The accountId of tokenRelation. + * @param tokenId The tokenId of tokenRelation. + * @return The original value, or null if there is no such tokenRelation in the state + */ + @Nullable + public TokenRelation getOriginalValue(@NonNull final AccountID accountId, @NonNull final TokenID tokenId) { + requireNonNull(accountId); + requireNonNull(tokenId); + return tokenRelState.getOriginalValue( + EntityIDPair.newBuilder().accountId(accountId).tokenId(tokenId).build()); + } + /** * @return the set of token relations modified in existing state */ diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableTokenStore.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableTokenStore.java index 8ea541ca6bed..5037e64518c3 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableTokenStore.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/WritableTokenStore.java @@ -24,6 +24,7 @@ import com.hedera.node.app.spi.state.WritableKVState; import com.hedera.node.app.spi.state.WritableStates; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -88,4 +89,17 @@ public long sizeOfState() { public Set modifiedTokens() { return tokenState.modifiedKeys(); } + + /** + * Gets the original value associated with the given tokenId before any modifications were made to + * it. The returned value will be {@code null} if the tokenId does not exist. + * + * @param tokenId The tokenId. + * @return The original value, or null if there is no such tokenId in the state + */ + @Nullable + public Token getOriginalValue(@NonNull final TokenID tokenId) { + requireNonNull(tokenId); + return tokenState.getOriginalValue(tokenId); + } } diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/comparator/TokenComparators.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/comparator/TokenComparators.java index e657c0e98516..3f220088a430 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/comparator/TokenComparators.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/comparator/TokenComparators.java @@ -19,7 +19,6 @@ import static com.hedera.node.app.spi.HapiUtils.ACCOUNT_ID_COMPARATOR; import com.hedera.hapi.node.base.AccountAmount; -import com.hedera.hapi.node.base.NftTransfer; import com.hedera.hapi.node.base.TokenID; import com.hedera.hapi.node.base.TokenTransferList; import java.util.Comparator; @@ -32,8 +31,6 @@ private TokenComparators() { public static final Comparator ACCOUNT_AMOUNT_COMPARATOR = Comparator.comparing(AccountAmount::accountID, ACCOUNT_ID_COMPARATOR); - public static final Comparator NFT_TRANSFER_COMPARATOR = - Comparator.comparingLong(NftTransfer::serialNumber); public static final Comparator TOKEN_ID_COMPARATOR = Comparator.comparingLong(TokenID::tokenNum); public static final Comparator TOKEN_TRANSFER_LIST_COMPARATOR = (o1, o2) -> Objects.compare(o1.token(), o2.token(), TOKEN_ID_COMPARATOR); diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/FinalizeChildRecordHandler.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/FinalizeChildRecordHandler.java new file mode 100644 index 000000000000..7d2c81e3df64 --- /dev/null +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/FinalizeChildRecordHandler.java @@ -0,0 +1,87 @@ +/* + * 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.token.impl.handlers; + +import static com.hedera.node.app.service.token.impl.comparator.TokenComparators.TOKEN_TRANSFER_LIST_COMPARATOR; +import static com.hedera.node.app.service.token.impl.handlers.staking.StakingRewardsHelper.asAccountAmounts; + +import com.hedera.hapi.node.base.TokenTransferList; +import com.hedera.hapi.node.base.TransferList; +import com.hedera.node.app.service.token.impl.RecordFinalizerBase; +import com.hedera.node.app.service.token.impl.WritableAccountStore; +import com.hedera.node.app.service.token.impl.WritableNftStore; +import com.hedera.node.app.service.token.impl.WritableTokenRelationStore; +import com.hedera.node.app.service.token.impl.records.CryptoTransferRecordBuilder; +import com.hedera.node.app.service.token.records.ChildRecordFinalizer; +import com.hedera.node.app.spi.workflows.HandleContext; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.ArrayList; +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * This is a special handler that is used to "finalize" hbar and token transfers for the child transaction record. + */ +@Singleton +public class FinalizeChildRecordHandler extends RecordFinalizerBase implements ChildRecordFinalizer { + + @Inject + public FinalizeChildRecordHandler() { + // For Dagger Injection + } + + @Override + public void finalizeChildRecord(@NonNull final HandleContext context) { + final var recordBuilder = context.recordBuilder(CryptoTransferRecordBuilder.class); + + // This handler won't ask the context for its transaction, but instead will determine the net hbar transfers and + // token transfers based on the original value from writable state, and based on changes made during this + // transaction via any relevant writable stores + final var writableAccountStore = context.writableStore(WritableAccountStore.class); + final var writableTokenRelStore = context.writableStore(WritableTokenRelationStore.class); + final var writableNftStore = context.writableStore(WritableNftStore.class); + + /* ------------------------- Hbar changes from child transaction ------------------------- */ + final var hbarChanges = hbarChangesFrom(writableAccountStore); + if (!hbarChanges.isEmpty()) { + // Save the modified hbar amounts so records can be written + recordBuilder.transferList(TransferList.newBuilder() + .accountAmounts(asAccountAmounts(hbarChanges)) + .build()); + } + + // Declare the top-level token transfer list, which list will include BOTH fungible and non-fungible token + // transfers + final ArrayList tokenTransferLists; + + // ---------- fungible token transfers ------------------------- + final var fungibleChanges = fungibleChangesFrom(writableTokenRelStore); + final var fungibleTokenTransferLists = asTokenTransferListFrom(fungibleChanges); + tokenTransferLists = new ArrayList<>(fungibleTokenTransferLists); + + // ---------- nft transfers ------------------------- + final var nftChanges = nftChangesFrom(writableNftStore); + final var nftTokenTransferLists = asTokenTransferListFromNftChanges(nftChanges); + tokenTransferLists.addAll(nftTokenTransferLists); + + // Record the modified fungible and non-fungible changes so records can be written + if (!tokenTransferLists.isEmpty()) { + tokenTransferLists.sort(TOKEN_TRANSFER_LIST_COMPARATOR); + recordBuilder.tokenTransferLists(tokenTransferLists); + } + } +} diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/FinalizeParentRecordHandler.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/FinalizeParentRecordHandler.java index 53d0b5560e49..84ae62074d7c 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/FinalizeParentRecordHandler.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/FinalizeParentRecordHandler.java @@ -16,64 +16,34 @@ package com.hedera.node.app.service.token.impl.handlers; -import static com.hedera.hapi.node.base.AccountAmount.*; -import static com.hedera.hapi.node.base.ResponseCodeEnum.FAIL_INVALID; -import static com.hedera.node.app.service.token.impl.comparator.TokenComparators.ACCOUNT_AMOUNT_COMPARATOR; -import static com.hedera.node.app.service.token.impl.comparator.TokenComparators.NFT_TRANSFER_COMPARATOR; import static com.hedera.node.app.service.token.impl.comparator.TokenComparators.TOKEN_TRANSFER_LIST_COMPARATOR; import static com.hedera.node.app.service.token.impl.handlers.staking.StakingRewardsHelper.asAccountAmounts; -import static com.hedera.node.app.spi.workflows.HandleException.validateTrue; -import com.hedera.hapi.node.base.AccountAmount; import com.hedera.hapi.node.base.AccountID; -import com.hedera.hapi.node.base.NftID; -import com.hedera.hapi.node.base.NftTransfer; -import com.hedera.hapi.node.base.TokenID; import com.hedera.hapi.node.base.TokenTransferList; import com.hedera.hapi.node.base.TransferList; -import com.hedera.hapi.node.state.common.EntityIDPair; -import com.hedera.node.app.service.token.ReadableAccountStore; -import com.hedera.node.app.service.token.ReadableNftStore; -import com.hedera.node.app.service.token.ReadableTokenRelationStore; +import com.hedera.hapi.node.transaction.TransactionRecord; +import com.hedera.node.app.service.token.impl.RecordFinalizerBase; import com.hedera.node.app.service.token.impl.WritableAccountStore; import com.hedera.node.app.service.token.impl.WritableNftStore; import com.hedera.node.app.service.token.impl.WritableTokenRelationStore; import com.hedera.node.app.service.token.impl.handlers.staking.StakingRewardsHandler; import com.hedera.node.app.service.token.impl.records.CryptoTransferRecordBuilder; +import com.hedera.node.app.service.token.records.ParentRecordFinalizer; 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.StakingConfig; import edu.umd.cs.findbugs.annotations.NonNull; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import javax.inject.Inject; import javax.inject.Singleton; /** - * This is a special handler that is used to "finalize" hbar and token transfers for the parent transaction record. - * Finalization in this context means summing the net changes to make to each account's hbar balance and token - * balances, and assigning the final owner of an nft after an arbitrary number of ownership changes. - * Based on issue https://github.com/hashgraph/hedera-services/issues/7084 the modularized - * transaction record for NFT transfer chain A -> B -> C, will look different from mono-service record. - * This is because mono-service will record both ownership changes from A -> b and then B-> C. - * - * In this finalizer, we will: - * 1.If staking is enabled, iterate through all modifications in writableAccountStore and compare with the corresponding entity in readableAccountStore - * 2. Comparing the changes, we look for balance/declineReward/stakedToMe/stakedId fields have been modified, - * if an account is staking to a node. Construct a list of possibleRewardReceivers - * 3. Pay staking rewards to any account who has pending rewards - * 4. Now again, iterate through all modifications in writableAccountStore, writableTokenRelationStore. - * 5. For each modification we look at the same entity in the respective readableStore - * 6. Calculate the difference between the two, and then construct a TransferList and TokenTransferList - * for the parent record + * This class is used to "finalize" hbar and token transfers for the parent transaction record. */ @Singleton -public class FinalizeParentRecordHandler implements TransactionHandler { +public class FinalizeParentRecordHandler extends RecordFinalizerBase implements ParentRecordFinalizer { private final StakingRewardsHandler stakingRewardsHandler; @Inject @@ -82,22 +52,16 @@ public FinalizeParentRecordHandler(@NonNull final StakingRewardsHandler stakingR } @Override - public void preHandle(@NonNull final PreHandleContext context) throws PreCheckException { - // Intentionally empty. There are no pre-checks to do before finalizing the transfer lists - } - - @Override - public void handle(@NonNull final HandleContext context) throws HandleException { + public void finalizeParentRecord( + @NonNull final HandleContext context, @NonNull final List childRecords) { final var recordBuilder = context.recordBuilder(CryptoTransferRecordBuilder.class); // This handler won't ask the context for its transaction, but instead will determine the net hbar transfers and - // token transfers based on the current readable state, and based on changes made during this transaction via + // token transfers based on the original value from writable state, and based on changes made during this + // transaction via // any relevant writable stores - final var readableAccountStore = context.readableStore(ReadableAccountStore.class); final var writableAccountStore = context.writableStore(WritableAccountStore.class); - final var readableTokenRelStore = context.readableStore(ReadableTokenRelationStore.class); final var writableTokenRelStore = context.writableStore(WritableTokenRelationStore.class); - final var readableNftStore = context.readableStore(ReadableNftStore.class); final var writableNftStore = context.writableStore(WritableNftStore.class); final var stakingConfig = context.configuration().getConfigData(StakingConfig.class); @@ -113,11 +77,14 @@ public void handle(@NonNull final HandleContext context) throws HandleException } /* ------------------------- Hbar changes from transaction including staking rewards ------------------------- */ - final var hbarChanges = hbarChangesWithStakingRewards(writableAccountStore, readableAccountStore); + final var hbarChanges = hbarChangesFrom(writableAccountStore); + // any hbar changes listed in child records should not be recorded again in parent record, so deduct them. + deductChangesFromChildRecords(hbarChanges, childRecords); if (!hbarChanges.isEmpty()) { // Save the modified hbar amounts so records can be written - recordBuilder.transferList( - TransferList.newBuilder().accountAmounts(hbarChanges).build()); + recordBuilder.transferList(TransferList.newBuilder() + .accountAmounts(asAccountAmounts(hbarChanges)) + .build()); } // Declare the top-level token transfer list, which list will include BOTH fungible and non-fungible token @@ -125,12 +92,17 @@ public void handle(@NonNull final HandleContext context) throws HandleException final ArrayList tokenTransferLists; // ---------- fungible token transfers - final var fungibleChanges = calculateFungibleChanges(writableTokenRelStore, readableTokenRelStore); - final var fungibleTokenTransferLists = moldFungibleChanges(fungibleChanges); + final var fungibleChanges = fungibleChangesFrom(writableTokenRelStore); + // any fungible token changes listed in child records should not be considered while building + // parent record, so don't deduct them. + final var fungibleTokenTransferLists = asTokenTransferListFrom(fungibleChanges); tokenTransferLists = new ArrayList<>(fungibleTokenTransferLists); // ---------- nft transfers - final var nftTokenTransferLists = calculateNftChanges(writableNftStore, readableNftStore); + final var nftChanges = nftChangesFrom(writableNftStore); + // any nft transfers listed in child records should not be considered while building + // parent record, so don't deduct them. + final var nftTokenTransferLists = asTokenTransferListFromNftChanges(nftChanges); tokenTransferLists.addAll(nftTokenTransferLists); // Record the modified fungible and non-fungible changes so records can be written @@ -140,143 +112,17 @@ public void handle(@NonNull final HandleContext context) throws HandleException } } - @NonNull - private static List hbarChangesWithStakingRewards( - @NonNull final WritableAccountStore writableAccountStore, - @NonNull final ReadableAccountStore readableAccountStore) { - final var hbarChanges = new ArrayList(); - var netHbarBalance = 0; - for (final AccountID modifiedAcctId : writableAccountStore.modifiedAccountsInState()) { - final var modifiedAcct = writableAccountStore.getAccountById(modifiedAcctId); - final var persistedAcct = readableAccountStore.getAccountById(modifiedAcctId); - // It's possible the modified account was created in this transaction, in which case the non-existent - // persisted account effectively has no balance (i.e. its prior balance is 0) - final var persistedBalance = persistedAcct != null ? persistedAcct.tinybarBalance() : 0; - - // Never allow an account's net hbar balance to be negative - validateTrue(modifiedAcct.tinybarBalance() >= 0, FAIL_INVALID); - - final var netHbarChange = modifiedAcct.tinybarBalance() - persistedBalance; - if (netHbarChange != 0) { - netHbarBalance += netHbarChange; - final var netChange = newBuilder() - .accountID(modifiedAcctId) - .amount(netHbarChange) - .isApproval(false) - .build(); - hbarChanges.add(netChange); - } - } - // Since this is a finalization handler, we should have already succeeded in handling the transaction in a - // handler before getting here. Therefore, if the sum is non-zero, something went wrong, and we'll respond with - // FAIL_INVALID - validateTrue(netHbarBalance == 0, FAIL_INVALID); - - return hbarChanges; - } - - @NonNull - private static Map calculateFungibleChanges( - @NonNull final WritableTokenRelationStore writableTokenRelStore, - @NonNull final ReadableTokenRelationStore readableTokenRelStore) { - final var fungibleChanges = new HashMap(); - for (final EntityIDPair modifiedRel : writableTokenRelStore.modifiedTokens()) { - final var relAcctId = modifiedRel.accountId(); - final var relTokenId = modifiedRel.tokenId(); - final var modifiedTokenRel = writableTokenRelStore.get(relAcctId, relTokenId); - final var persistedTokenRel = readableTokenRelStore.get(relAcctId, relTokenId); - final var modifiedBalance = modifiedTokenRel.balance(); - // It's possible the modified token rel was created in this transaction. If so, use a persisted balance of 0 - // for the token rel that didn't exist - final var persistedBalance = persistedTokenRel != null ? persistedTokenRel.balance() : 0; - - // Never allow a fungible token's balance to be negative - validateTrue(modifiedTokenRel.balance() >= 0, FAIL_INVALID); - - // If the token rel's balance has changed, add it to the list of changes - final var netFungibleChange = modifiedBalance - persistedBalance; - if (netFungibleChange != 0) { - final var netChange = newBuilder() - .accountID(relAcctId) - .amount(netFungibleChange) - .isApproval(false) - .build(); - fungibleChanges.put(modifiedRel, netChange); - } - } - - return fungibleChanges; - } - - @NonNull - private static List moldFungibleChanges( - @NonNull final Map fungibleChanges) { - final var fungibleTokenTransferLists = new ArrayList(); - final var acctAmountsByTokenId = new HashMap>(); - for (final var fungibleChange : fungibleChanges.entrySet()) { - final var tokenIdOfAcctAmountChange = fungibleChange.getKey().tokenId(); - if (!acctAmountsByTokenId.containsKey(tokenIdOfAcctAmountChange)) { - acctAmountsByTokenId.put(tokenIdOfAcctAmountChange, new ArrayList<>()); - } - if (fungibleChange.getValue().amount() != 0) { - acctAmountsByTokenId.get(tokenIdOfAcctAmountChange).add(fungibleChange.getValue()); - } - } - // Mold the fungible changes into a transfer ordered by (token ID, account ID). The fungible pairs are ordered - // by (accountId, tokenId), so we need to group by each token ID - for (final var acctAmountsForToken : acctAmountsByTokenId.entrySet()) { - final var singleTokenTransfers = acctAmountsForToken.getValue(); - if (!singleTokenTransfers.isEmpty()) { - singleTokenTransfers.sort(ACCOUNT_AMOUNT_COMPARATOR); - fungibleTokenTransferLists.add(TokenTransferList.newBuilder() - .token(acctAmountsForToken.getKey()) - .transfers(singleTokenTransfers) - .build()); - } - } - - return fungibleTokenTransferLists; - } - - @NonNull - private List calculateNftChanges( - @NonNull final WritableNftStore writableNftStore, @NonNull final ReadableNftStore readableNftStore) { - final var nftChanges = new HashMap>(); - for (final NftID nftId : writableNftStore.modifiedNfts()) { - final var modifiedNft = writableNftStore.get(nftId); - final var persistedNft = readableNftStore.get(nftId); - - // The NFT may not have existed before, in which case we'll use a null sender account ID - final var senderAccountId = persistedNft != null ? persistedNft.ownerId() : null; - final var nftTransfer = NftTransfer.newBuilder() - .serialNumber(modifiedNft.id().serialNumber()) - .senderAccountID(senderAccountId) - .receiverAccountID(modifiedNft.ownerId()) - .isApproval(false) - .build(); - if (!nftChanges.containsKey(nftId.tokenId())) { - nftChanges.put(nftId.tokenId(), new ArrayList<>()); - } - - final var currentNftChanges = nftChanges.get(nftId.tokenId()); - currentNftChanges.add(nftTransfer); - nftChanges.put(nftId.tokenId(), currentNftChanges); - } - - // Create a new transfer list for each token ID - final var nftTokenTransferLists = new ArrayList(); - for (final var nftsForTokenId : nftChanges.entrySet()) { - if (!nftsForTokenId.getValue().isEmpty()) { - // This var is the collection of all NFT transfers _for a single token ID_ - final var nftTransfersForTokenId = nftsForTokenId.getValue(); - nftTransfersForTokenId.sort(NFT_TRANSFER_COMPARATOR); - nftTokenTransferLists.add(TokenTransferList.newBuilder() - .token(nftsForTokenId.getKey()) - .nftTransfers(nftTransfersForTokenId) - .build()); + private void deductChangesFromChildRecords( + final Map hbarChanges, final List childRecords) { + for (final var childRecord : childRecords) { + final var childHbarChangesFromRecord = childRecord.transferList(); + for (final var childChange : childHbarChangesFromRecord.accountAmountsOrElse(List.of())) { + final var childHbarChangeAccountId = childChange.accountID(); + final var childHbarChangeAmount = childChange.amount(); + if (hbarChanges.containsKey(childHbarChangeAccountId)) { + hbarChanges.merge(childHbarChangeAccountId, -childHbarChangeAmount, Long::sum); + } } } - - return nftTokenTransferLists; } } diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakeRewardCalculator.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakeRewardCalculator.java index 4984f4660c25..3abf89fb39c8 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakeRewardCalculator.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakeRewardCalculator.java @@ -19,7 +19,7 @@ import com.hedera.hapi.node.state.token.Account; import com.hedera.hapi.node.state.token.StakingNodeInfo; import com.hedera.node.app.service.token.ReadableNetworkStakingRewardsStore; -import com.hedera.node.app.service.token.ReadableStakingInfoStore; +import com.hedera.node.app.service.token.impl.WritableStakingInfoStore; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; import java.time.Instant; @@ -42,7 +42,7 @@ public interface StakeRewardCalculator { */ long computePendingReward( @NonNull final Account account, - @NonNull final ReadableStakingInfoStore stakingInfoStore, + @NonNull final WritableStakingInfoStore stakingInfoStore, @NonNull final ReadableNetworkStakingRewardsStore rewardsStore, @NonNull final Instant consensusNow); diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakeRewardCalculatorImpl.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakeRewardCalculatorImpl.java index 7a59d457d901..7c2e06896610 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakeRewardCalculatorImpl.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakeRewardCalculatorImpl.java @@ -23,7 +23,7 @@ import com.hedera.hapi.node.state.token.Account; import com.hedera.hapi.node.state.token.StakingNodeInfo; import com.hedera.node.app.service.token.ReadableNetworkStakingRewardsStore; -import com.hedera.node.app.service.token.ReadableStakingInfoStore; +import com.hedera.node.app.service.token.impl.WritableStakingInfoStore; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; import java.time.Instant; @@ -44,7 +44,7 @@ public StakeRewardCalculatorImpl(@NonNull final StakePeriodManager stakePeriodMa @Override public long computePendingReward( @NonNull final Account account, - @NonNull final ReadableStakingInfoStore stakingInfoStore, + @NonNull final WritableStakingInfoStore stakingInfoStore, @NonNull final ReadableNetworkStakingRewardsStore rewardsStore, @NonNull final Instant consensusNow) { final var effectiveStart = stakePeriodManager.effectivePeriod(account.stakePeriodStart()); @@ -55,7 +55,7 @@ public long computePendingReward( // At this point all the accounts that are eligible for computing rewards should have a // staked to a node final var nodeId = account.stakedNodeIdOrThrow(); - final var stakingInfo = stakingInfoStore.get(nodeId); + final var stakingInfo = stakingInfoStore.getOriginalValue(nodeId); final var rewardOffered = computeRewardFromDetails( account, stakingInfo, stakePeriodManager.currentStakePeriod(consensusNow), effectiveStart); return account.declineReward() ? 0 : rewardOffered; diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsDistributor.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsDistributor.java index e2d52722413b..eac1b29b73cf 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsDistributor.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsDistributor.java @@ -20,7 +20,6 @@ import com.hedera.hapi.node.base.AccountID; import com.hedera.hapi.node.state.token.Account; -import com.hedera.node.app.service.token.ReadableAccountStore; import com.hedera.node.app.service.token.impl.WritableAccountStore; import com.hedera.node.app.service.token.impl.WritableNetworkStakingRewardsStore; import com.hedera.node.app.service.token.impl.WritableStakingInfoStore; @@ -55,7 +54,6 @@ public StakingRewardsDistributor( public Map payRewardsIfPending( @NonNull final Set possibleRewardReceivers, - @NonNull final ReadableAccountStore readableStore, @NonNull final WritableAccountStore writableStore, @NonNull final WritableNetworkStakingRewardsStore stakingRewardsStore, @NonNull final WritableStakingInfoStore stakingInfoStore, @@ -64,7 +62,7 @@ public Map payRewardsIfPending( final Map rewardsPaid = new HashMap<>(); for (final var receiver : possibleRewardReceivers) { - final var originalAccount = readableStore.getAccountById(receiver); + final var originalAccount = writableStore.getOriginalValue(receiver); final var modifiedAccount = writableStore.get(receiver); final var reward = rewardCalculator.computePendingReward( originalAccount, stakingInfoStore, stakingRewardsStore, consensusNow); @@ -95,7 +93,7 @@ public Map payRewardsIfPending( } // TODO: need to get this info ? // receiverId = txnCtx.getBeneficiaryOfDeleted(receiverNum); - beneficiary = writableStore.get(receiverId); + beneficiary = writableStore.getOriginalValue(receiverId); } while (beneficiary.deleted()); } } diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsHandlerImpl.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsHandlerImpl.java index 603f206ac7eb..15570fe977c3 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsHandlerImpl.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsHandlerImpl.java @@ -27,7 +27,6 @@ import com.hedera.hapi.node.base.AccountID; import com.hedera.hapi.node.state.token.Account; -import com.hedera.node.app.service.token.ReadableAccountStore; import com.hedera.node.app.service.token.ReadableNetworkStakingRewardsStore; import com.hedera.node.app.service.token.impl.WritableAccountStore; import com.hedera.node.app.service.token.impl.WritableNetworkStakingRewardsStore; @@ -64,7 +63,6 @@ public StakingRewardsHandlerImpl( /** {@inheritDoc} */ @Override public Map applyStakingRewards(final HandleContext context) { - final var readableStore = context.readableStore(ReadableAccountStore.class); final var writableStore = context.writableStore(WritableAccountStore.class); final var stakingRewardsStore = context.writableStore(WritableNetworkStakingRewardsStore.class); final var stakingInfoStore = context.writableStore(WritableStakingInfoStore.class); @@ -74,15 +72,14 @@ public Map applyStakingRewards(final HandleContext context) { // Apply all changes related to stakedId changes, and adjust stakedToMe // for all accounts staking to an account - adjustStakedToMeForAccountStakees(writableStore, readableStore); + adjustStakedToMeForAccountStakees(writableStore); // Get list of possible reward receivers and pay rewards to them - final var rewardReceivers = getPossibleRewardReceivers(writableStore, readableStore); + final var rewardReceivers = getPossibleRewardReceivers(writableStore); // Pay rewards to all possible reward receivers, returns all rewards paid final var rewardsPaid = rewardsPayer.payRewardsIfPending( - rewardReceivers, readableStore, writableStore, stakingRewardsStore, stakingInfoStore, consensusNow); + rewardReceivers, writableStore, stakingRewardsStore, stakingInfoStore, consensusNow); // Adjust stakes for nodes - adjustStakeMetadata( - writableStore, readableStore, stakingInfoStore, stakingRewardsStore, consensusNow, rewardsPaid); + adjustStakeMetadata(writableStore, stakingInfoStore, stakingRewardsStore, consensusNow, rewardsPaid); // Decrease staking reward account balance by rewardPaid amount decreaseStakeRewardAccountBalance(rewardsPaid, stakingRewardAccountId, writableStore); return rewardsPaid; @@ -98,14 +95,12 @@ public Map applyStakingRewards(final HandleContext context) { * assess if Y is staked to a node, and if so, we will update the node stake metadata. * * @param writableStore The store to write to for updated values - * @param readableStore The store to read from for original values */ - public void adjustStakedToMeForAccountStakees( - @NonNull final WritableAccountStore writableStore, @NonNull final ReadableAccountStore readableStore) { + public void adjustStakedToMeForAccountStakees(@NonNull final WritableAccountStore writableStore) { final var modifiedAccounts = writableStore.modifiedAccountsInState(); for (final var id : modifiedAccounts) { - final var originalAccount = readableStore.getAccountById(id); + final var originalAccount = writableStore.getOriginalValue(id); final var modifiedAccount = writableStore.get(id); // check if stakedId has changed @@ -148,7 +143,6 @@ public void adjustStakedToMeForAccountStakees( * nodes. It also updates stakeAtStartOfLastRewardedPeriod and stakePeriodStart for accounts. * * @param writableStore writable account store - * @param readableStore readable account store * @param stakingInfoStore writable staking info store * @param stakingRewardStore writable staking reward store * @param consensusNow consensus time @@ -156,13 +150,12 @@ public void adjustStakedToMeForAccountStakees( */ private void adjustStakeMetadata( final WritableAccountStore writableStore, - final ReadableAccountStore readableStore, final WritableStakingInfoStore stakingInfoStore, final WritableNetworkStakingRewardsStore stakingRewardStore, final Instant consensusNow, final Map paidRewards) { for (final var id : writableStore.modifiedAccountsInState()) { - final var originalAccount = readableStore.getAccountById(id); + final var originalAccount = writableStore.getOriginalValue(id); final var modifiedAccount = writableStore.get(id); final var scenario = StakeIdChangeType.forCase(originalAccount, modifiedAccount); diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsHelper.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsHelper.java index 5e43ca727a3e..991e50c3d4c7 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsHelper.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/handlers/staking/StakingRewardsHelper.java @@ -17,12 +17,12 @@ package com.hedera.node.app.service.token.impl.handlers.staking; import static com.hedera.node.app.service.mono.utils.Units.HBARS_TO_TINYBARS; +import static com.hedera.node.app.service.token.impl.comparator.TokenComparators.ACCOUNT_AMOUNT_COMPARATOR; import static java.util.Objects.requireNonNull; import com.hedera.hapi.node.base.AccountAmount; import com.hedera.hapi.node.base.AccountID; import com.hedera.hapi.node.state.token.Account; -import com.hedera.node.app.service.token.ReadableAccountStore; import com.hedera.node.app.service.token.impl.WritableAccountStore; import com.hedera.node.app.service.token.impl.WritableNetworkStakingRewardsStore; import edu.umd.cs.findbugs.annotations.NonNull; @@ -53,24 +53,22 @@ public StakingRewardsHelper() { /** * Looks through all the accounts modified in state and returns a list of accounts which are staked to a node * and has stakedId or stakedToMe or balance or declineReward changed in this transaction. - * @param writableAccountStore The store to write to for updated values - * @param readableAccountStore The store to read from for original values + * @param writableAccountStore The store to write to for updated values and original values * @return A list of accounts which are staked to a node and could possibly receive a reward */ - public static Set getPossibleRewardReceivers( - final WritableAccountStore writableAccountStore, final ReadableAccountStore readableAccountStore) { + public static Set getPossibleRewardReceivers(final WritableAccountStore writableAccountStore) { final var possibleRewardReceivers = new HashSet(); - for (final AccountID modifiedId : writableAccountStore.modifiedAccountsInState()) { - final var modifiedAcct = writableAccountStore.get(modifiedId); + for (final AccountID id : writableAccountStore.modifiedAccountsInState()) { + final var modifiedAcct = writableAccountStore.get(id); // TODO: change to use originalValue - final var originalAcct = readableAccountStore.getAccountById(modifiedId); + final var originalAcct = writableAccountStore.getOriginalValue(id); // It is possible that original account is null if the account was created in this transaction // In that case it is not a reward situation // If the account existed before this transaction and is staked to a node, // and the current transaction modified the stakedToMe field or declineReward or // the stakedId field, then it is a reward situation if (isRewardSituation(modifiedAcct, originalAcct)) { - possibleRewardReceivers.add(modifiedId); + possibleRewardReceivers.add(id); } } return possibleRewardReceivers; @@ -158,6 +156,7 @@ public static List asAccountAmounts(@NonNull final Map NFT_TRANSFER_COMPARATOR.compare(null, null)) - .isInstanceOf(NullPointerException.class); - Assertions.assertThatThrownBy(() -> NFT_TRANSFER_COMPARATOR.compare(NFT_TRANSFER_SER1, null)) - .isInstanceOf(NullPointerException.class); - Assertions.assertThatThrownBy(() -> NFT_TRANSFER_COMPARATOR.compare(null, NFT_TRANSFER_SER1)) - .isInstanceOf(NullPointerException.class); - //noinspection EqualsWithItself - Assertions.assertThat(NFT_TRANSFER_COMPARATOR.compare(NFT_TRANSFER_SER1, NFT_TRANSFER_SER1)) - .isZero(); - Assertions.assertThat(NFT_TRANSFER_COMPARATOR.compare(NFT_TRANSFER_SER1, NFT_TRANSFER_SER2)) - .isNegative(); - Assertions.assertThat(NFT_TRANSFER_COMPARATOR.compare(NFT_TRANSFER_SER2, NFT_TRANSFER_SER1)) - .isPositive(); - } - } - @Nested class TokenIdComparatorTest { private static final TokenID TOKEN_1111 = asToken(1111); diff --git a/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/FinalizeChildRecordHandlerTest.java b/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/FinalizeChildRecordHandlerTest.java new file mode 100644 index 000000000000..ae46645eb7a2 --- /dev/null +++ b/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/FinalizeChildRecordHandlerTest.java @@ -0,0 +1,692 @@ +/* + * 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.token.impl.test.handlers; + +import static com.hedera.hapi.node.base.ResponseCodeEnum.FAIL_INVALID; +import static com.hedera.node.app.service.token.impl.handlers.BaseTokenHandler.asToken; +import static com.hedera.node.app.spi.fixtures.workflows.ExceptionConditions.responseCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mock.Strictness.LENIENT; + +import com.hedera.hapi.node.base.AccountAmount; +import com.hedera.hapi.node.base.AccountID; +import com.hedera.hapi.node.base.NftID; +import com.hedera.hapi.node.base.NftTransfer; +import com.hedera.hapi.node.base.TokenID; +import com.hedera.hapi.node.base.TokenTransferList; +import com.hedera.hapi.node.base.TransferList; +import com.hedera.hapi.node.state.token.Account; +import com.hedera.hapi.node.state.token.Nft; +import com.hedera.node.app.service.token.ReadableAccountStore; +import com.hedera.node.app.service.token.ReadableNftStore; +import com.hedera.node.app.service.token.ReadableTokenRelationStore; +import com.hedera.node.app.service.token.impl.WritableAccountStore; +import com.hedera.node.app.service.token.impl.WritableNftStore; +import com.hedera.node.app.service.token.impl.WritableTokenRelationStore; +import com.hedera.node.app.service.token.impl.handlers.FinalizeChildRecordHandler; +import com.hedera.node.app.service.token.impl.records.CryptoTransferRecordBuilder; +import com.hedera.node.app.service.token.impl.test.handlers.util.CryptoTokenHandlerTestBase; +import com.hedera.node.app.service.token.impl.test.handlers.util.TestStoreFactory; +import com.hedera.node.app.spi.workflows.HandleContext; +import com.hedera.node.app.spi.workflows.HandleException; +import com.hedera.node.config.testfixtures.HederaTestConfigBuilder; +import com.hedera.pbj.runtime.io.buffer.Bytes; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.BDDMockito; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class FinalizeChildRecordHandlerTest extends CryptoTokenHandlerTestBase { + private final AccountID ACCOUNT_1212_ID = + AccountID.newBuilder().accountNum(1212).build(); + private final Account ACCOUNT_1212 = + givenValidAccountBuilder().accountId(ACCOUNT_1212_ID).build(); + private final AccountID ACCOUNT_3434_ID = + AccountID.newBuilder().accountNum(3434).build(); + private final Account ACCOUNT_3434 = givenValidAccountBuilder() + .accountId(ACCOUNT_3434_ID) + .tinybarBalance(500) + .build(); + private final AccountID ACCOUNT_5656_ID = + AccountID.newBuilder().accountNum(5656).build(); + private final Account ACCOUNT_5656 = givenValidAccountBuilder() + .accountId(ACCOUNT_5656_ID) + .tinybarBalance(10000) + .build(); + private static final TokenID TOKEN_321 = asToken(321); + + @Mock(strictness = LENIENT) + private HandleContext context; + + @Mock + private CryptoTransferRecordBuilder recordBuilder; + + private ReadableAccountStore readableAccountStore; + private WritableAccountStore writableAccountStore; + private ReadableNftStore readableNftStore; + private WritableNftStore writableNftStore; + + private FinalizeChildRecordHandler subject; + + @BeforeEach + public void setUp() { + super.setUp(); + subject = new FinalizeChildRecordHandler(); + } + + @Test + void handleNullArg() { + assertThatThrownBy(() -> subject.finalizeChildRecord(context)).isInstanceOf(NullPointerException.class); + } + + @Test + void handleHbarNetTransferAmountIsNotZero() { + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212); + writableAccountStore.put(ACCOUNT_1212 + .copyBuilder() + .tinybarBalance(ACCOUNT_1212.tinybarBalance() - 5) + .build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + assertThatThrownBy(() -> subject.finalizeChildRecord(context)) + .isInstanceOf(HandleException.class) + .has(responseCode(FAIL_INVALID)); + } + + @Test + void handleHbarAccountBalanceIsNegative() { + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434); + // This amount will cause the net transfer amount to be negative for account 1212 + final var amountToAdjust = ACCOUNT_1212.tinybarBalance() + 1; + writableAccountStore.put(ACCOUNT_1212 + .copyBuilder() + .tinybarBalance(ACCOUNT_1212.tinybarBalance() - amountToAdjust) + .build()); + writableAccountStore.put(ACCOUNT_3434 + .copyBuilder() + .tinybarBalance(ACCOUNT_3434.tinybarBalance() + amountToAdjust) + .build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + assertThatThrownBy(() -> subject.finalizeChildRecord(context)) + .isInstanceOf(HandleException.class) + .has(responseCode(FAIL_INVALID)); + } + + @Test + void handleHbarAccountBalanceDoesntChange() { + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(); // Intentionally empty + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(); // Intentionally empty + // Account 1212 changes by getting a new memo, but its balance doesn't change + writableAccountStore.put( + ACCOUNT_1212.copyBuilder().memo("different memo field").build()); + // Intentionally empty token rel store + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(); + context = mockContext(); + + given(context.configuration()).willReturn(configuration); + subject.finalizeChildRecord(context); + + BDDMockito.verifyNoInteractions(recordBuilder); + } + + @Test + void handleHbarTransfersToNewAccountSuccess() { + // This case handles a successful hbar transfer to an auto-created account + + final var amountToTransfer = ACCOUNT_1212.tinybarBalance() - 1; + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212); + writableAccountStore.put(ACCOUNT_1212.copyBuilder().tinybarBalance(1).build()); + // Putting ACCOUNT_3434 into the writable store here simulates this account being auto-created + writableAccountStore.put(ACCOUNT_3434 + .copyBuilder() + .alias(Bytes.wrap("00000000000000000001")) + .tinybarBalance(amountToTransfer) + .build()); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(); // Intentionally empty + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(); // Intentionally empty + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(); // Intentionally empty + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(); // Intentionally empty + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + subject.finalizeChildRecord(context); + + BDDMockito.verify(recordBuilder) + .transferList(TransferList.newBuilder() + .accountAmounts( + AccountAmount.newBuilder() + .accountID(ACCOUNT_1212_ID) + .amount(-amountToTransfer) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(amountToTransfer) + .build()) + .build()); + } + + @Test + void handleHbarTransfersToExistingAccountSuccess() { + // This test case handles successfully transferring hbar only (no tokens) + + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434, ACCOUNT_5656); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434, ACCOUNT_5656); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(); // Intentionally empty + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(); // Intentionally empty + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(); // Intentionally empty + final var acct1212Change = 10; + writableAccountStore.put(ACCOUNT_1212 + .copyBuilder() + .tinybarBalance(ACCOUNT_1212.tinybarBalance() - acct1212Change) + .build()); + writableAccountStore.put(ACCOUNT_3434 + .copyBuilder() + .tinybarBalance(ACCOUNT_3434.tinybarBalance() + acct1212Change) + .build()); + // Account 5656 changes by getting a new memo, but its balance doesn't change + writableAccountStore.put( + ACCOUNT_5656.copyBuilder().memo("different memo field").build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + subject.finalizeChildRecord(context); + + BDDMockito.verify(recordBuilder) + .transferList(TransferList.newBuilder() + .accountAmounts( + AccountAmount.newBuilder() + .accountID(ACCOUNT_1212_ID) + .amount(-acct1212Change) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(acct1212Change) + .build()) + // There shouldn't be any entry for account 5656 because its balance didn't change + .build()); + } + + @Test + void handleFungibleTokenBalanceIsNegative() { + final var validAcct = givenValidAccountBuilder(); + final var tokenRel = givenFungibleTokenRelation(); // Already tied to validAcct's account ID + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(validAcct.build()); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(validAcct.build()); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(tokenRel); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(tokenRel); + writableTokenRelStore.put(tokenRel.copyBuilder().balance(-1).build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + assertThatThrownBy(() -> subject.finalizeChildRecord(context)) + .isInstanceOf(HandleException.class) + .has(responseCode(FAIL_INVALID)); + } + + @Test + void handleFungibleTransferTokenBalancesDontChange() { + final var validAcct = givenValidAccountBuilder(); + final var tokenRel = givenFungibleTokenRelation(); + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(validAcct.build()); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(validAcct.build()); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(tokenRel); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(tokenRel); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(); // Intentionally empty + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(); // Intentionally empty + // The token relation's 'frozen' property is changed, but its balance doesn't change + writableTokenRelStore.put(tokenRel.copyBuilder().frozen(true).build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + subject.finalizeChildRecord(context); + + BDDMockito.verifyNoInteractions(recordBuilder); + } + + @Test + void handleFungibleTransfersToNewAccountSuccess() { + // This case handles a successful fungible token transfer to an auto-created account + + final var senderAcct = ACCOUNT_1212; + final var senderTokenRel = givenFungibleTokenRelation() + .copyBuilder() + .tokenId(TOKEN_321) + .accountId(ACCOUNT_1212_ID) + .build(); + final var fungibleAmountToTransfer = senderTokenRel.balance() - 1; + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(senderAcct); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(senderAcct); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(senderTokenRel); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(senderTokenRel); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(); // Intentionally empty + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(); // Intentionally empty + // Simulate the token receiver's account (ACCOUNT_3434) being auto-created (with an hbar balance of 0) + writableAccountStore.put(ACCOUNT_3434 + .copyBuilder() + .tinybarBalance(0) + .alias(Bytes.wrap("00000000000000000002")) + .build()); + // Simulate the receiver's token relation being auto-created (and both the sender and receiver token rel + // balances adjusted) + writableTokenRelStore.put(senderTokenRel.copyBuilder().balance(1).build()); + writableTokenRelStore.put(senderTokenRel + .copyBuilder() + .accountId(ACCOUNT_3434_ID) + .balance(fungibleAmountToTransfer) + .build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + subject.finalizeChildRecord(context); + + BDDMockito.verify(recordBuilder) + .tokenTransferLists(List.of(TokenTransferList.newBuilder() + .token(TOKEN_321) + .transfers( + AccountAmount.newBuilder() + .accountID(ACCOUNT_1212_ID) + .amount(-fungibleAmountToTransfer) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(fungibleAmountToTransfer) + .build()) + .build())); + } + + @Test + void handleFungibleTransfersToExistingAccountsSuccess() { + // This test case handles successfully transferring fungible tokens only + + final var token1Id = fungibleTokenId; + final var token2Id = asToken(2); + final var token3Id = asToken(3); + // Note: givenFungibleTokenRelation() has a non-zero balance, so we don't need to modify the token rel balances + final var acct1212Token1Rel = givenFungibleTokenRelation() + .copyBuilder() + .accountId(ACCOUNT_1212_ID) + .build(); + final var acct3434Token1Rel = givenFungibleTokenRelation() + .copyBuilder() + .tokenId(token1Id) + .accountId(ACCOUNT_3434_ID) + .balance(0) + .build(); + final var acct3434Token2Rel = givenFungibleTokenRelation() + .copyBuilder() + .tokenId(token2Id) + .accountId(ACCOUNT_3434_ID) + .build(); + final var acct5656Token2Rel = givenFungibleTokenRelation() + .copyBuilder() + .tokenId(token2Id) + .accountId(ACCOUNT_5656_ID) + .balance(0) + .build(); + final var acct5656Token3Rel = givenFungibleTokenRelation() + .copyBuilder() + .tokenId(token3Id) + .accountId(ACCOUNT_5656_ID) + .build(); + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434, ACCOUNT_5656); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434, ACCOUNT_5656); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels( + acct1212Token1Rel, acct3434Token1Rel, acct3434Token2Rel, acct5656Token2Rel, acct5656Token3Rel); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels( + acct1212Token1Rel, acct3434Token1Rel, acct3434Token2Rel, acct5656Token2Rel, acct5656Token3Rel); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(); // Intentionally empty + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(); // Intentionally empty + // The account in tokenRel1 will send X fungible units of token 1 to the account on tokenRel2 + // The account in tokenRel2 will send Y fungible units of token 2 to the account on tokenRel3 + // Token rels 1 and 2 will have balance changes, but token rel 3's balance won't change + final var token1AmountTransferred = acct1212Token1Rel.balance() - 1; + writableTokenRelStore.put(acct1212Token1Rel.copyBuilder().balance(1).build()); + writableTokenRelStore.put(acct3434Token2Rel + .copyBuilder() + .tokenId(token1Id) + .balance(token1AmountTransferred) + .build()); + final var token2AmountTransferred = acct3434Token2Rel.balance() - 10; + writableTokenRelStore.put(acct3434Token2Rel.copyBuilder().balance(10).build()); + writableTokenRelStore.put(acct5656Token3Rel + .copyBuilder() + .tokenId(token2Id) + .balance(token2AmountTransferred) + .build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + subject.finalizeChildRecord(context); + + BDDMockito.verify(recordBuilder) + .tokenTransferLists(List.of( + TokenTransferList.newBuilder() + .token(token1Id) + .transfers( + AccountAmount.newBuilder() + .accountID(ACCOUNT_1212_ID) + .amount(-token1AmountTransferred) + .isApproval(false) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(token1AmountTransferred) + .isApproval(false) + .build()) + .build(), + TokenTransferList.newBuilder() + .token(token2Id) + .transfers( + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(-token2AmountTransferred) + .isApproval(false) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_5656_ID) + .amount(token2AmountTransferred) + .isApproval(false) + .build()) + .build())); + } + + @Test + void handleNftTransfersToNewAccountSuccess() { + // This case handles a successful NFT transfer to an auto-created account + final var existingTokenRel = givenNonFungibleTokenRelation() + .copyBuilder() + .tokenId(TOKEN_321) + .accountId(ACCOUNT_1212_ID) + .build(); + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(existingTokenRel); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(existingTokenRel); + final var nft = givenNft( + NftID.newBuilder().tokenId(TOKEN_321).serialNumber(1).build()) + .copyBuilder() + .ownerId(ACCOUNT_1212_ID) + .build(); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(nft); + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(nft); + // Simulate the token receiver's account (ACCOUNT_3434) being auto-created + writableAccountStore.put(ACCOUNT_3434 + .copyBuilder() + .tinybarBalance(0) + .alias(Bytes.wrap("00000000000000000003")) + .build()); + writableNftStore.put(nft.copyBuilder().ownerId(ACCOUNT_3434_ID).build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + subject.finalizeChildRecord(context); + + BDDMockito.verify(recordBuilder) + .tokenTransferLists(List.of(TokenTransferList.newBuilder() + .token(TOKEN_321) + .nftTransfers(NftTransfer.newBuilder() + .serialNumber(1) + .senderAccountID(ACCOUNT_1212_ID) + .receiverAccountID(ACCOUNT_3434_ID) + .build()) + .build())); + } + + @Test + void handleNewNftTransferToAccountSuccess() { + final var existingTokenRel = givenNonFungibleTokenRelation() + .copyBuilder() + .tokenId(TOKEN_321) + .accountId(ACCOUNT_1212_ID) + .build(); + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(existingTokenRel); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(existingTokenRel); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(); // Intentionally empty + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(); // Intentionally empty + // Simulate the NFT being created and transferred to the receiver's account (ACCOUNT_3434) + final var newNft = givenNft( + NftID.newBuilder().tokenId(TOKEN_321).serialNumber(1).build()) + .copyBuilder() + .ownerId(ACCOUNT_1212_ID) + .build(); + writableNftStore.put(newNft.copyBuilder().ownerId(ACCOUNT_3434_ID).build()); + context = mockContext(); + + given(context.configuration()).willReturn(configuration); + subject.finalizeChildRecord(context); + + BDDMockito.verify(recordBuilder) + .tokenTransferLists(List.of(TokenTransferList.newBuilder() + .token(TOKEN_321) + .nftTransfers(NftTransfer.newBuilder() + .serialNumber(1) + .senderAccountID((AccountID) null) + .receiverAccountID(ACCOUNT_3434_ID) + .build()) + .build())); + } + + @Test + void handleNftTransfersToExistingAccountSuccess() { + // This test case handles successfully transferring NFTs only + + // Set up NFTs for token ID 531 (serials 111, 112) + final var nftId111 = + NftID.newBuilder().tokenId(TOKEN_321).serialNumber(111).build(); + final var nft111 = + Nft.newBuilder().id(nftId111).ownerId(ACCOUNT_1212_ID).build(); + final var nft112 = nft111.copyBuilder() + .id(nftId111.copyBuilder().serialNumber(112).build()) + .build(); + final var acct1212tokenRel1 = givenNonFungibleTokenRelation() + .copyBuilder() + .accountId(ACCOUNT_1212_ID) + .build(); + final var acct3434tokenRel1 = givenNonFungibleTokenRelation() + .copyBuilder() + .accountId(ACCOUNT_3434_ID) + .build(); + + // Set up NFTs for token ID 246 (serials 222, 223) + final var token246Id = asToken(246); + final var nftId222 = + NftID.newBuilder().tokenId(token246Id).serialNumber(222).build(); + final var nft222 = + nft111.copyBuilder().id(nftId222).ownerId(ACCOUNT_3434_ID).build(); + final var nft223 = nft222.copyBuilder() + .id(nftId222.copyBuilder().serialNumber(223).build()) + .build(); + final var acct1212tokenRel2 = givenNonFungibleTokenRelation() + .copyBuilder() + .accountId(ACCOUNT_1212_ID) + .build(); + final var acct3434tokenRel2 = givenNonFungibleTokenRelation() + .copyBuilder() + .accountId(ACCOUNT_3434_ID) + .build(); + + // Set up stores + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels( + acct1212tokenRel1, acct3434tokenRel1, acct1212tokenRel2, acct3434tokenRel2); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels( + acct1212tokenRel1, acct3434tokenRel1, acct1212tokenRel2, acct3434tokenRel2); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(nft111, nft112, nft222, nft223); + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(nft111, nft112, nft222, nft223); + writableNftStore.put(nft111.copyBuilder().ownerId(ACCOUNT_3434_ID).build()); + writableNftStore.put(nft112.copyBuilder().ownerId(ACCOUNT_3434_ID).build()); + writableNftStore.put(nft222.copyBuilder().ownerId(ACCOUNT_1212_ID).build()); + writableNftStore.put(nft223.copyBuilder().ownerId(ACCOUNT_1212_ID).build()); + context = mockContext(); + final var config = HederaTestConfigBuilder.create() + .withValue("staking.isEnabled", String.valueOf(false)) + .getOrCreateConfig(); + given(context.configuration()).willReturn(config); + + subject.finalizeChildRecord(context); + + // The transfer list should be sorted by token ID, then by serial number + BDDMockito.verify(recordBuilder) + .tokenTransferLists(List.of( + // Expected transfer list for token246 + TokenTransferList.newBuilder() + .token(token246Id) + .nftTransfers( + NftTransfer.newBuilder() + .serialNumber(222) + .senderAccountID(ACCOUNT_3434_ID) + .receiverAccountID(ACCOUNT_1212_ID) + .build(), + NftTransfer.newBuilder() + .serialNumber(223) + .senderAccountID(ACCOUNT_3434_ID) + .receiverAccountID(ACCOUNT_1212_ID) + .build()) + .build(), + // Expected transfer list for TOKEN_531 + TokenTransferList.newBuilder() + .token(TOKEN_321) + .nftTransfers( + NftTransfer.newBuilder() + .serialNumber(111) + .senderAccountID(ACCOUNT_1212_ID) + .receiverAccountID(ACCOUNT_3434_ID) + .build(), + NftTransfer.newBuilder() + .serialNumber(112) + .senderAccountID(ACCOUNT_1212_ID) + .receiverAccountID(ACCOUNT_3434_ID) + .build()) + .build())); + + subject.finalizeChildRecord(context); + } + + @Test + void handleCombinedHbarAndTokenTransfersSuccess() { + // This test case tests the combined success of hbar, fungible token, and nft transfers + + final var token321Rel = givenFungibleTokenRelation() + .copyBuilder() + .tokenId(TOKEN_321) + .accountId(ACCOUNT_3434_ID) + .balance(50) + .build(); + final var token654Id = asToken(654); + final var token654Rel = givenNonFungibleTokenRelation() + .copyBuilder() + .tokenId(token654Id) + .accountId(ACCOUNT_5656_ID) + .build(); + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434, ACCOUNT_5656); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434, ACCOUNT_5656); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(token321Rel, token654Rel); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(token321Rel, token654Rel); + final var nft = givenNft( + NftID.newBuilder().tokenId(token654Id).serialNumber(2).build()) + .copyBuilder() + .ownerId(ACCOUNT_5656_ID) + .build(); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(nft); + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(nft); + // Make hbar changes + final var hbar1212Change = ACCOUNT_1212.tinybarBalance() - 5; + writableAccountStore.put(ACCOUNT_1212.copyBuilder().tinybarBalance(5).build()); + writableAccountStore.put(ACCOUNT_3434 + .copyBuilder() + .tinybarBalance(ACCOUNT_3434.tinybarBalance() + hbar1212Change) + .build()); + // Make fungible token changes + final var fungible321Change = token321Rel.balance() - 25; + writableTokenRelStore.put(token321Rel.copyBuilder().balance(25).build()); + writableTokenRelStore.put(token321Rel + .copyBuilder() + .accountId(ACCOUNT_5656_ID) + .balance(fungible321Change) + .build()); + // Make NFT changes + writableNftStore.put(nft.copyBuilder().ownerId(ACCOUNT_1212_ID).build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + subject.finalizeChildRecord(context); + + BDDMockito.verify(recordBuilder) + .transferList(TransferList.newBuilder() + .accountAmounts( + AccountAmount.newBuilder() + .accountID(ACCOUNT_1212_ID) + .amount(-hbar1212Change) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(hbar1212Change) + .build()) + .build()); + BDDMockito.verify(recordBuilder) + .tokenTransferLists(List.of( + TokenTransferList.newBuilder() + .token(TOKEN_321) + .transfers( + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(-fungible321Change) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_5656_ID) + .amount(fungible321Change) + .build()) + .build(), + TokenTransferList.newBuilder() + .token(token654Id) + .nftTransfers(NftTransfer.newBuilder() + .serialNumber(2) + .senderAccountID(ACCOUNT_5656_ID) + .receiverAccountID(ACCOUNT_1212_ID) + .build()) + .build())); + } + + private HandleContext mockContext() { + given(context.recordBuilder(CryptoTransferRecordBuilder.class)).willReturn(recordBuilder); + + given(context.readableStore(ReadableAccountStore.class)).willReturn(readableAccountStore); + given(context.writableStore(WritableAccountStore.class)).willReturn(writableAccountStore); + given(context.readableStore(ReadableTokenRelationStore.class)).willReturn(readableTokenRelStore); + given(context.writableStore(WritableTokenRelationStore.class)).willReturn(writableTokenRelStore); + given(context.readableStore(ReadableNftStore.class)).willReturn(readableNftStore); + given(context.writableStore(WritableNftStore.class)).willReturn(writableNftStore); + + return context; + } +} diff --git a/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/FinalizeParentRecordHandlerTest.java b/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/FinalizeParentRecordHandlerTest.java index b2762946dab1..db01f668198a 100644 --- a/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/FinalizeParentRecordHandlerTest.java +++ b/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/FinalizeParentRecordHandlerTest.java @@ -17,11 +17,13 @@ package com.hedera.node.app.service.token.impl.test.handlers; import static com.hedera.hapi.node.base.ResponseCodeEnum.FAIL_INVALID; +import static com.hedera.node.app.service.token.impl.handlers.BaseCryptoHandler.asAccount; import static com.hedera.node.app.service.token.impl.handlers.BaseTokenHandler.asToken; import static com.hedera.node.app.spi.fixtures.workflows.ExceptionConditions.responseCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.Mock.Strictness.LENIENT; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -35,7 +37,7 @@ import com.hedera.hapi.node.base.TransferList; import com.hedera.hapi.node.state.token.Account; import com.hedera.hapi.node.state.token.Nft; -import com.hedera.hapi.node.transaction.TransactionBody; +import com.hedera.hapi.node.transaction.TransactionRecord; import com.hedera.node.app.service.token.ReadableAccountStore; import com.hedera.node.app.service.token.ReadableNftStore; import com.hedera.node.app.service.token.ReadableTokenRelationStore; @@ -49,11 +51,9 @@ import com.hedera.node.app.service.token.impl.test.handlers.util.TestStoreFactory; import com.hedera.node.app.spi.workflows.HandleContext; import com.hedera.node.app.spi.workflows.HandleException; -import com.hedera.node.app.spi.workflows.PreHandleContext; import com.hedera.node.config.testfixtures.HederaTestConfigBuilder; import com.hedera.pbj.runtime.io.buffer.Bytes; import java.util.List; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -103,21 +103,10 @@ public void setUp() { subject = new FinalizeParentRecordHandler(stakingRewardsHandler); } - @Test - void pureChecksSucceeds() { - Assertions.assertThatCode(() -> subject.pureChecks(mock(TransactionBody.class))) - .doesNotThrowAnyException(); - } - - @Test - void prehandleSucceeds() { - Assertions.assertThatCode(() -> subject.preHandle(mock(PreHandleContext.class))) - .doesNotThrowAnyException(); - } - @Test void handleNullArg() { - assertThatThrownBy(() -> subject.handle(context)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> subject.finalizeParentRecord(context, List.of())) + .isInstanceOf(NullPointerException.class); } @Test @@ -131,7 +120,7 @@ void handleHbarNetTransferAmountIsNotZero() { context = mockContext(); given(context.configuration()).willReturn(configuration); - assertThatThrownBy(() -> subject.handle(context)) + assertThatThrownBy(() -> subject.finalizeParentRecord(context, List.of())) .isInstanceOf(HandleException.class) .has(responseCode(FAIL_INVALID)); } @@ -153,7 +142,7 @@ void handleHbarAccountBalanceIsNegative() { context = mockContext(); given(context.configuration()).willReturn(configuration); - assertThatThrownBy(() -> subject.handle(context)) + assertThatThrownBy(() -> subject.finalizeParentRecord(context, List.of())) .isInstanceOf(HandleException.class) .has(responseCode(FAIL_INVALID)); } @@ -172,7 +161,7 @@ void handleHbarAccountBalanceDoesntChange() { context = mockContext(); given(context.configuration()).willReturn(configuration); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); BDDMockito.verifyNoInteractions(recordBuilder); } @@ -198,7 +187,7 @@ void handleHbarTransfersToNewAccountSuccess() { context = mockContext(); given(context.configuration()).willReturn(configuration); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); BDDMockito.verify(recordBuilder) .transferList(TransferList.newBuilder() @@ -214,10 +203,218 @@ void handleHbarTransfersToNewAccountSuccess() { .build()); } + @Test + void handleHbarTransfersToAccountDeductsFromChildRecordsSuccess() { + // This case handles a successful hbar transfer to an auto-created account + // deducts the child record transfers from parent transfer list + + final var amountToTransfer = ACCOUNT_1212.tinybarBalance() - 1; + // 1 tinybar left in parent account , transferred 9999 + final var childRecordTransfer = amountToTransfer / 2; // 1/2 of parent account balance, 4999 + + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212); + writableAccountStore.put(ACCOUNT_1212.copyBuilder().tinybarBalance(1).build()); + // Putting ACCOUNT_3434 into the writable store here simulates this account being auto-created + writableAccountStore.put(ACCOUNT_3434 + .copyBuilder() + .alias(Bytes.wrap("00000000000000000001")) + .tinybarBalance(amountToTransfer) + .build()); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(); // Intentionally empty + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(); // Intentionally empty + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(); // Intentionally empty + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(); // Intentionally empty + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + final var childRecord = mock(TransactionRecord.class); + // child record has 1212 (-) -> 3434(+) transfer + given(childRecord.transferList()) + .willReturn(TransferList.newBuilder() + .accountAmounts( + AccountAmount.newBuilder() + .accountID(ACCOUNT_1212_ID) + .amount(-childRecordTransfer) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(childRecordTransfer) + .build()) + .build()); + subject.finalizeParentRecord(context, List.of(childRecord)); + + final var transferAmount1212 = -amountToTransfer + childRecordTransfer; + final var transferAmount3434 = amountToTransfer - childRecordTransfer; + BDDMockito.verify(recordBuilder) + .transferList(TransferList.newBuilder() + .accountAmounts( + AccountAmount.newBuilder() + .accountID(ACCOUNT_1212_ID) + .amount(transferAmount1212) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(transferAmount3434) + .build()) + .build()); + } + + @Test + void handleFungibleTokenTransfersToAccountDeductsFromChildRecordsSuccess() { + // This case handles a successful fungible token transfer to an auto-created account + // does not deduct all child record transfers from parent transfer list + + final var senderAcct = ACCOUNT_1212; + final var senderTokenRel = givenFungibleTokenRelation() + .copyBuilder() + .tokenId(TOKEN_321) + .accountId(ACCOUNT_1212_ID) + .build(); + final var fungibleAmountToTransfer = senderTokenRel.balance() - 1; + final var childAmount = fungibleAmountToTransfer / 2; + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(senderAcct); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(senderAcct); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(senderTokenRel); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(senderTokenRel); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(); // Intentionally empty + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(); // Intentionally empty + // Simulate the token receiver's account (ACCOUNT_3434) being auto-created (with an hbar balance of 0) + writableAccountStore.put(ACCOUNT_3434 + .copyBuilder() + .tinybarBalance(0) + .alias(Bytes.wrap("00000000000000000002")) + .build()); + // Simulate the receiver's token relation being auto-created (and both the sender and receiver token rel + // balances adjusted) + writableTokenRelStore.put(senderTokenRel.copyBuilder().balance(1).build()); + writableTokenRelStore.put(senderTokenRel + .copyBuilder() + .accountId(ACCOUNT_3434_ID) + .balance(fungibleAmountToTransfer) + .build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + final var childRecord = mock(TransactionRecord.class); + // child record has 1212 (-) -> 3434(+) transfer + lenient() + .when(childRecord.transferList()) + .thenReturn(TransferList.newBuilder().build()); + lenient() + .when(childRecord.tokenTransferListsOrElse(List.of())) + .thenReturn(List.of(TokenTransferList.newBuilder() + .token(TOKEN_321) + .transfers( + AccountAmount.newBuilder() + .accountID(ACCOUNT_1212_ID) + .amount(-childAmount) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(childAmount) + .build()) + .build())); + + subject.finalizeParentRecord(context, List.of(childRecord)); + + BDDMockito.verify(recordBuilder) + .tokenTransferLists(List.of(TokenTransferList.newBuilder() + .token(TOKEN_321) + .transfers( + AccountAmount.newBuilder() + .accountID(ACCOUNT_1212_ID) + .amount(-fungibleAmountToTransfer) + .build(), + AccountAmount.newBuilder() + .accountID(ACCOUNT_3434_ID) + .amount(fungibleAmountToTransfer) + .build()) + .build())); + } + + @Test + void accountsForDissociatedTokenRelations() { + // This case handles a successful fungible token relation dissociation when token is deleted + // When just token is dissociated without any token delete, then transfer list doesn't show that case + + final var senderAcct = ACCOUNT_1212; + final var senderTokenRel = givenFungibleTokenRelation() + .copyBuilder() + .tokenId(TOKEN_321) + .accountId(ACCOUNT_1212_ID) + .build(); + final var receiverRel = givenFungibleTokenRelation() + .copyBuilder() + .tokenId(TOKEN_321) + .accountId(ACCOUNT_3434_ID) + .build(); + final var fungibleAmountToTransfer = senderTokenRel.balance() - 1; + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(senderAcct); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(senderAcct); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(senderTokenRel, receiverRel); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(senderTokenRel, receiverRel); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(); // Intentionally empty + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(); // Intentionally empty + // Simulate the receiver's token relation being dissociated, when token is deleted. + // This shows as a single debit in transfer list, instead of a debit and a credit. + writableTokenRelStore.remove(senderTokenRel); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + subject.finalizeParentRecord(context, List.of()); + + BDDMockito.verify(recordBuilder) + .tokenTransferLists(List.of(TokenTransferList.newBuilder() + .token(TOKEN_321) + .transfers(AccountAmount.newBuilder() + .accountID(ACCOUNT_1212_ID) + .amount(-1000L) + .build()) + .build())); + } + + @Test + void nftBurnsOrWipesAreAccounted() { + // This case handles a successful NFT burn or wipe + final var existingTokenRel = givenNonFungibleTokenRelation() + .copyBuilder() + .tokenId(TOKEN_321) + .accountId(ACCOUNT_1212_ID) + .build(); + readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212); + writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212); + readableTokenRelStore = TestStoreFactory.newReadableStoreWithTokenRels(existingTokenRel); + writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(existingTokenRel); + final var nft1 = givenNft( + NftID.newBuilder().tokenId(TOKEN_321).serialNumber(1).build()) + .copyBuilder() + .ownerId(ACCOUNT_1212_ID) + .build(); + readableNftStore = TestStoreFactory.newReadableStoreWithNfts(nft1); + writableNftStore = TestStoreFactory.newWritableStoreWithNfts(nft1); + + writableNftStore.remove( + NftID.newBuilder().tokenId(TOKEN_321).serialNumber(1).build()); + context = mockContext(); + given(context.configuration()).willReturn(configuration); + + subject.finalizeParentRecord(context, List.of()); + + BDDMockito.verify(recordBuilder) + .tokenTransferLists(List.of(TokenTransferList.newBuilder() + .token(TOKEN_321) + .nftTransfers(NftTransfer.newBuilder() + .serialNumber(1) + .senderAccountID(ACCOUNT_1212_ID) + .receiverAccountID(asAccount(0)) + .build()) + .build())); + } + @Test void handleHbarTransfersToExistingAccountSuccess() { // This test case handles successfully transferring hbar only (no tokens) - readableAccountStore = TestStoreFactory.newReadableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434, ACCOUNT_5656); writableAccountStore = TestStoreFactory.newWritableStoreWithAccounts(ACCOUNT_1212, ACCOUNT_3434, ACCOUNT_5656); writableTokenRelStore = TestStoreFactory.newWritableStoreWithTokenRels(); // Intentionally empty @@ -238,7 +435,7 @@ void handleHbarTransfersToExistingAccountSuccess() { context = mockContext(); given(context.configuration()).willReturn(configuration); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); BDDMockito.verify(recordBuilder) .transferList(TransferList.newBuilder() @@ -267,7 +464,7 @@ void handleFungibleTokenBalanceIsNegative() { context = mockContext(); given(context.configuration()).willReturn(configuration); - assertThatThrownBy(() -> subject.handle(context)) + assertThatThrownBy(() -> subject.finalizeParentRecord(context, List.of())) .isInstanceOf(HandleException.class) .has(responseCode(FAIL_INVALID)); } @@ -287,7 +484,7 @@ void handleFungibleTransferTokenBalancesDontChange() { context = mockContext(); given(context.configuration()).willReturn(configuration); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); BDDMockito.verifyNoInteractions(recordBuilder); } @@ -326,7 +523,7 @@ void handleFungibleTransfersToNewAccountSuccess() { context = mockContext(); given(context.configuration()).willReturn(configuration); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); BDDMockito.verify(recordBuilder) .tokenTransferLists(List.of(TokenTransferList.newBuilder() @@ -405,7 +602,7 @@ void handleFungibleTransfersToExistingAccountsSuccess() { context = mockContext(); given(context.configuration()).willReturn(configuration); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); BDDMockito.verify(recordBuilder) .tokenTransferLists(List.of( @@ -468,7 +665,7 @@ void handleNftTransfersToNewAccountSuccess() { context = mockContext(); given(context.configuration()).willReturn(configuration); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); BDDMockito.verify(recordBuilder) .tokenTransferLists(List.of(TokenTransferList.newBuilder() @@ -504,7 +701,7 @@ void handleNewNftTransferToAccountSuccess() { context = mockContext(); given(context.configuration()).willReturn(configuration); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); BDDMockito.verify(recordBuilder) .tokenTransferLists(List.of(TokenTransferList.newBuilder() @@ -575,7 +772,7 @@ void handleNftTransfersToExistingAccountSuccess() { .getOrCreateConfig(); given(context.configuration()).willReturn(config); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); // The transfer list should be sorted by token ID, then by serial number BDDMockito.verify(recordBuilder) @@ -611,7 +808,7 @@ void handleNftTransfersToExistingAccountSuccess() { .build()) .build())); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); verify(stakingRewardsHandler, never()).applyStakingRewards(context); } @@ -662,7 +859,7 @@ void handleCombinedHbarAndTokenTransfersSuccess() { context = mockContext(); given(context.configuration()).willReturn(configuration); - subject.handle(context); + subject.finalizeParentRecord(context, List.of()); BDDMockito.verify(recordBuilder) .transferList(TransferList.newBuilder() @@ -701,8 +898,6 @@ void handleCombinedHbarAndTokenTransfersSuccess() { } private HandleContext mockContext() { - final var context = mock(HandleContext.class); - given(context.recordBuilder(CryptoTransferRecordBuilder.class)).willReturn(recordBuilder); given(context.readableStore(ReadableAccountStore.class)).willReturn(readableAccountStore); diff --git a/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/staking/StakeRewardCalculatorImplTest.java b/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/staking/StakeRewardCalculatorImplTest.java index b0a9f4caad7a..79416850f89a 100644 --- a/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/staking/StakeRewardCalculatorImplTest.java +++ b/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/staking/StakeRewardCalculatorImplTest.java @@ -26,8 +26,8 @@ import com.hedera.hapi.node.state.token.Account; import com.hedera.hapi.node.state.token.StakingNodeInfo; import com.hedera.node.app.service.token.ReadableNetworkStakingRewardsStore; -import com.hedera.node.app.service.token.ReadableStakingInfoStore; import com.hedera.node.app.service.token.Units; +import com.hedera.node.app.service.token.impl.WritableStakingInfoStore; import com.hedera.node.app.service.token.impl.handlers.staking.StakePeriodManager; import com.hedera.node.app.service.token.impl.handlers.staking.StakeRewardCalculatorImpl; import java.time.Instant; @@ -53,7 +53,7 @@ class StakeRewardCalculatorImplTest { private StakePeriodManager stakePeriodManager; @Mock - private ReadableStakingInfoStore stakingInfoStore; + private WritableStakingInfoStore stakingInfoStore; @Mock private StakingNodeInfo stakingNodeInfo; @@ -92,7 +92,7 @@ void calculatesRewardsAppropriatelyIfBalanceAtStartOfLastRewardedPeriodIsSet() { rewardHistory.set(1, 3L); rewardHistory.set(2, 1L); setUpMocks(); - given(stakingInfoStore.get(0L)).willReturn(stakingNodeInfo); + given(stakingInfoStore.getOriginalValue(0L)).willReturn(stakingNodeInfo); given(stakePeriodManager.currentStakePeriod(consensusTime)).willReturn(TODAY_NUMBER); given(stakingNodeInfo.rewardSumHistory()).willReturn(rewardHistory); // Staked node ID of -1 will return a node ID address of 0 diff --git a/hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/records/ChildRecordFinalizer.java b/hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/records/ChildRecordFinalizer.java new file mode 100644 index 000000000000..500772280bf2 --- /dev/null +++ b/hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/records/ChildRecordFinalizer.java @@ -0,0 +1,39 @@ +/* + * 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.token.records; + +import com.hedera.node.app.spi.workflows.HandleContext; +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * This is a special handler that is used to "finalize" hbar and token transfers for the child transaction record. + * Finalization in this context means summing the net changes to make to each account's hbar balance and token + * balances, and assigning the final owner of an nft after an arbitrary number of ownership changes. + * Based on issue https://github.com/hashgraph/hedera-services/issues/7084 the modularized + * transaction record for NFT transfer chain A -> B -> C, will look different from mono-service record. + * This is because mono-service will record both ownership changes from A -> b and then B-> C. + * NOTE: This record doesn't calculate any staking rewards. + * Staking rewards are calculated only for parent transaction record. + * In this finalizer, we will: + * 1. Iterate through all modifications in writableAccountStore, writableTokenRelationStore. + * 2. For each modification we look at the same entity's original value + * 3. Calculate the difference between the two, and then construct a TransferList and TokenTransferList + * for the child record + */ +public interface ChildRecordFinalizer { + void finalizeChildRecord(@NonNull final HandleContext context); +} diff --git a/hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/records/ParentRecordFinalizer.java b/hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/records/ParentRecordFinalizer.java new file mode 100644 index 000000000000..4e2895ea4ef5 --- /dev/null +++ b/hedera-node/hedera-token-service/src/main/java/com/hedera/node/app/service/token/records/ParentRecordFinalizer.java @@ -0,0 +1,46 @@ +/* + * 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.token.records; + +import com.hedera.hapi.node.transaction.TransactionRecord; +import com.hedera.node.app.spi.workflows.HandleContext; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.List; + +/** + * This class is used to "finalize" hbar and token transfers for the parent transaction record. + * Finalization in this context means summing the net changes to make to each account's hbar balance and token + * balances, and assigning the final owner of an nft after an arbitrary number of ownership changes. + * Based on issue https://github.com/hashgraph/hedera-services/issues/7084 the modularized + * transaction record for NFT transfer chain A -> B -> C, will look different from mono-service record. + * This is because mono-service will record both ownership changes from A -> b and then B-> C. + * Parent record will record any staking rewards paid out due to transaction changes to state. + * It will deduct any transfer changes that are listed in child transaction records in the parent record. + * + * In this finalizer, we will: + * 1.If staking is enabled, iterate through all modifications in writableAccountStore and compare with the corresponding entity in readableAccountStore + * 2. Comparing the changes, we look for balance/declineReward/stakedToMe/stakedId fields have been modified, + * if an account is staking to a node. Construct a list of possibleRewardReceivers + * 3. Pay staking rewards to any account who has pending rewards + * 4. Now again, iterate through all modifications in writableAccountStore, writableTokenRelationStore. + * 5. For each modification we look at the same entity in the respective readableStore + * 6. Calculate the difference between the two, and then construct a TransferList and TokenTransferList + * for the parent record (excluding changes from child transaction records) + */ +public interface ParentRecordFinalizer { + void finalizeParentRecord(@NonNull HandleContext context, List childRecords); +} diff --git a/hedera-node/hedera-token-service/src/main/java/module-info.java b/hedera-node/hedera-token-service/src/main/java/module-info.java index 1034324cd520..253cdcf39b1e 100644 --- a/hedera-node/hedera-token-service/src/main/java/module-info.java +++ b/hedera-node/hedera-token-service/src/main/java/module-info.java @@ -4,6 +4,7 @@ com.hedera.node.app.service.contract.impl, com.hedera.node.app, com.hedera.node.app.service.token.impl; + exports com.hedera.node.app.service.token.records; uses com.hedera.node.app.service.token.TokenService;