diff --git a/.env.docker-compose b/.env.docker-compose index 3f2584dbc..d536616e0 100644 --- a/.env.docker-compose +++ b/.env.docker-compose @@ -109,7 +109,7 @@ CONTINUE_PARSING_ON_ERROR=true SYNC=true ## Peer Discovery -PEER_DISCOVERY=false +PEER_DISCOVERY=true ## Token Registry # Token registry is enabled for mainnet diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapperUtil.java b/api/src/main/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapperUtil.java index e642cee3e..8be317722 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapperUtil.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapperUtil.java @@ -76,7 +76,8 @@ public List mapUtxosToCoins(List utxos, Amt adaAsset = utxo.getAmounts().stream() .filter(amt -> Constants.LOVELACE.equals(amt.getUnit())) .findFirst() - .orElseGet(() -> new Amt(null, null, Constants.ADA, BigInteger.ZERO)); + .orElseGet(() -> new Amt(null, Constants.ADA, BigInteger.ZERO)); + String coinIdentifier = "%s:%d".formatted(utxo.getTxHash(), utxo.getOutputIndex()); return Coin.builder() diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/Amt.java b/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/Amt.java index cabb6cec9..b15ecf64c 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/Amt.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/Amt.java @@ -23,14 +23,18 @@ public class Amt implements Serializable { private String unit; // subject = policyId + hex(assetName) private String policyId; - // TODO avoid using assetName field for now - // TODO ASCI in case of CIP-26 and bech32 in case of CIP-68, actually it should always be ASCII and never bech32 - @Deprecated - // consider removing - private String assetName; - private BigInteger quantity; + /** + * Returns symbol as hex + * + * unit (subject) = policyId(hex) + symbol(hex) + */ + @Nullable + public String getAssetNameAsHex() { + return getSymbolHex(); + } + /** * Returns symbol as hex * @@ -45,10 +49,4 @@ public String getSymbolHex() { return unit.replace(policyId, ""); } - @Deprecated - // TODO avoid using assetName field for now - public String getAssetName() { - return assetName; - } - } diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/TxRepositoryCustomBase.java b/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/TxRepositoryCustomBase.java index 8f12c6bbb..58983c1a9 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/TxRepositoryCustomBase.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/TxRepositoryCustomBase.java @@ -46,9 +46,9 @@ public final Condition buildCurrencyCondition(Currency currency) { !"lovelace".equalsIgnoreCase(symbol) && !"ada".equalsIgnoreCase(symbol)) { String escapedSymbol = symbol.trim().replace("\"", "\\\""); return buildPolicyIdAndSymbolCondition(escapedPolicyId, escapedSymbol); - } else { - return buildPolicyIdOnlyCondition(escapedPolicyId); } + + return buildPolicyIdOnlyCondition(escapedPolicyId); } if (symbol != null && !symbol.trim().isEmpty()) { diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/h2/TxRepositoryH2Impl.java b/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/h2/TxRepositoryH2Impl.java index 63dfb10ba..340e49d4f 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/h2/TxRepositoryH2Impl.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/h2/TxRepositoryH2Impl.java @@ -152,14 +152,17 @@ public Page searchTxnEntitiesOR(Set txHashes, /** * H2-specific currency condition builder using LIKE operator for JSON string matching. + * Searches by hex-encoded symbols in the unit field to support CIP-68 assets. */ private static class H2CurrencyConditionBuilder extends BaseCurrencyConditionBuilder { - + @Override protected Condition buildPolicyIdAndSymbolCondition(String escapedPolicyId, String escapedSymbol) { + // Search for unit field containing policyId+symbol (hex-encoded) + // unit = policyId + symbol where symbol is hex-encoded asset name + String expectedUnit = escapedPolicyId + escapedSymbol; return DSL.condition("EXISTS (SELECT 1 FROM address_utxo au WHERE au.tx_hash = transaction.tx_hash " + - "AND au.amounts LIKE '%\"policy_id\":\"" + escapedPolicyId + "\"%' " + - "AND au.amounts LIKE '%\"asset_name\":\"" + escapedSymbol + "\"%')"); + "AND au.amounts LIKE '%\"unit\":\"" + expectedUnit + "\"%')"); } @Override @@ -176,8 +179,12 @@ protected Condition buildLovelaceCondition() { @Override protected Condition buildSymbolOnlyCondition(String escapedSymbol) { + // Search for unit field containing the hex-encoded symbol + // Since unit = policyId + symbol, the unit will contain the symbol substring + // We need to exclude lovelace since it's a special case return DSL.condition("EXISTS (SELECT 1 FROM address_utxo au WHERE au.tx_hash = transaction.tx_hash " + - "AND au.amounts LIKE '%\"asset_name\":\"" + escapedSymbol + "\"%')"); + "AND au.amounts LIKE '%\"unit\":\"%" + escapedSymbol + "\"%' " + + "AND au.amounts NOT LIKE '%\"unit\":\"lovelace\"%')"); } } diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/postgresql/TxRepositoryPostgreSQLImpl.java b/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/postgresql/TxRepositoryPostgreSQLImpl.java index d0aa31b5c..f28e73cd0 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/postgresql/TxRepositoryPostgreSQLImpl.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/postgresql/TxRepositoryPostgreSQLImpl.java @@ -193,11 +193,14 @@ private Table createValuesTable(Set hashes) { * PostgreSQL-specific currency condition builder using JSONB @> operator. */ private static class PostgreSQLCurrencyConditionBuilder extends BaseCurrencyConditionBuilder { - + @Override protected Condition buildPolicyIdAndSymbolCondition(String escapedPolicyId, String escapedSymbol) { + // Search for unit field containing policyId+symbol (hex-encoded) + // unit = policyId + symbol where symbol is hex-encoded asset name + String expectedUnit = escapedPolicyId + escapedSymbol; return DSL.condition("EXISTS (SELECT 1 FROM address_utxo au WHERE au.tx_hash = transaction.tx_hash " + - "AND au.amounts::jsonb @> '[{\"policy_id\": \"" + escapedPolicyId + "\", \"asset_name\": \"" + escapedSymbol + "\"}]')"); + "AND au.amounts::jsonb @> '[{\"unit\": \"" + expectedUnit + "\"}]')"); } @Override @@ -214,8 +217,14 @@ protected Condition buildLovelaceCondition() { @Override protected Condition buildSymbolOnlyCondition(String escapedSymbol) { - return DSL.condition("EXISTS (SELECT 1 FROM address_utxo au WHERE au.tx_hash = transaction.tx_hash " + - "AND au.amounts::jsonb @> '[{\"asset_name\": \"" + escapedSymbol + "\"}]')"); + // Search for unit field ending with the hex-encoded symbol + // Since unit = policyId + symbol, we look for units that end with the symbol + // Using jsonb_array_elements to iterate through amounts array and check each unit + return DSL.condition("EXISTS (SELECT 1 FROM address_utxo au, " + + "jsonb_array_elements(au.amounts::jsonb) AS amt " + + "WHERE au.tx_hash = transaction.tx_hash " + + "AND amt->>'unit' LIKE '%" + escapedSymbol + "' " + + "AND amt->>'unit' != 'lovelace')"); } } diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/common/model/AssetFingerprint.java b/api/src/main/java/org/cardanofoundation/rosetta/api/common/model/AssetFingerprint.java index 723a06de7..9e670d6c0 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/api/common/model/AssetFingerprint.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/api/common/model/AssetFingerprint.java @@ -1,13 +1,14 @@ package org.cardanofoundation.rosetta.api.common.model; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import org.cardanofoundation.rosetta.common.util.Constants; import javax.annotation.Nullable; +import static org.cardanofoundation.rosetta.common.util.HexUtils.isHexString; + @Data @AllArgsConstructor @EqualsAndHashCode @@ -54,7 +55,7 @@ public static AssetFingerprint fromSubject(@Nullable String subject) { } // Validate that subject is valid hex - if (!isHex(subject)) { + if (!isHexString(subject)) { throw new IllegalArgumentException("subject is not a hex string"); } @@ -64,12 +65,4 @@ public static AssetFingerprint fromSubject(@Nullable String subject) { return new AssetFingerprint(policyId, symbol); } - private static boolean isHex(String str) { - if (str == null || str.isEmpty()) { - return false; - } - - return str.matches("^[0-9a-fA-F]+$"); - } - } diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImpl.java b/api/src/main/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImpl.java index 8b3d5d133..8dd8fa46c 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImpl.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImpl.java @@ -11,6 +11,8 @@ import org.cardanofoundation.rosetta.api.common.service.TokenRegistryService; import org.cardanofoundation.rosetta.api.search.model.Operator; import org.cardanofoundation.rosetta.common.exception.ExceptionFactory; +import org.cardanofoundation.rosetta.common.util.Constants; +import org.cardanofoundation.rosetta.common.util.HexUtils; import org.openapitools.client.model.*; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; @@ -21,6 +23,8 @@ import java.util.Optional; import java.util.function.Function; +import static org.cardanofoundation.rosetta.common.util.HexUtils.isHexString; + @Slf4j @Service @RequiredArgsConstructor @@ -58,11 +62,15 @@ public Page searchTransaction( // Extract currency for filtering (policy ID or asset identifier) @Nullable org.cardanofoundation.rosetta.api.search.model.Currency currency = Optional.ofNullable(searchTransactionsRequest.getCurrency()) - .map(c -> org.cardanofoundation.rosetta.api.search.model.Currency.builder() - .symbol(c.getSymbol()) - .decimals(c.getDecimals()) - .policyId(Optional.ofNullable(c.getMetadata()).map(CurrencyMetadataRequest::getPolicyId).orElse(null)) - .build()) + .map(c -> { + validateCurrencySymbolIsHex(c); // Validate that currency symbol is hex-encoded (for native assets) + + return org.cardanofoundation.rosetta.api.search.model.Currency.builder() + .symbol(c.getSymbol()) + .decimals(c.getDecimals()) + .policyId(Optional.ofNullable(c.getMetadata()).map(CurrencyMetadataRequest::getPolicyId).orElse(null)) + .build(); + }) .orElse(null); @Nullable Long maxBlock = searchTransactionsRequest.getMaxBlock(); @@ -166,4 +174,20 @@ private Operator parseAndValidateOperator(@Nullable String operatorString) { } } + private void validateCurrencySymbolIsHex(CurrencyRequest currencyRequest) { + String symbol = currencyRequest.getSymbol(); + + // Skip validation for ADA (lovelace) as it doesn't have a symbol + if (symbol == null + || Constants.LOVELACE.equalsIgnoreCase(symbol) + || Constants.ADA.equals(symbol)) { + return; + } + + // For native assets, symbol must be hex-encoded + if (!isHexString(symbol)) { + throw ExceptionFactory.currencySymbolNotHex(symbol); + } + } + } diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/exception/ExceptionFactory.java b/api/src/main/java/org/cardanofoundation/rosetta/common/exception/ExceptionFactory.java index 142e0b7fc..f795f9f9a 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/common/exception/ExceptionFactory.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/common/exception/ExceptionFactory.java @@ -389,4 +389,9 @@ public static ApiException invalidOperationStatus(String status) { Details.builder().message("Invalid operation status: '" + status + "'. Supported values are: 'success', 'invalid', 'true', 'false'").build())); } + public static ApiException currencySymbolNotHex(String symbol) { + return new ApiException(RosettaErrorType.CURRENCY_SYMBOL_NOT_HEX.toRosettaError(false, + Details.builder().message("Currency symbol must be hex-encoded, but got: '" + symbol + "'").build())); + } + } diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/util/HexUtils.java b/api/src/main/java/org/cardanofoundation/rosetta/common/util/HexUtils.java new file mode 100644 index 000000000..0aa049323 --- /dev/null +++ b/api/src/main/java/org/cardanofoundation/rosetta/common/util/HexUtils.java @@ -0,0 +1,31 @@ +package org.cardanofoundation.rosetta.common.util; + +import javax.annotation.Nullable; + +/** + * Utility class for hexadecimal string validation and operations. + */ +public final class HexUtils { + + private HexUtils() { + throw new IllegalArgumentException("HexUtils is a utility class, a constructor is private"); + } + + /** + * Validates if a string contains only hexadecimal characters (0-9, a-f, A-F). + * Empty strings and null values are considered invalid. + * + * @param str the string to validate + * @return true if the string is a valid hexadecimal string, false otherwise + */ + public static boolean isHexString(@Nullable String str) { + if (str == null || str.isEmpty()) { + return false; + } + + // Use simple regex validation since Guava's canDecode requires even-length strings + // (it validates byte arrays), but we need to validate any hex string + return str.matches("^[0-9a-fA-F]+$"); + } + +} diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/util/RosettaConstants.java b/api/src/main/java/org/cardanofoundation/rosetta/common/util/RosettaConstants.java index 7515180bd..a3fda3d2b 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/common/util/RosettaConstants.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/common/util/RosettaConstants.java @@ -169,7 +169,8 @@ public enum RosettaErrorType { BOTH_ACCOUNT_AND_ACCOUNT_IDENTIFIER_PROVIDED( "Cannot specify both 'account' and 'accountIdentifier' parameters simultaneously", 5055), // gap in the error codes is because we removed some errors of issues that we resolved - OPERATION_TYPE_SEARCH_NOT_SUPPORTED("Operation type filtering is not currently supported", 5058); + OPERATION_TYPE_SEARCH_NOT_SUPPORTED("Operation type filtering is not currently supported", 5058), + CURRENCY_SYMBOL_NOT_HEX("Currency symbol must be hex-encoded", 5059); final String message; final int code; diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapperUtilTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapperUtilTest.java index 4565ef0dc..a3aea2bcc 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapperUtilTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapperUtilTest.java @@ -512,7 +512,6 @@ private Amt createAmt(String policyId, String assetName, BigInteger quantity) { private Amt createAmt(String policyId, String assetName, BigInteger quantity, String unit) { return Amt.builder() .policyId(policyId) - .assetName(assetName) .quantity(quantity) .unit(unit != null ? unit : (policyId != null ? policyId + assetName : assetName)) .build(); diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/account/service/AccountServiceImplTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/account/service/AccountServiceImplTest.java index 2beb2f84d..83f81cb1b 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/account/service/AccountServiceImplTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/account/service/AccountServiceImplTest.java @@ -344,7 +344,7 @@ void getAccountCoinsWithCurrenciesPositiveTest() { when(utxo.getTxHash()).thenReturn("txHash"); when(utxo.getOutputIndex()).thenReturn(1); when(utxo.getAmounts()).thenReturn( - Collections.singletonList(new Amt(LOVELACE, "", LOVELACE, BigInteger.valueOf(1000L)))); + Collections.singletonList(new Amt(LOVELACE, null, BigInteger.valueOf(1000L)))); when(accountCoinsRequest.getAccountIdentifier()).thenReturn(accountIdentifier); when(accountCoinsRequest.getCurrencies()).thenReturn(Collections.singletonList(currency)); when(accountIdentifier.getAddress()).thenReturn(accountAddress); @@ -369,7 +369,7 @@ void getAccountCoinsWithNullCurrenciesPositiveTest() { when(utxo.getTxHash()).thenReturn("txHash"); when(utxo.getOutputIndex()).thenReturn(1); when(utxo.getAmounts()).thenReturn( - Collections.singletonList(new Amt(LOVELACE, "", LOVELACE, BigInteger.valueOf(1000L)))); + Collections.singletonList(new Amt(LOVELACE, null, BigInteger.valueOf(1000L)))); when(accountCoinsRequest.getAccountIdentifier()).thenReturn(accountIdentifier); when(accountCoinsRequest.getCurrencies()).thenReturn(null); when(accountIdentifier.getAddress()).thenReturn(accountAddress); diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockToBlockResponseTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockToBlockResponseTest.java index 9e567d9b3..aeec7f9b0 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockToBlockResponseTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockToBlockResponseTest.java @@ -41,11 +41,11 @@ void mapToBlockResponse_test_invalidTransaction() { .blockNo(1L) .inputs( List.of(Utxo.builder().txHash("Hash").outputIndex(0).ownerAddr("Owner").amounts(List.of( - Amt.builder().unit(unit).policyId(policyId).assetName("tAda").quantity(BigInteger.valueOf(10L)).build())) + Amt.builder().unit(unit).policyId(policyId).quantity(BigInteger.valueOf(10L)).build())) .build())) .outputs( List.of(Utxo.builder().txHash("Hash").outputIndex(0).ownerAddr("Owner").amounts(List.of( - Amt.builder().unit(unit).policyId(policyId).assetName("tAda").quantity(BigInteger.valueOf(10L)).build())) + Amt.builder().unit(unit).policyId(policyId).quantity(BigInteger.valueOf(10L)).build())) .build())) .build(); diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockTxToBlockTxResponseTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockTxToBlockTxResponseTest.java index fd8219840..79e2df8fc 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockTxToBlockTxResponseTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockTxToBlockTxResponseTest.java @@ -72,7 +72,6 @@ private Utxo newUtxo() { private static Amt newAmt() { return Amt.builder() - .assetName("assetName1") .policyId("policyId1") .quantity(BigInteger.ONE) .unit("unit1") diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockTxToRosettaTransactionTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockTxToRosettaTransactionTest.java index 2f33d0dce..2c00d3bcd 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockTxToRosettaTransactionTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/BlockTxToRosettaTransactionTest.java @@ -425,7 +425,6 @@ private Utxo newUtxoOut() { private static Amt newAdaAmt() { return Amt.builder() - .assetName(Constants.LOVELACE) .quantity(BigInteger.TEN) .unit(Constants.LOVELACE) .build(); @@ -435,7 +434,6 @@ private static Amt newTokenAmt() { String policyId = "policyId1"; String symbol = "assetName1"; return Amt.builder() - .assetName("assetName1") .policyId(policyId) .quantity(BigInteger.ONE) .unit(policyId + symbol) diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapperUtilsTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapperUtilsTest.java index f73539da0..29ffa2294 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapperUtilsTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapperUtilsTest.java @@ -243,7 +243,6 @@ void mapToOperationMetaDataSpentAmountTest() { List amtList = Arrays.asList( Amt.builder() - .assetName(assetName) .policyId(policyId) .quantity(BigInteger.valueOf(1000)) .unit(policyId + assetName) @@ -299,7 +298,6 @@ private static Amt newAmt(int policy, int number, boolean isLovelace) { String unit = isLovelace ? Constants.LOVELACE : policyId + symbol; return Amt.builder() - .assetName(isLovelace ? Constants.LOVELACE : "assetName" + number) .policyId(policyId) .quantity(BigInteger.ONE) .unit(unit) @@ -310,7 +308,6 @@ private static Amt newAmtWithCustomName(String policyId, String assetName, boole String unit = isLovelace ? Constants.LOVELACE : policyId + assetName; return Amt.builder() - .assetName(isLovelace ? Constants.LOVELACE : assetName) .policyId(policyId) .quantity(BigInteger.ONE) .unit(unit) diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/block/model/repository/TxRepositoryCustomImplTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/block/model/repository/TxRepositoryCustomImplTest.java index 038b85e0e..441545b2f 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/block/model/repository/TxRepositoryCustomImplTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/block/model/repository/TxRepositoryCustomImplTest.java @@ -717,21 +717,22 @@ public void testSearchTxnEntitiesAND_FilterByPolicyIdOnly() { @Sql(scripts = "classpath:/testdata/sql/tx-repository-currency-test-init.sql", executionPhase = BEFORE_TEST_METHOD) @Sql(scripts = "classpath:/testdata/sql/tx-repository-currency-test-cleanup.sql", executionPhase = AFTER_TEST_METHOD) public void testSearchTxnEntitiesAND_FilterByPolicyIdAndSymbol() { - // Test filtering by both policy ID and asset name (most precise) + // Test filtering by both policy ID and hex-encoded symbol (most precise) + // MIN in hex: 4d494e Currency preciseAssetCurrency = Currency.builder() .policyId("29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6") - .symbol("MIN") + .symbol("4d494e") // hex-encoded "MIN" .decimals(6) .build(); Page results = txRepository.searchTxnEntitiesAND( - Collections.emptySet(), Set.of(), null, null, null, null, preciseAssetCurrency, + Collections.emptySet(), Set.of(), null, null, null, null, preciseAssetCurrency, new SimpleOffsetBasedPageRequest(0, 100)); List txList = results.getContent(); - + // Results could be empty if no transactions with this specific asset exist - // All transactions should contain the exact asset (policy ID + asset name) + // All transactions should contain the exact asset (policy ID + hex-encoded symbol) txList.forEach(tx -> { assertThat(tx.getTxHash()).isNotNull(); }); @@ -741,19 +742,20 @@ public void testSearchTxnEntitiesAND_FilterByPolicyIdAndSymbol() { @Sql(scripts = "classpath:/testdata/sql/tx-repository-currency-test-init.sql", executionPhase = BEFORE_TEST_METHOD) @Sql(scripts = "classpath:/testdata/sql/tx-repository-currency-test-cleanup.sql", executionPhase = AFTER_TEST_METHOD) public void testSearchTxnEntitiesAND_FilterBySymbolOnly() { - // Test filtering by symbol/asset name only (searches across all policy IDs) + // Test filtering by hex-encoded symbol only (searches across all policy IDs) + // MIN in hex: 4d494e Currency symbolCurrency = Currency.builder() - .symbol("MIN") + .symbol("4d494e") // hex-encoded "MIN" .build(); Page results = txRepository.searchTxnEntitiesAND( - Collections.emptySet(), Set.of(), null, null, null, null, symbolCurrency, + Collections.emptySet(), Set.of(), null, null, null, null, symbolCurrency, new SimpleOffsetBasedPageRequest(0, 100)); List txList = results.getContent(); - + // Results could be empty if no transactions with MIN tokens exist - // All transactions should contain assets with "MIN" as asset name + // All transactions should contain assets with hex-encoded "MIN" symbol txList.forEach(tx -> { assertThat(tx.getTxHash()).isNotNull(); }); diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryServiceImplTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryServiceImplTest.java index 566dcf0af..f35f78bd6 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryServiceImplTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryServiceImplTest.java @@ -1112,11 +1112,10 @@ private Utxo createUtxoWithAmounts(List amounts) { return Utxo.builder().amounts(amounts).build(); } - private Amt createAmt(String assetName, String policyId, String unit) { + private Amt createAmt(String unit, String policyId, String lovelaceOrUnit) { return Amt.builder() - .assetName(assetName) .policyId(policyId) - .unit(unit) + .unit(lovelaceOrUnit) .quantity(BigDecimal.valueOf(1000000).toBigInteger()) .build(); } diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImplTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImplTest.java index a0d0b3ee3..0deed57ba 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImplTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImplTest.java @@ -458,10 +458,10 @@ void shouldPassNullWhenNeitherAddressNorAccountIdentifierProvided() { class CurrencySearchTests { @Test - void shouldSupportCurrencySearch() { + void shouldSupportCurrencySearch_withLovelace() { // Given CurrencyRequest currency = CurrencyRequest.builder().symbol("ADA").build(); - + SearchTransactionsRequest request = SearchTransactionsRequest.builder() .networkIdentifier(networkIdentifier) .currency(currency) @@ -489,12 +489,149 @@ void shouldSupportCurrencySearch() { // Then assertThat(result).isNotNull(); assertThat(result.getContent()).isEmpty(); - + // Verify that ledgerSearchService was called (currency search is now supported) verify(ledgerSearchService).searchTransaction( any(), any(), any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong() ); } + + @Test + void shouldAcceptHexEncodedCurrencySymbol() { + // Given - hex-encoded asset name from issue #610 + CurrencyRequest currency = CurrencyRequest.builder() + .symbol("000de1404469616d6f6e64486f6f76657332363938") + .build(); + + SearchTransactionsRequest request = SearchTransactionsRequest.builder() + .networkIdentifier(networkIdentifier) + .currency(currency) + .build(); + + Page emptyPage = new PageImpl<>(List.of()); + when(ledgerSearchService.searchTransaction( + any(), any(), any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong() + )).thenReturn(emptyPage); + + // When + Page result = searchService.searchTransaction(request, 0L, 10L); + + // Then - should not throw exception + assertThat(result).isNotNull(); + verify(ledgerSearchService).searchTransaction( + any(), any(), any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong() + ); + } + + @Test + void shouldAcceptCIP68HexEncodedSymbol() { + // Given - CIP-68 asset with binary prefix + CurrencyRequest currency = CurrencyRequest.builder() + .symbol("000643b04469616d6f6e64") + .build(); + + SearchTransactionsRequest request = SearchTransactionsRequest.builder() + .networkIdentifier(networkIdentifier) + .currency(currency) + .build(); + + Page emptyPage = new PageImpl<>(List.of()); + when(ledgerSearchService.searchTransaction( + any(), any(), any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong() + )).thenReturn(emptyPage); + + // When + Page result = searchService.searchTransaction(request, 0L, 10L); + + // Then - should not throw exception + assertThat(result).isNotNull(); + } + + @Test + void shouldRejectAsciiCurrencySymbol() { + // Given - ASCII asset name (not hex-encoded) - issue #610 regression case + CurrencyRequest currency = CurrencyRequest.builder() + .symbol("Diamond") + .build(); + + SearchTransactionsRequest request = SearchTransactionsRequest.builder() + .networkIdentifier(networkIdentifier) + .currency(currency) + .build(); + + // When & Then + assertThatThrownBy(() -> searchService.searchTransaction(request, 0L, 10L)) + .isInstanceOf(ApiException.class) + .hasMessage("Currency symbol must be hex-encoded") + .extracting("error.details.message") + .isEqualTo("Currency symbol must be hex-encoded, but got: 'Diamond'"); + + verifyNoInteractions(ledgerSearchService); + } + + @Test + void shouldRejectCurrencySymbolWithSpaces() { + // Given + CurrencyRequest currency = CurrencyRequest.builder() + .symbol("invalid hex") + .build(); + + SearchTransactionsRequest request = SearchTransactionsRequest.builder() + .networkIdentifier(networkIdentifier) + .currency(currency) + .build(); + + // When & Then + assertThatThrownBy(() -> searchService.searchTransaction(request, 0L, 10L)) + .isInstanceOf(ApiException.class) + .hasMessage("Currency symbol must be hex-encoded"); + + verifyNoInteractions(ledgerSearchService); + } + + @Test + void shouldRejectCurrencySymbolWithSpecialCharacters() { + // Given + CurrencyRequest currency = CurrencyRequest.builder() + .symbol("test@123") + .build(); + + SearchTransactionsRequest request = SearchTransactionsRequest.builder() + .networkIdentifier(networkIdentifier) + .currency(currency) + .build(); + + // When & Then + assertThatThrownBy(() -> searchService.searchTransaction(request, 0L, 10L)) + .isInstanceOf(ApiException.class) + .hasMessage("Currency symbol must be hex-encoded"); + + verifyNoInteractions(ledgerSearchService); + } + + @Test + void shouldAllowNullCurrencySymbol() { + // Given + CurrencyRequest currency = CurrencyRequest.builder() + .symbol(null) + .build(); + + SearchTransactionsRequest request = SearchTransactionsRequest.builder() + .networkIdentifier(networkIdentifier) + .currency(currency) + .build(); + + Page emptyPage = new PageImpl<>(List.of()); + when(ledgerSearchService.searchTransaction( + any(), any(), any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong() + )).thenReturn(emptyPage); + + // When + Page result = searchService.searchTransaction(request, 0L, 10L); + + // Then - should not throw exception for null symbol + assertThat(result).isNotNull(); + } } @Nested diff --git a/api/src/test/java/org/cardanofoundation/rosetta/common/util/HexUtilsTest.java b/api/src/test/java/org/cardanofoundation/rosetta/common/util/HexUtilsTest.java new file mode 100644 index 000000000..3ffe0708e --- /dev/null +++ b/api/src/test/java/org/cardanofoundation/rosetta/common/util/HexUtilsTest.java @@ -0,0 +1,79 @@ +package org.cardanofoundation.rosetta.common.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class HexUtilsTest { + + @Test + @DisplayName("Should return true for valid lowercase hex strings") + void shouldReturnTrueForValidLowercaseHex() { + assertThat(HexUtils.isHexString("deadbeef")).isTrue(); + assertThat(HexUtils.isHexString("0123456789abcdef")).isTrue(); + assertThat(HexUtils.isHexString("a")).isTrue(); + assertThat(HexUtils.isHexString("000de1404469616d6f6e64486f6f76657332363938")).isTrue(); + } + + @Test + @DisplayName("Should return true for valid uppercase hex strings") + void shouldReturnTrueForValidUppercaseHex() { + assertThat(HexUtils.isHexString("DEADBEEF")).isTrue(); + assertThat(HexUtils.isHexString("0123456789ABCDEF")).isTrue(); + assertThat(HexUtils.isHexString("A")).isTrue(); + assertThat(HexUtils.isHexString("000DE1404469616D6F6E64486F6F76657332363938")).isTrue(); + } + + @Test + @DisplayName("Should return true for valid mixed case hex strings") + void shouldReturnTrueForValidMixedCaseHex() { + assertThat(HexUtils.isHexString("DeAdBeEf")).isTrue(); + assertThat(HexUtils.isHexString("0123456789AbCdEf")).isTrue(); + assertThat(HexUtils.isHexString("aB")).isTrue(); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("Should return false for null or empty strings") + void shouldReturnFalseForNullOrEmpty(String input) { + assertThat(HexUtils.isHexString(input)).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = { + "test", // ASCII text + "hello world", // ASCII with space + "0xdeadbeef", // Hex with prefix + "g", // Invalid hex char + "123xyz", // Mixed valid and invalid + "Diamond", // ASCII name (issue #610 example) + "!@#$", // Special characters + " deadbeef", // Leading space + "deadbeef ", // Trailing space + "dead beef" // Space in middle + }) + @DisplayName("Should return false for invalid hex strings") + void shouldReturnFalseForInvalidHex(String input) { + assertThat(HexUtils.isHexString(input)).isFalse(); + } + + @Test + @DisplayName("Should handle CIP-68 asset names with binary prefixes") + void shouldHandleCIP68Assets() { + // CIP-68 assets have binary prefixes like (000643b0) followed by hex-encoded name + String cip68AssetName = "000643b04469616d6f6e64"; // (000643b0) + hex("Diamond") + assertThat(HexUtils.isHexString(cip68AssetName)).isTrue(); + } + + @Test + @DisplayName("Should validate policy IDs correctly") + void shouldValidatePolicyIds() { + // Standard Cardano policy ID (56 hex chars) + String policyId = "e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed72"; + assertThat(HexUtils.isHexString(policyId)).isTrue(); + } +} diff --git a/api/src/test/resources/testdata/errors.json b/api/src/test/resources/testdata/errors.json index a674b7eda..26ac92c0d 100644 --- a/api/src/test/resources/testdata/errors.json +++ b/api/src/test/resources/testdata/errors.json @@ -472,5 +472,12 @@ "description": null, "retriable": false, "details": null + }, + { + "code": 5059, + "message": "Currency symbol must be hex-encoded", + "description": null, + "retriable": false, + "details": null } ] diff --git a/api/src/test/resources/testdata/sql/tx-repository-currency-test-init.sql b/api/src/test/resources/testdata/sql/tx-repository-currency-test-init.sql index e8f3c1442..7a554bdef 100644 --- a/api/src/test/resources/testdata/sql/tx-repository-currency-test-init.sql +++ b/api/src/test/resources/testdata/sql/tx-repository-currency-test-init.sql @@ -21,43 +21,50 @@ INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) VALUES ('tx_lovelace_2', 0, 'addr_lovelace_2', 100, '[{"unit": "lovelace", "quantity": "3000000"}]'); -- Transaction with native asset (policy ID + asset name) -INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) +-- MIN in hex: 4d494e +INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) VALUES ('tx_native_asset_1', 0, 'addr_native_1', 101, '[ {"unit": "lovelace", "quantity": "2000000"}, - {"policy_id": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6", "asset_name": "MIN", "quantity": "1000000"} + {"unit": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c64d494e", "policy_id": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6", "asset_name": "MIN", "quantity": "1000000"} ]'); -- Transaction with mixed assets -INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) +-- MIN in hex: 4d494e +-- SpaceBud3412 is already hex: 537061636542756433343132 +INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) VALUES ('tx_mixed_assets_1', 0, 'addr_mixed_1', 101, '[ {"unit": "lovelace", "quantity": "1500000"}, - {"policy_id": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6", "asset_name": "MIN", "quantity": "500000"}, - {"policy_id": "d5e6bf0500378d4f0da4e8dde6becec7621cd8cbf5cbb9b87013d4cc", "asset_name": "537061636542756433343132", "quantity": "100"} + {"unit": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c64d494e", "policy_id": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6", "asset_name": "MIN", "quantity": "500000"}, + {"unit": "d5e6bf0500378d4f0da4e8dde6becec7621cd8cbf5cbb9b87013d4cc537061636542756433343132", "policy_id": "d5e6bf0500378d4f0da4e8dde6becec7621cd8cbf5cbb9b87013d4cc", "asset_name": "537061636542756433343132", "quantity": "100"} ]'); -- Transaction with specific policy ID only assets -INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) +-- TOKEN1 in hex: 544f4b454e31 +-- TOKEN2 in hex: 544f4b454e32 +INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) VALUES ('tx_policy_specific_1', 0, 'addr_policy_1', 102, '[ {"unit": "lovelace", "quantity": "2500000"}, - {"policy_id": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6", "asset_name": "TOKEN1", "quantity": "1000"}, - {"policy_id": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6", "asset_name": "TOKEN2", "quantity": "2000"} + {"unit": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6544f4b454e31", "policy_id": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6", "asset_name": "TOKEN1", "quantity": "1000"}, + {"unit": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6544f4b454e32", "policy_id": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6", "asset_name": "TOKEN2", "quantity": "2000"} ]'); -- Transaction with MIN tokens from different policy -INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) +-- MIN in hex: 4d494e +INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) VALUES ('tx_min_token_1', 0, 'addr_min_1', 102, '[ {"unit": "lovelace", "quantity": "1000000"}, - {"policy_id": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234", "asset_name": "MIN", "quantity": "750000"} + {"unit": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234d494e", "policy_id": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234", "asset_name": "MIN", "quantity": "750000"} ]'); -- Additional UTXOs for same transactions (outputs) -INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) +INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) VALUES ('tx_native_asset_1', 1, 'addr_native_2', 101, '[ {"unit": "lovelace", "quantity": "1000000"} ]'); -INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) +-- MIN in hex: 4d494e +INSERT INTO address_utxo (tx_hash, output_index, owner_addr, block, amounts) VALUES ('tx_mixed_assets_1', 1, 'addr_mixed_2', 101, '[ {"unit": "lovelace", "quantity": "500000"}, - {"policy_id": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6", "asset_name": "MIN", "quantity": "250000"} + {"unit": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c64d494e", "policy_id": "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6", "asset_name": "MIN", "quantity": "250000"} ]'); \ No newline at end of file diff --git a/pom.xml b/pom.xml index a90c58665..71f8f7ba5 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,6 @@ - full 1.4.0 24 UTF-8 @@ -41,8 +40,8 @@ 1.18.38 2.2.8 2.20.0 - 0.6.6 - 0.3.8 + 0.7.0 + 0.4.0-beta5 2.14.0 2.0.1.Final 1.13.0 diff --git a/test-data-generator/pom.xml b/test-data-generator/pom.xml index 80f2c0670..8fd71da5d 100644 --- a/test-data-generator/pom.xml +++ b/test-data-generator/pom.xml @@ -28,12 +28,12 @@ com.bloxbean.cardano cardano-client-lib - 0.6.4 + 0.7.0 com.bloxbean.cardano cardano-client-backend-blockfrost - 0.6.4 + 0.7.0 org.projectlombok diff --git a/yaci-indexer/pom.xml b/yaci-indexer/pom.xml index 2a37aa3b6..390f2b069 100644 --- a/yaci-indexer/pom.xml +++ b/yaci-indexer/pom.xml @@ -18,8 +18,7 @@ 24 - full - 0.1.5 + 2.0.0-beta4 src/main/java/org/cardanofoundation/rosetta/yaciindexer/stores/txsize/model/* @@ -99,12 +98,12 @@ yaci-store-admin-spring-boot-starter ${yaci-store.version} - - - - - - + + + com.bloxbean.cardano + yaci-store-governance-spring-boot-starter + ${yaci-store.version} + com.bloxbean.cardano