Skip to content

Commit 4086a25

Browse files
matiwinnetouMateusz Czeladka
andauthored
fix: symbol search will be now in hex, not ascii, which will support CIP-26, CIP-68 and other type of such assets. (#620)
Co-authored-by: Mateusz Czeladka <mateusz.czeladka@cardanofoundation.org>
1 parent b12db08 commit 4086a25

File tree

13 files changed

+354
-52
lines changed

13 files changed

+354
-52
lines changed

api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/TxRepositoryCustomBase.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ public final Condition buildCurrencyCondition(Currency currency) {
4646
!"lovelace".equalsIgnoreCase(symbol) && !"ada".equalsIgnoreCase(symbol)) {
4747
String escapedSymbol = symbol.trim().replace("\"", "\\\"");
4848
return buildPolicyIdAndSymbolCondition(escapedPolicyId, escapedSymbol);
49-
} else {
50-
return buildPolicyIdOnlyCondition(escapedPolicyId);
5149
}
50+
51+
return buildPolicyIdOnlyCondition(escapedPolicyId);
5252
}
5353

5454
if (symbol != null && !symbol.trim().isEmpty()) {

api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/h2/TxRepositoryH2Impl.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,17 @@ public Page<TxnEntity> searchTxnEntitiesOR(Set<String> txHashes,
152152

153153
/**
154154
* H2-specific currency condition builder using LIKE operator for JSON string matching.
155+
* Searches by hex-encoded symbols in the unit field to support CIP-68 assets.
155156
*/
156157
private static class H2CurrencyConditionBuilder extends BaseCurrencyConditionBuilder {
157-
158+
158159
@Override
159160
protected Condition buildPolicyIdAndSymbolCondition(String escapedPolicyId, String escapedSymbol) {
161+
// Search for unit field containing policyId+symbol (hex-encoded)
162+
// unit = policyId + symbol where symbol is hex-encoded asset name
163+
String expectedUnit = escapedPolicyId + escapedSymbol;
160164
return DSL.condition("EXISTS (SELECT 1 FROM address_utxo au WHERE au.tx_hash = transaction.tx_hash " +
161-
"AND au.amounts LIKE '%\"policy_id\":\"" + escapedPolicyId + "\"%' " +
162-
"AND au.amounts LIKE '%\"asset_name\":\"" + escapedSymbol + "\"%')");
165+
"AND au.amounts LIKE '%\"unit\":\"" + expectedUnit + "\"%')");
163166
}
164167

165168
@Override
@@ -176,8 +179,12 @@ protected Condition buildLovelaceCondition() {
176179

177180
@Override
178181
protected Condition buildSymbolOnlyCondition(String escapedSymbol) {
182+
// Search for unit field containing the hex-encoded symbol
183+
// Since unit = policyId + symbol, the unit will contain the symbol substring
184+
// We need to exclude lovelace since it's a special case
179185
return DSL.condition("EXISTS (SELECT 1 FROM address_utxo au WHERE au.tx_hash = transaction.tx_hash " +
180-
"AND au.amounts LIKE '%\"asset_name\":\"" + escapedSymbol + "\"%')");
186+
"AND au.amounts LIKE '%\"unit\":\"%" + escapedSymbol + "\"%' " +
187+
"AND au.amounts NOT LIKE '%\"unit\":\"lovelace\"%')");
181188
}
182189
}
183190

api/src/main/java/org/cardanofoundation/rosetta/api/block/model/repository/postgresql/TxRepositoryPostgreSQLImpl.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,11 +193,14 @@ private Table<?> createValuesTable(Set<String> hashes) {
193193
* PostgreSQL-specific currency condition builder using JSONB @> operator.
194194
*/
195195
private static class PostgreSQLCurrencyConditionBuilder extends BaseCurrencyConditionBuilder {
196-
196+
197197
@Override
198198
protected Condition buildPolicyIdAndSymbolCondition(String escapedPolicyId, String escapedSymbol) {
199+
// Search for unit field containing policyId+symbol (hex-encoded)
200+
// unit = policyId + symbol where symbol is hex-encoded asset name
201+
String expectedUnit = escapedPolicyId + escapedSymbol;
199202
return DSL.condition("EXISTS (SELECT 1 FROM address_utxo au WHERE au.tx_hash = transaction.tx_hash " +
200-
"AND au.amounts::jsonb @> '[{\"policy_id\": \"" + escapedPolicyId + "\", \"asset_name\": \"" + escapedSymbol + "\"}]')");
203+
"AND au.amounts::jsonb @> '[{\"unit\": \"" + expectedUnit + "\"}]')");
201204
}
202205

203206
@Override
@@ -214,8 +217,14 @@ protected Condition buildLovelaceCondition() {
214217

215218
@Override
216219
protected Condition buildSymbolOnlyCondition(String escapedSymbol) {
217-
return DSL.condition("EXISTS (SELECT 1 FROM address_utxo au WHERE au.tx_hash = transaction.tx_hash " +
218-
"AND au.amounts::jsonb @> '[{\"asset_name\": \"" + escapedSymbol + "\"}]')");
220+
// Search for unit field ending with the hex-encoded symbol
221+
// Since unit = policyId + symbol, we look for units that end with the symbol
222+
// Using jsonb_array_elements to iterate through amounts array and check each unit
223+
return DSL.condition("EXISTS (SELECT 1 FROM address_utxo au, " +
224+
"jsonb_array_elements(au.amounts::jsonb) AS amt " +
225+
"WHERE au.tx_hash = transaction.tx_hash " +
226+
"AND amt->>'unit' LIKE '%" + escapedSymbol + "' " +
227+
"AND amt->>'unit' != 'lovelace')");
219228
}
220229
}
221230

api/src/main/java/org/cardanofoundation/rosetta/api/common/model/AssetFingerprint.java

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
package org.cardanofoundation.rosetta.api.common.model;
22

33
import lombok.AllArgsConstructor;
4-
import lombok.Builder;
54
import lombok.Data;
65
import lombok.EqualsAndHashCode;
76
import org.cardanofoundation.rosetta.common.util.Constants;
87

98
import javax.annotation.Nullable;
109

10+
import static org.cardanofoundation.rosetta.common.util.HexUtils.isHexString;
11+
1112
@Data
1213
@AllArgsConstructor
1314
@EqualsAndHashCode
@@ -54,7 +55,7 @@ public static AssetFingerprint fromSubject(@Nullable String subject) {
5455
}
5556

5657
// Validate that subject is valid hex
57-
if (!isHex(subject)) {
58+
if (!isHexString(subject)) {
5859
throw new IllegalArgumentException("subject is not a hex string");
5960
}
6061

@@ -64,12 +65,4 @@ public static AssetFingerprint fromSubject(@Nullable String subject) {
6465
return new AssetFingerprint(policyId, symbol);
6566
}
6667

67-
private static boolean isHex(String str) {
68-
if (str == null || str.isEmpty()) {
69-
return false;
70-
}
71-
72-
return str.matches("^[0-9a-fA-F]+$");
73-
}
74-
7568
}

api/src/main/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImpl.java

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import org.cardanofoundation.rosetta.api.common.service.TokenRegistryService;
1212
import org.cardanofoundation.rosetta.api.search.model.Operator;
1313
import org.cardanofoundation.rosetta.common.exception.ExceptionFactory;
14+
import org.cardanofoundation.rosetta.common.util.Constants;
15+
import org.cardanofoundation.rosetta.common.util.HexUtils;
1416
import org.openapitools.client.model.*;
1517
import org.springframework.data.domain.Page;
1618
import org.springframework.stereotype.Service;
@@ -21,6 +23,8 @@
2123
import java.util.Optional;
2224
import java.util.function.Function;
2325

26+
import static org.cardanofoundation.rosetta.common.util.HexUtils.isHexString;
27+
2428
@Slf4j
2529
@Service
2630
@RequiredArgsConstructor
@@ -58,11 +62,15 @@ public Page<BlockTransaction> searchTransaction(
5862

5963
// Extract currency for filtering (policy ID or asset identifier)
6064
@Nullable org.cardanofoundation.rosetta.api.search.model.Currency currency = Optional.ofNullable(searchTransactionsRequest.getCurrency())
61-
.map(c -> org.cardanofoundation.rosetta.api.search.model.Currency.builder()
62-
.symbol(c.getSymbol())
63-
.decimals(c.getDecimals())
64-
.policyId(Optional.ofNullable(c.getMetadata()).map(CurrencyMetadataRequest::getPolicyId).orElse(null))
65-
.build())
65+
.map(c -> {
66+
validateCurrencySymbolIsHex(c); // Validate that currency symbol is hex-encoded (for native assets)
67+
68+
return org.cardanofoundation.rosetta.api.search.model.Currency.builder()
69+
.symbol(c.getSymbol())
70+
.decimals(c.getDecimals())
71+
.policyId(Optional.ofNullable(c.getMetadata()).map(CurrencyMetadataRequest::getPolicyId).orElse(null))
72+
.build();
73+
})
6674
.orElse(null);
6775

6876
@Nullable Long maxBlock = searchTransactionsRequest.getMaxBlock();
@@ -166,4 +174,20 @@ private Operator parseAndValidateOperator(@Nullable String operatorString) {
166174
}
167175
}
168176

177+
private void validateCurrencySymbolIsHex(CurrencyRequest currencyRequest) {
178+
String symbol = currencyRequest.getSymbol();
179+
180+
// Skip validation for ADA (lovelace) as it doesn't have a symbol
181+
if (symbol == null
182+
|| Constants.LOVELACE.equalsIgnoreCase(symbol)
183+
|| Constants.ADA.equals(symbol)) {
184+
return;
185+
}
186+
187+
// For native assets, symbol must be hex-encoded
188+
if (!isHexString(symbol)) {
189+
throw ExceptionFactory.currencySymbolNotHex(symbol);
190+
}
191+
}
192+
169193
}

api/src/main/java/org/cardanofoundation/rosetta/common/exception/ExceptionFactory.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,4 +389,9 @@ public static ApiException invalidOperationStatus(String status) {
389389
Details.builder().message("Invalid operation status: '" + status + "'. Supported values are: 'success', 'invalid', 'true', 'false'").build()));
390390
}
391391

