diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/Balance.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/Balance.java new file mode 100644 index 0000000..acb7666 --- /dev/null +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/Balance.java @@ -0,0 +1,12 @@ +package com.openelements.hiero.base.data; + +import com.hedera.hashgraph.sdk.AccountId; +import org.jspecify.annotations.NonNull; + +import java.util.Objects; + +public record Balance(@NonNull AccountId accountId, long balance, long decimals) { + public Balance { + Objects.requireNonNull(accountId, "accountId must not be null"); + } +} diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/CustomFee.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/CustomFee.java new file mode 100644 index 0000000..eb9202a --- /dev/null +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/CustomFee.java @@ -0,0 +1,6 @@ +package com.openelements.hiero.base.data; + +import java.util.List; + +public record CustomFee(List fixedFees, List fractionalFees, List royaltyFees) { +} diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/FixedFee.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/FixedFee.java new file mode 100644 index 0000000..821006b --- /dev/null +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/FixedFee.java @@ -0,0 +1,8 @@ +package com.openelements.hiero.base.data; + +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.TokenId; +import org.jspecify.annotations.Nullable; + +public record FixedFee(long amount, @Nullable AccountId collectorAccountId, @Nullable TokenId denominatingTokenId) { +} diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/FractionalFee.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/FractionalFee.java new file mode 100644 index 0000000..0f26816 --- /dev/null +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/FractionalFee.java @@ -0,0 +1,13 @@ +package com.openelements.hiero.base.data; + +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.TokenId; +import org.jspecify.annotations.Nullable; + +public record FractionalFee( + long numeratorAmount, + long denominatorAmount, + @Nullable AccountId collectorAccountId, + @Nullable TokenId denominatingTokenId +) { +} diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/RoyaltyFee.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/RoyaltyFee.java new file mode 100644 index 0000000..450f4a8 --- /dev/null +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/RoyaltyFee.java @@ -0,0 +1,14 @@ +package com.openelements.hiero.base.data; + +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.TokenId; +import org.jspecify.annotations.Nullable; + +public record RoyaltyFee( + long numeratorAmount, + long denominatorAmount, + long fallbackFeeAmount, + @Nullable AccountId collectorAccountId, + @Nullable TokenId denominatingTokenId +) { +} diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/Token.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/Token.java new file mode 100644 index 0000000..8ab03a1 --- /dev/null +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/Token.java @@ -0,0 +1,24 @@ +package com.openelements.hiero.base.data; + +import com.hedera.hashgraph.sdk.TokenId; +import com.hedera.hashgraph.sdk.TokenType; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +public record Token( + long decimals, + byte[] metadata, + @NonNull String name, + @NonNull String symbol, + @Nullable TokenId tokenId, + @NonNull TokenType type +) { + public Token { + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(name, "name must not be null"); + Objects.requireNonNull(symbol, "symbol must not be null"); + Objects.requireNonNull(metadata, "metadata must not be null"); + } +} diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/TokenInfo.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/TokenInfo.java new file mode 100644 index 0000000..3b34ab9 --- /dev/null +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/data/TokenInfo.java @@ -0,0 +1,47 @@ +package com.openelements.hiero.base.data; + +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.TokenId; +import com.hedera.hashgraph.sdk.TokenSupplyType; +import com.hedera.hashgraph.sdk.TokenType; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.Objects; + +public record TokenInfo( + @NonNull TokenId tokenId, + @NonNull TokenType type, + @NonNull String name, + @NonNull String symbol, + @Nullable String memo, + long decimals, + byte[] metadata, + @NonNull Instant createdTimestamp, + @NonNull Instant modifiedTimestamp, + @Nullable Instant expiryTimestamp, + @NonNull TokenSupplyType supplyType, + @NonNull String initialSupply, + @NonNull String totalSupply, + @NonNull String maxSupply, + @NonNull AccountId treasuryAccountId, + boolean deleted, + @NonNull CustomFee customFees +) { + public TokenInfo { + Objects.requireNonNull(tokenId, "tokenId must not be null"); + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(name, "name must not be null"); + Objects.requireNonNull(symbol, "symbol must not be null"); + Objects.requireNonNull(metadata, "metadata must not be null"); + Objects.requireNonNull(createdTimestamp, "createdTimestamp must not be null"); + Objects.requireNonNull(modifiedTimestamp, "modifiedTimestamp must not be null"); + Objects.requireNonNull(supplyType, "supplyType must not be null"); + Objects.requireNonNull(initialSupply, "initialSupply must not be null"); + Objects.requireNonNull(totalSupply, "totalSupply must not be null"); + Objects.requireNonNull(maxSupply, "maxSupply must not be null"); + Objects.requireNonNull(treasuryAccountId, "treasuryAccountId must not be null"); + Objects.requireNonNull(customFees, "customFees must not be null"); + } +} \ No newline at end of file diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/AbstractMirrorNodeClient.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/AbstractMirrorNodeClient.java index cf54d0f..de3507c 100644 --- a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/AbstractMirrorNodeClient.java +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/AbstractMirrorNodeClient.java @@ -10,6 +10,7 @@ import com.openelements.hiero.base.data.NetworkSupplies; import com.openelements.hiero.base.data.Nft; import com.openelements.hiero.base.data.NftMetadata; +import com.openelements.hiero.base.data.TokenInfo; import com.openelements.hiero.base.mirrornode.MirrorNodeClient; import java.util.List; import java.util.Objects; @@ -68,6 +69,12 @@ final Optional queryNetworkSupplies() throws HieroException { return getJsonConverter().toNetworkSupplies(json); } + @NonNull + public final Optional queryTokenById(@NonNull TokenId tokenId) throws HieroException { + final JSON json = getRestClient().queryTokenById(tokenId); + return getJsonConverter().toTokenInfo(json); + } + @Override public @NonNull Optional getNftMetadata(TokenId tokenId) throws HieroException { throw new UnsupportedOperationException("Not yet implemented"); diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/MirrorNodeJsonConverter.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/MirrorNodeJsonConverter.java index 98ba515..235652a 100644 --- a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/MirrorNodeJsonConverter.java +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/MirrorNodeJsonConverter.java @@ -7,6 +7,10 @@ import com.openelements.hiero.base.data.NetworkSupplies; import com.openelements.hiero.base.data.Nft; import com.openelements.hiero.base.data.TransactionInfo; +import com.openelements.hiero.base.data.Token; +import com.openelements.hiero.base.data.TokenInfo; +import com.openelements.hiero.base.data.Balance; + import java.util.List; import java.util.Optional; import org.jspecify.annotations.NonNull; @@ -36,4 +40,9 @@ public interface MirrorNodeJsonConverter { List toNfts(@NonNull JSON json); + Optional toTokenInfo(JSON json); + + List toBalances(JSON node); + + List toTokens(JSON node); } diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/MirrorNodeRestClient.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/MirrorNodeRestClient.java index d8155d0..ffa0860 100644 --- a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/MirrorNodeRestClient.java +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/MirrorNodeRestClient.java @@ -50,6 +50,11 @@ default JSON queryNetworkSupplies() throws HieroException { return doGetCall("/api/v1/network/supply"); } + @NonNull + default JSON queryTokenById(TokenId tokenId) throws HieroException { + return doGetCall("/api/v1/tokens/" + tokenId); + } + @NonNull JSON doGetCall(@NonNull String path) throws HieroException; } diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/TokenRepositoryImpl.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/TokenRepositoryImpl.java new file mode 100644 index 0000000..9ef5afe --- /dev/null +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/implementation/TokenRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.openelements.hiero.base.implementation; + +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.TokenId; +import com.openelements.hiero.base.HieroException; +import com.openelements.hiero.base.data.Balance; +import com.openelements.hiero.base.data.Page; +import com.openelements.hiero.base.data.Token; +import com.openelements.hiero.base.data.TokenInfo; +import com.openelements.hiero.base.mirrornode.MirrorNodeClient; +import com.openelements.hiero.base.mirrornode.TokenRepository; +import org.jspecify.annotations.NonNull; + +import java.util.Objects; +import java.util.Optional; + +public class TokenRepositoryImpl implements TokenRepository { + private final MirrorNodeClient mirrorNodeClient; + + public TokenRepositoryImpl(@NonNull final MirrorNodeClient mirrorNodeClient) { + this.mirrorNodeClient = Objects.requireNonNull(mirrorNodeClient, "mirrorNodeClient must not be null"); + } + + @Override + public Page findByAccount(@NonNull AccountId accountId) throws HieroException { + return mirrorNodeClient.queryTokensForAccount(accountId); + } + + @Override + public Optional findById(@NonNull TokenId tokenId) throws HieroException { + return mirrorNodeClient.queryTokenById(tokenId); + } + + @Override + public Page getBalances(@NonNull TokenId tokenId) throws HieroException { + return mirrorNodeClient.queryTokenBalances(tokenId); + } + + @Override + public Page getBalancesForAccount(@NonNull TokenId tokenId, @NonNull AccountId accountId) throws HieroException { + return mirrorNodeClient.queryTokenBalancesForAccount(tokenId, accountId); + } +} diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/mirrornode/MirrorNodeClient.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/mirrornode/MirrorNodeClient.java index 9877121..0624e29 100644 --- a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/mirrornode/MirrorNodeClient.java +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/mirrornode/MirrorNodeClient.java @@ -12,6 +12,9 @@ import com.openelements.hiero.base.data.NftMetadata; import com.openelements.hiero.base.data.Page; import com.openelements.hiero.base.data.TransactionInfo; +import com.openelements.hiero.base.data.TokenInfo; +import com.openelements.hiero.base.data.Balance; +import com.openelements.hiero.base.data.Token; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -237,6 +240,99 @@ default Optional queryAccount(@NonNull String accountId) throws Hie @NonNull Optional queryNetworkSupplies() throws HieroException; + /** + * Return Tokens associated with given accountId. + * + * @param accountId id of the account + * @return Optional of TokenInfo + * @throws HieroException if the search fails + */ + Page queryTokensForAccount(@NonNull AccountId accountId) throws HieroException; + + /** + * Return Tokens associated with given accountId. + * + * @param accountId id of the account + * @return Optional of TokenInfo + * @throws HieroException if the search fails + */ + default Page queryTokensForAccount(@NonNull String accountId) throws HieroException { + Objects.requireNonNull(accountId, "accountId must not be null"); + return queryTokensForAccount(AccountId.fromString(accountId)); + } + + /** + * Return Token Info for given tokenID. + * + * @param tokenId id of the token + * @return Optional of Token + * @throws HieroException if the search fails + */ + @NonNull + Optional queryTokenById(@NonNull TokenId tokenId) throws HieroException; + + /** + * Return Token Info for given tokenID. + * + * @param tokenId id of the token + * @return Optional of Token + * @throws HieroException if the search fails + */ + @NonNull + default Optional queryTokenById(@NonNull String tokenId) throws HieroException { + Objects.requireNonNull(tokenId, "tokenId must not be null"); + return queryTokenById(TokenId.fromString(tokenId)); + } + + /** + * Return Balance Info for given tokenID. + * + * @param tokenId id of the token + * @return Page of Balance + * @throws HieroException if the search fails + */ + @NonNull + Page queryTokenBalances(@NonNull TokenId tokenId) throws HieroException; + + /** + * Return Balance Info for given tokenID. + * + * @param tokenId id of the token + * @return Page of Balance + * @throws HieroException if the search fails + */ + @NonNull + default Page queryTokenBalances(@NonNull String tokenId) throws HieroException { + Objects.requireNonNull(tokenId, "tokenId must not be null"); + return queryTokenBalances(TokenId.fromString(tokenId)); + } + + /** + * Return Balance Info for given tokenID and accountId. + * + * @param tokenId id of the token + * @param accountId id of the account + * @return Page of Balance + * @throws HieroException if the search fails + */ + @NonNull + Page queryTokenBalancesForAccount(@NonNull TokenId tokenId, @NonNull AccountId accountId) throws HieroException; + + /** + * Return Balance Info for given tokenID and accountId. + * + * @param tokenId id of the token + * @param accountId id of the account + * @return Page of Balance + * @throws HieroException if the search fails + */ + @NonNull + default Page queryTokenBalancesForAccount(@NonNull String tokenId, @NonNull String accountId) throws HieroException { + Objects.requireNonNull(tokenId, "tokenId must not be null"); + Objects.requireNonNull(accountId, "accountId must not be null"); + return queryTokenBalancesForAccount(TokenId.fromString(tokenId), AccountId.fromString(accountId)); + } + @NonNull Optional getNftMetadata(TokenId tokenId) throws HieroException; diff --git a/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/mirrornode/TokenRepository.java b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/mirrornode/TokenRepository.java new file mode 100644 index 0000000..0ef3080 --- /dev/null +++ b/hiero-enterprise-base/src/main/java/com/openelements/hiero/base/mirrornode/TokenRepository.java @@ -0,0 +1,105 @@ +package com.openelements.hiero.base.mirrornode; + +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.TokenId; +import com.openelements.hiero.base.HieroException; +import com.openelements.hiero.base.data.Balance; +import com.openelements.hiero.base.data.Page; +import com.openelements.hiero.base.data.Token; +import com.openelements.hiero.base.data.TokenInfo; +import org.jspecify.annotations.NonNull; + +import java.util.Objects; +import java.util.Optional; + +/** + * Interface for interacting with a Hiero network. This interface provides methods for searching Tokens. + */ +public interface TokenRepository { + /** + * Return Tokens associated with given accountId. + * + * @param accountId id of the account + * @return Optional of TokenInfo + * @throws HieroException if the search fails + */ + Page findByAccount(@NonNull AccountId accountId) throws HieroException; + + /** + * Return Tokens associated with given accountId. + * + * @param accountId id of the account + * @return Optional of TokenInfo + * @throws HieroException if the search fails + */ + default Page findByAccount(@NonNull String accountId) throws HieroException { + Objects.requireNonNull(accountId, "accountId must not be null"); + return findByAccount(AccountId.fromString(accountId)); + } + + /** + * Return Token Info for given tokenID. + * + * @param tokenId id of the token + * @return Optional of TokenInfo + * @throws HieroException if the search fails + */ + Optional findById(@NonNull TokenId tokenId) throws HieroException; + + /** + * Return Token Info for given tokenID. + * + * @param tokenId id of the token + * @return Optional of TokenInfo + * @throws HieroException if the search fails + */ + default Optional findById(@NonNull String tokenId) throws HieroException{ + Objects.requireNonNull(tokenId, "tokenId must not be null"); + return findById(TokenId.fromString(tokenId)); + } + + /** + * Return Balance Info for given tokenID. + * + * @param tokenId id of the token + * @return Page of Balance + * @throws HieroException if the search fails + */ + Page getBalances(@NonNull TokenId tokenId) throws HieroException; + + /** + * Return Balance Info for given tokenID. + * + * @param tokenId id of the token + * @return Page of Balance + * @throws HieroException if the search fails + */ + default Page getBalances(@NonNull String tokenId) throws HieroException { + Objects.requireNonNull(tokenId, "tokenId must not be null"); + return getBalances(TokenId.fromString(tokenId)); + } + + /** + * Return Balance Info for given tokenID and accountId. + * + * @param tokenId id of the token + * @param accountId id of the account + * @return Page of Balance + * @throws HieroException if the search fails + */ + Page getBalancesForAccount(@NonNull TokenId tokenId, @NonNull AccountId accountId) throws HieroException; + + /** + * Return Balance Info for given tokenID and accountId. + * + * @param tokenId id of the token + * @param accountId id of the account + * @return Page of Balance + * @throws HieroException if the search fails + */ + default Page getBalancesForAccount(@NonNull String tokenId, @NonNull String accountId) throws HieroException { + Objects.requireNonNull(tokenId, "tokenId must not be null"); + Objects.requireNonNull(accountId, "accountId must not be null"); + return getBalancesForAccount(TokenId.fromString(tokenId), AccountId.fromString(accountId)); + } +} diff --git a/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/ClientProvider.java b/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/ClientProvider.java index 0a2459d..1f2ff12 100644 --- a/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/ClientProvider.java +++ b/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/ClientProvider.java @@ -4,6 +4,7 @@ import com.openelements.hiero.base.FileClient; import com.openelements.hiero.base.HieroContext; import com.openelements.hiero.base.NftClient; +import com.openelements.hiero.base.FungibleTokenClient; import com.openelements.hiero.base.SmartContractClient; import com.openelements.hiero.base.config.HieroConfig; import com.openelements.hiero.base.implementation.AccountClientImpl; @@ -11,10 +12,10 @@ import com.openelements.hiero.base.implementation.FileClientImpl; import com.openelements.hiero.base.implementation.HieroNetwork; import com.openelements.hiero.base.implementation.NetworkRepositoryImpl; -import com.openelements.hiero.base.implementation.NetworkRepositoryImpl; import com.openelements.hiero.base.implementation.NftRepositoryImpl; import com.openelements.hiero.base.implementation.NftClientImpl; -import com.openelements.hiero.base.implementation.NftRepositoryImpl; +import com.openelements.hiero.base.implementation.FungibleTokenClientImpl; +import com.openelements.hiero.base.implementation.TokenRepositoryImpl; import com.openelements.hiero.base.implementation.ProtocolLayerClientImpl; import com.openelements.hiero.base.implementation.SmartContractClientImpl; import com.openelements.hiero.base.implementation.TransactionRepositoryImpl; @@ -23,6 +24,7 @@ import com.openelements.hiero.base.mirrornode.NetworkRepository; import com.openelements.hiero.base.mirrornode.NftRepository; import com.openelements.hiero.base.mirrornode.TransactionRepository; +import com.openelements.hiero.base.mirrornode.TokenRepository; import com.openelements.hiero.base.protocol.ProtocolLayerClient; import com.openelements.hiero.base.verification.ContractVerificationClient; import com.openelements.hiero.microprofile.implementation.ContractVerificationClientImpl; @@ -30,7 +32,6 @@ import com.openelements.hiero.microprofile.implementation.MirrorNodeClientImpl; import com.openelements.hiero.microprofile.implementation.MirrorNodeJsonConverterImpl; import com.openelements.hiero.microprofile.implementation.MirrorNodeRestClientImpl; -import com.openelements.hiero.microprofile.implementation.MirrorNodeClientImpl; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; @@ -91,6 +92,14 @@ NftClient createNftClient(@NonNull final ProtocolLayerClient protocolLayerClient return new NftClientImpl(protocolLayerClient, hieroContext.getOperatorAccount()); } + @NonNull + @Produces + @ApplicationScoped + FungibleTokenClient createFungibleTokenClient(@NonNull final ProtocolLayerClient protocolLayerClient, + @NonNull final HieroContext hieroContext) { + return new FungibleTokenClientImpl(protocolLayerClient, hieroContext.getOperatorAccount()); + } + @NonNull @Produces @ApplicationScoped @@ -148,4 +157,11 @@ NftRepository createNftRepository(@NonNull final MirrorNodeClient mirrorNodeClie TransactionRepository createTransactionRepository(@NonNull final MirrorNodeClient mirrorNodeClient) { return new TransactionRepositoryImpl(mirrorNodeClient); } + + @NonNull + @Produces + @ApplicationScoped + TokenRepository createTokenRepository(@NonNull final MirrorNodeClient mirrorNodeClient) { + return new TokenRepositoryImpl(mirrorNodeClient); + } } diff --git a/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeClientImpl.java b/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeClientImpl.java index 97c889a..f257d6b 100644 --- a/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeClientImpl.java +++ b/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeClientImpl.java @@ -7,21 +7,25 @@ import com.openelements.hiero.base.data.NftMetadata; import com.openelements.hiero.base.data.Page; import com.openelements.hiero.base.data.TransactionInfo; +import com.openelements.hiero.base.data.Token; +import com.openelements.hiero.base.data.Balance; import com.openelements.hiero.base.implementation.AbstractMirrorNodeClient; import com.openelements.hiero.base.implementation.MirrorNodeJsonConverter; import com.openelements.hiero.base.implementation.MirrorNodeRestClient; import jakarta.json.JsonObject; +import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.function.Function; import org.jspecify.annotations.NonNull; public class MirrorNodeClientImpl extends AbstractMirrorNodeClient { - private final MirrorNodeRestClient restClient; + private final MirrorNodeRestClientImpl restClient; private final MirrorNodeJsonConverter jsonConverter; - public MirrorNodeClientImpl(MirrorNodeRestClient restClient, + public MirrorNodeClientImpl(MirrorNodeRestClientImpl restClient, MirrorNodeJsonConverter jsonConverter) { this.restClient = Objects.requireNonNull(restClient, "restClient must not be null"); this.jsonConverter = Objects.requireNonNull(jsonConverter, "jsonConverter must not be null"); @@ -64,6 +68,32 @@ public MirrorNodeClientImpl(MirrorNodeRestClient restClient, throw new RuntimeException("Not implemented"); } + @Override + public Page queryTokensForAccount(@NonNull AccountId accountId) throws HieroException { + Objects.requireNonNull(accountId, "accountId must not be null"); + final String path = "/api/v1/tokens?account.id=" + accountId; + final Function> dataExtractionFunction = node -> jsonConverter.toTokens(node); + return new RestBasedPage<>(restClient.getTarget(), dataExtractionFunction, path); + } + + @Override + public @NonNull Page queryTokenBalances(@NonNull TokenId tokenId) throws HieroException { + Objects.requireNonNull(tokenId, "tokenId must not be null"); + final String path = "/api/v1/tokens/" + tokenId + "/balances"; + final Function> dataExtractionFunction = node -> jsonConverter.toBalances(node); + return new RestBasedPage<>(restClient.getTarget(), dataExtractionFunction, path); + } + + @Override + public @NonNull Page queryTokenBalancesForAccount(@NonNull TokenId tokenId, @NonNull AccountId accountId) throws HieroException { + Objects.requireNonNull(tokenId, "tokenId must not be null"); + Objects.requireNonNull(accountId, "accountId must not be null"); + final String path = "/api/v1/tokens/" + tokenId + "/balances?account.id=" + accountId; + final Function> dataExtractionFunction = node -> jsonConverter.toBalances(node); + return new RestBasedPage<>(restClient.getTarget(), dataExtractionFunction, path); + } + + @Override public @NonNull Page findNftTypesByOwner(AccountId ownerId) { throw new RuntimeException("Not implemented"); diff --git a/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeJsonConverterImpl.java b/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeJsonConverterImpl.java index e6c67c9..aad34d3 100644 --- a/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeJsonConverterImpl.java +++ b/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeJsonConverterImpl.java @@ -2,6 +2,9 @@ import com.hedera.hashgraph.sdk.AccountId; import com.hedera.hashgraph.sdk.TokenId; +import com.hedera.hashgraph.sdk.TokenId; +import com.hedera.hashgraph.sdk.TokenSupplyType; +import com.hedera.hashgraph.sdk.TokenType; import com.openelements.hiero.base.data.AccountInfo; import com.openelements.hiero.base.data.ExchangeRate; import com.openelements.hiero.base.data.ExchangeRates; @@ -10,12 +13,22 @@ import com.openelements.hiero.base.data.NetworkSupplies; import com.openelements.hiero.base.data.Nft; import com.openelements.hiero.base.data.TransactionInfo; +import com.openelements.hiero.base.data.Token; +import com.openelements.hiero.base.data.TokenInfo; +import com.openelements.hiero.base.data.Balance; +import com.openelements.hiero.base.data.CustomFee; +import com.openelements.hiero.base.data.FixedFee; +import com.openelements.hiero.base.data.FractionalFee; +import com.openelements.hiero.base.data.RoyaltyFee; import com.openelements.hiero.base.implementation.MirrorNodeJsonConverter; import jakarta.json.JsonArray; import jakarta.json.JsonObject; +import java.time.Instant; +import java.math.BigInteger; import java.time.Instant; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Spliterator; import java.util.Spliterators; @@ -193,5 +206,208 @@ private Stream jsonArrayToStream(@NonNull final JsonArray jsonObject) return StreamSupport .stream(Spliterators.spliteratorUnknownSize(jsonObject.iterator(), Spliterator.ORDERED), false); } + + @Override + public List toTokens(JsonObject jsonObject) { + Objects.requireNonNull(jsonObject, "jsonObject must not be null"); + if (!jsonObject.containsKey("tokens")) { + return List.of(); + } + final JsonArray tokens = jsonObject.getJsonArray("tokens"); + if (tokens == null) { + throw new IllegalArgumentException("Tokens node is not an array"); + } + Spliterator spliterator = Spliterators.spliteratorUnknownSize(tokens.iterator(), + Spliterator.ORDERED); + return StreamSupport.stream(spliterator, false) + .map(n -> toToken(n.asJsonObject())) + .filter(optional -> optional.isPresent()) + .map(optional -> optional.get()) + .toList(); + } + + private Optional toToken(JsonObject jsonObject) { + Objects.requireNonNull(jsonObject, "jsonObject must not be null"); + if (jsonObject.isEmpty()) { + return Optional.empty(); + } + + try { + final byte[] metadata = jsonObject.getString("metadata").getBytes(); + final String name = jsonObject.getString("name"); + final String symbol = jsonObject.getString("symbol"); + final long decimals = jsonObject.getJsonNumber("decimals").longValue(); + final TokenType type = TokenType.valueOf(jsonObject.getString("type")); + final TokenId tokenId = jsonObject.isNull("token_id")? null : TokenId.fromString(jsonObject.getString("token_id")); + + return Optional.of(new Token(decimals, metadata, name, symbol, tokenId, type)); + } catch (final Exception e) { + throw new IllegalStateException("Can not parse JSON: " + jsonObject, e); + } + } + + @Override + public Optional toTokenInfo(JsonObject jsonObject) { + Objects.requireNonNull(jsonObject, "jsonObject must not be null"); + if (jsonObject.isEmpty()) { + return Optional.empty(); + } + + try { + final TokenId tokenId = TokenId.fromString(jsonObject.getString("token_id")); + final TokenType type = TokenType.valueOf(jsonObject.getString("type")); + final String name = jsonObject.getString("name"); + final String symbol = jsonObject.getString("symbol"); + final String memo = jsonObject.getString("memo"); + final long decimals = Long.parseLong(jsonObject.getString("decimals")); + final byte[] metadata = jsonObject.getString("metadata").getBytes(); + final Instant createdTimeStamp = Instant.ofEpochSecond((long) Double.parseDouble(jsonObject.getString("created_timestamp"))); + final Instant modifiedTimestamp = Instant.ofEpochSecond((long) Double.parseDouble(jsonObject.getString("modified_timestamp"))); + final TokenSupplyType supplyType = TokenSupplyType.valueOf(jsonObject.getString("supply_type")); + final String totalSupply = jsonObject.getString("total_supply"); + final String initialSupply = jsonObject.getString("initial_supply"); + final AccountId treasuryAccountId = AccountId.fromString(jsonObject.getString("treasury_account_id")); + final boolean deleted = jsonObject.getBoolean("deleted"); + final String maxSupply = jsonObject.getString("max_supply"); + + final Instant expiryTimestamp; + if (!jsonObject.isNull("expiry_timestamp")) { + BigInteger nanoseconds = new BigInteger(String.valueOf(jsonObject.getJsonNumber("expiry_timestamp"))); + BigInteger expirySeconds = nanoseconds.divide(BigInteger.valueOf(1_000_000_000)); + expiryTimestamp = Instant.ofEpochSecond(expirySeconds.longValue()); + } else { + expiryTimestamp = null; + } + + final CustomFee customFees = getCustomFee(jsonObject.get("custom_fees").asJsonObject()); + + return Optional.of(new TokenInfo( + tokenId, + type, + name, + symbol, + memo, + decimals, + metadata, + createdTimeStamp, + modifiedTimestamp, + expiryTimestamp, + supplyType, + initialSupply, + totalSupply, + maxSupply, + treasuryAccountId, + deleted, + customFees + )); + } catch (final Exception e) { + throw new IllegalStateException("Can not parse JSON: " + jsonObject, e); + } + } + + private CustomFee getCustomFee(JsonObject object) { + List fractionalFees = null; + List fixedFees = null; + List royaltyFees = null; + + if (object.containsKey("fixed_fees")) { + JsonArray fixedFeeArray = object.get("fixed_fees").asJsonArray(); + if (fixedFeeArray == null) { + throw new IllegalArgumentException("FixedFeesArray is not an array: " + fixedFeeArray); + } + fixedFees = StreamSupport.stream(Spliterators.spliteratorUnknownSize(fixedFeeArray.iterator(), + Spliterator.ORDERED), false) + .map(n -> { + JsonObject obj = n.asJsonObject(); + final long amount = obj.getJsonNumber("amount").longValue(); + final AccountId accountId = obj.get("collector_account_id").asJsonObject() == null? + null : AccountId.fromString(obj.getString("collector_account_id")); + final TokenId tokenId = obj.get("denominating_token_id").asJsonObject() == null? + null : TokenId.fromString(obj.getString("denominating_token_id")); + return new FixedFee(amount, accountId, tokenId); + }) + .toList(); + } + + if (object.containsKey("fractional_fees")) { + JsonArray fractionalFeeArray = object.get("fractional_fees").asJsonArray(); + if (fractionalFeeArray == null) { + throw new IllegalArgumentException("FractionalFeeArray is not an array: " + fractionalFeeArray); + } + fractionalFees = StreamSupport.stream(Spliterators.spliteratorUnknownSize(fractionalFeeArray.iterator(), + Spliterator.ORDERED), false) + .map(n ->{ + JsonObject obj = n.asJsonObject(); + final long numeratorAmount = obj.get("amount").asJsonObject().getJsonNumber("numerator").longValue(); + final long denominatorAmount = obj.get("amount").asJsonObject().getJsonNumber("denominator").longValue(); + final AccountId accountId = obj.get("collector_account_id").asJsonObject() == null? + null : AccountId.fromString(obj.getString("collector_account_id")); + final TokenId tokenId = obj.get("denominating_token_id").asJsonObject() == null? + null : TokenId.fromString(obj.getString("denominating_token_id")); + return new FractionalFee(numeratorAmount, denominatorAmount, accountId, tokenId); + }) + .toList(); + } + + if (object.containsKey("royalty_fees")) { + JsonArray royaltyFeeArray = object.get("royalty_fees").asJsonArray(); + if (royaltyFeeArray == null) { + throw new IllegalArgumentException("RoyaltyFeeArray is not an array: " + royaltyFeeArray); + } + royaltyFees = StreamSupport.stream(Spliterators.spliteratorUnknownSize(royaltyFeeArray.iterator(), + Spliterator.ORDERED), false) + .map(n ->{ + JsonObject obj = n.asJsonObject(); + final long numeratorAmount = obj.get("amount").asJsonObject().getJsonNumber("numerator").longValue(); + final long denominatorAmount = obj.get("amount").asJsonObject().getJsonNumber("denominator").longValue(); + final long fallbackFeeAmount = obj.get("fallback_fee").asJsonObject().getJsonNumber("amount").longValue(); + final AccountId accountId = obj.get("collector_account_id").asJsonObject() == null? + null : AccountId.fromString(obj.getString("collector_account_id")); + final TokenId tokenId = obj.get("fallback_fee").asJsonObject().get("denominating_token_id").asJsonObject() == null? + null : TokenId.fromString(obj.get("fallback_fee").asJsonObject().getString("denominating_token_id")); + return new RoyaltyFee(numeratorAmount, denominatorAmount, fallbackFeeAmount, accountId, tokenId); + }) + .toList(); + } + + return new CustomFee(fixedFees, fractionalFees, royaltyFees); + } + + @Override + public List toBalances(JsonObject jsonObject) { + Objects.requireNonNull(jsonObject, "jsonObject must not be null"); + if (!jsonObject.containsKey("balances")) { + return List.of(); + } + final JsonArray balancesArray = jsonObject.getJsonArray("balances"); + if (balancesArray == null) { + throw new IllegalArgumentException("TokenBalances array is not an array: " + balancesArray); + } + + Spliterator spliterator = Spliterators.spliteratorUnknownSize(balancesArray.iterator(), + Spliterator.ORDERED); + return StreamSupport.stream(spliterator, false) + .map(n -> toBalance(n.asJsonObject())) + .filter(optional -> optional.isPresent()) + .map(optional -> optional.get()) + .toList(); + } + + private Optional toBalance(JsonObject jsonObject) { + Objects.requireNonNull(jsonObject, "jsonObject must not be null"); + if (jsonObject.isEmpty()) { + return Optional.empty(); + } + + try { + final AccountId account = AccountId.fromString(jsonObject.getString("account")); + final long balance = jsonObject.getJsonNumber("balance").longValue(); + final long decimals = jsonObject.getJsonNumber("decimals").longValue(); + + return Optional.of(new Balance(account, balance, decimals)); + } catch (final Exception e) { + throw new IllegalStateException("Can not parse JSON: " + jsonObject, e); + } + } } diff --git a/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeRestClientImpl.java b/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeRestClientImpl.java index 24c6ee4..6b8e31c 100644 --- a/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeRestClientImpl.java +++ b/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/MirrorNodeRestClientImpl.java @@ -25,6 +25,12 @@ public MirrorNodeRestClientImpl(String target) { .path(path) .request(MediaType.APPLICATION_JSON) .get(); + + if (response.getStatus() == 404 || !response.hasEntity()) { + return JsonObject.EMPTY_JSON_OBJECT; + } return response.readEntity(JsonObject.class); } + + public String getTarget() {return target;} } diff --git a/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/RestBasedPage.java b/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/RestBasedPage.java new file mode 100644 index 0000000..bf363d3 --- /dev/null +++ b/hiero-enterprise-microprofile/src/main/java/com/openelements/hiero/microprofile/implementation/RestBasedPage.java @@ -0,0 +1,122 @@ +package com.openelements.hiero.microprofile.implementation; + +import com.openelements.hiero.base.data.Page; +import jakarta.json.JsonObject; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.jspecify.annotations.NonNull; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +public class RestBasedPage implements Page { + private final String restTarget; + private final Function> dataExtractionFunction; + private final List data; + private final String rootPath; + private final String currentPath; + private final String nextPath; + private final int number; + + public RestBasedPage(@NonNull String restTarget, @NonNull Function> dataExtractionFunction, @NonNull String path) { + this(restTarget, dataExtractionFunction, path, path, 0); + } + + public RestBasedPage(@NonNull String restTarget, + @NonNull Function> dataExtractionFunction, @NonNull String path, + @NonNull String rootPath, int number) { + this.restTarget = Objects.requireNonNull(restTarget, "restTarget must not be null"); + this.dataExtractionFunction = Objects.requireNonNull(dataExtractionFunction, "dataExtractionFunction must not be null"); + this.rootPath = Objects.requireNonNull(rootPath, "rootPath must not be null"); + this.currentPath = Objects.requireNonNull(path, "path must not be null"); + this.number = number; + + String[] pathParts = currentPath.split("\\?"); + final String requestPath = pathParts[0]; + + try { + Client client = ClientBuilder.newClient(); + WebTarget target = client.target(restTarget).path(requestPath); + if (pathParts.length > 1) { + String[] params = pathParts[1].split("&"); + for (String param : params) { + String[] p = param.split("="); + target = target.queryParam(p[0], p[1]); + } + } + Response response = target.request(MediaType.APPLICATION_JSON).get(); + + final JsonObject jsonObject = response.readEntity(JsonObject.class); + this.data = Collections.unmodifiableList(dataExtractionFunction.apply(jsonObject)); + this.nextPath = getNextPath(jsonObject); + } catch (Exception e) { + throw new IllegalStateException("Can not parse JSON: " + e); + } + } + + private String getNextPath(final JsonObject jsonObject) { + if (!jsonObject.containsKey("links")) { + return null; + } + final JsonObject linksObject = jsonObject.getJsonObject("links"); + if (linksObject == null || !linksObject.containsKey("next")) { + return null; + } + + try { + final String next; + if (!linksObject.isNull("next")) { + next = linksObject.getString("next"); + } else { + next = null; + } + return next; + } catch (Exception e) { + throw new IllegalArgumentException("Error parsing next link '" + linksObject + "'", e); + } + } + + @Override + public int getPageIndex() { + return number; + } + + @Override + public int getSize() { + return data.size(); + } + + @Override + public List getData() { + return data; + } + + @Override + public boolean hasNext() { + return nextPath != null; + } + + @Override + public Page next() { + if (nextPath == null) { + throw new IllegalStateException("No next Page"); + } + return new RestBasedPage(restTarget, dataExtractionFunction, nextPath, rootPath, number+1); + } + + @Override + public Page first() { + return new RestBasedPage(restTarget, dataExtractionFunction, rootPath); + } + + @Override + public boolean isFirst() { + return Objects.equals(rootPath, currentPath); + } +} diff --git a/hiero-enterprise-microprofile/src/test/java/com/openelements/hiero/microprofile/test/TokenRepositoryTest.java b/hiero-enterprise-microprofile/src/test/java/com/openelements/hiero/microprofile/test/TokenRepositoryTest.java new file mode 100644 index 0000000..7e67d79 --- /dev/null +++ b/hiero-enterprise-microprofile/src/test/java/com/openelements/hiero/microprofile/test/TokenRepositoryTest.java @@ -0,0 +1,212 @@ +package com.openelements.hiero.microprofile.test; + +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.TokenId; +import com.hedera.hashgraph.sdk.TokenType; +import com.openelements.hiero.base.AccountClient; +import com.openelements.hiero.base.FungibleTokenClient; +import com.openelements.hiero.base.NftClient; +import com.openelements.hiero.base.data.Token; +import com.openelements.hiero.base.data.TokenInfo; +import com.openelements.hiero.base.data.Balance; +import com.openelements.hiero.base.data.Account; +import com.openelements.hiero.base.data.Page; +import com.openelements.hiero.base.mirrornode.TokenRepository; +import com.openelements.hiero.microprofile.ClientProvider; +import io.helidon.microprofile.tests.junit5.AddBean; +import io.helidon.microprofile.tests.junit5.Configuration; +import io.helidon.microprofile.tests.junit5.HelidonTest; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.spi.ConfigProviderResolver; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +@HelidonTest +@AddBean(ClientProvider.class) +@Configuration(useExisting = true) +public class TokenRepositoryTest { + @BeforeAll + static void setup() { + final Config build = ConfigProviderResolver.instance() + .getBuilder().withSources(new TestConfigSource()).build(); + ConfigProviderResolver.instance().registerConfig(build, Thread.currentThread().getContextClassLoader()); + } + + @Inject + private NftClient nftClient; + + @Inject + private FungibleTokenClient tokenClient; + + @Inject + private TokenRepository tokenRepository; + + @Inject + private AccountClient accountClient; + + @Test + void testNullParam() { + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.findByAccount((String) null)); + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.findById((String)null)); + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.getBalances((String)null)); + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.getBalancesForAccount(null, "1.2.3")); + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.getBalancesForAccount("1.2.3", null)); + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.getBalancesForAccount((String)null, null)); + } + + @Test + void testQueryTokenForAccount() throws Exception { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final Account account = accountClient.createAccount(); + final AccountId newOwner = account.accountId(); + final PrivateKey privateKey = account.privateKey(); + + final TokenId tokenId = tokenClient.createToken(name, symbol); + tokenClient.associateToken(tokenId, newOwner, privateKey); + //TODO: fix sleep + Thread.sleep(10_000); + + // when + final Page tokens = tokenRepository.findByAccount(newOwner); + + // then + Assertions.assertNotNull(tokens); + Assertions.assertTrue(!tokens.getData().isEmpty()); + } + + @Test + void testQueryTokenForAccountReturnZeroResult() throws Exception { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final Account account = accountClient.createAccount(); + final AccountId newOwner = account.accountId(); + + final TokenId tokenId = tokenClient.createToken(name, symbol); + //TODO: fix sleep + Thread.sleep(10_000); + + // when + final Page tokens = tokenRepository.findByAccount(newOwner); + + // then + Assertions.assertNotNull(tokens); + Assertions.assertTrue(tokens.getData().isEmpty()); + } + + @Test + void testQueryTokenById() throws Exception { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final TokenId fungiTokenId = tokenClient.createToken(name, symbol); + final TokenId nftTokenId = nftClient.createNftType(name, symbol); + //TODO: fix sleep + Thread.sleep(10_000); + + // when + final Optional fungiToken = tokenRepository.findById(fungiTokenId); + final Optional nftToken = tokenRepository.findById(nftTokenId); + + + // then + Assertions.assertTrue(fungiToken.isPresent()); + Assertions.assertEquals(name, fungiToken.get().name()); + Assertions.assertEquals(symbol, fungiToken.get().symbol()); + Assertions.assertEquals(TokenType.FUNGIBLE_COMMON, fungiToken.get().type()); + + Assertions.assertTrue(nftToken.isPresent()); + Assertions.assertEquals(name, nftToken.get().name()); + Assertions.assertEquals(symbol, nftToken.get().symbol()); + Assertions.assertEquals(TokenType.NON_FUNGIBLE_UNIQUE, nftToken.get().type()); + } + + @Test + void testQueryTokenByIdReturnEmptyOptionalForInvalidId() throws Exception { + // given + final TokenId tokenId = TokenId.fromString("1.2.3"); + // when + final Optional token = tokenRepository.findById(tokenId); + // then + Assertions.assertTrue(token.isEmpty()); + } + + + @Test + void testGetTokenBalances() throws Exception { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final TokenId tokenId = tokenClient.createToken(name, symbol); + //TODO: fix sleep + Thread.sleep(10_000); + + // when + final Page balances = tokenRepository.getBalances(tokenId); + + // then + Assertions.assertNotNull(balances.getData()); + Assertions.assertFalse(balances.getData().isEmpty()); + } + + @Test + void testGetTokenBalancesReturnEmptyResultForInvalidId() throws Exception { + // given + final TokenId tokenId = TokenId.fromString("1.2.3"); + // when + final Page balances = tokenRepository.getBalances(tokenId); + // then + Assertions.assertEquals(0, balances.getData().size()); + } + + + @Test + void testGetTokenBalancesForAccount() throws Exception { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final Account account = accountClient.createAccount(); + final AccountId newOwner = account.accountId(); + final PrivateKey newPrivateKey = account.privateKey(); + + final TokenId tokenId = tokenClient.createToken(name, symbol); + tokenClient.associateToken(tokenId, newOwner, newPrivateKey); + //TODO: fix sleep + Thread.sleep(10_000); + + // when + final Page balances = tokenRepository.getBalancesForAccount(tokenId, newOwner); + + // then + Assertions.assertNotNull(balances.getData()); + Assertions.assertFalse(balances.getData().isEmpty()); + } + + @Test + void testGetTokenBalancesForAccountReturnZeroResult() throws Exception { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final Account account = accountClient.createAccount(); + final AccountId newOwner = account.accountId(); + + final TokenId tokenId = tokenClient.createToken(name, symbol); + //TODO: fix sleep + Thread.sleep(10_000); + + // when + final Page balances = tokenRepository.getBalancesForAccount(tokenId, newOwner); + + // then + Assertions.assertNotNull(balances.getData()); + Assertions.assertTrue(balances.getData().isEmpty()); + } +} + diff --git a/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/HieroAutoConfiguration.java b/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/HieroAutoConfiguration.java index 1e6a818..8ea7fcb 100644 --- a/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/HieroAutoConfiguration.java +++ b/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/HieroAutoConfiguration.java @@ -17,10 +17,12 @@ import com.openelements.hiero.base.implementation.NftRepositoryImpl; import com.openelements.hiero.base.implementation.ProtocolLayerClientImpl; import com.openelements.hiero.base.implementation.SmartContractClientImpl; +import com.openelements.hiero.base.implementation.TokenRepositoryImpl; import com.openelements.hiero.base.mirrornode.AccountRepository; import com.openelements.hiero.base.mirrornode.MirrorNodeClient; import com.openelements.hiero.base.mirrornode.NetworkRepository; import com.openelements.hiero.base.mirrornode.NftRepository; +import com.openelements.hiero.base.mirrornode.TokenRepository; import com.openelements.hiero.base.protocol.ProtocolLayerClient; import com.openelements.hiero.base.verification.ContractVerificationClient; import java.net.URI; @@ -148,6 +150,13 @@ NetworkRepository networkRepository(final MirrorNodeClient mirrorNodeClient) { return new NetworkRepositoryImpl(mirrorNodeClient); } + @Bean + @ConditionalOnProperty(prefix = "spring.hiero", name = "mirrorNodeSupported", + havingValue = "true", matchIfMissing = true) + TokenRepository tokenRepository(final MirrorNodeClient mirrorNodeClient) { + return new TokenRepositoryImpl(mirrorNodeClient); + } + @Bean ContractVerificationClient contractVerificationClient(final HieroConfig hieroConfig) { return new ContractVerificationClientImplementation(hieroConfig.getNetwork()); diff --git a/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/MirrorNodeClientImpl.java b/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/MirrorNodeClientImpl.java index 74f8897..dcb4a94 100644 --- a/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/MirrorNodeClientImpl.java +++ b/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/MirrorNodeClientImpl.java @@ -9,6 +9,8 @@ import com.openelements.hiero.base.data.NftMetadata; import com.openelements.hiero.base.data.Page; import com.openelements.hiero.base.data.TransactionInfo; +import com.openelements.hiero.base.data.Balance; +import com.openelements.hiero.base.data.Token; import com.openelements.hiero.base.implementation.AbstractMirrorNodeClient; import com.openelements.hiero.base.implementation.MirrorNodeJsonConverter; import com.openelements.hiero.base.implementation.MirrorNodeRestClient; @@ -95,6 +97,31 @@ public Optional queryTransaction(@NonNull final String transact return Optional.of(new TransactionInfo(transactionId)); } + @Override + public Page queryTokensForAccount(@NonNull AccountId accountId) throws HieroException { + Objects.requireNonNull(accountId, "accountId must not be null"); + final String path = "/api/v1/tokens?account.id=" + accountId; + final Function> dataExtractionFunction = node -> jsonConverter.toTokens(node); + return new RestBasedPage<>(objectMapper, restClient.mutate().clone(), path, dataExtractionFunction); + } + + @Override + public @NonNull Page queryTokenBalances(TokenId tokenId) throws HieroException { + Objects.requireNonNull(tokenId, "tokenId must not be null"); + final String path = "/api/v1/tokens/" + tokenId +"/balances"; + final Function> dataExtractionFunction = node -> jsonConverter.toBalances(node); + return new RestBasedPage<>(objectMapper, restClient.mutate().clone(), path, dataExtractionFunction); + } + + @Override + public @NonNull Page queryTokenBalancesForAccount(@NonNull TokenId tokenId, @NonNull AccountId accountId) throws HieroException { + Objects.requireNonNull(tokenId, "tokenId must not be null"); + Objects.requireNonNull(accountId, "accountId must not be null"); + final String path = "/api/v1/tokens/" + tokenId +"/balances?account.id=" + accountId; + final Function> dataExtractionFunction = node -> jsonConverter.toBalances(node); + return new RestBasedPage<>(objectMapper, restClient.mutate().clone(), path, dataExtractionFunction); + } + @Override public @NonNull Page findNftTypesByOwner(AccountId ownerId) { throw new UnsupportedOperationException("Not yet implemented"); diff --git a/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/MirrorNodeJsonConverterImpl.java b/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/MirrorNodeJsonConverterImpl.java index d9841f0..14da302 100644 --- a/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/MirrorNodeJsonConverterImpl.java +++ b/hiero-enterprise-spring/src/main/java/com/openelements/hiero/spring/implementation/MirrorNodeJsonConverterImpl.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.hedera.hashgraph.sdk.AccountId; import com.hedera.hashgraph.sdk.TokenId; +import com.hedera.hashgraph.sdk.TokenSupplyType; +import com.hedera.hashgraph.sdk.TokenType; import com.openelements.hiero.base.data.AccountInfo; import com.openelements.hiero.base.data.ExchangeRate; import com.openelements.hiero.base.data.ExchangeRates; @@ -11,7 +13,15 @@ import com.openelements.hiero.base.data.NetworkSupplies; import com.openelements.hiero.base.data.Nft; import com.openelements.hiero.base.data.TransactionInfo; +import com.openelements.hiero.base.data.Token; +import com.openelements.hiero.base.data.TokenInfo; +import com.openelements.hiero.base.data.Balance; +import com.openelements.hiero.base.data.CustomFee; +import com.openelements.hiero.base.data.FixedFee; +import com.openelements.hiero.base.data.FractionalFee; +import com.openelements.hiero.base.data.RoyaltyFee; import com.openelements.hiero.base.implementation.MirrorNodeJsonConverter; +import java.math.BigInteger; import java.time.Instant; import java.util.List; import java.util.Objects; @@ -205,6 +215,205 @@ public List toNfts(@NonNull JsonNode node) { .toList(); } + @Override + public Optional toTokenInfo(JsonNode node) { + Objects.requireNonNull(node, "jsonNode must not be null"); + if (node.isNull() || node.isEmpty()) { + return Optional.empty(); + } + + try { + final TokenId tokenId = TokenId.fromString(node.get("token_id").asText()); + final TokenType type = TokenType.valueOf(node.get("type").asText()); + final String name = node.get("name").asText(); + final String symbol = node.get("symbol").asText(); + final String memo = node.get("memo").asText(); + final long decimals = node.get("decimals").asLong(); + final byte[] metadata = node.get("metadata").asText().getBytes(); + final Instant createdTimeStamp = Instant.ofEpochSecond(node.get("created_timestamp").asLong()); + final Instant modifiedTimestamp = Instant.ofEpochSecond(node.get("modified_timestamp").asLong()); + final TokenSupplyType supplyType = TokenSupplyType.valueOf(node.get("supply_type").asText()); + final String totalSupply = node.get("total_supply").asText(); + final String initialSupply = node.get("initial_supply").asText(); + final AccountId treasuryAccountId = AccountId.fromString(node.get("treasury_account_id").asText()); + final boolean deleted = node.get("deleted").asBoolean(); + final String maxSupply = node.get("max_supply").asText(); + + final Instant expiryTimestamp; + if (!node.get("expiry_timestamp").isNull()) { + BigInteger nanoseconds = new BigInteger(node.get("expiry_timestamp").asText()); + BigInteger expirySeconds = nanoseconds.divide(BigInteger.valueOf(1_000_000_000)); + expiryTimestamp = Instant.ofEpochSecond(expirySeconds.longValue()); + } else { + expiryTimestamp = null; + } + + final CustomFee customFees = getCustomFee(node.get("custom_fees")); + + return Optional.of(new TokenInfo( + tokenId, + type, + name, + symbol, + memo, + decimals, + metadata, + createdTimeStamp, + modifiedTimestamp, + expiryTimestamp, + supplyType, + initialSupply, + totalSupply, + maxSupply, + treasuryAccountId, + deleted, + customFees + )); + } catch (final Exception e) { + throw new JsonParseException(node, e); + } + } + + private CustomFee getCustomFee(JsonNode node) { + List fractionalFees = null; + List fixedFees = null; + List royaltyFees = null; + + if (node.has("fixed_fees")) { + JsonNode fixedFeeNode = node.get("fixed_fees"); + if (!fixedFeeNode.isArray()) { + throw new IllegalArgumentException("FixedFees node is not an array: " + fixedFeeNode); + } + fixedFees = StreamSupport.stream(Spliterators.spliteratorUnknownSize(fixedFeeNode.iterator(), + Spliterator.ORDERED), false) + .map(n ->{ + final long amount = n.get("amount").asLong(); + final AccountId accountId = n.get("collector_account_id").isNull()? + null : AccountId.fromString(n.get("collector_account_id").asText()); + final TokenId tokenId = n.get("denominating_token_id").isNull()? + null : TokenId.fromString(n.get("denominating_token_id").asText()); + return new FixedFee(amount, accountId, tokenId); + }) + .toList(); + } + + if (node.has("fractional_fees")) { + JsonNode fractionalFeeNode = node.get("fractional_fees"); + if (!fractionalFeeNode.isArray()) { + throw new IllegalArgumentException("FractionalFee node is not an array: " + fractionalFeeNode); + } + fractionalFees = StreamSupport.stream(Spliterators.spliteratorUnknownSize(fractionalFeeNode.iterator(), + Spliterator.ORDERED), false) + .map(n ->{ + final long numeratorAmount = n.get("amount").get("numerator").asLong(); + final long denominatorAmount = n.get("amount").get("denominator").asLong(); + final AccountId accountId = n.get("collector_account_id").isNull()? + null : AccountId.fromString(n.get("collector_account_id").asText()); + final TokenId tokenId = n.get("denominating_token_id").isNull()? + null : TokenId.fromString(n.get("denominating_token_id").asText()); + return new FractionalFee(numeratorAmount, denominatorAmount, accountId, tokenId); + }) + .toList(); + } + + if (node.has("royalty_fees")) { + JsonNode royaltyFeeNode = node.get("royalty_fees"); + if (!royaltyFeeNode.isArray()) { + throw new IllegalArgumentException("RoyaltyFee node is not an array: " + royaltyFeeNode); + } + royaltyFees = StreamSupport.stream(Spliterators.spliteratorUnknownSize(royaltyFeeNode.iterator(), + Spliterator.ORDERED), false) + .map(n ->{ + final long numeratorAmount = n.get("amount").get("numerator").asLong(); + final long denominatorAmount = n.get("amount").get("denominator").asLong(); + final long fallbackFeeAmount = n.get("fallback_fee").get("amount").asLong(); + final AccountId accountId = n.get("collector_account_id").isNull()? + null : AccountId.fromString(n.get("collector_account_id").asText()); + final TokenId tokenId = n.get("fallback_fee").get("denominating_token_id").isNull()? + null : TokenId.fromString(n.get("fallback_fee").get("denominating_token_id").asText()); + return new RoyaltyFee(numeratorAmount, denominatorAmount, fallbackFeeAmount, accountId, tokenId); + }) + .toList(); + } + + return new CustomFee(fixedFees, fractionalFees, royaltyFees); + } + + @Override + public List toBalances(JsonNode node) { + Objects.requireNonNull(node, "jsonNode must not be null"); + if (!node.has("balances")) { + return List.of(); + } + final JsonNode balancesNode = node.get("balances"); + if (!balancesNode.isArray()) { + throw new IllegalArgumentException("TokenBalances node is not an array: " + balancesNode); + } + Spliterator spliterator = Spliterators.spliteratorUnknownSize(balancesNode.iterator(), + Spliterator.ORDERED); + return StreamSupport.stream(spliterator, false) + .map(n -> toBalance(n)) + .filter(optional -> optional.isPresent()) + .map(optional -> optional.get()) + .toList(); + } + + @Override + public List toTokens(JsonNode node) { + Objects.requireNonNull(node, "jsonNode must not be null"); + if (!node.has("tokens")) { + return List.of(); + } + final JsonNode tokens = node.get("tokens"); + if (!tokens.isArray()) { + throw new IllegalArgumentException("Tokens node is not an array: " + tokens); + } + Spliterator spliterator = Spliterators.spliteratorUnknownSize(tokens.iterator(), + Spliterator.ORDERED); + return StreamSupport.stream(spliterator, false) + .map(n -> toToken(n)) + .filter(optional -> optional.isPresent()) + .map(optional -> optional.get()) + .toList(); + } + + private Optional toToken(JsonNode node) { + Objects.requireNonNull(node, "jsonNode must not be null"); + if (node.isNull() || node.isEmpty()) { + return Optional.empty(); + } + + try { + final byte[] metadata = node.get("metadata").asText().getBytes(); + final String name = node.get("name").asText(); + final String symbol = node.get("symbol").asText(); + final long decimals = node.get("decimals").asLong(); + final TokenType type = TokenType.valueOf(node.get("type").asText()); + final TokenId tokenId = node.get("token_id").isNull()? null : TokenId.fromString(node.get("token_id").asText()); + + return Optional.of(new Token(decimals, metadata, name, symbol, tokenId, type)); + } catch (final Exception e) { + throw new JsonParseException(node, e); + } + } + + private Optional toBalance(JsonNode node) { + Objects.requireNonNull(node, "jsonNode must not be null"); + if (node.isNull() || node.isEmpty()) { + return Optional.empty(); + } + + try { + final AccountId account = AccountId.fromString(node.get("account").asText()); + final long balance = node.get("balance").asLong(); + final long decimals = node.get("decimals").asLong(); + + return Optional.of(new Balance(account, balance, decimals)); + } catch (final Exception e) { + throw new JsonParseException(node, e); + } + } + @NonNull private Stream jsonArrayToStream(@NonNull final JsonNode node) { Objects.requireNonNull(node, "jsonNode must not be null"); diff --git a/hiero-enterprise-spring/src/test/java/com/openelements/hiero/spring/test/TokenRepositoryTest.java b/hiero-enterprise-spring/src/test/java/com/openelements/hiero/spring/test/TokenRepositoryTest.java new file mode 100644 index 0000000..86d7c92 --- /dev/null +++ b/hiero-enterprise-spring/src/test/java/com/openelements/hiero/spring/test/TokenRepositoryTest.java @@ -0,0 +1,194 @@ +package com.openelements.hiero.spring.test; + +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.TokenId; +import com.hedera.hashgraph.sdk.TokenType; +import com.openelements.hiero.base.AccountClient; +import com.openelements.hiero.base.FungibleTokenClient; +import com.openelements.hiero.base.HieroException; +import com.openelements.hiero.base.NftClient; +import com.openelements.hiero.base.data.Token; +import com.openelements.hiero.base.data.TokenInfo; +import com.openelements.hiero.base.data.Balance; +import com.openelements.hiero.base.data.Account; +import com.openelements.hiero.base.data.Page; +import com.openelements.hiero.base.mirrornode.TokenRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Optional; + +@SpringBootTest(classes = TestConfig.class) +public class TokenRepositoryTest { + @Autowired + private TokenRepository tokenRepository; + + @Autowired + private FungibleTokenClient tokenClient; + + @Autowired + private AccountClient accountClient; + + @Autowired + private NftClient nftClient; + + @Autowired + private HieroTestUtils hieroTestUtils; + + @Test + void testNullParam() { + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.findByAccount((String) null)); + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.findById((String)null)); + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.getBalances((String)null)); + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.getBalancesForAccount(null, "1.2.3")); + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.getBalancesForAccount("1.2.3", null)); + Assertions.assertThrows(NullPointerException.class, () -> tokenRepository.getBalancesForAccount((String)null, null)); + } + + @Test + void testQueryTokenForAccount() throws HieroException { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final Account account = accountClient.createAccount(); + final AccountId newOwner = account.accountId(); + final PrivateKey privateKey = account.privateKey(); + + final TokenId tokenId = tokenClient.createToken(name, symbol); + tokenClient.associateToken(tokenId, newOwner, privateKey); + hieroTestUtils.waitForMirrorNodeRecords(); + + // when + final Page tokens = tokenRepository.findByAccount(newOwner); + + // then + Assertions.assertNotNull(tokens); + Assertions.assertTrue(!tokens.getData().isEmpty()); + } + + @Test + void testQueryTokenForAccountReturnZeroResult() throws HieroException { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final Account account = accountClient.createAccount(); + final AccountId newOwner = account.accountId(); + + final TokenId tokenId = tokenClient.createToken(name, symbol); + hieroTestUtils.waitForMirrorNodeRecords(); + + // when + final Page tokens = tokenRepository.findByAccount(newOwner); + + // then + Assertions.assertNotNull(tokens); + Assertions.assertTrue(tokens.getData().isEmpty()); + } + + @Test + void testQueryTokenById() throws HieroException { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final TokenId fungiTokenId = tokenClient.createToken(name, symbol); + final TokenId nftTokenId = nftClient.createNftType(name, symbol); + hieroTestUtils.waitForMirrorNodeRecords(); + + // when + final Optional fungiToken = tokenRepository.findById(fungiTokenId); + final Optional nftToken = tokenRepository.findById(nftTokenId); + + + // then + Assertions.assertTrue(fungiToken.isPresent()); + Assertions.assertEquals(name, fungiToken.get().name()); + Assertions.assertEquals(symbol, fungiToken.get().symbol()); + Assertions.assertEquals(TokenType.FUNGIBLE_COMMON, fungiToken.get().type()); + + Assertions.assertTrue(nftToken.isPresent()); + Assertions.assertEquals(name, nftToken.get().name()); + Assertions.assertEquals(symbol, nftToken.get().symbol()); + Assertions.assertEquals(TokenType.NON_FUNGIBLE_UNIQUE, nftToken.get().type()); + } + + @Test + void testQueryTokenByIdReturnEmptyOptionalForInvalidId() throws HieroException { + // given + final TokenId tokenId = TokenId.fromString("1.2.3"); + // when + final Optional token = tokenRepository.findById(tokenId); + // then + Assertions.assertTrue(token.isEmpty()); + } + + + @Test + void testGetTokenBalances() throws HieroException { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final TokenId tokenId = tokenClient.createToken(name, symbol); + hieroTestUtils.waitForMirrorNodeRecords(); + + // when + final Page balances = tokenRepository.getBalances(tokenId); + + // then + Assertions.assertNotNull(balances.getData()); + Assertions.assertFalse(balances.getData().isEmpty()); + } + + @Test + void testGetTokenBalancesReturnEmptyResultForInvalidId() throws HieroException { + // given + final TokenId tokenId = TokenId.fromString("1.2.3"); + // when + final Page balances = tokenRepository.getBalances(tokenId); + // then + Assertions.assertEquals(0, balances.getData().size()); + } + + + @Test + void testGetTokenBalancesForAccount() throws HieroException { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final Account account = accountClient.createAccount(); + final AccountId newOwner = account.accountId(); + final PrivateKey newPrivateKey = account.privateKey(); + + final TokenId tokenId = tokenClient.createToken(name, symbol); + tokenClient.associateToken(tokenId, newOwner, newPrivateKey); + hieroTestUtils.waitForMirrorNodeRecords(); + + // when + final Page balances = tokenRepository.getBalancesForAccount(tokenId, newOwner); + + // then + Assertions.assertNotNull(balances.getData()); + Assertions.assertFalse(balances.getData().isEmpty()); + } + + @Test + void testGetTokenBalancesForAccountReturnZeroResult() throws HieroException { + // given + final String name = "TOKEN"; + final String symbol = "TSY"; + final Account account = accountClient.createAccount(); + final AccountId newOwner = account.accountId(); + + final TokenId tokenId = tokenClient.createToken(name, symbol); + hieroTestUtils.waitForMirrorNodeRecords(); + + // when + final Page balances = tokenRepository.getBalancesForAccount(tokenId, newOwner); + + // then + Assertions.assertNotNull(balances.getData()); + Assertions.assertTrue(balances.getData().isEmpty()); + } +}