diff --git a/docs/design/custom-fees.md b/docs/design/custom-fees.md index 9bc7b54a5be..745be848416 100644 --- a/docs/design/custom-fees.md +++ b/docs/design/custom-fees.md @@ -42,6 +42,9 @@ create table if not exists custom_fee denominating_token_id bigint, maximum_amount bigint, minimum_amount bigint not null default 0, + net_of_transfers boolean, + royalty_denominator bigint, + royalty_numerator bigint, token_id bigint not null ); create index if not exists @@ -52,10 +55,11 @@ create index if not exists ```sql create table if not exists assessed_custom_fee ( - amount bigint not null, - collector_account_id bigint not null, - consensus_timestamp bigint not null, - token_id bigint + amount bigint not null, + collector_account_id bigint not null, + consensus_timestamp bigint not null, + effective_payer_account_ids bigint[] not null, + token_id bigint ); create index if not exists assessed_custom_fee__consensus_timestamp on assessed_custom_fee (consensus_timestamp); @@ -122,8 +126,10 @@ Both new domain objects are insert-only. ### Transactions Endpoint -- Update `/api/v1/transactions/{id}` response to add assessed custom fees. Note if an assessed custom fee have a `null` - `token_id`, it's charged in HBAR; otherwise it's charged in the `token_id` +- Update `/api/v1/transactions/{id}` response to add assessed custom fees. Note: + - if an assessed custom fee have a `null` `token_id`, it's charged in HBAR; otherwise it's charged in the `token_id` + - prior to Hedera Service 0.17.1, there is no `effective_payer_account_id`, so the `effective_payer_account_ids` will + be an empty array for those transactions with assessed custom fees ```json { @@ -184,11 +190,17 @@ Both new domain objects are insert-only. { "amount": 150, "collector_account_id": "0.0.87501", + "effective_payer_account_ids": [ + "0.0.87501" + ], "token_id": null }, { "amount": 10, "collector_account_id": "0.0.87502", + "effective_payer_account_ids": [ + "0.0.10" + ], "token_id": "0.0.90000" } ] @@ -201,6 +213,8 @@ Both new domain objects are insert-only. Add `fee_schedule_key` and `custom_fees` to the response json object of `/api/v1/tokens/:id` +For fungible tokens, the `custom_fees` object includes `fixed_fees` and `fractional_fees`. + ```json { "token_id": "0.0.1135", @@ -251,7 +265,8 @@ Add `fee_schedule_key` and `custom_fees` to the response json object of `/api/v1 "collector_account_id": "0.0.99820", "denominating_token_id": "0.0.1135", "maximum": 200, - "minimum": 0 + "minimum": 0, + "net_of_transfers": false }, { "amount": { @@ -260,7 +275,86 @@ Add `fee_schedule_key` and `custom_fees` to the response json object of `/api/v1 }, "collector_account_id": "0.0.99821", "denominating_token_id": "0.0.1135", - "minimum": 10 + "minimum": 10, + "net_of_transfers": true + } + ] + } +} +``` + +For non-fungible tokens, the `custom_fees` object includes `fixed_fees` and `royalty_fees`. + +```json + { + "token_id": "0.0.1135", + "symbol": "ORIGINALRDKSE", + "admin_key": null, + "auto_renew_account": null, + "auto_renew_period": null, + "created_timestamp": "1234567890.000000002", + "decimals": "0", + "expiry_timestamp": null, + "freeze_default": false, + "freeze_key": null, + "initial_supply": "0", + "kyc_key": null, + "max_supply": "9223372036854775807", + "modified_timestamp": "1234567899.000000002", + "name": "Token name", + "supply_key": null, + "supply_type": "FINITE", + "total_supply": "1000000", + "treasury_account_id": "0.0.98", + "type": "NON_FUNGIBLE_UNIQUE", + "wipe_key": null, + "fee_schedule_key": { + "_type": "ProtobufEncoded", + "key": "7b2231222c2231222c2231227d" + }, + "custom_fees": { + "created_timestamp": "1234567896.000000001", + "fixed_fees": [ + { + "amount": 10, + "collector_account_id": "0.0.99812", + "denominating_token_id": null + }, + { + "amount": 10, + "collector_account_id": "0.0.99813", + "denominating_token_id": "0.0.10020" + } + ], + "royalty_fees": [ + { + "amount": { + "numerator": 1, + "denominator": 10 + }, + "collector_account_id": "0.0.99820" + }, + { + "amount": { + "numerator": 3, + "denominator": 20 + }, + "collector_account_id": "0.0.99821", + "fallback_fee": { + "amount": 10, + "denominating_token_id": "0.0.10020" + }, + }, + { + "amount": { + "numerator": 1, + "denominator": 20 + }, + "collector_account_id": "0.0.99821", + "fallback_fee": { + "amount": 9000, + "denominating_token_id": null + } } ] } diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/converter/AccountIdConverter.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/converter/AccountIdConverter.java index 92b3cac5708..5bf6466ee2b 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/converter/AccountIdConverter.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/converter/AccountIdConverter.java @@ -21,15 +21,18 @@ */ import javax.inject.Named; +import javax.persistence.Converter; import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; import com.hedera.mirror.importer.domain.EntityTypeEnum; @Named -@javax.persistence.Converter +@Converter @ConfigurationPropertiesBinding public class AccountIdConverter extends AbstractEntityIdConverter { + public static final AccountIdConverter INSTANCE = new AccountIdConverter(); + public AccountIdConverter() { super(EntityTypeEnum.ACCOUNT); } diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/converter/LongListToStringSerializer.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/converter/LongListToStringSerializer.java new file mode 100644 index 00000000000..0f8f1a38cbd --- /dev/null +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/converter/LongListToStringSerializer.java @@ -0,0 +1,38 @@ +package com.hedera.mirror.importer.converter; + +/*- + * ‌ + * Hedera Mirror Node + * ​ + * Copyright (C) 2019 - 2021 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. + * ‍ + */ + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import java.util.List; +import org.apache.commons.lang3.StringUtils; + +public class LongListToStringSerializer extends JsonSerializer> { + + @Override + public void serialize(List longs, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (longs != null) { + gen.writeString("{" + StringUtils.join(longs, ",") + "}"); + } + } +} diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/domain/AssessedCustomFee.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/domain/AssessedCustomFee.java index 22234b6853e..b958de812d8 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/domain/AssessedCustomFee.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/domain/AssessedCustomFee.java @@ -22,7 +22,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; import javax.persistence.Convert; import javax.persistence.Embeddable; import javax.persistence.EmbeddedId; @@ -30,9 +34,11 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; import org.springframework.data.domain.Persistable; import com.hedera.mirror.importer.converter.AccountIdConverter; +import com.hedera.mirror.importer.converter.LongListToStringSerializer; import com.hedera.mirror.importer.converter.TokenIdConverter; @Data @@ -46,6 +52,10 @@ public class AssessedCustomFee implements Persistable { private long amount; + @Type(type = "com.vladmihalcea.hibernate.type.array.ListArrayType") + @JsonSerialize(using = LongListToStringSerializer.class) + private List effectivePayerAccountIds = Collections.emptyList(); + @Convert(converter = TokenIdConverter.class) private EntityId tokenId; @@ -68,4 +78,10 @@ public static class Id implements Serializable { private long consensusTimestamp; } + + public void setEffectivePayerEntityIds(List effectivePayerEntityIds) { + effectivePayerAccountIds = effectivePayerEntityIds.stream() + .map(AccountIdConverter.INSTANCE::convertToDatabaseColumn) + .collect(Collectors.toList()); + } } diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/domain/CustomFee.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/domain/CustomFee.java index b103b958870..6a15461b58e 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/domain/CustomFee.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/domain/CustomFee.java @@ -58,6 +58,12 @@ public class CustomFee implements Persistable { private long minimumAmount; + private Boolean netOfTransfers; + + private Long royaltyDenominator; + + private Long royaltyNumerator; + @JsonIgnore @Override public boolean isNew() { diff --git a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityRecordItemListener.java b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityRecordItemListener.java index 1d95a2915cf..b14e2a86ba7 100644 --- a/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityRecordItemListener.java +++ b/hedera-mirror-importer/src/main/java/com/hedera/mirror/importer/parser/record/entity/EntityRecordItemListener.java @@ -32,6 +32,7 @@ import com.hederahashgraph.api.proto.java.FixedFee; import com.hederahashgraph.api.proto.java.FractionalFee; import com.hederahashgraph.api.proto.java.ResponseCodeEnum; +import com.hederahashgraph.api.proto.java.RoyaltyFee; import com.hederahashgraph.api.proto.java.ScheduleCreateTransactionBody; import com.hederahashgraph.api.proto.java.SignaturePair; import com.hederahashgraph.api.proto.java.TokenAssociateTransactionBody; @@ -54,6 +55,7 @@ import java.util.List; import java.util.Set; import java.util.function.Predicate; +import java.util.stream.Collectors; import javax.inject.Named; import lombok.extern.log4j.Log4j2; @@ -857,19 +859,23 @@ private void insertAssessedCustomFees(RecordItem recordItem) { long consensusTimestamp = recordItem.getConsensusTimestamp(); for (var protoAssessedCustomFee : recordItem.getRecord().getAssessedCustomFeesList()) { EntityId collectorAccountId = EntityId.of(protoAssessedCustomFee.getFeeCollectorAccountId()); - EntityId tokenId = EntityId.of(protoAssessedCustomFee.getTokenId()); - + // the effective payers must also appear in the *transfer lists of this transaction and the + // corresponding EntityIds should have been added to EntityListener, so skip it here. + List payerEntityIds = protoAssessedCustomFee.getEffectivePayerAccountIdList().stream() + .map(EntityId::of) + .collect(Collectors.toList()); AssessedCustomFee assessedCustomFee = new AssessedCustomFee(); assessedCustomFee.setAmount(protoAssessedCustomFee.getAmount()); + assessedCustomFee.setEffectivePayerEntityIds(payerEntityIds); assessedCustomFee.setId(new AssessedCustomFee.Id(collectorAccountId, consensusTimestamp)); - assessedCustomFee.setTokenId(tokenId); + assessedCustomFee.setTokenId(EntityId.of(protoAssessedCustomFee.getTokenId())); entityListener.onAssessedCustomFee(assessedCustomFee); } } } private Set insertCustomFees(List customFeeList, - long consensusTimestamp, boolean isTokenCreate, EntityId tokenId) { + long consensusTimestamp, boolean isTokenCreate, EntityId tokenId) { Set autoAssociatedAccounts = new HashSet<>(); CustomFee.Id id = new CustomFee.Id(consensusTimestamp, tokenId); @@ -880,23 +886,30 @@ private Set insertCustomFees(List insertCustomFees(List ids = List.of(1001L, 1002L); + + // when + assessedCustomFee.setEffectivePayerEntityIds(ids.stream() + .map(id -> EntityIdEndec.decode(id, EntityTypeEnum.ACCOUNT)) + .collect(Collectors.toList())); + + // then + assertThat(assessedCustomFee.getEffectivePayerAccountIds()).containsExactlyInAnyOrderElementsOf(ids); + } +} diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/domain/AssessedCustomFeeWrapper.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/domain/AssessedCustomFeeWrapper.java index 48419e7522e..596aa499233 100644 --- a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/domain/AssessedCustomFeeWrapper.java +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/domain/AssessedCustomFeeWrapper.java @@ -20,7 +20,10 @@ * ‍ */ +import java.sql.SQLException; +import java.util.Arrays; import lombok.Data; +import org.postgresql.jdbc.PgArray; import org.springframework.jdbc.core.DataClassRowMapper; import org.springframework.jdbc.core.RowMapper; @@ -36,12 +39,18 @@ public class AssessedCustomFeeWrapper { private final AssessedCustomFee assessedCustomFee; - public AssessedCustomFeeWrapper(long amount, long collectorAccountId, Long tokenId, long consensusTimestamp) { + public AssessedCustomFeeWrapper(long amount, long collectorAccountId, PgArray effectivePayerAccountIds, + Long tokenId, long consensusTimestamp) throws SQLException { assessedCustomFee = new AssessedCustomFee(); assessedCustomFee.setAmount(amount); assessedCustomFee.setId(new AssessedCustomFee.Id( EntityIdEndec.decode(collectorAccountId, EntityTypeEnum.ACCOUNT), consensusTimestamp)); + if (effectivePayerAccountIds != null) { + Long[] payers = (Long[]) effectivePayerAccountIds.getArray(); + assessedCustomFee.setEffectivePayerAccountIds(Arrays.asList(payers)); + } + if (tokenId != null) { assessedCustomFee.setTokenId(EntityIdEndec.decode(tokenId, EntityTypeEnum.TOKEN)); } diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/domain/CustomFeeWrapper.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/domain/CustomFeeWrapper.java index 5b9ea686378..19529cb5145 100644 --- a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/domain/CustomFeeWrapper.java +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/domain/CustomFeeWrapper.java @@ -35,7 +35,8 @@ public class CustomFeeWrapper { private final CustomFee customFee; public CustomFeeWrapper(Long amount, Long amountDenominator, Long collectorAccountId, long createdTimestamp, - Long denominatingTokenId, Long maximumAmount, long minimumAmount, long tokenId) { + Long denominatingTokenId, Long maximumAmount, long minimumAmount, + Boolean netOfTransfers, Long royaltyDenominator, Long royaltyNumerator, long tokenId) { customFee = new CustomFee(); customFee.setAmount(amount); customFee.setAmountDenominator(amountDenominator); @@ -50,6 +51,9 @@ public CustomFeeWrapper(Long amount, Long amountDenominator, Long collectorAccou customFee.setMaximumAmount(maximumAmount); customFee.setMinimumAmount(minimumAmount); + customFee.setNetOfTransfers(netOfTransfers); + customFee.setRoyaltyDenominator(royaltyDenominator); + customFee.setRoyaltyNumerator(royaltyNumerator); customFee.setId(new CustomFee.Id(createdTimestamp, EntityIdEndec.decode(tokenId, EntityTypeEnum.TOKEN))); } } 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 42477386163..24b5e339c2a 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 @@ -35,6 +35,7 @@ import com.hederahashgraph.api.proto.java.Key; import com.hederahashgraph.api.proto.java.NftTransfer; import com.hederahashgraph.api.proto.java.ResponseCodeEnum; +import com.hederahashgraph.api.proto.java.RoyaltyFee; import com.hederahashgraph.api.proto.java.Timestamp; import com.hederahashgraph.api.proto.java.TokenID; import com.hederahashgraph.api.proto.java.TokenSupplyType; @@ -45,6 +46,7 @@ import com.hederahashgraph.api.proto.java.TransactionReceipt; import com.hederahashgraph.api.proto.java.TransactionRecord; import com.vladmihalcea.hibernate.type.util.StringUtils; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -58,6 +60,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cache.CacheManager; @@ -98,6 +101,8 @@ class EntityRecordItemListenerTokenTest extends AbstractEntityRecordItemListener private static final EntityId FEE_COLLECTOR_ACCOUNT_ID_2 = EntityIdEndec.decode(1200, EntityTypeEnum.ACCOUNT); private static final EntityId FEE_COLLECTOR_ACCOUNT_ID_3 = EntityIdEndec.decode(1201, EntityTypeEnum.ACCOUNT); private static final EntityId FEE_DOMAIN_TOKEN_ID = EntityIdEndec.decode(9800, EntityTypeEnum.TOKEN); + private static final EntityId FEE_PAYER_1 = EntityIdEndec.decode(1500, EntityTypeEnum.ACCOUNT); + private static final EntityId FEE_PAYER_2 = EntityIdEndec.decode(1501, EntityTypeEnum.ACCOUNT); private static final long INITIAL_SUPPLY = 1_000_000L; private static final String METADATA = "METADATA"; private static final long SERIAL_NUMBER_1 = 1L; @@ -141,7 +146,7 @@ void before() { } @ParameterizedTest(name = "{0}") - @MethodSource("provideTokenCreateArguments") + @MethodSource("provideTokenCreateFtArguments") void tokenCreate(String name, List customFees, boolean freezeDefault, boolean freezeKey, boolean kycKey, List expectedTokenAccounts) { // given @@ -155,11 +160,10 @@ void tokenCreate(String name, List customFees, boolean freezeDefault, kycKey, customFees); // then - assertEquals(expectedEntityCount, entityRepository.count()); // Node, payer, token and autorenew + assertEquals(expectedEntityCount, entityRepository.count()); assertEntity(expected); // verify token - assertTokenInRepository(TOKEN_ID, true, CREATE_TIMESTAMP, CREATE_TIMESTAMP, SYMBOL, INITIAL_SUPPLY); assertTokenAccountsInRepository(expectedTokenAccounts); assertTokenTransferInRepository(TOKEN_ID, PAYER, CREATE_TIMESTAMP, INITIAL_SUPPLY); @@ -178,18 +182,29 @@ void tokenCreateWithoutPersistence() { assertCustomFeesInDb(Lists.emptyList()); } - @Test - void tokenCreateWithNfts() { - createTokenEntity(TOKEN_ID, TokenType.NON_FUNGIBLE_UNIQUE, SYMBOL, CREATE_TIMESTAMP, false, false); - + @ParameterizedTest(name = "{0}") + @MethodSource("provideTokenCreateNftArguments") + void tokenCreateWithNfts(String name, List customFees, boolean freezeDefault, boolean freezeKey, + boolean kycKey, List expectedTokenAccounts) { + // given Entity expected = createEntity(DOMAIN_TOKEN_ID, TOKEN_REF_KEY, EntityId.of(PAYER), AUTO_RENEW_PERIOD, false, EXPIRY_NS, TOKEN_CREATE_MEMO, null, CREATE_TIMESTAMP, CREATE_TIMESTAMP); - assertEquals(4, entityRepository.count()); // Node, payer, token and autorenew + // node, token, autorenew, and the number of accounts associated with the token (including the treasury) + long expectedEntityCount = 3 + expectedTokenAccounts.size(); + + // when + createTokenEntity(TOKEN_ID, TokenType.NON_FUNGIBLE_UNIQUE, SYMBOL, CREATE_TIMESTAMP, freezeDefault, freezeKey, + kycKey, customFees); + + // then + assertEquals(expectedEntityCount, entityRepository.count()); assertEntity(expected); // verify token assertTokenInRepository(TOKEN_ID, true, CREATE_TIMESTAMP, CREATE_TIMESTAMP, SYMBOL, 0); - assertCustomFeesInDb(deletedDbCustomFees(CREATE_TIMESTAMP, DOMAIN_TOKEN_ID)); + assertTokenAccountsInRepository(expectedTokenAccounts); + assertCustomFeesInDb(customFees); + assertThat(tokenTransferRepository.count()).isZero(); } @Test @@ -244,25 +259,27 @@ void tokenDelete() { assertTokenInRepository(TOKEN_ID, true, CREATE_TIMESTAMP, CREATE_TIMESTAMP, SYMBOL, INITIAL_SUPPLY); } - @Test - void tokenFeeScheduleUpdate() { + @ParameterizedTest(name = "{0}") + @EnumSource(value = TokenType.class, names = {"FUNGIBLE_COMMON", "NON_FUNGIBLE_UNIQUE"}) + void tokenFeeScheduleUpdate(TokenType tokenType) { // given // create the token entity with empty custom fees - createTokenEntity(TOKEN_ID, TokenType.FUNGIBLE_COMMON, SYMBOL, CREATE_TIMESTAMP, false, false); + createTokenEntity(TOKEN_ID, tokenType, SYMBOL, CREATE_TIMESTAMP, false, false); // update fee schedule long updateTimestamp = CREATE_TIMESTAMP + 10L; Entity expectedEntity = createEntity(DOMAIN_TOKEN_ID, TOKEN_REF_KEY, EntityId.of(PAYER), AUTO_RENEW_PERIOD, false, EXPIRY_NS, TOKEN_CREATE_MEMO, null, CREATE_TIMESTAMP, CREATE_TIMESTAMP); - List newCustomFees = nonEmptyCustomFees(updateTimestamp, DOMAIN_TOKEN_ID); + List newCustomFees = nonEmptyCustomFees(updateTimestamp, DOMAIN_TOKEN_ID, tokenType); List expectedCustomFees = Lists.newArrayList(deletedDbCustomFees(CREATE_TIMESTAMP, DOMAIN_TOKEN_ID)); expectedCustomFees.addAll(newCustomFees); + long expectedSupply = tokenType == TokenType.FUNGIBLE_COMMON ? INITIAL_SUPPLY : 0; // when updateTokenFeeSchedule(TOKEN_ID, updateTimestamp, newCustomFees); // then assertEntity(expectedEntity); - assertTokenInRepository(TOKEN_ID, true, CREATE_TIMESTAMP, CREATE_TIMESTAMP, SYMBOL, INITIAL_SUPPLY); + assertTokenInRepository(TOKEN_ID, true, CREATE_TIMESTAMP, CREATE_TIMESTAMP, SYMBOL, expectedSupply); assertCustomFeesInDb(expectedCustomFees); } @@ -425,11 +442,11 @@ void tokenBurnNft() { .size(), SERIAL_NUMBER_LIST, mintTransfer); long burnTimestamp = 15L; - TokenTransferList burnTranfer = nftTransfer(TOKEN_ID, DEFAULT_ACCOUNT_ID, PAYER, Arrays + TokenTransferList burnTransfer = nftTransfer(TOKEN_ID, DEFAULT_ACCOUNT_ID, PAYER, Arrays .asList(SERIAL_NUMBER_1)); Transaction burnTransaction = tokenSupplyTransaction(TOKEN_ID, TokenType.NON_FUNGIBLE_UNIQUE, false, 0, Arrays .asList(SERIAL_NUMBER_1)); - insertAndParseTransaction(burnTransaction, burnTimestamp, 0, burnTranfer); + insertAndParseTransaction(burnTransaction, burnTimestamp, 0, burnTransfer); // Verify assertThat(nftTransferRepository.count()).isEqualTo(3L); @@ -535,7 +552,8 @@ void tokenMintNftsMissingToken() { @ParameterizedTest(name = "{0}") @MethodSource("provideAssessedCustomFees") - void tokenTransfer(String name, List assessedCustomFees) { + void tokenTransfer(String name, List assessedCustomFees, + List protoAssessedCustomFees) { createAndAssociateToken(TOKEN_ID, TokenType.FUNGIBLE_COMMON, SYMBOL, CREATE_TIMESTAMP, ASSOCIATE_TIMESTAMP, PAYER2, false, false, INITIAL_SUPPLY); TokenID tokenID2 = TokenID.newBuilder().setTokenNum(7).build(); @@ -558,8 +576,8 @@ void tokenTransfer(String name, List assessedCustomFees) { .addTransfers(AccountAmount.newBuilder().setAccountID(accountId).setAmount(-333).build()) .build(); - insertAndParseTransactionWithCustomFees(transaction, TRANSFER_TIMESTAMP, INITIAL_SUPPLY, assessedCustomFees, - transferList1, transferList2); + insertAndParseTransactionWithCustomFees(transaction, TRANSFER_TIMESTAMP, INITIAL_SUPPLY, + protoAssessedCustomFees, transferList1, transferList2); assertTokenTransferInRepository(TOKEN_ID, PAYER, TRANSFER_TIMESTAMP, -1000); assertTokenTransferInRepository(TOKEN_ID, accountId, TRANSFER_TIMESTAMP, 1000); @@ -777,12 +795,12 @@ void tokenCreateAndAssociateAndWipeInSameRecordFile() { } private void insertAndParseTransaction(Transaction transaction, long timestamp, long newTotalSupply, - List assessedCustomFees, List serialNumbers, - TokenTransferList... tokenTransferLists) { + List protoAssessedCustomFees, + List serialNumbers, TokenTransferList... tokenTransferLists) { TransactionBody transactionBody = getTransactionBody(transaction); var transactionRecord = createTransactionRecord(timestamp, TOKEN_ID.getTokenNum(), - transactionBody, ResponseCodeEnum.SUCCESS, newTotalSupply, assessedCustomFees, serialNumbers, + transactionBody, ResponseCodeEnum.SUCCESS, newTotalSupply, protoAssessedCustomFees, serialNumbers, Arrays.asList(tokenTransferLists)); parseRecordItemAndCommit(new RecordItem(transaction, transactionRecord)); @@ -805,9 +823,9 @@ private void insertAndParseTransaction(Transaction transaction, long timestamp, } private void insertAndParseTransactionWithCustomFees(Transaction transaction, long timestamp, long newTotalSupply, - List assessedCustomFees, + List protoAssessedCustomFees, TokenTransferList... tokenTransferLists) { - insertAndParseTransaction(transaction, timestamp, newTotalSupply, assessedCustomFees, Lists.emptyList(), + insertAndParseTransaction(transaction, timestamp, newTotalSupply, protoAssessedCustomFees, Lists.emptyList(), tokenTransferLists); } @@ -971,7 +989,7 @@ private TransactionRecord createTransactionRecord(long consensusTimestamp, long TransactionBody transactionBody, ResponseCodeEnum responseCode, long newTotalSupply, - List assessedCustomFees, + List protoAssessedCustomFees, List serialNumbers, List tokenTransferLists) { var receipt = TransactionReceipt.newBuilder() @@ -987,7 +1005,7 @@ private TransactionRecord createTransactionRecord(long consensusTimestamp, long .setReceipt(receipt) .setConsensusTimestamp(TestUtils.toTimestamp(consensusTimestamp)) .addAllTokenTransferLists(tokenTransferLists) - .addAllAssessedCustomFees(convertAssessedCustomFees(assessedCustomFees)); + .addAllAssessedCustomFees(protoAssessedCustomFees); }, transactionBody, responseCode.getNumber()); } @@ -1205,46 +1223,90 @@ private static List deletedDbCustomFees(long consensusTimestamp, Enti return List.of(customFee); } - private static List nonEmptyCustomFees(long consensusTimestamp, EntityId tokenId) { + private static List nonEmptyCustomFees(long consensusTimestamp, EntityId tokenId, TokenType tokenType) { + List customFees = new ArrayList<>(); CustomFee.Id id = new CustomFee.Id(consensusTimestamp, tokenId); + EntityId treasury = EntityId.of(PAYER); CustomFee fixedFee1 = new CustomFee(); fixedFee1.setAmount(11L); fixedFee1.setCollectorAccountId(FEE_COLLECTOR_ACCOUNT_ID_1); fixedFee1.setId(id); + customFees.add(fixedFee1); CustomFee fixedFee2 = new CustomFee(); fixedFee2.setAmount(12L); fixedFee2.setCollectorAccountId(FEE_COLLECTOR_ACCOUNT_ID_2); fixedFee2.setDenominatingTokenId(FEE_DOMAIN_TOKEN_ID); fixedFee2.setId(id); + customFees.add(fixedFee2); CustomFee fixedFee3 = new CustomFee(); fixedFee3.setAmount(13L); fixedFee3.setCollectorAccountId(FEE_COLLECTOR_ACCOUNT_ID_2); fixedFee3.setDenominatingTokenId(tokenId); fixedFee3.setId(id); + customFees.add(fixedFee3); + + if (tokenType == TokenType.FUNGIBLE_COMMON) { + // fractional fees only apply for fungible tokens + CustomFee fractionalFee1 = new CustomFee(); + fractionalFee1.setAmount(14L); + fractionalFee1.setAmountDenominator(31L); + fractionalFee1.setCollectorAccountId(FEE_COLLECTOR_ACCOUNT_ID_3); + fractionalFee1.setMaximumAmount(100L); + fractionalFee1.setNetOfTransfers(true); + fractionalFee1.setId(id); + customFees.add(fractionalFee1); + + CustomFee fractionalFee2 = new CustomFee(); + fractionalFee2.setAmount(15L); + fractionalFee2.setAmountDenominator(32L); + fractionalFee2.setCollectorAccountId(treasury); + fractionalFee2.setMaximumAmount(110L); + fractionalFee2.setNetOfTransfers(false); + fractionalFee2.setId(id); + customFees.add(fractionalFee2); + } else { + // royalty fees only apply for non-fungible tokens + CustomFee royaltyFee1 = new CustomFee(); + royaltyFee1.setRoyaltyNumerator(14L); + royaltyFee1.setRoyaltyDenominator(31L); + royaltyFee1.setCollectorAccountId(FEE_COLLECTOR_ACCOUNT_ID_3);; + royaltyFee1.setId(id); + customFees.add(royaltyFee1); + + // with fallback fee + CustomFee royaltyFee2 = new CustomFee(); + royaltyFee2.setRoyaltyNumerator(15L); + royaltyFee2.setRoyaltyDenominator(32L); + royaltyFee2.setCollectorAccountId(treasury); + // fallback fee in form of fixed fee + royaltyFee2.setAmount(103L); + royaltyFee2.setDenominatingTokenId(FEE_DOMAIN_TOKEN_ID); + royaltyFee2.setId(id); + customFees.add(royaltyFee2); + } - CustomFee fractionalFee1 = new CustomFee(); - fractionalFee1.setAmount(14L); - fractionalFee1.setAmountDenominator(31L); - fractionalFee1.setCollectorAccountId(FEE_COLLECTOR_ACCOUNT_ID_3); - fractionalFee1.setMaximumAmount(100L); - fractionalFee1.setId(id); + return customFees; + } - CustomFee fractionalFee2 = new CustomFee(); - fractionalFee2.setAmount(15L); - fractionalFee2.setAmountDenominator(32L); - fractionalFee2.setCollectorAccountId(EntityId.of(PAYER)); // the treasury - fractionalFee2.setMaximumAmount(110L); - fractionalFee2.setId(id); + private static Stream provideTokenCreateFtArguments() { + return provideTokenCreateArguments(TokenType.FUNGIBLE_COMMON); + } - return List.of(fixedFee1, fixedFee2, fixedFee3, fractionalFee1, fractionalFee2); + private static Stream provideTokenCreateNftArguments() { + return provideTokenCreateArguments(TokenType.NON_FUNGIBLE_UNIQUE); } - private static Stream provideTokenCreateArguments() { - List nonEmptyCustomFees = nonEmptyCustomFees(CREATE_TIMESTAMP, DOMAIN_TOKEN_ID); + private static Stream provideTokenCreateArguments(TokenType tokenType) { + List nonEmptyCustomFees = nonEmptyCustomFees(CREATE_TIMESTAMP, DOMAIN_TOKEN_ID, tokenType); EntityId treasury = EntityId.of(PAYER); + // fractional fees only apply for FT, thus FEE_COLLECTOR_ACCOUNT_ID_3 (collector of a fractional fee for FT, and + // a royalty fee in case of NFT) will be auto enabled only for FT custom fees + List autoEnabledAccounts = tokenType == TokenType.FUNGIBLE_COMMON ? + List.of(treasury, FEE_COLLECTOR_ACCOUNT_ID_2, FEE_COLLECTOR_ACCOUNT_ID_3) : + List.of(treasury, FEE_COLLECTOR_ACCOUNT_ID_2); return Stream.of( TokenCreateArguments.builder() @@ -1256,20 +1318,20 @@ private static Stream provideTokenCreateArguments() { .build() .toArguments(), TokenCreateArguments.builder() - .accounts(List.of(treasury, FEE_COLLECTOR_ACCOUNT_ID_2, FEE_COLLECTOR_ACCOUNT_ID_3)) + .accounts(autoEnabledAccounts) .createdTimestamp(CREATE_TIMESTAMP) .customFees(nonEmptyCustomFees) - .customFeesDescription("empty custom fees") + .customFeesDescription("non-empty custom fees") .freezeKey(true) .freezeStatus(TokenFreezeStatusEnum.UNFROZEN) .tokenId(DOMAIN_TOKEN_ID) .build() .toArguments(), TokenCreateArguments.builder() - .accounts(List.of(treasury, FEE_COLLECTOR_ACCOUNT_ID_2, FEE_COLLECTOR_ACCOUNT_ID_3)) + .accounts(autoEnabledAccounts) .createdTimestamp(CREATE_TIMESTAMP) .customFees(nonEmptyCustomFees) - .customFeesDescription("empty custom fees") + .customFeesDescription("non-empty custom fees") .freezeDefault(true) .freezeKey(true) .freezeStatus(TokenFreezeStatusEnum.UNFROZEN) @@ -1277,17 +1339,17 @@ private static Stream provideTokenCreateArguments() { .build() .toArguments(), TokenCreateArguments.builder() - .accounts(List.of(treasury, FEE_COLLECTOR_ACCOUNT_ID_2, FEE_COLLECTOR_ACCOUNT_ID_3)) + .accounts(autoEnabledAccounts) .createdTimestamp(CREATE_TIMESTAMP) .customFees(nonEmptyCustomFees) - .customFeesDescription("empty custom fees") + .customFeesDescription("non-empty custom fees") .kycKey(true) .kycStatus(TokenKycStatusEnum.GRANTED) .tokenId(DOMAIN_TOKEN_ID) .build() .toArguments(), TokenCreateArguments.builder() - .accounts(List.of(treasury, FEE_COLLECTOR_ACCOUNT_ID_2, FEE_COLLECTOR_ACCOUNT_ID_3)) + .accounts(autoEnabledAccounts) .createdTimestamp(CREATE_TIMESTAMP) .customFees(nonEmptyCustomFees) .customFeesDescription("non-empty custom fees") @@ -1298,85 +1360,132 @@ private static Stream provideTokenCreateArguments() { } private static Stream provideAssessedCustomFees() { + // without effective payer account ids, this is prior to services 0.17.1 // paid in HBAR AssessedCustomFee assessedCustomFee1 = new AssessedCustomFee(); assessedCustomFee1.setAmount(12505L); + assessedCustomFee1.setEffectivePayerAccountIds(Collections.emptyList()); assessedCustomFee1.setId(new AssessedCustomFee.Id(FEE_COLLECTOR_ACCOUNT_ID_1, TRANSFER_TIMESTAMP)); // paid in FEE_DOMAIN_TOKEN_ID AssessedCustomFee assessedCustomFee2 = new AssessedCustomFee(); assessedCustomFee2.setAmount(8750L); + assessedCustomFee2.setEffectivePayerAccountIds(Collections.emptyList()); assessedCustomFee2.setId(new AssessedCustomFee.Id(FEE_COLLECTOR_ACCOUNT_ID_2, TRANSFER_TIMESTAMP)); assessedCustomFee2.setTokenId(FEE_DOMAIN_TOKEN_ID); - List assessedCustomFees = List.of(assessedCustomFee1, assessedCustomFee2); + // build the corresponding protobuf assessed custom fee list + var protoAssessedCustomFee1 = com.hederahashgraph.api.proto.java.AssessedCustomFee.newBuilder() + .setAmount(12505L) + .setFeeCollectorAccountId(convertAccountId(FEE_COLLECTOR_ACCOUNT_ID_1)) + .build(); + var protoAssessedCustomFee2 = com.hederahashgraph.api.proto.java.AssessedCustomFee.newBuilder() + .setAmount(8750L) + .setFeeCollectorAccountId(convertAccountId(FEE_COLLECTOR_ACCOUNT_ID_2)) + .setTokenId(convertTokenId(FEE_DOMAIN_TOKEN_ID)) + .build(); + var protoAssessedCustomFees = List.of(protoAssessedCustomFee1, protoAssessedCustomFee2); + + // with effective payer account ids + // paid in HBAR, one effective payer + AssessedCustomFee assessedCustomFee3 = new AssessedCustomFee(); + assessedCustomFee3.setAmount(12300L); + assessedCustomFee3.setEffectivePayerEntityIds(List.of(FEE_PAYER_1)); + assessedCustomFee3.setId(new AssessedCustomFee.Id(FEE_COLLECTOR_ACCOUNT_ID_1, TRANSFER_TIMESTAMP)); + + // paid in FEE_DOMAIN_TOKEN_ID, two effective payers + AssessedCustomFee assessedCustomFee4 = new AssessedCustomFee(); + assessedCustomFee4.setAmount(8790L); + assessedCustomFee4.setId(new AssessedCustomFee.Id(FEE_COLLECTOR_ACCOUNT_ID_2, TRANSFER_TIMESTAMP)); + assessedCustomFee4.setEffectivePayerEntityIds(List.of(FEE_PAYER_1, FEE_PAYER_2)); + assessedCustomFee4.setTokenId(FEE_DOMAIN_TOKEN_ID); + List assessedCustomFeesWithPayers = List.of(assessedCustomFee3, assessedCustomFee4); + + // build the corresponding protobuf assessed custom fee list, with effective payer account ids + var protoAssessedCustomFee3 = com.hederahashgraph.api.proto.java.AssessedCustomFee.newBuilder() + .addAllEffectivePayerAccountId(List.of(convertAccountId(FEE_PAYER_1))) + .setAmount(12300L) + .setFeeCollectorAccountId(convertAccountId(FEE_COLLECTOR_ACCOUNT_ID_1)) + .build(); + var protoAssessedCustomFee4 = com.hederahashgraph.api.proto.java.AssessedCustomFee.newBuilder() + .addAllEffectivePayerAccountId(List.of(convertAccountId(FEE_PAYER_1), convertAccountId(FEE_PAYER_2))) + .setAmount(8790L) + .setFeeCollectorAccountId(convertAccountId(FEE_COLLECTOR_ACCOUNT_ID_2)) + .setTokenId(convertTokenId(FEE_DOMAIN_TOKEN_ID)) + .build(); + var protoAssessedCustomFeesWithPayers = List.of(protoAssessedCustomFee3, protoAssessedCustomFee4); + return Stream.of( - Arguments.of("no assessed custom fees", Lists.emptyList()), - Arguments.of("has assessed custom fees", assessedCustomFees) + Arguments.of("no assessed custom fees", Lists.emptyList(), Lists.emptyList()), + Arguments.of("has assessed custom fees without effective payer account ids", assessedCustomFees, + protoAssessedCustomFees), + Arguments.of("has assessed custom fees with effective payer account ids", assessedCustomFeesWithPayers, + protoAssessedCustomFeesWithPayers) ); } - private List convertAssessedCustomFees( - List assessedCustomFees) { - return assessedCustomFees.stream() - .map(assessedCustomFee -> { - EntityId collectorAccountId = assessedCustomFee.getId().getCollectorAccountId(); - var builder = com.hederahashgraph.api.proto.java.AssessedCustomFee.newBuilder() - .setAmount(assessedCustomFee.getAmount()) - .setFeeCollectorAccountId(convertAccountId(collectorAccountId)); - - if (assessedCustomFee.getTokenId() != null) { - builder.setTokenId(convertTokenId(assessedCustomFee.getTokenId())); - } - - return builder.build(); - }) - .collect(Collectors.toList()); - } - private com.hederahashgraph.api.proto.java.CustomFee convertCustomFee(CustomFee customFee) { var protoCustomFee = com.hederahashgraph.api.proto.java.CustomFee.newBuilder() .setFeeCollectorAccountId(convertAccountId(customFee.getCollectorAccountId())); - if (customFee.getAmountDenominator() == null) { - // fixed fee - var fixedFee = FixedFee.newBuilder().setAmount(customFee.getAmount()); - EntityId denominatingTokenId = customFee.getDenominatingTokenId(); - if (denominatingTokenId != null) { - if (denominatingTokenId.equals(customFee.getId().getTokenId())) { - fixedFee.setDenominatingTokenId(TokenID.getDefaultInstance()); - } else { - fixedFee.setDenominatingTokenId(convertTokenId(denominatingTokenId)); - } - } - - protoCustomFee.setFixedFee(fixedFee).build(); - } else { + if (customFee.getAmountDenominator() != null) { // fractional fee long maximumAmount = customFee.getMaximumAmount() != null ? customFee.getMaximumAmount() : 0; - protoCustomFee.setFractionalFee(FractionalFee.newBuilder() - .setFractionalAmount(Fraction.newBuilder() - .setNumerator(customFee.getAmount()) - .setDenominator(customFee.getAmountDenominator()) - ) - .setMaximumAmount(maximumAmount) - .setMinimumAmount(customFee.getMinimumAmount()) - .build() + protoCustomFee.setFractionalFee( + FractionalFee.newBuilder() + .setFractionalAmount( + Fraction.newBuilder() + .setNumerator(customFee.getAmount()) + .setDenominator(customFee.getAmountDenominator()) + ) + .setMaximumAmount(maximumAmount) + .setMinimumAmount(customFee.getMinimumAmount()) + .setNetOfTransfers(customFee.getNetOfTransfers()) ); + } else if (customFee.getRoyaltyDenominator() != null) { + // royalty fee + RoyaltyFee.Builder royaltyFee = RoyaltyFee.newBuilder() + .setExchangeValueFraction( + Fraction.newBuilder() + .setNumerator(customFee.getRoyaltyNumerator()) + .setDenominator(customFee.getRoyaltyDenominator()) + ); + if (customFee.getAmount() != null) { + royaltyFee.setFallbackFee(convertFixedFee(customFee)); + } + + protoCustomFee.setRoyaltyFee(royaltyFee); + } else { + // fixed fee + protoCustomFee.setFixedFee(convertFixedFee(customFee)); } return protoCustomFee.build(); } + private FixedFee.Builder convertFixedFee(CustomFee customFee) { + FixedFee.Builder fixedFee = FixedFee.newBuilder().setAmount(customFee.getAmount()); + EntityId denominatingTokenId = customFee.getDenominatingTokenId(); + if (denominatingTokenId != null) { + if (denominatingTokenId.equals(customFee.getId().getTokenId())) { + fixedFee.setDenominatingTokenId(TokenID.getDefaultInstance()); + } else { + fixedFee.setDenominatingTokenId(convertTokenId(denominatingTokenId)); + } + } + + return fixedFee; + } + private List convertCustomFees(List customFees) { return customFees.stream() - .filter(customFee -> customFee.getAmount() != null) + .filter(customFee -> customFee.getAmount() != null || customFee.getRoyaltyDenominator() != null) .map(this::convertCustomFee) .collect(Collectors.toList()); } - private AccountID convertAccountId(EntityId accountId) { + private static AccountID convertAccountId(EntityId accountId) { return AccountID.newBuilder() .setShardNum(accountId.getShardNum()) .setRealmNum(accountId.getRealmNum()) @@ -1384,7 +1493,7 @@ private AccountID convertAccountId(EntityId accountId) { .build(); } - private TokenID convertTokenId(EntityId tokenId) { + private static TokenID convertTokenId(EntityId tokenId) { return TokenID.newBuilder() .setShardNum(tokenId.getShardNum()) .setRealmNum(tokenId.getRealmNum()) diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/repository/RepositoryEntityListenerTest.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/repository/RepositoryEntityListenerTest.java index bc60dc94d8e..c0cc608f12d 100644 --- a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/repository/RepositoryEntityListenerTest.java +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/repository/RepositoryEntityListenerTest.java @@ -24,6 +24,7 @@ import com.google.protobuf.ByteString; import com.hederahashgraph.api.proto.java.Key; +import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.binary.Hex; @@ -120,6 +121,7 @@ void onAssessedCustomFee() { AssessedCustomFee assessedCustomFee2 = new AssessedCustomFee(); assessedCustomFee2.setAmount(11L); + assessedCustomFee2.setEffectivePayerAccountIds(List.of(1002L, 1003L)); assessedCustomFee2.setId(new AssessedCustomFee.Id(ENTITY_ID, 1031L)); assessedCustomFee2.setTokenId(TOKEN_ID); diff --git a/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/sql/AssessedCustomFeePgCopyTest.java b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/sql/AssessedCustomFeePgCopyTest.java new file mode 100644 index 00000000000..05d32d0c052 --- /dev/null +++ b/hedera-mirror-importer/src/test/java/com/hedera/mirror/importer/parser/record/entity/sql/AssessedCustomFeePgCopyTest.java @@ -0,0 +1,105 @@ +package com.hedera.mirror.importer.parser.record.entity.sql; + +/*- + * ‌ + * Hedera Mirror Node + * ​ + * Copyright (C) 2019 - 2021 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. + * ‍ + */ + +import static org.assertj.core.api.Assertions.assertThat; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.sql.SQLException; +import java.util.List; +import javax.annotation.Resource; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import com.hedera.mirror.importer.IntegrationTest; +import com.hedera.mirror.importer.domain.AssessedCustomFee; +import com.hedera.mirror.importer.domain.AssessedCustomFeeWrapper; +import com.hedera.mirror.importer.domain.EntityId; +import com.hedera.mirror.importer.domain.EntityTypeEnum; +import com.hedera.mirror.importer.parser.PgCopy; +import com.hedera.mirror.importer.parser.record.RecordParserProperties; + +class AssessedCustomFeePgCopyTest extends IntegrationTest { + + private static final long CONSENSUS_TIMESTAMP = 10L; + private static final EntityId FEE_COLLECTOR_1 = EntityId.of("0.0.2000", EntityTypeEnum.ACCOUNT); + private static final EntityId FEE_COLLECTOR_2 = EntityId.of("0.0.2001", EntityTypeEnum.ACCOUNT); + private static final long FEE_PAYER_1 = 3000L; + private static final long FEE_PAYER_2 = 3001L; + private static final EntityId TOKEN_ID_1 = EntityId.of("0.0.5000", EntityTypeEnum.TOKEN); + private static final EntityId TOKEN_ID_2 = EntityId.of("0.0.5001", EntityTypeEnum.TOKEN); + + private final MeterRegistry meterRegistry = new SimpleMeterRegistry(); + + @Resource + private DataSource dataSource; + + @Resource + private JdbcTemplate jdbcTemplate; + + @Resource + private RecordParserProperties parserProperties; + + private PgCopy assessedCustomFeePgCopy; + + @Test + void copy() throws SQLException { + // given + assessedCustomFeePgCopy = new PgCopy<>(AssessedCustomFee.class, meterRegistry, parserProperties); + + // fee paid in HBAR with empty effective payer list + AssessedCustomFee assessedCustomFee1 = new AssessedCustomFee(); + assessedCustomFee1.setAmount(10L); + assessedCustomFee1.setId(new AssessedCustomFee.Id(FEE_COLLECTOR_1, CONSENSUS_TIMESTAMP)); + + // fee paid in TOKEN_ID_1 by FEE_PAYER_1 to FEE_COLLECTOR_2 + AssessedCustomFee assessedCustomFee2 = new AssessedCustomFee(); + assessedCustomFee2.setAmount(20L); + assessedCustomFee2.setEffectivePayerAccountIds(List.of(FEE_PAYER_1)); + assessedCustomFee2.setTokenId(TOKEN_ID_1); + assessedCustomFee2.setId(new AssessedCustomFee.Id(FEE_COLLECTOR_2, CONSENSUS_TIMESTAMP)); + + // fee paid in TOKEN_ID_2 by FEE_PAYER_1 and FEE_PAYER_2 to FEE_COLLECTOR_2 + AssessedCustomFee assessedCustomFee3 = new AssessedCustomFee(); + assessedCustomFee3.setAmount(30L); + assessedCustomFee3.setEffectivePayerAccountIds(List.of(FEE_PAYER_1, FEE_PAYER_2)); + assessedCustomFee3.setTokenId(TOKEN_ID_2); + assessedCustomFee3.setId(new AssessedCustomFee.Id(FEE_COLLECTOR_2, CONSENSUS_TIMESTAMP)); + + List assessedCustomFees = List.of( + assessedCustomFee1, + assessedCustomFee2, + assessedCustomFee3 + ); + + // when + assessedCustomFeePgCopy.copy(assessedCustomFees, dataSource.getConnection()); + + // then + List actual = jdbcTemplate.query(AssessedCustomFeeWrapper.SELECT_QUERY, + AssessedCustomFeeWrapper.ROW_MAPPER); + assertThat(actual) + .map(AssessedCustomFeeWrapper::getAssessedCustomFee) + .containsExactlyInAnyOrderElementsOf(assessedCustomFees); + } +} diff --git a/hedera-mirror-rest/__tests__/integrationDomainOps.js b/hedera-mirror-rest/__tests__/integrationDomainOps.js index 75991933a11..a5b22c5f9b0 100644 --- a/hedera-mirror-rest/__tests__/integrationDomainOps.js +++ b/hedera-mirror-rest/__tests__/integrationDomainOps.js @@ -226,23 +226,53 @@ const addAccount = async (account) => { }; const addAssessedCustomFee = async (assessedCustomFee) => { - const {amount, collector_account_id, consensus_timestamp, token_id} = assessedCustomFee; + assessedCustomFee = { + effective_payer_account_ids: [], + ...assessedCustomFee, + }; + const {amount, collector_account_id, consensus_timestamp, effective_payer_account_ids, token_id} = assessedCustomFee; + const effectivePayerAccountIds = [ + '{', + effective_payer_account_ids.map((payer) => EntityId.fromString(payer).getEncodedId()).join(','), + '}', + ].join(''); + await sqlConnection.query( - `insert into assessed_custom_fee (amount, collector_account_id, consensus_timestamp, token_id) - values ($1, $2, $3, $4);`, + `insert into + assessed_custom_fee (amount, collector_account_id, consensus_timestamp, effective_payer_account_ids, token_id) + values ($1, $2, $3, $4, $5);`, [ amount, EntityId.fromString(collector_account_id).getEncodedId(), consensus_timestamp.toString(), + effectivePayerAccountIds, EntityId.fromString(token_id, 'tokenId', true).getEncodedId(), ] ); }; const addCustomFee = async (customFee) => { + let netOfTransfers = customFee.net_of_transfers; + if (customFee.amount_denominator && netOfTransfers == null) { + // set default netOfTransfers for fractional fees + netOfTransfers = false; + } + await sqlConnection.query( - `insert into custom_fee (amount, amount_denominator, collector_account_id, created_timestamp, denominating_token_id, maximum_amount, minimum_amount, token_id) - values ($1, $2, $3, $4, $5, $6, $7, $8);`, + `insert into custom_fee ( + amount, + amount_denominator, + collector_account_id, + created_timestamp, + denominating_token_id, + maximum_amount, + minimum_amount, + net_of_transfers, + royalty_denominator, + royalty_numerator, + token_id + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`, [ customFee.amount || null, customFee.amount_denominator || null, @@ -251,6 +281,9 @@ const addCustomFee = async (customFee) => { EntityId.fromString(customFee.denominating_token_id, 'denominatingTokenId', true).getEncodedId(), customFee.maximum_amount || null, customFee.minimum_amount || '0', + netOfTransfers != null ? netOfTransfers : null, + customFee.royalty_denominator || null, + customFee.royalty_numerator || null, EntityId.fromString(customFee.token_id).getEncodedId(), ] ); @@ -536,6 +569,11 @@ const addToken = async (token) => { ...token, }; + if (token.type === 'NON_FUNGIBLE_UNIQUE') { + token.decimals = 0; + token.initial_supply = 0; + } + if (!token.modified_timestamp) { token.modified_timestamp = token.created_timestamp; } diff --git a/hedera-mirror-rest/__tests__/specs/token-info-04-non-fungible-unique-token-types.spec.json b/hedera-mirror-rest/__tests__/specs/token-info-04-non-fungible-unique-token-types.spec.json index faed86b5f00..bd7a83f064c 100644 --- a/hedera-mirror-rest/__tests__/specs/token-info-04-non-fungible-unique-token-types.spec.json +++ b/hedera-mirror-rest/__tests__/specs/token-info-04-non-fungible-unique-token-types.spec.json @@ -42,12 +42,12 @@ "auto_renew_account": null, "auto_renew_period": null, "created_timestamp": "1234567890.000000002", - "decimals": "1000", + "decimals": "0", "expiry_timestamp": null, "fee_schedule_key": null, "freeze_default": false, "freeze_key": null, - "initial_supply": "1000000", + "initial_supply": "0", "kyc_key": null, "max_supply": "9223372036854775807", "modified_timestamp": "1234567890.000000002", @@ -61,7 +61,7 @@ "custom_fees": { "created_timestamp": "1234567890.000000002", "fixed_fees": [], - "fractional_fees": [] + "royalty_fees": [] } } } diff --git a/hedera-mirror-rest/__tests__/specs/token-info-05-custom-fees.spec.json b/hedera-mirror-rest/__tests__/specs/token-info-05-custom-fees.spec.json index d537719be71..8c952ba6c05 100644 --- a/hedera-mirror-rest/__tests__/specs/token-info-05-custom-fees.spec.json +++ b/hedera-mirror-rest/__tests__/specs/token-info-05-custom-fees.spec.json @@ -86,6 +86,7 @@ "collector_account_id": "0.0.8904", "created_timestamp": "1234567899999999007", "minimum_amount": "0", + "net_of_transfers": true, "token_id": "0.0.1135" }, { @@ -153,7 +154,8 @@ }, "collector_account_id": "0.0.8902", "denominating_token_id": "0.0.1135", - "minimum": 1 + "minimum": 1, + "net_of_transfers": false }, { "amount": { @@ -163,7 +165,8 @@ "collector_account_id": "0.0.8901", "denominating_token_id": "0.0.1135", "maximum": 987, - "minimum": 79 + "minimum": 79, + "net_of_transfers": false }, { "amount": { @@ -172,7 +175,8 @@ }, "collector_account_id": "0.0.8904", "denominating_token_id": "0.0.1135", - "minimum": 0 + "minimum": 0, + "net_of_transfers": true } ] } diff --git a/hedera-mirror-rest/__tests__/specs/token-info-06-historical-custom-fees.spec.json b/hedera-mirror-rest/__tests__/specs/token-info-06-historical-custom-fees.spec.json index 4cdeee98c7b..ba2ae0f494e 100644 --- a/hedera-mirror-rest/__tests__/specs/token-info-06-historical-custom-fees.spec.json +++ b/hedera-mirror-rest/__tests__/specs/token-info-06-historical-custom-fees.spec.json @@ -144,7 +144,8 @@ "collector_account_id": "0.0.8901", "denominating_token_id": "0.0.1135", "maximum": 980, - "minimum": 97 + "minimum": 97, + "net_of_transfers": false } ] } diff --git a/hedera-mirror-rest/__tests__/specs/token-info-09-nft-custom-fees.spec.json b/hedera-mirror-rest/__tests__/specs/token-info-09-nft-custom-fees.spec.json new file mode 100644 index 00000000000..0435e0d09d5 --- /dev/null +++ b/hedera-mirror-rest/__tests__/specs/token-info-09-nft-custom-fees.spec.json @@ -0,0 +1,180 @@ +{ + "description": "Token info api call for a given non-fungible token with custom fees", + "extendedDescription": [ + "The token has 3 custom fees schedules: an empty schedule at creation, a single custom fee schedule at", + "1234567899999999001, and a 4 custom fees schedule at 1234567899999999007. Without the timestamp filter, the query", + "should return the last fee schedule" + ], + "setup": { + "entities": [ + { + "num": 1, + "type": 5 + }, + { + "num": 1135, + "type": 5 + }, + { + "realm": 7, + "num": 25301, + "type": 5 + }, + { + "realm": 23, + "num": 45678, + "type": 5 + } + ], + "tokens": [ + { + "token_id": "0.0.1", + "symbol": "FIRSTMOVERLPDJH", + "created_timestamp": "1234567890000000001", + "type": "NON_FUNGIBLE_UNIQUE" + }, + { + "token_id": "0.0.1135", + "symbol": "ORIGINALRDKSE", + "created_timestamp": "1234567890000000002", + "fee_schedule_key": [1, 2, 3], + "type": "NON_FUNGIBLE_UNIQUE" + }, + { + "token_id": "0.7.25301", + "symbol": "MIRRORTOKEN", + "created_timestamp": "1234567890000000003", + "type": "NON_FUNGIBLE_UNIQUE" + }, + { + "token_id": "0.23.45678", + "symbol": "HEDERACOIN", + "created_timestamp": "1234567890000000004", + "type": "NON_FUNGIBLE_UNIQUE" + } + ], + "customfees": [ + { + "amount": "10", + "amount_denominator": "13", + "collector_account_id": "0.0.8901", + "created_timestamp": "1234567899999999001", + "maximum_amount": "980", + "minimum_amount": "97", + "token_id": "0.0.1135" + }, + { + "collector_account_id": "0.0.8901", + "created_timestamp": "1234567899999999007", + "royalty_denominator": "17", + "royalty_numerator": "11", + "token_id": "0.0.1135" + }, + { + "amount": "1300", + "collector_account_id": "0.0.8902", + "created_timestamp": "1234567899999999007", + "denominating_token_id": "0.0.1137", + "royalty_denominator": "13", + "royalty_numerator": "9", + "token_id": "0.0.1135" + }, + { + "amount": "8", + "collector_account_id": "0.0.8904", + "created_timestamp": "1234567899999999007", + "royalty_denominator": "131", + "royalty_numerator": "41", + "token_id": "0.0.1135" + }, + { + "amount": "12", + "collector_account_id": "0.0.8904", + "created_timestamp": "1234567899999999007", + "denominating_token_id": "0.0.1137", + "token_id": "0.0.1135" + }, + { + "amount": "13", + "collector_account_id": "0.0.8905", + "created_timestamp": "1234567899999999007", + "token_id": "0.0.1135" + } + ] + }, + "urls": ["/api/v1/tokens/0.0.1135", "/api/v1/tokens/0.1135", "/api/v1/tokens/1135"], + "responseStatus": 200, + "responseJson": { + "token_id": "0.0.1135", + "symbol": "ORIGINALRDKSE", + "admin_key": null, + "auto_renew_account": null, + "auto_renew_period": null, + "created_timestamp": "1234567890.000000002", + "decimals": "0", + "expiry_timestamp": null, + "fee_schedule_key": { + "_type": "ProtobufEncoded", + "key": "7b2231222c2232222c2233227d" + }, + "freeze_default": false, + "freeze_key": null, + "initial_supply": "0", + "kyc_key": null, + "max_supply": "9223372036854775807", + "modified_timestamp": "1234567890.000000002", + "name": "Token name", + "supply_key": null, + "supply_type": "INFINITE", + "total_supply": "1000000", + "treasury_account_id": "0.0.98", + "type": "NON_FUNGIBLE_UNIQUE", + "wipe_key": null, + "custom_fees": { + "created_timestamp": "1234567899.999999007", + "fixed_fees": [ + { + "amount": 12, + "collector_account_id": "0.0.8904", + "denominating_token_id": "0.0.1137" + }, + { + "amount": 13, + "collector_account_id": "0.0.8905", + "denominating_token_id": null + } + ], + "royalty_fees": [ + { + "amount": { + "denominator": 131, + "numerator": 41 + }, + "collector_account_id": "0.0.8904", + "fallback_fee": { + "amount": 8, + "denominating_token_id": null + } + }, + { + "amount": { + "denominator": 13, + "numerator": 9 + }, + "collector_account_id": "0.0.8902", + "fallback_fee": { + "amount": 1300, + "denominating_token_id": "0.0.1137" + } + }, + { + "amount": { + "denominator": 17, + "numerator": 11 + }, + "collector_account_id": "0.0.8901" + } + ] + } + } +} diff --git a/hedera-mirror-rest/__tests__/specs/transactions-39-specific-id-assessed-custom-fees.spec.json b/hedera-mirror-rest/__tests__/specs/transactions-39-specific-id-assessed-custom-fees.spec.json index da6b81a47d6..0e48a3048d9 100644 --- a/hedera-mirror-rest/__tests__/specs/transactions-39-specific-id-assessed-custom-fees.spec.json +++ b/hedera-mirror-rest/__tests__/specs/transactions-39-specific-id-assessed-custom-fees.spec.json @@ -1,5 +1,5 @@ { - "description": "Transaction api calls for a specific transaction using transaction id", + "description": "Transaction api calls for a specific transaction with assessed custom fees", "setup": { "accounts": [ { @@ -121,16 +121,19 @@ { "amount": 11, "collector_account_id": "0.0.8901", + "effective_payer_account_ids": [], "token_id": null }, { "amount": 13, "collector_account_id": "0.0.8902", + "effective_payer_account_ids": [], "token_id": "0.0.10015" }, { "amount": 17, "collector_account_id": "0.0.8903", + "effective_payer_account_ids": [], "token_id": "0.0.90000" } ] diff --git a/hedera-mirror-rest/__tests__/specs/transactions-40-specific-id-assessed-custom-fees-with-effective-payer-account-ids.spec.json b/hedera-mirror-rest/__tests__/specs/transactions-40-specific-id-assessed-custom-fees-with-effective-payer-account-ids.spec.json new file mode 100644 index 00000000000..50844c59f54 --- /dev/null +++ b/hedera-mirror-rest/__tests__/specs/transactions-40-specific-id-assessed-custom-fees-with-effective-payer-account-ids.spec.json @@ -0,0 +1,147 @@ +{ + "description": "Transaction api calls for a specific transaction with assessed custom fees and effective payer info", + "setup": { + "accounts": [ + { + "num": 3 + }, + { + "num": 9 + }, + { + "num": 10 + }, + { + "num": 98 + } + ], + "balances": [], + "transactions": [], + "cryptotransfers": [ + { + "consensus_timestamp": "1565779555711927001", + "payerAccountId": "0.0.300", + "nodeAccountId": "0.0.3", + "treasuryAccountId": "0.0.98", + "token_transfer_list": [ + { + "token_id": "0.0.90000", + "account": "0.0.300", + "amount": -1200 + }, + { + "token_id": "0.0.90000", + "account": "0.0.200", + "amount": 200 + }, + { + "token_id": "0.0.90000", + "account": "0.0.400", + "amount": 1000 + } + ] + } + ], + "assessedcustomfees": [ + { + "amount": "9", + "collector_account_id": "0.0.8901", + "consensus_timestamp": "1565779555711000001", + "effective_payer_account_ids": ["0.0.4000"] + }, + { + "amount": "11", + "collector_account_id": "0.0.8901", + "consensus_timestamp": "1565779555711927001", + "effective_payer_account_ids": ["0.0.5000", "0.0.5001"] + }, + { + "amount": "13", + "collector_account_id": "0.0.8902", + "consensus_timestamp": "1565779555711927001", + "effective_payer_account_ids": ["0.0.5002", "0.0.5003"], + "token_id": "0.0.10015" + }, + { + "amount": "17", + "collector_account_id": "0.0.8903", + "consensus_timestamp": "1565779555711927001", + "effective_payer_account_ids": ["0.0.5005", "0.0.5006"], + "token_id": "0.0.90000" + } + ] + }, + "url": "/api/v1/transactions/0.0.300-1565779555-711927000", + "responseStatus": 200, + "responseJson": { + "transactions": [ + { + "bytes": "Ynl0ZXM=", + "consensus_timestamp": "1565779555.711927001", + "entity_id": null, + "valid_start_timestamp": "1565779555.711927000", + "charged_tx_fee": 7, + "memo_base64": null, + "result": "SUCCESS", + "scheduled": false, + "transaction_hash": "aGFzaA==", + "name": "CRYPTOTRANSFER", + "node": "0.0.3", + "transaction_id": "0.0.300-1565779555-711927000", + "valid_duration_seconds": "11", + "max_fee": "33", + "transfers": [ + { + "account": "0.0.3", + "amount": 2 + }, + { + "account": "0.0.98", + "amount": 1 + }, + { + "account": "0.0.300", + "amount": -3 + } + ], + "token_transfers": [ + { + "account": "0.0.300", + "amount": -1200, + "token_id": "0.0.90000" + }, + { + "account": "0.0.200", + "amount": 200, + "token_id": "0.0.90000" + }, + { + "account": "0.0.400", + "amount": 1000, + "token_id": "0.0.90000" + } + ], + "assessed_custom_fees": [ + { + "amount": 11, + "collector_account_id": "0.0.8901", + "effective_payer_account_ids": ["0.0.5000", "0.0.5001"], + "token_id": null + }, + { + "amount": 13, + "collector_account_id": "0.0.8902", + "effective_payer_account_ids": ["0.0.5002", "0.0.5003"], + "token_id": "0.0.10015" + }, + { + "amount": 17, + "collector_account_id": "0.0.8903", + "effective_payer_account_ids": ["0.0.5005", "0.0.5006"], + "token_id": "0.0.90000" + } + ] + } + ] + } +} diff --git a/hedera-mirror-rest/__tests__/tokens.test.js b/hedera-mirror-rest/__tests__/tokens.test.js index 0e09f88e231..18962e13577 100644 --- a/hedera-mirror-rest/__tests__/tokens.test.js +++ b/hedera-mirror-rest/__tests__/tokens.test.js @@ -1334,6 +1334,9 @@ describe('token extractSqlFromTokenInfoRequest tests', () => { 'denominating_token_id', denominating_token_id::text, 'maximum_amount', maximum_amount, 'minimum_amount', minimum_amount, + 'net_of_transfers', net_of_transfers, + 'royalty_denominator', royalty_denominator, + 'royalty_numerator', royalty_numerator, 'token_id', token_id::text ) order by amount, collector_account_id, denominating_token_id) from custom_fee cf diff --git a/hedera-mirror-rest/__tests__/transactions.test.js b/hedera-mirror-rest/__tests__/transactions.test.js index 67701b83546..b9fa4717679 100644 --- a/hedera-mirror-rest/__tests__/transactions.test.js +++ b/hedera-mirror-rest/__tests__/transactions.test.js @@ -416,16 +416,19 @@ describe('createAssessedCustomFeeList', () => { { amount: 8, collector_account_id: 9901, + effective_payer_account_ids: [], token_id: null, }, { amount: 9, collector_account_id: 9901, + effective_payer_account_ids: [7000], token_id: 10001, }, { amount: 29, collector_account_id: 9902, + effective_payer_account_ids: [7000, 7001], token_id: 10002, }, ]; @@ -433,16 +436,19 @@ describe('createAssessedCustomFeeList', () => { { amount: 8, collector_account_id: '0.0.9901', + effective_payer_account_ids: [], token_id: null, }, { amount: 9, collector_account_id: '0.0.9901', + effective_payer_account_ids: ['0.0.7000'], token_id: '0.0.10001', }, { amount: 29, collector_account_id: '0.0.9902', + effective_payer_account_ids: ['0.0.7000', '0.0.7001'], token_id: '0.0.10002', }, ]; diff --git a/hedera-mirror-rest/__tests__/viewmodel/assessedCustomFeeViewModel.test.js b/hedera-mirror-rest/__tests__/viewmodel/assessedCustomFeeViewModel.test.js index ef6fbf0637d..058a60ae955 100644 --- a/hedera-mirror-rest/__tests__/viewmodel/assessedCustomFeeViewModel.test.js +++ b/hedera-mirror-rest/__tests__/viewmodel/assessedCustomFeeViewModel.test.js @@ -24,34 +24,57 @@ const AssessedCustomFee = require('../../model/assessedCustomFee'); const AssessedCustomFeeViewModel = require('../../viewmodel/assessedCustomFeeViewModel'); describe('AssessedCustomFeeViewModel', () => { - test('fee charged in hbar', () => { - const model = new AssessedCustomFee({ - amount: 13, - collector_account_id: 8901, - consensus_timestamp: '1', - }); - const expected = { - amount: 13, - collector_account_id: '0.0.8901', - token_id: null, - }; + const effectivePayersTestSpecs = [ + { + name: 'empty effective payers', + payersModel: [], + expectedPayers: [], + }, + { + name: 'null empty effective payers', + expectedPayers: [], + }, + { + name: 'non-empty effective payers', + payersModel: [9000, 9001, 9002], + expectedPayers: ['0.0.9000', '0.0.9001', '0.0.9002'], + }, + ]; - expect(new AssessedCustomFeeViewModel(model)).toEqual(expected); - }); + effectivePayersTestSpecs.forEach((testSpec) => { + test(`fee charged in hbar with ${testSpec.name}`, () => { + const model = new AssessedCustomFee({ + amount: 13, + collector_account_id: 8901, + consensus_timestamp: '1', + effective_payer_account_ids: testSpec.payersModel, + }); + const expected = { + amount: 13, + collector_account_id: '0.0.8901', + effective_payer_account_ids: testSpec.expectedPayers, + token_id: null, + }; - test('fee charged in token', () => { - const model = new AssessedCustomFee({ - amount: 13, - collector_account_id: 8901, - consensus_timestamp: '1', - token_id: 10013, + expect(new AssessedCustomFeeViewModel(model)).toEqual(expected); }); - const expected = { - amount: 13, - collector_account_id: '0.0.8901', - token_id: '0.0.10013', - }; - expect(new AssessedCustomFeeViewModel(model)).toEqual(expected); + test(`fee charged in token with ${testSpec.name}`, () => { + const model = new AssessedCustomFee({ + amount: 13, + collector_account_id: 8901, + consensus_timestamp: '1', + effective_payer_account_ids: testSpec.payersModel, + token_id: 10013, + }); + const expected = { + amount: 13, + collector_account_id: '0.0.8901', + effective_payer_account_ids: testSpec.expectedPayers, + token_id: '0.0.10013', + }; + + expect(new AssessedCustomFeeViewModel(model)).toEqual(expected); + }); }); }); diff --git a/hedera-mirror-rest/__tests__/viewmodel/customFeeViewModel.test.js b/hedera-mirror-rest/__tests__/viewmodel/customFeeViewModel.test.js index d4efb685879..edf75e29e69 100644 --- a/hedera-mirror-rest/__tests__/viewmodel/customFeeViewModel.test.js +++ b/hedera-mirror-rest/__tests__/viewmodel/customFeeViewModel.test.js @@ -24,55 +24,83 @@ const CustomFee = require('../../model/customFee'); const CustomFeeViewModel = require('../../viewmodel/customFeeViewModel'); describe('CustomFeeViewModel', () => { - test('fixed fee in HBAR', () => { - const input = new CustomFee({ - amount: 15, - collector_account_id: 8901, - created_timestamp: '10', - token_id: 10015, + const fixedFeeTestSpecs = [ + { + name: 'HBAR', + expectedDenominatingTokenId: null, + }, + { + name: 'token', + dbDenominatingTokenId: 10012, + expectedDenominatingTokenId: '0.0.10012', + }, + ]; + + fixedFeeTestSpecs.forEach((testSpec) => { + test(`fixed fee in ${testSpec.name}`, () => { + const input = new CustomFee({ + amount: 15, + collector_account_id: 8901, + created_timestamp: '10', + denominating_token_id: testSpec.dbDenominatingTokenId, + token_id: 10015, + }); + const expected = { + amount: 15, + collector_account_id: '0.0.8901', + denominating_token_id: testSpec.expectedDenominatingTokenId, + }; + + const actual = new CustomFeeViewModel(input); + + expect(actual).toEqual(expected); + expect(actual.hasFee()).toBeTruthy(); + expect(actual.isFixedFee()).toBeTruthy(); + expect(actual.isFractionalFee()).toBeFalsy(); + expect(actual.isRoyaltyFee()).toBeFalsy(); }); - const expected = { - amount: 15, - collector_account_id: '0.0.8901', - denominating_token_id: null, - }; - - const actual = new CustomFeeViewModel(input); - - expect(actual).toEqual(expected); - expect(actual.hasFee()).toBeTruthy(); - expect(actual.isFractionalFee()).toBeFalsy(); }); - test('fixed fee in token', () => { + test('fractional fee', () => { const input = new CustomFee({ amount: 15, + amount_denominator: 31, collector_account_id: 8901, created_timestamp: '10', - denominating_token_id: 10012, + maximum_amount: 101, + minimum_amount: 37, + net_of_transfers: false, token_id: 10015, }); const expected = { - amount: 15, + amount: { + numerator: 15, + denominator: 31, + }, collector_account_id: '0.0.8901', - denominating_token_id: '0.0.10012', + denominating_token_id: '0.0.10015', + maximum: 101, + minimum: 37, + net_of_transfers: false, }; const actual = new CustomFeeViewModel(input); expect(actual).toEqual(expected); expect(actual.hasFee()).toBeTruthy(); - expect(actual.isFractionalFee()).toBeFalsy(); + expect(actual.isFixedFee()).toBeFalsy(); + expect(actual.isFractionalFee()).toBeTruthy(); + expect(actual.isRoyaltyFee()).toBeFalsy(); }); - test('fractional fee', () => { + test('fractional fee no maximum', () => { const input = new CustomFee({ amount: 15, amount_denominator: 31, collector_account_id: 8901, created_timestamp: '10', - maximum_amount: 101, minimum_amount: 37, + net_of_transfers: true, token_id: 10015, }); const expected = { @@ -82,25 +110,25 @@ describe('CustomFeeViewModel', () => { }, collector_account_id: '0.0.8901', denominating_token_id: '0.0.10015', - maximum: 101, minimum: 37, + net_of_transfers: true, }; const actual = new CustomFeeViewModel(input); expect(actual).toEqual(expected); expect(actual.hasFee()).toBeTruthy(); + expect(actual.isFixedFee()).toBeFalsy(); expect(actual.isFractionalFee()).toBeTruthy(); + expect(actual.isRoyaltyFee()).toBeFalsy(); }); - test('fractional fee no maximum', () => { + test('royalty fee without fallback', () => { const input = new CustomFee({ - amount: 15, - amount_denominator: 31, collector_account_id: 8901, created_timestamp: '10', - minimum_amount: 37, - token_id: 10015, + royalty_denominator: 31, + royalty_numerator: 15, }); const expected = { amount: { @@ -108,15 +136,47 @@ describe('CustomFeeViewModel', () => { denominator: 31, }, collector_account_id: '0.0.8901', - denominating_token_id: '0.0.10015', - minimum: 37, }; const actual = new CustomFeeViewModel(input); expect(actual).toEqual(expected); expect(actual.hasFee()).toBeTruthy(); - expect(actual.isFractionalFee()).toBeTruthy(); + expect(actual.isFixedFee()).toBeFalsy(); + expect(actual.isFractionalFee()).toBeFalsy(); + expect(actual.isRoyaltyFee()).toBeTruthy(); + }); + + fixedFeeTestSpecs.forEach((testSpec) => { + test(`royalty fee with fallback in ${testSpec.name}`, () => { + const input = new CustomFee({ + amount: 11, + collector_account_id: 8901, + created_timestamp: '10', + denominating_token_id: testSpec.dbDenominatingTokenId, + royalty_denominator: 31, + royalty_numerator: 15, + }); + const expected = { + amount: { + numerator: 15, + denominator: 31, + }, + collector_account_id: '0.0.8901', + fallback_fee: { + amount: 11, + denominating_token_id: testSpec.expectedDenominatingTokenId, + }, + }; + + const actual = new CustomFeeViewModel(input); + + expect(actual).toEqual(expected); + expect(actual.hasFee()).toBeTruthy(); + expect(actual.isFixedFee()).toBeFalsy(); + expect(actual.isFractionalFee()).toBeFalsy(); + expect(actual.isRoyaltyFee()).toBeTruthy(); + }); }); test('empty fee', () => { diff --git a/hedera-mirror-rest/api/v1/openapi.yml b/hedera-mirror-rest/api/v1/openapi.yml index 2e55789293d..188dc301e7d 100644 --- a/hedera-mirror-rest/api/v1/openapi.yml +++ b/hedera-mirror-rest/api/v1/openapi.yml @@ -475,6 +475,102 @@ paths: application/json: schema: $ref: '#/components/schemas/TokenInfo' + examples: + FungibleToken: + value: + admin_key: + _type: ProtobufEncoded + key: 7b2231222c2231222c2231227d + auto_renew_account: 0.1.2 + auto_renew_period: + created_timestamp: '1234567890.000000001' + decimals: 1000 + expiry_timestamp: + freeze_default: false + freeze_key: + _type: ProtobufEncoded + key: 7b2231222c2231222c2231227d + initial_supply: 1000000 + kyc_key: + _type: ProtobufEncoded + key: 7b2231222c2231222c2231227d + max_supply: 9223372036854776000 + modified_timestamp: '1234567890.000000001' + name: Token name + supply_key: + _type: ProtobufEncoded + key: 7b2231222c2231222c2231227d + supply_type: INFINITE + symbol: ORIGINALRDKSE + token_id: 0.10.1 + total_supply: 1000000 + treasury_account_id: 0.1.2 + type: FUNGIBLE_COMMON + wipe_key: + _type: ProtobufEncoded + key: 7b2231222c2231222c2231227d + custom_fees: + created_timestamp: '1234567890.000000001' + fixed_fees: + - amount: 100 + collector_account_id: 0.1.5 + denominating_token_id: 0.10.8 + fractional_fees: + - amount: + numerator: 12 + denominator: 29 + collector_account_id: 0.1.6 + denominating_token_id: 0.10.9 + maximum: 120 + minimum: 30 + net_of_transfers: true + NonFungibleToken: + value: + admin_key: + _type: ProtobufEncoded + key: 7b2231222c2231222c2231227d + auto_renew_account: 0.1.2 + auto_renew_period: + created_timestamp: '1234567890.000000001' + decimals: 0 + expiry_timestamp: + freeze_default: false + freeze_key: + _type: ProtobufEncoded + key: 7b2231222c2231222c2231227d + initial_supply: 0 + kyc_key: + _type: ProtobufEncoded + key: 7b2231222c2231222c2231227d + max_supply: 9223372036854776000 + modified_timestamp: '1234567890.000000001' + name: Token name + supply_key: + _type: ProtobufEncoded + key: 7b2231222c2231222c2231227d + supply_type: INFINITE + symbol: ORIGINALRDKSE + token_id: 0.10.1 + total_supply: 1000000 + treasury_account_id: 0.1.2 + type: NON_FUNGIBLE_UNIQUE + wipe_key: + _type: ProtobufEncoded + key: 7b2231222c2231222c2231227d + custom_fees: + created_timestamp: '1234567890.000000001' + fixed_fees: + - amount: 100 + collector_account_id: 0.1.5 + denominating_token_id: 0.10.6 + royalty_fees: + - amount: + numerator: 15 + denominator: 37 + collector_account_id: 0.1.6 + fallback_fee: + amount: 100 + denominating_token_id: 0.10.7 400: description: Bad Request content: @@ -799,6 +895,10 @@ components: type: array items: $ref: '#/components/schemas/FractionalFee' + royalty_fees: + type: array + items: + $ref: '#/components/schemas/RoyaltyFee' EntityId: type: string description: "Network entity ID in the format of `shard.realm.num`" @@ -850,6 +950,31 @@ components: minimum: type: number example: 30 + net_of_transfers: + type: boolean + example: true + RoyaltyFee: + type: object + properties: + amount: + type: object + properties: + numerator: + type: number + example: 15 + denominator: + type: number + example: 37 + collector_account_id: + $ref: '#/components/schemas/EntityId' + fallback_fee: + type: object + properties: + amount: + type: number + example: 100 + denominating_token_id: + $ref: '#/components/schemas/EntityId' Key: type: object nullable: true @@ -1285,8 +1410,10 @@ components: type: number collector_account_id: $ref: '#/components/schemas/EntityId' - payer_account_id: - $ref: '#/components/schemas/EntityId' + effective_payer_account_ids: + type: array + items: + $ref: '#/components/schemas/EntityId' token_id: $ref: '#/components/schemas/EntityId' example: @@ -1328,7 +1455,9 @@ components: assessed_custom_fees: - amount: 100 collector_account_id: 0.0.10 - payer_account_id: 0.0.8 + effective_payer_account_ids: + - 0.0.8 + - 0.0.72 token_id: 0.0.90001 Transactions: type: array diff --git a/hedera-mirror-rest/model/assessedCustomFee.js b/hedera-mirror-rest/model/assessedCustomFee.js index 7ad48f68f3c..4b125fd07ec 100644 --- a/hedera-mirror-rest/model/assessedCustomFee.js +++ b/hedera-mirror-rest/model/assessedCustomFee.js @@ -28,6 +28,7 @@ class AssessedCustomFee { this.amount = assessedCustomFee.amount; this.collectorAccountId = assessedCustomFee.collector_account_id; this.consensusTimestamp = assessedCustomFee.consensus_timestamp; + this.effectivePayerAccountIds = assessedCustomFee.effective_payer_account_ids; this.tokenId = assessedCustomFee.token_id; } @@ -40,6 +41,8 @@ class AssessedCustomFee { static COLLECTOR_ACCOUNT_ID_FULL_NAME = `${this.tableAlias}.${this.COLLECTOR_ACCOUNT_ID}`; static CONSENSUS_TIMESTAMP = `consensus_timestamp`; static CONSENSUS_TIMESTAMP_FULL_NAME = `${this.tableAlias}.${this.CONSENSUS_TIMESTAMP}`; + static EFFECTIVE_PAYER_ACCOUNT_IDS = `effective_payer_account_ids`; + static EFFECTIVE_PAYER_ACCOUNT_IDS_FULL_NAME = `${this.tableAlias}.${this.EFFECTIVE_PAYER_ACCOUNT_IDS}`; static TOKEN_ID = `token_id`; static TOKEN_ID_FULL_NAME = `${this.tableAlias}.${this.TOKEN_ID}`; } diff --git a/hedera-mirror-rest/model/customFee.js b/hedera-mirror-rest/model/customFee.js index a381b22c0a3..ae780458b80 100644 --- a/hedera-mirror-rest/model/customFee.js +++ b/hedera-mirror-rest/model/customFee.js @@ -34,6 +34,9 @@ class CustomFee { this.denominatingTokenId = customFee.denominating_token_id; this.maximumAmount = customFee.maximum_amount; this.minimumAmount = customFee.minimum_amount; + this.netOfTransfers = customFee.net_of_transfers; + this.royaltyDenominator = customFee.royalty_denominator; + this.royaltyNumerator = customFee.royalty_numerator; this.tokenId = customFee.token_id; } @@ -54,6 +57,12 @@ class CustomFee { static MAXIMUM_AMOUNT_FULL_NAME = this._getFullName(this.MAXIMUM_AMOUNT); static MINIMUM_AMOUNT = `minimum_amount`; static MINIMUM_AMOUNT_FULL_NAME = this._getFullName(this.MINIMUM_AMOUNT); + static NET_OF_TRANSFERS = `net_of_transfers`; + static NET_OF_TRANSFERS_FULL_NAME = this._getFullName(this.NET_OF_TRANSFERS); + static ROYALTY_DENOMINATOR = `royalty_denominator`; + static ROYALTY_DENOMINATOR_FULL_NAME = this._getFullName(this.ROYALTY_DENOMINATOR); + static ROYALTY_NUMERATOR = `royalty_numerator`; + static ROYALTY_NUMERATOR_FULL_NAME = this._getFullName(this.ROYALTY_NUMERATOR); static TOKEN_ID = `token_id`; static TOKEN_ID_FULL_NAME = this._getFullName(this.TOKEN_ID); diff --git a/hedera-mirror-rest/model/token.js b/hedera-mirror-rest/model/token.js index dd65ba66372..a8b580ddb6c 100644 --- a/hedera-mirror-rest/model/token.js +++ b/hedera-mirror-rest/model/token.js @@ -54,6 +54,11 @@ class Token { static tableName = this.tableAlias; static TOKEN_ID = `token_id`; + + static TYPE = { + FUNGIBLE_COMMON: 'FUNGIBLE_COMMON', + NON_FUNGIBLE_UNIQUE: 'NON_FUNGIBLE_UNIQUE', + }; } module.exports = Token; diff --git a/hedera-mirror-rest/tokens.js b/hedera-mirror-rest/tokens.js index c0b81bfea4d..a6dd67a558b 100644 --- a/hedera-mirror-rest/tokens.js +++ b/hedera-mirror-rest/tokens.js @@ -32,7 +32,7 @@ const {InvalidArgumentError} = require('./errors/invalidArgumentError'); const {NotFoundError} = require('./errors/notFoundError'); // models -const {CustomFee, Nft, NftTransfer, Transaction} = require('./model'); +const {CustomFee, Nft, NftTransfer, Token, Transaction} = require('./model'); // middleware const {httpStatusCodes} = require('./constants'); @@ -180,17 +180,19 @@ const formatTokenRow = (row) => { * Creates custom fees object from an array of aggregated json objects * * @param customFees + * @param tokenType * @return {{}|*} */ -const createCustomFeesObject = (customFees) => { +const createCustomFeesObject = (customFees, tokenType) => { if (!customFees) { return null; } + const nonFixedFeesField = tokenType === Token.TYPE.FUNGIBLE_COMMON ? 'fractional_fees' : 'royalty_fees'; const result = { created_timestamp: utils.nsToSecNs(customFees[0].created_timestamp), fixed_fees: [], - fractional_fees: [], + [nonFixedFeesField]: [], }; return customFees.reduce((customFeesObject, customFee) => { @@ -198,7 +200,7 @@ const createCustomFeesObject = (customFees) => { const viewModel = new CustomFeeViewModel(model); if (viewModel.hasFee()) { - const fees = viewModel.isFractionalFee() ? customFeesObject.fractional_fees : customFeesObject.fixed_fees; + const fees = viewModel.isFixedFee() ? customFeesObject.fixed_fees : customFeesObject[nonFixedFeesField]; fees.push(viewModel); } @@ -212,7 +214,7 @@ const formatTokenInfoRow = (row) => { auto_renew_account: EntityId.fromEncodedId(row.auto_renew_account_id, true).toString(), auto_renew_period: row.auto_renew_period, created_timestamp: utils.nsToSecNs(row.created_timestamp), - custom_fees: createCustomFeesObject(row.custom_fees), + custom_fees: createCustomFeesObject(row.custom_fees, row.type), decimals: row.decimals, expiry_timestamp: row.expiration_timestamp, fee_schedule_key: utils.encodeKey(row.fee_schedule_key), @@ -417,6 +419,9 @@ const extractSqlFromTokenInfoRequest = (tokenId, filters) => { 'denominating_token_id', ${CustomFee.DENOMINATING_TOKEN_ID}::text, 'maximum_amount', ${CustomFee.MAXIMUM_AMOUNT}, 'minimum_amount', ${CustomFee.MINIMUM_AMOUNT}, + 'net_of_transfers', ${CustomFee.NET_OF_TRANSFERS}, + 'royalty_denominator', ${CustomFee.ROYALTY_DENOMINATOR}, + 'royalty_numerator', ${CustomFee.ROYALTY_NUMERATOR}, 'token_id', ${CustomFee.TOKEN_ID}::text ) order by ${CustomFee.AMOUNT}, ${CustomFee.COLLECTOR_ACCOUNT_ID}, ${CustomFee.DENOMINATING_TOKEN_ID}) from ${CustomFee.tableName} ${CustomFee.tableAlias} @@ -917,7 +922,6 @@ const getNftTransferHistoryRequest = async (req, res) => { }, }; - // const token = await NftTransferService.getTransfer(tokenId, serialNumber); if (_.isEmpty(rows)) { if (_.isEmpty(filters)) { throw new NotFoundError(); // 404 if no transactions are present diff --git a/hedera-mirror-rest/transactions.js b/hedera-mirror-rest/transactions.js index 69d598ada81..00d6a30ffe8 100644 --- a/hedera-mirror-rest/transactions.js +++ b/hedera-mirror-rest/transactions.js @@ -66,9 +66,10 @@ const getSelectClauseWithTransfers = (includeExtraInfo) => { `; const aggregateAssessedCustomFeeQuery = ` select jsonb_agg(jsonb_build_object( - 'amount', ${AssessedCustomFee.AMOUNT}, - 'collector_account_id', ${AssessedCustomFee.COLLECTOR_ACCOUNT_ID}, - 'token_id', ${AssessedCustomFee.TOKEN_ID} + 'amount', ${AssessedCustomFee.AMOUNT}, + 'collector_account_id', ${AssessedCustomFee.COLLECTOR_ACCOUNT_ID}, + 'effective_payer_account_ids', ${AssessedCustomFee.EFFECTIVE_PAYER_ACCOUNT_IDS}, + 'token_id', ${AssessedCustomFee.TOKEN_ID} )) from ${AssessedCustomFee.tableName} ${AssessedCustomFee.tableAlias} where ${AssessedCustomFee.CONSENSUS_TIMESTAMP_FULL_NAME} = ${Transaction.CONSENSUS_NS_FULL_NAME} diff --git a/hedera-mirror-rest/viewmodel/assessedCustomFeeViewModel.js b/hedera-mirror-rest/viewmodel/assessedCustomFeeViewModel.js index bb09031b42d..ef1f3f3dd80 100644 --- a/hedera-mirror-rest/viewmodel/assessedCustomFeeViewModel.js +++ b/hedera-mirror-rest/viewmodel/assessedCustomFeeViewModel.js @@ -35,6 +35,14 @@ class AssessedCustomFeeViewModel { this.amount = assessedCustomFee.amount; this.collector_account_id = EntityId.fromEncodedId(assessedCustomFee.collectorAccountId).toString(); this.token_id = EntityId.fromEncodedId(assessedCustomFee.tokenId, true).toString(); + + if (assessedCustomFee.effectivePayerAccountIds != null) { + this.effective_payer_account_ids = assessedCustomFee.effectivePayerAccountIds.map((payer) => + EntityId.fromEncodedId(payer).toString() + ); + } else { + this.effective_payer_account_ids = []; + } } } diff --git a/hedera-mirror-rest/viewmodel/customFeeViewModel.js b/hedera-mirror-rest/viewmodel/customFeeViewModel.js index fd7deaa3a26..e1bb8bee820 100644 --- a/hedera-mirror-rest/viewmodel/customFeeViewModel.js +++ b/hedera-mirror-rest/viewmodel/customFeeViewModel.js @@ -32,7 +32,7 @@ class CustomFeeViewModel { * @param {CustomFee} customFee */ constructor(customFee) { - if (!customFee.amount) { + if (!customFee.amount && !customFee.royaltyDenominator) { return; } @@ -43,15 +43,23 @@ class CustomFeeViewModel { denominator: customFee.amountDenominator, }; - this.denominating_token_id = EntityId.fromEncodedId( - customFee.denominatingTokenId || customFee.tokenId - ).toString(); + this.denominating_token_id = EntityId.fromEncodedId(customFee.tokenId).toString(); this.maximum = customFee.maximumAmount || undefined; this.minimum = customFee.minimumAmount; + this.net_of_transfers = customFee.netOfTransfers; + } else if (customFee.royaltyDenominator) { + // royalty fee + this.amount = { + numerator: customFee.royaltyNumerator, + denominator: customFee.royaltyDenominator, + }; + + if (customFee.amount) { + this.fallback_fee = this._parseFixedFee(customFee); + } } else { // fixed fee - this.amount = customFee.amount; - this.denominating_token_id = EntityId.fromEncodedId(customFee.denominatingTokenId, true).toString(); + Object.assign(this, this._parseFixedFee(customFee)); } this.collector_account_id = EntityId.fromEncodedId(customFee.collectorAccountId, true).toString(); @@ -61,8 +69,23 @@ class CustomFeeViewModel { return !!this.amount; } + isFixedFee() { + return this.amount.denominator === undefined; + } + isFractionalFee() { - return !!this.amount.numerator; + return this.amount.denominator !== undefined && this.minimum !== undefined; + } + + isRoyaltyFee() { + return this.amount.denominator !== undefined && this.minimum === undefined; + } + + _parseFixedFee(customFee) { + return { + amount: customFee.amount, + denominating_token_id: EntityId.fromEncodedId(customFee.denominatingTokenId, true).toString(), + }; } } diff --git a/pom.xml b/pom.xml index b6c9e1ec7d5..7bea01edbee 100644 --- a/pom.xml +++ b/pom.xml @@ -65,7 +65,7 @@ 2.11.0.RELEASE 1.39.0 30.1.1-jre - 0.16.0 + 0.17.1 2.0.11 0.8.7 11