392+
public static ApiException currencySymbolNotHex(String symbol) {
393+
return new ApiException(RosettaErrorType.CURRENCY_SYMBOL_NOT_HEX.toRosettaError(false,
394+
Details.builder().message("Currency symbol must be hex-encoded, but got: '" + symbol + "'").build()));
395+
}
396+
392397
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.cardanofoundation.rosetta.common.util;
2+
3+
import javax.annotation.Nullable;
4+
5+
/**
6+
* Utility class for hexadecimal string validation and operations.
7+
*/
8+
public final class HexUtils {
9+
10+
private HexUtils() {
11+
throw new IllegalArgumentException("HexUtils is a utility class, a constructor is private");
12+
}
13+
14+
/**
15+
* Validates if a string contains only hexadecimal characters (0-9, a-f, A-F).
16+
* Empty strings and null values are considered invalid.
17+
*
18+
* @param str the string to validate
19+
* @return true if the string is a valid hexadecimal string, false otherwise
20+
*/
21+
public static boolean isHexString(@Nullable String str) {
22+
if (str == null || str.isEmpty()) {
23+
return false;
24+
}
25+
26+
// Use simple regex validation since Guava's canDecode requires even-length strings
27+
// (it validates byte arrays), but we need to validate any hex string
28+
return str.matches("^[0-9a-fA-F]+$");
29+
}
30+
31+
}

