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 8bc4583bdc7..974f5d683d8 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 @@ -103,6 +103,7 @@ import com.hedera.services.stream.proto.ContractAction.ResultDataCase; import com.hedera.services.stream.proto.ContractActionType; import com.hederahashgraph.api.proto.java.AccountID; +import com.hederahashgraph.api.proto.java.FeeExemptKeyList; import com.hederahashgraph.api.proto.java.FreezeType; import com.hederahashgraph.api.proto.java.Key; import com.hederahashgraph.api.proto.java.Key.KeyCase; @@ -571,34 +572,6 @@ public DomainWrapper fileData() { return new DomainWrapperImpl<>(builder, builder::build); } - private FallbackFee fallbackFee() { - return FallbackFee.builder() - .amount(number()) - .denominatingTokenId(entityId()) - .build(); - } - - private FixedFee fixedFee() { - return FixedFee.builder() - .allCollectorsAreExempt(true) - .amount(number()) - .collectorAccountId(entityId()) - .denominatingTokenId(entityId()) - .build(); - } - - private FractionalFee fractionalFee() { - return FractionalFee.builder() - .allCollectorsAreExempt(true) - .collectorAccountId(entityId()) - .denominator(number()) - .maximumAmount(number()) - .minimumAmount(1L) - .numerator(number()) - .netOfTransfers(true) - .build(); - } - public DomainWrapper liveHash() { var builder = LiveHash.builder().consensusTimestamp(timestamp()).livehash(bytes(64)); return new DomainWrapperImpl<>(builder, builder::build); @@ -800,16 +773,6 @@ public DomainWrapper recordFile() { return new DomainWrapperImpl<>(builder, builder::build); } - private RoyaltyFee royaltyFee() { - return RoyaltyFee.builder() - .allCollectorsAreExempt(true) - .collectorAccountId(entityId()) - .denominator(number()) - .fallbackFee(fallbackFee()) - .numerator(number()) - .build(); - } - public DomainWrapper schedule() { var builder = Schedule.builder() .consensusTimestamp(timestamp()) @@ -1025,7 +988,7 @@ public DomainWrapper tokenTra .adminKey(bytes(32)) .createdTimestamp(timestamp) .id(id()) - .feeExemptKeyList(bytes(32)) + .feeExemptKeyList(feeExemptKeyList()) .feeScheduleKey(bytes(32)) .submitKey(bytes(32)) .timestampRange(Range.atLeast(timestamp)); @@ -1155,12 +1118,25 @@ public byte[] bytes(int length) { return bytes; } + public EntityId entityId() { + return EntityId.of(0L, 0L, id()); + } + public byte[] evmAddress() { return bytes(20); } - public EntityId entityId() { - return EntityId.of(0L, 0L, id()); + public FixedFee fixedFee() { + return FixedFee.builder() + .allCollectorsAreExempt(true) + .amount(number()) + .collectorAccountId(entityId()) + .denominatingTokenId(entityId()) + .build(); + } + + public String hash(int characters) { + return RandomStringUtils.secure().next(characters, "0123456789abcdef"); } /** @@ -1173,44 +1149,12 @@ public long id() { return id.incrementAndGet() + LAST_RESERVED_ID; } - // SQL timestamp type only supports up to microsecond granularity - private Instant instant() { - return now.truncatedTo(ChronoUnit.MILLIS).plusMillis(number()); - } - public byte[] key() { return id.get() % 2 == 0 ? key(KeyCase.ECDSA_SECP256K1) : key(KeyCase.ED25519); } public byte[] key(KeyCase keyCase) { - var key = - switch (keyCase) { - case ECDSA_SECP256K1 -> Key.newBuilder().setECDSASecp256K1(generateSecp256k1Key()); - case ED25519 -> Key.newBuilder().setEd25519(ByteString.copyFrom(bytes(KEY_LENGTH_ED25519))); - default -> throw new UnsupportedOperationException("Key type not supported"); - }; - - return key.build().toByteArray(); - } - - @SneakyThrows - private ByteString generateSecp256k1Key() { - var keyPair = Keys.createEcKeyPair(); - var publicKey = keyPair.getPublicKey(); - - // Convert BigInteger public key to a full 65-byte uncompressed key - var fullPublicKey = Numeric.hexStringToByteArray(Numeric.toHexStringWithPrefixZeroPadded(publicKey, 130)); - - // Convert to compressed format (33 bytes) - var prefix = (byte) (fullPublicKey[64] % 2 == 0 ? 0x02 : 0x03); // 0x02 for even Y, 0x03 for odd Y - var compressedKey = new byte[33]; - compressedKey[0] = prefix; - System.arraycopy(fullPublicKey, 1, compressedKey, 1, 32); // Copy only X coordinate - return ByteString.copyFrom(compressedKey); - } - - public long number() { - return id.incrementAndGet(); + return protobufKey(keyCase).toByteArray(); } public byte[] nonZeroBytes(int length) { @@ -1223,12 +1167,28 @@ public byte[] nonZeroBytes(int length) { return bytes; } - public String text(int characters) { - return RandomStringUtils.secure().nextAlphanumeric(characters); + public long number() { + return id.incrementAndGet(); } - public String hash(int characters) { - return RandomStringUtils.secure().next(characters, "0123456789abcdef"); + public Key protobufKey(KeyCase keyCase) { + return switch (keyCase) { + case ECDSA_SECP256K1 -> Key.newBuilder() + .setECDSASecp256K1(generateSecp256k1Key()) + .build(); + case ED25519 -> Key.newBuilder() + .setEd25519(ByteString.copyFrom(bytes(KEY_LENGTH_ED25519))) + .build(); + default -> throw new UnsupportedOperationException("Key type not supported"); + }; + } + + public Timestamp protoTimestamp() { + long timestamp = timestamp(); + return Timestamp.newBuilder() + .setSeconds(timestamp / DomainUtils.NANOS_PER_SECOND) + .setNanos((int) (timestamp % DomainUtils.NANOS_PER_SECOND)) + .build(); } /** @@ -1240,12 +1200,8 @@ public void resetTimestamp(long value) { timestampOffset = value - timestampNoOffset(); } - public Timestamp protoTimestamp() { - long timestamp = timestamp(); - return Timestamp.newBuilder() - .setSeconds(timestamp / DomainUtils.NANOS_PER_SECOND) - .setNanos((int) (timestamp % DomainUtils.NANOS_PER_SECOND)) - .build(); + public String text(int characters) { + return RandomStringUtils.secure().nextAlphanumeric(characters); } public long timestamp() { @@ -1256,6 +1212,49 @@ private long tinybar() { return number() * TINYBARS_IN_ONE_HBAR; } + private FallbackFee fallbackFee() { + return FallbackFee.builder() + .amount(number()) + .denominatingTokenId(entityId()) + .build(); + } + + private byte[] feeExemptKeyList() { + return FeeExemptKeyList.newBuilder() + .addKeys(protobufKey(KeyCase.ECDSA_SECP256K1)) + .addKeys(protobufKey(KeyCase.ED25519)) + .build() + .toByteArray(); + } + + private FractionalFee fractionalFee() { + return FractionalFee.builder() + .allCollectorsAreExempt(true) + .collectorAccountId(entityId()) + .denominator(number()) + .maximumAmount(number()) + .minimumAmount(1L) + .numerator(number()) + .netOfTransfers(true) + .build(); + } + + @SneakyThrows + private ByteString generateSecp256k1Key() { + var keyPair = Keys.createEcKeyPair(); + var publicKey = keyPair.getPublicKey(); + + // Convert BigInteger public key to a full 65-byte uncompressed key + var fullPublicKey = Numeric.hexStringToByteArray(Numeric.toHexStringWithPrefixZeroPadded(publicKey, 130)); + + // Convert to compressed format (33 bytes) + var prefix = (byte) (fullPublicKey[64] % 2 == 0 ? 0x02 : 0x03); // 0x02 for even Y, 0x03 for odd Y + var compressedKey = new byte[33]; + compressedKey[0] = prefix; + System.arraycopy(fullPublicKey, 1, compressedKey, 1, 32); // Copy only X coordinate + return ByteString.copyFrom(compressedKey); + } + private long getEpochDay(long timestamp) { return LocalDate.ofInstant(Instant.ofEpochSecond(0, timestamp), ZoneId.of("UTC")) .atStartOfDay() @@ -1263,6 +1262,21 @@ private long getEpochDay(long timestamp) { .toEpochDay(); } + // SQL timestamp type only supports up to microsecond granularity + private Instant instant() { + return now.truncatedTo(ChronoUnit.MILLIS).plusMillis(number()); + } + + private RoyaltyFee royaltyFee() { + return RoyaltyFee.builder() + .allCollectorsAreExempt(true) + .collectorAccountId(entityId()) + .denominator(number()) + .fallbackFee(fallbackFee()) + .numerator(number()) + .build(); + } + private long timestampNoOffset() { return DomainUtils.convertToNanosMax(now.getEpochSecond(), now.getNano()) + number(); } diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/TopicController.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/TopicController.java index f7ce0a3a638..149f878c728 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/TopicController.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/TopicController.java @@ -19,6 +19,7 @@ import com.hedera.mirror.rest.model.Topic; import com.hedera.mirror.restjava.common.EntityIdNumParameter; import com.hedera.mirror.restjava.mapper.TopicMapper; +import com.hedera.mirror.restjava.service.CustomFeeService; import com.hedera.mirror.restjava.service.EntityService; import com.hedera.mirror.restjava.service.TopicService; import lombok.CustomLog; @@ -34,6 +35,7 @@ @RestController public class TopicController { + private final CustomFeeService customFeeService; private final EntityService entityService; private final TopicMapper topicMapper; private final TopicService topicService; @@ -42,6 +44,7 @@ public class TopicController { Topic getTopic(@PathVariable EntityIdNumParameter id) { var topic = topicService.findById(id.id()); var entity = entityService.findById(id.id()); - return topicMapper.map(entity, topic); + var customFee = customFeeService.findById(id.id()); + return topicMapper.map(customFee, entity, topic); } } diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/exception/InvalidFilterException.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/exception/InvalidMappingException.java similarity index 71% rename from hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/exception/InvalidFilterException.java rename to hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/exception/InvalidMappingException.java index 6e17144c566..21026662fe9 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/exception/InvalidFilterException.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/exception/InvalidMappingException.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024-2025 Hedera Hashgraph, LLC + * Copyright (C) 2025 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. @@ -19,12 +19,12 @@ import java.io.Serial; @SuppressWarnings("java:S110") -public class InvalidFilterException extends RestJavaException { +public class InvalidMappingException extends RestJavaException { @Serial - private static final long serialVersionUID = 1518569037954950068L; + private static final long serialVersionUID = -857679581991526245L; - public InvalidFilterException(String message) { - super(message); + public InvalidMappingException(String message, Throwable cause) { + super(message, cause); } } diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CollectionMapper.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CollectionMapper.java index 284212b7dd0..b898afb6068 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CollectionMapper.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CollectionMapper.java @@ -20,13 +20,14 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import org.springframework.util.CollectionUtils; public interface CollectionMapper { T map(S source); default List map(Collection sources) { - if (sources == null) { + if (CollectionUtils.isEmpty(sources)) { return Collections.emptyList(); } diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CommonMapper.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CommonMapper.java index e67d3bbd62f..30f302f7235 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CommonMapper.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CommonMapper.java @@ -17,12 +17,18 @@ package com.hedera.mirror.restjava.mapper; import com.google.common.collect.Range; +import com.google.protobuf.InvalidProtocolBufferException; import com.hedera.mirror.common.domain.entity.EntityId; import com.hedera.mirror.rest.model.Key; import com.hedera.mirror.rest.model.Key.TypeEnum; import com.hedera.mirror.rest.model.TimestampRange; +import com.hedera.mirror.restjava.exception.InvalidMappingException; +import com.hederahashgraph.api.proto.java.KeyList; +import java.util.Collections; +import java.util.List; import java.util.regex.Pattern; import org.apache.commons.codec.binary.Hex; +import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import org.mapstruct.Mapper; import org.mapstruct.MappingInheritanceStrategy; @@ -70,6 +76,29 @@ default Key mapKey(byte[] source) { return new Key().key(hex).type(TypeEnum.PROTOBUF_ENCODED); } + default List mapKeyList(byte[] source) { + if (ArrayUtils.isEmpty(source)) { + return Collections.emptyList(); + } + + try { + var keyList = KeyList.parseFrom(source); + return keyList.getKeysList().stream() + .map(key -> mapKey(key.toByteArray())) + .toList(); + } catch (InvalidProtocolBufferException e) { + throw new InvalidMappingException("Error parsing protobuf message", e); + } + } + + default String mapLowerRange(Range source) { + if (source == null || !source.hasLowerBound()) { + return null; + } + + return mapTimestamp(source.lowerEndpoint()); + } + default TimestampRange mapRange(Range source) { if (source == null) { return null; diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CustomFeeMapper.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CustomFeeMapper.java new file mode 100644 index 00000000000..b08d1224499 --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CustomFeeMapper.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2025 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.restjava.mapper; + +import com.hedera.mirror.common.domain.token.CustomFee; +import com.hedera.mirror.rest.model.ConsensusCustomFees; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(config = MapperConfiguration.class, uses = FixedCustomFeeMapper.class) +public interface CustomFeeMapper { + + @Mapping(source = "timestampRange", target = "createdTimestamp") + ConsensusCustomFees map(CustomFee customFee); +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/FixedCustomFeeMapper.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/FixedCustomFeeMapper.java new file mode 100644 index 00000000000..ee9990e3a6a --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/FixedCustomFeeMapper.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2025 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.restjava.mapper; + +import com.hedera.mirror.common.domain.token.FixedFee; +import com.hedera.mirror.rest.model.FixedCustomFee; +import org.mapstruct.Mapper; + +@Mapper(config = MapperConfiguration.class) +public interface FixedCustomFeeMapper extends CollectionMapper {} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/TopicMapper.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/TopicMapper.java index 5af49d60fb2..564b3229144 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/TopicMapper.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/TopicMapper.java @@ -19,16 +19,18 @@ import static com.hedera.mirror.restjava.mapper.CommonMapper.QUALIFIER_TIMESTAMP; import com.hedera.mirror.common.domain.entity.Entity; +import com.hedera.mirror.common.domain.token.CustomFee; import com.hedera.mirror.rest.model.Topic; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -@Mapper(config = MapperConfiguration.class) +@Mapper(config = MapperConfiguration.class, uses = CustomFeeMapper.class) public interface TopicMapper { + @Mapping(source = "customFee", target = "customFees") @Mapping(source = "entity.autoRenewAccountId", target = "autoRenewAccount") @Mapping(source = "entity.createdTimestamp", target = "createdTimestamp", qualifiedByName = QUALIFIER_TIMESTAMP) @Mapping(source = "entity.id", target = "topicId") @Mapping(source = "entity.timestampRange", target = "timestamp") - Topic map(Entity entity, com.hedera.mirror.common.domain.topic.Topic topic); + Topic map(CustomFee customFee, Entity entity, com.hedera.mirror.common.domain.topic.Topic topic); } diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/CustomFeeRepository.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/CustomFeeRepository.java new file mode 100644 index 00000000000..d7f846e71fc --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/CustomFeeRepository.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2025 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.restjava.repository; + +import com.hedera.mirror.common.domain.token.CustomFee; +import org.springframework.data.repository.CrudRepository; + +public interface CustomFeeRepository extends CrudRepository {} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/CustomFeeService.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/CustomFeeService.java new file mode 100644 index 00000000000..7bd72fb01cc --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/CustomFeeService.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2025 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.restjava.service; + +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.common.domain.token.CustomFee; +import jakarta.annotation.Nonnull; + +public interface CustomFeeService { + + CustomFee findById(@Nonnull EntityId id); +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/CustomFeeServiceImpl.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/CustomFeeServiceImpl.java new file mode 100644 index 00000000000..f9ba6f9447b --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/CustomFeeServiceImpl.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 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.restjava.service; + +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.common.domain.token.CustomFee; +import com.hedera.mirror.restjava.repository.CustomFeeRepository; +import jakarta.annotation.Nonnull; +import jakarta.inject.Named; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; + +@Named +@RequiredArgsConstructor +public class CustomFeeServiceImpl implements CustomFeeService { + + private final CustomFeeRepository customFeeRepository; + private final Validator validator; + + @Override + public CustomFee findById(@Nonnull EntityId id) { + validator.validateShard(id, id.getShard()); + + return customFeeRepository + .findById(id.getId()) + .orElseThrow(() -> new EntityNotFoundException("Custom fee for entity id %s not found".formatted(id))); + } +} diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/controller/TopicControllerTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/controller/TopicControllerTest.java index 118fbbe9b8a..d50297454e5 100644 --- a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/controller/TopicControllerTest.java +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/controller/TopicControllerTest.java @@ -50,6 +50,13 @@ protected String getUrl() { @Override protected RequestHeadersSpec defaultRequest(RequestHeadersUriSpec uriSpec) { var entity = domainBuilder.topicEntity().persist(); + domainBuilder + .customFee() + .customize(c -> c.entityId(entity.getId()) + .fractionalFees(null) + .royaltyFees(null) + .timestampRange(entity.getTimestampRange())) + .persist(); domainBuilder .topic() .customize(t -> t.createdTimestamp(entity.getCreatedTimestamp()) @@ -64,6 +71,13 @@ protected RequestHeadersSpec defaultRequest(RequestHeadersUriSpec uriSpec) void success(String id) { // Given var entity = domainBuilder.topicEntity().customize(e -> e.id(1000L)).persist(); + var customFee = domainBuilder + .customFee() + .customize(c -> c.entityId(1000L) + .fractionalFees(null) + .royaltyFees(null) + .timestampRange(entity.getTimestampRange())) + .persist(); var topic = domainBuilder .topic() .customize(t -> t.createdTimestamp(entity.getCreatedTimestamp()) @@ -75,7 +89,7 @@ void success(String id) { var response = restClient.get().uri("", id).retrieve().toEntity(Topic.class); // Then - assertThat(response.getBody()).isNotNull().isEqualTo(topicMapper.map(entity, topic)); + assertThat(response.getBody()).isNotNull().isEqualTo(topicMapper.map(customFee, entity, topic)); // Based on application.yml response headers configuration assertThat(response.getHeaders().getAccessControlAllowOrigin()).isEqualTo("*"); assertThat(response.getHeaders().getCacheControl()).isEqualTo("public, max-age=5"); diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/CommonMapperTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/CommonMapperTest.java index a510ddb2c3a..03f1dbe933d 100644 --- a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/CommonMapperTest.java +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/CommonMapperTest.java @@ -18,6 +18,7 @@ import static com.hedera.mirror.restjava.mapper.CommonMapper.NANO_DIGITS; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.google.common.collect.Range; import com.hedera.mirror.common.domain.DomainBuilder; @@ -25,8 +26,12 @@ import com.hedera.mirror.common.util.DomainUtils; import com.hedera.mirror.rest.model.Key.TypeEnum; import com.hedera.mirror.rest.model.TimestampRange; +import com.hedera.mirror.restjava.exception.InvalidMappingException; +import com.hederahashgraph.api.proto.java.FeeExemptKeyList; import com.hederahashgraph.api.proto.java.Key; import com.hederahashgraph.api.proto.java.KeyList; +import java.util.List; +import lombok.SneakyThrows; import org.apache.commons.codec.binary.Hex; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; @@ -52,6 +57,47 @@ void mapEntityIdLong() { assertThat(commonMapper.mapEntityId(0L)).isNull(); } + @Test + void mapKeyList() { + // Given + var bytesEcdsa = domainBuilder.bytes(DomainBuilder.KEY_LENGTH_ECDSA); + var ecdsa = Key.newBuilder() + .setECDSASecp256K1(DomainUtils.fromBytes(bytesEcdsa)) + .build(); + var bytesEd25519 = domainBuilder.bytes(DomainBuilder.KEY_LENGTH_ED25519); + var ed25519 = + Key.newBuilder().setEd25519(DomainUtils.fromBytes(bytesEd25519)).build(); + var innerKeyList = KeyList.newBuilder().addKeys(ecdsa).addKeys(ed25519).build(); + var innerKeyListKey = Key.newBuilder().setKeyList(innerKeyList).build(); + var bytesInnerKeyListKey = innerKeyListKey.toByteArray(); + var keyList = innerKeyList.toBuilder().addKeys(innerKeyListKey).build(); + var feeExemptKeyList = + FeeExemptKeyList.newBuilder().addAllKeys(keyList.getKeysList()).build(); + var expectedKeyList = List.of( + new com.hedera.mirror.rest.model.Key() + .key(Hex.encodeHexString(bytesEcdsa)) + .type(TypeEnum.ECDSA_SECP256_K1), + new com.hedera.mirror.rest.model.Key() + .key(Hex.encodeHexString(bytesEd25519)) + .type(TypeEnum.ED25519), + new com.hedera.mirror.rest.model.Key() + .key(Hex.encodeHexString(bytesInnerKeyListKey)) + .type(TypeEnum.PROTOBUF_ENCODED)); + + // Then + assertThat(commonMapper.mapKeyList(null)).isEmpty(); + assertThat(commonMapper.mapKeyList(new byte[0])).isEmpty(); + assertThat(commonMapper.mapKeyList(keyList.toByteArray())).isEqualTo(expectedKeyList); + assertThat(commonMapper.mapKeyList(feeExemptKeyList.toByteArray())).isEqualTo(expectedKeyList); + } + + @SneakyThrows + @Test + void mapKeyListThrow() { + byte[] data = Hex.decodeHex("deadbeef"); + assertThatThrownBy(() -> commonMapper.mapKeyList(data)).isInstanceOf(InvalidMappingException.class); + } + @Test void mapKey() { // Given @@ -83,6 +129,15 @@ void mapKey() { assertThat(commonMapper.mapKey(protobufEncoded)).isEqualTo(toKey(protobufEncoded, TypeEnum.PROTOBUF_ENCODED)); } + @Test + void mapLowerRange() { + assertThat(commonMapper.mapLowerRange(null)).isNull(); + assertThat(commonMapper.mapLowerRange(Range.atMost(200L))).isNull(); + assertThat(commonMapper.mapLowerRange(Range.atLeast(0L))).isEqualTo("0.0"); + assertThat(commonMapper.mapLowerRange(Range.atLeast(1500123456789L))).isEqualTo("1500.123456789"); + assertThat(commonMapper.mapLowerRange(Range.atLeast(1500123456000L))).isEqualTo("1500.123456000"); + } + @Test void mapRange() { var range = new TimestampRange(); diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/CustomFeeMapperTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/CustomFeeMapperTest.java new file mode 100644 index 00000000000..c1a0eff0d3a --- /dev/null +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/CustomFeeMapperTest.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2025 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.restjava.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hedera.mirror.common.domain.DomainBuilder; +import com.hedera.mirror.rest.model.ConsensusCustomFees; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class CustomFeeMapperTest { + + private CommonMapper commonMapper; + private DomainBuilder domainBuilder; + private FixedCustomFeeMapper fixedCustomFeeMapper; + private CustomFeeMapper mapper; + + @BeforeEach + void setup() { + domainBuilder = new DomainBuilder(); + commonMapper = new CommonMapperImpl(); + fixedCustomFeeMapper = new FixedCustomFeeMapperImpl(commonMapper); + mapper = new CustomFeeMapperImpl(fixedCustomFeeMapper, commonMapper); + } + + @Test + void map() { + var customFee = domainBuilder.customFee().get(); + var expected = new ConsensusCustomFees() + .createdTimestamp(commonMapper.mapLowerRange(customFee.getTimestampRange())) + .fixedFees(fixedCustomFeeMapper.map(customFee.getFixedFees())); + assertThat(mapper.map(customFee)).isEqualTo(expected); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void mapEmptyOrNullFixedFees(boolean nullFixedFees) { + var customFee = domainBuilder + .customFee() + .customize(c -> c.fixedFees(nullFixedFees ? null : Collections.emptyList())) + .get(); + var expected = new ConsensusCustomFees() + .createdTimestamp(commonMapper.mapLowerRange(customFee.getTimestampRange())) + .fixedFees(Collections.emptyList()); + assertThat(mapper.map(customFee)).isEqualTo(expected); + } +} diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/FixedCustomFeeMapperTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/FixedCustomFeeMapperTest.java new file mode 100644 index 00000000000..3a764e00d4f --- /dev/null +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/FixedCustomFeeMapperTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2025 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.restjava.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hedera.mirror.common.domain.DomainBuilder; +import com.hedera.mirror.common.domain.token.FixedFee; +import com.hedera.mirror.rest.model.FixedCustomFee; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FixedCustomFeeMapperTest { + + private CommonMapper commonMapper; + private DomainBuilder domainBuilder; + private FixedCustomFeeMapper mapper; + + @BeforeEach + void setup() { + commonMapper = new CommonMapperImpl(); + domainBuilder = new DomainBuilder(); + mapper = new FixedCustomFeeMapperImpl(commonMapper); + } + + @Test + void map() { + var fixedFees = List.of(domainBuilder.fixedFee(), domainBuilder.fixedFee()); + var expected = fixedFees.stream() + .map(fixedFee -> new FixedCustomFee() + .amount(fixedFee.getAmount()) + .collectorAccountId(commonMapper.mapEntityId(fixedFee.getCollectorAccountId())) + .denominatingTokenId(commonMapper.mapEntityId(fixedFee.getDenominatingTokenId()))) + .toList(); + assertThat(mapper.map(fixedFees)).containsExactlyElementsOf(expected); + } + + @Test + void mapEmptyOrNull() { + assertThat(mapper.map(List.of())).isEmpty(); + assertThat(mapper.map((Collection) null)).isEmpty(); + } +} diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/TopicMapperTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/TopicMapperTest.java index cc746514b19..1ad59615271 100644 --- a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/TopicMapperTest.java +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/TopicMapperTest.java @@ -21,15 +21,20 @@ import com.hedera.mirror.common.domain.DomainBuilder; import com.hedera.mirror.common.domain.entity.Entity; import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.rest.model.ConsensusCustomFees; import com.hedera.mirror.rest.model.Key.TypeEnum; import com.hedera.mirror.rest.model.Topic; import com.hederahashgraph.api.proto.java.Key.KeyCase; +import java.util.Collections; import org.apache.commons.codec.binary.Hex; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class TopicMapperTest { + private CustomFeeMapper customFeeMapper; private CommonMapper commonMapper; private DomainBuilder domainBuilder; private TopicMapper mapper; @@ -37,7 +42,9 @@ class TopicMapperTest { @BeforeEach void setup() { commonMapper = new CommonMapperImpl(); - mapper = new TopicMapperImpl(commonMapper); + var fixedCustomFeeMapper = new FixedCustomFeeMapperImpl(commonMapper); + customFeeMapper = new CustomFeeMapperImpl(fixedCustomFeeMapper, commonMapper); + mapper = new TopicMapperImpl(customFeeMapper, commonMapper); domainBuilder = new DomainBuilder(); } @@ -45,6 +52,10 @@ void setup() { void map() { var key = domainBuilder.key(KeyCase.ED25519); var entity = domainBuilder.topicEntity().get(); + var customFee = domainBuilder + .customFee() + .customize(c -> c.entityId(entity.getId())) + .get(); var topic = domainBuilder .topic() .customize(t -> t.adminKey(key) @@ -54,7 +65,7 @@ void map() { .timestampRange(entity.getTimestampRange())) .get(); - assertThat(mapper.map(entity, topic)) + assertThat(mapper.map(customFee, entity, topic)) .returns(TypeEnum.ED25519, t -> t.getAdminKey().getType()) .returns( Hex.encodeHexString(topic.getAdminKey()), @@ -62,6 +73,7 @@ void map() { .returns(EntityId.of(entity.getAutoRenewAccountId()).toString(), Topic::getAutoRenewAccount) .returns(entity.getAutoRenewPeriod(), Topic::getAutoRenewPeriod) .returns(commonMapper.mapTimestamp(topic.getCreatedTimestamp()), Topic::getCreatedTimestamp) + .returns(customFeeMapper.map(customFee), Topic::getCustomFees) .returns(entity.getDeleted(), Topic::getDeleted) .returns(entity.getMemo(), Topic::getMemo) .returns(TypeEnum.ED25519, t -> t.getSubmitKey().getType()) @@ -74,14 +86,24 @@ void map() { .returns(entity.toEntityId().toString(), Topic::getTopicId); } - @Test - void mapNulls() { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void mapEmptyAndNulls(boolean nullFixedFees) { var topicEntity = new Entity(); + var customFee = domainBuilder + .customFee() + .customize( + c -> c.entityId(topicEntity.getId()).fixedFees(nullFixedFees ? null : Collections.emptyList())) + .get(); var topic = new com.hedera.mirror.common.domain.topic.Topic(); - assertThat(mapper.map(topicEntity, topic)) + var expectedCustomFees = new ConsensusCustomFees() + .createdTimestamp(commonMapper.mapLowerRange(customFee.getTimestampRange())) + .fixedFees(Collections.emptyList()); + assertThat(mapper.map(customFee, topicEntity, topic)) .returns(null, Topic::getAdminKey) .returns(null, Topic::getAutoRenewAccount) .returns(null, Topic::getAutoRenewPeriod) + .returns(expectedCustomFees, Topic::getCustomFees) .returns(null, Topic::getCreatedTimestamp) .returns(null, Topic::getDeleted) .returns(null, Topic::getMemo) diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/CustomFeeRepositoryTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/CustomFeeRepositoryTest.java new file mode 100644 index 00000000000..d7334650be8 --- /dev/null +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/CustomFeeRepositoryTest.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2025 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.restjava.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hedera.mirror.restjava.RestJavaIntegrationTest; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; + +@RequiredArgsConstructor +class CustomFeeRepositoryTest extends RestJavaIntegrationTest { + + private final CustomFeeRepository customFeeRepository; + + @Test + void findById() { + var expected = domainBuilder.customFee().persist(); + assertThat(customFeeRepository.findById(expected.getEntityId())).contains(expected); + } +} diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/CustomFeeServiceTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/CustomFeeServiceTest.java new file mode 100644 index 00000000000..5a55023ab5c --- /dev/null +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/CustomFeeServiceTest.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2025 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.restjava.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.restjava.RestJavaIntegrationTest; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; + +@RequiredArgsConstructor +class CustomFeeServiceTest extends RestJavaIntegrationTest { + + private final CustomFeeService service; + + @Test + void findById() { + var customFee = domainBuilder.customFee().persist(); + assertThat(service.findById(EntityId.of(customFee.getEntityId()))).isEqualTo(customFee); + } + + @Test + void findByInvalidShard() { + var entityId = EntityId.of(1, 0, 200); + assertThatThrownBy(() -> service.findById(entityId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ID " + entityId + " has an invalid shard. Shard must be 0"); + } + + @Test + void findByIdNotFound() { + var entityId = EntityId.of(10L); + assertThatThrownBy(() -> service.findById(entityId)).isInstanceOf(EntityNotFoundException.class); + } +} diff --git a/hedera-mirror-rest/api/v1/openapi.yml b/hedera-mirror-rest/api/v1/openapi.yml index b6c3074339b..aeb63c23ee5 100644 --- a/hedera-mirror-rest/api/v1/openapi.yml +++ b/hedera-mirror-rest/api/v1/openapi.yml @@ -3172,6 +3172,27 @@ components: type: array items: $ref: "#/components/schemas/Block" + ConsensusCustomFees: + description: Custom fees assessed for each message submitted to the topic + type: object + properties: + created_timestamp: + $ref: "#/components/schemas/Timestamp" + fixed_fees: + type: array + items: + $ref: "#/components/schemas/FixedCustomFee" + FixedCustomFee: + type: object + properties: + amount: + example: 100 + format: int64 + type: integer + collector_account_id: + $ref: "#/components/schemas/EntityId" + denominating_token_id: + $ref: "#/components/schemas/EntityId" NftTransactionHistory: type: object properties: @@ -3673,11 +3694,20 @@ components: type: integer created_timestamp: $ref: "#/components/schemas/TimestampNullable" + custom_fees: + $ref: "#/components/schemas/ConsensusCustomFees" deleted: description: Whether the topic is deleted or not. example: false nullable: true type: boolean + fee_exempt_key_list: + description: Keys permitted to submit messages to the topic without paying custom fees + type: array + items: + $ref: "#/components/schemas/Key" + fee_schedule_key: + $ref: "#/components/schemas/Key" memo: description: The memo associated with the topic. example: topic memo @@ -3693,7 +3723,10 @@ components: - auto_renew_account - auto_renew_period - created_timestamp + - custom_fees - deleted + - fee_exempt_key_list + - fee_schedule_key - memo - submit_key - timestamp