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 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(0, currency.getDecimals()); // Default when no metadata + + // Verify fallback metadata is present with at least policyId + org.openapitools.client.model.CurrencyMetadataResponse currencyMetadata = currency.getMetadata(); + assertNotNull(currencyMetadata); + assertEquals(policyId, currencyMetadata.getPolicyId()); + // Other fields should be null since this is fallback metadata + assertNull(currencyMetadata.getName()); + assertNull(currencyMetadata.getDescription()); + } + + @Test + void mapToOperationMetaDataSpentAmountTest() { + // given + String policyId = "testPolicyId"; + String assetName = "testAsset"; + + AssetFingerprint assetFingerprint = AssetFingerprint.of(policyId, assetName); + + // Mock service to return fallback metadata + TokenRegistryCurrencyData fallbackMetadata = TokenRegistryCurrencyData.builder() + .policyId(policyId) + .decimals(0) + .build(); + + Map tokenMetadataMap = new HashMap<>(); + tokenMetadataMap.put(assetFingerprint, fallbackMetadata); + + List amtList = Arrays.asList( + Amt.builder() + .assetName(assetName) + .policyId(policyId) + .quantity(BigInteger.valueOf(1000)) + .unit(policyId + assetName) + .build() + ); + + // when - test spent=true + OperationMetadata operationMetadata = transactionMapperUtils.mapToOperationMetaDataWithCache(true, amtList, tokenMetadataMap); + + // then + assertNotNull(operationMetadata); + Amount amount = operationMetadata.getTokenBundle().get(0).getTokens().get(0); + assertEquals("-1000", amount.getValue()); // Negative for spent + + // when - test spent=false + operationMetadata = transactionMapperUtils.mapToOperationMetaDataWithCache(false, amtList, createMetadataMapForAmounts(amtList)); + + // then + assertNotNull(operationMetadata); + amount = operationMetadata.getTokenBundle().get(0).getTokens().get(0); + assertEquals("1000", amount.getValue()); // Positive for received + } + + private Map createMetadataMapForAmounts(List amtList) { + Map metadataMap = new HashMap<>(); + for (Amt amt : amtList) { + if (!Constants.LOVELACE.equals(amt.getUnit())) { + String symbol = amt.getSymbolHex(); + AssetFingerprint assetFingerprint = AssetFingerprint.of(amt.getPolicyId(), symbol); + TokenRegistryCurrencyData metadata = TokenRegistryCurrencyData.builder() + .policyId(amt.getPolicyId()) + .decimals(0) // Default decimals + .build(); + metadataMap.put(assetFingerprint, metadata); + } + } + return metadataMap; + } + private static List getPolicyIdUnits(List tokenBundle, String policyId) { return tokenBundle.stream() .filter(t -> t.getPolicyId().equals(policyId)) .map(TokenBundleItem::getTokens) .flatMap(List::stream) .map(Amount::getCurrency) - .map(Currency::getSymbol) + .map(CurrencyResponse::getSymbol) .toList(); } private static Amt newAmt(int policy, int number, boolean isLovelace) { + String policyId = "policyId" + policy; + String symbol = isLovelace ? "" : "unit" + number; + String unit = isLovelace ? Constants.LOVELACE : policyId + symbol; + return Amt.builder() .assetName(isLovelace ? Constants.LOVELACE : "assetName" + number) - .policyId("policyId" + policy) + .policyId(policyId) + .quantity(BigInteger.ONE) + .unit(unit) + .build(); + } + + private static Amt newAmtWithCustomName(String policyId, String assetName, boolean isLovelace) { + String unit = isLovelace ? Constants.LOVELACE : policyId + assetName; + + return Amt.builder() + .assetName(isLovelace ? Constants.LOVELACE : assetName) + .policyId(policyId) .quantity(BigInteger.ONE) - .unit("unit" + number) + .unit(unit) .build(); } diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/common/model/AssetFingerprintTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/common/model/AssetFingerprintTest.java new file mode 100644 index 000000000..d70496d3a --- /dev/null +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/common/model/AssetFingerprintTest.java @@ -0,0 +1,305 @@ +package org.cardanofoundation.rosetta.api.common.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AssetFingerprintTest { + + private static final String POLICY_ID = "1e349c9bdea19fd6c147626a5260bc44b71635f398b67c59881df209"; + private static final String SYMBOL_HEX = "504154415445"; + private static final String SUBJECT = POLICY_ID + SYMBOL_HEX; + + @Nested + @DisplayName("Factory Method Tests") + class FactoryMethodTests { + + @Test + @DisplayName("Should create AssetFingerprint from valid subject") + void shouldCreateFromValidSubject() { + // when + AssetFingerprint result = AssetFingerprint.fromSubject(SUBJECT); + + // then + assertThat(result).isNotNull(); + assertThat(result.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(result.getSymbol()).isEqualTo(SYMBOL_HEX); + } + + @Test + @DisplayName("Should create AssetFingerprint from valid unit") + void shouldCreateFromValidUnit() { + // when + AssetFingerprint result = AssetFingerprint.fromUnit(SUBJECT); + + // then + assertThat(result).isNotNull(); + assertThat(result.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(result.getSymbol()).isEqualTo(SYMBOL_HEX); + } + + @Test + @DisplayName("Should create AssetFingerprint with empty symbol") + void shouldCreateWithEmptySymbol() { + // given + String subjectWithoutSymbol = POLICY_ID; + + // when + AssetFingerprint result = AssetFingerprint.fromSubject(subjectWithoutSymbol); + + // then + assertThat(result).isNotNull(); + assertThat(result.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(result.getSymbol()).isEmpty(); + } + + @Test + @DisplayName("Should handle uppercase hex characters") + void shouldHandleUppercaseHex() { + // given + String upperCaseSubject = POLICY_ID.toUpperCase() + SYMBOL_HEX.toUpperCase(); + + // when + AssetFingerprint result = AssetFingerprint.fromSubject(upperCaseSubject); + + // then + assertThat(result).isNotNull(); + assertThat(result.getPolicyId()).isEqualTo(POLICY_ID.toUpperCase()); + assertThat(result.getSymbol()).isEqualTo(SYMBOL_HEX.toUpperCase()); + } + + @Test + @DisplayName("Should handle mixed case hex characters") + void shouldHandleMixedCaseHex() { + // given + String mixedCaseSubject = "1E349c9bDEA19fd6c147626a5260bc44b71635f398b67c59881df209504154415445"; + + // when + AssetFingerprint result = AssetFingerprint.fromSubject(mixedCaseSubject); + + // then + assertThat(result).isNotNull(); + assertThat(result.getPolicyId()).isEqualTo("1E349c9bDEA19fd6c147626a5260bc44b71635f398b67c59881df209"); + assertThat(result.getSymbol()).isEqualTo("504154415445"); + } + + @Test + @DisplayName("Should throw NullPointerException for null subject") + void shouldThrowNullPointerExceptionForNullSubject() { + // when/then + assertThatThrownBy(() -> AssetFingerprint.fromSubject(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("subject is null"); + } + + @Test + @DisplayName("Should throw NullPointerException for empty subject") + void shouldThrowNullPointerExceptionForEmptySubject() { + // when/then + assertThatThrownBy(() -> AssetFingerprint.fromSubject("")) + .isInstanceOf(NullPointerException.class) + .hasMessage("subject is null"); + } + + @Test + @DisplayName("Should throw NullPointerException for subject shorter than policy ID length") + void shouldThrowNullPointerExceptionForShortSubject() { + // given + String shortSubject = "1e349c9bdea19fd6c147626a5260bc44b71635f398b67c59881df2"; // 55 chars + + // when/then + assertThatThrownBy(() -> AssetFingerprint.fromSubject(shortSubject)) + .isInstanceOf(NullPointerException.class) + .hasMessage("subject is null"); + } + + @Test + @DisplayName("Should throw IllegalArgumentException for non-hex characters") + void shouldThrowIllegalArgumentExceptionForNonHexCharacters() { + // given + String invalidSubject = "1e349c9bdea19fd6c147626a5260bc44b71635f398b67c59881df20G504154415445"; // contains 'G' + + // when/then + assertThatThrownBy(() -> AssetFingerprint.fromSubject(invalidSubject)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("subject is not a hex string"); + } + + @Test + @DisplayName("Should throw IllegalArgumentException for subject with special characters") + void shouldThrowIllegalArgumentExceptionForSpecialCharacters() { + // given + String invalidSubject = POLICY_ID + "-" + SYMBOL_HEX; + + // when/then + assertThatThrownBy(() -> AssetFingerprint.fromSubject(invalidSubject)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("subject is not a hex string"); + } + + @Test + @DisplayName("Should throw IllegalArgumentException for subject with spaces") + void shouldThrowIllegalArgumentExceptionForSubjectWithSpaces() { + // given + String invalidSubject = POLICY_ID + " " + SYMBOL_HEX; + + // when/then + assertThatThrownBy(() -> AssetFingerprint.fromSubject(invalidSubject)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("subject is not a hex string"); + } + } + + @Nested + @DisplayName("Conversion Method Tests") + class ConversionMethodTests { + + @Test + @DisplayName("Should convert to subject correctly") + void shouldConvertToSubject() { + // given + AssetFingerprint fingerprint = AssetFingerprint.of(POLICY_ID, SYMBOL_HEX); + + // when + String result = fingerprint.toSubject(); + + // then + assertThat(result).isEqualTo(SUBJECT); + } + + @Test + @DisplayName("Should convert to unit correctly") + void shouldConvertToUnit() { + // given + AssetFingerprint fingerprint = AssetFingerprint.of(POLICY_ID, SYMBOL_HEX); + + // when + String result = fingerprint.toUnit(); + + // then + assertThat(result).isEqualTo(SUBJECT); + } + + @Test + @DisplayName("toSubject and toUnit should return same value") + void toSubjectAndToUnitShouldReturnSameValue() { + // given + AssetFingerprint fingerprint = AssetFingerprint.of(POLICY_ID, SYMBOL_HEX); + + // when + String subject = fingerprint.toSubject(); + String unit = fingerprint.toUnit(); + + // then + assertThat(subject).isEqualTo(unit); + } + + @Test + @DisplayName("Should handle empty symbol in conversion") + void shouldHandleEmptySymbolInConversion() { + // given + AssetFingerprint fingerprint = AssetFingerprint.of(POLICY_ID, ""); + + // when + String result = fingerprint.toSubject(); + + // then + assertThat(result).isEqualTo(POLICY_ID); + } + } + + @Nested + @DisplayName("Round-Trip Tests") + class RoundTripTests { + + @Test + @DisplayName("Should preserve data through fromSubject -> toSubject round-trip") + void shouldPreserveDataThroughSubjectRoundTrip() { + // when + AssetFingerprint fingerprint = AssetFingerprint.fromSubject(SUBJECT); + String roundTripped = fingerprint.toSubject(); + + // then + assertThat(roundTripped).isEqualTo(SUBJECT); + } + + @Test + @DisplayName("Should preserve data through fromUnit -> toUnit round-trip") + void shouldPreserveDataThroughUnitRoundTrip() { + // when + AssetFingerprint fingerprint = AssetFingerprint.fromUnit(SUBJECT); + String roundTripped = fingerprint.toUnit(); + + // then + assertThat(roundTripped).isEqualTo(SUBJECT); + } + + @Test + @DisplayName("Should preserve data with empty symbol through round-trip") + void shouldPreserveEmptySymbolThroughRoundTrip() { + // given + String policyIdOnly = POLICY_ID; + + // when + AssetFingerprint fingerprint = AssetFingerprint.fromSubject(policyIdOnly); + String roundTripped = fingerprint.toSubject(); + + // then + assertThat(roundTripped).isEqualTo(policyIdOnly); + } + } + + @Nested + @DisplayName("Equality and HashCode Tests") + class EqualityTests { + + @Test + @DisplayName("Should be equal when policyId and symbol match") + void shouldBeEqualWhenFieldsMatch() { + // given + AssetFingerprint fingerprint1 = AssetFingerprint.of(POLICY_ID, SYMBOL_HEX); + AssetFingerprint fingerprint2 = AssetFingerprint.of(POLICY_ID, SYMBOL_HEX); + + // then + assertThat(fingerprint1).isEqualTo(fingerprint2); + assertThat(fingerprint1.hashCode()).isEqualTo(fingerprint2.hashCode()); + } + + @Test + @DisplayName("Should not be equal when policyId differs") + void shouldNotBeEqualWhenPolicyIdDiffers() { + // given + AssetFingerprint fingerprint1 = AssetFingerprint.of(POLICY_ID, SYMBOL_HEX); + AssetFingerprint fingerprint2 = AssetFingerprint.of("ffffffffffffffffffffffffffffffffffffffffffffffffffffffff", SYMBOL_HEX); + + // then + assertThat(fingerprint1).isNotEqualTo(fingerprint2); + } + + @Test + @DisplayName("Should not be equal when symbol differs") + void shouldNotBeEqualWhenSymbolDiffers() { + // given + AssetFingerprint fingerprint1 = AssetFingerprint.of(POLICY_ID, SYMBOL_HEX); + AssetFingerprint fingerprint2 = AssetFingerprint.of(POLICY_ID, "123456"); + + // then + assertThat(fingerprint1).isNotEqualTo(fingerprint2); + } + + @Test + @DisplayName("Fingerprints created from same subject should be equal") + void fingerprintsFromSameSubjectShouldBeEqual() { + // when + AssetFingerprint fingerprint1 = AssetFingerprint.fromSubject(SUBJECT); + AssetFingerprint fingerprint2 = AssetFingerprint.fromSubject(SUBJECT); + + // then + assertThat(fingerprint1).isEqualTo(fingerprint2); + assertThat(fingerprint1.hashCode()).isEqualTo(fingerprint2.hashCode()); + } + } +} diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryServiceImplTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryServiceImplTest.java new file mode 100644 index 000000000..566dcf0af --- /dev/null +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/common/service/TokenRegistryServiceImplTest.java @@ -0,0 +1,1272 @@ +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.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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.openapitools.client.model.*; + +import static org.cardanofoundation.rosetta.common.util.Constants.LOVELACE; + +import java.math.BigDecimal; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TokenRegistryServiceImpl Tests") +class TokenRegistryServiceImplTest { + + @Mock + private TokenRegistryHttpGateway tokenRegistryHttpGateway; + + private TokenRegistryServiceImpl tokenRegistryService; + + private static final String POLICY_ID = "a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3"; + private static final String ASSET_NAME = "TestToken"; // Human-readable name + private static final String ASSET_SYMBOL_HEX = "54657374546f6b656e"; // hex encoding of "TestToken" + private static final String SUBJECT = POLICY_ID + ASSET_SYMBOL_HEX; + + @BeforeEach + void setUp() { + tokenRegistryService = new TokenRegistryServiceImpl(tokenRegistryHttpGateway); + } + + @Nested + @DisplayName("getTokenMetadataBatch Tests") + class GetTokenMetadataBatchTests { + + @Test + @DisplayName("Should return empty map when assets set is empty") + void shouldReturnEmptyMapForEmptyAssets() { + // given + Set emptyAssetFingerprints = Set.of(); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(emptyAssetFingerprints); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(tokenRegistryHttpGateway); + } + + @Test + @DisplayName("Should return fallback metadata when gateway returns no data") + void shouldReturnFallbackMetadataWhenNoGatewayData() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of(SUBJECT, Optional.empty())); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + assertThat(result).hasSize(1); + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + assertThat(metadata).isNotNull(); + assertThat(metadata.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(metadata.getSubject()).isNull(); + assertThat(metadata.getName()).isNull(); + assertThat(metadata.getDescription()).isNull(); + assertThat(metadata.getDecimals()).isNull(); + } + + @Test + @DisplayName("Should return fallback metadata when gateway returns null entry") + void shouldReturnFallbackMetadataWhenGatewayReturnsNull() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + Map> gatewayResponse = new HashMap<>(); + gatewayResponse.put(SUBJECT, null); + + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(gatewayResponse); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + assertThat(result).hasSize(1); + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + assertThat(metadata).isNotNull(); + assertThat(metadata.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(metadata.getSubject()).isNull(); + assertThat(metadata.getName()).isNull(); + } + + @Test + @DisplayName("Should return fallback metadata when subject not found in gateway response") + void shouldReturnFallbackMetadataWhenSubjectNotFound() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of()); // Empty map - subject not found + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + assertThat(result).hasSize(1); + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + assertThat(metadata).isNotNull(); + assertThat(metadata.getPolicyId()).isEqualTo(POLICY_ID); + } + + @Test + @DisplayName("Should extract complete metadata when gateway returns full token data") + void shouldExtractCompleteMetadataWhenFullDataAvailable() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + TokenSubject tokenSubject = createCompleteTokenSubject(); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of(SUBJECT, Optional.of(tokenSubject))); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + assertThat(result).hasSize(1); + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + + assertThat(metadata.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(metadata.getSubject()).isEqualTo(SUBJECT); + assertThat(metadata.getName()).isEqualTo("Test Token"); + assertThat(metadata.getDescription()).isEqualTo("Test Description"); + assertThat(metadata.getTicker()).isEqualTo("TST"); + assertThat(metadata.getUrl()).isEqualTo("https://test.com"); + assertThat(metadata.getDecimals()).isEqualTo(6); + assertThat(metadata.getVersion()).isEqualTo(BigDecimal.valueOf(1L)); + + assertThat(metadata.getLogo()).isNotNull(); + assertThat(metadata.getLogo().getFormat()).isEqualTo(TokenRegistryCurrencyData.LogoFormat.BASE64); + assertThat(metadata.getLogo().getValue()).isEqualTo("base64logo"); + } + + @Test + @DisplayName("Should handle multiple assets correctly") + void shouldHandleMultipleAssetsCorrectly() { + // given + AssetFingerprint assetFingerprint1 = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + AssetFingerprint assetFingerprint2 = createAsset("policy2", "Asset2"); + Set assetFingerprints = Set.of(assetFingerprint1, assetFingerprint2); + + String subject2 = "policy2" + "417373657432"; // hex encoding of "Asset2" + TokenSubject tokenSubject1 = createCompleteTokenSubject(); + + Map> gatewayResponse = Map.of( + SUBJECT, Optional.of(tokenSubject1), + subject2, Optional.empty() // No data for second asset + ); + + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(gatewayResponse); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + assertThat(result).hasSize(2); + + // First asset should have complete metadata + TokenRegistryCurrencyData metadata1 = result.get(assetFingerprint1); + assertThat(metadata1.getName()).isEqualTo("Test Token"); + assertThat(metadata1.getPolicyId()).isEqualTo(POLICY_ID); + + // Second asset should have fallback metadata only + TokenRegistryCurrencyData metadata2 = result.get(assetFingerprint2); + assertThat(metadata2.getPolicyId()).isEqualTo("policy2"); + assertThat(metadata2.getName()).isNull(); + } + + @Test + @DisplayName("Should handle minimal metadata correctly") + void shouldHandleMinimalMetadataCorrectly() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + TokenSubject tokenSubject = createMinimalTokenSubject(); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of(SUBJECT, Optional.of(tokenSubject))); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + assertThat(result).hasSize(1); + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + + assertThat(metadata.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(metadata.getSubject()).isEqualTo(SUBJECT); + assertThat(metadata.getName()).isEqualTo("Minimal Token"); + assertThat(metadata.getDescription()).isEqualTo("Minimal Description"); + assertThat(metadata.getDecimals()).isEqualTo(0); // Default value + + // Optional fields should be null + assertThat(metadata.getTicker()).isNull(); + assertThat(metadata.getUrl()).isNull(); + assertThat(metadata.getLogo()).isNull(); + assertThat(metadata.getVersion()).isNull(); + } + } + + @Nested + @DisplayName("Logo Conversion Tests") + class LogoConversionTests { + + @Test + @DisplayName("Should convert CIP_26 logo to BASE64 format") + void shouldConvertCip26LogoToBase64() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + TokenSubject tokenSubject = createTokenSubjectWithLogo("CIP_26", "hexdata123"); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of(SUBJECT, Optional.of(tokenSubject))); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + assertThat(metadata.getLogo()).isNotNull(); + assertThat(metadata.getLogo().getFormat()).isEqualTo(TokenRegistryCurrencyData.LogoFormat.BASE64); + assertThat(metadata.getLogo().getValue()).isEqualTo("hexdata123"); + } + + @Test + @DisplayName("Should convert CIP_68 logo to URL format") + void shouldConvertCip68LogoToUrl() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + TokenSubject tokenSubject = createTokenSubjectWithLogo("CIP_68", "https://example.com/logo.png"); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of(SUBJECT, Optional.of(tokenSubject))); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + assertThat(metadata.getLogo()).isNotNull(); + assertThat(metadata.getLogo().getFormat()).isEqualTo(TokenRegistryCurrencyData.LogoFormat.URL); + assertThat(metadata.getLogo().getValue()).isEqualTo("https://example.com/logo.png"); + } + + @Test + @DisplayName("Should handle case insensitive CIP standards") + void shouldHandleCaseInsensitiveCipStandards() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + TokenSubject tokenSubject = createTokenSubjectWithLogo("cip_26", "data"); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of(SUBJECT, Optional.of(tokenSubject))); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + assertThat(metadata.getLogo().getFormat()).isEqualTo(TokenRegistryCurrencyData.LogoFormat.BASE64); + } + + @Test + @DisplayName("Should return null format for unknown CIP standard") + void shouldReturnNullFormatForUnknownCipStandard() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + TokenSubject tokenSubject = createTokenSubjectWithLogo("UNKNOWN", "data"); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of(SUBJECT, Optional.of(tokenSubject))); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + assertThat(metadata.getLogo()).isNotNull(); + assertThat(metadata.getLogo().getFormat()).isNull(); + assertThat(metadata.getLogo().getValue()).isEqualTo("data"); + } + + @Test + @DisplayName("Should return null logo when logo property is null") + void shouldReturnNullLogoWhenPropertyIsNull() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + TokenSubject tokenSubject = createTokenSubjectWithLogo(null, null); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of(SUBJECT, Optional.of(tokenSubject))); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + assertThat(metadata.getLogo()).isNull(); + } + + @Test + @DisplayName("Should create logo with null value when logo value is null") + void shouldCreateLogoWithNullValueWhenValueIsNull() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + TokenProperty logoProperty = mock(TokenProperty.class); + when(logoProperty.getValue()).thenReturn(null); + when(logoProperty.getSource()).thenReturn("CIP_26"); + + TokenSubject tokenSubject = createTokenSubjectWithCustomLogo(logoProperty); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of(SUBJECT, Optional.of(tokenSubject))); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + assertThat(metadata.getLogo()).isNotNull(); + assertThat(metadata.getLogo().getFormat()).isEqualTo(TokenRegistryCurrencyData.LogoFormat.BASE64); + assertThat(metadata.getLogo().getValue()).isNull(); + } + + } + + @Nested + @DisplayName("Edge Cases and Error Handling") + class EdgeCasesAndErrorHandlingTests { + + @Test + @DisplayName("Should handle gateway throwing exception gracefully") + void shouldHandleGatewayExceptionGracefully() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenThrow(new RuntimeException("Gateway error")); + + // when & then + assertThrows(RuntimeException.class, () -> tokenRegistryService.getTokenMetadataBatch(assetFingerprints)); + } + + @Test + @DisplayName("Should handle null decimals gracefully") + void shouldHandleNullDecimalsGracefully() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + TokenSubject tokenSubject = createTokenSubjectWithNullDecimals(); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of(SUBJECT, Optional.of(tokenSubject))); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + TokenRegistryCurrencyData metadata = result.get(assetFingerprint); + assertThat(metadata.getDecimals()).isEqualTo(0); // Default value + } + + @Test + @DisplayName("Should verify correct subject generation for asset") + void shouldVerifyCorrectSubjectGenerationForAsset() { + // given + AssetFingerprint assetFingerprint = createAsset(POLICY_ID, ASSET_SYMBOL_HEX); + Set assetFingerprints = Set.of(assetFingerprint); + + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of()); + + // when + tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + verify(tokenRegistryHttpGateway).getTokenMetadataBatch(eq(Set.of(SUBJECT))); + } + + @Test + @DisplayName("Should handle large batch of assets") + void shouldHandleLargeBatchOfAssets() { + // given + Set assetFingerprints = new HashSet<>(); + Map> gatewayResponse = new HashMap<>(); + + for (int i = 0; i < 100; i++) { + String policyId = "policy" + String.format("%02d", i); + String assetName = "Asset" + i; + AssetFingerprint assetFingerprint = createAsset(policyId, assetName); + assetFingerprints.add(assetFingerprint); + + String subject = assetFingerprint.toSubject(); + gatewayResponse.put(subject, Optional.empty()); + } + + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(gatewayResponse); + + // when + Map result = tokenRegistryService.getTokenMetadataBatch(assetFingerprints); + + // then + assertThat(result).hasSize(100); + result.values().forEach(metadata -> { + assertThat(metadata).isNotNull(); + assertThat(metadata.getPolicyId()).isNotNull(); + }); + } + } + + @Nested + @DisplayName("Asset Extraction from BlockTx Tests") + class AssetFingerprintExtractionFromBlockTxTests { + + + @Test + @DisplayName("Should extract assets from inputs only") + void shouldExtractAssetsFromInputsOnly() { + // given + BlockTx blockTx = createBlockTxWithInputs(); + + // when + Set result = tokenRegistryService.extractAssetsFromBlockTx(blockTx); + + // then + assertThat(result).hasSize(2); + assertThat(result).contains( + AssetFingerprint.of("policy1", "token1"), + AssetFingerprint.of("policy2", "token2") + ); + } + + @Test + @DisplayName("Should extract assets from outputs only") + void shouldExtractAssetsFromOutputsOnly() { + // given + BlockTx blockTx = createBlockTxWithOutputs(); + + // when + Set result = tokenRegistryService.extractAssetsFromBlockTx(blockTx); + + // then + assertThat(result).hasSize(2); + assertThat(result).contains( + AssetFingerprint.of("policy3", "token3"), + AssetFingerprint.of("policy4", "token4") + ); + } + + @Test + @DisplayName("Should extract assets from both inputs and outputs") + void shouldExtractAssetsFromBothInputsAndOutputs() { + // given + BlockTx blockTx = createBlockTxWithInputsAndOutputs(); + + // when + Set result = tokenRegistryService.extractAssetsFromBlockTx(blockTx); + + // then + assertThat(result).hasSize(4); + assertThat(result).contains( + AssetFingerprint.of("policy1", "token1"), + AssetFingerprint.of("policy2", "token2"), + AssetFingerprint.of("policy3", "token3"), + AssetFingerprint.of("policy4", "token4") + ); + } + + @Test + @DisplayName("Should exclude lovelace from extraction") + void shouldExcludeLovelaceFromExtraction() { + // given + BlockTx blockTx = createBlockTxWithLovelaceAndTokens(); + + // when + Set result = tokenRegistryService.extractAssetsFromBlockTx(blockTx); + + // then + assertThat(result).hasSize(1); + assertThat(result).contains( + AssetFingerprint.of("policy1", "token1") + ); + } + + @Test + @DisplayName("Should handle empty inputs and outputs") + void shouldHandleEmptyInputsAndOutputs() { + // given + BlockTx blockTx = BlockTx.builder() + .inputs(List.of()) + .outputs(List.of()) + .build(); + + // when + Set result = tokenRegistryService.extractAssetsFromBlockTx(blockTx); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should handle null amounts in utxos") + void shouldHandleNullAmountsInUtxos() { + // given + Utxo utxoWithNullAmounts = Utxo.builder().amounts(null).build(); + BlockTx blockTx = BlockTx.builder() + .inputs(List.of(utxoWithNullAmounts)) + .build(); + + // when + Set result = tokenRegistryService.extractAssetsFromBlockTx(blockTx); + + // then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("Asset Extraction from Amounts Tests") + class AssetFingerprintExtractionFromAmountsTests { + + + @Test + @DisplayName("Should return empty set when amounts is empty") + void shouldReturnEmptySetWhenAmountsIsEmpty() { + // when + Set result = tokenRegistryService.extractAssetsFromAmounts(List.of()); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should extract native tokens only") + void shouldExtractNativeTokensOnly() { + // given + List amounts = List.of( + createAmt(LOVELACE, null, LOVELACE), // ADA - should be excluded + createAmt("token1", "policy1", "token1"), + createAmt("token2", "policy2", "token2") + ); + + // when + Set result = tokenRegistryService.extractAssetsFromAmounts(amounts); + + // then + assertThat(result).hasSize(2); + assertThat(result).contains( + AssetFingerprint.of("policy1", "token1"), + AssetFingerprint.of("policy2", "token2") + ); + } + + @Test + @DisplayName("Should return empty set when only lovelace present") + void shouldReturnEmptySetWhenOnlyLovelacePresent() { + // given + List amounts = List.of( + createAmt(LOVELACE, null, LOVELACE) + ); + + // when + Set result = tokenRegistryService.extractAssetsFromAmounts(amounts); + + // then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("Asset Extraction from BlockTransactions Tests") + class AssetFingerprintExtractionFromBlockTransactionsTests { + + // Removed: shouldReturnEmptySetWhenTransactionsIsNull - parameter is @NotNull, null is not valid + + @Test + @DisplayName("Should return empty set when transactions is empty") + void shouldReturnEmptySetWhenTransactionsIsEmpty() { + // when + Set result = tokenRegistryService.extractAssetsFromBlockTransactions(List.of()); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should extract assets from operation metadata token bundles") + void shouldExtractAssetsFromOperationMetadataTokenBundles() { + // given + List transactions = List.of(createBlockTransactionWithTokenBundles()); + + // when + Set result = tokenRegistryService.extractAssetsFromBlockTransactions(transactions); + + // then + assertThat(result).hasSize(2); + assertThat(result).contains( + AssetFingerprint.of("policy1", "token1"), + AssetFingerprint.of("policy2", "token2") + ); + } + + @Test + @DisplayName("Should extract assets from operation amount currency metadata") + void shouldExtractAssetsFromOperationAmountCurrencyMetadata() { + // given + List transactions = List.of(createBlockTransactionWithCurrencyMetadata()); + + // when + Set result = tokenRegistryService.extractAssetsFromBlockTransactions(transactions); + + // then + assertThat(result).hasSize(1); + assertThat(result).contains( + AssetFingerprint.of("policy1", "token1") + ); + } + + // Removed: shouldHandleTransactionsWithNullOperations - operations field is @NotNull and initialized to ArrayList, never null + + } + + @Nested + @DisplayName("Asset Extraction from Operations Tests") + class AssetFingerprintExtractionFromOperationsTests { + + + @Test + @DisplayName("Should return empty set when operations is empty") + void shouldReturnEmptySetWhenOperationsIsEmpty() { + // when + Set result = tokenRegistryService.extractAssetsFromOperations(List.of()); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should extract assets from token bundles in operation metadata") + void shouldExtractAssetsFromTokenBundles() { + // given + List operations = List.of(createOperationWithTokenBundle()); + + // when + Set result = tokenRegistryService.extractAssetsFromOperations(operations); + + // then + assertThat(result).hasSize(2); + assertThat(result).contains( + AssetFingerprint.of("policy1", "token1"), + AssetFingerprint.of("policy1", "token2") + ); + } + + @Test + @DisplayName("Should extract assets from operation amount currency metadata") + void shouldExtractAssetsFromAmountCurrencyMetadata() { + // given + List operations = List.of(createOperationWithCurrencyMetadata()); + + // when + Set result = tokenRegistryService.extractAssetsFromOperations(operations); + + // then + assertThat(result).hasSize(1); + assertThat(result).contains( + AssetFingerprint.of("policy1", "token1") + ); + } + + @Test + @DisplayName("Should exclude lovelace from extraction") + void shouldExcludeLovelaceFromExtractionInOperations() { + // given + List operations = List.of(createOperationWithLovelaceAndTokens()); + + // when + Set result = tokenRegistryService.extractAssetsFromOperations(operations); + + // then + assertThat(result).hasSize(1); + assertThat(result).contains( + AssetFingerprint.of("policy1", "token1") + ); + } + + @Test + @DisplayName("Should handle operations with null metadata") + void shouldHandleOperationsWithNullMetadata() { + // given + Operation operation = Operation.builder() + .metadata(null) + .amount(null) + .build(); + List operations = List.of(operation); + + // when + Set result = tokenRegistryService.extractAssetsFromOperations(operations); + + // then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("Helper Method Tests - fetchMetadataFor* variants") + class FetchMetadataHelperMethodTests { + + + @Test + @DisplayName("fetchMetadataForBlockTx should call gateway for BlockTx with assets") + void fetchMetadataForBlockTxShouldCallGatewayWithAssets() { + // given + BlockTx blockTx = createBlockTxWithInputs(); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of()); + + // when + Map result = tokenRegistryService.fetchMetadataForBlockTx(blockTx); + + // then + assertThat(result).hasSize(2); + verify(tokenRegistryHttpGateway).getTokenMetadataBatch(anySet()); + } + + + @Test + @DisplayName("fetchMetadataForBlockTransactions should return empty map for empty transactions") + void fetchMetadataForBlockTransactionsShouldReturnEmptyMapForEmpty() { + // when + Map result = tokenRegistryService.fetchMetadataForBlockTransactions(List.of()); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(tokenRegistryHttpGateway); + } + + @Test + @DisplayName("fetchMetadataForBlockTxList should return empty map for null list") + void fetchMetadataForBlockTxListShouldReturnEmptyMapForNull() { + // when + Map result = tokenRegistryService.fetchMetadataForBlockTxList(null); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(tokenRegistryHttpGateway); + } + + @Test + @DisplayName("fetchMetadataForBlockTxList should return empty map for empty list") + void fetchMetadataForBlockTxListShouldReturnEmptyMapForEmpty() { + // when + Map result = tokenRegistryService.fetchMetadataForBlockTxList(List.of()); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(tokenRegistryHttpGateway); + } + + @Test + @DisplayName("fetchMetadataForBlockTxList should aggregate assets from multiple transactions") + void fetchMetadataForBlockTxListShouldAggregateAssetsFromMultipleTx() { + // given + List blockTxList = List.of( + createBlockTxWithInputs(), + createBlockTxWithOutputs() + ); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of()); + + // when + Map result = tokenRegistryService.fetchMetadataForBlockTxList(blockTxList); + + // then + assertThat(result).hasSize(4); // 2 from inputs + 2 from outputs + verify(tokenRegistryHttpGateway).getTokenMetadataBatch(anySet()); + } + + + @Test + @DisplayName("fetchMetadataForAddressBalances should return empty map for empty balances") + void fetchMetadataForAddressBalancesShouldReturnEmptyMapForEmpty() { + // when + Map result = tokenRegistryService.fetchMetadataForAddressBalances(List.of()); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(tokenRegistryHttpGateway); + } + + @Test + @DisplayName("fetchMetadataForAddressBalances should extract assets from valid balances") + void fetchMetadataForAddressBalancesShouldExtractAssetsFromValidBalances() { + // given + List balances = List.of( + createAddressBalance(LOVELACE), // Should be excluded + createAddressBalance("a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3e4f5a0b1c2d3token1"), + createAddressBalance("short") // Should be excluded - too short + ); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of()); + + // when + Map result = tokenRegistryService.fetchMetadataForAddressBalances(balances); + + // then + assertThat(result).hasSize(1); + verify(tokenRegistryHttpGateway).getTokenMetadataBatch(anySet()); + } + + + @Test + @DisplayName("fetchMetadataForUtxos should return empty map for empty utxos") + void fetchMetadataForUtxosShouldReturnEmptyMapForEmpty() { + // when + Map result = tokenRegistryService.fetchMetadataForUtxos(List.of()); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(tokenRegistryHttpGateway); + } + + @Test + @DisplayName("fetchMetadataForUtxos should extract assets from utxos with native tokens") + void fetchMetadataForUtxosShouldExtractAssetsFromUtxosWithNativeTokens() { + // given + List utxos = List.of( + createUtxoWithAmounts(List.of( + createAmt(LOVELACE, null, LOVELACE), // Should be excluded + createAmt("token1", "policy1", "token1"), + createAmt("token2", "policy2", "token2") + )) + ); + when(tokenRegistryHttpGateway.getTokenMetadataBatch(anySet())) + .thenReturn(Map.of()); + + // when + Map result = tokenRegistryService.fetchMetadataForUtxos(utxos); + + // then + assertThat(result).hasSize(2); + verify(tokenRegistryHttpGateway).getTokenMetadataBatch(anySet()); + } + + + @Test + @DisplayName("fetchMetadataForUtxos should handle amounts with null policyId") + void fetchMetadataForUtxosShouldHandleAmountsWithNullPolicyId() { + // given + List utxos = List.of( + createUtxoWithAmounts(List.of( + createAmt("token1", null, "token1") // null policyId should be excluded + )) + ); + + // when + Map result = tokenRegistryService.fetchMetadataForUtxos(utxos); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(tokenRegistryHttpGateway); + } + } + + // Helper methods + private AssetFingerprint createAsset(String policyId, String symbolHex) { + return AssetFingerprint.of(policyId, symbolHex); + } + + private TokenSubject createCompleteTokenSubject() { + TokenSubject tokenSubject = mock(TokenSubject.class); + when(tokenSubject.getSubject()).thenReturn(SUBJECT); + + TokenMetadata tokenMetadata = mock(TokenMetadata.class); + when(tokenSubject.getMetadata()).thenReturn(tokenMetadata); + + // Mandatory fields + TokenProperty name = mock(TokenProperty.class); + when(name.getValue()).thenReturn("Test Token"); + when(tokenMetadata.getName()).thenReturn(name); + + TokenProperty description = mock(TokenProperty.class); + when(description.getValue()).thenReturn("Test Description"); + when(tokenMetadata.getDescription()).thenReturn(description); + + // Optional fields + TokenProperty ticker = mock(TokenProperty.class); + when(ticker.getValue()).thenReturn("TST"); + when(tokenMetadata.getTicker()).thenReturn(ticker); + + TokenProperty url = mock(TokenProperty.class); + when(url.getValue()).thenReturn("https://test.com"); + when(tokenMetadata.getUrl()).thenReturn(url); + + TokenProperty logo = mock(TokenProperty.class); + when(logo.getValue()).thenReturn("base64logo"); + when(logo.getSource()).thenReturn("CIP_26"); + when(tokenMetadata.getLogo()).thenReturn(logo); + + TokenPropertyNumber version = mock(TokenPropertyNumber.class); + when(version.getValue()).thenReturn(1L); + when(tokenMetadata.getVersion()).thenReturn(version); + + TokenPropertyNumber decimals = mock(TokenPropertyNumber.class); + when(decimals.getValue()).thenReturn(6L); + when(tokenMetadata.getDecimals()).thenReturn(decimals); + + return tokenSubject; + } + + private TokenSubject createMinimalTokenSubject() { + TokenSubject tokenSubject = mock(TokenSubject.class); + when(tokenSubject.getSubject()).thenReturn(SUBJECT); + + TokenMetadata tokenMetadata = mock(TokenMetadata.class); + when(tokenSubject.getMetadata()).thenReturn(tokenMetadata); + + // Only mandatory fields + TokenProperty name = mock(TokenProperty.class); + when(name.getValue()).thenReturn("Minimal Token"); + when(tokenMetadata.getName()).thenReturn(name); + + TokenProperty description = mock(TokenProperty.class); + when(description.getValue()).thenReturn("Minimal Description"); + when(tokenMetadata.getDescription()).thenReturn(description); + + // Optional fields are null + when(tokenMetadata.getTicker()).thenReturn(null); + when(tokenMetadata.getUrl()).thenReturn(null); + when(tokenMetadata.getLogo()).thenReturn(null); + when(tokenMetadata.getVersion()).thenReturn(null); + when(tokenMetadata.getDecimals()).thenReturn(null); + + return tokenSubject; + } + + private TokenSubject createTokenSubjectWithLogo(String source, String value) { + TokenSubject tokenSubject = mock(TokenSubject.class); + when(tokenSubject.getSubject()).thenReturn(SUBJECT); + + TokenMetadata tokenMetadata = mock(TokenMetadata.class); + when(tokenSubject.getMetadata()).thenReturn(tokenMetadata); + + TokenProperty name = mock(TokenProperty.class); + when(name.getValue()).thenReturn("Test Token"); + when(tokenMetadata.getName()).thenReturn(name); + + TokenProperty description = mock(TokenProperty.class); + when(description.getValue()).thenReturn("Test Description"); + when(tokenMetadata.getDescription()).thenReturn(description); + + if (source != null && value != null) { + TokenProperty logo = mock(TokenProperty.class); + when(logo.getValue()).thenReturn(value); + when(logo.getSource()).thenReturn(source); + when(tokenMetadata.getLogo()).thenReturn(logo); + } else { + when(tokenMetadata.getLogo()).thenReturn(null); + } + + return tokenSubject; + } + + private TokenSubject createTokenSubjectWithCustomLogo(TokenProperty logoProperty) { + TokenSubject tokenSubject = mock(TokenSubject.class); + when(tokenSubject.getSubject()).thenReturn(SUBJECT); + + TokenMetadata tokenMetadata = mock(TokenMetadata.class); + when(tokenSubject.getMetadata()).thenReturn(tokenMetadata); + + TokenProperty name = mock(TokenProperty.class); + when(name.getValue()).thenReturn("Test Token"); + when(tokenMetadata.getName()).thenReturn(name); + + TokenProperty description = mock(TokenProperty.class); + when(description.getValue()).thenReturn("Test Description"); + when(tokenMetadata.getDescription()).thenReturn(description); + + when(tokenMetadata.getLogo()).thenReturn(logoProperty); + + return tokenSubject; + } + + private TokenSubject createTokenSubjectWithNullDecimals() { + TokenSubject tokenSubject = mock(TokenSubject.class); + when(tokenSubject.getSubject()).thenReturn(SUBJECT); + + TokenMetadata tokenMetadata = mock(TokenMetadata.class); + when(tokenSubject.getMetadata()).thenReturn(tokenMetadata); + + TokenProperty name = mock(TokenProperty.class); + when(name.getValue()).thenReturn("Test Token"); + when(tokenMetadata.getName()).thenReturn(name); + + TokenProperty description = mock(TokenProperty.class); + when(description.getValue()).thenReturn("Test Description"); + when(tokenMetadata.getDescription()).thenReturn(description); + + when(tokenMetadata.getDecimals()).thenReturn(null); + + return tokenSubject; + } + + // Additional helper methods for new tests + private BlockTx createBlockTxWithInputs() { + return BlockTx.builder() + .inputs(List.of( + createUtxoWithAmounts(List.of( + createAmt("token1", "policy1", "token1"), + createAmt(LOVELACE, null, LOVELACE) + )), + createUtxoWithAmounts(List.of( + createAmt("token2", "policy2", "token2") + )) + )) + .build(); + } + + private BlockTx createBlockTxWithOutputs() { + return BlockTx.builder() + .outputs(List.of( + createUtxoWithAmounts(List.of( + createAmt("token3", "policy3", "token3"), + createAmt(LOVELACE, null, LOVELACE) + )), + createUtxoWithAmounts(List.of( + createAmt("token4", "policy4", "token4") + )) + )) + .build(); + } + + private BlockTx createBlockTxWithInputsAndOutputs() { + return BlockTx.builder() + .inputs(List.of( + createUtxoWithAmounts(List.of( + createAmt("token1", "policy1", "token1") + )), + createUtxoWithAmounts(List.of( + createAmt("token2", "policy2", "token2") + )) + )) + .outputs(List.of( + createUtxoWithAmounts(List.of( + createAmt("token3", "policy3", "token3") + )), + createUtxoWithAmounts(List.of( + createAmt("token4", "policy4", "token4") + )) + )) + .build(); + } + + private BlockTx createBlockTxWithLovelaceAndTokens() { + return BlockTx.builder() + .inputs(List.of( + createUtxoWithAmounts(List.of( + createAmt(LOVELACE, null, LOVELACE), + createAmt("token1", "policy1", "token1") + )) + )) + .build(); + } + + private Utxo createUtxoWithAmounts(List amounts) { + return Utxo.builder().amounts(amounts).build(); + } + + private Amt createAmt(String assetName, String policyId, String unit) { + return Amt.builder() + .assetName(assetName) + .policyId(policyId) + .unit(unit) + .quantity(BigDecimal.valueOf(1000000).toBigInteger()) + .build(); + } + + private BlockTransaction createBlockTransactionWithTokenBundles() { + List tokens1 = List.of( + Amount.builder() + .currency(CurrencyResponse.builder().symbol("token1").build()) + .value("1000") + .build() + ); + + List tokens2 = List.of( + Amount.builder() + .currency(CurrencyResponse.builder().symbol("token2").build()) + .value("2000") + .build() + ); + + TokenBundleItem bundle1 = TokenBundleItem.builder() + .policyId("policy1") + .tokens(tokens1) + .build(); + + TokenBundleItem bundle2 = TokenBundleItem.builder() + .policyId("policy2") + .tokens(tokens2) + .build(); + + OperationMetadata metadata = OperationMetadata.builder() + .tokenBundle(List.of(bundle1, bundle2)) + .build(); + + Operation operation = Operation.builder() + .metadata(metadata) + .build(); + + Transaction transaction = Transaction.builder() + .operations(List.of(operation)) + .build(); + + return BlockTransaction.builder() + .transaction(transaction) + .build(); + } + + private BlockTransaction createBlockTransactionWithCurrencyMetadata() { + CurrencyMetadataResponse metadata = CurrencyMetadataResponse.builder() + .policyId("policy1") + .build(); + + CurrencyResponse currency = CurrencyResponse.builder() + .symbol("token1") + .metadata(metadata) + .build(); + + Amount amount = Amount.builder() + .currency(currency) + .value("1000") + .build(); + + Operation operation = Operation.builder() + .amount(amount) + .build(); + + Transaction transaction = Transaction.builder() + .operations(List.of(operation)) + .build(); + + return BlockTransaction.builder() + .transaction(transaction) + .build(); + } + + private Operation createOperationWithTokenBundle() { + List tokens = List.of( + Amount.builder() + .currency(CurrencyResponse.builder().symbol("token1").build()) + .value("1000") + .build(), + Amount.builder() + .currency(CurrencyResponse.builder().symbol("token2").build()) + .value("2000") + .build() + ); + + TokenBundleItem bundle = TokenBundleItem.builder() + .policyId("policy1") + .tokens(tokens) + .build(); + + OperationMetadata metadata = OperationMetadata.builder() + .tokenBundle(List.of(bundle)) + .build(); + + return Operation.builder() + .metadata(metadata) + .build(); + } + + private Operation createOperationWithCurrencyMetadata() { + CurrencyMetadataResponse metadata = CurrencyMetadataResponse.builder() + .policyId("policy1") + .build(); + + CurrencyResponse currency = CurrencyResponse.builder() + .symbol("token1") + .metadata(metadata) + .build(); + + Amount amount = Amount.builder() + .currency(currency) + .value("1000") + .build(); + + return Operation.builder() + .amount(amount) + .build(); + } + + private Operation createOperationWithLovelaceAndTokens() { + List tokens = List.of( + Amount.builder() + .currency(CurrencyResponse.builder().symbol(LOVELACE).build()) + .value("1000000") + .build(), + Amount.builder() + .currency(CurrencyResponse.builder().symbol("token1").build()) + .value("1000") + .build() + ); + + TokenBundleItem bundle = TokenBundleItem.builder() + .policyId("policy1") + .tokens(tokens) + .build(); + + OperationMetadata metadata = OperationMetadata.builder() + .tokenBundle(List.of(bundle)) + .build(); + + return Operation.builder() + .metadata(metadata) + .build(); + } + + private AddressBalance createAddressBalance(String unit) { + return AddressBalance.builder() + .unit(unit) + .quantity(BigDecimal.valueOf(1000000).toBigInteger()) + .build(); + } +} \ No newline at end of file diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImplTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImplTest.java index eaae2322c..a0d0b3ee3 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImplTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/search/service/SearchServiceImplTest.java @@ -3,6 +3,7 @@ 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.service.TokenRegistryService; import org.cardanofoundation.rosetta.common.exception.ApiException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -15,6 +16,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import java.util.Collections; import java.util.List; import java.util.Optional; @@ -22,6 +24,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) class SearchServiceImplTest { @@ -32,6 +35,9 @@ class SearchServiceImplTest { @Mock private LedgerSearchService ledgerSearchService; + @Mock + private TokenRegistryService tokenRegistryService; + @InjectMocks private SearchServiceImpl searchService; @@ -48,6 +54,9 @@ void setUp() { baseRequest = SearchTransactionsRequest.builder() .networkIdentifier(networkIdentifier) .build(); + + // Mock tokenRegistryService to return empty map (no native tokens) - lenient for tests that don't use it + lenient().when(tokenRegistryService.fetchMetadataForBlockTxList(any())).thenReturn(Collections.emptyMap()); } @Nested @@ -451,7 +460,7 @@ class CurrencySearchTests { @Test void shouldSupportCurrencySearch() { // Given - Currency currency = Currency.builder().symbol("ADA").build(); + CurrencyRequest currency = CurrencyRequest.builder().symbol("ADA").build(); SearchTransactionsRequest request = SearchTransactionsRequest.builder() .networkIdentifier(networkIdentifier) diff --git a/api/src/test/java/org/cardanofoundation/rosetta/client/CachingTokenRegistryHttpGatewayImplTest.java b/api/src/test/java/org/cardanofoundation/rosetta/client/CachingTokenRegistryHttpGatewayImplTest.java new file mode 100644 index 000000000..83441e1ae --- /dev/null +++ b/api/src/test/java/org/cardanofoundation/rosetta/client/CachingTokenRegistryHttpGatewayImplTest.java @@ -0,0 +1,348 @@ +package org.cardanofoundation.rosetta.client; + +import com.google.common.cache.Cache; +import org.cardanofoundation.rosetta.client.model.domain.TokenCacheEntry; +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.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.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CachingTokenRegistryHttpGatewayImplTest { + + @Mock + private HttpClient httpClient; + + @Mock + private Cache tokenMetadataCache; + + @Mock + private HttpResponse httpResponse; + + @InjectMocks + private CachingTokenRegistryHttpGatewayImpl tokenRegistryHttpGateway; + + private final String testSubject = "577f0b1342f8f8f4aed3388b80a8535812950c7a892495c0ecdf0f1e0014df10464c4454"; + private final String testSubject2 = "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c64d494e"; + + private TokenSubject createTestTokenSubject(String subject, String name, String ticker, Long decimals) { + return TokenSubject.builder() + .subject(subject) + .metadata(TokenMetadata.builder() + .name(TokenProperty.builder().value(name).source("CIP_68").build()) + .description(TokenProperty.builder().value(name + " Token").source("CIP_68").build()) + .ticker(TokenProperty.builder().value(ticker).source("CIP_68").build()) + .decimals(TokenPropertyNumber.builder().value(decimals).source("CIP_68").build()) + .build()) + .build(); + } + + private String createTestBatchResponseJson() { + return """ + { + "subjects": [ + { + "subject": "577f0b1342f8f8f4aed3388b80a8535812950c7a892495c0ecdf0f1e0014df10464c4454", + "metadata": { + "name": { + "value": "FLDT", + "source": "CIP_68" + }, + "description": { + "value": "FLDT Token", + "source": "CIP_68" + }, + "ticker": { + "value": "FLDT", + "source": "CIP_68" + }, + "decimals": { + "value": 6, + "source": "CIP_68" + } + } + } + ], + "queryPriority": [ + "CIP_68", + "CIP_26" + ] + } + """; + } + + @BeforeEach + void setUp() { + tokenRegistryHttpGateway.enabled = true; + tokenRegistryHttpGateway.tokenRegistryBaseUrl = "https://tokens.cardano.org/api"; + tokenRegistryHttpGateway.httpRequestTimeoutSeconds = 5; + tokenRegistryHttpGateway.init(); + } + + + + @Nested + class GetTokenMetadataBatchTests { + + @Test + void getTokenMetadataBatch_WhenDisabled_ReturnsEmptyMap() { + tokenRegistryHttpGateway.enabled = false; + + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch( + Set.of(testSubject)); + + assertThat(result).isEmpty(); + verifyNoInteractions(tokenMetadataCache, httpClient); + } + + @Test + void getTokenMetadataBatch_WhenSubjectsIsNull_ThrowsException() { + assertThatThrownBy(() -> tokenRegistryHttpGateway.getTokenMetadataBatch(null)) + .isInstanceOf(NullPointerException.class); + + verifyNoInteractions(tokenMetadataCache, httpClient); + } + + @Test + void getTokenMetadataBatch_WhenSubjectsIsEmpty_ReturnsEmptyMap() { + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch(Collections.emptySet()); + + assertThat(result).isEmpty(); + verifyNoInteractions(tokenMetadataCache, httpClient); + } + + @Test + void getTokenMetadataBatch_WhenAllCacheHits_ReturnsCachedResults() { + Set subjects = Set.of(testSubject, testSubject2); + TokenSubject cached1 = createTestTokenSubject(testSubject, "FLDT", "FLDT", 6L); + TokenSubject cached2 = createTestTokenSubject(testSubject2, "MIN", "MIN", 6L); + + when(tokenMetadataCache.getIfPresent(testSubject)).thenReturn(TokenCacheEntry.found(cached1)); + when(tokenMetadataCache.getIfPresent(testSubject2)).thenReturn(TokenCacheEntry.found(cached2)); + + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch(subjects); + + assertThat(result).hasSize(2); + assertThat(result.get(testSubject)).isEqualTo(Optional.of(cached1)); + assertThat(result.get(testSubject2)).isEqualTo(Optional.of(cached2)); + verifyNoInteractions(httpClient); + } + + @Test + void getTokenMetadataBatch_WhenCacheMiss_FetchesFromRegistry() throws Exception { + Set subjects = Set.of(testSubject); + when(tokenMetadataCache.getIfPresent(anyString())).thenReturn(null); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + when(httpResponse.statusCode()).thenReturn(200); + when(httpResponse.body()).thenReturn(createTestBatchResponseJson()); + + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch(subjects); + + assertThat(result).hasSize(1); + assertThat(result.get(testSubject)).isPresent(); + assertThat(result.get(testSubject).get().getMetadata().getName().getValue()).isEqualTo("FLDT"); + + // Verify HTTP request was made + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).send(requestCaptor.capture(), any(HttpResponse.BodyHandler.class)); + + HttpRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.uri().toString()).isEqualTo("https://tokens.cardano.org/api/v2/subjects/query"); + assertThat(capturedRequest.method()).isEqualTo("POST"); + + verify(tokenMetadataCache).put(eq(testSubject), any(TokenCacheEntry.class)); + } + + @Test + void getTokenMetadataBatch_WhenHttpError_ReturnsPartialResults() throws Exception { + Set subjects = Set.of(testSubject, testSubject2); + TokenSubject cached1 = createTestTokenSubject(testSubject, "FLDT", "FLDT", 6L); + + when(tokenMetadataCache.getIfPresent(testSubject)).thenReturn(TokenCacheEntry.found(cached1)); + when(tokenMetadataCache.getIfPresent(testSubject2)).thenReturn(null); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + when(httpResponse.statusCode()).thenReturn(500); // Simulate server error + + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch(subjects); + + assertThat(result).hasSize(1); // Only cached result + assertThat(result.get(testSubject)).isEqualTo(Optional.of(cached1)); + assertThat(result.get(testSubject2)).isNull(); + } + + @Test + void getTokenMetadataBatch_FiltersInvalidSubjects() throws Exception { + // Use HashSet to allow null values + Set subjects = new HashSet<>(Arrays.asList(testSubject, null, "", " ")); + when(tokenMetadataCache.getIfPresent(any())).thenReturn(null); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + when(httpResponse.statusCode()).thenReturn(200); + when(httpResponse.body()).thenReturn(createTestBatchResponseJson()); + + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch(subjects); + + // Should only process the valid subject + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).send(requestCaptor.capture(), any(HttpResponse.BodyHandler.class)); + + // Verify cache was checked for all subjects (implementation doesn't filter) + verify(tokenMetadataCache).getIfPresent(testSubject); + verify(tokenMetadataCache).getIfPresent(eq(null)); + verify(tokenMetadataCache).getIfPresent(""); + verify(tokenMetadataCache).getIfPresent(" "); + } + + @Test + void getTokenMetadataBatch_WithProperties_UsesPropertiesInRequest() throws Exception { + Set subjects = Set.of(testSubject); + + when(tokenMetadataCache.getIfPresent(anyString())).thenReturn(null); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + when(httpResponse.statusCode()).thenReturn(200); + when(httpResponse.body()).thenReturn(createTestBatchResponseJson()); + + tokenRegistryHttpGateway.getTokenMetadataBatch(subjects); + + // Verify properties were included in cache key + String expectedCacheKey = testSubject; + verify(tokenMetadataCache).getIfPresent(expectedCacheKey); + verify(tokenMetadataCache).put(eq(expectedCacheKey), any(TokenCacheEntry.class)); + } + + @Test + void getTokenMetadataBatch_WhenSubjectNotFoundInRegistry_ReturnsEmptyOptional() throws Exception { + Set subjects = Set.of(testSubject, "nonexistent_subject"); + when(tokenMetadataCache.getIfPresent(anyString())).thenReturn(null); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + when(httpResponse.statusCode()).thenReturn(200); + when(httpResponse.body()).thenReturn(createTestBatchResponseJson()); // Only contains testSubject + + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch(subjects); + + assertThat(result).hasSize(2); + assertThat(result.get(testSubject)).isPresent(); + assertThat(result.get("nonexistent_subject")).isEqualTo(Optional.empty()); + + // Verify both subjects were cached (found as TokenCacheEntry.found, not found as TokenCacheEntry.notFound) + verify(tokenMetadataCache).put(eq(testSubject), argThat(entry -> entry.isFound())); + verify(tokenMetadataCache).put(eq("nonexistent_subject"), argThat(entry -> !entry.isFound())); + } + + @Test + void getTokenMetadataBatch_WhenNotFoundTokenIsCached_SkipsRegistryCall() throws Exception { + Set subjects = Set.of("nonexistent_subject"); + + // Token is cached as not found + when(tokenMetadataCache.getIfPresent("nonexistent_subject")).thenReturn(TokenCacheEntry.notFound()); + + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch(subjects); + + assertThat(result).hasSize(1); + assertThat(result.get("nonexistent_subject")).isEqualTo(Optional.empty()); + + // Verify no HTTP call was made since we had cached not-found result + verifyNoInteractions(httpClient); + } + } + + @Nested + class CacheManagementTests { + + @Test + void evictFromCache_WithValidSubject_InvalidatesCache() { + tokenRegistryHttpGateway.evictFromCache(testSubject); + + verify(tokenMetadataCache).invalidate(testSubject); + // Only invalidates the specific subject + verify(tokenMetadataCache, times(1)).invalidate(testSubject); + } + + @Test + void evictFromCache_WithNullSubject_InvalidatesCache() { + tokenRegistryHttpGateway.evictFromCache(null); + + verify(tokenMetadataCache).invalidate(null); + } + + @Test + void evictFromCache_WithEmptySubject_InvalidatesCache() { + tokenRegistryHttpGateway.evictFromCache(""); + + verify(tokenMetadataCache).invalidate(""); + } + + } + + @Nested + class ErrorHandlingTests { + + @Test + void getTokenMetadataBatch_WhenInterrupted_HandlesInterruption() throws Exception { + when(tokenMetadataCache.getIfPresent(anyString())).thenReturn(null); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new InterruptedException("Thread interrupted")); + + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch(Set.of(testSubject)); + + assertThat(result).isEmpty(); + // Verify thread interrupt status is restored + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + Thread.interrupted(); // Clear interrupt status for subsequent tests + } + + @Test + void getTokenMetadataBatch_WhenIOException_ReturnsPartialResults() throws Exception { + Set subjects = Set.of(testSubject, testSubject2); + TokenSubject cached1 = createTestTokenSubject(testSubject, "FLDT", "FLDT", 6L); + + when(tokenMetadataCache.getIfPresent(testSubject)).thenReturn(TokenCacheEntry.found(cached1)); + when(tokenMetadataCache.getIfPresent(testSubject2)).thenReturn(null); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Network error")); + + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch(subjects); + + assertThat(result).hasSize(1); // Only cached result + assertThat(result.get(testSubject)).isEqualTo(Optional.of(cached1)); + } + + @Test + void getTokenMetadataBatch_WhenJSONParsingFails_ReturnsPartialResults() throws Exception { + Set subjects = Set.of(testSubject); + when(tokenMetadataCache.getIfPresent(anyString())).thenReturn(null); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + when(httpResponse.statusCode()).thenReturn(200); + when(httpResponse.body()).thenReturn("invalid json"); + + Map> result = tokenRegistryHttpGateway.getTokenMetadataBatch(subjects); + + assertThat(result).isEmpty(); + } + } + +} \ No newline at end of file diff --git a/api/src/test/java/org/cardanofoundation/rosetta/client/SelectivePropertyFetchingTest.java b/api/src/test/java/org/cardanofoundation/rosetta/client/SelectivePropertyFetchingTest.java new file mode 100644 index 000000000..d7ea81051 --- /dev/null +++ b/api/src/test/java/org/cardanofoundation/rosetta/client/SelectivePropertyFetchingTest.java @@ -0,0 +1,55 @@ +package org.cardanofoundation.rosetta.client; + +import org.cardanofoundation.rosetta.client.model.domain.TokenRegistryBatchRequest; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test for selective property fetching functionality in token registry integration + */ +class SelectivePropertyFetchingTest { + + @Test + void tokenRegistryBatchRequest_HasCorrectPropertiesField() { + // Given + TokenRegistryBatchRequest request = TokenRegistryBatchRequest.builder() + .subjects(List.of("test-subject")) + .properties(List.of("name", "description", "ticker")) + .build(); + + // When & Then + assertThat(request.getSubjects()).contains("test-subject"); + assertThat(request.getProperties()).containsExactly("name", "description", "ticker"); + } + + @Test + void tokenRegistryBatchRequest_SupportsAllKnownProperties() { + // Given + List allKnownProperties = List.of("name", "description", "ticker", "decimals", "url", "version", "logo"); + + TokenRegistryBatchRequest request = TokenRegistryBatchRequest.builder() + .subjects(List.of("subject1", "subject2")) + .properties(allKnownProperties) + .build(); + + // When & Then + assertThat(request.getSubjects()).hasSize(2); + assertThat(request.getProperties()).containsExactlyElementsOf(allKnownProperties); + } + + @Test + void tokenRegistryBatchRequest_HandlesEmptyCollections() { + // Given + TokenRegistryBatchRequest request = TokenRegistryBatchRequest.builder() + .subjects(List.of()) + .properties(List.of()) + .build(); + + // When & Then + assertThat(request.getSubjects()).isEmpty(); + assertThat(request.getProperties()).isEmpty(); + } +} \ No newline at end of file diff --git a/api/src/test/java/org/cardanofoundation/rosetta/client/TokenRegistryPropertiesFetchTest.java b/api/src/test/java/org/cardanofoundation/rosetta/client/TokenRegistryPropertiesFetchTest.java new file mode 100644 index 000000000..a0a1a239d --- /dev/null +++ b/api/src/test/java/org/cardanofoundation/rosetta/client/TokenRegistryPropertiesFetchTest.java @@ -0,0 +1,81 @@ +package org.cardanofoundation.rosetta.client; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import org.cardanofoundation.rosetta.client.model.domain.TokenCacheEntry; +import org.cardanofoundation.rosetta.client.model.domain.TokenSubject; +import org.junit.jupiter.api.Test; + +import java.net.http.HttpClient; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Simple test for selective property fetching without reflection + */ +class TokenRegistryPropertiesFetchTest { + + @Test + void buildPropertiesList_WhenLogoFetchEnabled_IncludesAllProperties() { + // Given + HttpClient httpClient = mock(HttpClient.class); + Cache cache = CacheBuilder.newBuilder().maximumSize(100).build(); + + TestableTokenRegistryGateway gateway = new TestableTokenRegistryGateway(httpClient, cache, true); + + // When + List properties = gateway.buildPropertiesList(); + + // Then + assertThat(properties).contains("name", "description", "ticker", "decimals", "url", "version", "logo"); + } + + @Test + void buildPropertiesList_WhenLogoFetchDisabled_ExcludesLogo() { + // Given + HttpClient httpClient = mock(HttpClient.class); + Cache cache = CacheBuilder.newBuilder().maximumSize(100).build(); + + TestableTokenRegistryGateway gateway = new TestableTokenRegistryGateway(httpClient, cache, false); + + // When + List properties = gateway.buildPropertiesList(); + + // Then + assertThat(properties).contains("name", "description", "ticker", "decimals", "url", "version"); + assertThat(properties).doesNotContain("logo"); + } + + /** + * Test-specific subclass that exposes configuration for testing without reflection + */ + static class TestableTokenRegistryGateway extends CachingTokenRegistryHttpGatewayImpl { + private final boolean testLogoFetchEnabled; + + public TestableTokenRegistryGateway(HttpClient httpClient, Cache cache, boolean logoFetchEnabled) { + super(httpClient, cache); + this.testLogoFetchEnabled = logoFetchEnabled; + } + + @Override + List buildPropertiesList() { + // Override to use test configuration instead of @Value field + List properties = new java.util.ArrayList<>(); + properties.add("name"); + properties.add("description"); + properties.add("ticker"); + properties.add("decimals"); + properties.add("url"); + properties.add("version"); + + if (testLogoFetchEnabled) { + properties.add("logo"); + } + + return properties; + } + } +} \ No newline at end of file diff --git a/api/src/test/java/org/cardanofoundation/rosetta/common/mapper/CborMapToOperationTest.java b/api/src/test/java/org/cardanofoundation/rosetta/common/mapper/CborMapToOperationTest.java index 48e40edad..eba66d2b4 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/common/mapper/CborMapToOperationTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/common/mapper/CborMapToOperationTest.java @@ -141,13 +141,20 @@ void shouldMapAmountWithCurrencyAndPolicyId() { Map currencyMetadataMap = new Map(); currencyMetadataMap.put(key(Constants.POLICYID), new UnicodeString("policy123")); currencyMap.put(key(Constants.METADATA), currencyMetadataMap); + currencyMap.put(key(Constants.POLICYID), new UnicodeString("policy123")); amountMap.put(key(Constants.CURRENCY), currencyMap); operationMap.put(key(Constants.AMOUNT), amountMap); Operation expected = new Operation(); - Currency currency = new Currency("tADA", 6, null); - currency.setMetadata(new CurrencyMetadata().policyId("policy123")); + CurrencyMetadataResponse metadata = CurrencyMetadataResponse.builder() + .policyId("policy123") + .build(); + CurrencyResponse currency = CurrencyResponse.builder() + .symbol("tADA") + .decimals(6) + .metadata(metadata) + .build(); Amount amount = new Amount("1000000", currency, null); expected.setAmount(amount); @@ -227,7 +234,7 @@ void shouldMapMetadataWithAmountFields() { // Arrange Map operationMap = new Map(); Map metadataMap = new Map(); - Currency ada = new Currency("ADA", 6, null); + CurrencyResponse ada = CurrencyResponse.builder().symbol("ADA").decimals(6).build(); // Withdrawal Amount Map withdrawalAmountMap = new Map(); @@ -303,7 +310,7 @@ void shouldMapTokenBundle() { item1Map.put(key(Constants.POLICYID), new UnicodeString("policy1")); Array tokens1Array = new Array(); Map amount1Wrapper = new Map(); - amount1Wrapper.put(key(Constants.AMOUNT), fromAmount(new Amount("10", new Currency("tokenA", 0, null), null))); + amount1Wrapper.put(key(Constants.AMOUNT), fromAmount(new Amount("10", CurrencyResponse.builder().symbol("tokenA").decimals(0).build(), null))); tokens1Array.add(amount1Wrapper); item1Map.put(key(Constants.TOKENS), tokens1Array); tokenBundleArray.add(item1Map); @@ -313,9 +320,9 @@ void shouldMapTokenBundle() { item2Map.put(key(Constants.POLICYID), new UnicodeString("policy2")); Array tokens2Array = new Array(); Map amount2Wrapper = new Map(); - amount2Wrapper.put(key(Constants.AMOUNT), fromAmount(new Amount("20", new Currency("tokenB", 0, null), null))); + amount2Wrapper.put(key(Constants.AMOUNT), fromAmount(new Amount("20", CurrencyResponse.builder().symbol("tokenB").decimals(0).build(), null))); Map amount3Wrapper = new Map(); - amount3Wrapper.put(key(Constants.AMOUNT), fromAmount(new Amount("30", new Currency("tokenC", 0, null), null))); + amount3Wrapper.put(key(Constants.AMOUNT), fromAmount(new Amount("30", CurrencyResponse.builder().symbol("tokenC").decimals(0).build(), null))); tokens2Array.add(amount2Wrapper); tokens2Array.add(amount3Wrapper); item2Map.put(key(Constants.TOKENS), tokens2Array); @@ -328,13 +335,13 @@ void shouldMapTokenBundle() { OperationMetadata metadata = new OperationMetadata(); TokenBundleItem item1 = new TokenBundleItem(); item1.setPolicyId("policy1"); - item1.setTokens(Collections.singletonList(new Amount("10", new Currency("tokenA", 0, null), null))); + item1.setTokens(Collections.singletonList(new Amount("10", CurrencyResponse.builder().symbol("tokenA").decimals(0).build(), null))); TokenBundleItem item2 = new TokenBundleItem(); item2.setPolicyId("policy2"); item2.setTokens(List.of( - new Amount("20", new Currency("tokenB", 0, null), null), - new Amount("30", new Currency("tokenC", 0, null), null) + new Amount("20", CurrencyResponse.builder().symbol("tokenB").decimals(0).build(), null), + new Amount("30", CurrencyResponse.builder().symbol("tokenC").decimals(0).build(), null) )); metadata.setTokenBundle(List.of(item1, item2)); expected.setMetadata(metadata); @@ -566,7 +573,7 @@ void shouldIgnoreEmptyGovernanceActionString() { } // Helper methods to convert Rosetta models back to CBOR DataItems for test setup - private Map fromCurrency(Currency currency) { + private Map fromCurrency(CurrencyResponse currency) { Map currencyMap = new Map(); currencyMap.put(key(Constants.SYMBOL), new UnicodeString(currency.getSymbol())); currencyMap.put(key(Constants.DECIMALS), new UnsignedInteger(currency.getDecimals())); diff --git a/api/src/test/java/org/cardanofoundation/rosetta/common/mapper/OperationToCborMapTest.java b/api/src/test/java/org/cardanofoundation/rosetta/common/mapper/OperationToCborMapTest.java index bd5beea2f..22b443694 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/common/mapper/OperationToCborMapTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/common/mapper/OperationToCborMapTest.java @@ -9,8 +9,8 @@ import org.openapitools.client.model.AccountIdentifier; import org.openapitools.client.model.AccountIdentifierMetadata; import org.openapitools.client.model.Amount; -import org.openapitools.client.model.Currency; -import org.openapitools.client.model.CurrencyMetadata; +import org.openapitools.client.model.CurrencyResponse; +import org.openapitools.client.model.CurrencyMetadataResponse; import org.openapitools.client.model.CurveType; import org.openapitools.client.model.GovVoteParams; import org.openapitools.client.model.GovVoteRationaleParams; @@ -59,7 +59,7 @@ void shouldConvertToCborMapAndBackSuccessfully() { OperationMetadata operationMetadata = OperationMetadata.builder() .poolRegistrationParams(poolRegistrationParams) - .refundAmount(new Amount("2", new Currency(Constants.ADA, 2, new CurrencyMetadata("policyId")), new Object())) + .refundAmount(new Amount("2", CurrencyResponse.builder().symbol(Constants.ADA).decimals(2).metadata(CurrencyMetadataResponse.builder().policyId("policyId").build()).build(), new Object())) .tokenBundle(List.of(new TokenBundleItem("tokenBundlePolicyId", List.of(new Amount())))) .build(); diff --git a/api/src/test/java/org/cardanofoundation/rosetta/common/util/ValidateParseUtilTest.java b/api/src/test/java/org/cardanofoundation/rosetta/common/util/ValidateParseUtilTest.java index 31358747e..306d88ad5 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/common/util/ValidateParseUtilTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/common/util/ValidateParseUtilTest.java @@ -86,8 +86,8 @@ void validateAndParseTokenBundleWithParametersErrorTest() { ApiException exception = assertThrows(ApiException.class, () -> validateAndParseTokenBundle(List.of( new TokenBundleItem("11111111111111111111111111111111111111111111111111111111", - List.of(new Amount("6", new Currency("ADA", 6, new CurrencyMetadata()), new Object()), - new Amount("6", new Currency("ADA", 6, new CurrencyMetadata()), new Object())))))); + List.of(new Amount("6", CurrencyResponse.builder().symbol("ADA").decimals(6).metadata(CurrencyMetadataResponse.builder().build()).build(), new Object()), + new Amount("6", CurrencyResponse.builder().symbol("ADA").decimals(6).metadata(CurrencyMetadataResponse.builder().build()).build(), new Object())))))); assertEquals("Transaction outputs parameters errors in operations array", exception.getError().getMessage()); assertEquals(4009, exception.getError().getCode()); } diff --git a/api/src/test/java/org/cardanofoundation/rosetta/common/util/ValidationTest.java b/api/src/test/java/org/cardanofoundation/rosetta/common/util/ValidationTest.java deleted file mode 100644 index 1d606a821..000000000 --- a/api/src/test/java/org/cardanofoundation/rosetta/common/util/ValidationTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.cardanofoundation.rosetta.common.util; - -import java.util.HashMap; -import java.util.Map; - -import org.junit.jupiter.api.Test; - -import static org.cardanofoundation.rosetta.common.util.ValidationUtil.isVoteDataValid; -import static org.cardanofoundation.rosetta.common.util.ValidationUtil.isVoteSignatureValid; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class ValidationTest { - - @Test - void test_isVoteDataValid_true_when_vote_data_metadata_format_is_valid() { - Map validDataLabel = new HashMap<>(); - validDataLabel.put("1", "0x8bcec4282239b2cc1a7d8bb294c154c849fc200c7ebd27ef45e610d849bc302a"); - validDataLabel.put("2", "0x56f29f391a3bb5ff90637b2d2d0a32590214871284b0577e4671b0c1a83f79ba"); - validDataLabel.put("3", - "0x01663f13971437b6e2f09771c06534c4ffd95950ac94390f34e091b5ba8cc49dce93335c74cb3aaf8e0f7eacb8813ae4a107383ee7649985e6"); - validDataLabel.put("4", 26912766); - - assertTrue(isVoteDataValid(validDataLabel)); - } - - @Test - void test_isVoteDataValid_false_when_jsonObject_is_null() { - - assertFalse(isVoteDataValid(null)); - } - - @Test - void test_isVoteDataValid_true_when_vote_data_metadata_format_is_valid_besides_hex_string_does_not_tart_with_0x() { - Map validDataLabel = new HashMap<>(); - validDataLabel.put("1", "8bcec4282239b2cc1a7d8bb294c154c849fc200c7ebd27ef45e610d849bc302a"); - validDataLabel.put("2", "56f29f391a3bb5ff90637b2d2d0a32590214871284b0577e4671b0c1a83f79ba"); - validDataLabel.put("3", - "01663f13971437b6e2f09771c06534c4ffd95950ac94390f34e091b5ba8cc49dce93335c74cb3aaf8e0f7eacb8813ae4a107383ee7649985e6"); - validDataLabel.put("4", 26912766); - - assertTrue(isVoteDataValid(validDataLabel)); - } - - @Test - void test_isVoteDataValid_falsy_when_there_are_missing_fields() { - Map missingFieldsDataLabel = new HashMap<>(); - missingFieldsDataLabel.put("1", - "0x8bcec4282239b2cc1a7d8bb294c154c849fc200c7ebd27ef45e610d849bc302a"); - missingFieldsDataLabel.put("2", - "0x56f29f391a3bb5ff90637b2d2d0a32590214871284b0577e4671b0c1a83f79ba"); - missingFieldsDataLabel.put("4", 26912766); - - assertFalse(isVoteDataValid(missingFieldsDataLabel)); - } - - @Test - void test_isVoteDataValid_false_when_expected_hex_string_has_invalid_format() { - Map invalidHexStringDataLabel = new HashMap<>(); - invalidHexStringDataLabel.put("1", - "0x8bcec4282239b2cc1a7d8bb294c154c849fc200c7ebd27ef45e610d849bc302a"); - invalidHexStringDataLabel.put("2", "thisIsNotAHexString"); - invalidHexStringDataLabel.put("3", - "0x01663f13971437b6e2f09771c06534c4ffd95950ac94390f34e091b5ba8cc49dce93335c74cb3aaf8e0f7eacb8813ae4a107383ee7649985e6"); - invalidHexStringDataLabel.put("4", 26912766); - - assertFalse(isVoteDataValid(invalidHexStringDataLabel)); - } - - @Test - void test_isVoteDataValid_false_when_expected_number_has_invalid_format() { - Map invalidNumberDataLabel = new HashMap<>(); - invalidNumberDataLabel.put("1", - "0x8bcec4282239b2cc1a7d8bb294c154c849fc200c7ebd27ef45e610d849bc302a"); - invalidNumberDataLabel.put("2", "thisIsNotAHexString"); - invalidNumberDataLabel.put("3", - "0x01663f13971437b6e2f09771c06534c4ffd95950ac94390f34e091b5ba8cc49dce93335c74cb3aaf8e0f7eacb8813ae4a107383ee7649985e6"); - invalidNumberDataLabel.put("4", "NaN"); - - assertFalse(isVoteDataValid(invalidNumberDataLabel)); - } - - @Test - void test_isVoteSignatureValid_true_when_vote_signature_metadata_format_is_valid() { - Map validSignatureLabel = new HashMap<>(); - validSignatureLabel.put("1", - "0xf75f7a54a79352f9d0e2c4de4e8ded8ae9304fa0f3b021754f8d149c90c7b01e1c6bbfdd623c294d82f5e5cbbfc0bd6fd1c674780db4025446e2eafc87f61b0a"); - - assertTrue(isVoteSignatureValid(validSignatureLabel)); - } - - @Test - void test_isVoteSignatureValid_true_when_vote_signature_label_format_is_valid_besides_hex_string_does_not_start_with_0x() { - Map validSignatureLabel = new HashMap<>(); - validSignatureLabel.put("1", - "f75f7a54a79352f9d0e2c4de4e8ded8ae9304fa0f3b021754f8d149c90c7b01e1c6bbfdd623c294d82f5e5cbbfc0bd6fd1c674780db4025446e2eafc87f61b0a"); - - assertTrue(isVoteSignatureValid(validSignatureLabel)); - } - - @Test - void test_isVoteSignatureValid_falsy_when_there_is_a_missing_field() { - Map invalidSignatureLabel = new HashMap<>(); - invalidSignatureLabel.put("2", - "0xf75f7a54a79352f9d0e2c4de4e8ded8ae9304fa0f3b021754f8d149c90c7b01e1c6bbfdd623c294d82f5e5cbbfc0bd6fd1c674780db4025446e2eafc87f61b0a"); - - assertFalse(isVoteSignatureValid(invalidSignatureLabel)); - } - - @Test - void test_isVoteSignatureValid_false_when_expected_hex_string_has_invalid_format() { - Map invalidSignatureLabel = new HashMap<>(); - invalidSignatureLabel.put("1", "thisIsNotAHexString"); - - assertFalse(isVoteSignatureValid(invalidSignatureLabel)); - } -} diff --git a/api/src/test/resources/config/application-test-integration.yaml b/api/src/test/resources/config/application-test-integration.yaml index d1ebb73fa..022b0ad82 100644 --- a/api/src/test/resources/config/application-test-integration.yaml +++ b/api/src/test/resources/config/application-test-integration.yaml @@ -54,8 +54,9 @@ cardano: SEARCH_LIMIT: ${SEARCH_LIMIT:100} 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: http://localhost:9095/api/v1 diff --git a/config/monitoring/dashboards/rosetta-dashboards.json b/config/monitoring/dashboards/rosetta-dashboards.json index 4646b972e..77a728a61 100644 --- a/config/monitoring/dashboards/rosetta-dashboards.json +++ b/config/monitoring/dashboards/rosetta-dashboards.json @@ -3387,7 +3387,7 @@ "preload": false, "refresh": "5s", "schemaVersion": 41, - "tags": [], + "tags": ["node", "postgres", "indexer", "api"], "templating": { "list": [] }, diff --git a/config/monitoring/dashboards/server-health-dashboard.json b/config/monitoring/dashboards/server-health-dashboard.json new file mode 100644 index 000000000..4f2595731 --- /dev/null +++ b/config/monitoring/dashboards/server-health-dashboard.json @@ -0,0 +1,278 @@ +{ + "id": null, + "title": "Server Health Monitoring", + "description": "Comprehensive server health metrics including CPU, Memory, Disk I/O, Network, and Load", + "tags": ["server", "health", "system"], + "style": "dark", + "timezone": "", + "refresh": "30s", + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "templating": { + "list": [] + }, + "panels": [ + { + "id": 1, + "title": "CPU Usage", + "type": "stat", + "targets": [ + { + "expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", + "legendFormat": "CPU Usage %", + "refId": "A" + } + ], + "gridPos": {"h": 8, "w": 6, "x": 0, "y": 0}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": { + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 70}, + {"color": "red", "value": 90} + ] + }, + "unit": "percent" + } + } + }, + { + "id": 2, + "title": "Memory Usage", + "type": "stat", + "targets": [ + { + "expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100", + "legendFormat": "Memory Usage %", + "refId": "A" + } + ], + "gridPos": {"h": 8, "w": 6, "x": 6, "y": 0}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": { + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 80}, + {"color": "red", "value": 95} + ] + }, + "unit": "percent" + } + } + }, + { + "id": 3, + "title": "Disk Usage", + "type": "stat", + "targets": [ + { + "expr": "100 - ((node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"}) * 100)", + "legendFormat": "Disk Usage %", + "refId": "A" + } + ], + "gridPos": {"h": 8, "w": 6, "x": 12, "y": 0}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": { + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 80}, + {"color": "red", "value": 95} + ] + }, + "unit": "percent" + } + } + }, + { + "id": 4, + "title": "System Load (1m)", + "type": "stat", + "targets": [ + { + "expr": "node_load1", + "legendFormat": "Load Average", + "refId": "A" + } + ], + "gridPos": {"h": 8, "w": 6, "x": 18, "y": 0}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": { + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 8}, + {"color": "red", "value": 16} + ] + } + } + } + }, + { + "id": 5, + "title": "CPU Usage Over Time", + "type": "timeseries", + "targets": [ + { + "expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", + "legendFormat": "CPU Usage %", + "refId": "A" + }, + { + "expr": "avg(rate(node_cpu_seconds_total{mode=\"iowait\"}[5m])) * 100", + "legendFormat": "CPU I/O Wait %", + "refId": "B" + } + ], + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 8}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "percent", + "min": 0, + "max": 100 + } + } + }, + { + "id": 6, + "title": "Memory Usage Over Time", + "type": "timeseries", + "targets": [ + { + "expr": "node_memory_MemTotal_bytes", + "legendFormat": "Total Memory", + "refId": "A" + }, + { + "expr": "node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes", + "legendFormat": "Used Memory", + "refId": "B" + }, + { + "expr": "node_memory_MemAvailable_bytes", + "legendFormat": "Available Memory", + "refId": "C" + } + ], + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 8}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "bytes" + } + } + }, + { + "id": 7, + "title": "Disk I/O", + "type": "timeseries", + "targets": [ + { + "expr": "rate(node_disk_read_bytes_total[5m])", + "legendFormat": "Disk Read {{device}}", + "refId": "A" + }, + { + "expr": "rate(node_disk_written_bytes_total[5m])", + "legendFormat": "Disk Write {{device}}", + "refId": "B" + } + ], + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 16}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "Bps" + } + } + }, + { + "id": 8, + "title": "Network I/O", + "type": "timeseries", + "targets": [ + { + "expr": "rate(node_network_receive_bytes_total{device!=\"lo\"}[5m])", + "legendFormat": "Network RX {{device}}", + "refId": "A" + }, + { + "expr": "rate(node_network_transmit_bytes_total{device!=\"lo\"}[5m])", + "legendFormat": "Network TX {{device}}", + "refId": "B" + } + ], + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 16}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "Bps" + } + } + }, + { + "id": 9, + "title": "System Load Average", + "type": "timeseries", + "targets": [ + { + "expr": "node_load1", + "legendFormat": "1 minute", + "refId": "A" + }, + { + "expr": "node_load5", + "legendFormat": "5 minutes", + "refId": "B" + }, + { + "expr": "node_load15", + "legendFormat": "15 minutes", + "refId": "C" + } + ], + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 24}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"} + } + } + }, + { + "id": 10, + "title": "Disk IOPS", + "type": "timeseries", + "targets": [ + { + "expr": "rate(node_disk_reads_completed_total[5m])", + "legendFormat": "Disk Reads/sec {{device}}", + "refId": "A" + }, + { + "expr": "rate(node_disk_writes_completed_total[5m])", + "legendFormat": "Disk Writes/sec {{device}}", + "refId": "B" + } + ], + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 24}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "ops" + } + } + } + ], + "version": 1 +} diff --git a/config/monitoring/prometheus.yml b/config/monitoring/prometheus.yml index b44081c18..5f3768e26 100644 --- a/config/monitoring/prometheus.yml +++ b/config/monitoring/prometheus.yml @@ -8,6 +8,13 @@ scrape_configs: static_configs: - targets: ['prometheus:9090'] + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] + labels: + application: 'system_metrics' + scrape_interval: 10s + - job_name: 'postgres_exporter and cardano_node' static_configs: - targets: ['postgresql-exporter:9187'] diff --git a/docker-compose-api.yaml b/docker-compose-api.yaml index 932c92b74..9399ffa19 100644 --- a/docker-compose-api.yaml +++ b/docker-compose-api.yaml @@ -1,4 +1,3 @@ -version: '3.8' services: api: image: cardanofoundation/cardano-rosetta-java-api:${API_DOCKER_IMAGE_TAG} @@ -49,6 +48,13 @@ services: # DB tuning debugging API_DB_SHOW_SQL: ${API_DB_SHOW_SQL} + # Token Registry configuration + TOKEN_REGISTRY_ENABLED: ${TOKEN_REGISTRY_ENABLED} + TOKEN_REGISTRY_BASE_URL: ${TOKEN_REGISTRY_BASE_URL} + TOKEN_REGISTRY_CACHE_TTL_HOURS: ${TOKEN_REGISTRY_CACHE_TTL_HOURS} + TOKEN_REGISTRY_LOGO_FETCH: ${TOKEN_REGISTRY_LOGO_FETCH} + TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS: ${TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS} + volumes: - ${CARDANO_CONFIG}:/config - ${CARDANO_NODE_DIR}:${CARDANO_NODE_DIR} diff --git a/docker-compose-indexer.yaml b/docker-compose-indexer.yaml index e8210012a..8449113f0 100644 --- a/docker-compose-indexer.yaml +++ b/docker-compose-indexer.yaml @@ -1,4 +1,3 @@ -version: '3.8' services: yaci-indexer: image: cardanofoundation/cardano-rosetta-java-indexer:${INDEXER_DOCKER_IMAGE_TAG} @@ -23,13 +22,15 @@ services: GENESIS_CONWAY_PATH: ${GENESIS_CONWAY_PATH} REMOVE_SPENT_UTXOS: ${REMOVE_SPENT_UTXOS} REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT: ${REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT} + REMOVE_SPENT_UTXOS_BATCH_SIZE: ${REMOVE_SPENT_UTXOS_BATCH_SIZE} BLOCK_TRANSACTION_API_TIMEOUT_SECS: ${BLOCK_TRANSACTION_API_TIMEOUT_SECS} CARDANO_NODE_SOCKET_PATH: ${CARDANO_NODE_SOCKET_PATH} HOST_N2C_SOCAT_HOST: ${HOST_N2C_SOCAT_HOST} HOST_N2C_SOCAT_PORT: ${HOST_N2C_SOCAT_PORT} - SEARCH_LIMIT: ${LIMIT} + SEARCH_LIMIT: ${SEARCH_LIMIT} CONTINUE_PARSING_ON_ERROR: ${CONTINUE_PARSING_ON_ERROR} PEER_DISCOVERY: ${PEER_DISCOVERY} + LOG: ${LOG} volumes: - ${CARDANO_CONFIG}:/config - ${CARDANO_NODE_DIR}:${CARDANO_NODE_DIR} diff --git a/docker-compose-integration-test-indexer.yaml b/docker-compose-integration-test-indexer.yaml index e44d145ab..86e108286 100644 --- a/docker-compose-integration-test-indexer.yaml +++ b/docker-compose-integration-test-indexer.yaml @@ -1,4 +1,3 @@ -version: '3.8' services: yaci-indexer: image: cardanofoundation/cardano-rosetta-java-indexer:${INDEXER_DOCKER_IMAGE_TAG} @@ -23,13 +22,15 @@ services: GENESIS_CONWAY_PATH: ${GENESIS_CONWAY_PATH} REMOVE_SPENT_UTXOS: ${REMOVE_SPENT_UTXOS} REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT: ${REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT} + REMOVE_SPENT_UTXOS_BATCH_SIZE: ${REMOVE_SPENT_UTXOS_BATCH_SIZE} BLOCK_TRANSACTION_API_TIMEOUT_SECS: ${BLOCK_TRANSACTION_API_TIMEOUT_SECS} CARDANO_NODE_SOCKET_PATH: ${CARDANO_NODE_SOCKET_PATH} HOST_N2C_SOCAT_HOST: ${HOST_N2C_SOCAT_HOST} HOST_N2C_SOCAT_PORT: ${HOST_N2C_SOCAT_PORT} - SEARCH_LIMIT: ${LIMIT} + SEARCH_LIMIT: ${SEARCH_LIMIT} CONTINUE_PARSING_ON_ERROR: ${CONTINUE_PARSING_ON_ERROR} PEER_DISCOVERY: ${PEER_DISCOVERY} + LOG: ${LOG} volumes: - ${CARDANO_CONFIG}:/config - ${CARDANO_NODE_DIR}:${CARDANO_NODE_DIR} diff --git a/docker-compose-monitor.yaml b/docker-compose-monitor.yaml index 143b9ac08..257fc8688 100644 --- a/docker-compose-monitor.yaml +++ b/docker-compose-monitor.yaml @@ -1,4 +1,3 @@ -version: "3" services: prometheus: image: prom/prometheus:v3.4.1 @@ -44,7 +43,23 @@ services: condition: service_started db: condition: service_healthy - restart: unless-stopped + restart: unless-stopped + + node-exporter: + image: prom/node-exporter:v1.8.2 + restart: unless-stopped + ports: + - "9101:9100" + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - '--path.procfs=/host/proc' + - '--path.rootfs=/rootfs' + - '--path.sysfs=/host/sys' + - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' + volumes: prometheus-data: grafana-data: diff --git a/docker-compose-node.yaml b/docker-compose-node.yaml index fafe46c3f..7d756bb55 100644 --- a/docker-compose-node.yaml +++ b/docker-compose-node.yaml @@ -1,4 +1,3 @@ -version: '3.8' services: mithril: image: cardanofoundation/cardano-rosetta-java-mithril:${MITHRIL_VERSION} diff --git a/docker-compose-offline.yaml b/docker-compose-offline.yaml new file mode 100644 index 000000000..95cde81ae --- /dev/null +++ b/docker-compose-offline.yaml @@ -0,0 +1,7 @@ +version: '3.8' +include: + - docker-compose-api.yaml + +networks: + default: + name: cardano-rosetta-java-${NETWORK} diff --git a/docker-compose.yaml b/docker-compose.yaml index b821539c8..24302363a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,3 @@ -version: '3.8' include: - docker-compose-indexer.yaml - docker-compose-node.yaml diff --git a/docker-integration-test-environment.yaml b/docker-integration-test-environment.yaml index 403cf7848..1f313df32 100644 --- a/docker-integration-test-environment.yaml +++ b/docker-integration-test-environment.yaml @@ -1,4 +1,3 @@ -version: '3.8' include: - docker-compose-integration-test-indexer.yaml - docker-compose-api.yaml diff --git a/docker/.env.dockerfile b/docker/.env.dockerfile index c950f737c..89ee016b3 100644 --- a/docker/.env.dockerfile +++ b/docker/.env.dockerfile @@ -52,9 +52,10 @@ PRINT_EXCEPTION=true ## Yaci Indexer env YACI_SPRING_PROFILES=postgres,n2c-socket -REMOVE_SPENT_UTXOS=false -REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=2160 +REMOVE_SPENT_UTXOS=true +REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT=129600 BLOCK_TRANSACTION_API_TIMEOUT_SECS=5 +REMOVE_SPENT_UTXOS_BATCH_SIZE=3000 # database profiles: h2, h2-testdata, postgres MEMPOOL_ENABLED=false diff --git a/docs/docs/advanced-configuration/token-metadata.md b/docs/docs/advanced-configuration/token-metadata.md new file mode 100644 index 000000000..2c2da55a6 --- /dev/null +++ b/docs/docs/advanced-configuration/token-metadata.md @@ -0,0 +1,366 @@ +--- +sidebar_position: 4 +title: Enabling Token Metadata +description: Configure token registry integration for native asset metadata +--- + +# Enabling Token Metadata + +## Overview + +Cardano native tokens (also called multi-assets) can have associated metadata that provides human-readable information such as token names, tickers, descriptions, and logos. This metadata helps exchanges display accurate token information to users and improves the overall user experience when working with Cardano native assets. + +Cardano Rosetta Java supports integration with the **Cardano Token Metadata Registry**, which provides a unified API for retrieving token metadata from two complementary standards: + +- **CIP-26**: Off-chain metadata registry for static token information +- **CIP-68**: On-chain metadata standard for dynamic token information + +### Why Exchanges Need This + +Without token metadata integration, native assets in API responses appear only as hex-encoded policy IDs and asset names. With the token registry enabled: + +- **User Experience**: Display readable token names instead of hex strings +- **Accurate Information**: Show correct decimals, tickers, and descriptions for tokens +- **Trust**: Present verified token metadata from the official Cardano registry +- **Compliance**: Provide proper token identification for regulatory requirements + +### What This Integration Provides + +When enabled, the following endpoints will include enriched metadata for native tokens in their `currency` objects: + +- `/block` - Block retrieval with transaction details +- `/block/transaction` - Individual transaction details +- `/account/balance` - Account balance information +- `/account/coins` - UTXO details for accounts +- `/search/transactions` - Transaction search results + +#### Metadata Fields Added + +The integration adds the following optional fields to the `currency.metadata` object: + +| Field | Description | Example | +|-------|-------------|---------| +| `subject` | Base16-encoded combination of policyId + assetName | `"5dac8536...4e4d4b52"` | +| `name` | Human-readable token name | `"MKT coin"` | +| `description` | Token description | `"Utility Token for..."` | +| `ticker` | Token ticker/symbol | `"MKT"` | +| `url` | Project website URL | `"https://example.com"` | +| `decimals` | Number of decimal places | `6` | + +#### Before and After Examples + +**Before** (without token metadata): +```json +{ + "currency": { + "symbol": "567946695f43726564656e7469616c", + "decimals": 0, + "metadata": { + "policyId": "4d07e0ceae00e6c53598cea00a53c54a94c6b6aa071482244cc0adb5" + } + } +} +``` + +**After** (with token metadata enabled): +```json +{ + "currency": { + "symbol": "567946695f43726564656e7469616c", + "decimals": 0, + "metadata": { + "policyId": "4d07e0ceae00e6c53598cea00a53c54a94c6b6aa071482244cc0adb5", + "subject": "4d07e0ceae00e6c53598cea00a53c54a94c6b6aa071482244cc0adb5567946695f43726564656e7469616c", + "name": "MKT coin", + "description": "MKT description of token coin", + "ticker": "MKT", + "url": "https://example.com" + } + } +} +``` + +:::note Optional Metadata +All metadata fields are optional. If a token has not been registered in the Cardano Token Registry, only the basic `policyId` will be present. Missing metadata does not indicate an error - many tokens simply don't have registered metadata. +::: + +## Installation Steps + +### Step 1: Clone the Token Metadata Registry + +Clone the official Cardano Foundation token metadata registry repository: + +```bash +git clone https://github.com/cardano-foundation/cf-token-metadata-registry.git +cd cf-token-metadata-registry +``` + +Check out the latest release tag: + +```bash +# List available tags +git tag -l + +# Checkout the latest stable release (replace with actual latest version) +git checkout tags/v1.x.x # Use the latest version from the tag list +``` + +### Step 2: Configure Ports + +Since Cardano Rosetta Java uses PostgreSQL on port 5432, configure the token registry to use different ports to avoid conflicts. + +Edit the `.env` file: + +```bash +# Open the .env file +nano .env # or use your preferred editor +``` + +Modify the port configuration: + +```bash +# Database Port - Change from default 5432 to avoid conflict +# This line: +# DB_PORT='5432' + +# To: +DB_PORT='5434' + +# Ensure DB_URL uses the internal port (5432 inside the container): +DB_URL=jdbc:postgresql://db:5432/cf_token_metadata_registry + +# API Port - Change if port 8080 is already in use +# Default: +# API_LOCAL_BIND_PORT='8080' + +# If you need a different port (e.g., if 8080 is in use): +API_LOCAL_BIND_PORT='8088' +``` + +### Step 3: Start the Token Registry + +Start the token metadata registry service using Docker Compose: + +```bash +docker compose up -d +``` + +This will: +- Start a PostgreSQL database container for token metadata +- Start the token registry API container +- Begin syncing token metadata from the Cardano blockchain. +- Synchronize the official token registry from GitHub + +:::tip Initial Sync Time +The first sync can take 10-12 hours as the service indexes on-chain metadata and downloads the GitHub registry. Monitor progress with `docker compose logs -f api`. +::: + +### Step 4: Verify Registry Health + +Wait for the token registry to complete its initial sync and become healthy: + +```bash +# Check service health (use your configured API_LOCAL_BIND_PORT) +curl http://localhost:8080/health +``` + +Expected response when ready: +```json +{"synced":true,"syncStatus":"Sync done"} +``` + +You can also check the logs to monitor sync progress: + +```bash +# View logs +docker compose logs -f api + +# The service is ready when you see: +# "Started TokenMetadataRegistryApplication" +``` + +### Step 5: Get the Gateway IP Address + +To allow Cardano Rosetta Java to communicate with the token registry, you need the Docker gateway IP address. + +Run this command to get the gateway IP for the token registry API container: + +```bash +docker inspect cf-token-metadata-registry-api-1 --format '{{range .NetworkSettings.Networks}}{{.Gateway}} {{end}}' +``` + +This will output something like: +``` +172.20.0.1 +``` + +### Step 6: Configure Rosetta API + +Navigate to your Cardano Rosetta Java directory and edit the `.env.docker-compose` file: + +```bash +cd /path/to/cardano-rosetta-java +nano .env.docker-compose +``` + +Add or update the following environment variables: + +```bash +# Enable token registry integration +TOKEN_REGISTRY_ENABLED=true + +# Set the base URL using the gateway IP from Step 5 +# Replace with the actual IP from the previous step +TOKEN_REGISTRY_BASE_URL=http://:8080/api + +# Optional: Configure cache TTL (in hours) +TOKEN_REGISTRY_CACHE_TTL_HOURS=12 + +# Optional: Disable logo fetching to reduce response size +TOKEN_REGISTRY_LOGO_FETCH=false + +# Optional: Set request timeout (in seconds) +TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS=2 +``` + +Example configuration: +```bash +TOKEN_REGISTRY_ENABLED=true +TOKEN_REGISTRY_BASE_URL=http://172.20.0.1:8080/api +TOKEN_REGISTRY_CACHE_TTL_HOURS=12 +TOKEN_REGISTRY_LOGO_FETCH=false +TOKEN_REGISTRY_REQUEST_TIMEOUT_SECONDS=2 +``` + +### Step 7: Restart Rosetta API + +Apply the configuration changes by restarting the Rosetta API service: + +```bash +# Stop the API +docker compose --env-file .env.docker-compose \ + --env-file .env.docker-compose-profile-mid-level \ + -f docker-compose.yaml \ + down api + +# Start the API with new configuration +docker compose --env-file .env.docker-compose \ + --env-file .env.docker-compose-profile-mid-level \ + -f docker-compose.yaml \ + up -d api +``` + +:::tip Hardware Profiles +The command above uses the `mid-level` profile. Adjust the profile file (`entry-level`, `mid-level`, or `advanced-level`) based on your deployment configuration. See [Hardware Profiles](../install-and-deploy/hardware-profiles) for details. +::: + +### Step 8: Verify Integration + +Test that the token metadata integration is working correctly: + +```bash +# Check API startup logs +docker logs cardano-rosetta-java-api-1 | grep TokenRegistry +``` + +You should see a line like: +``` +TokenRegistryHttpGatewayImpl initialized with enabled: true, batchEndpointUrl: http://172.20.0.1:8080/api/v2/subjects/query +``` + +To verify metadata is being returned in API responses, query a block containing native tokens: + +```bash +curl -X POST http://localhost:8082/block \ + -H "Content-Type: application/json" \ + -d '{ + "network_identifier": { + "blockchain": "cardano", + "network": "mainnet" + }, + "block_identifier": { + "index": 10000259 + } + }' | jq '.block.transactions[].operations[] | select(.metadata.tokenBundle) | .metadata.tokenBundle[].tokens[0].currency.metadata' +``` + +You should see metadata fields populated with token information for the tokens: +```json +{ + "policyId": "5dac8536653edc12f6f5e1045d8164b9f59998d3bdc300fc92843489", + "subject": "5dac8536653edc12f6f5e1045d8164b9f59998d3bdc300fc928434894e4d4b52", + "name": "NMKR", + "description": "Utility Token for Tokenization & NFT Infrastructure by NMKR", + "ticker": "NMKR", + "url": "https://nmkr.io" +} +``` + +:::tip Testing Multiple Endpoints +You can test token metadata on other endpoints like `/account/balance`, `/account/coins`, and `/search/transactions`. All endpoints that return `currency` objects will include the enriched metadata. +::: + +## Troubleshooting + +### Rosetta API Not Fetching Metadata + +**Symptom**: API responses don't include token metadata + +**Solutions**: + +1. **Verify registry is healthy**: + ```bash + curl http://localhost:8080/health + ``` + +2. **Check API logs for connection errors**: + ```bash + docker logs cardano-rosetta-java-api-1 | grep -i "token registry" + ``` + +3. **Verify gateway IP is correct**: + ```bash + # Re-check the gateway IP + docker inspect cf-token-metadata-registry-api-1 \ + --format '{{range .NetworkSettings.Networks}}{{.Gateway}} {{end}}' + + # Ensure it matches TOKEN_REGISTRY_BASE_URL in .env.docker-compose + ``` + +4. **Test registry connectivity from API container**: + ```bash + docker exec cardano-rosetta-java-api-1 \ + curl -s http://:8080/health + ``` + +### Slow API Response Times + +**Symptom**: API requests are slower after enabling token metadata + +**Solutions**: + +1. **Verify Docker connectivity** between Rosetta API and token registry: + ```bash + # Test connectivity from API container to registry + docker exec cardano-rosetta-java-api-1 \ + curl -s -w "\nTime: %{time_total}s\n" \ + http://:8080/health + + # Should respond quickly (< 1 second) + ``` + +2. **Increase cache TTL** to reduce registry API calls: + ```bash + TOKEN_REGISTRY_CACHE_TTL_HOURS=24 + ``` + +3. **Disable logo fetching** if not needed: + ```bash + TOKEN_REGISTRY_LOGO_FETCH=false + ``` + +## Further Reading + +- [Cardano Token Registry CIP-26](https://developers.cardano.org/docs/native-tokens/token-registry/cardano-token-registry-cip26) +- [Cardano Token Registry CIP-68](https://developers.cardano.org/docs/native-tokens/token-registry/cardano-token-registry-cip68) +- [Token Registry Server Documentation](https://developers.cardano.org/docs/native-tokens/token-registry/cardano-token-registry-server) \ No newline at end of file diff --git a/docs/docs/install-and-deploy/env-vars.md b/docs/docs/install-and-deploy/env-vars.md index 66346c54c..db5db25b6 100644 --- a/docs/docs/install-and-deploy/env-vars.md +++ b/docs/docs/install-and-deploy/env-vars.md @@ -86,6 +86,10 @@ Within root folder of the project there are example `.env` files, which can be c | `CONTINUE_PARSING_ON_ERROR` | Continue processing failed to parse blocks | true | added in release 1.3.0 | | `SEARCH_LIMIT` | Search limit used in search | 100 | added in release 1.3.2 | | `PEER_DISCOVERY` | Enable peer discovery job for automatic peer refreshing | false | added in release 1.3.2 | +| `TOKEN_REGISTRY_ENABLED` | Enable token registry integration for native token metadata | true | added in release 1.4.0 | +| `TOKEN_REGISTRY_BASE_URL` | Base URL for the token registry API | https://tokens.cardano.org/api | added in release 1.4.0 | +| `TOKEN_REGISTRY_CACHE_TTL_HOURS` | Cache TTL for token metadata in hours | 1 | added in release 1.4.0 | +| `TOKEN_REGISTRY_LOGO_FETCH` | Enable fetching token logos from registry (increases response size) | false | added in release 1.4.0 | ## Deprecated Environment Variables (Previous Versions) diff --git a/pom.xml b/pom.xml index ff4f09589..a90c58665 100644 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,7 @@ full - 1.3.3 + 1.4.0 24 UTF-8 3.5.0 diff --git a/tests/data-endpoints/.gitignore b/tests/data-endpoints/.gitignore index e4f2d89ad..15868a359 100644 --- a/tests/data-endpoints/.gitignore +++ b/tests/data-endpoints/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .pytest_cache/ .venv/ *allure* +.env diff --git a/tests/data-endpoints/README.md b/tests/data-endpoints/README.md index a1c4d7d61..e743b0189 100644 --- a/tests/data-endpoints/README.md +++ b/tests/data-endpoints/README.md @@ -48,37 +48,118 @@ allure serve allure-results ## Environment Variables +### Test Execution Variables - `ROSETTA_URL`: Base URL for Rosetta API (default: `http://localhost:8082`) - `CARDANO_NETWORK`: Network to test against (default: `preprod`, options: `mainnet`, `preview`) +### Configuration Variables (Read from .env file) +Tests read the actual service configuration from `.env` file in this directory: +- `REMOVE_SPENT_UTXOS`: Whether pruning is enabled (`true`/`false`) +- `REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT`: Grace window in blocks (default: `2160`) +- `TOKEN_REGISTRY_ENABLED`: Whether token metadata is enabled (`true`/`false`) + +**How it works**: +- Workflows create `.env` by merging `.env.docker-compose-preprod` + `.env.docker-compose-profile-mid-level` +- Tests auto-load `.env` via `conftest.py` at import time +- Configuration detection is based on actual env vars, not API inference + ```bash # Run against mainnet (requires mainnet test data in network_test_data.yaml) CARDANO_NETWORK=mainnet uv run pytest -m "not smoke" # Run against different port ROSETTA_URL=http://localhost:8083 uv run pytest + +# Test with specific configuration (create .env file) +echo "REMOVE_SPENT_UTXOS=true" > .env +echo "TOKEN_REGISTRY_ENABLED=true" >> .env +uv run pytest -m nightly ``` ## Test Organization -**125 total tests** organized across 6 files: +**130+ total tests** organized across 8 files: -### Behavioral Tests (113 tests, 19 properly skipped) +### Core Behavioral Tests (113 tests, 19 properly skipped) - `test_network_endpoints.py` - /network/* endpoints (3 tests) - `test_search_transactions.py` - /search/transactions (72 tests including parametrized) - `test_block_endpoints.py` - /block and /block/transaction (18 tests) - `test_account_endpoints.py` - /account/balance and /account/coins (13 tests) - `test_error_handling.py` - Cross-cutting error tests for all 7 endpoints (14 tests) +### v1.4.0 Feature Tests +- `test_pruning_behavior.py` - Validates UTXO-based pruning behavior (3 tests) +- `test_token_registry.py` - Token metadata enrichment tests (4 tests) + ### Smoke Tests (12 tests) - `test_smoke_network_data.py` - Validates network_test_data.yaml entries across endpoints ### Test Data - `network_test_data.yaml` - Network-specific addresses and assets (extensible for mainnet) +## Pruning Compatibility + +Tests automatically adapt to pruned instances (where `REMOVE_SPENT_UTXOS=true`): + +- **Auto-detection**: Tests detect pruning via `oldest_block_identifier` in `/network/status` +- **Relaxed validation**: Schema validation makes `address` field optional in `account` objects for pruned responses +- **Conditional tests**: Tests marked `@pytest.mark.requires_full_history` are intended for full-history environments (PR runs); skip them manually with `-m "not requires_full_history"` when testing against a pruned instance + +### Understanding Pruning + +When pruning is enabled, **spent UTXOs** are removed to save disk space: + +- **Inputs**: Always spent (by definition), so always show `account: {}` and `amount: "0"` in old blocks +- **Outputs**: Only pruned if spent later; unspent outputs preserve full data even in old blocks +- **Boundary**: `oldest_block_identifier` marks where ALL data is complete +- **Current state**: `/account/balance` and `/account/coins` work normally (unspent UTXOs preserved) + +For detailed pruning behavior, see `test_pruning_behavior.py`. + +## Test Markers + +Tests are organized with markers for different execution contexts: + +### Execution Tier Markers +```bash +# PR workflow - fast essential tests (minutes) +pytest -m pr + +# Nightly workflow - comprehensive validation (hours) +pytest -m nightly + +# Weekly workflow - sync validation (many hours) +pytest -m weekly +``` + +### Configuration Requirement Markers +```bash +# Tests requiring full historical data (skip on pruned instances) +pytest -m requires_full_history + +# Tests compatible with both pruned and non-pruned instances +pytest -m pruning_compatible + +# Tests requiring token registry enabled +pytest -m requires_token_registry + +# Tests requiring peer discovery enabled +pytest -m requires_peer_discovery +``` + +### Special Markers +```bash +# Smoke tests that validate test data +pytest -m smoke + +# Slow tests (>5 seconds) +pytest -m slow +``` + ## Features - **Schema Validation**: Automatic validation against OpenAPI spec using Draft4Validator +- **Pruning Detection**: Auto-adapts to pruned instances with relaxed validation - **Clear Test Names**: Each test function clearly describes what it tests - **Parameterization**: Uses pytest parametrize for test variations - **Allure Integration**: Beautiful HTML reports with test history @@ -89,4 +170,4 @@ ROSETTA_URL=http://localhost:8083 uv run pytest ```bash # Run a quick sanity check uv run pytest test_search_transactions.py::TestSanityChecks::test_default_pagination_returns_100_transactions -v -``` \ No newline at end of file +``` diff --git a/tests/data-endpoints/client.py b/tests/data-endpoints/client.py index 2db40247e..a8d1c3c56 100644 --- a/tests/data-endpoints/client.py +++ b/tests/data-endpoints/client.py @@ -2,6 +2,7 @@ Rosetta API client with schema validation. """ +import os import httpx import yaml from pathlib import Path @@ -12,7 +13,15 @@ class SchemaValidator: """Validates API responses against OpenAPI schemas.""" - def __init__(self, openapi_path: Optional[Path] = None): + def __init__(self, openapi_path: Optional[Path] = None, relaxed_mode: bool = False): + """ + Initialize schema validator. + + Args: + openapi_path: Path to OpenAPI spec file + relaxed_mode: If True, relaxes validation for pruned instances + (makes address field optional in account objects) + """ if openapi_path is None: openapi_path = ( Path(__file__).parent.parent.parent @@ -29,13 +38,51 @@ def __init__(self, openapi_path: Optional[Path] = None): raise ValueError("Invalid OpenAPI spec") self.schemas = self.spec["components"]["schemas"] + self.relaxed_mode = relaxed_mode self.validators = {} # Load ALL schemas with resolved references for schema_name in self.schemas: resolved = self._resolve_refs(self.schemas[schema_name]) + # Apply pruning-specific schema relaxations + if self.relaxed_mode: + resolved = self._apply_pruning_relaxations(resolved, schema_name) self.validators[schema_name] = Draft4Validator(resolved) + def _apply_pruning_relaxations(self, schema: dict, schema_name: str) -> dict: + """ + Apply schema relaxations for pruned instances. + + When pruning is enabled, historical transactions may have empty account objects. + This method makes the 'address' field optional in AccountIdentifier objects. + """ + if schema_name in ["SearchTransactionsResponse", "BlockResponse", "BlockTransactionResponse"]: + # These responses contain operations with account identifiers + # We need to make address field optional in nested account objects + schema = self._make_account_address_optional(schema) + return schema + + def _make_account_address_optional(self, obj: Any) -> Any: + """ + Recursively traverse schema and make address field optional in AccountIdentifier. + + This handles the case where pruned instances return empty account objects. + """ + if isinstance(obj, dict): + # Check if this is an AccountIdentifier schema + if obj.get("type") == "object" and "properties" in obj: + props = obj.get("properties", {}) + # If this object has an address property that's required + if "address" in props and "required" in obj: + # Remove 'address' from required fields + obj["required"] = [r for r in obj["required"] if r != "address"] + + # Recursively process all nested objects + return {k: self._make_account_address_optional(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [self._make_account_address_optional(item) for item in obj] + return obj + def _resolve_refs(self, obj, depth=0): """Recursively resolve all $ref references.""" @@ -68,12 +115,32 @@ class RosettaClient: """Rosetta API client with schema validation.""" def __init__( - self, base_url: str = "http://localhost:8082", validate_schemas: bool = True + self, base_url: str = "http://localhost:8082", + validate_schemas: bool = True, + relaxed_validation: bool = None ): + """ + Initialize Rosetta client. + + Args: + base_url: Base URL for Rosetta API + validate_schemas: Whether to validate responses against OpenAPI schemas + relaxed_validation: Enable relaxed schema validation (makes account address optional). + If None (default) and schema validation stays enabled, the setting is + derived from REMOVE_SPENT_UTXOS. Ignored when validate_schemas=False. + """ self.base_url = base_url self.client = httpx.Client(timeout=httpx.Timeout(600.0)) self.validate_schemas = validate_schemas - self.validator = SchemaValidator() if validate_schemas else None + + # Read pruning config from environment instead of API detection + if relaxed_validation is None and validate_schemas: + pruning_enabled = os.environ.get("REMOVE_SPENT_UTXOS", "false").lower() == "true" + self.relaxed_validation = pruning_enabled + else: + self.relaxed_validation = relaxed_validation + + self.validator = SchemaValidator(relaxed_mode=self.relaxed_validation) if validate_schemas else None def _post( self, path: str, body: Dict[str, Any], schema_name: Optional[str] = None diff --git a/tests/data-endpoints/conftest.py b/tests/data-endpoints/conftest.py index 4a6bd942c..a47bfad33 100644 --- a/tests/data-endpoints/conftest.py +++ b/tests/data-endpoints/conftest.py @@ -4,9 +4,15 @@ import os import pytest +from pathlib import Path +from dotenv import load_dotenv from client import RosettaClient +# Load .env file at module import time; explicit .env values override the parent env +load_dotenv(Path(__file__).parent / ".env", override=True) + + @pytest.fixture(scope="session") def rosetta_url(): """Base URL for Rosetta API.""" @@ -27,24 +33,83 @@ def client(rosetta_url): @pytest.fixture(scope="module") -def blockchain_height(rosetta_url, network): +def blockchain_height(network_status): """ Get current blockchain height once per test module. Cached to avoid repeated network_status calls. Fails loudly if blockchain is too young for integration testing. """ + height = network_status["current_block_identifier"]["index"] + + if height < 100: + raise AssertionError( + f"Blockchain too young ({height} blocks). " + f"Need at least 100 blocks for integration testing." + ) + + return height + + +@pytest.fixture(scope="session") +def network_status(rosetta_url, network): + """ + Get network status once per test session. + + Cached to avoid repeated calls and used for configuration detection. + """ with RosettaClient(base_url=rosetta_url) as client: - status = client.network_status(network=network).json() - height = status["current_block_identifier"]["index"] + return client.network_status(network=network).json() - if height < 100: - raise AssertionError( - f"Blockchain too young ({height} blocks). " - f"Need at least 100 blocks for integration testing." - ) - return height +@pytest.fixture(scope="session") +def pruning_enabled(): + """Read REMOVE_SPENT_UTXOS from environment.""" + return os.environ.get("REMOVE_SPENT_UTXOS", "false").lower() == "true" + + +@pytest.fixture(scope="session") +def grace_window(): + """Read pruning grace window from environment.""" + return int(os.environ.get("REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT", "2160")) + + +@pytest.fixture(scope="session") +def is_pruned_instance(pruning_enabled): + """ + Check if running against a pruned instance. + + Reads from environment configuration instead of API detection. + """ + return pruning_enabled + + +@pytest.fixture(scope="session") +def oldest_block_identifier(network_status, is_pruned_instance): + """ + Get oldest fully queryable block if pruning is enabled. + + Returns None for non-pruned instances. + Below this block index, blocks might have missing data due to pruning. + + NOTE: This reads from API response, not configuration. + Use this to validate the API behavior, not to detect pruning. + """ + if is_pruned_instance: + return network_status.get("oldest_block_identifier", {}).get("index") + return None + + +@pytest.fixture(scope="session") +def has_token_registry(): + """Read TOKEN_REGISTRY_ENABLED from environment.""" + return os.environ.get("TOKEN_REGISTRY_ENABLED", "false").lower() == "true" + + +@pytest.fixture(scope="session") +def has_peer_discovery(): + """Read PEER_DISCOVERY from environment.""" + return os.environ.get("PEER_DISCOVERY", "false").lower() == "true" @pytest.fixture(scope="session") @@ -94,3 +159,4 @@ def get_error_message(error_response): message = error_response.get("message", "") details_message = error_response.get("details", {}).get("message", "") return (message + " " + details_message).strip() + diff --git a/tests/data-endpoints/network_test_data.yaml b/tests/data-endpoints/network_test_data.yaml index 894e3c6df..1d59ca2ad 100644 --- a/tests/data-endpoints/network_test_data.yaml +++ b/tests/data-endpoints/network_test_data.yaml @@ -12,6 +12,9 @@ preprod: # Shelley enterprise address (payment only, no stake) - 47,336 transactions shelley_enterprise: "addr_test1vqnku6rsllyln4fa5s4tlv5ujx0y6kvu4mzzfh5jaht8nfq8584jf" + # Whale address with lots of historical activity (for pruning comparison tests) + whale: "addr_test1vqnku6rsllyln4fa5s4tlv5ujx0y6kvu4mzzfh5jaht8nfq8584jf" # Same as shelley_enterprise + # Byron-era address (legacy format) - 39 transactions byron: "37btjrVyb4KEB2STADSsj3MYSAdj52X5FrFWpw2r7Wmj2GDzXjFRsHWuZqrw7zSkwopv8Ci3VWeg6bisU9dgJxW5hb2MZYeduNKbQJrqz3zVBsu9nT" @@ -30,6 +33,38 @@ preprod: decimals: 6 policy_id: "e68f1cea19752d1292b4be71b7f5d2b3219a15859c028f7454f66cdf" + # Tokens registered in metadata server (for v1.4.0 token registry tests) + # These tokens have enriched metadata (name, ticker, description) when registry enabled + # Verified in metadata server DB as of 2025-10-22 + tokens_in_registry: + - name: "Genius Yield Test Token" + ticker: "tGENS" + symbol_hex: "7447454e53" # Symbol in hexadecimal + symbol_ascii: "tGENS" + decimals: 6 + policy_id: "c6e65ba7878b2f8ea0ad39287d3e2fd256dc5c4160fc19bdf4c4d87e" + subject: "c6e65ba7878b2f8ea0ad39287d3e2fd256dc5c4160fc19bdf4c4d87e7447454e53" + description: "Test token for Genius Yield" + url: "https://preprod.genius-x.co" + logo_format: "base64" + logo_value_prefix: "89504e47" + # Known location where this token exists (for historical queries) + test_address: "addr_test1qqzdcr8caujvm4kjdv3mh90xvc3gh8k3d0et4cnja47cy74aavghhj8e4rryf0xyth5yj0yu7lcxulk6rqhwfvel7p0qa8czcz" + test_block: 486800 # Block where test_address held this token + - name: "tUSDM" + ticker: "tUSDM" + symbol_hex: "0014df10745553444d" + symbol_ascii: "" + decimals: 6 + policy_id: "16a55b2a349361ff88c03788f93e1e966e5d689605d044fef722ddde" + subject: "16a55b2a349361ff88c03788f93e1e966e5d689605d044fef722ddde0014df10745553444d" + description: "Fiat-backed stablecoin native to the Cardano blockchain" + url: "https://moneta.global/" + logo_format: "url" + logo_value_prefix: "ipfs://" + test_address: "addr_test1qpq4x7rrrh3pxx0w8ng3xxjkshd070zg6hmux638ej57xtakfrrt9cwl632xwcrg8mumxfz5wvphzuxnrluexy2g4z5qxz5t2c" + test_block: 4042548 # Block containing CIP-68 logo (URL) metadata + # mainnet: # addresses: # shelley_base: "addr1qy..." diff --git a/tests/data-endpoints/pyproject.toml b/tests/data-endpoints/pyproject.toml index 0c6a39bfb..442241c18 100644 --- a/tests/data-endpoints/pyproject.toml +++ b/tests/data-endpoints/pyproject.toml @@ -10,5 +10,7 @@ dependencies = [ "jsonschema>=4.25.1", "pytest>=8.4.2", "pytest-xdist>=3.8.0", + "python-dotenv>=1.1.1", "pyyaml>=6.0.2", + "requests>=2.32.5", ] diff --git a/tests/data-endpoints/pytest.ini b/tests/data-endpoints/pytest.ini index 06d00df0b..3b3f27772 100644 --- a/tests/data-endpoints/pytest.ini +++ b/tests/data-endpoints/pytest.ini @@ -5,7 +5,7 @@ python_classes = Test* python_functions = test_* console_output_style = times addopts = - -n auto + -n 0 -r a --strict-markers --disable-warnings @@ -13,4 +13,12 @@ addopts = --color=yes markers = slow: Tests that take longer than 5 seconds - smoke: Smoke tests that validate network test data (non-blocking) \ No newline at end of file + smoke: Smoke tests that validate network test data (non-blocking) + requires_full_history: Tests that require complete historical data (skip when pruning is enabled) + pruning_compatible: Tests that work correctly with pruned data + requires_token_registry: Tests that require token registry to be enabled + requires_logo_fetch: Tests that require TOKEN_REGISTRY_LOGO_FETCH to be enabled + requires_peer_discovery: Tests that require peer discovery to be enabled + pr: Tests to run in PR workflow (fast, essential validation) + nightly: Tests to run in nightly workflow (comprehensive) + weekly: Tests to run in weekly workflow (sync validation) diff --git a/tests/data-endpoints/test_account_endpoints.py b/tests/data-endpoints/test_account_endpoints.py index 1f9c481bb..8da5e582a 100644 --- a/tests/data-endpoints/test_account_endpoints.py +++ b/tests/data-endpoints/test_account_endpoints.py @@ -8,12 +8,15 @@ import allure from conftest import get_error_message +pytestmark = pytest.mark.pr + @allure.feature("Account") @allure.story("Account Balance") class TestAccountBalance: """Test /account/balance endpoint.""" + @pytest.mark.pr def test_get_current_balance(self, client, network, network_data): """Get current account balance.""" address = network_data["addresses"]["shelley_base"] @@ -215,6 +218,7 @@ def test_get_unspent_utxos(self, client, network, network_data): assert all(c in "0123456789abcdef" for c in tx_hash.lower()) assert output_index.isdigit(), "Output index must be numeric" + @pytest.mark.requires_full_history def test_utxos_match_search_transactions_outputs( self, client, network, network_data ): @@ -261,6 +265,7 @@ def test_coins_response_structure(self, client, network, network_data): assert "coins" in data assert isinstance(data["coins"], list) + @pytest.mark.requires_full_history def test_all_coins_are_unspent(self, client, network, network_data): """All returned coins should be unspent (not consumed in later transactions).""" address = network_data["addresses"]["shelley_base"] diff --git a/tests/data-endpoints/test_block_endpoints.py b/tests/data-endpoints/test_block_endpoints.py index 235aef911..a97272952 100644 --- a/tests/data-endpoints/test_block_endpoints.py +++ b/tests/data-endpoints/test_block_endpoints.py @@ -4,15 +4,19 @@ Tests both /block and /block/transaction endpoints with behavioral assertions. """ +import pytest import allure from conftest import get_error_message +pytestmark = pytest.mark.pr + @allure.feature("Block") @allure.story("Block Lookup") class TestBlockLookup: """Test /block endpoint with different lookup methods.""" + @pytest.mark.pr def test_lookup_by_index(self, client, network, blockchain_height): """Lookup block by index.""" # Test at multiple points to ensure it works across blockchain @@ -212,7 +216,7 @@ class TestBlockTransactionLookup: def test_get_transaction_from_block(self, client, network): """Get specific transaction from block.""" # Find a block with transactions - search_response = client.search_transactions(network=network, limit=1) + search_response = client.search_transactions(network=network) tx_data = search_response.json()["transactions"][0] block_id = tx_data["block_identifier"] @@ -232,7 +236,7 @@ def test_get_transaction_from_block(self, client, network): def test_requires_block_hash_not_just_index(self, client, network): """block_identifier requires both index AND hash.""" # Get a transaction - search_response = client.search_transactions(network=network, limit=1) + search_response = client.search_transactions(network=network) tx_data = search_response.json()["transactions"][0] # Try with only index (should fail) @@ -254,7 +258,7 @@ def test_requires_block_hash_not_just_index(self, client, network): def test_transaction_not_in_block_returns_error(self, client, network): """Requesting transaction not in specified block should error.""" # Get transactions and ensure they're from different blocks - search_response = client.search_transactions(network=network, limit=10) + search_response = client.search_transactions(network=network) txs = search_response.json()["transactions"] # Find two transactions from different blocks diff --git a/tests/data-endpoints/test_error_handling.py b/tests/data-endpoints/test_error_handling.py index 5d81dca3b..049da268e 100644 --- a/tests/data-endpoints/test_error_handling.py +++ b/tests/data-endpoints/test_error_handling.py @@ -8,6 +8,8 @@ import pytest import allure +pytestmark = pytest.mark.pr + @allure.feature("Error Handling") @allure.story("Missing Network Identifier") diff --git a/tests/data-endpoints/test_network_endpoints.py b/tests/data-endpoints/test_network_endpoints.py index 601fcab05..818384428 100644 --- a/tests/data-endpoints/test_network_endpoints.py +++ b/tests/data-endpoints/test_network_endpoints.py @@ -1,11 +1,15 @@ """Tests for Rosetta /network endpoints.""" +import pytest import allure +pytestmark = pytest.mark.pr + @allure.feature("Network") @allure.story("List") class TestNetworkList: + @pytest.mark.pr def test_returns_configured_network(self, client, network): """Test that the configured network appears in the list.""" response = client.network_list() @@ -24,6 +28,7 @@ def test_returns_configured_network(self, client, network): @allure.feature("Network") @allure.story("Status") class TestNetworkStatus: + @pytest.mark.pr def test_returns_current_network_status(self, client, network): response = client.network_status(network=network) assert response.status_code == 200 diff --git a/tests/data-endpoints/test_peer_discovery.py b/tests/data-endpoints/test_peer_discovery.py new file mode 100644 index 000000000..9b9f41421 --- /dev/null +++ b/tests/data-endpoints/test_peer_discovery.py @@ -0,0 +1,54 @@ +"""Peer discovery TTL validation (v1.4.0).""" +import os +import pytest +import allure +from pathlib import Path + +import yaml + + +@allure.feature("Peer Discovery") +@allure.story("Dynamic Peer List") +class TestPeerDiscovery: + """Validate dynamic peer discovery with TTL.""" + + @pytest.mark.nightly + @pytest.mark.requires_peer_discovery + @pytest.mark.slow + @pytest.mark.skip(reason="Peer discovery takes too long to populate dynamic peers (#619)") + def test_peer_list_contains_dynamic_entries(self, client, network, has_peer_discovery): + """Verify peer discovery returns peers beyond the static bootstrap list.""" + if not has_peer_discovery: + pytest.skip("Peer discovery not enabled") + + repo_root = Path(__file__).resolve().parents[2] + topology_file = repo_root / "config" / "node" / network / "topology.json" + assert topology_file.exists(), f"Bootstrap topology file not found: {topology_file}" + + bootstrap_hosts = set() + try: + topology = yaml.safe_load(topology_file.read_text(encoding="utf-8")) + for peer in topology.get("bootstrapPeers", []): + host = peer.get("address") + if host: + bootstrap_hosts.add(host) + except Exception as exc: + pytest.fail(f"Unable to parse bootstrap topology {topology_file}: {exc}") + assert bootstrap_hosts, f"Bootstrap topology {topology_file} does not define any bootstrap peers" + + peers = client.network_status(network=network).json().get("peers", []) + peer_hosts = set() + for peer in peers: + peer_id = peer.get("peer_id", "") + if ":" in peer_id: + host = peer_id.split(":", 1)[0] + if host: + peer_hosts.add(host) + + assert peer_hosts, "Peer discovery should return peers with peer_id host values" + + dynamic_hosts = peer_hosts - bootstrap_hosts + assert dynamic_hosts, ( + "Peer discovery did not return any dynamic peers beyond bootstrap list. " + f"Bootstrap hosts: {sorted(bootstrap_hosts)}; Reported peers: {sorted(peer_hosts)}" + ) diff --git a/tests/data-endpoints/test_search_transactions.py b/tests/data-endpoints/test_search_transactions.py index 83345f4e9..748fa2c3c 100644 --- a/tests/data-endpoints/test_search_transactions.py +++ b/tests/data-endpoints/test_search_transactions.py @@ -12,6 +12,8 @@ # Network configuration from environment - works on ANY network NETWORK = os.environ.get("CARDANO_NETWORK", "preprod") +pytestmark = pytest.mark.pr + class TestSanityChecks: """Basic endpoint availability and error handling tests.""" @@ -151,7 +153,7 @@ class TestTransactionIdentifier: def test_valid_transaction_hash(self, client, network): """Search by transaction hash returns that specific transaction.""" # Dynamically fetch a transaction to test with - sample_response = client.search_transactions(network=network, limit=1) + sample_response = client.search_transactions(network=network) assert sample_response.status_code == 200 sample_tx = sample_response.json()["transactions"][0] tx_hash = sample_tx["transaction"]["transaction_identifier"]["hash"] @@ -203,8 +205,9 @@ def test_invalid_transaction_hash_format_returns_empty(self, client): @allure.feature("Search Transactions") @allure.story("Transaction Identifier") + @pytest.mark.pruning_compatible def test_valid_payment_address_shelley_base_using_address_field_large_utxos( - self, client, network_data + self, client, network_data, is_pruned_instance ): """Test with address that has large UTXOs.""" address = network_data["addresses"]["with_large_utxos"] @@ -223,7 +226,8 @@ class TestAccountIdentifier: @pytest.mark.parametrize( "address_type", ["shelley_base", "shelley_enterprise", "byron"] ) - def test_valid_payment_addresses(self, client, network_data, address_type): + @pytest.mark.pruning_compatible + def test_valid_payment_addresses(self, client, network_data, address_type, is_pruned_instance): """Test filtering by different address types.""" address = network_data["addresses"][address_type] @@ -247,8 +251,9 @@ def test_valid_payment_addresses(self, client, network_data, address_type): @allure.feature("Search Transactions") @allure.story("Account Identifier") + @pytest.mark.pruning_compatible def test_valid_payment_address_shelley_base_using_address_field( - self, client, network_data + self, client, network_data, is_pruned_instance ): """Test filtering by shelley base address using address field.""" address = network_data["addresses"]["with_large_utxos"] @@ -292,15 +297,20 @@ class TestMaxBlock: @allure.feature("Search Transactions") @allure.story("Max Block") - @pytest.mark.parametrize("percentage", [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) + @pytest.mark.slow + @pytest.mark.parametrize("percentage", [1, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) def test_max_block_filtering_at_percentage( - self, client, network, blockchain_height, percentage + self, client, network, blockchain_height, percentage, oldest_block_identifier ): """Test max_block filtering at different percentages of blockchain height.""" max_block = int(blockchain_height * percentage / 100) + # Skip test if max_block is before oldest queryable block in pruned instance + if oldest_block_identifier and max_block < oldest_block_identifier: + pytest.skip(f"max_block {max_block} is before oldest_block_identifier {oldest_block_identifier}") + response = client.search_transactions( - network=network, max_block=max_block, limit=25 + network=network, max_block=max_block ) assert response.status_code == 200 @@ -621,18 +631,21 @@ def test_invalid_operator_returns_error(self, client, network_data): class TestCurrencyFiltering: """Test currency filtering.""" - @allure.feature("Search Transactions") @allure.story("Currency Filtering") - def test_ada_filter(self, client, network): + @pytest.mark.parametrize("symbol", ["ada", "ADA"]) + @pytest.mark.skip(reason="ADA filter test needs review due to fee ops always including ADA") + def test_ada_filter_case_insensitive(self, client, network, symbol): """Filter by ada currency (case-insensitive).""" - # Search by lowercase "ada" response = client.search_transactions( - network=network, currency={"symbol": "ada", "decimals": 6} + network=network, currency={"symbol": symbol, "decimals": 6} ) assert response.status_code == 200 # Verify filtering works - ALL returned transactions must have ADA + # TODO: review this test as all transactions do need to have at least one ADA operation (txIn used for fees) + # Even if the filter is for another currency, ADA ops will still be present + # because Cardano transactions always include ADA for fees. txs = response.json()["transactions"] for tx in txs: currencies = [ @@ -644,48 +657,8 @@ def test_ada_filter(self, client, network): @allure.feature("Search Transactions") @allure.story("Currency Filtering") - def test_ada_uppercase_filter(self, client, network): - """Filter by ADA currency (uppercase) - case insensitive.""" - response = client.search_transactions( - network=network, currency={"symbol": "ADA", "decimals": 6} - ) - assert response.status_code == 200 - - # Verify case-insensitive filtering works - txs = response.json()["transactions"] - for tx in txs: - currencies = [ - op["amount"]["currency"]["symbol"] - for op in tx["transaction"]["operations"] - if "amount" in op and "currency" in op["amount"] - ] - assert "ADA" in currencies, "ADA filter must only return ADA transactions" - - @allure.feature("Search Transactions") - @allure.story("Currency Filtering") - def test_lovelace_filter(self, client, network): - """Filter by lovelace currency.""" - response = client.search_transactions( - network=network, currency={"symbol": "lovelace", "decimals": 0} - ) - assert response.status_code == 200 - - # Verify filtering works - ALL returned transactions must have lovelace/ADA - txs = response.json()["transactions"] - for tx in txs: - currencies = [ - op["amount"]["currency"]["symbol"] - for op in tx["transaction"]["operations"] - if "amount" in op and "currency" in op["amount"] - ] - # Lovelace and ADA are the same, API returns "ADA" - assert any(c in ["lovelace", "ADA"] for c in currencies), ( - "lovelace filter must only return transactions with lovelace/ADA" - ) - - @allure.feature("Search Transactions") - @allure.story("Currency Filtering") - def test_native_asset_filtering_by_ascii_symbol(self, client, network, network_data): + @pytest.mark.pruning_compatible + def test_native_asset_filtering_by_ascii_symbol(self, client, network, network_data, is_pruned_instance): """Currency filter works with ASCII symbols (backwards compat in v1.3.3).""" asset = network_data["assets"][0] hex_symbol = asset["symbol"].encode().hex().lower() # Lowercase hex @@ -772,7 +745,8 @@ def test_currency_filter_with_hex_encoded_symbol(self, client, network, network_ @allure.feature("Search Transactions") @allure.story("Currency Filtering") - def test_native_asset_filtering_with_policy_id(self, client, network, network_data): + @pytest.mark.pruning_compatible + def test_native_asset_filtering_with_policy_id(self, client, network, network_data, is_pruned_instance): """Test currency filtering with metadata.policyId.""" asset = network_data["assets"][0] diff --git a/tests/data-endpoints/test_smoke_network_data.py b/tests/data-endpoints/test_smoke_network_data.py index 6802d57d0..3259c1135 100644 --- a/tests/data-endpoints/test_smoke_network_data.py +++ b/tests/data-endpoints/test_smoke_network_data.py @@ -19,6 +19,7 @@ import allure +@pytest.mark.pr @pytest.mark.smoke @allure.feature("Smoke Tests") @allure.story("Network Test Data Validation") @@ -29,14 +30,14 @@ class TestNetworkDataValidity: "address_type", ["shelley_base", "shelley_enterprise", "byron", "with_large_utxos"], ) + @pytest.mark.pruning_compatible def test_configured_address_has_transactions( - self, client, network, network_data, address_type - ): + self, client, network, network_data, address_type): """Configured addresses should have transactions on the network.""" address = network_data["addresses"][address_type] response = client.search_transactions( - network=network, account_identifier={"address": address}, limit=1 + network=network, account_identifier={"address": address} ) assert response.status_code == 200 @@ -46,6 +47,7 @@ def test_configured_address_has_transactions( f"Network may have changed or address is wrong - update network_test_data.yaml" ) + @pytest.mark.pruning_compatible def test_configured_assets_have_transactions(self, client, network, network_data): """Configured native assets should have transactions on the network.""" for asset in network_data["assets"]: diff --git a/tests/data-endpoints/test_token_registry.py b/tests/data-endpoints/test_token_registry.py new file mode 100644 index 000000000..b7a0e4787 --- /dev/null +++ b/tests/data-endpoints/test_token_registry.py @@ -0,0 +1,320 @@ +""" +Tests for token registry metadata enrichment (v1.4.0). + +These tests validate: +* Policy identifiers are surfaced for configured tokens +* Metadata enrichment is consistent across account, block, and search endpoints +* Logo formats (base64 vs URL) for CIP-26 vs CIP-68 tokens +* Rosetta metadata matches the upstream metadata server (weekly parity check) +""" + +import os +from functools import lru_cache +from typing import Dict, List, Tuple + +import allure +import pytest +import requests + + +TOKEN_REGISTRY_BASE_URL = os.environ.get("TOKEN_REGISTRY_BASE_URL") +TOKEN_REGISTRY_LOGO_FETCH = os.environ.get("TOKEN_REGISTRY_LOGO_FETCH", "false").lower() == "true" + + +def _tokens_config(network_data: Dict) -> List[Dict]: + tokens = network_data.get("tokens_in_registry") + assert tokens, "network_test_data.yaml must define tokens_in_registry for the configured network" + return tokens + + +def _ensure_token_registry_base_url() -> str: + assert TOKEN_REGISTRY_BASE_URL, "TOKEN_REGISTRY_BASE_URL environment variable must be set" + return TOKEN_REGISTRY_BASE_URL.rstrip("/") + + +@lru_cache(maxsize=None) +def _registry_metadata(subject: str) -> Dict: + base_url = _ensure_token_registry_base_url() + # Use v2 endpoint shape: {base}/v2/subjects/{subject} + response = requests.get(f"{base_url}/v2/subjects/{subject}", timeout=15) + response.raise_for_status() + return _simplify_registry_metadata(response.json()) + + +def _simplify_registry_metadata(raw: Dict) -> Dict: + """Simplify v2 registry payload: keep only scalar values as-is.""" + simple: Dict[str, object] = {} + for key, value in raw.items(): + if key in {"subject", "policy"}: + continue + if not isinstance(value, (dict, list)): + simple[key] = value + return simple + + +def _account_token_metadata(client, network: str, token: Dict) -> Tuple[Dict, Dict]: + response = client.account_balance( + network=network, + account_identifier={"address": token["test_address"]}, + block_identifier={"index": token["test_block"]}, + ) + assert response.status_code == 200, ( + f"/account/balance returned {response.status_code} for {token['test_address']} " + f"at block {token['test_block']}" + ) + + for balance in response.json().get("balances", []): + currency = balance.get("currency", {}) + metadata = currency.get("metadata", {}) + if metadata.get("policyId") == token["policy_id"]: + return currency, metadata + + raise AssertionError( + f"Token with policyId {token['policy_id']} not found in /account/balance response " + f"for address {token['test_address']} at block {token['test_block']}" + ) + + +def _assert_basic_metadata(currency: Dict, metadata: Dict, token: Dict) -> None: + assert metadata.get("policyId") == token["policy_id"] + if token.get("subject"): + subject = metadata.get("subject") + if subject is None: + policy_id = metadata.get("policyId", "") + symbol = currency.get("symbol", "") + if policy_id and symbol: + subject = policy_id + symbol + assert subject == token["subject"], "subject mismatch" + + # Currency symbol/decimals should match expectations + if "symbol_hex" in token: + assert currency.get("symbol") == token["symbol_hex"], "currency symbol must use hex form" + if "decimals" in token: + assert currency.get("decimals") == token["decimals"], "currency decimals mismatch" + + for field in ("name", "description", "ticker", "url"): + expected = token.get(field) + if expected is not None: + assert metadata.get(field) == expected, f"Metadata field '{field}' mismatch" + + +def _block_operations_for_token(client, network: str, token: Dict) -> List[Tuple[Dict, Dict]]: + response = client.block(network=network, block_identifier={"index": token["test_block"]}) + assert response.status_code == 200, ( + f"/block {token['test_block']} returned {response.status_code}" + ) + + block = response.json().get("block", {}) + matches: List[Tuple[Dict, Dict]] = [] + + for tx in block.get("transactions", []): + for op in tx.get("operations", []): + # Strict: only consider bundled tokens inside operation metadata.tokenBundle + bundle = op.get("metadata", {}).get("tokenBundle", []) + for entry in bundle: + if entry.get("policyId") != token["policy_id"]: + continue + for t in entry.get("tokens", []): + t_currency = t.get("currency", {}) + t_metadata = t_currency.get("metadata", {}) + if t_metadata.get("policyId") == token["policy_id"]: + # Create a minimal pseudo-op so downstream checks read the token currency + pseudo_op = {"amount": {"currency": t_currency}} + matches.append((tx, pseudo_op)) + break + # Only one match per operation is needed + if matches and matches[-1][0] is tx: + break + + return matches + + +def _search_operations_for_token(client, network: str, token: Dict) -> List[Tuple[Dict, Dict]]: + # Use ASCII symbol in search request (known API quirk). No fallbacks. + symbol = token["symbol_ascii"] + response = client.search_transactions( + network=network, + currency={ + "symbol": symbol, + "decimals": token["decimals"], + "metadata": {"policyId": token["policy_id"]}, + }, + ) + + if response.status_code != 200: + return [] + + matches: List[Tuple[Dict, Dict]] = [] + for tx in response.json().get("transactions", []): + for op in tx.get("transaction", {}).get("operations", []): + # Prefer matched asset surfaced as operation amount (typical for search) + currency = op.get("amount", {}).get("currency", {}) + metadata = currency.get("metadata", {}) + if metadata.get("policyId") == token["policy_id"]: + matches.append((tx, op)) + continue + + # Also allow bundled tokens if present + bundle = op.get("metadata", {}).get("tokenBundle", []) + for entry in bundle: + if entry.get("policyId") != token["policy_id"]: + continue + for t in entry.get("tokens", []): + t_currency = t.get("currency", {}) + t_metadata = t_currency.get("metadata", {}) + if t_metadata.get("policyId") == token["policy_id"]: + pseudo_op = {"amount": {"currency": t_currency}} + matches.append((tx, pseudo_op)) + break + if matches and matches[-1][0] is tx: + break + + return matches + + +@allure.feature("Token Registry") +@allure.story("v1.4.0 Feature - policyId") +class TestPolicyIdMetadata: + """Verify policy identifiers are exposed for configured tokens.""" + + @pytest.mark.pr + def test_tokens_expose_policy_id(self, client, network, network_data, has_token_registry): + assert has_token_registry, "Token registry must be enabled for this test" + + for token in _tokens_config(network_data): + currency, metadata = _account_token_metadata(client, network, token) + assert "policyId" in metadata, "Missing policyId in enriched metadata" + if token.get("subject"): + symbol = currency.get("symbol", "") + assert metadata.get("policyId") + symbol == token["subject"] + + +@pytest.mark.smoke +@pytest.mark.requires_token_registry +@allure.feature("Smoke Tests") +@allure.story("Token Registry Health") +class TestTokenRegistryHealth: + """Validate metadata enrichment for configured tokens using account balances.""" + + def test_configured_tokens_have_enrichment(self, client, network, network_data, has_token_registry): + assert has_token_registry, "Token registry must be enabled for this test" + + for token in _tokens_config(network_data): + currency, metadata = _account_token_metadata(client, network, token) + _assert_basic_metadata(currency, metadata, token) + + +@allure.feature("Token Registry") +@allure.story("Enriched Metadata") +class TestTokenRegistryEnrichment: + """Validate enrichment across block and search endpoints.""" + + @pytest.mark.nightly + @pytest.mark.requires_token_registry + def test_enriched_metadata_in_block_operations(self, client, network, network_data, has_token_registry): + assert has_token_registry, "Token registry must be enabled for this test" + + for token in _tokens_config(network_data): + matches = _block_operations_for_token(client, network, token) + assert matches, ( + f"No operations found with token {token['ticker']} in block {token['test_block']}" + ) + for tx, op in matches: + currency = op.get("amount", {}).get("currency", {}) + metadata = currency.get("metadata", {}) + _assert_basic_metadata(currency, metadata, token) + + @pytest.mark.nightly + @pytest.mark.requires_token_registry + def test_enriched_metadata_in_search_results(self, client, network, network_data, has_token_registry): + assert has_token_registry, "Token registry must be enabled for this test" + + for token in _tokens_config(network_data): + matches = _search_operations_for_token(client, network, token) + assert matches, ( + f"/search/transactions returned no results for token {token['ticker']} " + f"(symbol tried: {token['symbol_ascii']})" + ) + for tx, op in matches: + currency = op.get("amount", {}).get("currency", {}) + metadata = currency.get("metadata", {}) + _assert_basic_metadata(currency, metadata, token) + + +@allure.feature("Token Registry") +@allure.story("Logo Formats") +class TestTokenRegistryLogos: + """Validate logo enrichment for CIP-26 (base64) and CIP-68 (URL) tokens.""" + + @pytest.mark.nightly + @pytest.mark.requires_token_registry + @pytest.mark.requires_logo_fetch + def test_logo_formats_match_expected_standard(self, client, network, network_data, has_token_registry): + assert has_token_registry, "Token registry must be enabled for this test" + if not TOKEN_REGISTRY_LOGO_FETCH: + pytest.skip("TOKEN_REGISTRY_LOGO_FETCH must be true to validate logo payloads") + + for token in _tokens_config(network_data): + _, metadata = _account_token_metadata(client, network, token) + logo = metadata.get("logo") + assert isinstance(logo, dict), "Logo metadata missing or malformed" + expected_format = token.get("logo_format") + if expected_format: + assert logo.get("format") == expected_format, ( + f"Expected logo format '{expected_format}' for token {token['ticker']}" + ) + prefix = token.get("logo_value_prefix") + if prefix: + assert str(logo.get("value", "")).startswith(prefix), ( + f"Logo value does not start with expected prefix '{prefix}' for token {token['ticker']}" + ) + + +@allure.feature("Token Registry") +@allure.story("Metadata Parity") +class TestTokenRegistryParity: + """Weekly parity check between Rosetta enrichment and metadata registry source.""" + + @pytest.mark.weekly + @pytest.mark.requires_token_registry + def test_rosetta_metadata_matches_registry(self, client, network, network_data, has_token_registry): + assert has_token_registry, "Token registry must be enabled for this test" + _ensure_token_registry_base_url() + + for token in _tokens_config(network_data): + currency, rosetta_metadata = _account_token_metadata(client, network, token) + try: + registry_metadata = _registry_metadata(token["subject"]) + except requests.HTTPError as e: + if getattr(e.response, "status_code", None) == 404: + pytest.fail( + f"Registry missing subject {token['subject']} at {TOKEN_REGISTRY_BASE_URL}" + ) + raise + + _assert_basic_metadata(currency, rosetta_metadata, token) + + for field in ("name", "description", "ticker", "url"): + registry_value = registry_metadata.get(field) + if registry_value is not None: + assert rosetta_metadata.get(field) == registry_value, ( + f"Field '{field}' mismatch between Rosetta and registry for {token['ticker']}" + ) + + registry_decimals = registry_metadata.get("decimals") + if registry_decimals is not None: + assert int(registry_decimals) == token["decimals"], ( + f"Decimals mismatch for {token['ticker']}: registry={registry_decimals}" + ) + + registry_logo = registry_metadata.get("logo") + rosetta_logo = rosetta_metadata.get("logo") + if registry_logo and TOKEN_REGISTRY_LOGO_FETCH: + assert isinstance(rosetta_logo, dict), "Rosetta logo metadata missing" + assert rosetta_logo.get("value") == registry_logo, ( + f"Logo value mismatch between Rosetta and registry for {token['ticker']}" + ) + elif not TOKEN_REGISTRY_LOGO_FETCH: + assert rosetta_logo is None or rosetta_logo == {}, ( + "Logo metadata should be absent when TOKEN_REGISTRY_LOGO_FETCH is disabled" + ) diff --git a/tests/data-endpoints/uv.lock b/tests/data-endpoints/uv.lock index 23d9aa782..71744ea1d 100644 --- a/tests/data-endpoints/uv.lock +++ b/tests/data-endpoints/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.12" [[package]] @@ -10,9 +10,9 @@ dependencies = [ { name = "allure-python-commons" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/f7/f0941b53dcf0fcac7a16a0c4e85985dbab2d2488423c33c757aacf16fcef/allure_pytest-2.15.0.tar.gz", hash = "sha256:b8f464d3bfeb6cb5e6a77e70eacd1aa3762133677cabece57a07c9f3bcd4b1e8", size = 17682 } +sdist = { url = "https://files.pythonhosted.org/packages/44/f7/f0941b53dcf0fcac7a16a0c4e85985dbab2d2488423c33c757aacf16fcef/allure_pytest-2.15.0.tar.gz", hash = "sha256:b8f464d3bfeb6cb5e6a77e70eacd1aa3762133677cabece57a07c9f3bcd4b1e8", size = 17682, upload-time = "2025-07-22T11:12:17.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/e5/0bc588f0a1605435cd8811696c17d78e82fb84cc3933d57edda10bea4b5c/allure_pytest-2.15.0-py3-none-any.whl", hash = "sha256:f33c6ad242995bd27d2b467ee685772904d6941a6a6c9877657e0b8fe97a0ede", size = 12448 }, + { url = "https://files.pythonhosted.org/packages/e5/e5/0bc588f0a1605435cd8811696c17d78e82fb84cc3933d57edda10bea4b5c/allure_pytest-2.15.0-py3-none-any.whl", hash = "sha256:f33c6ad242995bd27d2b467ee685772904d6941a6a6c9877657e0b8fe97a0ede", size = 12448, upload-time = "2025-07-22T11:12:16.583Z" }, ] [[package]] @@ -23,9 +23,9 @@ dependencies = [ { name = "attrs" }, { name = "pluggy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/da/d44069099ddb2b2deb736ec91f7c52691cef8de68d784e013c0d6fcd6b8c/allure_python_commons-2.15.0.tar.gz", hash = "sha256:4f639c8bb4b79df0d94f1baa88782c9399be3f45fd839ae583a314a5d451b978", size = 15212 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/da/d44069099ddb2b2deb736ec91f7c52691cef8de68d784e013c0d6fcd6b8c/allure_python_commons-2.15.0.tar.gz", hash = "sha256:4f639c8bb4b79df0d94f1baa88782c9399be3f45fd839ae583a314a5d451b978", size = 15212, upload-time = "2025-07-22T11:12:09.226Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/d7/63e1a5fa876a8e8106ce73079fd0394ae1bf953f99ea14886c180c210ba5/allure_python_commons-2.15.0-py3-none-any.whl", hash = "sha256:84ebbbca2e4c147ed151e615f995eb2e0dd688818d06c55df9e8e3ce4de6eeeb", size = 16198 }, + { url = "https://files.pythonhosted.org/packages/45/d7/63e1a5fa876a8e8106ce73079fd0394ae1bf953f99ea14886c180c210ba5/allure_python_commons-2.15.0-py3-none-any.whl", hash = "sha256:84ebbbca2e4c147ed151e615f995eb2e0dd688818d06c55df9e8e3ce4de6eeeb", size = 16198, upload-time = "2025-07-22T11:12:08.034Z" }, ] [[package]] @@ -37,36 +37,93 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213 }, + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] [[package]] name = "attrs" version = "25.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] [[package]] name = "certifi" version = "2025.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216 }, + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -79,7 +136,9 @@ dependencies = [ { name = "jsonschema" }, { name = "pytest" }, { name = "pytest-xdist" }, + { name = "python-dotenv" }, { name = "pyyaml" }, + { name = "requests" }, ] [package.metadata] @@ -89,25 +148,27 @@ requires-dist = [ { name = "jsonschema", specifier = ">=4.25.1" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "requests", specifier = ">=2.32.5" }, ] [[package]] name = "execnet" version = "2.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] @@ -118,9 +179,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -133,27 +194,27 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] @@ -166,9 +227,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342 } +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040 }, + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, ] [[package]] @@ -178,36 +239,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] @@ -221,9 +282,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] @@ -234,35 +295,44 @@ dependencies = [ { name = "execnet" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069 } +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396 }, + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] [[package]] name = "pyyaml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] [[package]] @@ -274,106 +344,130 @@ dependencies = [ { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 } +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] name = "rpds-py" version = "0.27.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795 }, - { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121 }, - { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976 }, - { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953 }, - { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915 }, - { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883 }, - { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699 }, - { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713 }, - { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324 }, - { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646 }, - { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137 }, - { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343 }, - { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497 }, - { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790 }, - { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741 }, - { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574 }, - { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051 }, - { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395 }, - { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334 }, - { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691 }, - { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868 }, - { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469 }, - { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125 }, - { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341 }, - { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511 }, - { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736 }, - { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462 }, - { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034 }, - { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392 }, - { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355 }, - { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138 }, - { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247 }, - { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699 }, - { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852 }, - { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582 }, - { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126 }, - { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486 }, - { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832 }, - { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249 }, - { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356 }, - { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300 }, - { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714 }, - { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943 }, - { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472 }, - { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676 }, - { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313 }, - { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080 }, - { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868 }, - { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750 }, - { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688 }, - { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225 }, - { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361 }, - { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493 }, - { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623 }, - { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800 }, - { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943 }, - { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739 }, - { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120 }, - { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944 }, - { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283 }, - { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320 }, - { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760 }, - { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476 }, - { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418 }, - { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771 }, - { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022 }, - { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787 }, - { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538 }, - { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512 }, - { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813 }, - { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385 }, - { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097 }, + { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, + { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, + { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, + { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, + { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, + { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, + { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, + { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, + { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, + { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, + { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, + { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, + { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, + { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, + { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, + { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] diff --git a/yaci-indexer/pom.xml b/yaci-indexer/pom.xml index 368282918..2a37aa3b6 100644 --- a/yaci-indexer/pom.xml +++ b/yaci-indexer/pom.xml @@ -36,6 +36,7 @@ + org.springframework.boot diff --git a/yaci-indexer/src/main/resources/application.properties b/yaci-indexer/src/main/resources/application.properties index dd33cf59e..f5bfdb97c 100644 --- a/yaci-indexer/src/main/resources/application.properties +++ b/yaci-indexer/src/main/resources/application.properties @@ -104,9 +104,14 @@ logging.level.com.bloxbean.cardano.yaci.core.protocol.keepalive=debug store.admin.auto-recovery-enabled=true store.admin.health-check-interval=20 -store.utxo.pruning-enabled=${REMOVE_SPENT_UTXOS:false} -store.utxo.pruning-safe-blocks=${REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT:2160} -store.utxo.pruning-interval=600 +store.utxo.pruning-enabled=${REMOVE_SPENT_UTXOS:true} +store.utxo.pruning-safe-blocks=${REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT:129600} +store.utxo.pruning-interval=10800 +store.utxo.pruning-batch-size=${REMOVE_SPENT_UTXOS_BATCH_SIZE:3000} +logging.level.com.bloxbean.cardano.yaci.store=${LOG:error} +spring.datasource.hikari.maximum-pool-size=20 +spring.datasource.hikari.minimum-idle=5 + store.epoch.endpoints.epoch.local.enabled=true diff --git a/yaci-indexer/src/test/resources/application-test-integration.properties b/yaci-indexer/src/test/resources/application-test-integration.properties index 78f6aa44c..278dd46b2 100644 --- a/yaci-indexer/src/test/resources/application-test-integration.properties +++ b/yaci-indexer/src/test/resources/application-test-integration.properties @@ -13,5 +13,6 @@ spring.h2.console.enabled=true store.cardano.host=${CARDANO_NODE_HOST:localhost} store.cardano.port=${CARDANO_NODE_PORT:8090} -store.utxo.pruning-interval=600 -store.utxo.pruning-safe-blocks=${REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT:2160} +store.utxo.pruning-interval=3600 +store.utxo.pruning-safe-blocks=${REMOVE_SPENT_UTXOS_LAST_BLOCKS_GRACE_COUNT:129600} +store.utxo.pruning-batch-size=${REMOVE_SPENT_UTXOS_BATCH_SIZE:3000} \ No newline at end of file