Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,17 @@ public Page<TxnEntity> searchTxnEntitiesOR(Set<String> 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
Expand All @@ -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\"%')");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,14 @@ private Table<?> createValuesTable(Set<String> 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
Expand All @@ -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')");
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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");
}

Expand All @@ -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]+$");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -58,11 +62,15 @@ public Page<BlockTransaction> 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();
Expand Down Expand Up @@ -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);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}

}
Original file line number Diff line number Diff line change
@@ -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]+$");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TxnEntity> 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<TxnEntity> 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();
});
Expand All @@ -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<TxnEntity> 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<TxnEntity> 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();
});
Expand Down
Loading
Loading