diff --git a/.env.IntegrationTest b/.env.IntegrationTest
index 2a9a5f7d7..1c960a9fe 100644
--- a/.env.IntegrationTest
+++ b/.env.IntegrationTest
@@ -64,6 +64,7 @@ REMOVE_SPENT_UTXOS=false
#The number of safe blocks to keep in the store. 2160 blocks *(20 seconds/block in average)=4320 seconds=12 hours.
REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=2160
BLOCK_TRANSACTION_API_TIMEOUT_SECS=5
+REMOVE_SPENT_UTXOS_BATCH_SIZE=3000
YACI_SPRING_PROFILES=postgres,n2c-socat
# database profiles: h2, h2-testdata, postgres
@@ -79,6 +80,8 @@ HOST_CLUSTER_API_PORT=10000
HOST_OGMIOS_PORT=1337
HOST_KUPO_PORT=1442
HOST_VIEWER_PORT=5173
+PROMETHEUS_PORT=9090
+GRAFANA_PORT=3000
## Devkit env
DEVKIT_ENABLED=true
@@ -149,4 +152,14 @@ CONTINUE_PARSING_ON_ERROR=false
SYNC=false
## Peer Discovery
-PEER_DISCOVERY=false
\ No newline at end of file
+PEER_DISCOVERY=false
+
+## Token Registry
+TOKEN_REGISTRY_ENABLED=false
+TOKEN_REGISTRY_BASE_URL=https://tokens.cardano.org/api
+TOKEN_REGISTRY_CACHE_TTL_HOURS=12
+TOKEN_REGISTRY_LOGO_FETCH=false
+TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS=2
+
+## Mithril version for Docker build
+MITHRIL_VERSION=2524.0
diff --git a/.env.docker-compose b/.env.docker-compose
index 7dd56957c..48836d41f 100644
--- a/.env.docker-compose
+++ b/.env.docker-compose
@@ -59,9 +59,10 @@ SEARCH_LIMIT=100
## Yaci Indexer env
INDEXER_DOCKER_IMAGE_TAG=main
-REMOVE_SPENT_UTXOS=false
-#The number of safe blocks to keep in the store. 2160 blocks *(20 seconds/block in average)=4320 seconds=12 hours.
-REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=2160
+REMOVE_SPENT_UTXOS=true
+#The number of safe blocks to keep in the store. 129600 blocks *(20 seconds/block in average)=30 days.
+REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=129600
+REMOVE_SPENT_UTXOS_BATCH_SIZE=3000
BLOCK_TRANSACTION_API_TIMEOUT_SECS=5
YACI_SPRING_PROFILES=postgres,n2c-socket
@@ -109,3 +110,14 @@ SYNC=true
## Peer Discovery
PEER_DISCOVERY=false
+
+## Token Registry
+TOKEN_REGISTRY_ENABLED=false
+# your local org token registry, e.g. http://myexchange.org/cardano-token-registry/api
+# https://tokens.cardano.org/api cannot be used as it is not possible to request hundreds of tokens due to security / DDOS attacks
+# if you use it, it will work for some requests but not others
+TOKEN_REGISTRY_BASE_URL=
+TOKEN_REGISTRY_CACHE_TTL_HOURS=12
+# by default fetching of logo is disabled as it can add up to a lot of bytes over the wire
+TOKEN_REGISTRY_LOGO_FETCH=false
+TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS=2
diff --git a/.env.docker-compose-preprod b/.env.docker-compose-preprod
index fa22a5875..42b313545 100644
--- a/.env.docker-compose-preprod
+++ b/.env.docker-compose-preprod
@@ -59,9 +59,10 @@ SEARCH_LIMIT=100
## Yaci Indexer env
INDEXER_DOCKER_IMAGE_TAG=main
-REMOVE_SPENT_UTXOS=false
-#The number of safe blocks to keep in the store. 2160 blocks *(20 seconds/block in average)=4320 seconds=12 hours.
-REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=2160
+REMOVE_SPENT_UTXOS=true
+#The number of safe blocks to keep in the store. 129600 blocks *(20 seconds/block in average)=30 days
+REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=129600
+REMOVE_SPENT_UTXOS_BATCH_SIZE=3000
BLOCK_TRANSACTION_API_TIMEOUT_SECS=5
YACI_SPRING_PROFILES=postgres,n2c-socket
@@ -108,4 +109,12 @@ GRAFANA_PORT=3000
POSTGRESQL_EXPORTER_PORT=9187
## Peer Discovery
-PEER_DISCOVERY=true
\ No newline at end of file
+PEER_DISCOVERY=true
+
+## Token Registry
+# Externally hosted token registry is currently only available for mainnet
+TOKEN_REGISTRY_ENABLED=true
+TOKEN_REGISTRY_BASE_URL=http://preview.integrations.cf-systems.internal:8080/api
+TOKEN_REGISTRY_CACHE_TTL_HOURS=1
+TOKEN_REGISTRY_LOGO_FETCH=true
+TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS=2
diff --git a/.env.docker-compose-profile-advanced-level b/.env.docker-compose-profile-advanced-level
index 03854b4d5..c866ff775 100644
--- a/.env.docker-compose-profile-advanced-level
+++ b/.env.docker-compose-profile-advanced-level
@@ -15,7 +15,8 @@ DB_POSTGRES_MAX_PARALLEL_WORKERS_PER_GATHER=8
DB_POSTGRES_MAX_PARALLEL_WORKERS=16
DB_POSTGRES_SEQ_PAGE_COST=0.5
DB_POSTGRES_JIT=off
-DB_POSTGRES_BGWRITER_LRU_MAXPAGES=100
+DB_POSTGRES_BGWRITER_LRU_MAXPAGES=300
DB_POSTGRES_BGWRITER_DELAY=200ms
DB_POSTGRES_WAL_BUFFERS=512MB
DB_POSTGRES_CHECKPOINT_COMPLETION_TARGET=0.9
+DB_POSTGRES_AUTOVACUUM_MAX_WORKERS=5
diff --git a/.env.docker-compose-profile-mid-level b/.env.docker-compose-profile-mid-level
index 82a09e0a3..2f8b91943 100644
--- a/.env.docker-compose-profile-mid-level
+++ b/.env.docker-compose-profile-mid-level
@@ -17,5 +17,6 @@ DB_POSTGRES_MAX_PARALLEL_WORKERS_PER_GATHER=4
DB_POSTGRES_MAX_PARALLEL_WORKERS=8
DB_POSTGRES_SEQ_PAGE_COST=1.0
DB_POSTGRES_JIT=off
-DB_POSTGRES_BGWRITER_LRU_MAXPAGES=100
+DB_POSTGRES_BGWRITER_LRU_MAXPAGES=200
DB_POSTGRES_BGWRITER_DELAY=200ms
+DB_POSTGRES_AUTOVACUUM_MAX_WORKERS=5
diff --git a/.env.h2 b/.env.h2
index f41b041ce..d1daeb9f3 100644
--- a/.env.h2
+++ b/.env.h2
@@ -40,8 +40,10 @@ SEARCH_LIMIT=100
## Yaci Indexer env
INDEXER_DOCKER_IMAGE_TAG=main
-REMOVE_SPENT_UTXOS=false
-REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=2160
+REMOVE_SPENT_UTXOS=true
+REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=129600
+REMOVE_SPENT_UTXOS_BATCH_SIZE=3000
+
BLOCK_TRANSACTION_API_TIMEOUT_SECS=5
YACI_SPRING_PROFILES=h2,n2c-socket
@@ -102,3 +104,10 @@ CONTINUE_PARSING_ON_ERROR=false
## Indexer sync starts after node is at tip. Set false for offline mode.
SYNC=false
+
+## Token Registry
+TOKEN_REGISTRY_ENABLED=false
+TOKEN_REGISTRY_BASE_URL=https://tokens.cardano.org/api
+TOKEN_REGISTRY_CACHE_TTL_HOURS=12
+TOKEN_REGISTRY_LOGO_FETCH=false
+TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS=2
diff --git a/.env.h2-testdata b/.env.h2-testdata
index bac08714e..4eb66ee8c 100644
--- a/.env.h2-testdata
+++ b/.env.h2-testdata
@@ -40,9 +40,11 @@ SEARCH_LIMIT=100
## Yaci Indexer env
INDEXER_DOCKER_IMAGE_TAG=main
-REMOVE_SPENT_UTXOS=false
-#The number of safe blocks to keep in the store. 2160 blocks *(20 seconds/block in average)=4320 seconds=12 hours.
-REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=2160
+REMOVE_SPENT_UTXOS=true
+#The number of safe blocks to keep in the store. 129600 blocks *(20 seconds/block in average)=30 days.
+REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=129600
+REMOVE_SPENT_UTXOS_BATCH_SIZE=3000
+
BLOCK_TRANSACTION_API_TIMEOUT_SECS=5
YACI_SPRING_PROFILES=h2-testdata,n2c-socket
@@ -103,3 +105,10 @@ CONTINUE_PARSING_ON_ERROR=false
## Indexer sync starts after node is at tip. Set false for offline mode.
SYNC=false
+
+## Token Registry
+TOKEN_REGISTRY_ENABLED=false
+TOKEN_REGISTRY_BASE_URL=https://tokens.cardano.org/api
+TOKEN_REGISTRY_CACHE_TTL_HOURS=12
+TOKEN_REGISTRY_LOGO_FETCH=false
+TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS=2
diff --git a/.github/workflows/pr-preprod-tests.yaml b/.github/workflows/pr-preprod-tests.yaml
index 74868819c..f559586ad 100644
--- a/.github/workflows/pr-preprod-tests.yaml
+++ b/.github/workflows/pr-preprod-tests.yaml
@@ -18,7 +18,7 @@ jobs:
timeout-minutes: 30
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Update local repository
run: |
@@ -39,10 +39,35 @@ jobs:
# Stop all services
docker compose \
--env-file .env.docker-compose-preprod \
- --env-file .env.docker-compose-profile-entry-level \
+ --env-file .env.docker-compose-profile-mid-level \
-f docker-compose.yaml \
down
+ - name: Configure environment for full-history tests
+ run: |
+ cd /home/integration/git/cardano-rosetta-java
+
+ ensure_var() {
+ local key="$1"
+ local value="$2"
+ local file=".env.docker-compose-preprod"
+ if grep -q "^${key}=" "$file"; then
+ sed -i "s#^${key}=.*#${key}=${value}#" "$file"
+ else
+ echo "${key}=${value}" >> "$file"
+ fi
+ }
+
+ ensure_var REMOVE_SPENT_UTXOS false
+ ensure_var REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT 129600
+ ensure_var DB_PORT 5433
+ ensure_var TOKEN_REGISTRY_ENABLED true
+ ensure_var TOKEN_REGISTRY_BASE_URL http://preview.integrations.cf-systems.internal:8080/api
+ ensure_var TOKEN_REGISTRY_CACHE_TTL_HOURS 1
+ ensure_var TOKEN_REGISTRY_LOGO_FETCH true
+ ensure_var TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS 2
+ ensure_var PEER_DISCOVERY true
+
- name: Build and start services with PR code
run: |
@@ -55,7 +80,7 @@ jobs:
# Build and start all services
docker compose \
--env-file .env.docker-compose-preprod \
- --env-file .env.docker-compose-profile-entry-level \
+ --env-file .env.docker-compose-profile-mid-level \
-f docker-compose.yaml \
up --build -d --wait
@@ -110,6 +135,17 @@ jobs:
# Sync Python dependencies
uv sync
+ - name: Create test environment file
+ run: |
+ cd /home/integration/git/cardano-rosetta-java
+
+ # Merge env files so tests can read actual configuration
+ cat .env.docker-compose-preprod > .env.test
+ cat .env.docker-compose-profile-mid-level >> .env.test
+
+ # Copy to tests directory
+ cp .env.test tests/data-endpoints/.env
+
- name: Run smoke tests (validate test data)
id: smoke_tests
run: |
@@ -142,6 +178,7 @@ jobs:
# Run behavioral tests (skip smoke tests)
uv run pytest -m "not smoke" \
+ -n auto \
--alluredir=./allure-results \
--tb=short \
-v || TEST_RESULT=$?
@@ -155,6 +192,27 @@ jobs:
ROSETTA_URL: http://localhost:8082
CARDANO_NETWORK: preprod
+ - name: Run construction API tests
+ id: construction_test
+ run: |
+ export PATH="$HOME/.local/bin:$PATH"
+
+ cd /home/integration/git/cardano-rosetta-java/tests/integration
+
+ # Run construction API snapshot tests
+ uv run test_construction_api.py \
+ -v || CONSTRUCTION_RESULT=$?
+
+ # Output test result
+ echo "construction_result=${CONSTRUCTION_RESULT:-0}" >> $GITHUB_OUTPUT
+
+ # Don't fail the whole job if construction tests fail
+ # These are informational for now
+ exit 0
+ env:
+ ROSETTA_URL: http://localhost:8082
+ CARDANO_NETWORK: preprod
+
- name: Generate Allure report
if: always()
run: |
@@ -217,23 +275,26 @@ jobs:
if: failure() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled')
run: |
cd /home/integration/git/cardano-rosetta-java
+ sed -i "s#^DB_PORT=.*#DB_PORT=5433#" .env.docker-compose-preprod
echo "⚠️ Test failed - stopping services"
# Stop all services cleanly
docker compose \
--env-file .env.docker-compose-preprod \
- --env-file .env.docker-compose-profile-entry-level \
+ --env-file .env.docker-compose-profile-mid-level \
-f docker-compose.yaml \
down
# Check if migrations changed in this PR
LAST_TAG=$(git tag --sort=-version:refname | head -1)
- git checkout $LAST_TAG
+ git checkout -f $LAST_TAG
+ sed -i "s#^DB_PORT=.*#DB_PORT=5433#" .env.docker-compose-preprod
STABLE_MIGRATION_HASH=$(find yaci-indexer/src/main/resources/db/store -type f -name 'V*.sql' -exec md5sum {} \; | sort | md5sum | cut -d' ' -f1)
- git checkout -
+ git checkout -f -
+ sed -i "s#^DB_PORT=.*#DB_PORT=5433#" .env.docker-compose-preprod
PR_MIGRATION_HASH=$(find yaci-indexer/src/main/resources/db/store -type f -name 'V*.sql' -exec md5sum {} \; | sort | md5sum | cut -d' ' -f1)
if [ "$STABLE_MIGRATION_HASH" != "$PR_MIGRATION_HASH" ]; then
@@ -242,7 +303,7 @@ jobs:
# Start only the database
docker compose \
--env-file .env.docker-compose-preprod \
- --env-file .env.docker-compose-profile-entry-level \
+ --env-file .env.docker-compose-profile-mid-level \
-f docker-compose.yaml \
up -d db
@@ -262,7 +323,7 @@ jobs:
for VERSION in $CHANGED_MIGRATIONS; do
echo "Removing Flyway metadata for version: $VERSION"
docker exec cardano-rosetta-java-db-1 sh -c \
- "PGPASSWORD=weakpwd#123_d psql -U rosetta_db_admin -d rosetta-java \
+ "PGPASSWORD=weakpwd#123_d psql -U rosetta_db_admin -d rosetta-java -p 5433 \
-c \"DELETE FROM preprod.flyway_schema_history WHERE version LIKE '${VERSION}%';\""
done
@@ -270,13 +331,13 @@ jobs:
else
echo "No specific migration versions detected - truncating flyway history"
docker exec cardano-rosetta-java-db-1 sh -c \
- "PGPASSWORD=weakpwd#123_d psql -U rosetta_db_admin -d rosetta-java \
+ "PGPASSWORD=weakpwd#123_d psql -U rosetta_db_admin -d rosetta-java -p 5433 \
-c 'TRUNCATE preprod.flyway_schema_history;'"
fi
docker compose \
--env-file .env.docker-compose-preprod \
- --env-file .env.docker-compose-profile-entry-level \
+ --env-file .env.docker-compose-profile-mid-level \
-f docker-compose.yaml \
down
fi
@@ -284,11 +345,12 @@ jobs:
# Now safe to rollback
LAST_TAG=$(git tag --sort=-version:refname | head -1)
echo "Rolling back to stable version: $LAST_TAG"
- git checkout $LAST_TAG
+ git checkout -f $LAST_TAG
+ sed -i "s#^DB_PORT=.*#DB_PORT=5433#" .env.docker-compose-preprod
docker compose \
--env-file .env.docker-compose-preprod \
- --env-file .env.docker-compose-profile-entry-level \
+ --env-file .env.docker-compose-profile-mid-level \
-f docker-compose.yaml \
up -d
@@ -305,4 +367,10 @@ jobs:
sleep 5
done
- echo "✅ Rollback to $LAST_TAG completed"
\ No newline at end of file
+ echo "✅ Rollback to $LAST_TAG completed"
+
+ - name: Restore baseline configuration
+ if: always()
+ run: |
+ cd /home/integration/git/cardano-rosetta-java
+ git restore .env.docker-compose-preprod
diff --git a/CLAUDE.md b/CLAUDE.md
index 71936cd40..2fa569802 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -75,6 +75,8 @@ docker exec rosetta tail -f /logs/indexer.log
- Controller implementations in `api/{domain}/controller/` implement generated interfaces
- Always use @Nullable annotation in case of optional fields for function methods parameter inputs and outputs, records, DTOs, and entities
- Avoid if { return } else {} , if we already have a return statement, we can just return the value, no need for else block
+- Use @NotNull annotation everywhere where we can be sure that value will not be null, use @Nullable in case value can be null sometimes
+- Considering that we will have @NotNull and @Nullable annotations, just put nulls checks only when you actually need it, if a field / property is annotated with @NonNull, there is no need for a null check in the code
### Database Architecture
- **Hibernate JPA** for standard ORM operations
diff --git a/README.md b/README.md
index b32753e5a..7630ddcfd 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,7 @@ For every Release we provide pre-built docker images stored in the DockerHub Rep
To start it use the following command:
```bash
- docker run --name rosetta -v {CUSTOM_MOUNT_PATH}:/node --env-file ./docker/.env.dockerfile --env-file ./docker/.env.docker-profile-mid-level -p 8082:8082 --shm-size=4g -d cardanofoundation/cardano-rosetta-java:1.3.3
+ docker run --name rosetta -v {CUSTOM_MOUNT_PATH}:/node --env-file ./docker/.env.dockerfile --env-file ./docker/.env.docker-profile-mid-level -p 8082:8082 --shm-size=4g -d cardanofoundation/cardano-rosetta-java:1.4.0
```
Changes to the configuration can be made by adjusting the `docker/.env.dockerfile` file. For more information on the environment variables, please refer to the [documentation](https://cardano-foundation.github.io/cardano-rosetta-java/docs/install-and-deploy/env-vars).
@@ -108,7 +108,7 @@ Changes to the configuration can be made by adjusting the `docker/.env.dockerfil
If you want to use the `cardano-submit-api` you can additionally expose port `8090`. It can then be used to submit raw cbor transaction (API documentation here: [Link](https://input-output-hk.github.io/cardano-rest/submit-api/))
```bash
- docker run --name rosetta -v {CUSTOM_MOUNT_PATH}:/node --env-file ./docker/.env.dockerfile --env-file ./docker/.env.docker-profile-mid-level -p 8090:8090 -p 8082:8082 --shm-size=4g -d cardanofoundation/cardano-rosetta-java:1.3.3
+ docker run --name rosetta -v {CUSTOM_MOUNT_PATH}:/node --env-file ./docker/.env.dockerfile --env-file ./docker/.env.docker-profile-mid-level -p 8090:8090 -p 8082:8082 --shm-size=4g -d cardanofoundation/cardano-rosetta-java:1.4.0
```
### Docker compose
diff --git a/api/pom.xml b/api/pom.xml
index 448f15007..537cc3231 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -88,6 +88,11 @@
commons-io
commons-io
+
+ com.google.guava
+ guava
+ 33.0.0-jre
+
javax.validation
validation-api
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapper.java b/api/src/main/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapper.java
index 06be63f6f..d54d10a7b 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapper.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapper.java
@@ -1,7 +1,10 @@
package org.cardanofoundation.rosetta.api.account.mapper;
import java.util.List;
+import java.util.Map;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.openapitools.client.model.AccountBalanceResponse;
@@ -10,6 +13,7 @@
import org.cardanofoundation.rosetta.api.account.model.domain.AddressBalance;
import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
import org.cardanofoundation.rosetta.api.block.model.domain.BlockIdentifierExtended;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
import org.cardanofoundation.rosetta.common.mapper.util.BaseMapper;
@Mapper(config = BaseMapper.class, uses = {AccountMapperUtil.class})
@@ -26,13 +30,15 @@ public interface AccountMapper {
*/
@Mapping(target = "blockIdentifier.hash", source = "block.hash")
@Mapping(target = "blockIdentifier.index", source = "block.number")
- @Mapping(target = "balances", qualifiedByName = "mapAddressBalancesToAmounts")
+ @Mapping(target = "balances", source = "balances", qualifiedByName = "mapAddressBalancesToAmounts")
AccountBalanceResponse mapToAccountBalanceResponse(BlockIdentifierExtended block,
- List balances);
+ List balances,
+ @Context Map metadataMap);
@Mapping(target = "blockIdentifier.hash", source = "block.hash")
@Mapping(target = "blockIdentifier.index", source = "block.number")
@Mapping(target = "coins", source = "utxos", qualifiedByName = "mapUtxosToCoins")
AccountCoinsResponse mapToAccountCoinsResponse(BlockIdentifierExtended block,
- List utxos);
+ List utxos,
+ @Context Map metadataMap);
}
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 f9650785d..e642cee3e 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
@@ -1,95 +1,151 @@
package org.cardanofoundation.rosetta.api.account.mapper;
-import java.math.BigInteger;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import javax.annotation.Nullable;
-
-import org.springframework.stereotype.Component;
-import org.mapstruct.Named;
-import org.openapitools.client.model.Amount;
-import org.openapitools.client.model.Coin;
-import org.openapitools.client.model.CoinIdentifier;
-import org.openapitools.client.model.CoinTokens;
-import org.openapitools.client.model.Currency;
-import org.openapitools.client.model.CurrencyMetadata;
-
+import lombok.RequiredArgsConstructor;
import org.cardanofoundation.rosetta.api.account.model.domain.AddressBalance;
import org.cardanofoundation.rosetta.api.account.model.domain.Amt;
import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
import org.cardanofoundation.rosetta.common.mapper.DataMapper;
import org.cardanofoundation.rosetta.common.util.Constants;
+import org.mapstruct.Context;
+import org.mapstruct.Named;
+import org.openapitools.client.model.*;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Nullable;
+import javax.validation.constraints.NotNull;
+import java.math.BigInteger;
+import java.util.*;
@Component
+@RequiredArgsConstructor
public class AccountMapperUtil {
- @Named("mapAddressBalancesToAmounts")
- public List mapAddressBalancesToAmounts(List balances) {
- BigInteger lovelaceAmount = balances.stream()
- .filter(b -> Constants.LOVELACE.equals(b.unit()))
- .map(AddressBalance::quantity)
- .findFirst()
- .orElse(BigInteger.ZERO);
- List amounts = new ArrayList<>();
- // always adding lovelace amount to the beginning of the list. Even if lovelace amount is 0
- amounts.add(DataMapper.mapAmount(String.valueOf(lovelaceAmount), null, null, null));
- balances.stream()
- .filter(b -> !Constants.LOVELACE.equals(b.unit()))
- .forEach(b -> amounts.add(
- DataMapper.mapAmount(b.quantity().toString(),
- b.unit().substring(Constants.POLICY_ID_LENGTH),
- Constants.MULTI_ASSET_DECIMALS,
- new CurrencyMetadata(b.unit().substring(0, Constants.POLICY_ID_LENGTH)))
- ));
- return amounts;
- }
-
- @Named("mapUtxosToCoins")
- public List mapUtxosToCoins(List utxos) {
- return utxos.stream().map(utxo -> {
- Amt adaAsset = utxo.getAmounts().stream()
- .filter(amt -> Constants.LOVELACE.equals(amt.getAssetName()))
- .findFirst()
- .orElseGet(() -> new Amt(null, null, Constants.ADA, BigInteger.ZERO));
- String coinIdentifier = utxo.getTxHash() + ":" + utxo.getOutputIndex();
- return Coin.builder()
- .coinIdentifier(new CoinIdentifier(coinIdentifier))
- .amount(Amount.builder()
- .value(adaAsset.getQuantity().toString())
- .currency(getAdaCurrency())
- .build())
- .metadata(mapCoinMetadata(utxo, coinIdentifier))
- .build();
- }).toList();
- }
-
- @Nullable
- private Map> mapCoinMetadata(Utxo utxo, String coinIdentifier) {
- List coinTokens =
- utxo.getAmounts().stream()
- .filter(Objects::nonNull)
- .filter(amount -> amount.getPolicyId() != null
- && amount.getAssetName() != null
- && amount.getQuantity() != null)
- .map(amount -> {
- CoinTokens tokens = new CoinTokens();
- tokens.setPolicyId(amount.getPolicyId());
- tokens.setTokens(List.of(DataMapper.mapAmount(amount.getQuantity().toString(),
- // unit = assetName + policyId. To get the symbol policy ID must be removed from Unit. According to CIP67
- amount.getUnit().replace(amount.getPolicyId(), ""),
- Constants.MULTI_ASSET_DECIMALS, new CurrencyMetadata(amount.getPolicyId()))));
- return tokens;
- })
- .toList();
- return coinTokens.isEmpty() ? null : Map.of(coinIdentifier, coinTokens);
- }
-
- private Currency getAdaCurrency() {
- return Currency.builder()
- .symbol(Constants.ADA)
- .decimals(Constants.ADA_DECIMALS)
- .build();
- }
+ private final DataMapper dataMapper;
+
+ @Named("mapAddressBalancesToAmounts")
+ public List mapAddressBalancesToAmounts(List balances,
+ @Context Map metadataMap) {
+ BigInteger lovelaceAmount = balances.stream()
+ .filter(b -> Constants.LOVELACE.equals(b.unit()))
+ .map(AddressBalance::quantity)
+ .findFirst()
+ .orElse(BigInteger.ZERO);
+
+ List amounts = new ArrayList<>();
+ // always adding lovelace amount to the beginning of the list. Even if lovelace amount is 0
+ amounts.add(dataMapper.mapAmount(String.valueOf(lovelaceAmount), null, null, null));
+
+ // Filter native token balances (those with proper unit format)
+ List nativeTokenBalances = balances.stream()
+ .filter(b -> !Constants.LOVELACE.equals(b.unit()))
+ .filter(b -> b.unit().length() >= Constants.POLICY_ID_LENGTH) // Has policyId + assetName (assetName can be empty)
+ .toList();
+
+ if (nativeTokenBalances.isEmpty()) {
+ return amounts;
+ }
+
+ // Use pre-fetched metadata passed via @Context from service layer
+ // Process each native token balance with metadata
+ for (AddressBalance b : nativeTokenBalances) {
+ String symbol = b.getSymbol();
+ String policyId = b.getPolicyId();
+
+ AssetFingerprint assetFingerprint = AssetFingerprint.of(policyId, symbol);
+
+ // Get metadata from pre-fetched map
+ TokenRegistryCurrencyData metadata = metadataMap.get(assetFingerprint);
+
+ amounts.add(
+ dataMapper.mapAmount(b.quantity().toString(),
+ symbol,
+ getDecimalsWithFallback(metadata),
+ metadata)
+ );
+ }
+
+ return amounts;
+ }
+
+ @Named("mapUtxosToCoins")
+ public List mapUtxosToCoins(List utxos,
+ @Context Map metadataMap) {
+ return utxos.stream().map(utxo -> {
+ Amt adaAsset = utxo.getAmounts().stream()
+ .filter(amt -> Constants.LOVELACE.equals(amt.getUnit()))
+ .findFirst()
+ .orElseGet(() -> new Amt(null, null, Constants.ADA, BigInteger.ZERO));
+ String coinIdentifier = "%s:%d".formatted(utxo.getTxHash(), utxo.getOutputIndex());
+
+ return Coin.builder()
+ .coinIdentifier(new CoinIdentifier(coinIdentifier))
+ .amount(Amount.builder()
+ .value(adaAsset.getQuantity().toString())
+ .currency(getAdaCurrency())
+ .build())
+
+ .metadata(mapCoinMetadata(utxo, coinIdentifier, metadataMap))
+ .build();
+ }).toList();
+ }
+
+ @Nullable
+ private Map> mapCoinMetadata(Utxo utxo, String coinIdentifier,
+ Map metadataMap) {
+ // Filter only native tokens (non-ADA amounts with policyId)
+ List nativeTokenAmounts = utxo.getAmounts().stream()
+ .filter(Objects::nonNull)
+ .filter(amount -> amount.getPolicyId() != null
+ && amount.getQuantity() != null)
+ .filter(amount -> !Constants.LOVELACE.equals(amount.getUnit())) // exclude ADA
+ .toList();
+
+ if (nativeTokenAmounts.isEmpty()) {
+ return null;
+ }
+
+ // Use pre-fetched metadata passed via @Context from service layer
+ // Create separate CoinTokens entry for each native token (one token per entry)
+ List coinTokens = nativeTokenAmounts.stream()
+ .map(amount -> {
+ String policyId = amount.getPolicyId();
+ String symbol = amount.getSymbolHex();
+
+ AssetFingerprint assetFingerprint = AssetFingerprint.of(policyId, symbol);
+
+ // Get metadata from pre-fetched map
+ TokenRegistryCurrencyData metadata = metadataMap.get(assetFingerprint);
+
+ Amount tokenAmount = dataMapper.mapAmount(
+ amount.getQuantity().toString(),
+ symbol,
+ getDecimalsWithFallback(metadata),
+ metadata
+ );
+
+ CoinTokens tokens = new CoinTokens();
+ tokens.setPolicyId(policyId);
+ tokens.setTokens(List.of(tokenAmount));
+
+ return tokens;
+ })
+ .toList();
+
+ return coinTokens.isEmpty() ? null : Map.of(coinIdentifier, coinTokens);
+ }
+
+ private static int getDecimalsWithFallback(@NotNull TokenRegistryCurrencyData metadata) {
+ return Optional.ofNullable(metadata.getDecimals())
+ .orElse(0);
+ }
+
+ private CurrencyResponse getAdaCurrency() {
+ return CurrencyResponse.builder()
+ .symbol(Constants.ADA)
+ .decimals(Constants.ADA_DECIMALS)
+ .build();
+ }
+
}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/AddressBalance.java b/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/AddressBalance.java
index 21c137dd9..95850b827 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/AddressBalance.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/AddressBalance.java
@@ -3,6 +3,9 @@
import java.math.BigInteger;
import lombok.Builder;
+import org.cardanofoundation.rosetta.common.util.Constants;
+
+import javax.annotation.Nullable;
@Builder
public record AddressBalance(String address,
@@ -10,4 +13,31 @@ public record AddressBalance(String address,
Long slot,
BigInteger quantity,
Long number) {
+
+ /**
+ * Returns symbol as hex
+ * unit (subject) = policyId(hex) + symbol(hex)
+ */
+ @Nullable
+ public String getSymbol() {
+ if (unit == null || unit.length() < Constants.POLICY_ID_LENGTH) {
+ return null;
+ }
+
+ return unit.substring(Constants.POLICY_ID_LENGTH);
+ }
+
+ /**
+ * Returns policyId as hex
+ * unit (subject) = policyId(hex) + symbol(hex)
+ */
+ @Nullable
+ public String getPolicyId() {
+ if (unit == null || unit.length() < Constants.POLICY_ID_LENGTH) {
+ return null;
+ }
+
+ return unit.substring(0, Constants.POLICY_ID_LENGTH);
+ }
+
}
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 1073e0e96..cabb6cec9 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
@@ -1,16 +1,16 @@
package org.cardanofoundation.rosetta.api.account.model.domain;
-import java.io.Serializable;
-import java.math.BigInteger;
-
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
-import com.fasterxml.jackson.databind.PropertyNamingStrategies;
-import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import javax.annotation.Nullable;
+import java.io.Serializable;
+import java.math.BigInteger;
@Data
@NoArgsConstructor
@@ -20,9 +20,35 @@
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Amt implements Serializable {
- private String unit;
+ 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 getSymbolHex() {
+ if (unit == null || policyId == null) {
+ return null;
+ }
+
+ 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/account/model/domain/Utxo.java b/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/Utxo.java
index c9233f0a9..dcef038f5 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/Utxo.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/account/model/domain/Utxo.java
@@ -1,12 +1,14 @@
package org.cardanofoundation.rosetta.api.account.model.domain;
-import java.util.List;
-
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import javax.validation.constraints.NotNull;
+import java.util.ArrayList;
+import java.util.List;
+
@Data
@AllArgsConstructor
@NoArgsConstructor
@@ -16,7 +18,10 @@ public class Utxo {
private String txHash;
private Integer outputIndex;
private String ownerAddr;
- private List amounts;
+
+ @NotNull
+ @Builder.Default
+ private List amounts = new ArrayList<>();
public Utxo(String txHash, Integer outputIndex) {
this.txHash = txHash;
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/AccountServiceImpl.java b/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/AccountServiceImpl.java
index 33b8f4981..8591296d4 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/AccountServiceImpl.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/AccountServiceImpl.java
@@ -2,6 +2,7 @@
import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Pattern;
@@ -16,6 +17,9 @@
import org.cardanofoundation.rosetta.api.account.mapper.AddressBalanceMapper;
import org.cardanofoundation.rosetta.api.account.model.domain.AddressBalance;
import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.cardanofoundation.rosetta.api.common.service.TokenRegistryService;
import org.cardanofoundation.rosetta.api.block.model.domain.BlockIdentifierExtended;
import org.cardanofoundation.rosetta.api.block.service.LedgerBlockService;
import org.cardanofoundation.rosetta.client.YaciHttpGateway;
@@ -44,6 +48,7 @@ public class AccountServiceImpl implements AccountService {
private final AccountMapper accountMapper;
private final YaciHttpGateway yaciHttpGateway;
private final AddressBalanceMapper balanceMapper;
+ private final TokenRegistryService tokenRegistryService;
@Override
public AccountBalanceResponse getAccountBalance(AccountBalanceRequest accountBalanceRequest) {
@@ -70,25 +75,31 @@ public AccountCoinsResponse getAccountCoins(AccountCoinsRequest accountCoinsRequ
String accountAddress = accountCoinsRequest.getAccountIdentifier().getAddress();
CardanoAddressUtils.verifyAddress(accountAddress);
- List currencies = accountCoinsRequest.getCurrencies();
+ List currencies = accountCoinsRequest.getCurrencies();
// accountCoinsRequest.getIncludeMempool(); // TODO
log.debug("[accountCoins] Request received {}", accountCoinsRequest);
if (Objects.nonNull(currencies)) {
validateCurrencies(currencies);
}
- List currenciesRequested = filterRequestedCurrencies(currencies);
+ List currenciesRequested = filterRequestedCurrencies(currencies);
log.debug("[accountCoins] Filter currency is {}", currenciesRequested);
BlockIdentifierExtended latestBlock = ledgerBlockService.findLatestBlockIdentifier();
log.debug("[accountCoins] Latest block is {}", latestBlock);
List utxos = ledgerAccountService.findUtxoByAddressAndCurrency(accountAddress,
currenciesRequested);
log.debug("[accountCoins] found {} Utxos for Address {}", utxos.size(), accountAddress);
- return accountMapper.mapToAccountCoinsResponse(latestBlock, utxos);
+
+ // Extract assets from UTXOs and fetch metadata in single batch call
+ Map metadataMap = tokenRegistryService.fetchMetadataForUtxos(utxos);
+
+ return accountMapper.mapToAccountCoinsResponse(latestBlock, utxos, metadataMap);
}
- private AccountBalanceResponse findBalanceDataByAddressAndBlock(String address, Long number,
- String hash, List currencies) {
+ private AccountBalanceResponse findBalanceDataByAddressAndBlock(String address,
+ Long number,
+ String hash,
+ List currencies) {
return findBlockOrLast(number, hash)
.map(blockDto -> {
@@ -106,7 +117,10 @@ private AccountBalanceResponse findBalanceDataByAddressAndBlock(String address,
balances = ledgerAccountService.findBalanceByAddressAndBlock(address, blockDto.getNumber());
}
- AccountBalanceResponse accountBalanceResponse = accountMapper.mapToAccountBalanceResponse(blockDto, balances);
+ // Extract assets from balances and fetch metadata in single batch call
+ Map metadataMap = tokenRegistryService.fetchMetadataForAddressBalances(balances);
+
+ AccountBalanceResponse accountBalanceResponse = accountMapper.mapToAccountBalanceResponse(blockDto, balances, metadataMap);
if (Objects.nonNull(currencies) && !currencies.isEmpty()) {
validateCurrencies(currencies);
@@ -123,15 +137,15 @@ private AccountBalanceResponse findBalanceDataByAddressAndBlock(String address,
private Optional findBlockOrLast(Long number, String hash) {
if (number != null || hash != null) {
return ledgerBlockService.findBlockIdentifier(number, hash);
- } else {
+ }
+
return Optional.of(ledgerBlockService.findLatestBlockIdentifier());
}
- }
- private void validateCurrencies(List currencies) {
- for (Currency currency : currencies) {
+ private void validateCurrencies(List currencies) {
+ for (CurrencyRequest currency : currencies) {
String symbol = currency.getSymbol();
- CurrencyMetadata metadata = currency.getMetadata();
+ CurrencyMetadataRequest metadata = currency.getMetadata();
if (!isTokenNameValid(symbol)) {
throw invalidTokenNameError("Given name is " + symbol);
}
@@ -151,10 +165,12 @@ private boolean isPolicyIdValid(String policyId) {
return POLICY_ID_VALIDATION.matcher(policyId).matches();
}
- private List filterRequestedCurrencies(List currencies) {
+ private List filterRequestedCurrencies(List currencies) {
boolean isAdaAbsent = Optional.ofNullable(currencies)
- .map(c -> c.stream().map(Currency::getSymbol).noneMatch(Constants.ADA::equals))
+ .map(c -> c.stream().map(CurrencyRequest::getSymbol).noneMatch(Constants.ADA::equals))
.orElse(false);
+
return isAdaAbsent ? currencies : Collections.emptyList();
}
+
}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/LedgerAccountService.java b/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/LedgerAccountService.java
index 4a7181cf6..50ee41f4b 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/LedgerAccountService.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/LedgerAccountService.java
@@ -3,7 +3,7 @@
import java.util.List;
-import org.openapitools.client.model.Currency;
+import org.openapitools.client.model.CurrencyRequest;
import org.cardanofoundation.rosetta.api.account.model.domain.AddressBalance;
import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
@@ -15,6 +15,6 @@ public interface LedgerAccountService {
List findBalanceByAddressAndBlock(String address, Long number);
- List findUtxoByAddressAndCurrency(String address, List currencies);
+ List findUtxoByAddressAndCurrency(String address, List currencies);
}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/LedgerAccountServiceImpl.java b/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/LedgerAccountServiceImpl.java
index a05cd5156..2a8221c5e 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/LedgerAccountServiceImpl.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/account/service/LedgerAccountServiceImpl.java
@@ -10,7 +10,7 @@
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
-import org.openapitools.client.model.Currency;
+import org.openapitools.client.model.CurrencyRequest;
import org.cardanofoundation.rosetta.api.account.mapper.AddressUtxoEntityToUtxo;
import org.cardanofoundation.rosetta.api.account.model.domain.AddressBalance;
@@ -56,7 +56,7 @@ private static List mapAndGroupAddressUtxoEntityToAddressBalance
}
@Override
- public List findUtxoByAddressAndCurrency(String address, List currencies) {
+ public List findUtxoByAddressAndCurrency(String address, List currencies) {
log.debug("Finding UTXOs for address {} with currencies {}", address, currencies);
List addressUtxoEntities = addressUtxoRepository.findunspentUtxosByAddress(address);
return addressUtxoEntities.stream()
@@ -64,13 +64,14 @@ public List findUtxoByAddressAndCurrency(String address, List cu
.toList();
}
- private Utxo createUtxoModel(List currencies, AddressUtxoEntity entity) {
+ private Utxo createUtxoModel(List currencies, AddressUtxoEntity entity) {
Utxo utxo = addressUtxoEntityToUtxo.toDto(entity);
utxo.setAmounts(getAmts(currencies, entity));
+
return utxo;
}
- private static List getAmts(List currencies, AddressUtxoEntity entity) {
+ private static List getAmts(List currencies, AddressUtxoEntity entity) {
return currencies.isEmpty()
? entity.getAmounts().stream().toList()
: entity.getAmounts().stream()
@@ -78,7 +79,7 @@ private static List getAmts(List currencies, AddressUtxoEntity en
.toList();
}
- private static boolean isAmountMatchesCurrency(List currencies, Amt amt) {
+ private static boolean isAmountMatchesCurrency(List currencies, Amt amt) {
return currencies.stream()
.anyMatch(currency -> {
String currencyUnit = Formatters.isEmptyHexString(currency.getSymbol()) ?
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImpl.java b/api/src/main/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImpl.java
index 8e782767c..2bb176fad 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImpl.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImpl.java
@@ -2,6 +2,9 @@
import lombok.RequiredArgsConstructor;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.cardanofoundation.rosetta.api.common.service.TokenRegistryService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
@@ -16,12 +19,15 @@
import org.cardanofoundation.rosetta.api.network.service.NetworkService;
import org.cardanofoundation.rosetta.common.exception.ExceptionFactory;
+import java.util.Map;
+
@RestController
@RequiredArgsConstructor
public class BlockApiImpl implements BlockApi {
private final BlockService blockService;
private final NetworkService networkService;
+ private final TokenRegistryService tokenRegistryService;
private final BlockMapper mapper;
@@ -46,12 +52,16 @@ public ResponseEntity block(@RequestBody BlockRequest blockReques
Block block = blockService.findBlock(index, hash);
- return ResponseEntity.ok(mapper.mapToBlockResponse(block));
+ // Make single batch call to fetch all token metadata for all transactions in this block (empty map if no native tokens)
+ Map metadataMap = tokenRegistryService.fetchMetadataForBlockTxList(block.getTransactions());
+
+ // Always use metadata version - downstream code won't lookup from empty map if no native tokens
+ return ResponseEntity.ok(mapper.mapToBlockResponseWithMetadata(block, metadataMap));
}
@Override
public ResponseEntity blockTransaction(
- @RequestBody BlockTransactionRequest blockReq) {
+ @RequestBody BlockTransactionRequest blockReq) {
if (offlineMode) {
throw ExceptionFactory.notSupportedInOfflineMode();
}
@@ -67,7 +77,11 @@ public ResponseEntity blockTransaction(
BlockTx blockTx = blockService.getBlockTransaction(blockId, blockHash, txHash);
- return ResponseEntity.ok(mapper.mapToBlockTransactionResponse(blockTx));
+ // Make single batch call to fetch all token metadata for this transaction (empty map if no native tokens)
+ Map metadataMap = tokenRegistryService.fetchMetadataForBlockTx(blockTx);
+
+ // Always use metadata version - downstream code won't lookup from empty map if no native tokens
+ return ResponseEntity.ok(mapper.mapToBlockTransactionResponseWithMetadata(blockTx, metadataMap));
}
}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/BlockMapper.java b/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/BlockMapper.java
index 118d3c6f5..676a11351 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/BlockMapper.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/BlockMapper.java
@@ -1,16 +1,15 @@
package org.cardanofoundation.rosetta.api.block.mapper;
+import java.util.Map;
import java.util.concurrent.TimeUnit;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
-import org.openapitools.client.model.BlockIdentifier;
-import org.openapitools.client.model.BlockResponse;
-import org.openapitools.client.model.BlockTransaction;
-import org.openapitools.client.model.BlockTransactionResponse;
-import org.openapitools.client.model.Transaction;
-import org.openapitools.client.model.TransactionIdentifier;
+import org.openapitools.client.model.*;
import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
import org.cardanofoundation.rosetta.api.block.model.domain.Block;
@@ -41,19 +40,35 @@ public interface BlockMapper {
@Mapping(target = "block.transactions", source = "transactions")
BlockResponse mapToBlockResponse(Block model);
+ @Mapping(target = "block.blockIdentifier.hash", source = "model.hash")
+ @Mapping(target = "block.blockIdentifier.index", source = "model.number")
+ @Mapping(target = "block.parentBlockIdentifier.hash", source = "model.previousBlockHash")
+ @Mapping(target = "block.parentBlockIdentifier.index", source = "model.previousBlockNumber")
+ @Mapping(target = "block.timestamp", source = "model.createdAt")
+ @Mapping(target = "block.metadata.transactionsCount", source = "model.transactionsCount")
+ @Mapping(target = "block.metadata.createdBy", source = "model.createdBy")
+ @Mapping(target = "block.metadata.size", source = "model.size")
+ @Mapping(target = "block.metadata.slotNo", source = "model.slotNo")
+ @Mapping(target = "block.metadata.epochNo", source = "model.epochNo")
+ @Mapping(target = "block.transactions", source = "model.transactions", qualifiedByName = "mapToRosettaTransactionWithMetadata")
+ BlockResponse mapToBlockResponseWithMetadata(Block model, @Context Map metadataMap);
+
+ @Named("mapToBlockTransactionWithMetadata")
@Mapping(target = "blockIdentifier", source = "source")
- @Mapping(target = "transaction", source = "source")
- BlockTransaction mapToBlockTransaction(BlockTx source);
+ @Mapping(target = "transaction", source = "source", qualifiedByName = "mapToRosettaTransactionWithMetadata")
+ BlockTransaction mapToBlockTransactionWithMetadata(BlockTx source, @Context Map metadataMap);
@Mapping(target = "hash", source = "blockHash")
@Mapping(target = "index", source = "blockNo")
BlockIdentifier mapToBlockIdentifier(BlockTx source);
- @Mapping(target = "transactionIdentifier", source = "hash")
- @Mapping(target = "metadata.size", source = "size")
- @Mapping(target = "metadata.scriptSize", source = "scriptSize")
- @Mapping(target = "operations", source = "source", qualifiedByName = "mapTransactionsToOperations")
- Transaction mapToRosettaTransaction(BlockTx source);
+
+ @Named("mapToRosettaTransactionWithMetadata")
+ @Mapping(target = "transactionIdentifier", source = "source.hash")
+ @Mapping(target = "metadata.size", source = "source.size")
+ @Mapping(target = "metadata.scriptSize", source = "source.scriptSize")
+ @Mapping(target = "operations", source = "source", qualifiedByName = "mapTransactionsToOperationsWithMetadata")
+ Transaction mapToRosettaTransactionWithMetadata(BlockTx source, @Context Map metadataMap);
@Mapping(target = "hash", source = "txHash")
@Mapping(target = "blockHash", source = "block.hash")
@@ -76,6 +91,16 @@ public interface BlockMapper {
@Mapping(target = "transaction", source = "model")
BlockTransactionResponse mapToBlockTransactionResponse(BlockTx model);
+
+ @Mapping(target = "transaction", source = "model", qualifiedByName = "mapToRosettaTransactionWithMetadata")
+ BlockTransactionResponse mapToBlockTransactionResponseWithMetadata(BlockTx model,
+ @Context Map metadataMap);
+
+ @Mapping(target = "transactionIdentifier", source = "hash")
+ @Mapping(target = "metadata.size", source = "size")
+ @Mapping(target = "metadata.scriptSize", source = "scriptSize")
+ @Mapping(target = "operations", ignore = true)
+ Transaction mapToRosettaTransactionBasic(BlockTx source);
TransactionIdentifier getTransactionIdentifier(String hash);
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapper.java b/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapper.java
index f72ab93e0..cceaa5227 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapper.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapper.java
@@ -1,6 +1,9 @@
package org.cardanofoundation.rosetta.api.block.mapper;
import com.bloxbean.cardano.yaci.core.model.certs.CertificateType;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
@@ -13,6 +16,8 @@
import org.cardanofoundation.rosetta.common.mapper.util.BaseMapper;
import org.cardanofoundation.rosetta.common.util.Constants;
+import java.util.Map;
+
@Mapper(config = BaseMapper.class, uses = {TransactionMapperUtils.class})
public interface TransactionMapper {
@@ -69,7 +74,7 @@ public interface TransactionMapper {
@Mapping(target = "type", constant = Constants.INPUT)
@Mapping(target = "coinChange.coinAction", source = "model", qualifiedByName = "getCoinSpentAction")
- @Mapping(target = "metadata", source = "model.amounts", qualifiedByName = "mapAmountsToOperationMetadataInput")
+ @Mapping(target = "metadata", source = "model.amounts", qualifiedByName = "mapAmountsToOperationMetadataInputWithCache")
@Mapping(target = "operationIdentifier", source = "index", qualifiedByName = "OperationIdentifier")
@Mapping(target = "amount.value", source = "model", qualifiedByName = "getAdaAmountInput")
@Mapping(target = "status", source = "status.status")
@@ -77,19 +82,19 @@ public interface TransactionMapper {
@Mapping(target = "amount.currency.symbol", constant = Constants.ADA)
@Mapping(target = "amount.currency.decimals", constant = Constants.ADA_DECIMALS_STRING)
@Mapping(target = "coinChange.coinIdentifier.identifier", source = "model", qualifiedByName = "getUtxoName")
- Operation mapInputUtxoToOperation(Utxo model, OperationStatus status, int index);
+ Operation mapInputUtxoToOperation(Utxo model, OperationStatus status, int index, @Context Map metadataMap);
@Mapping(target = "type", constant = Constants.OUTPUT)
@Mapping(target = "status", source = "status.status")
@Mapping(target = "coinChange.coinAction", source = "model", qualifiedByName = "getCoinCreatedAction")
@Mapping(target = "operationIdentifier", source = "index", qualifiedByName = "OperationIdentifier")
- @Mapping(target = "metadata", source = "model.amounts", qualifiedByName = "mapAmountsToOperationMetadataOutput")
+ @Mapping(target = "metadata", source = "model.amounts", qualifiedByName = "mapAmountsToOperationMetadataOutputWithCache")
@Mapping(target = "account.address", source = "model.ownerAddr")
@Mapping(target = "amount.value", source = "model", qualifiedByName = "getAdaAmountOutput")
@Mapping(target = "amount.currency.symbol", constant = Constants.ADA)
@Mapping(target = "amount.currency.decimals", constant = Constants.ADA_DECIMALS_STRING)
@Mapping(target = "coinChange.coinIdentifier.identifier", source = "model", qualifiedByName = "getUtxoName")
- Operation mapOutputUtxoToOperation(Utxo model, OperationStatus status, int index);
+ Operation mapOutputUtxoToOperation(Utxo model, OperationStatus status, int index, @Context Map metadataMap);
StakeRegistration mapStakeRegistrationEntityToStakeRegistration(StakeRegistrationEntity entity);
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapperUtils.java b/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapperUtils.java
index 60f99f659..ced554b0a 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapperUtils.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/block/mapper/TransactionMapperUtils.java
@@ -10,17 +10,22 @@
import org.cardanofoundation.rosetta.api.block.model.domain.DRepDelegation;
import org.cardanofoundation.rosetta.api.block.model.domain.GovernancePoolVote;
import org.cardanofoundation.rosetta.api.block.model.domain.StakeRegistration;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
import org.cardanofoundation.rosetta.common.enumeration.OperationType;
import org.cardanofoundation.rosetta.common.mapper.DataMapper;
import org.cardanofoundation.rosetta.common.services.ProtocolParamService;
import org.cardanofoundation.rosetta.common.util.Constants;
+import org.mapstruct.Context;
import org.mapstruct.Named;
import org.openapitools.client.model.*;
import org.springframework.stereotype.Component;
import javax.annotation.Nullable;
+import javax.validation.constraints.NotNull;
import java.math.BigInteger;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -31,7 +36,8 @@
@RequiredArgsConstructor
public class TransactionMapperUtils {
- final ProtocolParamService protocolParamService;
+ private final ProtocolParamService protocolParamService;
+ private final DataMapper dataMapper;
@Named("convertGovAnchorFromRosetta")
public GovVoteRationaleParams convertGovAnchorFromRosetta(Anchor anchor) {
@@ -78,35 +84,47 @@ public DRepParams convertDRepFromRosetta(DRepDelegation.DRep drep) {
return DRepDelegation.DRep.convertDRepFromRosetta(drep);
}
- @Named("mapAmountsToOperationMetadataInput")
- public OperationMetadata mapToOperationMetaDataInput(List amounts) {
- return mapToOperationMetaData(true, amounts);
+ @Named("mapAmountsToOperationMetadataInputWithCache")
+ public OperationMetadata mapToOperationMetaDataInputWithCache(List amounts,
+ @Context Map metadataMap) {
+ return mapToOperationMetaDataWithCache(true, amounts, metadataMap);
}
- @Named("mapAmountsToOperationMetadataOutput")
- public OperationMetadata mapToOperationMetaDataOutput(List amounts) {
- return mapToOperationMetaData(false, amounts);
+ @Named("mapAmountsToOperationMetadataOutputWithCache")
+ public OperationMetadata mapToOperationMetaDataOutputWithCache(List amounts,
+ @Context Map metadataMap) {
+ return mapToOperationMetaDataWithCache(false, amounts, metadataMap);
}
- public OperationMetadata mapToOperationMetaData(boolean spent, List amounts) {
+ @Nullable
+ public OperationMetadata mapToOperationMetaDataWithCache(boolean spent,
+ List amounts,
+ Map metadataMap) {
OperationMetadata operationMetadata = new OperationMetadata();
- Optional.ofNullable(amounts)
- .stream()
- .flatMap(List::stream)
- .filter(amount -> !amount.getAssetName().equals(LOVELACE))
+
+ if (amounts == null || amounts.isEmpty()) {
+ return null;
+ }
+
+ // Filter out ADA amounts
+ List nonAdaAmounts = amounts.stream()
+ .filter(amount -> !LOVELACE.equals(amount.getUnit()))
+ .toList();
+
+ // token bundle is only for ada, no native assets present
+ if (nonAdaAmounts.isEmpty()) {
+ return null;
+ }
+
+ // Group amounts by policyId and create token bundles using the pre-fetched metadata
+ nonAdaAmounts.stream()
.collect(Collectors.groupingBy(Amt::getPolicyId))
.forEach((policyId, policyIdAmounts) ->
operationMetadata.addTokenBundleItem(
TokenBundleItem.builder()
.policyId(policyId)
.tokens(policyIdAmounts.stream()
- .map(amount -> Amount.builder()
- .value(DataMapper.mapValue(amount.getQuantity().toString(), spent))
- .currency(Currency.builder()
- .symbol(amount.getUnit().replace(amount.getPolicyId(), ""))
- .decimals(0)
- .build())
- .build())
+ .map(amount -> extractAmountWithCache(spent, amount, metadataMap))
.toList())
.build()
)
@@ -127,15 +145,18 @@ public String getAdaAmountOutput(Utxo f) {
public String getAdaAmount(Utxo f, boolean input) {
BigInteger adaAmount = Optional.ofNullable(f.getAmounts())
- .map(amts -> amts.stream().filter(amt -> amt.getAssetName().equals(
- LOVELACE)).findFirst().map(Amt::getQuantity).orElse(BigInteger.ZERO))
+ .map(amts -> amts.stream().filter(amt -> LOVELACE.equals(amt.getUnit()))
+ .findFirst()
+ .map(Amt::getQuantity)
+ .orElse(BigInteger.ZERO))
.orElse(BigInteger.ZERO);
return input ? adaAmount.negate().toString() : adaAmount.toString();
}
public Amount getDepositAmountPool() {
String deposit = String.valueOf(protocolParamService.findProtocolParameters().getPoolDeposit());
- return DataMapper.mapAmount(deposit, Constants.ADA, Constants.ADA_DECIMALS, null);
+
+ return dataMapper.mapAmount(deposit, Constants.ADA, Constants.ADA_DECIMALS, null);
}
@Named("getDepositAmountStake")
@@ -143,10 +164,12 @@ public Amount getDepositAmountStake(StakeRegistration model) {
CertificateType type = model.getType();
BigInteger keyDeposit = Optional.ofNullable(protocolParamService.findProtocolParameters()
.getKeyDeposit()).orElse(BigInteger.ZERO);
+
if (type == CertificateType.STAKE_DEREGISTRATION) {
keyDeposit = keyDeposit.negate();
}
- return DataMapper.mapAmount(keyDeposit.toString(), Constants.ADA, Constants.ADA_DECIMALS, null);
+
+ return dataMapper.mapAmount(keyDeposit.toString(), Constants.ADA, Constants.ADA_DECIMALS, null);
}
@Named("OperationIdentifier")
@@ -156,7 +179,7 @@ public OperationIdentifier getOperationIdentifier(long index) {
@Named("getUtxoName")
public String getUtxoName(Utxo model) {
- return model.getTxHash() + ":" + model.getOutputIndex();
+ return "%s:%d".formatted(model.getTxHash(), model.getOutputIndex());
}
@Named("updateDepositAmountNegate")
@@ -165,7 +188,7 @@ public Amount updateDepositAmountNegate(BigInteger amount) {
.map(BigInteger::negate)
.orElse(BigInteger.ZERO);
- return DataMapper.mapAmount(bigInteger.toString(), Constants.ADA, Constants.ADA_DECIMALS, null);
+ return dataMapper.mapAmount(bigInteger.toString(), Constants.ADA, Constants.ADA_DECIMALS, null);
}
@Named("convertStakeCertificateType")
@@ -192,4 +215,26 @@ public CoinAction getCoinCreatedAction(Utxo model) {
return CoinAction.CREATED;
}
+ private Amount extractAmountWithCache(boolean spent,
+ Amt amount,
+ Map metadataMap) {
+ String symbol = amount.getSymbolHex();
+
+ AssetFingerprint assetFingerprint = AssetFingerprint.of(amount.getPolicyId(), symbol);
+
+ TokenRegistryCurrencyData metadata = metadataMap.get(assetFingerprint);
+
+ return dataMapper.mapAmount(
+ dataMapper.mapValue(amount.getQuantity().toString(), spent),
+ symbol,
+ getDecimalsWithFallback(metadata),
+ metadata
+ );
+ }
+
+ private static int getDecimalsWithFallback(@NotNull TokenRegistryCurrencyData metadata) {
+ return Optional.ofNullable(metadata.getDecimals())
+ .orElse(0);
+ }
+
}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/block/service/LedgerBlockServiceImpl.java b/api/src/main/java/org/cardanofoundation/rosetta/api/block/service/LedgerBlockServiceImpl.java
index 4adb41b3d..3691e5705 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/block/service/LedgerBlockServiceImpl.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/block/service/LedgerBlockServiceImpl.java
@@ -64,10 +64,10 @@ public class LedgerBlockServiceImpl implements LedgerBlockService {
@Value("${cardano.rosetta.BLOCK_TRANSACTION_API_TIMEOUT_SECS:5}")
private int blockTransactionApiTimeoutSecs;
- @Value("${cardano.rosetta.REMOVE_SPENT_UTXOS:false}")
+ @Value("${cardano.rosetta.REMOVE_SPENT_UTXOS:true}")
private boolean isRemovalOfSpentUTxOsEnabled;
- @Value("${cardano.rosetta.REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT:2160}")
+ @Value("${cardano.rosetta.REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT:129600}")
private int removeSpentUTxOsLastBlocksGraceCount;
@PostConstruct
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/common/mapper/TokenRegistryMapper.java b/api/src/main/java/org/cardanofoundation/rosetta/api/common/mapper/TokenRegistryMapper.java
new file mode 100644
index 000000000..6f0628cc2
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/common/mapper/TokenRegistryMapper.java
@@ -0,0 +1,60 @@
+package org.cardanofoundation.rosetta.api.common.mapper;
+
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.cardanofoundation.rosetta.common.mapper.util.BaseMapper;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.openapitools.client.model.CurrencyMetadataResponse;
+import org.openapitools.client.model.LogoType;
+
+/**
+ * Mapper for converting domain TokenRegistryCurrencyData to view/serialization CurrencyMetadataResponse.
+ * This mapper is responsible for the serialization layer, converting internal domain objects
+ * to OpenAPI-generated response models.
+ */
+@Mapper(config = BaseMapper.class)
+public interface TokenRegistryMapper {
+
+ /**
+ * Maps TokenRegistryCurrencyData to CurrencyMetadataResponse.
+ * Note: decimals field is intentionally not mapped as it should not be in the response.
+ *
+ * @param data The domain object containing token registry data
+ * @return The OpenAPI response object for serialization
+ */
+ @Mapping(target = "policyId", source = "policyId")
+ @Mapping(target = "subject", source = "subject")
+ @Mapping(target = "name", source = "name")
+ @Mapping(target = "description", source = "description")
+ @Mapping(target = "ticker", source = "ticker")
+ @Mapping(target = "url", source = "url")
+ @Mapping(target = "version", source = "version")
+ @Mapping(target = "logo", source = "logo", qualifiedByName = "mapLogoData")
+ CurrencyMetadataResponse toCurrencyMetadataResponse(TokenRegistryCurrencyData data);
+
+ /**
+ * Maps LogoData to LogoType.
+ *
+ * @param logoData The domain logo data
+ * @return The OpenAPI LogoType
+ */
+ @org.mapstruct.Named("mapLogoData")
+ default LogoType mapLogoData(TokenRegistryCurrencyData.LogoData logoData) {
+ if (logoData == null) {
+ return null;
+ }
+
+ LogoType.FormatEnum format = null;
+ if (logoData.getFormat() != null) {
+ format = switch (logoData.getFormat()) {
+ case BASE64 -> LogoType.FormatEnum.BASE64;
+ case URL -> LogoType.FormatEnum.URL;
+ };
+ }
+
+ return LogoType.builder()
+ .format(format)
+ .value(logoData.getValue())
+ .build();
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..723a06de7
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/common/model/AssetFingerprint.java
@@ -0,0 +1,75 @@
+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;
+
+@Data
+@AllArgsConstructor
+@EqualsAndHashCode
+public class AssetFingerprint {
+
+ private final String policyId;
+ private final String symbol; // assetName as hex
+
+ public String toSubject() {
+ return policyId + symbol;
+ }
+
+ public String toUnit() {
+ return policyId + symbol;
+ }
+
+ public static AssetFingerprint fromUnit(String unit) {
+ return fromSubject(unit);
+ }
+
+ /**
+ * Creates an AssetFingerprint from separate policyId and symbol.
+ * This is a convenience factory method for internal use when policyId and symbol are already separated.
+ * For parsing external subject strings, use {@link #fromSubject(String)} which includes validation.
+ *
+ * @param policyId the policy ID
+ * @param symbol the symbol
+ * @return AssetFingerprint instance
+ */
+ public static AssetFingerprint of(String policyId, String symbol) {
+ return new AssetFingerprint(policyId, symbol);
+ }
+
+ /**
+ * Creates an AssetFingerprint from a subject string (policyId + symbol in hex).
+ *
+ * @param subject the concatenated policyId and symbol in hex format
+ * @return AssetFingerprint instance, or null if subject is invalid
+ */
+ @Nullable
+ public static AssetFingerprint fromSubject(@Nullable String subject) {
+ if (subject == null || subject.length() < Constants.POLICY_ID_LENGTH) {
+ throw new NullPointerException("subject is null");
+ }
+
+ // Validate that subject is valid hex
+ if (!isHex(subject)) {
+ throw new IllegalArgumentException("subject is not a hex string");
+ }
+
+ String policyId = subject.substring(0, Constants.POLICY_ID_LENGTH);
+ String symbol = subject.substring(Constants.POLICY_ID_LENGTH);
+
+ 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/common/model/TokenRegistryCurrencyData.java b/api/src/main/java/org/cardanofoundation/rosetta/api/common/model/TokenRegistryCurrencyData.java
new file mode 100644
index 000000000..6b81ec88f
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/common/model/TokenRegistryCurrencyData.java
@@ -0,0 +1,71 @@
+package org.cardanofoundation.rosetta.api.common.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import javax.annotation.Nullable;
+import java.math.BigDecimal;
+
+/**
+ * Domain object representing token registry currency metadata.
+ * This is an immutable domain object separate from view/serialization concerns.
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TokenRegistryCurrencyData {
+
+ @Nullable
+ private String policyId;
+
+ @Nullable
+ private String subject;
+
+ @Nullable
+ private String name;
+
+ @Nullable
+ private String description;
+
+ @Nullable
+ private String ticker;
+
+ @Nullable
+ private String url;
+
+ @Nullable
+ private LogoData logo;
+
+ @Nullable
+ private BigDecimal version;
+
+ @Nullable
+ private Integer decimals;
+
+ /**
+ * Domain object representing logo information.
+ */
+ @Getter
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class LogoData {
+
+ @Nullable
+ private LogoFormat format;
+
+ @Nullable
+ private String value;
+ }
+
+ /**
+ * Logo format enum for domain layer.
+ */
+ public enum LogoFormat {
+ BASE64,
+ URL
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryService.java b/api/src/main/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryService.java
new file mode 100644
index 000000000..2396a1590
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryService.java
@@ -0,0 +1,99 @@
+package org.cardanofoundation.rosetta.api.common.service;
+
+import org.cardanofoundation.rosetta.api.account.model.domain.AddressBalance;
+import org.cardanofoundation.rosetta.api.account.model.domain.Amt;
+import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
+import org.cardanofoundation.rosetta.api.block.model.domain.BlockTx;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.openapitools.client.model.BlockTransaction;
+import org.openapitools.client.model.Operation;
+
+import lombok.NonNull;
+
+import javax.validation.constraints.NotNull;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Service for retrieving token metadata from the token registry.
+ * Provides normalized token registry currency data for use in the domain layer.
+ * Designed to encourage bulk operations for efficiency.
+ */
+public interface TokenRegistryService {
+
+ /**
+ * Get token metadata for multiple assets using batch request
+ * @param assetFingerprints Set of Asset objects containing policyId and optional assetName
+ * @return Map of Asset -> TokenRegistryCurrencyData with metadata (always returns at least policyId)
+ */
+ Map getTokenMetadataBatch(@NotNull Set assetFingerprints);
+
+ /**
+ * Extract all native token assets from a BlockTx (inputs and outputs)
+ * @param blockTx The block transaction to extract assets from
+ * @return Set of unique Asset objects found in the transaction
+ */
+ Set extractAssetsFromBlockTx(@NonNull BlockTx blockTx);
+
+ /**
+ * Extract all native token assets from a list of BlockTransaction objects
+ * @param transactions List of BlockTransaction objects from search results
+ * @return Set of unique Asset objects found across all transactions
+ */
+ Set extractAssetsFromBlockTransactions(@NotNull List transactions);
+
+ /**
+ * Extract assets from a list of Amt objects (utility method)
+ * @param amounts List of amounts potentially containing native tokens
+ * @return Set of unique Asset objects (excludes ADA/lovelace)
+ */
+ Set extractAssetsFromAmounts(@NonNull List amounts);
+
+ /**
+ * Extract assets from a list of Operation objects
+ * @param operations List of operations potentially containing native tokens
+ * @return Set of unique Asset objects found in operation amounts
+ */
+ Set extractAssetsFromOperations(@NotNull List operations);
+
+ /**
+ * Convenience method to extract assets and fetch metadata for a BlockTx in one call
+ * @param blockTx The block transaction to process
+ * @return Map of Asset -> TokenRegistryCurrencyData with metadata
+ */
+ Map fetchMetadataForBlockTx(@NotNull BlockTx blockTx);
+
+ /**
+ * Convenience method to extract assets and fetch metadata for BlockTransactions in one call
+ * @param transactions List of BlockTransaction objects from search results
+ * @return Map of Asset -> TokenRegistryCurrencyData with metadata
+ */
+ Map fetchMetadataForBlockTransactions(@NotNull List transactions);
+
+ /**
+ * Convenience method to extract assets and fetch metadata for a list of BlockTx objects in one call.
+ * This method handles the common pattern of processing multiple transactions and fetching their metadata
+ * in a single batch call for optimal performance.
+ *
+ * @param blockTxList List of BlockTx objects to process
+ * @return Map of Asset -> TokenRegistryCurrencyData with metadata (empty map if no native tokens)
+ */
+ Map fetchMetadataForBlockTxList(@NotNull List blockTxList);
+
+ /**
+ * Extract all native token assets from AddressBalance list and fetch metadata in a single batch call
+ * @param balances List of address balances potentially containing native tokens
+ * @return Map of Asset -> TokenRegistryCurrencyData with metadata
+ */
+ Map fetchMetadataForAddressBalances(@NotNull List balances);
+
+ /**
+ * Extract all native token assets from UTXO list and fetch metadata in a single batch call
+ * @param utxos List of UTXOs potentially containing native tokens
+ * @return Map of Asset -> TokenRegistryCurrencyData with metadata
+ */
+ Map fetchMetadataForUtxos(@NotNull List utxos);
+
+}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryServiceImpl.java b/api/src/main/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryServiceImpl.java
new file mode 100644
index 000000000..72e48ff6d
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryServiceImpl.java
@@ -0,0 +1,308 @@
+package org.cardanofoundation.rosetta.api.common.service;
+
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import org.cardanofoundation.rosetta.api.account.model.domain.AddressBalance;
+import org.cardanofoundation.rosetta.api.account.model.domain.Amt;
+import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
+import org.cardanofoundation.rosetta.api.block.model.domain.BlockTx;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.cardanofoundation.rosetta.client.TokenRegistryHttpGateway;
+import org.cardanofoundation.rosetta.client.model.domain.TokenMetadata;
+import org.cardanofoundation.rosetta.client.model.domain.TokenProperty;
+import org.cardanofoundation.rosetta.client.model.domain.TokenPropertyNumber;
+import org.cardanofoundation.rosetta.client.model.domain.TokenSubject;
+import org.cardanofoundation.rosetta.common.util.Constants;
+import org.openapitools.client.model.*;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Nullable;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static org.cardanofoundation.rosetta.common.util.Constants.ADA;
+import static org.cardanofoundation.rosetta.common.util.Constants.LOVELACE;
+
+@Service
+@RequiredArgsConstructor
+public class TokenRegistryServiceImpl implements TokenRegistryService {
+
+ private final TokenRegistryHttpGateway tokenRegistryHttpGateway;
+
+ @Override
+ public Map getTokenMetadataBatch(@NotNull Set assetFingerprints) {
+ if (assetFingerprints.isEmpty()) {
+ return Map.of();
+ }
+
+ // Convert assets to subjects for the gateway call
+ Set subjects = assetFingerprints.stream()
+ .map(AssetFingerprint::toSubject)
+ .collect(Collectors.toSet());
+
+ // Make the batch call to the gateway
+ Map> tokenSubjectMap = tokenRegistryHttpGateway.getTokenMetadataBatch(subjects);
+
+ // Convert back to Asset -> TokenRegistryCurrencyData mapping
+ Map result = new HashMap<>();
+
+ for (AssetFingerprint assetFingerprint : assetFingerprints) {
+ String subject = assetFingerprint.toSubject();
+ Optional tokenSubject = tokenSubjectMap.get(subject);
+
+ if (tokenSubject != null && tokenSubject.isPresent()) {
+ result.put(assetFingerprint, extractTokenMetadata(assetFingerprint.getPolicyId(), tokenSubject.get()));
+ } else {
+ // Always return fallback metadata with at least policyId
+ result.put(assetFingerprint, createFallbackMetadata(assetFingerprint.getPolicyId()));
+ }
+ }
+
+ return result;
+ }
+
+ private TokenRegistryCurrencyData extractTokenMetadata(String policyId,
+ TokenSubject tokenSubject) {
+ TokenRegistryCurrencyData.TokenRegistryCurrencyDataBuilder builder = TokenRegistryCurrencyData.builder()
+ .policyId(policyId);
+
+ TokenMetadata tokenMeta = tokenSubject.getMetadata();
+
+ // Mandatory fields from registry API related to token data
+ builder.subject(tokenSubject.getSubject());
+ builder.name(tokenMeta.getName().getValue());
+ builder.description(tokenMeta.getDescription().getValue());
+
+ // Optional fields
+ Optional.ofNullable(tokenMeta.getTicker()).ifPresent(ticker -> builder.ticker(ticker.getValue()));
+ Optional.ofNullable(tokenMeta.getUrl()).ifPresent(url -> builder.url(url.getValue()));
+ Optional.ofNullable(tokenMeta.getLogo()).ifPresent(logo -> builder.logo(convertToLogoData(logo)));
+ Optional.ofNullable(tokenMeta.getVersion()).ifPresent(version -> builder.version(BigDecimal.valueOf(version.getValue())));
+
+ // Set decimals, defaulting to 0 if not found
+ int decimals = Optional.ofNullable(tokenMeta.getDecimals())
+ .map(TokenPropertyNumber::getValue)
+ .map(Long::intValue)
+ .orElse(0);
+ builder.decimals(decimals);
+
+ return builder.build();
+ }
+
+ @Override
+ public Set extractAssetsFromBlockTx(@NonNull BlockTx blockTx) {
+ Set allAssetFingerprints = new HashSet<>();
+
+ // Collect assets from inputs
+ Optional.ofNullable(blockTx.getInputs()).ifPresent(inputs ->
+ inputs.forEach(input ->
+ Optional.ofNullable(input.getAmounts()).ifPresent(amounts ->
+ allAssetFingerprints.addAll(extractAssetsFromAmounts(amounts)))));
+
+ // Collect assets from outputs
+ Optional.ofNullable(blockTx.getOutputs()).ifPresent(outputs ->
+ outputs.forEach(output ->
+ Optional.ofNullable(output.getAmounts()).ifPresent(amounts ->
+ allAssetFingerprints.addAll(extractAssetsFromAmounts(amounts)))));
+
+ return allAssetFingerprints;
+ }
+
+ @Override
+ public Set extractAssetsFromAmounts(@NonNull List amounts) {
+ return amounts.stream()
+ .filter(amount -> amount.getPolicyId() != null)
+ .filter(amount -> !LOVELACE.equals(amount.getUnit())) // Filter out ADA
+ .map(amount -> {
+ String symbol = amount.getSymbolHex();
+
+ return AssetFingerprint.of(amount.getPolicyId(), symbol);
+ })
+ .collect(Collectors.toSet());
+ }
+
+ @Override
+ public Set extractAssetsFromBlockTransactions(@NotNull List transactions) {
+ if (transactions.isEmpty()) {
+ return Set.of();
+ }
+
+ Set allAssetFingerprints = new HashSet<>();
+
+ for (BlockTransaction blockTx : transactions) {
+ Transaction tx = blockTx.getTransaction();
+ allAssetFingerprints.addAll(extractAssetsFromOperations(tx.getOperations()));
+ }
+
+ return allAssetFingerprints;
+ }
+
+ @Override
+ public Set extractAssetsFromOperations(@NotNull List operations) {
+ if (operations.isEmpty()) {
+ return Set.of();
+ }
+
+ Set allAssetFingerprints = new HashSet<>();
+
+ for (Operation operation : operations) {
+ OperationMetadata metadata = operation.getMetadata();
+ if (metadata != null) {
+ List tokenBundle = metadata.getTokenBundle();
+ if (tokenBundle != null) {
+ for (TokenBundleItem bundleItem : tokenBundle) {
+ String policyId = bundleItem.getPolicyId();
+ List tokens = bundleItem.getTokens();
+ if (tokens != null) {
+ for (Amount tokenAmount : tokens) {
+ CurrencyResponse currency = tokenAmount.getCurrency();
+ if (currency != null) {
+ String symbol = currency.getSymbol();
+
+ if (!ADA.equals(symbol) && !LOVELACE.equals(symbol)) {
+ allAssetFingerprints.add(AssetFingerprint.of(policyId, currency.getSymbol()));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Also check the amount field if it contains native tokens
+ Amount amount = operation.getAmount();
+ if (amount != null) {
+ CurrencyResponse currency = amount.getCurrency();
+ if (currency != null) {
+ String symbol = currency.getSymbol();
+ if (!LOVELACE.equals(symbol) && !ADA.equals(symbol)) {
+ CurrencyMetadataResponse currencyMetadata = currency.getMetadata();
+ if (currencyMetadata != null && currencyMetadata.getPolicyId() != null) {
+ allAssetFingerprints.add(AssetFingerprint.of(currencyMetadata.getPolicyId(), symbol));
+ }
+ }
+ }
+ }
+ }
+
+ return allAssetFingerprints;
+ }
+
+ @Override
+ public Map fetchMetadataForBlockTx(@NotNull BlockTx blockTx) {
+ Set assetFingerprints = extractAssetsFromBlockTx(blockTx);
+ if (assetFingerprints.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ return getTokenMetadataBatch(assetFingerprints);
+ }
+
+ @Override
+ public Map fetchMetadataForBlockTransactions(@NotNull List transactions) {
+ if (transactions.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ Set assetFingerprints = extractAssetsFromBlockTransactions(transactions);
+ if (assetFingerprints.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ return getTokenMetadataBatch(assetFingerprints);
+ }
+
+ @Override
+ public Map fetchMetadataForBlockTxList(@NotNull List blockTxList) {
+ if (blockTxList == null || blockTxList.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ // Extract all assets from all transactions in the list
+ Set allAssetFingerprints = new HashSet<>();
+
+ for (BlockTx tx : blockTxList) {
+ allAssetFingerprints.addAll(extractAssetsFromBlockTx(tx));
+ }
+
+ // If there are native tokens, make single batch call for metadata
+ if (!allAssetFingerprints.isEmpty()) {
+ return getTokenMetadataBatch(allAssetFingerprints);
+ }
+
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public Map fetchMetadataForAddressBalances(@NotNull List balances) {
+ Set assetFingerprints = balances.stream()
+ .filter(b -> !LOVELACE.equals(b.unit()))
+ .filter(b -> b.unit().length() >= Constants.POLICY_ID_LENGTH)
+ .map(b -> {
+ String symbol = b.getSymbol();
+ String policyId = b.getPolicyId();
+
+ return AssetFingerprint.of(policyId, symbol);
+ })
+ .collect(Collectors.toSet());
+
+ if (assetFingerprints.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ return getTokenMetadataBatch(assetFingerprints);
+ }
+
+ @Override
+ public Map fetchMetadataForUtxos(@NotNull List utxos) {
+ Set assetFingerprints = new HashSet<>();
+ for (Utxo utxo : utxos) {
+ for (Amt amount : utxo.getAmounts()) {
+ if (amount.getPolicyId() != null && !LOVELACE.equals(amount.getUnit())) { // Filter out ADA and null policyId
+ String symbol = amount.getSymbolHex();
+
+ assetFingerprints.add(AssetFingerprint.of(amount.getPolicyId(), symbol));
+ }
+ }
+ }
+
+ if (assetFingerprints.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ return getTokenMetadataBatch(assetFingerprints);
+ }
+
+ @Nullable
+ private TokenRegistryCurrencyData.LogoData convertToLogoData(TokenProperty logoProperty) {
+ if (logoProperty == null) {
+ return null;
+ }
+ String source = logoProperty.getSource();
+ String value = logoProperty.getValue();
+
+ return TokenRegistryCurrencyData.LogoData.builder()
+ .format(getLogoFormat(source))
+ .value(value)
+ .build();
+ }
+
+ @Nullable
+ private static TokenRegistryCurrencyData.LogoFormat getLogoFormat(@NonNull String source) {
+ return switch (source.toLowerCase()) {
+ case "cip_26" -> TokenRegistryCurrencyData.LogoFormat.BASE64;
+ case "cip_68" -> TokenRegistryCurrencyData.LogoFormat.URL;
+ default -> null;
+ };
+ }
+
+ private TokenRegistryCurrencyData createFallbackMetadata(String policyId) {
+ return TokenRegistryCurrencyData.builder()
+ .policyId(policyId)
+ .build();
+ }
+
+}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/construction/enumeration/CatalystLabels.java b/api/src/main/java/org/cardanofoundation/rosetta/api/construction/enumeration/CatalystLabels.java
deleted file mode 100644
index a1cf918e6..000000000
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/construction/enumeration/CatalystLabels.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package org.cardanofoundation.rosetta.api.construction.enumeration;
-
-import com.fasterxml.jackson.annotation.JsonValue;
-
-public enum CatalystLabels {
- DATA("61284"),
- SIG("61285");
-
- private final String label;
-
- CatalystLabels(String value) {
- this.label = value;
- }
-
- public static CatalystLabels findByValue(String label) {
- for (CatalystLabels a : CatalystLabels.values()) {
- if (a.getLabel().equals(label)) {
- return a;
- }
- }
- return null;
- }
-
- @JsonValue
- public String getLabel() {
- return label;
- }
-
- @Override
- public String toString() {
- return label;
- }
-}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/construction/mapper/ConstructionMapperUtils.java b/api/src/main/java/org/cardanofoundation/rosetta/api/construction/mapper/ConstructionMapperUtils.java
index 6c835473d..00b5e2c82 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/construction/mapper/ConstructionMapperUtils.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/construction/mapper/ConstructionMapperUtils.java
@@ -6,7 +6,7 @@
import org.mapstruct.Named;
import org.openapitools.client.model.AccountIdentifier;
import org.openapitools.client.model.Amount;
-import org.openapitools.client.model.Currency;
+import org.openapitools.client.model.CurrencyResponse;
import org.openapitools.client.model.Signature;
import org.cardanofoundation.rosetta.common.model.cardano.crypto.Signatures;
@@ -37,8 +37,8 @@ public Signatures getSignatures(Signature signature) {
chainCode, address);
}
- private Currency getAdaCurrency() {
- return Currency.builder()
+ private CurrencyResponse getAdaCurrency() {
+ return CurrencyResponse.builder()
.symbol(Constants.ADA)
.decimals(Constants.ADA_DECIMALS)
.build();
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/network/service/NetworkServiceImpl.java b/api/src/main/java/org/cardanofoundation/rosetta/api/network/service/NetworkServiceImpl.java
index 672fdce79..f7a0bca0d 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/api/network/service/NetworkServiceImpl.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/api/network/service/NetworkServiceImpl.java
@@ -56,7 +56,7 @@ public class NetworkServiceImpl implements NetworkService {
@Value("${cardano.rosetta.SYNC_GRACE_SLOTS_COUNT:100}")
private int allowedSlotsDelta;
- @Value("${cardano.rosetta.REMOVE_SPENT_UTXOS:false}")
+ @Value("${cardano.rosetta.REMOVE_SPENT_UTXOS:true}")
private boolean isRemovalOfSpentUTxOsEnabled;
@PostConstruct
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 a6068fc3d..8b3d5d133 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
@@ -6,6 +6,9 @@
import org.cardanofoundation.rosetta.api.block.mapper.BlockMapper;
import org.cardanofoundation.rosetta.api.block.model.domain.BlockTx;
import org.cardanofoundation.rosetta.api.block.model.entity.UtxoKey;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+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.openapitools.client.model.*;
@@ -14,6 +17,7 @@
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Nullable;
+import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
@@ -25,6 +29,7 @@ public class SearchServiceImpl implements SearchService {
private final BlockMapper blockMapper;
private final LedgerSearchService ledgerSearchService;
+ private final TokenRegistryService tokenRegistryService;
@Override
@Transactional // Override class-level readOnly=true for methods that may use temporary tables
@@ -56,7 +61,7 @@ public Page searchTransaction(
.map(c -> org.cardanofoundation.rosetta.api.search.model.Currency.builder()
.symbol(c.getSymbol())
.decimals(c.getDecimals())
- .policyId(Optional.ofNullable(c.getMetadata()).map(CurrencyMetadata::getPolicyId).orElse(null))
+ .policyId(Optional.ofNullable(c.getMetadata()).map(CurrencyMetadataRequest::getPolicyId).orElse(null))
.build())
.orElse(null);
@@ -86,7 +91,13 @@ public Page searchTransaction(
limit
);
- return blockTxes.map(blockMapper::mapToBlockTransaction);
+ // Always fetch metadata for all transactions in this page (will be empty map if no native tokens)
+ final Map metadataMap =
+ tokenRegistryService.fetchMetadataForBlockTxList(blockTxes.getContent());
+
+ // Always use the metadata version (with empty map when no native tokens)
+ return blockTxes.map(tx ->
+ blockMapper.mapToBlockTransactionWithMetadata(tx, metadataMap));
}
static Function> extractUTxOFromCoinIdentifier() {
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/client/CachingTokenRegistryHttpGatewayImpl.java b/api/src/main/java/org/cardanofoundation/rosetta/client/CachingTokenRegistryHttpGatewayImpl.java
new file mode 100644
index 000000000..69429c16b
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/client/CachingTokenRegistryHttpGatewayImpl.java
@@ -0,0 +1,193 @@
+package org.cardanofoundation.rosetta.client;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Stopwatch;
+import com.google.common.cache.Cache;
+import jakarta.annotation.PostConstruct;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.cardanofoundation.rosetta.client.model.domain.TokenCacheEntry;
+import org.cardanofoundation.rosetta.client.model.domain.TokenRegistryBatchRequest;
+import org.cardanofoundation.rosetta.client.model.domain.TokenRegistryBatchResponse;
+import org.cardanofoundation.rosetta.client.model.domain.TokenSubject;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.*;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class CachingTokenRegistryHttpGatewayImpl implements TokenRegistryHttpGateway {
+
+ private final HttpClient httpClient;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final Cache tokenMetadataCache;
+
+ @Value("${cardano.rosetta.TOKEN_REGISTRY_ENABLED:true}")
+ protected boolean enabled;
+
+ @Value("${cardano.rosetta.TOKEN_REGISTRY_BASE_URL:https://tokens.cardano.org/api}")
+ protected String tokenRegistryBaseUrl;
+
+ @Value("${cardano.rosetta.TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS:2}") // aggressive timeout as we do not want to block the request
+ protected int httpRequestTimeoutSeconds;
+
+ @Value("${cardano.rosetta.TOKEN_REGISTRY_LOGO_FETCH:false}")
+ protected boolean logoFetchEnabled;
+
+ private String batchEndpointUrl;
+
+ @PostConstruct
+ public void init() {
+ batchEndpointUrl = tokenRegistryBaseUrl + "/v2/subjects/query";
+ log.info("TokenRegistryHttpGatewayImpl initialized with enabled: {}, batchEndpointUrl: {}, httpRequestTimeoutSeconds: {}",
+ enabled, batchEndpointUrl, httpRequestTimeoutSeconds);
+ }
+
+ @Override
+ public Map> getTokenMetadataBatch(@NonNull Set subjects) {
+ if (!enabled) {
+ log.debug("Token registry is disabled, returning empty map");
+ return Collections.emptyMap();
+ }
+
+ if (subjects.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ // Check cache for existing entries
+ Set subjectsToFetch = new HashSet<>();
+ Map> result = new HashMap<>();
+
+ for (String subject : subjects) {
+ TokenCacheEntry cached = tokenMetadataCache.getIfPresent(subject);
+
+ if (cached != null) {
+ // We have a cache entry (either found or not found)
+ result.put(subject, cached.getTokenSubject());
+ log.debug("Retrieved cached entry for subject: {} (found: {})", subject, cached.isFound());
+ } else {
+ // Not in cache at all, need to fetch
+ subjectsToFetch.add(subject);
+ }
+ }
+
+ // If we have all subjects cached, return cached results
+ if (subjectsToFetch.isEmpty()) {
+ log.debug("All subjects found in cache, returning cached results");
+ return result;
+ }
+
+ log.info("Initiating token registry request for {} subjects", subjectsToFetch.size());
+
+ Stopwatch stopwatch = Stopwatch.createStarted();
+
+ try {
+ // Create batch request with selective properties
+ TokenRegistryBatchRequest request = TokenRegistryBatchRequest.builder()
+ .subjects(new ArrayList<>(subjectsToFetch))
+ .properties(buildPropertiesList())
+ .build();
+
+ // Prepare HTTP request
+ String requestBody = objectMapper.writeValueAsString(request);
+
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .uri(URI.create(batchEndpointUrl))
+ .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+ .timeout(Duration.ofSeconds(httpRequestTimeoutSeconds))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .build();
+
+ // Execute request
+ HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+
+ long elapsedMillis = stopwatch.elapsed(MILLISECONDS);
+ log.info("Token registry fetch completed in {} ms (HTTP status: {})", elapsedMillis, response.statusCode());
+
+ if (response.statusCode() != 200) {
+ log.error("Token registry returned non-200 status: {} for batch request", response.statusCode());
+ return result; // Return partial results from cache
+ }
+
+ // Parse response
+ TokenRegistryBatchResponse batchResponse = objectMapper.readValue(response.body(), TokenRegistryBatchResponse.class);
+
+ if (batchResponse.getSubjects() != null) {
+ for (TokenSubject tokenSubject : batchResponse.getSubjects()) {
+ // If subject is in response, cache it as found
+ result.put(tokenSubject.getSubject(), Optional.of(tokenSubject));
+ tokenMetadataCache.put(tokenSubject.getSubject(), TokenCacheEntry.found(tokenSubject));
+ }
+ }
+
+ // Cache empty results for subjects that were requested but not found in the response
+ for (String subjectToFetch : subjectsToFetch) {
+ if (!result.containsKey(subjectToFetch)) {
+ result.put(subjectToFetch, Optional.empty());
+ tokenMetadataCache.put(subjectToFetch, TokenCacheEntry.notFound());
+ }
+ }
+
+ int fetchedCount = batchResponse.getSubjects() != null ? batchResponse.getSubjects().size() : 0;
+ log.info("Successfully fetched {} token metadata entries out of {} requested subjects",
+ fetchedCount, subjectsToFetch.size());
+
+ return result;
+
+ } catch (IOException e) {
+ long elapsedMillis = stopwatch.elapsed(MILLISECONDS);
+ log.error("IO error while fetching token metadata batch after {} ms for {} subjects", elapsedMillis, subjectsToFetch.size(), e);
+ return result; // Return partial results from cache
+ } catch (InterruptedException e) {
+ long elapsedMillis = stopwatch.elapsed(MILLISECONDS);
+ log.error("Request interrupted while fetching token metadata batch after {} ms for {} subjects", elapsedMillis, subjectsToFetch.size(), e);
+ Thread.currentThread().interrupt();
+ return result; // Return partial results from cache
+ } catch (Exception e) {
+ long elapsedMillis = stopwatch.elapsed(MILLISECONDS);
+ log.error("Unexpected error while fetching token metadata batch after {} ms for {} subjects", elapsedMillis, subjectsToFetch.size(), e);
+ return result; // Return partial results from cache
+ }
+ }
+
+ /** helper for testing */
+ void evictFromCache(String subject) {
+ tokenMetadataCache.invalidate(subject);
+ log.debug("Evicted cache entry for subject: {}", subject);
+ }
+
+ List buildPropertiesList() {
+ List properties = new ArrayList<>();
+
+ // Add all properties except logo (conditionally)
+ properties.add("name");
+ properties.add("description");
+ properties.add("ticker");
+ properties.add("decimals");
+ properties.add("url");
+ properties.add("version");
+
+ // Only add logo if enabled
+ if (logoFetchEnabled) {
+ properties.add("logo");
+ log.debug("Logo fetching enabled - including logo property in request");
+ } else {
+ log.debug("Logo fetching disabled - excluding logo property from request");
+ }
+
+ return properties;
+ }
+
+}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/client/TokenRegistryHttpGateway.java b/api/src/main/java/org/cardanofoundation/rosetta/client/TokenRegistryHttpGateway.java
new file mode 100644
index 000000000..1b4e0e548
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/client/TokenRegistryHttpGateway.java
@@ -0,0 +1,24 @@
+package org.cardanofoundation.rosetta.client;
+
+import org.cardanofoundation.rosetta.client.model.domain.TokenSubject;
+import org.springframework.lang.Nullable;
+
+import javax.validation.constraints.NotNull;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Gateway for interacting with the Cardano Token Registry API
+ */
+public interface TokenRegistryHttpGateway {
+
+ /**
+ * Get token metadata for multiple subjects using batch request
+ * @param subjects Set of subject identifiers (policy_id + asset_name hex)
+ * @return Map of subject -> Optional with metadata
+ */
+ Map> getTokenMetadataBatch(@NotNull Set subjects);
+
+}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenCacheEntry.java b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenCacheEntry.java
new file mode 100644
index 000000000..2d9e4a20a
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenCacheEntry.java
@@ -0,0 +1,48 @@
+package org.cardanofoundation.rosetta.client.model.domain;
+
+import java.util.Optional;
+
+/**
+ * Cache entry wrapper for token registry responses.
+ * Allows us to cache both found tokens and "not found" results to avoid repeated registry calls.
+ * This prevents unnecessary HTTP requests for tokens that don't exist in the registry.
+ */
+public record TokenCacheEntry(Optional tokenSubject, boolean found) {
+
+ /**
+ * Creates a cache entry for a found token
+ *
+ * @param tokenSubject the token metadata found in the registry
+ * @return cache entry representing a found token
+ */
+ public static TokenCacheEntry found(TokenSubject tokenSubject) {
+ return new TokenCacheEntry(Optional.of(tokenSubject), true);
+ }
+
+ /**
+ * Creates a cache entry for a token that was not found in the registry
+ *
+ * @return cache entry representing a token not found in the registry
+ */
+ public static TokenCacheEntry notFound() {
+ return new TokenCacheEntry(Optional.empty(), false);
+ }
+
+ /**
+ * Returns true if this entry represents a token that was found in the registry
+ *
+ * @return true if token was found and has valid metadata
+ */
+ public boolean isFound() {
+ return found && tokenSubject.isPresent();
+ }
+
+ /**
+ * Returns the token subject if found, otherwise empty Optional
+ *
+ * @return Optional containing the TokenSubject if found, empty otherwise
+ */
+ public Optional getTokenSubject() {
+ return tokenSubject;
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenMetadata.java b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenMetadata.java
new file mode 100644
index 000000000..a98359957
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenMetadata.java
@@ -0,0 +1,44 @@
+package org.cardanofoundation.rosetta.client.model.domain;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.lang.Nullable;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class TokenMetadata {
+
+ @JsonProperty("name")
+ private TokenProperty name;
+
+ @JsonProperty("description")
+ private TokenProperty description;
+
+ @JsonProperty("ticker")
+ @Nullable
+ private TokenProperty ticker;
+
+ @JsonProperty("decimals")
+ @Nullable
+ private TokenPropertyNumber decimals;
+
+ @JsonProperty("logo")
+ @Nullable
+ private TokenProperty logo;
+
+ @JsonProperty("url")
+ @Nullable
+ private TokenProperty url;
+
+ @JsonProperty("version")
+ @Nullable
+ private TokenPropertyNumber version;
+
+}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenProperty.java b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenProperty.java
new file mode 100644
index 000000000..7ef78da06
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenProperty.java
@@ -0,0 +1,24 @@
+package org.cardanofoundation.rosetta.client.model.domain;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.lang.Nullable;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class TokenProperty {
+
+ @JsonProperty("value")
+ private String value;
+
+ @JsonProperty("source")
+ private String source;
+
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenPropertyNumber.java b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenPropertyNumber.java
new file mode 100644
index 000000000..36754a769
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenPropertyNumber.java
@@ -0,0 +1,23 @@
+package org.cardanofoundation.rosetta.client.model.domain;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class TokenPropertyNumber {
+
+ @JsonProperty("value")
+ private Long value;
+
+ @JsonProperty("source")
+ private String source;
+
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenRegistryBatchRequest.java b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenRegistryBatchRequest.java
new file mode 100644
index 000000000..9aa0ecb6b
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenRegistryBatchRequest.java
@@ -0,0 +1,28 @@
+package org.cardanofoundation.rosetta.client.model.domain;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.validation.Valid;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class TokenRegistryBatchRequest {
+
+ @JsonProperty("subjects")
+ @Valid
+ private List subjects;
+
+ @JsonProperty("properties")
+ @Valid
+ private List properties;
+
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenRegistryBatchResponse.java b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenRegistryBatchResponse.java
new file mode 100644
index 000000000..61eea5978
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenRegistryBatchResponse.java
@@ -0,0 +1,28 @@
+package org.cardanofoundation.rosetta.client.model.domain;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.lang.Nullable;
+
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class TokenRegistryBatchResponse {
+
+ @JsonProperty("subjects")
+ @Nullable
+ private List subjects;
+
+ @JsonProperty("queryPriority")
+ @Nullable
+ private List queryPriority;
+
+}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenSubject.java b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenSubject.java
new file mode 100644
index 000000000..855485bc3
--- /dev/null
+++ b/api/src/main/java/org/cardanofoundation/rosetta/client/model/domain/TokenSubject.java
@@ -0,0 +1,24 @@
+package org.cardanofoundation.rosetta.client.model.domain;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.lang.Nullable;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class TokenSubject {
+
+ @JsonProperty("subject")
+ private String subject;
+
+ @JsonProperty("metadata")
+ private TokenMetadata metadata;
+
+}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/enumeration/CatalystDataIndexes.java b/api/src/main/java/org/cardanofoundation/rosetta/common/enumeration/CatalystDataIndexes.java
deleted file mode 100644
index 2e226cc29..000000000
--- a/api/src/main/java/org/cardanofoundation/rosetta/common/enumeration/CatalystDataIndexes.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.cardanofoundation.rosetta.common.enumeration;
-
-import com.fasterxml.jackson.annotation.JsonValue;
-
-public enum CatalystDataIndexes {
- VOTING_KEY(1L),
- STAKE_KEY(2L),
- REWARD_ADDRESS(3L),
- VOTING_NONCE(4L);
-
- private final Long value;
-
- CatalystDataIndexes(Long value) {
- this.value = value;
- }
-
- public static CatalystDataIndexes findByValue(Long value) {
- for (CatalystDataIndexes a : CatalystDataIndexes.values()) {
- if (a.getValue().equals(value)) {
- return a;
- }
- }
- return null;
- }
-
- @JsonValue
- public Long getValue() {
- return value;
- }
-
- @Override
- public String toString() {
- return String.valueOf(value);
- }
-}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/enumeration/CatalystSigIndexes.java b/api/src/main/java/org/cardanofoundation/rosetta/common/enumeration/CatalystSigIndexes.java
deleted file mode 100644
index 3f7326495..000000000
--- a/api/src/main/java/org/cardanofoundation/rosetta/common/enumeration/CatalystSigIndexes.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package org.cardanofoundation.rosetta.common.enumeration;
-
-public enum CatalystSigIndexes {
- VOTING_SIGNATURE(1);
-
- private final Integer value;
-
- CatalystSigIndexes(int value) {
- this.value = value;
- }
-
- public Integer getValue() {
- return value;
- }
-}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/CborMapToOperation.java b/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/CborMapToOperation.java
index 58f9c9aaf..0739d0840 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/CborMapToOperation.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/CborMapToOperation.java
@@ -274,7 +274,7 @@ private static void addValueToAmount(Map am, Amount amount) {
private static void addCurrencyToAmount(Map amountMap, Amount amount) {
Optional.ofNullable(amountMap.get(key(Constants.CURRENCY))).ifPresent(o -> {
Map currencyMap = (Map) o;
- Currency currency = getCurrencyMap(currencyMap);
+ CurrencyResponse currency = getCurrencyMap(currencyMap);
addMetadataToCurrency(currencyMap, currency);
amount.setCurrency(currency);
});
@@ -285,24 +285,17 @@ private static void addCurrencyToAmount(Map amountMap, Amount amount) {
* @param currencyMap The map containing the currency field
* @param currency The Currency object to fill
*/
- private static void addMetadataToCurrency(Map currencyMap, Currency currency) {
- Optional.ofNullable(currencyMap.get(key(Constants.METADATA))).ifPresent(o -> {
- CurrencyMetadata metadata = new CurrencyMetadata();
- Map addedMetadataMap = (Map) o;
- addPolicyIdToMetadata(addedMetadataMap, metadata);
+ private static void addMetadataToCurrency(Map currencyMap, CurrencyResponse currency) {
+ Optional.ofNullable(currencyMap.get(key(Constants.POLICYID))).ifPresent(o -> {
+ String policyId = ((UnicodeString) o).getString();
+ CurrencyMetadataResponse metadata = CurrencyMetadataResponse.builder()
+ .policyId(policyId)
+ .build();
+
currency.setMetadata(metadata);
});
}
- /**
- * Add PolicyId to CurrencyMetadata object. The policyId is accessed through {@value Constants#POLICYID}
- * @param addedMetadataMap The map containing the metadata field
- * @param metadata The CurrencyMetadata object to fill
- */
- private static void addPolicyIdToMetadata(Map addedMetadataMap, CurrencyMetadata metadata) {
- Optional.ofNullable(addedMetadataMap.get(key(Constants.POLICYID)))
- .ifPresent(o -> metadata.setPolicyId(((UnicodeString) o).getString()));
- }
/**
* Returns a Currency object populated from the cbor MAP if not null.
@@ -310,8 +303,8 @@ private static void addPolicyIdToMetadata(Map addedMetadataMap, CurrencyMetadata
* @param currencyMap The map containing the currency field
* @return The populated Currency object
*/
- private static Currency getCurrencyMap(Map currencyMap) {
- Currency currency = new Currency();
+ private static CurrencyResponse getCurrencyMap(Map currencyMap) {
+ CurrencyResponse currency = new CurrencyResponse();
Optional.ofNullable(currencyMap.get(key(Constants.SYMBOL))).ifPresent(o1 -> {
currency.setSymbol(((UnicodeString) o1).getString());
});
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/DataMapper.java b/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/DataMapper.java
index f38dbc654..d2f86997a 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/DataMapper.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/DataMapper.java
@@ -1,18 +1,23 @@
package org.cardanofoundation.rosetta.common.mapper;
-import java.util.Objects;
-
import lombok.RequiredArgsConstructor;
-
+import org.cardanofoundation.rosetta.api.common.mapper.TokenRegistryMapper;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.cardanofoundation.rosetta.common.util.Constants;
import org.openapitools.client.model.Amount;
-import org.openapitools.client.model.Currency;
-import org.openapitools.client.model.CurrencyMetadata;
+import org.openapitools.client.model.CurrencyMetadataResponse;
+import org.openapitools.client.model.CurrencyResponse;
+import org.springframework.stereotype.Component;
-import org.cardanofoundation.rosetta.common.util.Constants;
+import javax.annotation.Nullable;
+import java.util.Objects;
-@RequiredArgsConstructor(access = lombok.AccessLevel.PRIVATE)
+@Component
+@RequiredArgsConstructor
public class DataMapper {
+ private final TokenRegistryMapper tokenRegistryMapper;
+
/**
* Basic mapping if a value is spent or not.
*
@@ -20,7 +25,7 @@ public class DataMapper {
* @param spent if the value is spent. Will add a "-" in front of the value if spent.
* @return the mapped value
*/
- public static String mapValue(String value, boolean spent) {
+ public String mapValue(String value, boolean spent) {
return spent ? "-" + value : value;
}
@@ -29,26 +34,32 @@ public static String mapValue(String value, boolean spent) {
* 6 decimals are used.
*
* @param value The amount of the token
- * @param symbol The symbol of the token - it will be hex encoded
- * @param decimals The number of decimals of the token
- * @param metadata The metadata of the token
+ * @param symbol The symbol of the token - it will be hex encoded (null for ADA)
+ * @param decimals The number of decimals of the token (null for ADA)
+ * @param metadata The metadata of the token (domain object, null for ADA)
* @return The Rosetta compatible Amount
*/
- public static Amount mapAmount(String value, String symbol, Integer decimals,
- CurrencyMetadata metadata) {
+ public Amount mapAmount(String value,
+ @Nullable String symbol,
+ @Nullable Integer decimals,
+ @Nullable TokenRegistryCurrencyData metadata) {
if (Objects.isNull(symbol)) {
symbol = Constants.ADA;
- }
- if (Objects.isNull(decimals)) {
decimals = Constants.ADA_DECIMALS;
}
Amount amount = new Amount();
amount.setValue(value);
- amount.setCurrency(Currency.builder()
+
+ // Convert domain metadata to response metadata (without decimals field) for serialization
+ CurrencyMetadataResponse metadataResponse = metadata != null ? tokenRegistryMapper.toCurrencyMetadataResponse(metadata) : null;
+
+ amount.setCurrency(CurrencyResponse.builder()
.symbol(symbol)
.decimals(decimals)
- .metadata(metadata)
- .build());
+ .metadata(metadataResponse)
+ .build()
+ );
+
return amount;
}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/OperationToCborMap.java b/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/OperationToCborMap.java
index 5e1ef6c18..c9a9b3101 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/OperationToCborMap.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/OperationToCborMap.java
@@ -336,7 +336,7 @@ private static void addAmountToMap(Amount amount, Map operationMap) {
Optional.ofNullable(amount).ifPresent(am -> {
Map amountMap = new Map();
addCurrencyToAmountMap(am.getCurrency(), amountMap);
- putStringDataItemToMap(amountMap,Constants.VALUE,am.getValue());
+ putStringDataItemToMap(amountMap,Constants.VALUE, am.getValue());
operationMap.put(key(Constants.AMOUNT), amountMap);
});
}
@@ -346,10 +346,10 @@ private static void addAmountToMap(Amount amount, Map operationMap) {
* @param currency currency
* @param amountMap amountMap
*/
- private static void addCurrencyToAmountMap(Currency currency, Map amountMap) {
+ private static void addCurrencyToAmountMap(CurrencyResponse currency, Map amountMap) {
Optional.ofNullable(currency).ifPresent(cur -> {
Map currencyMap = new Map();
- putStringDataItemToMap(currencyMap,Constants.SYMBOL, cur.getSymbol());
+ putStringDataItemToMap(currencyMap, Constants.SYMBOL, cur.getSymbol());
putUnsignedIntegerToMap(currencyMap, Constants.DECIMALS, cur.getDecimals());
addMetadataToCurrencyMap(cur.getMetadata(), currencyMap);
amountMap.put(key(Constants.CURRENCY), currencyMap);
@@ -361,11 +361,11 @@ private static void addCurrencyToAmountMap(Currency currency, Map amountMap) {
* @param metadata metadata
* @param currencyMap currencyMap
*/
- private static void addMetadataToCurrencyMap(CurrencyMetadata metadata, Map currencyMap) {
- Optional.ofNullable(metadata).ifPresent(m -> {
- Map metadataMap = new Map();
- Optional.ofNullable(metadata.getPolicyId()).ifPresent(policyId -> metadataMap.put(key(Constants.POLICYID), new UnicodeString(policyId)));
- currencyMap.put(key(Constants.METADATA), metadataMap);
+ private static void addMetadataToCurrencyMap(CurrencyMetadataResponse metadata, Map currencyMap) {
+ Optional.ofNullable(metadata).ifPresent(meta -> {
+ Optional.ofNullable(meta.getPolicyId()).ifPresent(policyId -> {
+ currencyMap.put(key(Constants.POLICYID), new UnicodeString(policyId));
+ });
});
}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/util/OperationMapperService.java b/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/util/OperationMapperService.java
index 7d07e5f78..bd7fa9864 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/util/OperationMapperService.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/common/mapper/util/OperationMapperService.java
@@ -1,10 +1,14 @@
package org.cardanofoundation.rosetta.common.mapper.util;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.mutable.MutableInt;
import org.cardanofoundation.rosetta.api.block.mapper.TransactionMapper;
import org.cardanofoundation.rosetta.api.block.model.domain.BlockTx;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
import org.cardanofoundation.rosetta.common.util.RosettaConstants;
+import org.mapstruct.Context;
import org.mapstruct.Named;
import org.openapitools.client.model.Operation;
import org.openapitools.client.model.OperationIdentifier;
@@ -13,10 +17,12 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
@Component
@RequiredArgsConstructor
+@Slf4j
public class OperationMapperService {
final TransactionMapper transactionMapper;
@@ -29,16 +35,20 @@ public class OperationMapperService {
.status(RosettaConstants.INVALID_OPERATION_STATUS.getStatus())
.build();
- @Named("mapTransactionsToOperations")
- public List mapTransactionsToOperations(BlockTx source){
+
+ @Named("mapTransactionsToOperationsWithMetadata")
+ public List mapTransactionsToOperationsWithMetadata(BlockTx source,
+ @Context Map metadataMap) {
List operations = new ArrayList<>();
MutableInt ix = new MutableInt(0);
OperationStatus txStatus = source.isInvalid() ? invalidOperationStatus: successOperationStatus;
+ // Use the pre-fetched metadata map instead of fetching again
List inpOps = Optional.ofNullable(source.getInputs()).stream()
.flatMap(List::stream)
- .map(input -> transactionMapper.mapInputUtxoToOperation(input, txStatus, ix.getAndIncrement()))
+ .map(input -> transactionMapper.mapInputUtxoToOperation(input, txStatus, ix.getAndIncrement(), metadataMap))
.toList();
+
operations.addAll(inpOps);
operations.addAll(Optional.ofNullable(source.getWithdrawals()).stream()
@@ -85,7 +95,7 @@ public List mapTransactionsToOperations(BlockTx source){
.flatMap(List::stream)
.map(output -> {
Operation operation = transactionMapper.mapOutputUtxoToOperation(output,
- txStatus, ix.getAndIncrement());
+ txStatus, ix.getAndIncrement(), metadataMap);
// It's needed to add output index for output Operations, this represents the output index of these utxos
Optional.ofNullable(operation.getOperationIdentifier())
.ifPresent(operationIdentifier ->
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/util/MoreEnums.java b/api/src/main/java/org/cardanofoundation/rosetta/common/util/MoreEnums.java
index 17dfc3815..a5095c70b 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/common/util/MoreEnums.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/common/util/MoreEnums.java
@@ -1,10 +1,10 @@
package org.cardanofoundation.rosetta.common.util;
+import lombok.experimental.UtilityClass;
+
import java.util.Arrays;
import java.util.Optional;
-import lombok.experimental.UtilityClass;
-
@UtilityClass
public class MoreEnums {
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/util/ParseConstructionUtil.java b/api/src/main/java/org/cardanofoundation/rosetta/common/util/ParseConstructionUtil.java
index 25e6405c3..8c53e97dc 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/common/util/ParseConstructionUtil.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/common/util/ParseConstructionUtil.java
@@ -13,7 +13,6 @@
import org.apache.commons.lang3.ObjectUtils;
import org.cardanofoundation.rosetta.common.enumeration.OperationType;
import org.cardanofoundation.rosetta.common.exception.ExceptionFactory;
-import org.cardanofoundation.rosetta.common.mapper.DataMapper;
import org.cardanofoundation.rosetta.common.model.cardano.network.RelayType;
import org.openapitools.client.model.*;
import org.openapitools.client.model.Relay;
@@ -95,7 +94,10 @@ public static Operation transActionOutputToOperation(TransactionOutput output,
OperationIdentifier operationIdentifier = new OperationIdentifier(index, null);
AccountIdentifier account = new AccountIdentifier(output.getAddress(), null, null);
Amount amount = new Amount(output.getValue().getCoin().toString(),
- new Currency(Constants.ADA, Constants.ADA_DECIMALS, null), null);
+ CurrencyResponse.builder()
+ .symbol(Constants.ADA)
+ .decimals(Constants.ADA_DECIMALS)
+ .build(), null);
return new Operation(operationIdentifier, relatedOperations, OperationType.OUTPUT.getValue(),
"",
@@ -182,7 +184,15 @@ public static Amount parseAsset(List assets, String key) throws CborExcep
log.error("[parseAsset] asset value for symbol: {} not provided", assetSymbol);
throw ExceptionFactory.tokenAssetValueMissingError();
}
- return DataMapper.mapAmount(assetValue.toString(), assetSymbol, 0, null);
+
+ // Create Amount without metadata (metadata is null for parsing operations)
+ return Amount.builder()
+ .value(assetValue.toString())
+ .currency(CurrencyResponse.builder()
+ .symbol(assetSymbol)
+ .decimals(0)
+ .build())
+ .build();
}
public static List parseCertsToOperations(TransactionBody transactionBody,
@@ -356,7 +366,10 @@ public static Operation parseWithdrawalToOperation(String value, String hex, Lon
.status("")
.account(new AccountIdentifier(address, null, null))
.amount(Amount.builder().value(value)
- .currency(new Currency(Constants.ADA, Constants.ADA_DECIMALS, null))
+ .currency(CurrencyResponse.builder()
+ .symbol(Constants.ADA)
+ .decimals(Constants.ADA_DECIMALS)
+ .build())
.build())
.metadata(OperationMetadata.builder()
.stakingCredential(new PublicKey(hex, CurveType.EDWARDS25519))
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/common/util/ValidationUtil.java b/api/src/main/java/org/cardanofoundation/rosetta/common/util/ValidationUtil.java
deleted file mode 100644
index ce96e29cb..000000000
--- a/api/src/main/java/org/cardanofoundation/rosetta/common/util/ValidationUtil.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package org.cardanofoundation.rosetta.common.util;
-
-
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-
-import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
-import org.cardanofoundation.rosetta.common.enumeration.CatalystDataIndexes;
-import org.cardanofoundation.rosetta.common.enumeration.CatalystSigIndexes;
-
-/**
- * Utility class for validation methods. Will be used for common validation methods.
- */
-public class ValidationUtil {
-
- private ValidationUtil() {
- }
-
- // [FutureUse] This method will be used to validate Transaction votes.
- public static boolean validateVoteDataFields(Map object) {
- List hexStringIndexes = Arrays.asList(
- CatalystDataIndexes.REWARD_ADDRESS,
- CatalystDataIndexes.STAKE_KEY,
- CatalystDataIndexes.VOTING_KEY
- );
- boolean isValidVotingNonce =
- object.containsKey(CatalystDataIndexes.VOTING_NONCE.getValue().toString())
- && object.get(CatalystDataIndexes.VOTING_NONCE.getValue().toString()) instanceof Number;
-
- return isValidVotingNonce
- && hexStringIndexes.stream().allMatch(index ->
- object.containsKey(index.getValue().toString()) && isHexString(
- object.get(index.getValue().toString()).toString()));
- }
-
- // [FutureUse] Votes-related code.
- public static boolean isVoteSignatureValid(Map mapJsonString) {
-
- List dataIndexes = Arrays.stream(CatalystSigIndexes.values())
- .map(CatalystSigIndexes::getValue)
- .filter(value -> value > 0)
- .toList();
- return dataIndexes.stream().allMatch(index ->
- mapJsonString.containsKey(String.valueOf(index))
- && isHexString(mapJsonString.get(String.valueOf(index))));
- }
-
- // [FutureUse] Votes-related code.
- public static boolean isVoteDataValid(Map jsonObject) {
- boolean isObject = Objects.nonNull(jsonObject);
-
- return isObject && validateVoteDataFields(jsonObject);
-
- }
-
- // [FutureUse]
- public static boolean isHexString(Object value) {
- if (value instanceof String str) {
- return str.matches("^(0x)?[0-9a-fA-F]+$");
- }
- return false;
- }
-
- // [FutureUse]
- public static boolean areEqualUtxos(Utxo firstUtxo, Utxo secondUtxo) {
- return Objects.equals(firstUtxo.getOutputIndex(), secondUtxo.getOutputIndex())
- && Objects.equals(firstUtxo.getTxHash(), secondUtxo.getTxHash());
- }
-
-}
diff --git a/api/src/main/java/org/cardanofoundation/rosetta/config/CacheConfig.java b/api/src/main/java/org/cardanofoundation/rosetta/config/CacheConfig.java
index f34caa9c5..6eaed7a27 100644
--- a/api/src/main/java/org/cardanofoundation/rosetta/config/CacheConfig.java
+++ b/api/src/main/java/org/cardanofoundation/rosetta/config/CacheConfig.java
@@ -1,10 +1,16 @@
package org.cardanofoundation.rosetta.config;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import org.cardanofoundation.rosetta.client.model.domain.TokenCacheEntry;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import static java.util.concurrent.TimeUnit.HOURS;
+
@Configuration
@EnableCaching
public class CacheConfig {
@@ -13,4 +19,16 @@ public class CacheConfig {
public ConcurrentMapCacheManager cacheManager() {
return new ConcurrentMapCacheManager("protocolParamsCache");
}
+
+ //a cache for token metadata from token registry
+ @Bean
+ public Cache tokenMetadataCache(
+ @Value("${cardano.rosetta.TOKEN_REGISTRY_CACHE_TTL_HOURS:12}") int cacheTtlHours) {
+ return CacheBuilder.newBuilder()
+ .maximumSize(10_000) // Maximum 10k cached entries
+ .expireAfterWrite(cacheTtlHours, HOURS)
+ .recordStats()
+ .build();
+ }
+
}
diff --git a/api/src/main/resources/config/application-h2.yaml b/api/src/main/resources/config/application-h2.yaml
index 26a92f0be..5f96f9afb 100644
--- a/api/src/main/resources/config/application-h2.yaml
+++ b/api/src/main/resources/config/application-h2.yaml
@@ -40,3 +40,8 @@ logging:
name: ${LOG_FILE_NAME:logs/rosetta-api.log}
max-size: ${LOG_FILE_MAX_SIZE:10MB}
max-history: ${LOG_FILE_MAX_HISTORY:10}
+
+cardano:
+ rosetta:
+ # Token Registry disabled for H2 test environment
+ TOKEN_REGISTRY_ENABLED: false
diff --git a/api/src/main/resources/config/application-offline.yaml b/api/src/main/resources/config/application-offline.yaml
index 3b12803e2..133b0e52e 100644
--- a/api/src/main/resources/config/application-offline.yaml
+++ b/api/src/main/resources/config/application-offline.yaml
@@ -13,8 +13,11 @@ cardano:
rosetta:
OFFLINE_MODE: true
SYNC_GRACE_SLOTS_COUNT: ${SYNC_GRACE_SLOTS_COUNT:100}
- REMOVE_SPENT_UTXOS: ${REMOVE_SPENT_UTXOS:false}
+ REMOVE_SPENT_UTXOS: ${REMOVE_SPENT_UTXOS:true}
YACI_HTTP_BASE_URL: ${YACI_HTTP_BASE_URL:http://localhost:9095}
HTTP_CONNECT_TIMEOUT_SECONDS: ${HTTP_CONNECT_TIMEOUT_SECONDS:5}
- HTTP_REQUEST_TIMEOUT_SECONDS: ${HTTP_REQUEST_TIMEOUT_SECONDS:5}
\ No newline at end of file
+ HTTP_REQUEST_TIMEOUT_SECONDS: ${HTTP_REQUEST_TIMEOUT_SECONDS:5}
+
+ # Token Registry disabled for offline mode
+ TOKEN_REGISTRY_ENABLED: false
\ No newline at end of file
diff --git a/api/src/main/resources/config/application-online.yaml b/api/src/main/resources/config/application-online.yaml
index 136c341b0..45147a076 100644
--- a/api/src/main/resources/config/application-online.yaml
+++ b/api/src/main/resources/config/application-online.yaml
@@ -41,3 +41,7 @@ cardano:
YACI_HTTP_BASE_URL: ${YACI_HTTP_BASE_URL:http://localhost:9095}
HTTP_CONNECT_TIMEOUT_SECONDS: ${HTTP_CONNECT_TIMEOUT_SECONDS:5}
HTTP_REQUEST_TIMEOUT_SECONDS: ${HTTP_REQUEST_TIMEOUT_SECONDS:5}
+ # Token Registry configuration
+ TOKEN_REGISTRY_ENABLED: ${TOKEN_REGISTRY_ENABLED:true}
+ TOKEN_REGISTRY_BASE_URL: ${TOKEN_REGISTRY_BASE_URL:https://tokens.cardano.org/api}
+ TOKEN_REGISTRY_CACHE_TTL_HOURS: ${TOKEN_REGISTRY_CACHE_TTL_HOURS:1}
diff --git a/api/src/main/resources/config/application-staging.yaml b/api/src/main/resources/config/application-staging.yaml
index 38d06c115..4801819bc 100644
--- a/api/src/main/resources/config/application-staging.yaml
+++ b/api/src/main/resources/config/application-staging.yaml
@@ -12,3 +12,4 @@ spring:
dialect: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: none
+
diff --git a/api/src/main/resources/config/application-test.yaml b/api/src/main/resources/config/application-test.yaml
index 25018e75a..f3eb38097 100644
--- a/api/src/main/resources/config/application-test.yaml
+++ b/api/src/main/resources/config/application-test.yaml
@@ -23,3 +23,8 @@ logging:
web:
filter:
CommonsRequestLoggingFilter: DEBUG
+
+cardano:
+ rosetta:
+ # Token Registry disabled for test environment
+ TOKEN_REGISTRY_ENABLED: false
diff --git a/api/src/main/resources/config/application.yaml b/api/src/main/resources/config/application.yaml
index 929a5ba91..e070c1ca9 100644
--- a/api/src/main/resources/config/application.yaml
+++ b/api/src/main/resources/config/application.yaml
@@ -40,14 +40,21 @@ cardano:
OFFLINE_MODE: ${OFFLINE_MODE:false}
SYNC_GRACE_SLOTS_COUNT: ${SYNC_GRACE_SLOTS_COUNT:100}
- REMOVE_SPENT_UTXOS: ${REMOVE_SPENT_UTXOS:false}
- REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT: ${REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT:2160}
+ REMOVE_SPENT_UTXOS: ${REMOVE_SPENT_UTXOS:true}
+ REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT: ${REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT:129600}
+ REMOVE_SPENT_UTXOS_BATCH_SIZE: ${REMOVE_SPENT_UTXOS_BATCH_SIZE:3000}
BLOCK_TRANSACTION_API_TIMEOUT_SECS: ${BLOCK_TRANSACTION_API_TIMEOUT_SECS:5}
YACI_HTTP_BASE_URL: ${YACI_HTTP_BASE_URL:http://localhost:9095}
HTTP_CONNECT_TIMEOUT_SECONDS: ${HTTP_CONNECT_TIMEOUT_SECONDS:5}
HTTP_REQUEST_TIMEOUT_SECONDS: ${HTTP_REQUEST_TIMEOUT_SECONDS:5}
+ TOKEN_REGISTRY_ENABLED: ${TOKEN_REGISTRY_ENABLED:true}
+ TOKEN_REGISTRY_BASE_URL: ${TOKEN_REGISTRY_BASE_URL:https://tokens.cardano.org/api}
+ TOKEN_REGISTRY_CACHE_TTL_HOURS: ${TOKEN_REGISTRY_CACHE_TTL_HOURS:1}
+ TOKEN_REGISTRY_LOGO_FETCH: ${TOKEN_REGISTRY_LOGO_FETCH:false}
+ TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS: ${TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS:2}
+
logging:
level:
root: ${LOG:INFO}
diff --git a/api/src/main/resources/rosetta-specifications-1.4.15/api.yaml b/api/src/main/resources/rosetta-specifications-1.4.15/api.yaml
index e14460f34..eb0257a25 100644
--- a/api/src/main/resources/rosetta-specifications-1.4.15/api.yaml
+++ b/api/src/main/resources/rosetta-specifications-1.4.15/api.yaml
@@ -753,30 +753,46 @@ components:
type: string
example: '1238089899992'
currency:
- $ref: '#/components/schemas/Currency'
+ $ref: '#/components/schemas/CurrencyResponse'
metadata:
type: object
- Currency:
- description: Currency is composed of a canonical Symbol and Decimals. This Decimals value is used to convert an Amount.Value from atomic units (Lovelaces for ADA, or smallest unit for native tokens) to standard units (ADA or token units).
+
+ CurrencyRequest:
+ description: Currency for request operations
type: object
required:
- symbol
- decimals
properties:
symbol:
- description: |
- Canonical symbol associated with a currency. For Cardano native tokens, this should be the ASCII representation of the token name.
- Empty string "" should be used when a token has no name.
type: string
example: ADA
decimals:
- description: Number of decimal places in the standard unit representation of the amount. For example, ADA has 6 decimals. Note that it is not possible to represent the value of some currency in atomic units that is not base 10.
type: integer
format: int32
minimum: 0
example: 8
metadata:
- $ref: '#/components/schemas/CurrencyMetadata'
+ $ref: '#/components/schemas/CurrencyMetadataRequest'
+
+ CurrencyResponse:
+ description: Currency for response operations
+ type: object
+ required:
+ - symbol
+ - decimals
+ properties:
+ symbol:
+ type: string
+ example: ADA
+ decimals:
+ type: integer
+ format: int32
+ minimum: 0
+ example: 8
+ metadata:
+ $ref: '#/components/schemas/CurrencyMetadataResponse'
+
SyncStatus:
description: SyncStatus is used to provide additional context about an implementation's sync status. This object is often used by implementations to indicate healthiness when block data cannot be queried until some sync phase completes or cannot be determined by comparing the timestamp of the most recent block with the current time.
type: object
@@ -1011,7 +1027,7 @@ components:
type: string
example: staking
currency:
- $ref: '#/components/schemas/Currency'
+ $ref: '#/components/schemas/CurrencyResponse'
exemption_type:
$ref: '#/components/schemas/ExemptionType'
ExemptionType:
@@ -1106,7 +1122,7 @@ components:
type: array
description: In some cases, the caller may not want to retrieve all available balances for an AccountIdentifier. If the currencies field is populated, only balances for the specified currencies will be returned. If not populated, all available balances will be returned.
items:
- $ref: '#/components/schemas/Currency'
+ $ref: '#/components/schemas/CurrencyRequest'
AccountBalanceResponse:
description: 'An AccountBalanceResponse is returned on the /account/balance endpoint. If an account has a balance for each AccountIdentifier describing it (ex: an ERC-20 token balance on a few smart contracts), an account balance request must be made with each AccountIdentifier. The `coins` field was removed and replaced by by `/account/coins` in `v1.4.7`.'
type: object
@@ -1145,7 +1161,7 @@ components:
type: array
description: In some cases, the caller may not want to retrieve coins for all currencies for an AccountIdentifier. If the currencies field is populated, only coins for the specified currencies will be returned. If not populated, all unspent coins will be returned.
items:
- $ref: '#/components/schemas/Currency'
+ $ref: '#/components/schemas/CurrencyRequest'
AccountCoinsResponse:
description: AccountCoinsResponse is returned on the /account/coins endpoint and includes all unspent Coins owned by an AccountIdentifier.
type: object
@@ -1638,7 +1654,7 @@ components:
coin_identifier:
$ref: '#/components/schemas/CoinIdentifier'
currency:
- $ref: '#/components/schemas/Currency'
+ $ref: '#/components/schemas/CurrencyRequest'
status:
type: string
description: status is the network-specific operation type.
@@ -2059,15 +2075,65 @@ components:
type: object
properties:
chain_code:
- description: 'ChainCode '
+ description: 'ChainCode'
+ type: string
+ CurrencyMetadataRequest:
+ description: 'Cardano-specific currency metadata for native tokens'
+ type: object
+ required:
+ - policyId
+ properties:
+ policyId:
+ description: 'The policy ID that controls this native token (hex string). Required for all non-ADA currencies.'
type: string
- CurrencyMetadata:
- description: 'Cardano-specific metadata for native tokens'
+
+ LogoType:
+ description: 'Token logo representation that can be either base64 encoded data (CIP_26) or URL (CIP_68)'
type: object
+ required:
+ - format
+ - value
+ properties:
+ format:
+ description: 'The format of the logo data'
+ type: string
+ enum:
+ - base64
+ - url
+ value:
+ description: 'The logo content - either base64 encoded string or URL'
+ type: string
+ CurrencyMetadataResponse:
+ description: 'Cardano-specific response metadata for native tokens'
+ type: object
+ required:
+ - policyId
properties:
policyId:
description: 'The policy ID that controls this native token (hex string). Required for all non-ADA currencies.'
type: string
+ subject:
+ description: 'The base16-encoded policyId + base16-encoded assetName'
+ type: string
+ name:
+ description: 'Token Registry data representing an asset name'
+ type: string
+ description:
+ description: 'Token Registry data representing an asset description'
+ type: string
+ ticker:
+ description: 'Token Registry data representing an asset ticker symbol'
+ type: string
+ url:
+ description: 'Token Registry data representing an asset''s project url'
+ type: string
+ logo:
+ description: 'Token Registry data representing an asset''s logo'
+ $ref: '#/components/schemas/LogoType'
+ version:
+ description: 'Token Registry data representing an asset''s version'
+ type: number
+
ConstructionMetadataRequestOption:
description: ''
type: object
diff --git a/api/src/test/java/org/cardanofoundation/rosetta/EntityGenerator.java b/api/src/test/java/org/cardanofoundation/rosetta/EntityGenerator.java
index c4458d230..076353afe 100644
--- a/api/src/test/java/org/cardanofoundation/rosetta/EntityGenerator.java
+++ b/api/src/test/java/org/cardanofoundation/rosetta/EntityGenerator.java
@@ -289,7 +289,7 @@ public static Operation givenOperation() {
.build())
.amount(Amount.builder()
.value("-90000")
- .currency(Currency.builder().symbol("ADA").build())
+ .currency(CurrencyResponse.builder().symbol("ADA").build())
.build())
.coinChange(CoinChange.builder()
.coinIdentifier(CoinIdentifier.builder()
diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/BaseMapperSetup.java b/api/src/test/java/org/cardanofoundation/rosetta/api/BaseMapperSetup.java
index 73b590507..bc7dff91e 100644
--- a/api/src/test/java/org/cardanofoundation/rosetta/api/BaseMapperSetup.java
+++ b/api/src/test/java/org/cardanofoundation/rosetta/api/BaseMapperSetup.java
@@ -1,7 +1,10 @@
package org.cardanofoundation.rosetta.api;
import java.math.BigInteger;
+import java.util.HashMap;
+import java.util.Map;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
@@ -16,8 +19,11 @@
import org.cardanofoundation.rosetta.api.BaseMapperSetup.BaseMappersConfig;
import org.cardanofoundation.rosetta.api.block.model.domain.ProtocolParams;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.cardanofoundation.rosetta.api.common.service.TokenRegistryService;
import org.cardanofoundation.rosetta.common.services.ProtocolParamService;
+import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.when;
@ExtendWith(SpringExtension.class)
@@ -27,6 +33,9 @@ public class BaseMapperSetup {
@MockitoBean
protected ProtocolParamService protocolParamService;
+ @MockitoBean
+ protected TokenRegistryService tokenRegistryService;
+
@Mock
ProtocolParams protocolParams;
@@ -34,6 +43,20 @@ public class BaseMapperSetup {
public void before() {
when(protocolParamService.findProtocolParameters()).thenReturn(protocolParams);
when(protocolParams.getPoolDeposit()).thenReturn(new BigInteger("500"));
+
+ // Configure TokenRegistryService to return fallback metadata for any asset
+ when(tokenRegistryService.getTokenMetadataBatch(anySet())).thenAnswer(invocation -> {
+ Map result = new HashMap<>();
+ @SuppressWarnings("unchecked")
+ java.util.Set assetFingerprints = (java.util.Set) invocation.getArgument(0);
+ for (AssetFingerprint assetFingerprint : assetFingerprints) {
+ result.put(assetFingerprint, TokenRegistryCurrencyData.builder()
+ .policyId(assetFingerprint.getPolicyId())
+ .decimals(0) // Default decimals
+ .build());
+ }
+ return result;
+ });
}
@TestConfiguration
@@ -41,6 +64,7 @@ public void before() {
"org.cardanofoundation.rosetta.api.block.mapper",
"org.cardanofoundation.rosetta.api.account.mapper",
"org.cardanofoundation.rosetta.api.search.mapper",
+ "org.cardanofoundation.rosetta.api.common.mapper",
"org.cardanofoundation.rosetta.common.mapper"})
public static class BaseMappersConfig {
diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/TestConfig.java b/api/src/test/java/org/cardanofoundation/rosetta/api/TestConfig.java
index 60dd38052..428c47905 100644
--- a/api/src/test/java/org/cardanofoundation/rosetta/api/TestConfig.java
+++ b/api/src/test/java/org/cardanofoundation/rosetta/api/TestConfig.java
@@ -1,11 +1,24 @@
package org.cardanofoundation.rosetta.api;
import java.time.Clock;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.cardanofoundation.rosetta.client.TokenRegistryHttpGateway;
+import org.cardanofoundation.rosetta.client.model.domain.TokenMetadata;
+import org.cardanofoundation.rosetta.client.model.domain.TokenProperty;
+import org.cardanofoundation.rosetta.client.model.domain.TokenPropertyNumber;
+import org.cardanofoundation.rosetta.client.model.domain.TokenSubject;
+import org.mockito.Mockito;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.Mockito.when;
+
@Configuration
public class TestConfig {
@@ -16,4 +29,39 @@ public Clock clockFixed() {
return Clock.systemDefaultZone();
}
+ @Bean
+ @Primary
+ public TokenRegistryHttpGateway tokenRegistryHttpGateway() {
+ TokenRegistryHttpGateway mock = Mockito.mock(TokenRegistryHttpGateway.class);
+
+ // Create a default response with all mandatory fields populated
+ when(mock.getTokenMetadataBatch(anySet())).thenAnswer(invocation -> {
+ Set subjects = invocation.getArgument(0);
+ Map> result = new HashMap<>();
+
+ for (String subject : subjects) {
+ // Create a TokenSubject with all mandatory fields
+ TokenSubject tokenSubject = new TokenSubject();
+ tokenSubject.setSubject(subject);
+
+ TokenMetadata metadata = new TokenMetadata();
+ // Set mandatory fields with mock values
+ metadata.setName(TokenProperty.builder().value("TestToken").source("test").build());
+ metadata.setDescription(TokenProperty.builder().value("Test token description").source("test").build());
+
+ // Set optional fields
+ metadata.setTicker(TokenProperty.builder().value("TST").source("test").build());
+ metadata.setUrl(TokenProperty.builder().value("https://example.com").source("test").build());
+ metadata.setDecimals(TokenPropertyNumber.builder().value(6L).source("test").build());
+
+ tokenSubject.setMetadata(metadata);
+ result.put(subject, Optional.of(tokenSubject));
+ }
+
+ return result;
+ });
+
+ return mock;
+ }
+
}
diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/account/controller/AccountCoinsApiTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/account/controller/AccountCoinsApiTest.java
index 5928852cc..902fbbb8c 100644
--- a/api/src/test/java/org/cardanofoundation/rosetta/api/account/controller/AccountCoinsApiTest.java
+++ b/api/src/test/java/org/cardanofoundation/rosetta/api/account/controller/AccountCoinsApiTest.java
@@ -30,10 +30,10 @@ class AccountCoinsApiTest extends BaseSpringMvcSetup {
TestTransactionNames.SIMPLE_NEW_EMPTY_NAME_COINS_TRANSACTION.getName()).txHash());
private final String expectedTestAccountCoinAmount = "1635602";
- private final Currency myAssetCurrency =
+ private final CurrencyRequest myAssetCurrency =
getCurrency(TestConstants.MY_ASSET_SYMBOL,Constants.MULTI_ASSET_DECIMALS, myAssetPolicyId);
- private final Currency ada = getCurrency(Constants.ADA, Constants.ADA_DECIMALS);
- private final Currency lovelace = getCurrency(Constants.LOVELACE, Constants.MULTI_ASSET_DECIMALS);
+ private final CurrencyRequest ada = getCurrency(Constants.ADA, Constants.ADA_DECIMALS);
+ private final CurrencyRequest lovelace = getCurrency(Constants.LOVELACE, Constants.MULTI_ASSET_DECIMALS);
@Test
void accountCoins2Ada_Test() {
@@ -108,10 +108,7 @@ void accountCoinsDifferentCoins_Test() {
.get(latestTxHashOnZeroSlot);
assertEquals(2, metadata.size());
assertEquals(metadata.get(1).getPolicyId(), metadata.getFirst().getPolicyId());
- assertEquals(metadata.getFirst().getPolicyId(), metadata
- .getFirst().getTokens().getFirst().getCurrency().getMetadata().getPolicyId());
- assertEquals(metadata.get(1).getPolicyId(), metadata
- .get(1).getTokens().getFirst().getCurrency().getMetadata().getPolicyId());
+ // Metadata no longer contains policyId - it's not duplicated in response
assertEquals(TestConstants.ACCOUNT_BALANCE_MINTED_TOKENS_AMOUNT,
metadata.getFirst().getTokens().getFirst().getValue());
assertEquals(TestConstants.ACCOUNT_BALANCE_MINTED_TOKENS_AMOUNT,
@@ -187,15 +184,13 @@ void accountCoinsMultipleSpecifiedCurrencies_Test() {
assertEquals(coinsMetadata.getFirst().getPolicyId(), coinsMetadata.get(1).getPolicyId());
assertEquals(TestConstants.ACCOUNT_BALANCE_MINTED_TOKENS_AMOUNT, coinsMetadata.getFirst()
.getTokens().getFirst().getValue());
- assertEquals(coinsMetadata.getFirst().getPolicyId(),
- coinsMetadata.getFirst().getTokens().getFirst().getCurrency().getMetadata().getPolicyId());
- assertEquals(Constants.MULTI_ASSET_DECIMALS,
+ // With TokenRegistry integration, decimals come from metadata instead of default
+ assertEquals(6,
coinsMetadata.getFirst().getTokens().getFirst().getCurrency().getDecimals());
assertEquals(TestConstants.ACCOUNT_BALANCE_MINTED_TOKENS_AMOUNT, coinsMetadata.get(1)
.getTokens().getFirst().getValue());
- assertEquals(coinsMetadata.get(1).getPolicyId(),
- coinsMetadata.get(1).getTokens().getFirst().getCurrency().getMetadata().getPolicyId());
- assertEquals(Constants.MULTI_ASSET_DECIMALS,
+ // With TokenRegistry integration, decimals come from metadata instead of default
+ assertEquals(6,
coinsMetadata.get(1).getTokens().getFirst().getCurrency().getDecimals());
}
@@ -267,7 +262,7 @@ void accountCoinsTooLongPolicyIdException_Test() throws Exception {
.collect(Collectors.joining());
AccountCoinsRequest request = getAccountCoinsRequestWithCurrencies(TEST_ACCOUNT_ADDRESS,
getCurrency(TestConstants.CURRENCY_HEX_SYMBOL, Constants.MULTI_ASSET_DECIMALS)
- .metadata(new CurrencyMetadata(tooLongPolicyId)));
+ .metadata(CurrencyMetadataRequest.builder().policyId(tooLongPolicyId).build()));
mockMvc.perform(MockMvcRequestBuilders.post("/account/coins")
.contentType(MediaType.APPLICATION_JSON)
@@ -282,7 +277,7 @@ void accountCoinsTooLongPolicyIdException_Test() throws Exception {
void accountCoinsNonHexPolicyIdException_Test() throws Exception {
AccountCoinsRequest request = getAccountCoinsRequestWithCurrencies(TEST_ACCOUNT_ADDRESS,
getCurrency(TestConstants.CURRENCY_HEX_SYMBOL, Constants.MULTI_ASSET_DECIMALS)
- .metadata(new CurrencyMetadata("thisIsNonHexPolicyId")));
+ .metadata(CurrencyMetadataRequest.builder().policyId("thisIsNonHexPolicyId").build()));
mockMvc.perform(MockMvcRequestBuilders.post("/account/coins")
.contentType(MediaType.APPLICATION_JSON)
@@ -308,7 +303,7 @@ private AccountCoinsRequest getAccountCoinsRequest(String accountAddress) {
}
private AccountCoinsRequest getAccountCoinsRequestWithCurrencies(String accountAddress,
- Currency... currencies) {
+ CurrencyRequest... currencies) {
return AccountCoinsRequest.builder()
.networkIdentifier(NetworkIdentifier.builder()
.blockchain(TestConstants.TEST_BLOCKCHAIN)
@@ -322,18 +317,18 @@ private AccountCoinsRequest getAccountCoinsRequestWithCurrencies(String accountA
.build();
}
- private Currency getCurrency(String symbol, int decimals) {
- return Currency.builder()
+ private CurrencyRequest getCurrency(String symbol, int decimals) {
+ return CurrencyRequest.builder()
.symbol(symbol)
.decimals(decimals)
.build();
}
- private Currency getCurrency(String symbol, int decimals, String policyId) {
- return Currency.builder()
+ private CurrencyRequest getCurrency(String symbol, int decimals, String policyId) {
+ return CurrencyRequest.builder()
.symbol(symbol)
.decimals(decimals)
- .metadata(CurrencyMetadata.builder().policyId(policyId).build())
+ .metadata(CurrencyMetadataRequest.builder().policyId(policyId).build())
.build();
}
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
new file mode 100644
index 000000000..4565ef0dc
--- /dev/null
+++ b/api/src/test/java/org/cardanofoundation/rosetta/api/account/mapper/AccountMapperUtilTest.java
@@ -0,0 +1,547 @@
+package org.cardanofoundation.rosetta.api.account.mapper;
+
+import org.cardanofoundation.rosetta.api.account.model.domain.AddressBalance;
+import org.cardanofoundation.rosetta.api.account.model.domain.Amt;
+import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.service.TokenRegistryService;
+import org.cardanofoundation.rosetta.common.util.Constants;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.cardanofoundation.rosetta.common.mapper.DataMapper;
+import org.openapitools.client.model.Amount;
+import org.openapitools.client.model.Coin;
+import org.openapitools.client.model.CoinTokens;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AccountMapperUtilTest {
+
+ @Mock
+ private TokenRegistryService tokenRegistryService;
+
+ private AccountMapperUtil accountMapperUtil;
+ private DataMapper dataMapper;
+
+ @BeforeEach
+ void setUp() {
+ // Create real DataMapper instance with its dependency
+ org.cardanofoundation.rosetta.api.common.mapper.TokenRegistryMapper tokenRegistryMapper =
+ new org.cardanofoundation.rosetta.api.common.mapper.TokenRegistryMapperImpl();
+ dataMapper = new DataMapper(tokenRegistryMapper);
+
+ accountMapperUtil = new AccountMapperUtil(dataMapper);
+
+ // Configure TokenRegistryService to return fallback metadata for any asset
+ lenient().when(tokenRegistryService.getTokenMetadataBatch(anySet())).thenAnswer(invocation -> {
+ java.util.Map result = new java.util.HashMap<>();
+ @SuppressWarnings("unchecked")
+ java.util.Set assetFingerprints = (java.util.Set) invocation.getArgument(0);
+ for (AssetFingerprint assetFingerprint : assetFingerprints) {
+ result.put(assetFingerprint, TokenRegistryCurrencyData.builder()
+ .policyId(assetFingerprint.getPolicyId())
+ .decimals(0) // Default decimals
+ .build());
+ }
+ return result;
+ });
+ }
+
+ // Helper method to create metadata map from balances
+ private Map createMetadataMapFromBalances(List balances) {
+ Set assetFingerprints = balances.stream()
+ .filter(b -> !Constants.LOVELACE.equals(b.unit()))
+ .filter(b -> b.unit().length() >= Constants.POLICY_ID_LENGTH)
+ .map(b -> {
+ String symbol = b.getSymbol();
+ String policyId = b.getPolicyId();
+
+ return AssetFingerprint.of(policyId, symbol);
+
+ })
+ .collect(Collectors.toSet());
+
+ if (assetFingerprints.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ return tokenRegistryService.getTokenMetadataBatch(assetFingerprints);
+ }
+
+ // Helper method to create metadata map from UTXOs
+ private Map createMetadataMapFromUtxos(List utxos) {
+ Set assetFingerprints = new HashSet<>();
+ for (Utxo utxo : utxos) {
+ for (Amt amount : utxo.getAmounts()) {
+ if (!Constants.LOVELACE.equals(amount.getUnit()) && amount.getPolicyId() != null) {
+ assetFingerprints.add(AssetFingerprint.of(amount.getPolicyId(), amount.getSymbolHex()));
+ }
+ }
+ }
+
+ if (assetFingerprints.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ return tokenRegistryService.getTokenMetadataBatch(assetFingerprints);
+ }
+
+ @Nested
+ class MapAddressBalancesToAmountsTests {
+
+ @Test
+ void shouldMapOnlyLovelaceBalance() {
+ // given
+ List balances = List.of(
+ createAddressBalance(Constants.LOVELACE, BigInteger.valueOf(1000000))
+ );
+
+ // when
+ Map metadataMap = createMetadataMapFromBalances(balances);
+ List amounts = accountMapperUtil.mapAddressBalancesToAmounts(balances, metadataMap);
+
+ // then
+ assertEquals(1, amounts.size());
+ Amount adaAmount = amounts.get(0);
+ assertEquals("1000000", adaAmount.getValue());
+ assertEquals(Constants.ADA, adaAmount.getCurrency().getSymbol());
+ assertEquals(Constants.ADA_DECIMALS, adaAmount.getCurrency().getDecimals());
+ assertNull(adaAmount.getCurrency().getMetadata());
+ }
+
+ @Test
+ void shouldMapLovelaceAndNativeTokensWithoutRegistry() {
+ // given
+ String policyId = "a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3"; // exactly 56 chars
+ String assetName = "TestToken";
+ String unit = policyId + assetName;
+
+ // Mock service to return fallback metadata (service always returns something now)
+ AssetFingerprint assetFingerprint = AssetFingerprint.of(policyId, assetName);
+ TokenRegistryCurrencyData fallbackMetadata = TokenRegistryCurrencyData.builder()
+ .policyId(policyId)
+ .decimals(0)
+ .build();
+ Map tokenMetadataMap = Map.of(assetFingerprint, fallbackMetadata);
+ when(tokenRegistryService.getTokenMetadataBatch(anySet())).thenReturn(tokenMetadataMap);
+
+ List balances = List.of(
+ createAddressBalance(Constants.LOVELACE, BigInteger.valueOf(2000000)),
+ createAddressBalance(unit, BigInteger.valueOf(500))
+ );
+
+ // when
+ Map metadataMap = createMetadataMapFromBalances(balances);
+ List amounts = accountMapperUtil.mapAddressBalancesToAmounts(balances, metadataMap);
+
+ // then
+ assertEquals(2, amounts.size());
+
+ // ADA amount
+ Amount adaAmount = amounts.get(0);
+ assertEquals("2000000", adaAmount.getValue());
+ assertEquals(Constants.ADA, adaAmount.getCurrency().getSymbol());
+
+ // Native token amount - symbol should be just the asset name part
+ Amount tokenAmount = amounts.get(1);
+ assertEquals("500", tokenAmount.getValue());
+ assertEquals(assetName, tokenAmount.getCurrency().getSymbol());
+ assertEquals(Constants.MULTI_ASSET_DECIMALS, tokenAmount.getCurrency().getDecimals());
+
+ // Verify metadata contains only policyId (no registry data)
+ org.openapitools.client.model.CurrencyMetadataResponse metadata = tokenAmount.getCurrency().getMetadata();
+ assertNotNull(metadata);
+ assertEquals(policyId, metadata.getPolicyId());
+ assertNull(metadata.getName());
+ assertNull(metadata.getDescription());
+ }
+
+ @Test
+ void shouldMapNativeTokensWithRegistryData() {
+ // given
+ String policyId = "a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3"; // exactly 56 chars
+ String assetName = "TestToken";
+ String unit = policyId + assetName;
+ String subject = policyId + "54657374546f6b656e"; // hex encoding of "TestToken"
+
+ // Mock token registry response
+ TokenRegistryCurrencyData currencyMetadata = createCurrencyMetadata(policyId, subject, "Test Token", "Test description",
+ "TST", "https://test.com", "logo", 6, 1L);
+
+ Map tokenMetadataMap = new HashMap<>();
+ AssetFingerprint assetFingerprint = AssetFingerprint.of(policyId, assetName);
+ tokenMetadataMap.put(assetFingerprint, currencyMetadata);
+
+ when(tokenRegistryService.getTokenMetadataBatch(anySet())).thenReturn(tokenMetadataMap);
+
+ List balances = List.of(
+ createAddressBalance(Constants.LOVELACE, BigInteger.valueOf(1500000)),
+ createAddressBalance(unit, BigInteger.valueOf(750))
+ );
+
+ // when
+ Map metadataMap = createMetadataMapFromBalances(balances);
+ List amounts = accountMapperUtil.mapAddressBalancesToAmounts(balances, metadataMap);
+
+ // then
+ assertEquals(2, amounts.size());
+
+ // Native token amount with registry data
+ Amount tokenAmount = amounts.get(1);
+ assertEquals("750", tokenAmount.getValue());
+ assertEquals(assetName, tokenAmount.getCurrency().getSymbol());
+ assertEquals(6, tokenAmount.getCurrency().getDecimals()); // From registry
+
+ // Verify full metadata from registry
+ org.openapitools.client.model.CurrencyMetadataResponse metadata = tokenAmount.getCurrency().getMetadata();
+ assertNotNull(metadata);
+ assertEquals(policyId, metadata.getPolicyId());
+ assertEquals(subject, metadata.getSubject());
+ assertEquals("Test Token", metadata.getName());
+ assertEquals("Test description", metadata.getDescription());
+ assertEquals("TST", metadata.getTicker());
+ assertEquals("https://test.com", metadata.getUrl());
+ assertNotNull(metadata.getLogo());
+ assertEquals("logo", metadata.getLogo().getValue());
+ assertEquals(BigDecimal.valueOf(1L), metadata.getVersion());
+ }
+
+ @Test
+ void shouldHandleMultipleNativeTokensWithBatchRegistryCall() {
+ // given
+ String policyId1 = "a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3"; // exactly 56 chars
+ String policyId2 = "f5e4d3c2b1a0f5e4d3c2b1a0f5e4d3c2b1a0f5e4d3c2b1a0f5e4d3c2"; // exactly 56 chars
+ String assetName1 = "Token1";
+ String assetName2 = "Token2";
+ String unit1 = policyId1 + assetName1;
+ String unit2 = policyId2 + assetName2;
+ String subject1 = policyId1 + "546f6b656e31"; // hex of "Token1"
+ String subject2 = policyId2 + "546f6b656e32"; // hex of "Token2"
+
+ // Mock batch registry response
+ Map tokenMetadataMap = new HashMap<>();
+ AssetFingerprint assetFingerprint1 = AssetFingerprint.of(policyId1, assetName1);
+ AssetFingerprint assetFingerprint2 = AssetFingerprint.of(policyId2, assetName2);
+ tokenMetadataMap.put(assetFingerprint1, createCurrencyMetadata(policyId1, subject1, "First Token", "First desc", "TK1", null, null, 8, null));
+ tokenMetadataMap.put(assetFingerprint2, TokenRegistryCurrencyData.builder().policyId(policyId2).decimals(0).build()); // Fallback metadata for second token
+
+ when(tokenRegistryService.getTokenMetadataBatch(anySet())).thenReturn(tokenMetadataMap);
+
+ List balances = List.of(
+ createAddressBalance(Constants.LOVELACE, BigInteger.valueOf(3000000)),
+ createAddressBalance(unit1, BigInteger.valueOf(100)),
+ createAddressBalance(unit2, BigInteger.valueOf(200))
+ );
+
+ // when
+ Map metadataMap = createMetadataMapFromBalances(balances);
+ List amounts = accountMapperUtil.mapAddressBalancesToAmounts(balances, metadataMap);
+
+ // then
+ assertEquals(3, amounts.size());
+
+ // First token with registry data
+ Amount token1Amount = amounts.get(1);
+ assertEquals("100", token1Amount.getValue());
+ assertEquals(8, token1Amount.getCurrency().getDecimals());
+ assertEquals("First Token", token1Amount.getCurrency().getMetadata().getName());
+
+ // Second token without registry data
+ Amount token2Amount = amounts.get(2);
+ assertEquals("200", token2Amount.getValue());
+ assertEquals(Constants.MULTI_ASSET_DECIMALS, token2Amount.getCurrency().getDecimals());
+ assertEquals(policyId2, token2Amount.getCurrency().getMetadata().getPolicyId());
+ assertNull(token2Amount.getCurrency().getMetadata().getName());
+ }
+
+ @Test
+ void shouldHandleEmptyBalances() {
+ // given
+ List balances = Collections.emptyList();
+
+ // when
+ Map metadataMap = createMetadataMapFromBalances(balances);
+ List amounts = accountMapperUtil.mapAddressBalancesToAmounts(balances, metadataMap);
+
+ // then
+ assertEquals(1, amounts.size()); // Only ADA with 0 amount
+ Amount adaAmount = amounts.get(0);
+ assertEquals("0", adaAmount.getValue());
+ assertEquals(Constants.ADA, adaAmount.getCurrency().getSymbol());
+ }
+
+ @Test
+ void shouldHandleTokenWithNullMetadataFields() {
+ // Test case for token with null name and description - should be treated as not found
+ String policyId = "a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3"; // exactly 56 chars
+ String assetName = "TestToken";
+ String unit = policyId + assetName;
+ String subject = policyId + "54657374546f6b656e"; // hex of "TestToken"
+
+ List balances = List.of(
+ createAddressBalance(Constants.LOVELACE, BigInteger.valueOf(1000000)),
+ createAddressBalance(unit, BigInteger.valueOf(500))
+ );
+
+ // Token with null metadata fields should return fallback metadata with only policyId
+ AssetFingerprint assetFingerprint = AssetFingerprint.of(policyId, assetName);
+ when(tokenRegistryService.getTokenMetadataBatch(anySet()))
+ .thenReturn(Map.of(assetFingerprint, TokenRegistryCurrencyData.builder().policyId(policyId).decimals(0).build()));
+
+ // when
+ Map metadataMap = createMetadataMapFromBalances(balances);
+ List amounts = accountMapperUtil.mapAddressBalancesToAmounts(balances, metadataMap);
+
+ // then
+ assertEquals(2, amounts.size());
+
+ // Token should not have enriched metadata
+ Amount tokenAmount = amounts.get(1);
+ assertEquals("500", tokenAmount.getValue());
+ assertEquals(assetName, tokenAmount.getCurrency().getSymbol());
+ assertEquals(Constants.MULTI_ASSET_DECIMALS, tokenAmount.getCurrency().getDecimals());
+
+ org.openapitools.client.model.CurrencyMetadataResponse metadata = tokenAmount.getCurrency().getMetadata();
+ assertNotNull(metadata);
+ assertEquals(policyId, metadata.getPolicyId());
+ // Should not have enriched fields when token is not in registry
+ assertNull(metadata.getName());
+ assertNull(metadata.getDescription());
+ assertNull(metadata.getSubject());
+ }
+
+ @Test
+ void shouldHandleTokenWithNullMetadataObject() {
+ // Test case for token with completely null metadata - should be treated as not found
+ String policyId = "a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3"; // exactly 56 chars
+ String assetName = "TestToken";
+ String unit = policyId + assetName;
+ String subject = policyId + "54657374546f6b656e"; // hex of "TestToken"
+
+ List balances = List.of(
+ createAddressBalance(Constants.LOVELACE, BigInteger.valueOf(1000000)),
+ createAddressBalance(unit, BigInteger.valueOf(500))
+ );
+
+ // Token with null metadata should return fallback metadata with only policyId
+ AssetFingerprint assetFingerprint = AssetFingerprint.of(policyId, assetName);
+ when(tokenRegistryService.getTokenMetadataBatch(anySet()))
+ .thenReturn(Map.of(assetFingerprint, TokenRegistryCurrencyData.builder().policyId(policyId).decimals(0).build()));
+
+ // when
+ Map metadataMap = createMetadataMapFromBalances(balances);
+ List amounts = accountMapperUtil.mapAddressBalancesToAmounts(balances, metadataMap);
+
+ // then
+ assertEquals(2, amounts.size());
+
+ Amount tokenAmount = amounts.get(1);
+ assertEquals("500", tokenAmount.getValue());
+ assertEquals(assetName, tokenAmount.getCurrency().getSymbol());
+ assertEquals(Constants.MULTI_ASSET_DECIMALS, tokenAmount.getCurrency().getDecimals());
+
+ org.openapitools.client.model.CurrencyMetadataResponse metadata = tokenAmount.getCurrency().getMetadata();
+ assertNotNull(metadata);
+ assertEquals(policyId, metadata.getPolicyId());
+ // Should not have enriched fields when token is not in registry
+ assertNull(metadata.getName());
+ assertNull(metadata.getDescription());
+ assertNull(metadata.getSubject());
+ }
+ }
+
+ @Nested
+ class MapUtxosToCoinsTests {
+
+ @Test
+ void shouldMapUtxosWithOnlyAda() {
+ // given
+ List utxos = List.of(
+ createUtxo("txhash1", 0, List.of(
+ createAmt(null, Constants.LOVELACE, BigInteger.valueOf(1000000))
+ ))
+ );
+
+ // when
+ Map metadataMap = createMetadataMapFromUtxos(utxos);
+ List coins = accountMapperUtil.mapUtxosToCoins(utxos, metadataMap);
+
+ // then
+ assertEquals(1, coins.size());
+ Coin coin = coins.get(0);
+ assertEquals("txhash1:0", coin.getCoinIdentifier().getIdentifier());
+ assertEquals("1000000", coin.getAmount().getValue());
+ assertEquals(Constants.ADA, coin.getAmount().getCurrency().getSymbol());
+ assertNull(coin.getMetadata()); // No native tokens
+ }
+
+ @Test
+ void shouldMapUtxosWithNativeTokensAndRegistry() {
+ // given
+ String policyId = "a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3"; // exactly 56 chars
+ String assetName = "TestToken";
+ String unit = policyId + assetName;
+ String subject = policyId + "54657374546f6b656e"; // hex of "TestToken"
+
+ // Mock registry response
+ TokenRegistryCurrencyData currencyMetadata = createCurrencyMetadata(policyId, subject, "Test Token", "Test desc", "TST", null, null, 4, null);
+ AssetFingerprint assetFingerprint = AssetFingerprint.of(policyId, assetName);
+ Map tokenMetadataMap = Map.of(assetFingerprint, currencyMetadata);
+ when(tokenRegistryService.getTokenMetadataBatch(anySet())).thenReturn(tokenMetadataMap);
+
+ List utxos = List.of(
+ createUtxo("txhash1", 0, List.of(
+ createAmt(null, Constants.LOVELACE, BigInteger.valueOf(2000000)),
+ createAmt(policyId, assetName, BigInteger.valueOf(500), unit)
+ ))
+ );
+
+ // when
+ Map metadataMap = createMetadataMapFromUtxos(utxos);
+ List coins = accountMapperUtil.mapUtxosToCoins(utxos, metadataMap);
+
+ // then
+ assertEquals(1, coins.size());
+ Coin coin = coins.get(0);
+ assertEquals("txhash1:0", coin.getCoinIdentifier().getIdentifier());
+ assertEquals("2000000", coin.getAmount().getValue()); // ADA amount
+
+ // Verify native token metadata
+ assertNotNull(coin.getMetadata());
+ List coinTokens = coin.getMetadata().get("txhash1:0");
+ assertEquals(1, coinTokens.size());
+
+ CoinTokens tokens = coinTokens.get(0);
+ assertEquals(policyId, tokens.getPolicyId());
+ assertEquals(1, tokens.getTokens().size());
+
+ Amount tokenAmount = tokens.getTokens().get(0);
+ assertEquals("500", tokenAmount.getValue());
+ assertEquals(assetName, tokenAmount.getCurrency().getSymbol());
+ assertEquals(4, tokenAmount.getCurrency().getDecimals()); // From registry
+ assertEquals("Test Token", tokenAmount.getCurrency().getMetadata().getName());
+ }
+
+ @Test
+ void shouldMapUtxosWithMultipleNativeTokensSamePolicyId() {
+ // given
+ String policyId = "a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3"; // exactly 56 chars
+ String assetName1 = "Token1";
+ String assetName2 = "Token2";
+ String unit1 = policyId + assetName1;
+ String unit2 = policyId + assetName2;
+
+ List utxos = List.of(
+ createUtxo("txhash1", 0, List.of(
+ createAmt(null, Constants.LOVELACE, BigInteger.valueOf(1500000)),
+ createAmt(policyId, assetName1, BigInteger.valueOf(100), unit1),
+ createAmt(policyId, assetName2, BigInteger.valueOf(200), unit2)
+ ))
+ );
+
+ // when
+ Map metadataMap = createMetadataMapFromUtxos(utxos);
+ List coins = accountMapperUtil.mapUtxosToCoins(utxos, metadataMap);
+
+ // then
+ assertEquals(1, coins.size());
+ Coin coin = coins.get(0);
+
+ // Should have two separate CoinTokens entries (one per token)
+ List coinTokens = coin.getMetadata().get("txhash1:0");
+ assertEquals(2, coinTokens.size());
+
+ // Both tokens have the same policy ID but are in separate CoinTokens entries
+ assertEquals(policyId, coinTokens.get(0).getPolicyId());
+ assertEquals(policyId, coinTokens.get(1).getPolicyId());
+ assertEquals(1, coinTokens.get(0).getTokens().size());
+ assertEquals(1, coinTokens.get(1).getTokens().size());
+ }
+
+ @Test
+ void shouldHandleEmptyUtxos() {
+ // given
+ List utxos = Collections.emptyList();
+
+ // when
+ Map metadataMap = createMetadataMapFromUtxos(utxos);
+ List coins = accountMapperUtil.mapUtxosToCoins(utxos, metadataMap);
+
+ // then
+ assertTrue(coins.isEmpty());
+ }
+ }
+
+ // Helper methods
+ private AddressBalance createAddressBalance(String unit, BigInteger quantity) {
+ return AddressBalance.builder()
+ .address("addr_test123")
+ .unit(unit)
+ .slot(1000L)
+ .quantity(quantity)
+ .number(100L)
+ .build();
+ }
+
+ private Utxo createUtxo(String txHash, int outputIndex, List amounts) {
+ return Utxo.builder()
+ .txHash(txHash)
+ .outputIndex(outputIndex)
+ .amounts(amounts)
+ .build();
+ }
+
+ private Amt createAmt(String policyId, String assetName, BigInteger quantity) {
+ return createAmt(policyId, assetName, quantity, null);
+ }
+
+ 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();
+ }
+
+ private TokenRegistryCurrencyData createCurrencyMetadata(String policyId, String subject, String name, String description,
+ String ticker, String url, String logo, Integer decimals, Long version) {
+ TokenRegistryCurrencyData.TokenRegistryCurrencyDataBuilder builder = TokenRegistryCurrencyData.builder()
+ .policyId(policyId)
+ .subject(subject)
+ .name(name)
+ .description(description);
+
+ if (ticker != null) {
+ builder.ticker(ticker);
+ }
+ if (url != null) {
+ builder.url(url);
+ }
+ if (logo != null) {
+ builder.logo(TokenRegistryCurrencyData.LogoData.builder().format(TokenRegistryCurrencyData.LogoFormat.BASE64).value(logo).build());
+ }
+ if (decimals != null) {
+ builder.decimals(decimals);
+ }
+ if (version != null) {
+ builder.version(BigDecimal.valueOf(version));
+ }
+
+ return builder.build();
+ }
+}
\ No newline at end of file
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 e7a56f8d5..2beb2f84d 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
@@ -1,18 +1,18 @@
package org.cardanofoundation.rosetta.api.account.service;
import java.math.BigInteger;
-import java.util.Collections;
-import java.util.List;
+import java.util.*;
import java.util.Optional;
import jakarta.validation.constraints.NotNull;
-import org.mockito.InjectMocks;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openapitools.client.model.*;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -22,6 +22,9 @@
import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
import org.cardanofoundation.rosetta.api.block.model.domain.BlockIdentifierExtended;
import org.cardanofoundation.rosetta.api.block.service.LedgerBlockService;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.cardanofoundation.rosetta.api.common.service.TokenRegistryService;
+import org.cardanofoundation.rosetta.common.mapper.DataMapper;
import org.cardanofoundation.rosetta.client.YaciHttpGateway;
import org.cardanofoundation.rosetta.client.model.domain.StakeAccountInfo;
import org.cardanofoundation.rosetta.common.exception.ApiException;
@@ -46,18 +49,34 @@ class AccountServiceImplTest {
@Mock
YaciHttpGateway yaciHttpGateway;
- @Spy
- AccountMapper accountMapper = new AccountMapperImpl(new AccountMapperUtil());
+ @Mock
+ TokenRegistryService tokenRegistryService;
@Spy
AddressBalanceMapperImpl addressBalanceMapper;
- @Spy
- @InjectMocks
+ AccountMapper accountMapper;
AccountServiceImpl accountService;
+ DataMapper dataMapper;
private final String HASH = "hash";
+ @BeforeEach
+ void setUp() {
+ // Mock TokenRegistryService to return empty metadata maps
+ lenient().when(tokenRegistryService.getTokenMetadataBatch(any())).thenReturn(Collections.emptyMap());
+ lenient().when(tokenRegistryService.fetchMetadataForAddressBalances(any())).thenReturn(Collections.emptyMap());
+ lenient().when(tokenRegistryService.fetchMetadataForUtxos(any())).thenReturn(Collections.emptyMap());
+
+ // Create real DataMapper instance with its dependency
+ org.cardanofoundation.rosetta.api.common.mapper.TokenRegistryMapper tokenRegistryMapper =
+ new org.cardanofoundation.rosetta.api.common.mapper.TokenRegistryMapperImpl();
+ dataMapper = new DataMapper(tokenRegistryMapper);
+
+ accountMapper = new AccountMapperImpl(new AccountMapperUtil(dataMapper));
+ accountService = new AccountServiceImpl(ledgerAccountService, ledgerBlockService, accountMapper, yaciHttpGateway, addressBalanceMapper, tokenRegistryService);
+ }
+
@Test
void getAccountBalanceNoStakeAddressPositiveTest() {
String accountAddress = ADDRESS_PREFIX
@@ -140,9 +159,20 @@ void getFilteredAccountBalance() {
BigInteger.valueOf(10)).build()));
BlockIdentifierExtended block = getMockedBlockIdentifierExtended();
when(ledgerBlockService.findLatestBlockIdentifier()).thenReturn(block);
+ when(tokenRegistryService.fetchMetadataForAddressBalances(any())).thenAnswer(invocation -> {
+ Map result = new HashMap<>();
+ // Create an asset for the native token in the test data
+ AssetFingerprint assetFingerprint = AssetFingerprint.of("bd976e131cfc3956b806967b06530e48c20ed5498b46a5eb836b61c2", ""); // Empty asset name
+ result.put(assetFingerprint, TokenRegistryCurrencyData.builder()
+ .policyId("bd976e131cfc3956b806967b06530e48c20ed5498b46a5eb836b61c2")
+ .decimals(0)
+ .build());
+ return result;
+ });
AccountBalanceRequest accountBalanceRequest = AccountBalanceRequest.builder()
.accountIdentifier(AccountIdentifier.builder().address(address).build())
- .currencies(List.of(Currency.builder().symbol("ADA").build()))
+ .blockIdentifier(null)
+ .currencies(List.of(CurrencyRequest.builder().symbol("ADA").build()))
.build();
AccountBalanceResponse accountBalanceResponse = accountService.getAccountBalance(
accountBalanceRequest);
@@ -158,6 +188,8 @@ void getAccountBalanceNoStakeAddressNullBlockIdentifierPositiveTest() {
AccountIdentifier accountIdentifier = Mockito.mock(AccountIdentifier.class);
when(accountBalanceRequest.getAccountIdentifier()).thenReturn(accountIdentifier);
+ when(accountBalanceRequest.getBlockIdentifier()).thenReturn(null);
+ when(accountBalanceRequest.getCurrencies()).thenReturn(null);
when(accountIdentifier.getAddress()).thenReturn(accountAddress);
BlockIdentifierExtended block = getMockedBlockIdentifierExtended();
AddressBalance addressBalance = new AddressBalance(accountAddress, LOVELACE, 1L,
@@ -306,7 +338,7 @@ void getAccountCoinsWithCurrenciesPositiveTest() {
String accountAddress = "Ae2tdPwUPEZGvXJ3ebp4LDgBhbxekAH2oKZgfahKq896fehv8oCJxmGJgLt";
AccountCoinsRequest accountCoinsRequest = Mockito.mock(AccountCoinsRequest.class);
AccountIdentifier accountIdentifier = Mockito.mock(AccountIdentifier.class);
- Currency currency = Mockito.mock(Currency.class);
+ CurrencyRequest currency = Mockito.mock(CurrencyRequest.class);
BlockIdentifierExtended block = Mockito.mock(BlockIdentifierExtended.class);
Utxo utxo = Mockito.mock(Utxo.class);
when(utxo.getTxHash()).thenReturn("txHash");
@@ -390,6 +422,7 @@ private static AccountIdentifier getMockedAccountIdentifierAndMockAccountBalance
AccountIdentifier accountIdentifier = Mockito.mock(AccountIdentifier.class);
when(accountBalanceRequest.getAccountIdentifier()).thenReturn(accountIdentifier);
when(accountBalanceRequest.getBlockIdentifier()).thenReturn(blockIdentifier);
+ when(accountBalanceRequest.getCurrencies()).thenReturn(null);
when(accountIdentifier.getAddress()).thenReturn(accountAddress);
return accountIdentifier;
}
diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImplTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImplTest.java
index 089dd40e5..d97fb45bf 100644
--- a/api/src/test/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImplTest.java
+++ b/api/src/test/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImplTest.java
@@ -2,6 +2,7 @@
import java.lang.reflect.Field;
import java.math.BigInteger;
+import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -17,6 +18,7 @@
import org.cardanofoundation.rosetta.api.block.model.domain.BlockTx;
import org.cardanofoundation.rosetta.api.block.model.domain.ProtocolParams;
import org.cardanofoundation.rosetta.api.block.service.BlockService;
+import org.cardanofoundation.rosetta.api.common.service.TokenRegistryService;
import org.cardanofoundation.rosetta.common.exception.ExceptionFactory;
import org.cardanofoundation.rosetta.common.services.ProtocolParamService;
@@ -36,6 +38,9 @@ class BlockApiImplTest extends BaseSpringMvcSetup {
@MockitoBean
private ProtocolParamService protocolParamService;
+ @MockitoBean
+ private TokenRegistryService tokenRegistryService;
+
@MockitoBean
BlockMapper blockMapper;
@@ -130,7 +135,7 @@ void blockTransaction_Test() throws Exception {
when(protocolParamService.findProtocolParameters()).thenReturn(protocolParams);
when(protocolParams.getPoolDeposit()).thenReturn(new BigInteger("1000"));
//any string
- when(blockMapper.mapToBlockTransactionResponse(any(BlockTx.class))).thenReturn(resp);
+ when(blockMapper.mapToBlockTransactionResponseWithMetadata(any(BlockTx.class), any())).thenReturn(resp);
//when
//then
String txHash = resp.getTransaction().getTransactionIdentifier().getHash();
@@ -161,7 +166,7 @@ void blockTransaction_notFound_Test() throws Exception {
private BlockRequest givenBlockRequest() {
BlockRequest blockRequest = newBlockRequest();
BlockResponse blockResp = newBlockResponse();
- when(blockMapper.mapToBlockResponse(any(Block.class))).thenReturn(blockResp);
+ when(blockMapper.mapToBlockResponseWithMetadata(any(Block.class), any(Map.class))).thenReturn(blockResp);
return blockRequest;
}
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 6f6ee1ec1..9e567d9b3 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
@@ -7,9 +7,11 @@
import org.cardanofoundation.rosetta.api.account.model.domain.Utxo;
import org.cardanofoundation.rosetta.api.block.model.domain.*;
import org.cardanofoundation.rosetta.api.block.model.domain.Block;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
import org.junit.jupiter.api.Test;
import org.openapitools.client.model.*;
-import org.openapitools.client.model.Currency;
+import org.openapitools.client.model.CurrencyResponse;
import org.springframework.beans.factory.annotation.Autowired;
import java.math.BigInteger;
@@ -29,20 +31,31 @@ class BlockToBlockResponseTest extends BaseMapperSetup {
@Test
void mapToBlockResponse_test_invalidTransaction() {
+ String policyId = "tAda";
+ String symbol = "123";
+ String unit = policyId + symbol;
+
BlockTx build = BlockTx.builder()
.invalid(true)
.blockHash("Hash")
.blockNo(1L)
.inputs(
List.of(Utxo.builder().txHash("Hash").outputIndex(0).ownerAddr("Owner").amounts(List.of(
- Amt.builder().unit("tAda123").policyId("tAda").assetName("tAda").quantity(BigInteger.valueOf(10L)).build()))
+ Amt.builder().unit(unit).policyId(policyId).assetName("tAda").quantity(BigInteger.valueOf(10L)).build()))
.build()))
.outputs(
List.of(Utxo.builder().txHash("Hash").outputIndex(0).ownerAddr("Owner").amounts(List.of(
- Amt.builder().unit("tAda123").policyId("tAda").assetName("tAda").quantity(BigInteger.valueOf(10L)).build()))
+ Amt.builder().unit(unit).policyId(policyId).assetName("tAda").quantity(BigInteger.valueOf(10L)).build()))
.build()))
.build();
- BlockTransactionResponse blockTransactionResponse = my.mapToBlockTransactionResponse(build);
+
+ // Create test metadata map for the asset
+ Map metadataMap = Map.of(
+ AssetFingerprint.of(policyId, symbol),
+ TokenRegistryCurrencyData.builder().decimals(6).build()
+ );
+
+ BlockTransactionResponse blockTransactionResponse = my.mapToBlockTransactionResponseWithMetadata(build, metadataMap);
// Since the Transaction is invalid we are not returning any outputs. The outputs will be removed, since there aren't any outputs
assertThat(blockTransactionResponse.getTransaction().getOperations()).hasSize(1);
}
@@ -53,8 +66,11 @@ void mapToBlockResponse_test_Ok() {
//given
Block from = newBlock();
+ // Create empty metadata map since this test uses only stake operations, no native tokens
+ Map metadataMap = Map.of();
+
//when
- BlockResponse into = my.mapToBlockResponse(newBlock());
+ BlockResponse into = my.mapToBlockResponseWithMetadata(newBlock(), metadataMap);
//then
assertThat(from.getHash()).isEqualTo(into.getBlock().getBlockIdentifier().getHash());
@@ -157,7 +173,7 @@ void mapToBlockResponse_test_Ok() {
.toList())
.allSatisfy(BlockToBlockResponseTest::assertAllElementsIsNull);
- Currency ada = Currency
+ CurrencyResponse ada = CurrencyResponse
.builder()
.symbol("ADA")
.decimals(6)
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 50e16f232..2f33d0dce 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
@@ -1,17 +1,21 @@
package org.cardanofoundation.rosetta.api.block.mapper;
import java.math.BigInteger;
+import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.TreeSet;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
import org.springframework.beans.factory.annotation.Autowired;
import com.bloxbean.cardano.yaci.core.model.certs.CertificateType;
import org.assertj.core.util.introspection.CaseFormatUtils;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
import org.openapitools.client.model.Amount;
import org.openapitools.client.model.CoinAction;
import org.openapitools.client.model.CoinChange;
-import org.openapitools.client.model.Currency;
+import org.openapitools.client.model.CurrencyResponse;
import org.openapitools.client.model.Operation;
import org.openapitools.client.model.PoolRegistrationParams;
import org.openapitools.client.model.Relay;
@@ -48,7 +52,8 @@ void mapToRosettaTransaction_Test_empty_operations() {
from.setInputs(List.of());
from.setOutputs(List.of());
//when
- Transaction into = my.mapToRosettaTransaction(from);
+ Map emptyMetadataMap = Collections.emptyMap();
+ Transaction into = my.mapToRosettaTransactionWithMetadata(from, emptyMetadataMap);
//then
assertThat(into.getMetadata().getSize()).isEqualTo(from.getSize());
assertThat(into.getMetadata().getScriptSize()).isEqualTo(from.getScriptSize());
@@ -71,7 +76,8 @@ void mapToRosettaTransaction_Test_StakeRegistrationOperations() {
.type(stakeType)
.build()));
//when
- Transaction into = my.mapToRosettaTransaction(from);
+ Map metadataMap = createTestMetadataMap();
+ Transaction into = my.mapToRosettaTransactionWithMetadata(from, metadataMap);
//then
assertThat(into.getMetadata().getSize()).isEqualTo(from.getSize());
assertThat(into.getMetadata().getScriptSize()).isEqualTo(from.getScriptSize());
@@ -110,7 +116,8 @@ void mapToRosettaTransaction_Test_DelegationOperations() {
.poolId("pool_id1")
.build()));
//when
- Transaction into = my.mapToRosettaTransaction(from);
+ Map metadataMap = createTestMetadataMap();
+ Transaction into = my.mapToRosettaTransactionWithMetadata(from, metadataMap);
//then
assertThat(into.getMetadata().getSize()).isEqualTo(from.getSize());
assertThat(into.getMetadata().getScriptSize()).isEqualTo(from.getScriptSize());
@@ -161,7 +168,8 @@ void mapToRosettaTransaction_Test_getPoolRegistrationOperations() {
.relays(relays)
.build()));
//when
- Transaction into = my.mapToRosettaTransaction(from);
+ Map metadataMap = createTestMetadataMap();
+ Transaction into = my.mapToRosettaTransactionWithMetadata(from, metadataMap);
//then
assertThat(into.getMetadata().getSize()).isEqualTo(from.getSize());
assertThat(into.getMetadata().getScriptSize()).isEqualTo(from.getScriptSize());
@@ -207,7 +215,8 @@ void mapToRosettaTransaction_Test_getPoolRetirementOperations() {
.build()));
//when
- Transaction into = my.mapToRosettaTransaction(from);
+ Map metadataMap = createTestMetadataMap();
+ Transaction into = my.mapToRosettaTransactionWithMetadata(from, metadataMap);
//then
assertThat(into.getMetadata().getSize()).isEqualTo(from.getSize());
assertThat(into.getMetadata().getScriptSize()).isEqualTo(from.getScriptSize());
@@ -238,7 +247,8 @@ void mapToRosettaTransaction_Test_getOutputsAsOperations() {
//given
BlockTx from = newTran();
//when
- Transaction into = my.mapToRosettaTransaction(from);
+ Map metadataMap = createTestMetadataMap();
+ Transaction into = my.mapToRosettaTransactionWithMetadata(from, metadataMap);
//then
assertThat(into.getMetadata().getSize()).isEqualTo(from.getSize());
assertThat(into.getMetadata().getScriptSize()).isEqualTo(from.getScriptSize());
@@ -284,7 +294,7 @@ void mapToRosettaTransaction_Test_getOutputsAsOperations() {
Amount token = bundle.getTokens().getFirst();
assertThat(token.getCurrency().getSymbol())
- .isEqualTo("unit1");
+ .isEqualTo("assetName1");
assertThat(token.getCurrency().getDecimals()).isZero();
}
@@ -294,7 +304,8 @@ void mapToRosettaTransaction_Test_getInputsAsOperations() {
//given
BlockTx from = newTran();
//when
- Transaction into = my.mapToRosettaTransaction(from);
+ Map metadataMap = createTestMetadataMap();
+ Transaction into = my.mapToRosettaTransactionWithMetadata(from, metadataMap);
//then
assertThat(into.getMetadata().getSize()).isEqualTo(from.getSize());
assertThat(into.getMetadata().getScriptSize()).isEqualTo(from.getScriptSize());
@@ -331,7 +342,7 @@ void mapToRosettaTransaction_Test_getInputsAsOperations() {
Amount token = bundle.getTokens().getFirst();
assertThat(token.getCurrency().getSymbol())
- .isEqualTo("unit1");
+ .isEqualTo("assetName1");
assertThat(token.getCurrency().getDecimals()).isZero();
}
@@ -345,7 +356,8 @@ void mapToRosettaTransaction_Test_getWithdrawlOperations() {
.stakeAddress("stake_addr1_for_withdraw")
.build()));
//when
- Transaction into = my.mapToRosettaTransaction(from);
+ Map metadataMap = createTestMetadataMap();
+ Transaction into = my.mapToRosettaTransactionWithMetadata(from, metadataMap);
//then
assertThat(into.getOperations()).hasSize(3);
Optional opt = into.getOperations()
@@ -367,7 +379,7 @@ void mapToRosettaTransaction_Test_getWithdrawlOperations() {
private static Amount amountActual(String value) {
return Amount.builder()
- .currency(Currency.builder().symbol(ADA).decimals(ADA_DECIMALS).build())
+ .currency(CurrencyResponse.builder().symbol(ADA).decimals(ADA_DECIMALS).build())
.value(value)
.build();
}
@@ -420,11 +432,24 @@ private static Amt newAdaAmt() {
}
private static Amt newTokenAmt() {
+ String policyId = "policyId1";
+ String symbol = "assetName1";
return Amt.builder()
.assetName("assetName1")
- .policyId("policyId1")
+ .policyId(policyId)
.quantity(BigInteger.ONE)
- .unit("unit1")
+ .unit(policyId + symbol)
+ .build();
+ }
+
+ private static Map createTestMetadataMap() {
+ Map metadataMap = new java.util.HashMap<>();
+ AssetFingerprint testAssetFingerprint = AssetFingerprint.of("policyId1", "assetName1");
+ TokenRegistryCurrencyData metadata = TokenRegistryCurrencyData.builder()
+ .policyId("policyId1")
+ .decimals(0) // Default decimals for test
.build();
+ metadataMap.put(testAssetFingerprint, metadata);
+ return metadataMap;
}
}
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 dd8a3b540..f73539da0 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
@@ -2,25 +2,62 @@
import org.assertj.core.api.Assertions;
import org.cardanofoundation.rosetta.api.account.model.domain.Amt;
+import org.cardanofoundation.rosetta.api.common.model.AssetFingerprint;
+import org.cardanofoundation.rosetta.api.common.model.TokenRegistryCurrencyData;
+import org.cardanofoundation.rosetta.api.common.service.TokenRegistryService;
+import org.cardanofoundation.rosetta.common.mapper.DataMapper;
+import org.cardanofoundation.rosetta.common.services.ProtocolParamService;
import org.cardanofoundation.rosetta.common.util.Constants;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.mockito.Mockito;
-import org.openapitools.client.model.Amount;
-import org.openapitools.client.model.Currency;
-import org.openapitools.client.model.OperationMetadata;
-import org.openapitools.client.model.TokenBundleItem;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.openapitools.client.model.*;
+import java.math.BigDecimal;
import java.math.BigInteger;
-import java.util.Arrays;
-import java.util.List;
+import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.Mockito.lenient;
+@ExtendWith(MockitoExtension.class)
class TransactionMapperUtilsTest {
- final private TransactionMapperUtils transactionMapperUtils = Mockito.mock(
- TransactionMapperUtils.class,
- Mockito.CALLS_REAL_METHODS);
+ @Mock
+ private ProtocolParamService protocolParamService;
+
+ @Mock
+ private TokenRegistryService tokenRegistryService;
+
+ private TransactionMapperUtils transactionMapperUtils;
+ private DataMapper dataMapper;
+
+ @BeforeEach
+ void setUp() {
+ // Create real DataMapper instance with its dependency
+ org.cardanofoundation.rosetta.api.common.mapper.TokenRegistryMapper tokenRegistryMapper =
+ new org.cardanofoundation.rosetta.api.common.mapper.TokenRegistryMapperImpl();
+ dataMapper = new DataMapper(tokenRegistryMapper);
+
+ transactionMapperUtils = new TransactionMapperUtils(protocolParamService, dataMapper);
+
+ // Configure TokenRegistryService to return fallback metadata for any asset
+ lenient().when(tokenRegistryService.getTokenMetadataBatch(anySet())).thenAnswer(invocation -> {
+ Map result = new HashMap<>();
+ @SuppressWarnings("unchecked")
+ Set assetFingerprints = (Set) invocation.getArgument(0);
+ for (AssetFingerprint assetFingerprint : assetFingerprints) {
+ result.put(assetFingerprint, TokenRegistryCurrencyData.builder()
+ .policyId(assetFingerprint.getPolicyId())
+ .decimals(0) // Default decimals
+ .build());
+ }
+ return result;
+ });
+ }
@Test
void mapToOperationMetaDataTest() {
@@ -37,8 +74,12 @@ void mapToOperationMetaDataTest() {
newAmt(3, 33, false),
newAmt(4, 41, true)
);
+
+ // Create metadata map for the cached method
+ Map metadataMap = createMetadataMapForAmounts(amtList);
+
// when
- OperationMetadata operationMetadata = transactionMapperUtils.mapToOperationMetaData(true, amtList);
+ OperationMetadata operationMetadata = transactionMapperUtils.mapToOperationMetaDataWithCache(true, amtList, metadataMap);
// then
assertNotNull(operationMetadata);
List tokenBundle = operationMetadata.getTokenBundle();
@@ -60,28 +101,219 @@ void mapToOperationMetaDataNegativeTest() {
List amtList = Arrays.asList(
newAmt(1, 11, true),
newAmt(2, 21, true));
+
+ // Create metadata map for the cached method
+ Map metadataMap = createMetadataMapForAmounts(amtList);
+
// when
- OperationMetadata operationMetadata = transactionMapperUtils.mapToOperationMetaData(true, amtList);
+ OperationMetadata operationMetadata = transactionMapperUtils.mapToOperationMetaDataWithCache(true, amtList, metadataMap);
// then
assertNull(operationMetadata);
}
+ @Test
+ void mapToOperationMetaDataWithTokenRegistryTest() {
+ // given
+ String policyId = "testPolicyId";
+ String assetName = "testAsset";
+ String subject = policyId + "746573744173736574"; // hex encoding of "testAsset"
+
+ // Create Asset object for the request
+ AssetFingerprint assetFingerprint = AssetFingerprint.of(policyId, assetName);
+
+ // Mock token registry response with full metadata
+ TokenRegistryCurrencyData currencyMetadata = TokenRegistryCurrencyData.builder()
+ .policyId(policyId)
+ .subject(subject)
+ .name("Test Token")
+ .description("Test description")
+ .ticker("TST")
+ .url("https://test.com")
+ .logo(TokenRegistryCurrencyData.LogoData.builder().format(TokenRegistryCurrencyData.LogoFormat.BASE64).value("base64logo").build())
+ .decimals(6)
+ .version(BigDecimal.valueOf(1L))
+ .build();
+
+ Map tokenMetadataMap = new HashMap<>();
+ tokenMetadataMap.put(assetFingerprint, currencyMetadata);
+
+ List amtList = Arrays.asList(
+ newAmtWithCustomName(policyId, assetName, false)
+ );
+
+ // when
+ OperationMetadata operationMetadata = transactionMapperUtils.mapToOperationMetaDataWithCache(false, amtList, tokenMetadataMap);
+
+ // then
+ assertNotNull(operationMetadata);
+ List tokenBundle = operationMetadata.getTokenBundle();
+ assertEquals(1, tokenBundle.size());
+
+ TokenBundleItem item = tokenBundle.get(0);
+ assertEquals(policyId, item.getPolicyId());
+ assertEquals(1, item.getTokens().size());
+
+ Amount amount = item.getTokens().get(0);
+ assertNotNull(amount.getCurrency());
+
+ CurrencyResponse currency = amount.getCurrency();
+ assertEquals(assetName, currency.getSymbol());
+ assertEquals(6, currency.getDecimals());
+
+ // Verify metadata injection
+ org.openapitools.client.model.CurrencyMetadataResponse responseMetadata = currency.getMetadata();
+ assertNotNull(responseMetadata);
+ assertEquals(policyId, responseMetadata.getPolicyId());
+ assertEquals(subject, responseMetadata.getSubject());
+ assertEquals("Test Token", responseMetadata.getName());
+ assertEquals("Test description", responseMetadata.getDescription());
+ assertEquals("TST", responseMetadata.getTicker());
+ assertEquals("https://test.com", responseMetadata.getUrl());
+ assertNotNull(responseMetadata.getLogo());
+ assertEquals(org.openapitools.client.model.LogoType.FormatEnum.BASE64, responseMetadata.getLogo().getFormat());
+ assertEquals("base64logo", responseMetadata.getLogo().getValue());
+ assertEquals(BigDecimal.valueOf(1L), responseMetadata.getVersion());
+ }
+
+ @Test
+ void mapToOperationMetaDataWithoutTokenRegistryTest() {
+ // given - token registry returns fallback metadata with only policyId
+ String policyId = "testPolicyId";
+ String assetName = "testAsset";
+
+ AssetFingerprint assetFingerprint = AssetFingerprint.of(policyId, assetName);
+
+ // Mock service to return fallback metadata (service always returns something now)
+ TokenRegistryCurrencyData fallbackMetadata = TokenRegistryCurrencyData.builder()
+ .policyId(policyId)
+ .decimals(0)
+ .build();
+
+ Map tokenMetadataMap = new HashMap<>();
+ tokenMetadataMap.put(assetFingerprint, fallbackMetadata);
+
+ List amtList = Arrays.asList(
+ newAmtWithCustomName(policyId, assetName, false)
+ );
+
+ // when
+ OperationMetadata operationMetadata = transactionMapperUtils.mapToOperationMetaDataWithCache(false, amtList, tokenMetadataMap);
+
+ // then
+ assertNotNull(operationMetadata);
+ List