diff --git a/.github/workflows/charts.yml b/.github/workflows/charts.yml index 16bab02eab9..a1054665179 100644 --- a/.github/workflows/charts.yml +++ b/.github/workflows/charts.yml @@ -2,11 +2,11 @@ name: Charts on: pull_request: - branches: [ main, release/** ] - paths: [ charts/** ] + branches: [main, release/**] + paths: [charts/**] push: - branches: [ main, release/** ] - tags: [ v* ] + branches: [main, release/**] + tags: [v*] permissions: contents: read diff --git a/docs/checklist/feature.md b/docs/checklist/feature.md index 244156ad031..91a278711eb 100644 --- a/docs/checklist/feature.md +++ b/docs/checklist/feature.md @@ -12,6 +12,10 @@ aims to document the required changes associated with any new feature. - [ ] `TransactionType` - [ ] `DomainBuilder` +## GraphQL API + +- [ ] Transaction type + ## Importer - [ ] V1 migration diff --git a/docs/configuration.md b/docs/configuration.md index f1f8a2a1ed4..ad71119978a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -142,6 +142,7 @@ value, it is recommended to only populate overridden properties in the custom `a | `hedera.mirror.importer.parser.record.entity.persist.syntheticContractLogs` | true | Persist synthetic contract logs from HAPI transaction to the database | | `hedera.mirror.importer.parser.record.entity.persist.syntheticContractResults` | false | Persist synthetic contract results from HAPI transaction to the database | | `hedera.mirror.importer.parser.record.entity.persist.systemFiles` | true | Persist only system files (number lower than `1000`) to the database | +| `hedera.mirror.importer.parser.record.entity.persist.tokenAirdrops` | true | Persist token airdrop data to the database | | `hedera.mirror.importer.parser.record.entity.persist.tokens` | true | Persist token data to the database | | `hedera.mirror.importer.parser.record.entity.persist.topics` | true | Persist topic messages to the database | | `hedera.mirror.importer.parser.record.entity.persist.topicMessageLookups` | false | Persist topic message lookups to the database | diff --git a/docs/database/README.md b/docs/database/README.md index 5d26bcf2576..d23e4a324d5 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -39,7 +39,7 @@ work_mem = 50MB The table below documents the database indexes with the usage in APIs / services. | Table | Indexed Columns | Component | Service | Description | -|-----------------|----------------------------------------------|---------------|----------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| --------------- | -------------------------------------------- | ------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | contract_result | consensus_timestamp | REST API | `/api/v1/contracts/results` | Used to query contract results with timestamp filter | | contract_result | contract_id, sender_id, consensus_timestamp | REST API | `/api/v1/contracts/:idOrAddress/results?from=:from` | Used to query a specific contract's results with `from` filter | | contract_result | contract_id, consensus_timestamp | REST API | `/api/v1/contracts/:idOrAddress/results` | Used to query a specific contract's results with optional timestamp filter | diff --git a/docs/runbook/perform-stackgres-security-upgrade.md b/docs/runbook/perform-stackgres-security-upgrade.md index 75a7e886d51..95c9440c8dc 100644 --- a/docs/runbook/perform-stackgres-security-upgrade.md +++ b/docs/runbook/perform-stackgres-security-upgrade.md @@ -35,4 +35,3 @@ spec: securityUpgrade: mode: InPlace ``` - diff --git a/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/AbstractTokenAirdrop.java b/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/AbstractTokenAirdrop.java new file mode 100644 index 00000000000..6084281197c --- /dev/null +++ b/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/AbstractTokenAirdrop.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2024 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.mirror.common.domain.token; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.google.common.collect.Range; +import com.hedera.mirror.common.domain.History; +import com.hedera.mirror.common.domain.Upsertable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.IdClass; +import jakarta.persistence.MappedSuperclass; +import java.io.Serial; +import java.io.Serializable; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Data +@IdClass(AbstractTokenAirdrop.Id.class) +@MappedSuperclass +@NoArgsConstructor +@SuperBuilder(toBuilder = true) +@Upsertable(history = true) +public class AbstractTokenAirdrop implements History { + + private Long amount; + + @jakarta.persistence.Id + private long receiverAccountId; + + @jakarta.persistence.Id + private long senderAccountId; + + @jakarta.persistence.Id + private long serialNumber; + + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + private TokenAirdropStateEnum state; + + private Range timestampRange; + + @jakarta.persistence.Id + private long tokenId; + + @JsonIgnore + public Id getId() { + Id id = new Id(); + id.setReceiverAccountId(receiverAccountId); + id.setSenderAccountId(senderAccountId); + id.setSerialNumber(serialNumber); + id.setTokenId(tokenId); + return id; + } + + @Data + public static class Id implements Serializable { + @Serial + private static final long serialVersionUID = -8165098238647325621L; + + private long receiverAccountId; + private long senderAccountId; + private long serialNumber; + private long tokenId; + } +} diff --git a/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/TokenAirdrop.java b/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/TokenAirdrop.java new file mode 100644 index 00000000000..c4021f9fafe --- /dev/null +++ b/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/TokenAirdrop.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 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.mirror.common.domain.token; + +import jakarta.persistence.Entity; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@Entity +@NoArgsConstructor +@SuperBuilder(toBuilder = true) +public class TokenAirdrop extends AbstractTokenAirdrop { + // Only the parent class should contain fields so that they're shared with both the history and non-history tables. +} diff --git a/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/TokenAirdropHistory.java b/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/TokenAirdropHistory.java new file mode 100644 index 00000000000..cacddfeed03 --- /dev/null +++ b/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/TokenAirdropHistory.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 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.mirror.common.domain.token; + +import jakarta.persistence.Entity; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@Entity +@NoArgsConstructor +@SuperBuilder(toBuilder = true) +public class TokenAirdropHistory extends AbstractTokenAirdrop { + // Only the parent class should contain fields so that they're shared with both the history and non-history tables. +} diff --git a/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/TokenAirdropStateEnum.java b/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/TokenAirdropStateEnum.java new file mode 100644 index 00000000000..838b8e15b13 --- /dev/null +++ b/hedera-mirror-common/src/main/java/com/hedera/mirror/common/domain/token/TokenAirdropStateEnum.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 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.mirror.common.domain.token; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TokenAirdropStateEnum { + CANCELLED(0), + CLAIMED(1), + PENDING(2); + + private final int id; +} diff --git a/hedera-mirror-common/src/test/java/com/hedera/mirror/common/domain/DomainBuilder.java b/hedera-mirror-common/src/test/java/com/hedera/mirror/common/domain/DomainBuilder.java index f5ea36e925c..24bd8b2a6d4 100644 --- a/hedera-mirror-common/src/test/java/com/hedera/mirror/common/domain/DomainBuilder.java +++ b/hedera-mirror-common/src/test/java/com/hedera/mirror/common/domain/DomainBuilder.java @@ -70,6 +70,9 @@ import com.hedera.mirror.common.domain.token.Token; import com.hedera.mirror.common.domain.token.TokenAccount; import com.hedera.mirror.common.domain.token.TokenAccountHistory; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.common.domain.token.TokenAirdropHistory; +import com.hedera.mirror.common.domain.token.TokenAirdropStateEnum; import com.hedera.mirror.common.domain.token.TokenFreezeStatusEnum; import com.hedera.mirror.common.domain.token.TokenHistory; import com.hedera.mirror.common.domain.token.TokenKycStatusEnum; @@ -926,6 +929,42 @@ public DomainWrapper sidecarFile() return new DomainWrapperImpl<>(builder, builder::build); } + public DomainWrapper> tokenAirdrop(TokenTypeEnum type) { + long timestamp = timestamp(); + var builder = TokenAirdrop.builder() + .receiverAccountId(id()) + .senderAccountId(id()) + .state(TokenAirdropStateEnum.PENDING) + .timestampRange(Range.atLeast(timestamp)) + .tokenId(id()); + if (type == TokenTypeEnum.NON_FUNGIBLE_UNIQUE) { + builder.serialNumber(number()); + } else { + long amount = number() + 1000; + builder.amount(amount); + } + + return new DomainWrapperImpl<>(builder, builder::build); + } + + public DomainWrapper> tokenAirdropHistory( + TokenTypeEnum type) { + long timestamp = timestamp(); + var builder = TokenAirdropHistory.builder() + .receiverAccountId(id()) + .senderAccountId(id()) + .state(TokenAirdropStateEnum.PENDING) + .timestampRange(Range.closedOpen(timestamp, timestamp + 10)) + .tokenId(id()); + if (type == TokenTypeEnum.NON_FUNGIBLE_UNIQUE) { + builder.serialNumber(number()); + } else { + long amount = number() + 1000; + builder.amount(amount); + } + return new DomainWrapperImpl<>(builder, builder::build); + } + public DomainWrapper> tokenAllowance() { long amount = number() + 1000; var spender = entityId(); diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/CompositeEntityListener.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/CompositeEntityListener.java index d3fcfc3e409..9d4909d1a3c 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/CompositeEntityListener.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/CompositeEntityListener.java @@ -36,6 +36,7 @@ import com.hedera.mirror.common.domain.token.Nft; import com.hedera.mirror.common.domain.token.Token; import com.hedera.mirror.common.domain.token.TokenAccount; +import com.hedera.mirror.common.domain.token.TokenAirdrop; import com.hedera.mirror.common.domain.token.TokenTransfer; import com.hedera.mirror.common.domain.topic.TopicMessage; import com.hedera.mirror.common.domain.transaction.AssessedCustomFee; @@ -203,6 +204,11 @@ public void onTokenAccount(TokenAccount tokenAccount) throws ImporterException { onEach(EntityListener::onTokenAccount, tokenAccount); } + @Override + public void onTokenAirdrop(TokenAirdrop tokenAirdrop) throws ImporterException { + onEach(EntityListener::onTokenAirdrop, tokenAirdrop); + } + @Override public void onTokenAllowance(TokenAllowance tokenAllowance) throws ImporterException { onEach(EntityListener::onTokenAllowance, tokenAllowance); diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityListener.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityListener.java index 00f2f244648..8542e3545c5 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityListener.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityListener.java @@ -36,6 +36,7 @@ import com.hedera.mirror.common.domain.token.Nft; import com.hedera.mirror.common.domain.token.Token; import com.hedera.mirror.common.domain.token.TokenAccount; +import com.hedera.mirror.common.domain.token.TokenAirdrop; import com.hedera.mirror.common.domain.token.TokenTransfer; import com.hedera.mirror.common.domain.topic.TopicMessage; import com.hedera.mirror.common.domain.transaction.AssessedCustomFee; @@ -111,6 +112,8 @@ default void onToken(Token token) throws ImporterException {} default void onTokenAccount(TokenAccount tokenAccount) throws ImporterException {} + default void onTokenAirdrop(TokenAirdrop tokenAirdrop) {} + default void onTokenAllowance(TokenAllowance tokenAllowance) {} default void onTokenTransfer(TokenTransfer tokenTransfer) throws ImporterException {} diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityProperties.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityProperties.java index 882b95b6152..a63bc8f497b 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityProperties.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityProperties.java @@ -76,6 +76,8 @@ public static class PersistProperties { private boolean tokens = true; + private boolean tokenAirdrops = true; + private boolean topics = true; private boolean topicMessageLookups = false; @@ -108,6 +110,10 @@ public static class PersistProperties { @NotNull private Set transactionSignatures = EnumSet.of(SCHEDULECREATE, SCHEDULESIGN); + public boolean isTokenAirdrops() { + return tokenAirdrops && tokens; + } + public boolean shouldPersistEntityTransaction(EntityId entityId) { return entityTransactions && !EntityId.isEmpty(entityId) && !entityTransactionExclusion.contains(entityId); } diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/sql/SqlEntityListener.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/sql/SqlEntityListener.java index 1be465c3e3e..7586b32f27a 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/sql/SqlEntityListener.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/sql/SqlEntityListener.java @@ -41,6 +41,7 @@ import com.hedera.mirror.common.domain.token.NftTransfer; import com.hedera.mirror.common.domain.token.Token; import com.hedera.mirror.common.domain.token.TokenAccount; +import com.hedera.mirror.common.domain.token.TokenAirdrop; import com.hedera.mirror.common.domain.token.TokenTransfer; import com.hedera.mirror.common.domain.topic.TopicMessage; import com.hedera.mirror.common.domain.transaction.AssessedCustomFee; @@ -295,6 +296,13 @@ public void onTokenAccount(TokenAccount tokenAccount) throws ImporterException { context.merge(tokenAccount.getId(), tokenAccount, this::mergeTokenAccount); } + @Override + public void onTokenAirdrop(TokenAirdrop tokenAirdrop) { + if (entityProperties.getPersist().isTokenAirdrops()) { + context.merge(tokenAirdrop.getId(), tokenAirdrop, this::mergeTokenAirdrop); + } + } + @Override public void onTokenAllowance(TokenAllowance tokenAllowance) { context.merge(tokenAllowance.getId(), tokenAllowance, this::mergeFungibleAllowance); @@ -768,6 +776,16 @@ private TokenAccount mergeTokenAccountBalance(TokenAccount lastTokenAccount, Tok return lastTokenAccount; } + private TokenAirdrop mergeTokenAirdrop(TokenAirdrop previous, TokenAirdrop current) { + if (previous.getAmount() != null && current.getAmount() == null) { + // Cancel or claim do not contain an amount so set the amount here so as not to override it with null + current.setAmount(previous.getAmount()); + } + + previous.setTimestampUpper(current.getTimestampLower()); + return current; + } + private void onNftTransferList(Transaction transaction) { var nftTransferList = transaction.getNftTransfer(); if (CollectionUtils.isEmpty(nftTransferList)) { diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/AbstractTokenUpdateAirdropTransactionHandler.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/AbstractTokenUpdateAirdropTransactionHandler.java new file mode 100644 index 00000000000..a15e56254ca --- /dev/null +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/AbstractTokenUpdateAirdropTransactionHandler.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 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.mirror.importer.parser.record.transactionhandler; + +import com.google.common.collect.Range; +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.common.domain.token.TokenAirdropStateEnum; +import com.hedera.mirror.common.domain.transaction.RecordItem; +import com.hedera.mirror.common.domain.transaction.Transaction; +import com.hedera.mirror.common.domain.transaction.TransactionType; +import com.hedera.mirror.importer.domain.EntityIdService; +import com.hedera.mirror.importer.parser.record.entity.EntityListener; +import com.hedera.mirror.importer.parser.record.entity.EntityProperties; +import com.hedera.mirror.importer.util.Utility; +import com.hederahashgraph.api.proto.java.PendingAirdropId; +import com.hederahashgraph.api.proto.java.TokenID; +import java.util.List; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +abstract class AbstractTokenUpdateAirdropTransactionHandler extends AbstractTransactionHandler { + + private final EntityIdService entityIdService; + private final EntityListener entityListener; + private final EntityProperties entityProperties; + private final Function> extractor; + private final TokenAirdropStateEnum state; + private final TransactionType type; + + @Override + public void doUpdateTransaction(Transaction transaction, RecordItem recordItem) { + if (!entityProperties.getPersist().isTokenAirdrops() || !recordItem.isSuccessful()) { + return; + } + + var pendingAirdropIds = extractor.apply(recordItem); + for (var pendingAirdropId : pendingAirdropIds) { + var receiver = + entityIdService.lookup(pendingAirdropId.getReceiverId()).orElse(EntityId.EMPTY); + var sender = entityIdService.lookup(pendingAirdropId.getSenderId()).orElse(EntityId.EMPTY); + if (EntityId.isEmpty(receiver) || EntityId.isEmpty(sender)) { + Utility.handleRecoverableError( + "Invalid update token airdrop entity id at {}", recordItem.getConsensusTimestamp()); + continue; + } + + recordItem.addEntityId(receiver); + recordItem.addEntityId(sender); + + var tokenAirdrop = new TokenAirdrop(); + tokenAirdrop.setState(state); + tokenAirdrop.setReceiverAccountId(receiver.getId()); + tokenAirdrop.setSenderAccountId(sender.getId()); + tokenAirdrop.setTimestampRange(Range.atLeast(recordItem.getConsensusTimestamp())); + + TokenID tokenId; + if (pendingAirdropId.hasFungibleTokenType()) { + tokenId = pendingAirdropId.getFungibleTokenType(); + } else { + tokenId = pendingAirdropId.getNonFungibleToken().getTokenID(); + var serialNumber = pendingAirdropId.getNonFungibleToken().getSerialNumber(); + tokenAirdrop.setSerialNumber(serialNumber); + } + + var tokenEntityId = EntityId.of(tokenId); + recordItem.addEntityId(tokenEntityId); + tokenAirdrop.setTokenId(tokenEntityId.getId()); + entityListener.onTokenAirdrop(tokenAirdrop); + } + } + + @Override + public TransactionType getType() { + return type; + } +} diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenAirdropTransactionHandler.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenAirdropTransactionHandler.java index 563daa78d24..2949c1f0b35 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenAirdropTransactionHandler.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenAirdropTransactionHandler.java @@ -16,12 +16,64 @@ package com.hedera.mirror.importer.parser.record.transactionhandler; +import com.google.common.collect.Range; +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.common.domain.token.TokenAirdropStateEnum; +import com.hedera.mirror.common.domain.transaction.RecordItem; +import com.hedera.mirror.common.domain.transaction.Transaction; import com.hedera.mirror.common.domain.transaction.TransactionType; +import com.hedera.mirror.importer.parser.record.entity.EntityListener; +import com.hedera.mirror.importer.parser.record.entity.EntityProperties; +import com.hederahashgraph.api.proto.java.TokenID; import jakarta.inject.Named; +import lombok.RequiredArgsConstructor; @Named +@RequiredArgsConstructor class TokenAirdropTransactionHandler extends AbstractTransactionHandler { + private final EntityListener entityListener; + private final EntityProperties entityProperties; + + @Override + protected void doUpdateTransaction(Transaction transaction, RecordItem recordItem) { + if (!entityProperties.getPersist().isTokenAirdrops() || !recordItem.isSuccessful()) { + return; + } + + var pendingAirdrops = recordItem.getTransactionRecord().getNewPendingAirdropsList(); + for (var pendingAirdrop : pendingAirdrops) { + var pendingAirdropId = pendingAirdrop.getPendingAirdropId(); + var receiver = EntityId.of(pendingAirdropId.getReceiverId()); + var sender = EntityId.of(pendingAirdropId.getSenderId()); + recordItem.addEntityId(receiver); + recordItem.addEntityId(sender); + + var tokenAirdrop = new TokenAirdrop(); + tokenAirdrop.setState(TokenAirdropStateEnum.PENDING); + tokenAirdrop.setReceiverAccountId(receiver.getId()); + tokenAirdrop.setSenderAccountId(sender.getId()); + tokenAirdrop.setTimestampRange(Range.atLeast(recordItem.getConsensusTimestamp())); + + TokenID tokenId; + if (pendingAirdropId.hasFungibleTokenType()) { + tokenId = pendingAirdropId.getFungibleTokenType(); + var amount = pendingAirdrop.getPendingAirdropValue().getAmount(); + tokenAirdrop.setAmount(amount); + } else { + tokenId = pendingAirdropId.getNonFungibleToken().getTokenID(); + var serialNumber = pendingAirdropId.getNonFungibleToken().getSerialNumber(); + tokenAirdrop.setSerialNumber(serialNumber); + } + + var tokenEntityId = EntityId.of(tokenId); + recordItem.addEntityId(tokenEntityId); + tokenAirdrop.setTokenId(tokenEntityId.getId()); + entityListener.onTokenAirdrop(tokenAirdrop); + } + } + @Override public TransactionType getType() { return TransactionType.TOKENAIRDROP; diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenCancelAirdropTransactionHandler.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenCancelAirdropTransactionHandler.java index c524081162b..cf7a368345c 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenCancelAirdropTransactionHandler.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenCancelAirdropTransactionHandler.java @@ -16,14 +16,31 @@ package com.hedera.mirror.importer.parser.record.transactionhandler; +import com.hedera.mirror.common.domain.token.TokenAirdropStateEnum; +import com.hedera.mirror.common.domain.transaction.RecordItem; import com.hedera.mirror.common.domain.transaction.TransactionType; +import com.hedera.mirror.importer.domain.EntityIdService; +import com.hedera.mirror.importer.parser.record.entity.EntityListener; +import com.hedera.mirror.importer.parser.record.entity.EntityProperties; +import com.hederahashgraph.api.proto.java.PendingAirdropId; import jakarta.inject.Named; +import java.util.List; +import java.util.function.Function; @Named -class TokenCancelAirdropTransactionHandler extends AbstractTransactionHandler { +class TokenCancelAirdropTransactionHandler extends AbstractTokenUpdateAirdropTransactionHandler { - @Override - public TransactionType getType() { - return TransactionType.TOKENCANCELAIRDROP; + private static final Function> airdropExtractor = + r -> r.getTransactionBody().getTokenCancelAirdrop().getPendingAirdropsList(); + + public TokenCancelAirdropTransactionHandler( + EntityIdService entityIdService, EntityListener entityListener, EntityProperties entityProperties) { + super( + entityIdService, + entityListener, + entityProperties, + airdropExtractor, + TokenAirdropStateEnum.CANCELLED, + TransactionType.TOKENCANCELAIRDROP); } } diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenClaimAirdropTransactionHandler.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenClaimAirdropTransactionHandler.java index 04e415fde8e..de634239847 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenClaimAirdropTransactionHandler.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenClaimAirdropTransactionHandler.java @@ -16,14 +16,31 @@ package com.hedera.mirror.importer.parser.record.transactionhandler; +import com.hedera.mirror.common.domain.token.TokenAirdropStateEnum; +import com.hedera.mirror.common.domain.transaction.RecordItem; import com.hedera.mirror.common.domain.transaction.TransactionType; +import com.hedera.mirror.importer.domain.EntityIdService; +import com.hedera.mirror.importer.parser.record.entity.EntityListener; +import com.hedera.mirror.importer.parser.record.entity.EntityProperties; +import com.hederahashgraph.api.proto.java.PendingAirdropId; import jakarta.inject.Named; +import java.util.List; +import java.util.function.Function; @Named -class TokenClaimAirdropTransactionHandler extends AbstractTransactionHandler { +class TokenClaimAirdropTransactionHandler extends AbstractTokenUpdateAirdropTransactionHandler { - @Override - public TransactionType getType() { - return TransactionType.TOKENCLAIMAIRDROP; + private static final Function> airdropExtractor = + r -> r.getTransactionBody().getTokenClaimAirdrop().getPendingAirdropsList(); + + public TokenClaimAirdropTransactionHandler( + EntityIdService entityIdService, EntityListener entityListener, EntityProperties entityProperties) { + super( + entityIdService, + entityListener, + entityProperties, + airdropExtractor, + TokenAirdropStateEnum.CLAIMED, + TransactionType.TOKENCLAIMAIRDROP); } } diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenRejectTransactionHandler.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenRejectTransactionHandler.java index 74df88d0737..5297d89bdee 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenRejectTransactionHandler.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenRejectTransactionHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019-2024 Hedera Hashgraph, LLC + * Copyright (C) 2024 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. diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/repository/TokenAirdropHistoryRepository.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/repository/TokenAirdropHistoryRepository.java new file mode 100644 index 00000000000..72adf67f9e7 --- /dev/null +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/repository/TokenAirdropHistoryRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 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.mirror.importer.repository; + +import com.hedera.mirror.common.domain.token.AbstractTokenAirdrop; +import com.hedera.mirror.common.domain.token.TokenAirdropHistory; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; + +public interface TokenAirdropHistoryRepository + extends CrudRepository, RetentionRepository { + + @Modifying + @Override + @Query(value = "delete from token_airdrop_history where timestamp_range << int8range(?1, null)", nativeQuery = true) + int prune(long consensusTimestamp); +} diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/repository/TokenAirdropRepository.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/repository/TokenAirdropRepository.java new file mode 100644 index 00000000000..363a99eb8b5 --- /dev/null +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/repository/TokenAirdropRepository.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 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.mirror.importer.repository; + +import com.hedera.mirror.common.domain.token.AbstractTokenAirdrop; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import org.springframework.data.repository.CrudRepository; + +public interface TokenAirdropRepository extends CrudRepository {} diff --git a/hedera-mirror-importer/src/main/resources/db/migration/common/R__01_temp_tables.sql b/hedera-mirror-importer/src/main/resources/db/migration/common/R__01_temp_tables.sql index 59ffc508bfa..380a84d1d38 100644 --- a/hedera-mirror-importer/src/main/resources/db/migration/common/R__01_temp_tables.sql +++ b/hedera-mirror-importer/src/main/resources/db/migration/common/R__01_temp_tables.sql @@ -30,6 +30,7 @@ call create_temp_table_safe('nft', 'token_id', 'serial_number'); call create_temp_table_safe('node', 'node_id'); call create_temp_table_safe('schedule', 'schedule_id'); call create_temp_table_safe('token_account', 'account_id', 'token_id'); +call create_temp_table_safe('token_airdrop', 'receiver_account_id', 'sender_account_id', 'serial_number', 'token_id'); call create_temp_table_safe('token_allowance', 'owner', 'spender', 'token_id'); call create_temp_table_safe('token', 'token_id'); call create_temp_table_safe('topic_message_lookup', 'topic_id', 'partition'); diff --git a/hedera-mirror-importer/src/main/resources/db/migration/v1/V1.100.0__add_token_airdrop.sql b/hedera-mirror-importer/src/main/resources/db/migration/v1/V1.100.0__add_token_airdrop.sql new file mode 100644 index 00000000000..2b0fefe0cd7 --- /dev/null +++ b/hedera-mirror-importer/src/main/resources/db/migration/v1/V1.100.0__add_token_airdrop.sql @@ -0,0 +1,23 @@ +create type airdrop_state as enum ('CANCELLED', 'CLAIMED', 'PENDING'); + +create table if not exists token_airdrop +( + amount bigint, + receiver_account_id bigint not null, + sender_account_id bigint not null, + serial_number bigint not null, + state airdrop_state not null default 'PENDING', + timestamp_range int8range not null, + token_id bigint not null +); + +create unique index if not exists token_airdrop__sender_id on token_airdrop (sender_account_id, receiver_account_id, token_id, serial_number); +create index if not exists token_airdrop__receiver_id on token_airdrop (receiver_account_id, sender_account_id, token_id, serial_number); + +create table if not exists token_airdrop_history +( + like token_airdrop including defaults +); + +create index if not exists token_airdrop_history__timestamp_range + on token_airdrop_history using gist (timestamp_range); diff --git a/hedera-mirror-importer/src/main/resources/db/migration/v2/R__02_temp_table_distribution.sql b/hedera-mirror-importer/src/main/resources/db/migration/v2/R__02_temp_table_distribution.sql index a8b76d4a538..4bbf3eb01e4 100644 --- a/hedera-mirror-importer/src/main/resources/db/migration/v2/R__02_temp_table_distribution.sql +++ b/hedera-mirror-importer/src/main/resources/db/migration/v2/R__02_temp_table_distribution.sql @@ -22,6 +22,7 @@ call create_distributed_table_safe('nft_allowance_temp', 'owner', 'nft_allowance call create_distributed_table_safe('nft_temp', 'token_id', 'nft'); call create_distributed_table_safe('schedule_temp', 'schedule_id', 'schedule'); call create_distributed_table_safe('token_account_temp', 'account_id', 'token_account'); +call create_distributed_table_safe('token_airdrop_temp', 'receiver_account_id', 'token_airdrop'); call create_distributed_table_safe('token_allowance_temp', 'owner', 'token_allowance'); call create_distributed_table_safe('dissociate_token_transfer', 'token_id', 'nft'); call create_distributed_table_safe('token_temp', 'token_id', 'token'); diff --git a/hedera-mirror-importer/src/main/resources/db/migration/v2/V2.5.2__add_token_airdrop.sql b/hedera-mirror-importer/src/main/resources/db/migration/v2/V2.5.2__add_token_airdrop.sql new file mode 100644 index 00000000000..54216bb9dd1 --- /dev/null +++ b/hedera-mirror-importer/src/main/resources/db/migration/v2/V2.5.2__add_token_airdrop.sql @@ -0,0 +1,26 @@ +create type airdrop_state as enum ('CANCELLED', 'CLAIMED', 'PENDING'); + +create table if not exists token_airdrop +( + amount bigint, + receiver_account_id bigint not null, + sender_account_id bigint not null, + serial_number bigint not null, + state airdrop_state not null default 'PENDING', + timestamp_range int8range not null, + token_id bigint not null +); +comment on table token_airdrop is 'Token airdrops'; + +create table if not exists token_airdrop_history +( + like token_airdrop including defaults +); +comment on table token_airdrop_history is 'History of token airdrops'; + +select create_distributed_table('token_airdrop', 'receiver_account_id', colocate_with => 'entity'); +select create_distributed_table('token_airdrop_history', 'receiver_account_id', colocate_with => 'token_airdrop'); + +create unique index if not exists token_airdrop__sender_id on token_airdrop (sender_account_id, receiver_account_id, token_id, serial_number); +create index if not exists token_airdrop__receiver_id on token_airdrop (receiver_account_id, sender_account_id, token_id, serial_number); +create index if not exists token_airdrop_history__timestamp_range on token_airdrop_history using gist (timestamp_range); diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/domain/RecordItemBuilder.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/domain/RecordItemBuilder.java index 703dba4e9f3..7be16c6cdc6 100644 --- a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/domain/RecordItemBuilder.java +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/domain/RecordItemBuilder.java @@ -96,6 +96,9 @@ import com.hederahashgraph.api.proto.java.NodeStake; import com.hederahashgraph.api.proto.java.NodeStakeUpdateTransactionBody; import com.hederahashgraph.api.proto.java.NodeUpdateTransactionBody; +import com.hederahashgraph.api.proto.java.PendingAirdropId; +import com.hederahashgraph.api.proto.java.PendingAirdropRecord; +import com.hederahashgraph.api.proto.java.PendingAirdropValue; import com.hederahashgraph.api.proto.java.RealmID; import com.hederahashgraph.api.proto.java.ResponseCodeEnum; import com.hederahashgraph.api.proto.java.RoyaltyFee; @@ -113,10 +116,13 @@ import com.hederahashgraph.api.proto.java.SystemUndeleteTransactionBody; import com.hederahashgraph.api.proto.java.Timestamp; import com.hederahashgraph.api.proto.java.TimestampSeconds; +import com.hederahashgraph.api.proto.java.TokenAirdropTransactionBody; import com.hederahashgraph.api.proto.java.TokenAllowance; import com.hederahashgraph.api.proto.java.TokenAssociateTransactionBody; import com.hederahashgraph.api.proto.java.TokenAssociation; import com.hederahashgraph.api.proto.java.TokenBurnTransactionBody; +import com.hederahashgraph.api.proto.java.TokenCancelAirdropTransactionBody; +import com.hederahashgraph.api.proto.java.TokenClaimAirdropTransactionBody; import com.hederahashgraph.api.proto.java.TokenCreateTransactionBody; import com.hederahashgraph.api.proto.java.TokenDeleteTransactionBody; import com.hederahashgraph.api.proto.java.TokenDissociateTransactionBody; @@ -723,6 +729,13 @@ public Builder nodeStakeUpdate() { return new Builder<>(TransactionType.NODESTAKEUPDATE, builder); } + public PendingAirdropId.Builder pendingAirdropId() { + return PendingAirdropId.newBuilder() + .setReceiverId(accountId()) + .setSenderId(accountId()) + .setFungibleTokenType(tokenId()); + } + public Builder prng() { return prng(0); } @@ -790,6 +803,66 @@ public Builder systemUndelete() { return new Builder<>(TransactionType.SYSTEMUNDELETE, builder); } + public Builder tokenAirdrop() { + var fungibleTokenId = tokenId(); + var nftTokenId = tokenId(); + var sender = accountId(); + var receiver = accountId(); + var pendingReceiver = accountId(); + + // Airdrops that transfer to the account and do not go into the pending airdrop list + var tokenTransferList = TokenTransferList.newBuilder() + .setToken(fungibleTokenId) + .addTransfers(AccountAmount.newBuilder() + .setAccountID(sender) + .setAmount(-100) + .build()) + .addTransfers(AccountAmount.newBuilder() + .setAccountID(receiver) + .setAmount(100) + .build()); + var nftTransferList = TokenTransferList.newBuilder() + .setToken(nftTokenId) + .addNftTransfers(NftTransfer.newBuilder() + .setSenderAccountID(sender) + .setSerialNumber(1L) + .setReceiverAccountID(receiver) + .build()); + + var fungiblePendingAirdropId = PendingAirdropId.newBuilder() + .setSenderId(sender) + .setReceiverId(pendingReceiver) + .setFungibleTokenType(fungibleTokenId); + var fungiblePendingAirdrop = PendingAirdropRecord.newBuilder() + .setPendingAirdropId(fungiblePendingAirdropId) + .setPendingAirdropValue( + PendingAirdropValue.newBuilder().setAmount(1000L).build()); + var nftPendingAirdropId = PendingAirdropId.newBuilder() + .setSenderId(sender) + .setReceiverId(pendingReceiver) + .setNonFungibleToken(NftID.newBuilder() + .setTokenID(nftTokenId) + .setSerialNumber(1L) + .build()); + var nftPendingAirdrop = PendingAirdropRecord.newBuilder().setPendingAirdropId(nftPendingAirdropId); + + return new Builder<>(TransactionType.TOKENAIRDROP, TokenAirdropTransactionBody.newBuilder()) + .record(r -> r.addTokenTransferLists(tokenTransferList) + .addTokenTransferLists(nftTransferList) + .addNewPendingAirdrops(fungiblePendingAirdrop) + .addNewPendingAirdrops(nftPendingAirdrop)); + } + + public Builder tokenCancelAirdrop() { + var transactionBody = TokenCancelAirdropTransactionBody.newBuilder().addPendingAirdrops(pendingAirdropId()); + return new Builder<>(TransactionType.TOKENCANCELAIRDROP, transactionBody); + } + + public Builder tokenClaimAirdrop() { + var transactionBody = TokenClaimAirdropTransactionBody.newBuilder().addPendingAirdrops(pendingAirdropId()); + return new Builder<>(TransactionType.TOKENCLAIMAIRDROP, transactionBody); + } + public Builder tokenAssociate() { var transactionBody = TokenAssociateTransactionBody.newBuilder() .setAccount(accountId()) diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/EntityRecordItemListenerTokenTest.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/EntityRecordItemListenerTokenTest.java index 5d71d6b18d6..4651322902f 100644 --- a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/EntityRecordItemListenerTokenTest.java +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/EntityRecordItemListenerTokenTest.java @@ -41,17 +41,22 @@ import com.hedera.mirror.common.domain.token.Nft; import com.hedera.mirror.common.domain.token.Token; import com.hedera.mirror.common.domain.token.TokenAccount; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.common.domain.token.TokenAirdropStateEnum; import com.hedera.mirror.common.domain.token.TokenFreezeStatusEnum; import com.hedera.mirror.common.domain.token.TokenKycStatusEnum; import com.hedera.mirror.common.domain.token.TokenPauseStatusEnum; import com.hedera.mirror.common.domain.token.TokenTransfer; +import com.hedera.mirror.common.domain.token.TokenTypeEnum; import com.hedera.mirror.common.domain.transaction.AssessedCustomFee; import com.hedera.mirror.common.domain.transaction.RecordItem; import com.hedera.mirror.common.util.DomainUtils; import com.hedera.mirror.importer.TestUtils; +import com.hedera.mirror.importer.parser.domain.RecordItemBuilder; import com.hedera.mirror.importer.repository.ContractLogRepository; import com.hedera.mirror.importer.repository.NftRepository; import com.hedera.mirror.importer.repository.TokenAccountRepository; +import com.hedera.mirror.importer.repository.TokenAirdropRepository; import com.hedera.mirror.importer.repository.TokenAllowanceRepository; import com.hedera.mirror.importer.repository.TokenHistoryRepository; import com.hedera.mirror.importer.repository.TokenRepository; @@ -69,6 +74,9 @@ import com.hederahashgraph.api.proto.java.NftAllowance; import com.hederahashgraph.api.proto.java.NftID; import com.hederahashgraph.api.proto.java.NftTransfer; +import com.hederahashgraph.api.proto.java.PendingAirdropId; +import com.hederahashgraph.api.proto.java.PendingAirdropRecord; +import com.hederahashgraph.api.proto.java.PendingAirdropValue; import com.hederahashgraph.api.proto.java.ResponseCodeEnum; import com.hederahashgraph.api.proto.java.RoyaltyFee; import com.hederahashgraph.api.proto.java.Timestamp; @@ -148,6 +156,7 @@ class EntityRecordItemListenerTokenTest extends AbstractEntityRecordItemListener private final JdbcTemplate jdbcTemplate; private final NftRepository nftRepository; private final TokenAccountRepository tokenAccountRepository; + private final TokenAirdropRepository tokenAirdropRepository; private final TokenAllowanceRepository tokenAllowanceRepository; private final TokenRepository tokenRepository; private final TokenHistoryRepository tokenHistoryRepository; @@ -3497,6 +3506,363 @@ void tokenCreateAndAssociateAndWipeInSameRecordFile() { assertTokenTransferInRepository(TOKEN_ID, PAYER2, wipeTimestamp, transferAmount); } + @ParameterizedTest(name = "{0}") + @EnumSource( + value = TokenAirdropStateEnum.class, + names = {"CANCELLED", "CLAIMED"}) + void tokenAirdrop(TokenAirdropStateEnum airdropType) { + // given + long transferAmount = 100; + long pendingAmount = 1000; + long createTimestamp = 10L; + var nftTokenId = TokenID.newBuilder().setTokenNum(1234L).build(); + + var tokenCreateRecordItem = recordItemBuilder + .tokenCreate() + .transactionBody(b -> b.setInitialSupply(INITIAL_SUPPLY) + .setTokenType(FUNGIBLE_COMMON) + .setTreasury(PAYER)) + .receipt(r -> r.setTokenID(TOKEN_ID)) + .record(r -> r.addAutomaticTokenAssociations(TokenAssociation.newBuilder() + .setAccountId(PAYER) + .setTokenId(TOKEN_ID)) + .setConsensusTimestamp(TestUtils.toTimestamp(createTimestamp))) + .build(); + parseRecordItemAndCommit(tokenCreateRecordItem); + + var tokenMintRecordItem = recordItemBuilder + .tokenMint() + .transactionBody(b -> b.setToken(nftTokenId).addMetadata(DomainUtils.fromBytes(METADATA))) + .receipt(r -> r.clearSerialNumbers().addSerialNumbers(SERIAL_NUMBER_1)) + .record(r -> r.setConsensusTimestamp(TestUtils.toTimestamp(createTimestamp + 1))) + .build(); + parseRecordItemAndCommit(tokenMintRecordItem); + + // when + long airdropTimestamp = 20L; + + // Airdrops that where directly transferred and not added to pending airdrops. + var fungibleAirdrop = TokenTransferList.newBuilder() + .setToken(TOKEN_ID) + .addTransfers(AccountAmount.newBuilder() + .setAccountID(PAYER) + .setAmount(-transferAmount) + .build()) + .addTransfers(AccountAmount.newBuilder() + .setAccountID(PAYER3) + .setAmount(transferAmount) + .build()) + .build(); + var nftAirdrop = TokenTransferList.newBuilder() + .setToken(nftTokenId) + .addNftTransfers(NftTransfer.newBuilder() + .setReceiverAccountID(PAYER3) + .setSenderAccountID(PAYER) + .setSerialNumber(SERIAL_NUMBER_1) + .build()) + .build(); + var pendingFungibleAirdrop = PendingAirdropRecord.newBuilder() + .setPendingAirdropId(PendingAirdropId.newBuilder() + .setReceiverId(RECEIVER) + .setSenderId(PAYER) + .setFungibleTokenType(TOKEN_ID)) + .setPendingAirdropValue(PendingAirdropValue.newBuilder() + .setAmount(pendingAmount) + .build()); + var protoNftId = NftID.newBuilder().setTokenID(nftTokenId).setSerialNumber(SERIAL_NUMBER_1); + var pendingNftAirdrop = PendingAirdropRecord.newBuilder() + .setPendingAirdropId(PendingAirdropId.newBuilder() + .setReceiverId(RECEIVER) + .setSenderId(PAYER) + .setNonFungibleToken(protoNftId)); + var tokenAirdrop = recordItemBuilder + .tokenAirdrop() + .record(r -> r.setConsensusTimestamp(TestUtils.toTimestamp(airdropTimestamp)) + .clearNewPendingAirdrops() + .clearTokenTransferLists() + .addNewPendingAirdrops(pendingFungibleAirdrop) + .addNewPendingAirdrops(pendingNftAirdrop) + .addTokenTransferLists(fungibleAirdrop) + .addTokenTransferLists(nftAirdrop)) + .build(); + parseRecordItemAndCommit(tokenAirdrop); + + // then + var expectedTransferFromPayer = domainBuilder + .tokenTransfer() + .customize(t -> t.amount(-transferAmount) + .id(new TokenTransfer.Id(airdropTimestamp, EntityId.of(TOKEN_ID), EntityId.of(PAYER)))) + .get(); + var expectedTransferToReceiver = domainBuilder + .tokenTransfer() + .customize(t -> t.amount(transferAmount) + .id(new TokenTransfer.Id(airdropTimestamp, EntityId.of(TOKEN_ID), EntityId.of(PAYER3)))) + .get(); + var expectedNftTransfer = domainBuilder + .nftTransfer() + .customize(t -> t.serialNumber(SERIAL_NUMBER_1) + .receiverAccountId(EntityId.of(PAYER3)) + .senderAccountId(EntityId.of(PAYER)) + .tokenId(EntityId.of(nftTokenId)) + .isApproval(false)) + .get(); + var expectedPendingFungible = domainBuilder + .tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON) + .customize(t -> t.amount(pendingAmount) + .receiverAccountId(RECEIVER.getAccountNum()) + .senderAccountId(PAYER.getAccountNum()) + .state(TokenAirdropStateEnum.PENDING) + .timestampRange(Range.atLeast(airdropTimestamp)) + .tokenId(TOKEN_ID.getTokenNum())) + .get(); + var expectedPendingNft = domainBuilder + .tokenAirdrop(TokenTypeEnum.NON_FUNGIBLE_UNIQUE) + .customize(t -> t.receiverAccountId(RECEIVER.getAccountNum()) + .senderAccountId(PAYER.getAccountNum()) + .serialNumber(SERIAL_NUMBER_1) + .state(TokenAirdropStateEnum.PENDING) + .timestampRange(Range.atLeast(airdropTimestamp)) + .tokenId(nftTokenId.getTokenNum())) + .get(); + + assertThat(tokenTransferRepository.findAll()) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("isApproval", "payerAccountId") + .containsExactlyInAnyOrderElementsOf(List.of(expectedTransferFromPayer, expectedTransferToReceiver)); + assertNftTransferInRepository(airdropTimestamp, expectedNftTransfer); + assertThat(tokenAirdropRepository.findAll()) + .containsExactlyInAnyOrderElementsOf(List.of(expectedPendingFungible, expectedPendingNft)); + assertThat(findHistory(TokenAirdrop.class)).isEmpty(); + + // when + long updateTimestamp = 30L; + var pendingFungibleAirdropId = PendingAirdropId.newBuilder() + .setReceiverId(RECEIVER) + .setSenderId(PAYER) + .setFungibleTokenType(TOKEN_ID) + .build(); + var pendingNftAirdropId = PendingAirdropId.newBuilder() + .setReceiverId(RECEIVER) + .setSenderId(PAYER) + .setNonFungibleToken(protoNftId) + .build(); + + var expectedState = TokenAirdropStateEnum.CANCELLED; + RecordItemBuilder.Builder updateAirdrop = recordItemBuilder + .tokenCancelAirdrop() + .transactionBody(b -> b.clearPendingAirdrops() + .addPendingAirdrops(pendingFungibleAirdropId) + .addPendingAirdrops(pendingNftAirdropId)) + .record(r -> r.setConsensusTimestamp(TestUtils.toTimestamp(updateTimestamp))); + if (airdropType == TokenAirdropStateEnum.CLAIMED) { + expectedState = TokenAirdropStateEnum.CLAIMED; + updateAirdrop = recordItemBuilder + .tokenClaimAirdrop() + .transactionBody(b -> b.clearPendingAirdrops() + .addPendingAirdrops(pendingFungibleAirdropId) + .addPendingAirdrops(pendingNftAirdropId)) + .record(r -> r.setConsensusTimestamp(TestUtils.toTimestamp(updateTimestamp))); + } + parseRecordItemAndCommit(updateAirdrop.build()); + + // then + expectedPendingFungible.setTimestampRange(Range.closedOpen(airdropTimestamp, updateTimestamp)); + expectedPendingNft.setTimestampRange(Range.closedOpen(airdropTimestamp, updateTimestamp)); + assertThat(findHistory(TokenAirdrop.class)) + .containsExactlyInAnyOrderElementsOf(List.of(expectedPendingFungible, expectedPendingNft)); + + expectedPendingFungible.setState(expectedState); + expectedPendingFungible.setTimestampRange(Range.atLeast(updateTimestamp)); + expectedPendingNft.setState(expectedState); + expectedPendingNft.setTimestampRange(Range.atLeast(updateTimestamp)); + assertThat(tokenAirdropRepository.findAll()) + .containsExactlyInAnyOrderElementsOf(List.of(expectedPendingFungible, expectedPendingNft)); + } + + @ParameterizedTest(name = "{0}") + @EnumSource( + value = TokenAirdropStateEnum.class, + names = {"CANCELLED", "CLAIMED"}) + void tokenAirdropUpdateState(TokenAirdropStateEnum airdropType) { + // given + long pendingAmount = 1000; + long createTimestamp = 10L; + var nftTokenId = TokenID.newBuilder().setTokenNum(1234L).build(); + + var tokenCreateRecordItem = recordItemBuilder + .tokenCreate() + .transactionBody(b -> b.setInitialSupply(INITIAL_SUPPLY) + .setTokenType(FUNGIBLE_COMMON) + .setTreasury(PAYER)) + .receipt(r -> r.setTokenID(TOKEN_ID)) + .record(r -> r.addAutomaticTokenAssociations(TokenAssociation.newBuilder() + .setAccountId(PAYER) + .setTokenId(TOKEN_ID)) + .setConsensusTimestamp(TestUtils.toTimestamp(createTimestamp))) + .build(); + parseRecordItemAndCommit(tokenCreateRecordItem); + + var tokenMintRecordItem = recordItemBuilder + .tokenMint() + .transactionBody(b -> b.setToken(nftTokenId).addMetadata(DomainUtils.fromBytes(METADATA))) + .receipt(r -> r.clearSerialNumbers().addSerialNumbers(SERIAL_NUMBER_1)) + .record(r -> r.setConsensusTimestamp(TestUtils.toTimestamp(createTimestamp + 1))) + .build(); + parseRecordItemAndCommit(tokenMintRecordItem); + + // when + long airdropTimestamp = 20L; + var pendingFungibleAirdrop = PendingAirdropRecord.newBuilder() + .setPendingAirdropId(PendingAirdropId.newBuilder() + .setReceiverId(RECEIVER) + .setSenderId(PAYER) + .setFungibleTokenType(TOKEN_ID)) + .setPendingAirdropValue(PendingAirdropValue.newBuilder() + .setAmount(pendingAmount) + .build()); + var protoNftId = NftID.newBuilder().setTokenID(nftTokenId).setSerialNumber(SERIAL_NUMBER_1); + var pendingNftAirdrop = PendingAirdropRecord.newBuilder() + .setPendingAirdropId(PendingAirdropId.newBuilder() + .setReceiverId(RECEIVER) + .setSenderId(PAYER) + .setNonFungibleToken(protoNftId)); + var tokenAirdrop = recordItemBuilder + .tokenAirdrop() + .record(r -> r.setConsensusTimestamp(TestUtils.toTimestamp(airdropTimestamp)) + .clearNewPendingAirdrops() + .addNewPendingAirdrops(pendingFungibleAirdrop) + .addNewPendingAirdrops(pendingNftAirdrop)) + .build(); + + // then + long updateTimestamp = 30L; + var expectedPendingFungible = domainBuilder + .tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON) + .customize(t -> t.amount(pendingAmount) + .receiverAccountId(RECEIVER.getAccountNum()) + .senderAccountId(PAYER.getAccountNum()) + .timestampRange(Range.atLeast(airdropTimestamp)) + .tokenId(TOKEN_ID.getTokenNum())) + .get(); + var expectedPendingNft = domainBuilder + .tokenAirdrop(TokenTypeEnum.NON_FUNGIBLE_UNIQUE) + .customize(t -> t.receiverAccountId(RECEIVER.getAccountNum()) + .senderAccountId(PAYER.getAccountNum()) + .serialNumber(SERIAL_NUMBER_1) + .timestampRange(Range.atLeast(airdropTimestamp)) + .tokenId(nftTokenId.getTokenNum())) + .get(); + + var pendingFungibleAirdropId = PendingAirdropId.newBuilder() + .setReceiverId(RECEIVER) + .setSenderId(PAYER) + .setFungibleTokenType(TOKEN_ID) + .build(); + var pendingNftAirdropId = PendingAirdropId.newBuilder() + .setReceiverId(RECEIVER) + .setSenderId(PAYER) + .setNonFungibleToken(protoNftId) + .build(); + + var expectedState = TokenAirdropStateEnum.CANCELLED; + RecordItemBuilder.Builder updateAirdrop = recordItemBuilder + .tokenCancelAirdrop() + .transactionBody(b -> b.clearPendingAirdrops() + .addPendingAirdrops(pendingFungibleAirdropId) + .addPendingAirdrops(pendingNftAirdropId)) + .record(r -> r.setConsensusTimestamp(TestUtils.toTimestamp(updateTimestamp))); + if (airdropType == TokenAirdropStateEnum.CLAIMED) { + expectedState = TokenAirdropStateEnum.CLAIMED; + updateAirdrop = recordItemBuilder + .tokenClaimAirdrop() + .transactionBody(b -> b.clearPendingAirdrops() + .addPendingAirdrops(pendingFungibleAirdropId) + .addPendingAirdrops(pendingNftAirdropId)) + .record(r -> r.setConsensusTimestamp(TestUtils.toTimestamp(updateTimestamp))); + } + + // when + parseRecordItemsAndCommit(List.of(tokenAirdrop, updateAirdrop.build())); + + // then + expectedPendingFungible.setTimestampRange(Range.closedOpen(airdropTimestamp, updateTimestamp)); + expectedPendingNft.setTimestampRange(Range.closedOpen(airdropTimestamp, updateTimestamp)); + assertThat(findHistory(TokenAirdrop.class)) + .containsExactlyInAnyOrderElementsOf(List.of(expectedPendingFungible, expectedPendingNft)); + + expectedPendingFungible.setState(expectedState); + expectedPendingFungible.setTimestampRange(Range.atLeast(updateTimestamp)); + expectedPendingNft.setState(expectedState); + expectedPendingNft.setTimestampRange(Range.atLeast(updateTimestamp)); + assertThat(tokenAirdropRepository.findAll()) + .containsExactlyInAnyOrderElementsOf(List.of(expectedPendingFungible, expectedPendingNft)); + } + + @ParameterizedTest(name = "{0}") + @EnumSource( + value = TokenAirdropStateEnum.class, + names = {"CANCELLED", "CLAIMED"}) + void tokenAirdropPartialData(TokenAirdropStateEnum airdropType) { + // given + // when a claim or cancel occurs but there is no prior pending airdrop + long updateTimestamp = 30L; + var pendingFungibleAirdropId = PendingAirdropId.newBuilder() + .setReceiverId(RECEIVER) + .setSenderId(PAYER) + .setFungibleTokenType(TOKEN_ID) + .build(); + var nftTokenId = TokenID.newBuilder().setTokenNum(1234L).build(); + var protoNftId = NftID.newBuilder().setTokenID(nftTokenId).setSerialNumber(SERIAL_NUMBER_1); + var pendingNftAirdropId = PendingAirdropId.newBuilder() + .setReceiverId(RECEIVER) + .setSenderId(PAYER) + .setNonFungibleToken(protoNftId) + .build(); + + var expectedState = TokenAirdropStateEnum.CANCELLED; + RecordItemBuilder.Builder updateAirdrop; + if (airdropType == TokenAirdropStateEnum.CANCELLED) { + updateAirdrop = recordItemBuilder + .tokenCancelAirdrop() + .transactionBody(b -> b.clearPendingAirdrops() + .addPendingAirdrops(pendingFungibleAirdropId) + .addPendingAirdrops(pendingNftAirdropId)) + .record(r -> r.setConsensusTimestamp(TestUtils.toTimestamp(updateTimestamp))); + } else { + expectedState = TokenAirdropStateEnum.CLAIMED; + updateAirdrop = recordItemBuilder + .tokenClaimAirdrop() + .transactionBody(b -> b.clearPendingAirdrops() + .addPendingAirdrops(pendingFungibleAirdropId) + .addPendingAirdrops(pendingNftAirdropId)) + .record(r -> r.setConsensusTimestamp(TestUtils.toTimestamp(updateTimestamp))); + } + parseRecordItemAndCommit(updateAirdrop.build()); + + // then + var expectedPendingFungible = domainBuilder + .tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON) + // Amount will be null when there is no pending airdrop + .customize(t -> t.amount(null) + .receiverAccountId(RECEIVER.getAccountNum()) + .senderAccountId(PAYER.getAccountNum()) + .timestampRange(Range.atLeast(updateTimestamp)) + .tokenId(TOKEN_ID.getTokenNum())) + .get(); + var expectedPendingNft = domainBuilder + .tokenAirdrop(TokenTypeEnum.NON_FUNGIBLE_UNIQUE) + .customize(t -> t.receiverAccountId(RECEIVER.getAccountNum()) + .senderAccountId(PAYER.getAccountNum()) + .serialNumber(SERIAL_NUMBER_1) + .timestampRange(Range.atLeast(updateTimestamp)) + .tokenId(nftTokenId.getTokenNum())) + .get(); + expectedPendingFungible.setState(expectedState); + expectedPendingNft.setState(expectedState); + assertThat(tokenAirdropRepository.findAll()) + .containsExactlyInAnyOrderElementsOf(List.of(expectedPendingFungible, expectedPendingNft)); + assertThat(findHistory(TokenAirdrop.class)).isEmpty(); + } + @ParameterizedTest @ValueSource(booleans = {true, false}) void tokenRejectFungible(boolean hasOwner) { diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/sql/SqlEntityListenerTest.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/sql/SqlEntityListenerTest.java index d60e1da35f7..4425f2c9d6c 100644 --- a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/sql/SqlEntityListenerTest.java +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/sql/SqlEntityListenerTest.java @@ -44,6 +44,8 @@ import com.hedera.mirror.common.domain.token.Nft; import com.hedera.mirror.common.domain.token.Token; import com.hedera.mirror.common.domain.token.TokenAccount; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.common.domain.token.TokenAirdropStateEnum; import com.hedera.mirror.common.domain.token.TokenFreezeStatusEnum; import com.hedera.mirror.common.domain.token.TokenKycStatusEnum; import com.hedera.mirror.common.domain.token.TokenPauseStatusEnum; @@ -87,6 +89,7 @@ import com.hedera.mirror.importer.repository.ScheduleRepository; import com.hedera.mirror.importer.repository.StakingRewardTransferRepository; import com.hedera.mirror.importer.repository.TokenAccountRepository; +import com.hedera.mirror.importer.repository.TokenAirdropRepository; import com.hedera.mirror.importer.repository.TokenAllowanceRepository; import com.hedera.mirror.importer.repository.TokenRepository; import com.hedera.mirror.importer.repository.TokenTransferRepository; @@ -150,6 +153,7 @@ class SqlEntityListenerTest extends ImporterIntegrationTest { private final SqlProperties sqlProperties; private final StakingRewardTransferRepository stakingRewardTransferRepository; private final TokenAccountRepository tokenAccountRepository; + private final TokenAirdropRepository tokenAirdropRepository; private final TokenAllowanceRepository tokenAllowanceRepository; private final TokenRepository tokenRepository; private final TokenTransferRepository tokenTransferRepository; @@ -2701,6 +2705,143 @@ void onTokenAccountSpanningRecordFiles() { assertThat(findHistory(TokenAccount.class)).containsExactlyInAnyOrderElementsOf(expected); } + @Test + void onTokenAirdrop() { + // given + var tokenAirdrop = + domainBuilder.tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON).get(); + var tokenAirdrop2 = + domainBuilder.tokenAirdrop(TokenTypeEnum.NON_FUNGIBLE_UNIQUE).get(); + + // when + sqlEntityListener.onTokenAirdrop(tokenAirdrop); + sqlEntityListener.onTokenAirdrop(tokenAirdrop2); + + // when + long newAmount = 50000L; + var updatedAmountAirdrop = domainBuilder + .tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON) + .customize(a -> a.amount(newAmount) + .receiverAccountId(tokenAirdrop.getReceiverAccountId()) + .senderAccountId(tokenAirdrop.getSenderAccountId()) + .tokenId(tokenAirdrop.getTokenId()) + .timestampRange(Range.atLeast(domainBuilder.timestamp()))) + .get(); + sqlEntityListener.onTokenAirdrop(updatedAmountAirdrop); + completeFileAndCommit(); + + // then + tokenAirdrop.setTimestampRange( + Range.closedOpen(tokenAirdrop.getTimestampLower(), updatedAmountAirdrop.getTimestampLower())); + assertThat(findHistory(TokenAirdrop.class)).containsExactly(tokenAirdrop); + updatedAmountAirdrop.setAmount(newAmount); + assertThat(tokenAirdropRepository.findAll()).containsExactlyInAnyOrder(updatedAmountAirdrop, tokenAirdrop2); + } + + @ParameterizedTest + @CsvSource( + textBlock = + """ + CANCELLED, 1 + CANCELLED, 2 + CLAIMED, 1 + CLAIMED, 2 + """) + void onTokenAirdropUpdate(TokenAirdropStateEnum state, int commitIndex) { + // given + var tokenAirdrop = + domainBuilder.tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON).get(); + var nftAirdrop = + domainBuilder.tokenAirdrop(TokenTypeEnum.NON_FUNGIBLE_UNIQUE).get(); + + sqlEntityListener.onTokenAirdrop(tokenAirdrop); + sqlEntityListener.onTokenAirdrop(nftAirdrop); + if (commitIndex > 1) { + completeFileAndCommit(); + assertThat(tokenAirdropRepository.findAll()).containsExactlyInAnyOrder(tokenAirdrop, nftAirdrop); + assertThat(findHistory(TokenAirdrop.class)).isEmpty(); + } + + // when + var tokenAirdropUpdateState = domainBuilder + .tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON) + .customize(a -> a.state(state) + .amount(null) // Record files that change state do not include PendingAirdropValue so remove the + // amount here + .receiverAccountId(tokenAirdrop.getReceiverAccountId()) + .senderAccountId(tokenAirdrop.getSenderAccountId()) + .tokenId(tokenAirdrop.getTokenId()) + .timestampRange(Range.atLeast(domainBuilder.timestamp()))) + .get(); + var nftAirdropUpdateState = domainBuilder + .tokenAirdrop(TokenTypeEnum.NON_FUNGIBLE_UNIQUE) + .customize(a -> a.state(state) + .receiverAccountId(nftAirdrop.getReceiverAccountId()) + .senderAccountId(nftAirdrop.getSenderAccountId()) + .serialNumber(nftAirdrop.getSerialNumber()) + .tokenId(nftAirdrop.getTokenId()) + .timestampRange(Range.atLeast(domainBuilder.timestamp()))) + .get(); + sqlEntityListener.onTokenAirdrop(tokenAirdropUpdateState); + sqlEntityListener.onTokenAirdrop(nftAirdropUpdateState); + completeFileAndCommit(); + + // then + tokenAirdrop.setTimestampRange( + Range.closedOpen(tokenAirdrop.getTimestampLower(), tokenAirdropUpdateState.getTimestampLower())); + nftAirdrop.setTimestampRange( + Range.closedOpen(nftAirdrop.getTimestampLower(), nftAirdropUpdateState.getTimestampLower())); + assertThat(findHistory(TokenAirdrop.class)).containsExactly(tokenAirdrop, nftAirdrop); + tokenAirdropUpdateState.setAmount(tokenAirdrop.getAmount()); + assertThat(tokenAirdropRepository.findAll()) + .containsExactlyInAnyOrder(tokenAirdropUpdateState, nftAirdropUpdateState); + } + + @ParameterizedTest + @EnumSource( + value = TokenAirdropStateEnum.class, + names = {"CANCELLED", "CLAIMED"}) + void onTokenAirdropPartialUpdate(TokenAirdropStateEnum state) { + // given + var tokenAirdrop = + domainBuilder.tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON).get(); + var nftAirdrop = + domainBuilder.tokenAirdrop(TokenTypeEnum.NON_FUNGIBLE_UNIQUE).get(); + + // when + var tokenAirdropUpdateState = domainBuilder + .tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON) + .customize(a -> a.state(state) + .amount(null) // Record files that change state do not include PendingAirdropValue so remove the + // amount here + .receiverAccountId(tokenAirdrop.getReceiverAccountId()) + .senderAccountId(tokenAirdrop.getSenderAccountId()) + .tokenId(tokenAirdrop.getTokenId()) + .timestampRange(Range.atLeast(domainBuilder.timestamp()))) + .get(); + var nftAirdropUpdateState = domainBuilder + .tokenAirdrop(TokenTypeEnum.NON_FUNGIBLE_UNIQUE) + .customize(a -> a.state(state) + .receiverAccountId(nftAirdrop.getReceiverAccountId()) + .senderAccountId(nftAirdrop.getSenderAccountId()) + .serialNumber(nftAirdrop.getSerialNumber()) + .tokenId(nftAirdrop.getTokenId()) + .timestampRange(Range.atLeast(domainBuilder.timestamp()))) + .get(); + sqlEntityListener.onTokenAirdrop(tokenAirdropUpdateState); + sqlEntityListener.onTokenAirdrop(nftAirdropUpdateState); + completeFileAndCommit(); + + // then + tokenAirdrop.setTimestampRange( + Range.closedOpen(tokenAirdrop.getTimestampLower(), tokenAirdropUpdateState.getTimestampLower())); + nftAirdrop.setTimestampRange( + Range.closedOpen(nftAirdrop.getTimestampLower(), nftAirdropUpdateState.getTimestampLower())); + assertThat(findHistory(TokenAirdrop.class)).isEmpty(); + assertThat(tokenAirdropRepository.findAll()) + .containsExactlyInAnyOrder(tokenAirdropUpdateState, nftAirdropUpdateState); + } + @Test void onTokenAllowance() { // given diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenAirdropTransactionHandlerTest.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenAirdropTransactionHandlerTest.java new file mode 100644 index 00000000000..cf93dcff1f3 --- /dev/null +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenAirdropTransactionHandlerTest.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2024 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.mirror.importer.parser.record.transactionhandler; + +import static com.hedera.mirror.common.domain.token.TokenAirdropStateEnum.PENDING; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import com.google.common.collect.Range; +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.common.domain.entity.EntityType; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hederahashgraph.api.proto.java.NftID; +import com.hederahashgraph.api.proto.java.PendingAirdropId; +import com.hederahashgraph.api.proto.java.PendingAirdropRecord; +import com.hederahashgraph.api.proto.java.PendingAirdropValue; +import com.hederahashgraph.api.proto.java.TokenAirdropTransactionBody; +import com.hederahashgraph.api.proto.java.TransactionBody; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class TokenAirdropTransactionHandlerTest extends AbstractTransactionHandlerTest { + @Override + protected TransactionHandler getTransactionHandler() { + return new TokenAirdropTransactionHandler(entityListener, entityProperties); + } + + @Override + protected TransactionBody.Builder getDefaultTransactionBody() { + return TransactionBody.newBuilder() + .setTokenAirdrop(TokenAirdropTransactionBody.newBuilder().build()); + } + + @Override + protected EntityType getExpectedEntityIdType() { + return null; + } + + @Test + void updateTransactionSuccessfulFungiblePendingAirdrop() { + // given + var tokenAirdrop = ArgumentCaptor.forClass(TokenAirdrop.class); + long amount = 5L; + var receiver = recordItemBuilder.accountId(); + var sender = recordItemBuilder.accountId(); + var token = recordItemBuilder.tokenId(); + var fungibleAirdrop = PendingAirdropRecord.newBuilder() + .setPendingAirdropId(PendingAirdropId.newBuilder() + .setReceiverId(receiver) + .setSenderId(sender) + .setFungibleTokenType(token)) + .setPendingAirdropValue( + PendingAirdropValue.newBuilder().setAmount(amount).build()); + var recordItem = recordItemBuilder + .tokenAirdrop() + .record(r -> r.clearNewPendingAirdrops().addNewPendingAirdrops(fungibleAirdrop)) + .build(); + long timestamp = recordItem.getConsensusTimestamp(); + var transaction = domainBuilder + .transaction() + .customize(t -> t.consensusTimestamp(timestamp)) + .get(); + + var expectedEntityTransactions = getExpectedEntityTransactions( + recordItem, transaction, EntityId.of(receiver), EntityId.of(sender), EntityId.of(token)); + + // when + transactionHandler.updateTransaction(transaction, recordItem); + + // then + assertThat(recordItem.getEntityTransactions()).containsExactlyInAnyOrderEntriesOf(expectedEntityTransactions); + + verify(entityListener).onTokenAirdrop(tokenAirdrop.capture()); + assertThat(tokenAirdrop.getValue()) + .returns(amount, TokenAirdrop::getAmount) + .returns(receiver.getAccountNum(), TokenAirdrop::getReceiverAccountId) + .returns(sender.getAccountNum(), TokenAirdrop::getSenderAccountId) + .returns(0L, TokenAirdrop::getSerialNumber) + .returns(PENDING, TokenAirdrop::getState) + .returns(Range.atLeast(timestamp), TokenAirdrop::getTimestampRange) + .returns(token.getTokenNum(), TokenAirdrop::getTokenId); + } + + @Test + void updateTransactionSuccessfulNftPendingAirdrop() { + // given + var tokenAirdrop = ArgumentCaptor.forClass(TokenAirdrop.class); + var receiver = recordItemBuilder.accountId(); + var sender = recordItemBuilder.accountId(); + var token = recordItemBuilder.tokenId(); + var nftAirdrop = PendingAirdropRecord.newBuilder() + .setPendingAirdropId(PendingAirdropId.newBuilder() + .setReceiverId(receiver) + .setSenderId(sender) + .setNonFungibleToken( + NftID.newBuilder().setTokenID(token).setSerialNumber(1L))); + var recordItem = recordItemBuilder + .tokenAirdrop() + .record(r -> r.clearNewPendingAirdrops().addNewPendingAirdrops(nftAirdrop)) + .build(); + long timestamp = recordItem.getConsensusTimestamp(); + var transaction = domainBuilder + .transaction() + .customize(t -> t.consensusTimestamp(timestamp)) + .get(); + + var expectedEntityTransactions = getExpectedEntityTransactions( + recordItem, transaction, EntityId.of(receiver), EntityId.of(sender), EntityId.of(token)); + + // when + transactionHandler.updateTransaction(transaction, recordItem); + + // then + assertThat(recordItem.getEntityTransactions()).containsExactlyInAnyOrderEntriesOf(expectedEntityTransactions); + + verify(entityListener).onTokenAirdrop(tokenAirdrop.capture()); + assertThat(tokenAirdrop.getValue()) + .returns(null, TokenAirdrop::getAmount) + .returns(receiver.getAccountNum(), TokenAirdrop::getReceiverAccountId) + .returns(sender.getAccountNum(), TokenAirdrop::getSenderAccountId) + .returns(PENDING, TokenAirdrop::getState) + .returns(Range.atLeast(timestamp), TokenAirdrop::getTimestampRange) + .returns(token.getTokenNum(), TokenAirdrop::getTokenId); + } +} diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenCancelAirdropTransactionHandlerTest.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenCancelAirdropTransactionHandlerTest.java new file mode 100644 index 00000000000..3508d7e8733 --- /dev/null +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenCancelAirdropTransactionHandlerTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2024 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.mirror.importer.parser.record.transactionhandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.Range; +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.common.domain.entity.EntityType; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.common.domain.token.TokenAirdropStateEnum; +import com.hedera.mirror.common.domain.token.TokenTypeEnum; +import com.hederahashgraph.api.proto.java.AccountID; +import com.hederahashgraph.api.proto.java.NftID; +import com.hederahashgraph.api.proto.java.PendingAirdropId; +import com.hederahashgraph.api.proto.java.TokenCancelAirdropTransactionBody; +import com.hederahashgraph.api.proto.java.TransactionBody; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; + +class TokenCancelAirdropTransactionHandlerTest extends AbstractTransactionHandlerTest { + private final EntityId receiver = domainBuilder.entityId(); + private final AccountID receiverAccountId = recordItemBuilder.accountId(); + private final EntityId sender = domainBuilder.entityId(); + private final AccountID senderAccountId = recordItemBuilder.accountId(); + + @Override + protected TransactionHandler getTransactionHandler() { + return new TokenCancelAirdropTransactionHandler(entityIdService, entityListener, entityProperties); + } + + @Override + protected TransactionBody.Builder getDefaultTransactionBody() { + return TransactionBody.newBuilder() + .setTokenCancelAirdrop( + TokenCancelAirdropTransactionBody.newBuilder().build()); + } + + @Override + protected EntityType getExpectedEntityIdType() { + return null; + } + + @BeforeEach + void beforeEach() { + when(entityIdService.lookup(receiverAccountId)).thenReturn(Optional.of(receiver)); + when(entityIdService.lookup(senderAccountId)).thenReturn(Optional.of(sender)); + } + + @ParameterizedTest + @EnumSource(TokenTypeEnum.class) + void cancelAirdrop(TokenTypeEnum tokenType) { + // given + var tokenAirdrop = ArgumentCaptor.forClass(TokenAirdrop.class); + var token = recordItemBuilder.tokenId(); + var pendingAirdropId = + PendingAirdropId.newBuilder().setReceiverId(receiverAccountId).setSenderId(senderAccountId); + if (tokenType == TokenTypeEnum.FUNGIBLE_COMMON) { + pendingAirdropId.setFungibleTokenType(token); + } else { + pendingAirdropId.setNonFungibleToken( + NftID.newBuilder().setTokenID(token).setSerialNumber(1L)); + } + var recordItem = recordItemBuilder + .tokenCancelAirdrop() + .transactionBody(b -> b.clearPendingAirdrops().addPendingAirdrops(pendingAirdropId.build())) + .build(); + long timestamp = recordItem.getConsensusTimestamp(); + var transaction = domainBuilder + .transaction() + .customize(t -> t.consensusTimestamp(timestamp)) + .get(); + + var expectedEntityTransactions = + getExpectedEntityTransactions(recordItem, transaction, receiver, sender, EntityId.of(token)); + + // when + transactionHandler.updateTransaction(transaction, recordItem); + + // then + assertThat(recordItem.getEntityTransactions()).containsExactlyInAnyOrderEntriesOf(expectedEntityTransactions); + + verify(entityListener).onTokenAirdrop(tokenAirdrop.capture()); + assertThat(tokenAirdrop.getValue()) + .returns(receiver.getNum(), TokenAirdrop::getReceiverAccountId) + .returns(sender.getNum(), TokenAirdrop::getSenderAccountId) + .returns(TokenAirdropStateEnum.CANCELLED, TokenAirdrop::getState) + .returns(Range.atLeast(timestamp), TokenAirdrop::getTimestampRange) + .returns(token.getTokenNum(), TokenAirdrop::getTokenId); + } +} diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenClaimAirdropTransactionHandlerTest.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenClaimAirdropTransactionHandlerTest.java new file mode 100644 index 00000000000..e0ada6968ee --- /dev/null +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenClaimAirdropTransactionHandlerTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2024 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.mirror.importer.parser.record.transactionhandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.Range; +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.common.domain.entity.EntityType; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.common.domain.token.TokenAirdropStateEnum; +import com.hedera.mirror.common.domain.token.TokenTypeEnum; +import com.hederahashgraph.api.proto.java.AccountID; +import com.hederahashgraph.api.proto.java.NftID; +import com.hederahashgraph.api.proto.java.PendingAirdropId; +import com.hederahashgraph.api.proto.java.TokenClaimAirdropTransactionBody; +import com.hederahashgraph.api.proto.java.TransactionBody; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; + +class TokenClaimAirdropTransactionHandlerTest extends AbstractTransactionHandlerTest { + private final EntityId receiver = domainBuilder.entityId(); + private final AccountID receiverAccountId = recordItemBuilder.accountId(); + private final EntityId sender = domainBuilder.entityId(); + private final AccountID senderAccountId = recordItemBuilder.accountId(); + + @Override + protected TransactionHandler getTransactionHandler() { + return new TokenClaimAirdropTransactionHandler(entityIdService, entityListener, entityProperties); + } + + @Override + protected TransactionBody.Builder getDefaultTransactionBody() { + return TransactionBody.newBuilder() + .setTokenClaimAirdrop( + TokenClaimAirdropTransactionBody.newBuilder().build()); + } + + @Override + protected EntityType getExpectedEntityIdType() { + return null; + } + + @BeforeEach + void beforeEach() { + when(entityIdService.lookup(receiverAccountId)).thenReturn(Optional.of(receiver)); + when(entityIdService.lookup(senderAccountId)).thenReturn(Optional.of(sender)); + } + + @ParameterizedTest + @EnumSource(TokenTypeEnum.class) + void claimAirdrop(TokenTypeEnum tokenType) { + // given + var tokenAirdrop = ArgumentCaptor.forClass(TokenAirdrop.class); + var token = recordItemBuilder.tokenId(); + var pendingAirdropId = + PendingAirdropId.newBuilder().setReceiverId(receiverAccountId).setSenderId(senderAccountId); + if (tokenType == TokenTypeEnum.FUNGIBLE_COMMON) { + pendingAirdropId.setFungibleTokenType(token); + } else { + pendingAirdropId.setNonFungibleToken( + NftID.newBuilder().setTokenID(token).setSerialNumber(1L)); + } + var recordItem = recordItemBuilder + .tokenClaimAirdrop() + .transactionBody(b -> b.clearPendingAirdrops().addPendingAirdrops(pendingAirdropId.build())) + .build(); + long timestamp = recordItem.getConsensusTimestamp(); + var transaction = domainBuilder + .transaction() + .customize(t -> t.consensusTimestamp(timestamp)) + .get(); + + var expectedEntityTransactions = + getExpectedEntityTransactions(recordItem, transaction, receiver, sender, EntityId.of(token)); + + // when + transactionHandler.updateTransaction(transaction, recordItem); + + // then + assertThat(recordItem.getEntityTransactions()).containsExactlyInAnyOrderEntriesOf(expectedEntityTransactions); + + verify(entityListener).onTokenAirdrop(tokenAirdrop.capture()); + assertThat(tokenAirdrop.getValue()) + .returns(receiver.getNum(), TokenAirdrop::getReceiverAccountId) + .returns(sender.getNum(), TokenAirdrop::getSenderAccountId) + .returns(TokenAirdropStateEnum.CLAIMED, TokenAirdrop::getState) + .returns(Range.atLeast(timestamp), TokenAirdrop::getTimestampRange) + .returns(token.getTokenNum(), TokenAirdrop::getTokenId); + } +} diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenRejectTransactionHandlerTest.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenRejectTransactionHandlerTest.java index 8e38193d25d..3433f2491b9 100644 --- a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenRejectTransactionHandlerTest.java +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/transactionhandler/TokenRejectTransactionHandlerTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019-2024 Hedera Hashgraph, LLC + * Copyright (C) 2024 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. diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/repository/TokenAirdropHistoryRepositoryTest.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/repository/TokenAirdropHistoryRepositoryTest.java new file mode 100644 index 00000000000..5d2e8a96fa2 --- /dev/null +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/repository/TokenAirdropHistoryRepositoryTest.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 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.mirror.importer.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hedera.mirror.common.domain.token.TokenTypeEnum; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; + +@RequiredArgsConstructor +class TokenAirdropHistoryRepositoryTest extends AbstractRepositoryTest { + + private final TokenAirdropHistoryRepository tokenAirdropHistoryRepository; + + @Test + void prune() { + domainBuilder.tokenAirdropHistory(TokenTypeEnum.NON_FUNGIBLE_UNIQUE).persist(); + var tokenAirdropHistory2 = + domainBuilder.tokenAirdropHistory(TokenTypeEnum.FUNGIBLE_COMMON).persist(); + var tokenAirdropHistory3 = domainBuilder + .tokenAirdropHistory(TokenTypeEnum.NON_FUNGIBLE_UNIQUE) + .persist(); + + tokenAirdropHistoryRepository.prune(tokenAirdropHistory2.getTimestampUpper()); + + assertThat(tokenAirdropHistoryRepository.findAll()).containsExactly(tokenAirdropHistory3); + } + + @Test + void save() { + var tokenAirdropHistory = domainBuilder + .tokenAirdropHistory(TokenTypeEnum.NON_FUNGIBLE_UNIQUE) + .get(); + tokenAirdropHistoryRepository.save(tokenAirdropHistory); + assertThat(tokenAirdropHistoryRepository.findAll()).containsOnly(tokenAirdropHistory); + } +} diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/repository/TokenAirdropRepositoryTest.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/repository/TokenAirdropRepositoryTest.java new file mode 100644 index 00000000000..ec947769486 --- /dev/null +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/repository/TokenAirdropRepositoryTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2024 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.mirror.importer.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.common.domain.token.TokenTypeEnum; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; + +@RequiredArgsConstructor +class TokenAirdropRepositoryTest extends AbstractRepositoryTest { + + private final TokenAirdropRepository repository; + + @Test + void saveFungible() { + var tokenAirdrop = + domainBuilder.tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON).get(); + repository.save(tokenAirdrop); + assertThat(repository.findAll()).containsOnly(tokenAirdrop); + } + + @Test + void saveNft() { + var tokenAirdrop = + domainBuilder.tokenAirdrop(TokenTypeEnum.NON_FUNGIBLE_UNIQUE).get(); + repository.save(tokenAirdrop); + assertThat(repository.findAll()).containsOnly(tokenAirdrop); + } + + /** + * This test verifies that the domain object and table definition are in sync with the history table. + */ + @Test + void history() { + var tokenAirdrop = + domainBuilder.tokenAirdrop(TokenTypeEnum.FUNGIBLE_COMMON).persist(); + + jdbcOperations.update("insert into token_airdrop_history select * from token_airdrop"); + var tokenAirdropHistory = findHistory(TokenAirdrop.class); + + assertThat(repository.findAll()).containsExactly(tokenAirdrop); + assertThat(tokenAirdropHistory).containsExactly(tokenAirdrop); + } +} diff --git a/hedera-mirror-rest/check-state-proof/build.gradle.kts b/hedera-mirror-rest/check-state-proof/build.gradle.kts index 3eea62c92f6..75096783138 100644 --- a/hedera-mirror-rest/check-state-proof/build.gradle.kts +++ b/hedera-mirror-rest/check-state-proof/build.gradle.kts @@ -18,9 +18,7 @@ description = "Hedera Mirror Node Check State Proof" plugins { id("javascript-conventions") } -node { - version = "20.15.1" -} +node { version = "20.15.1" } // This project imports code from the parent project tasks.npmInstall { dependsOn(":rest:npmInstall") } diff --git a/hedera-mirror-web3/src/test/java/com/hedera/mirror/web3/service/ContractCallServiceERCTokenModificationFunctionsTest.java b/hedera-mirror-web3/src/test/java/com/hedera/mirror/web3/service/ContractCallServiceERCTokenModificationFunctionsTest.java index 113cc2817de..66953349e1a 100644 --- a/hedera-mirror-web3/src/test/java/com/hedera/mirror/web3/service/ContractCallServiceERCTokenModificationFunctionsTest.java +++ b/hedera-mirror-web3/src/test/java/com/hedera/mirror/web3/service/ContractCallServiceERCTokenModificationFunctionsTest.java @@ -642,9 +642,10 @@ void delegateTransferDoesNotExecuteAndReturnEmpty() throws Exception { final var amount = 10L; // When contract.send_delegateTransfer( - tokenAddress.toHexString(), toAddress(recipient).toHexString(), BigInteger.valueOf(amount)).send(); + tokenAddress.toHexString(), toAddress(recipient).toHexString(), BigInteger.valueOf(amount)) + .send(); final var result = testWeb3jService.getTransactionResult(); - //Then + // Then assertThat(result).isEqualTo("0x"); }