api/src/main/java/org/cardanofoundation/rosetta/common/util/RosettaConstants.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ public enum RosettaErrorType {
169169
BOTH_ACCOUNT_AND_ACCOUNT_IDENTIFIER_PROVIDED(
170170
"Cannot specify both 'account' and 'accountIdentifier' parameters simultaneously", 5055),
171171
// gap in the error codes is because we removed some errors of issues that we resolved
172-
OPERATION_TYPE_SEARCH_NOT_SUPPORTED("Operation type filtering is not currently supported", 5058);
172+
OPERATION_TYPE_SEARCH_NOT_SUPPORTED("Operation type filtering is not currently supported", 5058),
173+
CURRENCY_SYMBOL_NOT_HEX("Currency symbol must be hex-encoded", 5059);
173174

174175
final String message;
175176
final int code;

api/src/test/java/org/cardanofoundation/rosetta/api/block/model/repository/TxRepositoryCustomImplTest.java

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -717,21 +717,22 @@ public void testSearchTxnEntitiesAND_FilterByPolicyIdOnly() {
717717
@Sql(scripts = "classpath:/testdata/sql/tx-repository-currency-test-init.sql", executionPhase = BEFORE_TEST_METHOD)
718718
@Sql(scripts = "classpath:/testdata/sql/tx-repository-currency-test-cleanup.sql", executionPhase = AFTER_TEST_METHOD)
719719
public void testSearchTxnEntitiesAND_FilterByPolicyIdAndSymbol() {
720-
// Test filtering by both policy ID and asset name (most precise)
720+
// Test filtering by both policy ID and hex-encoded symbol (most precise)
721+
// MIN in hex: 4d494e
721722
Currency preciseAssetCurrency = Currency.builder()
722723
.policyId("29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6")
723-
.symbol("MIN")
724+
.symbol("4d494e") // hex-encoded "MIN"
724725
.decimals(6)
725726
.build();
726727

727728
Page<TxnEntity> results = txRepository.searchTxnEntitiesAND(
728-
Collections.emptySet(), Set.of(), null, null, null, null, preciseAssetCurrency,
729+
Collections.emptySet(), Set.of(), null, null, null, null, preciseAssetCurrency,
729730
new SimpleOffsetBasedPageRequest(0, 100));
730731

731732
List<TxnEntity> txList = results.getContent();
732-
733+
733734
// Results could be empty if no transactions with this specific asset exist
734-
// All transactions should contain the exact asset (policy ID + asset name)
735+
// All transactions should contain the exact asset (policy ID + hex-encoded symbol)
735736
txList.forEach(tx -> {
736737
assertThat(tx.getTxHash()).isNotNull();
737738
});
@@ -741,19 +742,20 @@ public void testSearchTxnEntitiesAND_FilterByPolicyIdAndSymbol() {
741742
@Sql(scripts = "classpath:/testdata/sql/tx-repository-currency-test-init.sql", executionPhase = BEFORE_TEST_METHOD)
742743
@Sql(scripts = "classpath:/testdata/sql/tx-repository-currency-test-cleanup.sql", executionPhase = AFTER_TEST_METHOD)
743744
public void testSearchTxnEntitiesAND_FilterBySymbolOnly() {
744-
// Test filtering by symbol/asset name only (searches across all policy IDs)
745+
// Test filtering by hex-encoded symbol only (searches across all policy IDs)
746+
// MIN in hex: 4d494e
745747
Currency symbolCurrency = Currency.builder()
746-
.symbol("MIN")
748+
.symbol("4d494e") // hex-encoded "MIN"
747749
.build();
748750

749751
Page<TxnEntity> results = txRepository.searchTxnEntitiesAND(
750-
Collections.emptySet(), Set.of(), null, null, null, null, symbolCurrency,
752+
Collections.emptySet(), Set.of(), null, null, null, null, symbolCurrency,
751753
new SimpleOffsetBasedPageRequest(0, 100));
752754

753755
List<TxnEntity> txList = results.getContent();
754-
756+
755757
// Results could be empty if no transactions with MIN tokens exist
756-
// All transactions should contain assets with "MIN" as asset name
758+
// All transactions should contain assets with hex-encoded "MIN" symbol
757759
txList.forEach(tx -> {
758760
assertThat(tx.getTxHash()).isNotNull();
759761
});

0 commit comments

Comments
 (0)