diff --git a/.changeset/quiet-comets-relocate.md b/.changeset/quiet-comets-relocate.md new file mode 100644 index 000000000000..bbef9ecc650c --- /dev/null +++ b/.changeset/quiet-comets-relocate.md @@ -0,0 +1,8 @@ +--- +"@domain/entity-aggregated-asset": minor +"@domain/entity-interest-rate": minor +"@domain/api-aggregated-assets": minor +"@ledgerhq/live-common": patch +--- + +Move the dada-client domain layer into the aggregated-assets DDD packages, leaving re-export shims at the old paths so no consumer changes diff --git a/domain/api/aggregated-assets/package.json b/domain/api/aggregated-assets/package.json index 826aeef0898f..d1d825db35fe 100644 --- a/domain/api/aggregated-assets/package.json +++ b/domain/api/aggregated-assets/package.json @@ -7,14 +7,17 @@ "types": "src/index.ts", "exports": { ".": "./src/index.ts", + "./mock": "./src/fixtures/assetsData.mock.ts", + "./mock/stocks": "./src/fixtures/stocks.mock.ts", + "./mock/stablecoins": "./src/fixtures/stablecoins.mock.ts", "./package.json": "./package.json" }, "sideEffects": false, "scripts": { "typecheck": "tsc --noEmit", - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "jest --watch", - "coverage": "jest --coverage --passWithNoTests", + "coverage": "jest --coverage", "unimported": "pnpm knip --directory ../../.. -W domain/api/aggregated-assets" }, "devDependencies": { @@ -24,6 +27,23 @@ "@types/jest": "catalog:", "@types/node": "catalog:", "jest": "catalog:", + "react": "catalog:", + "react-redux": "catalog:", "typescript": "catalog:" + }, + "dependencies": { + "@domain/api-currency-token": "workspace:^", + "@domain/entity-aggregated-asset": "workspace:*", + "@domain/entity-currency": "workspace:^", + "@domain/entity-currency-crypto": "workspace:^", + "@domain/entity-currency-token": "workspace:^", + "@domain/entity-interest-rate": "workspace:*", + "@reduxjs/toolkit": "catalog:", + "@shared/api-services": "workspace:*", + "@shared/env": "workspace:*" + }, + "peerDependencies": { + "react": ">=19.0.0", + "react-redux": ">=9.0.0" } } diff --git a/domain/api/aggregated-assets/src/accessors.ts b/domain/api/aggregated-assets/src/accessors.ts new file mode 100644 index 000000000000..453ae0d231d9 --- /dev/null +++ b/domain/api/aggregated-assets/src/accessors.ts @@ -0,0 +1,16 @@ +import { collectAllByCategory } from "./internals/collectAllByCategory"; +import type { GetAssetsByCategoryParams } from "./types"; + +/** Every ticker in a category, across all pages. */ +export function fetchAllAssetsByCategory(queryArg: GetAssetsByCategoryParams) { + return collectAllByCategory(queryArg, data => + Object.values(data.cryptoAssets).map(a => a.ticker), + ); +} + +/** Every per-network currency id in a category, across all pages. */ +export function fetchAllAssetCurrencyIdsByCategory(queryArg: GetAssetsByCategoryParams) { + return collectAllByCategory(queryArg, data => + Object.values(data.cryptoAssets).flatMap(meta => Object.values(meta.assetsIds)), + ); +} diff --git a/domain/api/aggregated-assets/src/api.test.ts b/domain/api/aggregated-assets/src/api.test.ts new file mode 100644 index 000000000000..fd565c02e2d7 --- /dev/null +++ b/domain/api/aggregated-assets/src/api.test.ts @@ -0,0 +1,21 @@ +/* + * The Domain Test CI job installs only ./domain/** and ./shared/**, so @shared/env's transitive + * @ledgerhq/live-env (a libs/ package) is absent. Mock it with a factory so the real module is + * never resolved — this test only reads a static property. + */ +jest.mock("@shared/env", () => ({ + getEnv: jest.fn().mockReturnValue("https://dada.api.ledger.com/v1"), +})); + +import { assetsDataApi } from "./api"; + +describe("assetsDataApi", () => { + /* + * createCurrencyDataSelector hand-scans state.assetsDataApi.queries by string, and Storybook + * stories preload this exact key. A rename produces no type error and silently returns + * undefined for every market and interest-rate lookup, so pin the literal. + */ + it("keeps the frozen reducerPath", () => { + expect(assetsDataApi.reducerPath).toBe("assetsDataApi"); + }); +}); diff --git a/domain/api/aggregated-assets/src/api.ts b/domain/api/aggregated-assets/src/api.ts new file mode 100644 index 000000000000..c95d500be83d --- /dev/null +++ b/domain/api/aggregated-assets/src/api.ts @@ -0,0 +1,139 @@ +import { dadaApi } from "@shared/api-services"; +import { + AssetsDataTags, + type AssetsData, + type AssetsDataWithPagination, + type GetAssetsByCategoryParams, + type GetAssetsDataParams, + type PageParam, +} from "./types"; +import { ONE_DAY_IN_SECONDS } from "./constants"; +import { transformAssetsResponse } from "./transforms"; +import { fetchAllAssetCurrencyIdsByCategory, fetchAllAssetsByCategory } from "./accessors"; +import { buildAssetsQueryParams } from "./requests"; +import { fetchAssetsPage, resolveBaseUrl } from "./internals/requests"; +import { + allSettled, + chunkCurrencyIds, + deepMergeCryptoAssets, + emptyAssetsData, +} from "./internals/utils"; + +/* + * `injectEndpoints` adds this use case's endpoints to the shared DADA service api, and + * `enhanceEndpoints` widens its tag union in place — `injectEndpoints` does not accept `tagTypes`. + * Both mutate and return the same object, so one reducer, one middleware and one cache slice serve + * every use case on this backend. + * + * That single slice is load-bearing: `createCurrencyDataSelector` hand-scans + * `state.assetsDataApi.queries` across every cache entry, which is how interest rates and market + * trend fetched by one query reach a caller that ran a different one. + */ +export const assetsDataApi = dadaApi + .enhanceEndpoints({ addTagTypes: [AssetsDataTags.Assets] }) + .injectEndpoints({ + endpoints: build => ({ + getAssetsData: build.infiniteQuery({ + query: ({ pageParam, queryArg }) => ({ + url: `${resolveBaseUrl(queryArg)}/assets`, + params: buildAssetsQueryParams(queryArg, { cursor: pageParam?.cursor }), + }), + providesTags: [AssetsDataTags.Assets], + transformResponse: transformAssetsResponse, + infiniteQueryOptions: { + initialPageParam: { + cursor: "", + }, + getNextPageParam: lastPage => { + if (lastPage.pagination.nextCursor) { + return { + cursor: lastPage.pagination.nextCursor, + }; + } else { + return undefined; + } + }, + }, + }), + getAssetData: build.query({ + query: queryArg => ({ + url: `${resolveBaseUrl(queryArg)}/assets`, + params: buildAssetsQueryParams(queryArg, { pageSize: 1 }), + }), + providesTags: [AssetsDataTags.Assets], + transformResponse: transformAssetsResponse, + }), + getAssetsByCategory: build.query({ + queryFn: async queryArg => { + return fetchAllAssetsByCategory(queryArg); + }, + keepUnusedDataFor: ONE_DAY_IN_SECONDS, + }), + getAssetCurrencyIdsByCategory: build.query({ + queryFn: async queryArg => { + return fetchAllAssetCurrencyIdsByCategory(queryArg); + }, + keepUnusedDataFor: ONE_DAY_IN_SECONDS, + }), + getChunkedAssetsData: build.query({ + queryFn: async queryArg => { + try { + const chunks = chunkCurrencyIds(queryArg.currencyIds ?? []); + const baseUrl = resolveBaseUrl(queryArg); + + if (chunks.length === 0) { + return { data: emptyAssetsData() }; + } + + const results = await allSettled( + chunks.map(chunkIds => + fetchAssetsPage(baseUrl, { ...queryArg, currencyIds: chunkIds }), + ), + ); + + const responses = results.flatMap(r => (r.status === "fulfilled" ? [r.value] : [])); + + if (responses.length === 0) { + const firstError = results.find(r => r.status === "rejected"); + const reason = firstError?.status === "rejected" ? firstError.reason : undefined; + return { + error: { + status: "FETCH_ERROR", + error: reason instanceof Error ? reason.message : "All DADA chunks failed", + }, + }; + } + + const merged = responses.reduce((acc, res) => { + deepMergeCryptoAssets(acc.cryptoAssets, res.cryptoAssets); + Object.assign(acc.networks, res.networks); + Object.assign(acc.cryptoOrTokenCurrencies, res.cryptoOrTokenCurrencies); + Object.assign(acc.interestRates, res.interestRates); + Object.assign(acc.markets, res.markets); + acc.currenciesOrder.metaCurrencyIds.push(...res.currenciesOrder.metaCurrencyIds); + return acc; + }, emptyAssetsData()); + + return { data: merged }; + } catch (error) { + return { + error: { + status: "FETCH_ERROR", + error: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + }, + providesTags: [AssetsDataTags.Assets], + keepUnusedDataFor: ONE_DAY_IN_SECONDS, + }), + }), + }); + +export const { + useGetAssetsDataInfiniteQuery, + useGetAssetDataQuery, + useGetAssetsByCategoryQuery, + useGetAssetCurrencyIdsByCategoryQuery, + useGetChunkedAssetsDataQuery, +} = assetsDataApi; diff --git a/domain/api/aggregated-assets/src/constants.ts b/domain/api/aggregated-assets/src/constants.ts new file mode 100644 index 000000000000..358c215f6e1c --- /dev/null +++ b/domain/api/aggregated-assets/src/constants.ts @@ -0,0 +1,2 @@ +/** Cache lifetime for the collections DADA only changes daily (`keepUnusedDataFor`, in seconds). */ +export const ONE_DAY_IN_SECONDS = 24 * 60 * 60; diff --git a/domain/api/aggregated-assets/src/errors.ts b/domain/api/aggregated-assets/src/errors.ts new file mode 100644 index 000000000000..5f5c8b261197 --- /dev/null +++ b/domain/api/aggregated-assets/src/errors.ts @@ -0,0 +1,57 @@ +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; + +/** + * Type guard to check if error is a FetchBaseQueryError + */ +export function isFetchBaseQueryError(error: unknown): error is FetchBaseQueryError { + return typeof error === "object" && error !== null && "status" in error; +} + +/** + * Check if the error is a network connectivity error (no internet, timeout, etc.) + */ +export function isNetworkError(error: unknown): boolean { + if (!isFetchBaseQueryError(error)) { + return false; + } + return error.status === "FETCH_ERROR" || error.status === "TIMEOUT_ERROR"; +} + +/** + * Check if the error is an API error with HTTP status code (4xx, 5xx) + */ +export function isApiError(error: unknown): boolean { + if (!isFetchBaseQueryError(error)) { + return false; + } + return typeof error.status === "number"; +} + +/** + * Get HTTP status code from API error, or undefined if not an API error + */ +export function getApiErrorStatus(error: unknown): number | undefined { + if (!isFetchBaseQueryError(error) || typeof error.status !== "number") { + return undefined; + } + return error.status; +} + +export type ErrorInfo = { + hasError: boolean; + isNetworkError: boolean; + isApiError: boolean; + apiStatus: number | undefined; +}; + +/** + * Parse error into a structured ErrorInfo object + */ +export function parseError(error: unknown): ErrorInfo { + return { + hasError: !!error, + isNetworkError: isNetworkError(error), + isApiError: isApiError(error), + apiStatus: getApiErrorStatus(error), + }; +} diff --git a/domain/api/aggregated-assets/src/fixtures/assetsData.mock.ts b/domain/api/aggregated-assets/src/fixtures/assetsData.mock.ts new file mode 100644 index 000000000000..68cf78a5aad5 --- /dev/null +++ b/domain/api/aggregated-assets/src/fixtures/assetsData.mock.ts @@ -0,0 +1,217 @@ +import { CryptoCurrencyIdSchema, getCryptoCurrencyById } from "@domain/entity-currency-crypto"; +import { TokenCurrencyIdSchema } from "@domain/entity-currency-token"; +const mockInjectiveCurrency = getCryptoCurrencyById("injective"); + +export const mockAssetsData = { + cryptoAssets: { + "urn:crypto:meta-currency:injective_protocol": { + id: "urn:crypto:meta-currency:injective_protocol", + ticker: "INJ", + name: "Injective", + assetsIds: { + injective: "injective", + ethereum: "ethereum/erc20/injective_token", + bsc: "bsc/bep20/injective_protocol", + }, + }, + }, + networks: { + bsc: { id: "bsc", name: "Binance Smart Chain" }, + ethereum: { id: "ethereum", name: "Ethereum" }, + injective: { id: "injective", name: "Injective" }, + }, + cryptoOrTokenCurrencies: { + "bsc/bep20/injective_protocol": { + type: "TokenCurrency" as const, + id: TokenCurrencyIdSchema.parse("bsc/bep20/injective_protocol"), + name: "Injective Protocol", + ticker: "INJ", + contractAddress: "0x0", + parentCurrencyId: CryptoCurrencyIdSchema.parse("bsc"), + tokenType: "bep20", + units: [ + { + name: "INJ", + code: "INJ", + magnitude: 18, + }, + ], + }, + "ethereum/erc20/injective_token": { + type: "TokenCurrency" as const, + id: TokenCurrencyIdSchema.parse("ethereum/erc20/injective_token"), + name: "Injective Token", + ticker: "INJ", + contractAddress: "0x0", + parentCurrencyId: CryptoCurrencyIdSchema.parse("ethereum"), + tokenType: "erc20", + units: [ + { + name: "INJ", + code: "INJ", + magnitude: 18, + }, + ], + }, + injective: mockInjectiveCurrency, + }, + interestRates: {}, + markets: {}, + currenciesOrder: { + key: "marketCap", + order: "desc", + metaCurrencyIds: ["urn:crypto:meta-currency:injective_protocol"], + }, +}; + +export const mockAssetsDataWithPagination = { + ...mockAssetsData, + pagination: { + nextCursor: "cursor-1", + }, +}; + +// Bitcoin mock data +const mockBitcoinCurrency = getCryptoCurrencyById("bitcoin"); + +export const mockBitcoinAssetsData = { + cryptoAssets: { + bitcoin: { + id: "bitcoin", + ticker: "BTC", + name: "Bitcoin", + assetsIds: { + bitcoin: "bitcoin", + ethereum: "ethereum/erc20/wrapped_bitcoin", + }, + }, + }, + networks: { + bitcoin: { id: "bitcoin", name: "Bitcoin" }, + ethereum: { id: "ethereum", name: "Ethereum" }, + }, + cryptoOrTokenCurrencies: { + bitcoin: mockBitcoinCurrency, + "ethereum/erc20/wrapped_bitcoin": { + type: "TokenCurrency" as const, + id: TokenCurrencyIdSchema.parse("ethereum/erc20/wrapped_bitcoin"), + name: "Wrapped Bitcoin", + ticker: "WBTC", + contractAddress: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + parentCurrencyId: CryptoCurrencyIdSchema.parse("ethereum"), + tokenType: "erc20", + units: [ + { + name: "WBTC", + code: "WBTC", + magnitude: 8, + }, + ], + }, + }, + interestRates: {}, + markets: {}, + currenciesOrder: { + key: "marketCap", + order: "desc", + metaCurrencyIds: ["bitcoin"], + }, +}; + +// USDC mock data +export const mockUsdcAssetsData = { + cryptoAssets: { + usdc: { + id: "usdc", + ticker: "USDC", + name: "USD Coin", + assetsIds: { + ethereum: "ethereum/erc20/usd_coin", + polygon: "polygon/erc20/usd_coin", + }, + }, + }, + networks: { + ethereum: { id: "ethereum", name: "Ethereum" }, + polygon: { id: "polygon", name: "Polygon" }, + }, + cryptoOrTokenCurrencies: { + "ethereum/erc20/usd_coin": { + type: "TokenCurrency" as const, + id: TokenCurrencyIdSchema.parse("ethereum/erc20/usd_coin"), + name: "USD Coin", + ticker: "USDC", + contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + parentCurrencyId: CryptoCurrencyIdSchema.parse("ethereum"), + tokenType: "erc20", + units: [ + { + name: "USDC", + code: "USDC", + magnitude: 6, + }, + ], + }, + "polygon/erc20/usd_coin": { + type: "TokenCurrency" as const, + id: TokenCurrencyIdSchema.parse("polygon/erc20/usd_coin"), + name: "USD Coin (Polygon)", + ticker: "USDC", + contractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + parentCurrencyId: CryptoCurrencyIdSchema.parse("polygon"), + tokenType: "erc20", + units: [ + { + name: "USDC", + code: "USDC", + magnitude: 6, + }, + ], + }, + }, + interestRates: {}, + markets: {}, + currenciesOrder: { + key: "marketCap", + order: "desc", + metaCurrencyIds: ["usdc"], + }, +}; + +export const mockArbitrumTokenAssetsData = { + cryptoAssets: { + arbitrum: { + id: "arbitrum", + ticker: "ARB", + name: "Arbitrum", + assetsIds: { + ethereum: "ethereum/erc20/arbitrum", + arbitrum: "arbitrum", + }, + }, + }, + networks: { + ethereum: { id: "ethereum", name: "Ethereum" }, + arbitrum: { id: "arbitrum", name: "Arbitrum One" }, + }, + cryptoOrTokenCurrencies: { + "ethereum/erc20/arbitrum": { + type: "TokenCurrency" as const, + id: TokenCurrencyIdSchema.parse("ethereum/erc20/arbitrum"), + name: "Arbitrum", + ticker: "ARB", + contractAddress: "0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1", + parentCurrencyId: CryptoCurrencyIdSchema.parse("ethereum"), + tokenType: "erc20", + units: [{ name: "ARB", code: "ARB", magnitude: 18 }], + }, + arbitrum: getCryptoCurrencyById("arbitrum"), + }, + interestRates: {}, + markets: {}, + currenciesOrder: { + key: "marketCap", + order: "desc", + metaCurrencyIds: ["arbitrum"], + }, +}; diff --git a/domain/api/aggregated-assets/src/fixtures/categoryResponse.mock.ts b/domain/api/aggregated-assets/src/fixtures/categoryResponse.mock.ts new file mode 100644 index 000000000000..2aa9822300d2 --- /dev/null +++ b/domain/api/aggregated-assets/src/fixtures/categoryResponse.mock.ts @@ -0,0 +1,101 @@ +import { CryptoCurrencyIdSchema } from "@domain/entity-currency-crypto"; +import { TokenCurrencyIdSchema } from "@domain/entity-currency-token"; +import type { CryptoOrTokenCurrency } from "@domain/entity-currency"; +import type { NetworkInfo } from "../schema"; +import type { PartialMarketItemResponse } from "../internals/market"; + +/** DADA names every aggregated asset with this prefix. */ +const META_CURRENCY_PREFIX = "urn:crypto:meta-currency:"; + +export const metaCurrencyId = (slug: string) => `${META_CURRENCY_PREFIX}${slug}`; + +/** The per-network token backing a category asset, when it has one. */ +type TokenSpec = { + /** Network id, e.g. `"solana"`. Also added to `networks`. */ + network: string; + /** Display name of the network, defaults to the capitalised id. */ + networkName?: string; + /** Token standard, e.g. `"spl"`, `"erc20"`. */ + tokenType: string; + contractAddress: string; + magnitude?: number; +}; + +export type CategoryAssetSpec = { + ticker: string; + /** Display name, defaults to the ticker. */ + name?: string; + /** Id slug, defaults to the lower-cased ticker. */ + slug?: string; + token?: TokenSpec; + market?: PartialMarketItemResponse; +}; + +/** + * Builds a DADA category response from a list of assets, so a new category is a list rather than + * another hand-written fixture. + * + * Everything not described by the specs comes out empty, which is what the category endpoints + * actually return: they page server-side and keep one field per asset. + */ +export function buildCategoryResponse(assets: CategoryAssetSpec[]) { + const resolved = assets.map(asset => { + const slug = asset.slug ?? asset.ticker.toLowerCase(); + return { ...asset, slug, id: metaCurrencyId(slug), name: asset.name ?? asset.ticker }; + }); + + const networks: Record = {}; + const cryptoOrTokenCurrencies: Record = {}; + const markets: Record = {}; + + for (const asset of resolved) { + if (asset.market) markets[asset.id] = asset.market; + if (!asset.token) continue; + + const { network, networkName, tokenType, contractAddress, magnitude = 8 } = asset.token; + networks[network] = { + id: network, + name: networkName ?? network[0].toUpperCase() + network.slice(1), + }; + + const tokenId = `${network}/${tokenType}/${asset.slug}`; + cryptoOrTokenCurrencies[tokenId] = { + type: "TokenCurrency" as const, + id: TokenCurrencyIdSchema.parse(tokenId), + name: asset.name, + ticker: asset.ticker, + contractAddress, + parentCurrencyId: CryptoCurrencyIdSchema.parse(network), + tokenType, + units: [{ name: asset.ticker, code: asset.ticker, magnitude }], + }; + } + + return { + cryptoAssets: Object.fromEntries( + resolved.map(asset => [ + asset.id, + { + id: asset.id, + ticker: asset.ticker, + name: asset.name, + assetsIds: asset.token + ? { + [asset.token.network]: + `${asset.token.network}/${asset.token.tokenType}/${asset.slug}`, + } + : {}, + }, + ]), + ), + networks, + cryptoOrTokenCurrencies, + interestRates: {}, + markets, + currenciesOrder: { + key: "marketCap", + order: "desc", + metaCurrencyIds: resolved.map(asset => asset.id), + }, + }; +} diff --git a/domain/api/aggregated-assets/src/fixtures/categoryResponse.test.ts b/domain/api/aggregated-assets/src/fixtures/categoryResponse.test.ts new file mode 100644 index 000000000000..4327bcf317c5 --- /dev/null +++ b/domain/api/aggregated-assets/src/fixtures/categoryResponse.test.ts @@ -0,0 +1,135 @@ +import { buildCategoryResponse, metaCurrencyId } from "./categoryResponse.mock"; + +describe("metaCurrencyId", () => { + it("applies the DADA meta-currency prefix", () => { + expect(metaCurrencyId("usdt")).toBe("urn:crypto:meta-currency:usdt"); + }); +}); + +describe("buildCategoryResponse", () => { + it("returns every collection empty for no assets", () => { + expect(buildCategoryResponse([])).toEqual({ + cryptoAssets: {}, + networks: {}, + cryptoOrTokenCurrencies: {}, + interestRates: {}, + markets: {}, + currenciesOrder: { key: "marketCap", order: "desc", metaCurrencyIds: [] }, + }); + }); + + describe("a ticker-only asset, which is all the stablecoins list needs", () => { + const response = buildCategoryResponse([{ ticker: "USDT" }]); + + it("derives the slug from the lower-cased ticker", () => { + expect(response.cryptoAssets["urn:crypto:meta-currency:usdt"]).toEqual({ + id: "urn:crypto:meta-currency:usdt", + ticker: "USDT", + name: "USDT", + assetsIds: {}, + }); + }); + + it("adds no network, currency or market entry", () => { + expect(response.networks).toEqual({}); + expect(response.cryptoOrTokenCurrencies).toEqual({}); + expect(response.markets).toEqual({}); + }); + + it("lists the asset in currenciesOrder", () => { + expect(response.currenciesOrder.metaCurrencyIds).toEqual(["urn:crypto:meta-currency:usdt"]); + }); + }); + + describe("a tokenised asset", () => { + const response = buildCategoryResponse([ + { + ticker: "AAPLX", + slug: "applex", + name: "Apple xStock", + token: { + network: "solana", + tokenType: "spl", + contractAddress: "XsAAPL", + }, + }, + ]); + + it("points assetsIds at the token id", () => { + expect(response.cryptoAssets["urn:crypto:meta-currency:applex"].assetsIds).toEqual({ + solana: "solana/spl/applex", + }); + }); + + it("builds the token currency under the same id", () => { + expect(response.cryptoOrTokenCurrencies["solana/spl/applex"]).toEqual({ + type: "TokenCurrency", + id: "solana/spl/applex", + name: "Apple xStock", + ticker: "AAPLX", + contractAddress: "XsAAPL", + parentCurrencyId: "solana", + tokenType: "spl", + units: [{ name: "AAPLX", code: "AAPLX", magnitude: 8 }], + }); + }); + + it("registers the network with a capitalised name", () => { + expect(response.networks).toEqual({ solana: { id: "solana", name: "Solana" } }); + }); + }); + + it("honours an explicit slug over the ticker", () => { + const response = buildCategoryResponse([{ ticker: "TSLAX", slug: "teslax" }]); + + expect(Object.keys(response.cryptoAssets)).toEqual(["urn:crypto:meta-currency:teslax"]); + }); + + it("honours an explicit network name and magnitude", () => { + const response = buildCategoryResponse([ + { + ticker: "USDC", + token: { + network: "bsc", + networkName: "Binance Smart Chain", + tokenType: "bep20", + contractAddress: "0x0", + magnitude: 18, + }, + }, + ]); + + expect(response.networks.bsc.name).toBe("Binance Smart Chain"); + expect(response.cryptoOrTokenCurrencies["bsc/bep20/usdc"]).toMatchObject({ + units: [{ name: "USDC", code: "USDC", magnitude: 18 }], + }); + }); + + it("keys markets by meta-currency id", () => { + const response = buildCategoryResponse([ + { ticker: "AAPLX", slug: "applex", market: { price: 1 } }, + ]); + + expect(response.markets).toEqual({ "urn:crypto:meta-currency:applex": { price: 1 } }); + }); + + it("deduplicates a shared network across assets", () => { + const token = { network: "solana", tokenType: "spl", contractAddress: "0x0" }; + const response = buildCategoryResponse([ + { ticker: "AAPLX", token }, + { ticker: "TSLAX", token }, + ]); + + expect(Object.keys(response.networks)).toEqual(["solana"]); + expect(Object.keys(response.cryptoOrTokenCurrencies)).toHaveLength(2); + }); + + it("preserves the given order in currenciesOrder", () => { + const response = buildCategoryResponse([{ ticker: "B" }, { ticker: "A" }]); + + expect(response.currenciesOrder.metaCurrencyIds).toEqual([ + "urn:crypto:meta-currency:b", + "urn:crypto:meta-currency:a", + ]); + }); +}); diff --git a/domain/api/aggregated-assets/src/fixtures/stablecoins.mock.ts b/domain/api/aggregated-assets/src/fixtures/stablecoins.mock.ts new file mode 100644 index 000000000000..84d17442dfa0 --- /dev/null +++ b/domain/api/aggregated-assets/src/fixtures/stablecoins.mock.ts @@ -0,0 +1,32 @@ +import { buildCategoryResponse } from "./categoryResponse.mock"; + +const STABLECOIN_TICKERS = [ + "USDT", + "USDC", + "USDS", + "USDE", + "DAI", + "USD1", + "PYUSD", + "PAXG", + "USDG", + "RLUSD", + "USDD", + "TUSD", + "EURC", + "FDUSD", + "CRVUSD", + "FRAX", + "AUSD", + "BUSD", + "EURI", + "GUSD", +]; + +/* + * The category endpoints keep one field per asset, so tickers alone are enough here: the + * stablecoins list is consumed as tickers and never resolved to currencies. + */ +export const mockStablecoinsResponse = buildCategoryResponse( + STABLECOIN_TICKERS.map(ticker => ({ ticker })), +); diff --git a/domain/api/aggregated-assets/src/fixtures/stocks.mock.ts b/domain/api/aggregated-assets/src/fixtures/stocks.mock.ts new file mode 100644 index 000000000000..0d4a8af3cf8d --- /dev/null +++ b/domain/api/aggregated-assets/src/fixtures/stocks.mock.ts @@ -0,0 +1,26 @@ +import { buildCategoryResponse } from "./categoryResponse.mock"; + +export const mockStocksResponse = buildCategoryResponse([ + { + ticker: "AAPLX", + slug: "applex", + name: "Apple xStock", + token: { + network: "solana", + tokenType: "spl", + contractAddress: "XsAAPL000000000000000000000000000000000000", + }, + market: { price: 226.4, marketCap: 3_400_000_000_000, priceChangePercentage24h: 1.2 }, + }, + { + ticker: "TSLAX", + slug: "teslax", + name: "Tesla xStock", + token: { + network: "solana", + tokenType: "spl", + contractAddress: "XsTSLA000000000000000000000000000000000000", + }, + market: { price: 248.5, marketCap: 790_000_000_000, priceChangePercentage24h: -0.8 }, + }, +]); diff --git a/domain/api/aggregated-assets/src/index.ts b/domain/api/aggregated-assets/src/index.ts index cb0ff5c3b541..ec29e09860c5 100644 --- a/domain/api/aggregated-assets/src/index.ts +++ b/domain/api/aggregated-assets/src/index.ts @@ -1 +1,9 @@ -export {}; +export * from "./constants"; +export * from "./schema"; +export * from "./types"; +export * from "./transforms"; +export * from "./requests"; +export * from "./accessors"; +export * from "./api"; +export * from "./errors"; +export * from "./pagination"; diff --git a/domain/api/aggregated-assets/src/internals/collectAllByCategory.test.ts b/domain/api/aggregated-assets/src/internals/collectAllByCategory.test.ts new file mode 100644 index 000000000000..cb6f556df2c8 --- /dev/null +++ b/domain/api/aggregated-assets/src/internals/collectAllByCategory.test.ts @@ -0,0 +1,155 @@ +import { collectAllByCategory } from "./collectAllByCategory"; +import { AssetCategory, type GetAssetsByCategoryParams } from "../types"; +import type { RawApiResponse } from "../schema"; + +/* + * `@shared/env` is mocked because the Domain Test CI job installs only ./domain/** and ./shared/**, + * which leaves the underlying @ledgerhq/live-env unresolvable. + */ +jest.mock("@shared/env", () => ({ + getEnv: jest.fn((name: string) => + name === "DADA_API_STAGING" + ? "https://dada.api.ledger-test.com/v1" + : "https://dada.api.ledger.com/v1", + ), +})); + +const queryArg: GetAssetsByCategoryParams = { + category: AssetCategory.Stocks, + product: "llm", + version: "1.0.0", +}; + +const rawWith = (tickers: string[]): RawApiResponse => ({ + cryptoAssets: Object.fromEntries( + tickers.map(t => [t, { id: t, ticker: t.toUpperCase(), name: t, assetsIds: {} }]), + ), + networks: {}, + cryptoOrTokenCurrencies: {}, + interestRates: {}, + markets: {}, + currenciesOrder: { key: "marketCap", order: "desc", metaCurrencyIds: [] }, +}); + +const tickersOf = (data: RawApiResponse) => Object.values(data.cryptoAssets).map(a => a.ticker); + +function page(body: RawApiResponse, nextCursor?: string): Response { + const headers = new Headers(); + if (nextCursor) headers.set("x-ledger-next", nextCursor); + return new Response(JSON.stringify(body), { status: 200, headers }); +} + +describe("collectAllByCategory", () => { + let fetchSpy: jest.SpyInstance; + + const urls = () => fetchSpy.mock.calls.map(c => new URL(c[0] as string)); + + beforeEach(() => { + fetchSpy = jest.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("returns the projection of a single page", async () => { + fetchSpy.mockResolvedValueOnce(page(rawWith(["aaplx"]))); + + const result = await collectAllByCategory(queryArg, tickersOf); + + expect(result).toEqual({ data: ["AAPLX"] }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("sends the category, product, version and a fixed page size", async () => { + fetchSpy.mockResolvedValueOnce(page(rawWith([]))); + + await collectAllByCategory(queryArg, tickersOf); + + const url = urls()[0]; + expect(url.pathname).toBe("/v1/assets"); + expect(url.searchParams.get("categories")).toBe("stocks"); + expect(url.searchParams.get("product")).toBe("llm"); + expect(url.searchParams.get("minVersion")).toBe("1.0.0"); + expect(url.searchParams.get("pageSize")).toBe("100"); + }); + + it("does not send a cursor on the first request", async () => { + fetchSpy.mockResolvedValueOnce(page(rawWith([]))); + + await collectAllByCategory(queryArg, tickersOf); + + expect(urls()[0].searchParams.has("cursor")).toBe(false); + }); + + it("walks every page and concatenates the projections in order", async () => { + fetchSpy + .mockResolvedValueOnce(page(rawWith(["aaplx"]), "cursor-2")) + .mockResolvedValueOnce(page(rawWith(["teslax"]), "cursor-3")) + .mockResolvedValueOnce(page(rawWith(["nvdax"]))); + + const result = await collectAllByCategory(queryArg, tickersOf); + + expect(result).toEqual({ data: ["AAPLX", "TESLAX", "NVDAX"] }); + expect(urls().map(u => u.searchParams.get("cursor"))).toEqual([null, "cursor-2", "cursor-3"]); + }); + + it("stops when the next-cursor header is absent", async () => { + fetchSpy + .mockResolvedValueOnce(page(rawWith(["aaplx"]), "cursor-2")) + .mockResolvedValueOnce(page(rawWith(["teslax"]))); + + await collectAllByCategory(queryArg, tickersOf); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("targets staging when asked", async () => { + fetchSpy.mockResolvedValueOnce(page(rawWith([]))); + + await collectAllByCategory({ ...queryArg, isStaging: true }, tickersOf); + + expect(urls()[0].hostname).toBe("dada.api.ledger-test.com"); + }); + + it("returns the http status as an error and stops walking", async () => { + fetchSpy.mockResolvedValueOnce( + new Response("nope", { status: 500, statusText: "Internal Server Error" }), + ); + + const result = await collectAllByCategory(queryArg, tickersOf); + + expect(result.error).toEqual({ + status: 500, + data: "Failed to fetch assets by category: Internal Server Error", + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("discards pages already collected when a later page fails", async () => { + fetchSpy + .mockResolvedValueOnce(page(rawWith(["aaplx"]), "cursor-2")) + .mockResolvedValueOnce(new Response("nope", { status: 502, statusText: "Bad Gateway" })); + + const result = await collectAllByCategory(queryArg, tickersOf); + + expect(result.data).toBeUndefined(); + expect(result.error).toMatchObject({ status: 502 }); + }); + + it("maps a thrown network failure to a FETCH_ERROR", async () => { + fetchSpy.mockRejectedValueOnce(new Error("offline")); + + const result = await collectAllByCategory(queryArg, tickersOf); + + expect(result.error).toEqual({ status: "FETCH_ERROR", error: "offline" }); + }); + + it("maps a non-Error rejection to a FETCH_ERROR", async () => { + fetchSpy.mockRejectedValueOnce("just a string"); + + const result = await collectAllByCategory(queryArg, tickersOf); + + expect(result.error).toEqual({ status: "FETCH_ERROR", error: "Unknown error" }); + }); +}); diff --git a/domain/api/aggregated-assets/src/internals/collectAllByCategory.ts b/domain/api/aggregated-assets/src/internals/collectAllByCategory.ts new file mode 100644 index 000000000000..39a9431d4bd9 --- /dev/null +++ b/domain/api/aggregated-assets/src/internals/collectAllByCategory.ts @@ -0,0 +1,61 @@ +import type { + FetchBaseQueryError, + FetchBaseQueryMeta, + QueryReturnValue, +} from "@reduxjs/toolkit/query/react"; +import type { RawApiResponse } from "../schema"; +import type { GetAssetsByCategoryParams } from "../types"; +import { assertDadaApiUrl } from "./utils"; +import { resolveBaseUrl } from "./requests"; + +/** + * Walks every page of a category and collects one projection per asset. + * + * Internal: both public category accessors share it, nothing outside this package should call it. + */ +export async function collectAllByCategory( + queryArg: GetAssetsByCategoryParams, + extract: (data: RawApiResponse) => string[], +): Promise> { + try { + const baseUrl = resolveBaseUrl(queryArg); + const collected: string[] = []; + let cursor: string | undefined; + + do { + const url = new URL(`${baseUrl}/assets`); + url.searchParams.set("categories", queryArg.category); + url.searchParams.set("product", queryArg.product); + url.searchParams.set("pageSize", "100"); + url.searchParams.set("minVersion", queryArg.version); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + + assertDadaApiUrl(url); + const response = await fetch(url.toString()); + + if (!response.ok) { + return { + error: { + status: response.status, + data: `Failed to fetch assets by category: ${response.statusText}`, + }, + }; + } + + const data: RawApiResponse = await response.json(); + collected.push(...extract(data)); + cursor = response.headers.get("x-ledger-next") || undefined; + } while (cursor); + + return { data: collected }; + } catch (error) { + return { + error: { + status: "FETCH_ERROR", + error: error instanceof Error ? error.message : "Unknown error", + }, + }; + } +} diff --git a/domain/api/aggregated-assets/src/internals/market.test.ts b/domain/api/aggregated-assets/src/internals/market.test.ts new file mode 100644 index 000000000000..1dfc2923d9d4 --- /dev/null +++ b/domain/api/aggregated-assets/src/internals/market.test.ts @@ -0,0 +1,36 @@ +import { dadaIdToMarketId } from "./market"; + +describe("dadaIdToMarketId", () => { + it("returns a plain crypto id unchanged", () => { + expect(dadaIdToMarketId("bitcoin")).toBe("bitcoin"); + }); + + it("keeps the last segment of a token id", () => { + expect(dadaIdToMarketId("ethereum:erc20:usd_tether")).toBe("usd-tether"); + }); + + it("converts underscores to hyphens, which is what the market api expects", () => { + expect(dadaIdToMarketId("solana:spl:jupiter_perps_lp")).toBe("jupiter-perps-lp"); + }); + + it("leaves an id without underscores alone", () => { + expect(dadaIdToMarketId("ethereum:erc20:dai")).toBe("dai"); + }); + + it("does not touch underscores when there is no separator", () => { + expect(dadaIdToMarketId("usd_tether")).toBe("usd_tether"); + }); + + /* + * Characterizing a sharp edge rather than endorsing it: the `?? id` fallback only catches + * null/undefined, and `"".split(":").pop()` is `""`, so a trailing separator yields an empty + * market id instead of the original. + */ + it("yields an empty id for a trailing separator", () => { + expect(dadaIdToMarketId("ethereum:erc20:")).toBe(""); + }); + + it("returns an empty string unchanged", () => { + expect(dadaIdToMarketId("")).toBe(""); + }); +}); diff --git a/domain/api/aggregated-assets/src/internals/market.ts b/domain/api/aggregated-assets/src/internals/market.ts new file mode 100644 index 000000000000..508e94f2110d --- /dev/null +++ b/domain/api/aggregated-assets/src/internals/market.ts @@ -0,0 +1,51 @@ +/* + * Copied from libs/ledger-live-common/src/market/utils rather than imported: a domain/* package + * must not import legacy libs/*. + * + * TODO: replace with a real market entity when one exists. Until then note the drift risk — + * every field of PartialMarketItemResponse is optional, so divergence from the original will + * never produce a type error. + */ + +export type MarketItemResponse = { + allTimeHigh: number; + allTimeHighDate: string; + allTimeLow: number; + allTimeLowDate: string; + circulatingSupply: number; + fullyDilutedValuation: number; + high24h: number; + currencyId: string; + id: string; + image: string; + ledgerIds: string[]; + low24h: number; + marketCap: number; + marketCapChange24h: number; + marketCapChangePercentage24h: number; + marketCapRank: number; + maxSupply: number; + name: string; + price: number; + priceChange24h: number; + priceChangePercentage1h: number; + priceChangePercentage24h: number; + priceChangePercentage30d: number; + priceChangePercentage7d: number; + priceChangePercentage6m?: number; + priceChangePercentage1y: number; + sparkline: number[]; + ticker: string; + totalSupply: number; + totalVolume: number; + updatedAt: string; +}; + +export type PartialMarketItemResponse = Partial; + +/** Maps an aggregated-asset id such as "ethereum:erc20:usd_tether" to its market id. */ +export function dadaIdToMarketId(id: string): string { + if (!id.includes(":")) return id; + const lastSegment = id.split(":").pop(); + return lastSegment?.replaceAll("_", "-") ?? id; +} diff --git a/domain/api/aggregated-assets/src/internals/requests.test.ts b/domain/api/aggregated-assets/src/internals/requests.test.ts new file mode 100644 index 000000000000..b4403ef8ba9d --- /dev/null +++ b/domain/api/aggregated-assets/src/internals/requests.test.ts @@ -0,0 +1,121 @@ +import { fetchAssetsPage, resolveBaseUrl } from "./requests"; +import type { GetAssetsDataParams } from "../types"; +import type { RawApiResponse } from "../schema"; + +/* + * `@shared/env` is mocked because the Domain Test CI job installs only ./domain/** and ./shared/**, + * which leaves the underlying @ledgerhq/live-env unresolvable. + */ +jest.mock("@shared/env", () => ({ + getEnv: jest.fn((name: string) => + name === "DADA_API_STAGING" + ? "https://dada.api.ledger-test.com/v1" + : "https://dada.api.ledger.com/v1", + ), +})); + +const params = (overrides: Partial = {}): GetAssetsDataParams => ({ + product: "llm", + version: "1.0.0", + ...overrides, +}); + +const emptyRaw: RawApiResponse = { + cryptoAssets: {}, + networks: {}, + cryptoOrTokenCurrencies: {}, + interestRates: {}, + markets: {}, + currenciesOrder: { key: "marketCap", order: "desc", metaCurrencyIds: [] }, +}; + +describe("resolveBaseUrl", () => { + it("uses the prod url by default", () => { + expect(resolveBaseUrl({})).toBe("https://dada.api.ledger.com/v1"); + }); + + it("uses the prod url when isStaging is false", () => { + expect(resolveBaseUrl({ isStaging: false })).toBe("https://dada.api.ledger.com/v1"); + }); + + it("uses the staging url when isStaging is true", () => { + expect(resolveBaseUrl({ isStaging: true })).toBe("https://dada.api.ledger-test.com/v1"); + }); +}); + +describe("fetchAssetsPage", () => { + const baseUrl = "https://dada.api.ledger.com/v1"; + let fetchSpy: jest.SpyInstance; + + const respondWith = (body: unknown, init?: ResponseInit) => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify(body), { status: 200, ...init })); + }; + + const requestedUrl = () => new URL(fetchSpy.mock.calls[0][0] as string); + + beforeEach(() => { + fetchSpy = jest.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("targets the /assets path on the given base url", async () => { + respondWith(emptyRaw); + + await fetchAssetsPage(baseUrl, params()); + + expect(requestedUrl().pathname).toBe("/v1/assets"); + }); + + it("serialises the query params onto the url", async () => { + respondWith(emptyRaw); + + await fetchAssetsPage(baseUrl, params({ currencyIds: ["bitcoin", "ethereum"] })); + + const url = requestedUrl(); + expect(url.searchParams.get("currencyIds")).toBe("bitcoin,ethereum"); + expect(url.searchParams.get("product")).toBe("llm"); + expect(url.searchParams.get("minVersion")).toBe("1.0.0"); + expect(url.searchParams.get("pageSize")).toBe("100"); + }); + + it("omits undefined params rather than sending the string 'undefined'", async () => { + respondWith(emptyRaw); + + await fetchAssetsPage(baseUrl, params()); + + expect(requestedUrl().searchParams.has("search")).toBe(false); + }); + + it("returns the response with currencies converted", async () => { + respondWith(emptyRaw); + + const result = await fetchAssetsPage(baseUrl, params()); + + expect(result.cryptoOrTokenCurrencies).toEqual({}); + expect(result.currenciesOrder).toEqual(emptyRaw.currenciesOrder); + }); + + it("throws with the status when the response is not ok", async () => { + fetchSpy.mockResolvedValue( + new Response("nope", { status: 503, statusText: "Service Unavailable" }), + ); + + await expect(fetchAssetsPage(baseUrl, params())).rejects.toThrow( + "DADA fetch failed: 503 Service Unavailable", + ); + }); + + /* + * This endpoint builds its own url instead of going through `baseQuery`, so the host guard is + * the only thing standing between a mis-resolved base url and a request to another host. + */ + it("refuses to fetch from an untrusted host", async () => { + await expect(fetchAssetsPage("https://evil.example.com", params())).rejects.toThrow( + "Blocked request to untrusted host: evil.example.com", + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/domain/api/aggregated-assets/src/internals/requests.ts b/domain/api/aggregated-assets/src/internals/requests.ts new file mode 100644 index 000000000000..4d12421e1f31 --- /dev/null +++ b/domain/api/aggregated-assets/src/internals/requests.ts @@ -0,0 +1,52 @@ +import { getEnv } from "@shared/env"; +import type { RawApiResponse } from "../schema"; +import type { AssetsData, GetAssetsDataParams } from "../types"; +import { convertApiAssets } from "../transforms"; +import { buildAssetsQueryParams } from "../requests"; +import { assertDadaApiUrl } from "./utils"; + +/** + * Picks prod or staging per request. + * + * Exists only because the shared DADA base query is configured with `baseUrl: ""`, so every + * endpoint has to resolve its own absolute url. LIVE-35301 moves url ownership to + * `@shared/api-services` via `extraArgument`, which removes the need for this. + */ +export function resolveBaseUrl(queryArg: { isStaging?: boolean }): string { + return queryArg.isStaging ? getEnv("DADA_API_STAGING") : getEnv("DADA_API_PROD"); +} + +/** + * One page for one chunk of currency ids. Used by the chunked lookup endpoint. + * + * Hand-rolls `fetch` instead of going through the base query, which is why `assertDadaApiUrl` is + * needed and why RTK's `AbortSignal` never reaches the request. `queryFn` receives `baseQuery` as + * its fourth argument, so the fan-out could use it — see LIVE-35301. + */ +export async function fetchAssetsPage( + baseUrl: string, + queryArg: GetAssetsDataParams, +): Promise { + const params = buildAssetsQueryParams(queryArg); + const url = new URL(`${baseUrl}/assets`); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, Array.isArray(value) ? value.join(",") : String(value)); + } + } + + assertDadaApiUrl(url); + const response = await fetch(url.toString()); + + if (!response.ok) { + throw new Error(`DADA fetch failed: ${response.status} ${response.statusText}`); + } + + const raw: RawApiResponse = await response.json(); + const enrichedCryptoOrTokenCurrencies = convertApiAssets(raw.cryptoOrTokenCurrencies); + + return { + ...raw, + cryptoOrTokenCurrencies: enrichedCryptoOrTokenCurrencies, + }; +} diff --git a/domain/api/aggregated-assets/src/internals/utils.test.ts b/domain/api/aggregated-assets/src/internals/utils.test.ts new file mode 100644 index 000000000000..24dc91a4c08b --- /dev/null +++ b/domain/api/aggregated-assets/src/internals/utils.test.ts @@ -0,0 +1,237 @@ +import type { CryptoAssetMeta } from "@domain/entity-aggregated-asset"; +import { + allSettled, + assertDadaApiUrl, + chunkCurrencyIds, + deepMergeCryptoAssets, + emptyAssetsData, +} from "./utils"; + +const makeIds = (n: number, prefix = "cur") => Array.from({ length: n }, (_, i) => `${prefix}${i}`); + +describe("chunkCurrencyIds", () => { + it("should return an empty array when given no IDs", () => { + expect(chunkCurrencyIds([])).toEqual([]); + }); + + it("should return a single chunk with one ID", () => { + expect(chunkCurrencyIds(["bitcoin"])).toEqual([["bitcoin"]]); + }); + + it("should keep 25 IDs in one chunk with default size", () => { + const ids = makeIds(25); + expect(chunkCurrencyIds(ids).map(c => c.length)).toEqual([25]); + }); + + it("should split 75 IDs into 3 chunks of 25", () => { + const ids = makeIds(75); + const chunks = chunkCurrencyIds(ids); + + expect(chunks.map(c => c.length)).toEqual([25, 25, 25]); + expect(chunks.flat()).toEqual(ids); + }); + + it("should handle a non-divisible count (last chunk smaller)", () => { + const ids = makeIds(30); + const chunks = chunkCurrencyIds(ids); + + expect(chunks.map(c => c.length)).toEqual([25, 5]); + expect(chunks.flat()).toEqual(ids); + }); + + it("should respect a custom chunk size", () => { + const ids = makeIds(10, "id"); + const chunks = chunkCurrencyIds(ids, 3); + + expect(chunks.map(c => c.length)).toEqual([3, 3, 3, 1]); + }); + + it.each([0, -1, NaN, Infinity])("should throw RangeError for invalid size %s", size => { + expect(() => chunkCurrencyIds(["a"], size)).toThrow(RangeError); + }); +}); + +type MetaMap = Record; + +const makeMeta = (id: string, assetsIds: Record): CryptoAssetMeta => ({ + id, + ticker: id.toUpperCase(), + name: id, + assetsIds, +}); + +describe("deepMergeCryptoAssets", () => { + it("should add new meta-currencies from source", () => { + const target: MetaMap = {}; + deepMergeCryptoAssets(target, { eth: makeMeta("eth", { ethereum: "ethereum" }) }); + + expect(target.eth.assetsIds).toEqual({ ethereum: "ethereum" }); + }); + + it("should merge assetsIds when same meta-currency exists in both", () => { + const target: MetaMap = { eth: makeMeta("eth", { ethereum: "ethereum" }) }; + deepMergeCryptoAssets(target, { eth: makeMeta("eth", { arbitrum: "arbitrum", base: "base" }) }); + + expect(target.eth.assetsIds).toEqual({ + ethereum: "ethereum", + arbitrum: "arbitrum", + base: "base", + }); + }); + + it("should overwrite assetsIds entries from source", () => { + const target: MetaMap = { eth: makeMeta("eth", { ethereum: "ethereum-v1" }) }; + deepMergeCryptoAssets(target, { + eth: makeMeta("eth", { ethereum: "ethereum-v2", optimism: "optimism" }), + }); + + expect(target.eth.assetsIds.ethereum).toBe("ethereum-v2"); + expect(target.eth.assetsIds.optimism).toBe("optimism"); + }); + + it("should handle both new and existing meta-currencies in one call", () => { + const target: MetaMap = { eth: makeMeta("eth", { ethereum: "ethereum" }) }; + deepMergeCryptoAssets(target, { + eth: makeMeta("eth", { arbitrum: "arbitrum" }), + btc: makeMeta("btc", { bitcoin: "bitcoin" }), + }); + + expect(Object.keys(target)).toEqual(["eth", "btc"]); + expect(target.eth.assetsIds).toEqual({ ethereum: "ethereum", arbitrum: "arbitrum" }); + expect(target.btc.assetsIds).toEqual({ bitcoin: "bitcoin" }); + }); + + it("should be a no-op for empty source", () => { + const target: MetaMap = { eth: makeMeta("eth", { ethereum: "ethereum" }) }; + deepMergeCryptoAssets(target, {}); + + expect(target.eth.assetsIds).toEqual({ ethereum: "ethereum" }); + }); + + it("should populate empty target from source", () => { + const target: MetaMap = {}; + const source = { btc: makeMeta("btc", { bitcoin: "bitcoin" }) }; + deepMergeCryptoAssets(target, source); + + expect(target.btc).toEqual(source.btc); + }); +}); + +describe("emptyAssetsData", () => { + it("returns every collection, all empty", () => { + expect(emptyAssetsData()).toEqual({ + cryptoAssets: {}, + networks: {}, + cryptoOrTokenCurrencies: {}, + interestRates: {}, + markets: {}, + currenciesOrder: { metaCurrencyIds: [], key: "", order: "" }, + }); + }); + + /* + * Load-bearing: the chunked lookup endpoint uses this as a reduce seed and then mutates the + * accumulator in place. A shared instance would leak merged assets between queries. + */ + it("returns a fresh object on every call", () => { + const first = emptyAssetsData(); + const second = emptyAssetsData(); + + expect(first).not.toBe(second); + expect(first.cryptoAssets).not.toBe(second.cryptoAssets); + expect(first.currenciesOrder).not.toBe(second.currenciesOrder); + expect(first.currenciesOrder.metaCurrencyIds).not.toBe(second.currenciesOrder.metaCurrencyIds); + }); + + it("is not affected by mutating a previous result", () => { + const first = emptyAssetsData(); + first.cryptoAssets.btc = { id: "btc", ticker: "BTC", name: "Bitcoin", assetsIds: {} }; + first.currenciesOrder.metaCurrencyIds.push("btc"); + + expect(emptyAssetsData().cryptoAssets).toEqual({}); + expect(emptyAssetsData().currenciesOrder.metaCurrencyIds).toEqual([]); + }); +}); + +describe("assertDadaApiUrl", () => { + it.each(["https://dada.api.ledger.com/assets", "https://dada.api.ledger-test.com/assets"])( + "allows the known DADA host %s", + href => { + expect(() => assertDadaApiUrl(new URL(href))).not.toThrow(); + }, + ); + + /* + * This guards the endpoints that build their own `fetch` instead of going through `baseQuery`, + * so a mis-resolved base url cannot send the request to another host. + */ + it.each([ + "https://evil.example.com/assets", + "https://dada.api.ledger.com.evil.example.com/assets", + "https://ledger.com/assets", + ])("blocks %s", href => { + expect(() => assertDadaApiUrl(new URL(href))).toThrow(/untrusted host/); + }); + + it("names the rejected hostname in the error", () => { + expect(() => assertDadaApiUrl(new URL("https://evil.example.com/assets"))).toThrow( + "Blocked request to untrusted host: evil.example.com", + ); + }); + + it("matches on hostname only, ignoring port and protocol", () => { + expect(() => assertDadaApiUrl(new URL("http://dada.api.ledger.com:8080/x"))).not.toThrow(); + }); +}); + +describe("allSettled", () => { + it("resolves an empty list", async () => { + await expect(allSettled([])).resolves.toEqual([]); + }); + + it("reports fulfilled values in input order", async () => { + const results = await allSettled([Promise.resolve("a"), Promise.resolve("b")]); + + expect(results).toEqual([ + { status: "fulfilled", value: "a" }, + { status: "fulfilled", value: "b" }, + ]); + }); + + /* + * Load-bearing: the chunked lookup endpoint returns partial results when a chunk fails, and + * portfolio distribution depends on that. A rejection must never reject the batch. + */ + it("does not reject when one promise rejects", async () => { + const boom = new Error("boom"); + const results = await allSettled([ + Promise.resolve("ok"), + Promise.reject(boom), + Promise.resolve("also ok"), + ]); + + expect(results).toEqual([ + { status: "fulfilled", value: "ok" }, + { status: "rejected", reason: boom }, + { status: "fulfilled", value: "also ok" }, + ]); + }); + + it("reports every rejection when all fail", async () => { + const results = await allSettled([Promise.reject("x"), Promise.reject("y")]); + + expect(results.every(r => r.status === "rejected")).toBe(true); + }); + + it("preserves input order regardless of settle timing", async () => { + const slow = new Promise(resolve => setTimeout(() => resolve("slow"), 10)); + const fast = Promise.resolve("fast"); + + const results = await allSettled([slow, fast]); + + expect(results.map(r => (r.status === "fulfilled" ? r.value : r.reason))).toEqual([ + "slow", + "fast", + ]); + }); +}); diff --git a/domain/api/aggregated-assets/src/internals/utils.ts b/domain/api/aggregated-assets/src/internals/utils.ts new file mode 100644 index 000000000000..7200ff45a319 --- /dev/null +++ b/domain/api/aggregated-assets/src/internals/utils.ts @@ -0,0 +1,78 @@ +import type { CryptoAssetMeta } from "@domain/entity-aggregated-asset"; +import type { AssetsData } from "../types"; + +type SettledResult = { status: "fulfilled"; value: T } | { status: "rejected"; reason: unknown }; + +/** + * Local `Promise.allSettled`, so a failed chunk does not reject the whole batch. + * + * Internal: the chunked lookup endpoint's partial-result tolerance depends on it. + */ +export function allSettled(promises: Promise[]): Promise[]> { + return Promise.all( + promises.map(p => + p + .then(value => ({ status: "fulfilled" as const, value })) + .catch(reason => ({ status: "rejected" as const, reason })), + ), + ); +} + +const ALLOWED_DADA_HOSTS = new Set(["dada.api.ledger.com", "dada.api.ledger-test.com"]); + +/** Guards endpoints that issue their own `fetch` against a mis-resolved base url. */ +export function assertDadaApiUrl(url: URL): void { + if (!ALLOWED_DADA_HOSTS.has(url.hostname)) { + throw new Error(`Blocked request to untrusted host: ${url.hostname}`); + } +} + +export type CurrencyIdChunks = string[][]; + +const DEFAULT_CHUNK_SIZE = 25; + +export function chunkCurrencyIds( + ids: string[], + size: number = DEFAULT_CHUNK_SIZE, +): CurrencyIdChunks { + if (!Number.isFinite(size) || size <= 0) { + throw new RangeError(`chunkCurrencyIds: size must be a positive finite number, got ${size}`); + } + + const chunks: CurrencyIdChunks = []; + + for (let i = 0; i < ids.length; i += size) { + chunks.push(ids.slice(i, i + size)); + } + + return chunks; +} + +/** + * Deep-merges two `cryptoAssets` maps so that `assetsIds` entries + * from different pages/chunks are accumulated instead of overwritten. + */ +export function deepMergeCryptoAssets( + target: Record, + source: Record, +): void { + for (const [metaId, meta] of Object.entries(source)) { + if (target[metaId]) { + Object.assign(target[metaId].assetsIds, meta.assetsIds); + } else { + target[metaId] = { ...meta, assetsIds: { ...meta.assetsIds } }; + } + } +} + +/** The zero value of an aggregated-assets response: every collection present but empty. */ +export function emptyAssetsData(): AssetsData { + return { + cryptoAssets: {}, + networks: {}, + cryptoOrTokenCurrencies: {}, + interestRates: {}, + markets: {}, + currenciesOrder: { metaCurrencyIds: [], key: "", order: "" }, + }; +} diff --git a/libs/ledger-live-common/src/dada-client/utils/__test__/mergeAssetsDataPages.test.ts b/domain/api/aggregated-assets/src/pagination.test.ts similarity index 97% rename from libs/ledger-live-common/src/dada-client/utils/__test__/mergeAssetsDataPages.test.ts rename to domain/api/aggregated-assets/src/pagination.test.ts index a7b5bb18a988..9c01c75e1924 100644 --- a/libs/ledger-live-common/src/dada-client/utils/__test__/mergeAssetsDataPages.test.ts +++ b/domain/api/aggregated-assets/src/pagination.test.ts @@ -1,5 +1,5 @@ -import { mergeAssetsDataPages } from "../mergeAssetsDataPages"; -import type { AssetsDataWithPagination } from "../../state-manager/types"; +import { mergeAssetsDataPages } from "./pagination"; +import type { AssetsDataWithPagination } from "./types"; const makePage = (overrides: Partial = {}): AssetsDataWithPagination => ({ cryptoAssets: {}, diff --git a/domain/api/aggregated-assets/src/pagination.ts b/domain/api/aggregated-assets/src/pagination.ts new file mode 100644 index 000000000000..bfe9e932765b --- /dev/null +++ b/domain/api/aggregated-assets/src/pagination.ts @@ -0,0 +1,35 @@ +import { AssetsDataWithPagination } from "./types"; + +const emptyData = (): AssetsDataWithPagination => ({ + cryptoAssets: {}, + networks: {}, + cryptoOrTokenCurrencies: {}, + interestRates: {}, + markets: {}, + currenciesOrder: { + metaCurrencyIds: [], + key: "", + order: "", + }, + pagination: { nextCursor: "" }, +}); + +export function mergeAssetsDataPages( + pages: AssetsDataWithPagination[] | undefined, +): AssetsDataWithPagination | undefined { + return pages?.reduce((acc, page) => { + Object.assign(acc.cryptoAssets, page.cryptoAssets); + Object.assign(acc.networks, page.networks); + Object.assign(acc.cryptoOrTokenCurrencies, page.cryptoOrTokenCurrencies); + Object.assign(acc.interestRates, page.interestRates); + Object.assign(acc.markets, page.markets); + + acc.currenciesOrder.metaCurrencyIds.push(...page.currenciesOrder.metaCurrencyIds); + + acc.currenciesOrder.key = page.currenciesOrder.key; + acc.currenciesOrder.order = page.currenciesOrder.order; + acc.pagination.nextCursor = page.pagination.nextCursor; + + return acc; + }, emptyData()); +} diff --git a/domain/api/aggregated-assets/src/requests.ts b/domain/api/aggregated-assets/src/requests.ts new file mode 100644 index 000000000000..11450aea22c0 --- /dev/null +++ b/domain/api/aggregated-assets/src/requests.ts @@ -0,0 +1,32 @@ +import { AssetsAdditionalData, type GetAssetsDataParams } from "./types"; + +export function buildAssetsQueryParams( + queryArg: GetAssetsDataParams, + opts?: { pageSize?: number; cursor?: string }, +): Record { + return { + pageSize: opts?.pageSize ?? 100, + ...(opts?.cursor && { cursor: opts.cursor }), + ...(queryArg.useCase && { transaction: queryArg.useCase }), + ...(queryArg.currencyIds && + queryArg.currencyIds.length > 0 && { + currencyIds: queryArg.currencyIds, + }), + ...(queryArg.networkIds && + queryArg.networkIds.length > 0 && { + networkIds: queryArg.networkIds.join(","), + }), + ...(queryArg.categories && + queryArg.categories.length > 0 && { + categories: queryArg.categories.join(","), + }), + ...(queryArg.search && { search: queryArg.search }), + product: queryArg.product, + minVersion: queryArg.version, + ...(queryArg.includeTestNetworks && { includeTestNetworks: queryArg.includeTestNetworks }), + additionalData: queryArg.additionalData || [ + AssetsAdditionalData.Apy, + AssetsAdditionalData.MarketTrend, + ], + }; +} diff --git a/domain/api/aggregated-assets/src/schema.ts b/domain/api/aggregated-assets/src/schema.ts new file mode 100644 index 000000000000..42dcc94788d4 --- /dev/null +++ b/domain/api/aggregated-assets/src/schema.ts @@ -0,0 +1,83 @@ +import type { CryptoAssetMeta } from "@domain/entity-aggregated-asset"; +import type { InterestRate } from "@domain/entity-interest-rate"; +import type { PartialMarketItemResponse } from "./internals/market"; + +// Raw DADA API wire-format shapes for currency assets +export interface ApiTokenCurrency { + type: "token_currency"; + id: string; + contractAddress: string; + name: string; + ticker: string; + units: Array<{ code: string; name: string; magnitude: number }>; + standard: string; + parentCurrency?: string | null; + tokenIdentifier?: string; + symbol?: string; + delisted?: boolean; + disableCountervalue?: boolean; + descriptor?: unknown; +} + +export interface ApiCryptoCurrency { + type: "crypto_currency"; + id: string; + name: string; + ticker: string; + units: Array<{ code: string; name: string; magnitude: number }>; + chainId?: string | null; + confirmationsNeeded?: number; + symbol?: string; + coinType?: number; + family?: string; + hasSegwit?: boolean; + hasTokens?: boolean; + hrp?: string | null; + disableCountervalue?: boolean; +} + +export type ApiAsset = ApiTokenCurrency | ApiCryptoCurrency; + +/** + * A network's id and display name. + * + * Not an entity: a network *is* a chain, already modelled by @domain/entity-currency-crypto. + * This is the wire shape only; resolve to the existing crypto currency rather than duplicating + * the concept. + */ +export interface NetworkInfo { + /** Network identifier */ + id: string; + /** Network display name */ + name: string; +} + +/** + * The server-provided ordering. + * + * Not an entity: this is response metadata, not a business object. + */ +export interface CurrenciesOrder { + /** Sorting key (e.g. "marketCap") */ + key: string; + /** Sort order (e.g. "desc") */ + order: string; + /** Ordered list of meta-currency IDs */ + metaCurrencyIds: string[]; +} + +// Types for raw API response (before transformation) +export interface RawApiResponse { + /** Grouped crypto assets by meta-currency */ + cryptoAssets: Record; + /** Network information */ + networks: Record; + /** Raw crypto currencies and token currencies from API */ + cryptoOrTokenCurrencies: Record; + /** Interest rates for various currencies */ + interestRates: Record; + /** Market data for currencies */ + markets: Record; + /** Currency ordering information */ + currenciesOrder: CurrenciesOrder; +} diff --git a/domain/api/aggregated-assets/src/transforms.ts b/domain/api/aggregated-assets/src/transforms.ts new file mode 100644 index 000000000000..24909e6a3a7f --- /dev/null +++ b/domain/api/aggregated-assets/src/transforms.ts @@ -0,0 +1,72 @@ +import type { FetchBaseQueryMeta } from "@reduxjs/toolkit/query/react"; +import { CryptoCurrencySchema, findCryptoCurrencyById } from "@domain/entity-currency-crypto"; +import type { CryptoOrTokenCurrency } from "@domain/entity-currency"; +import { convertApiToken } from "@domain/api-currency-token"; +import type { ApiAsset, RawApiResponse } from "./schema"; +import type { AssetsDataWithPagination } from "./types"; + +/** + * Converts the wire currency map to app currencies. + * + * Deliberately lenient, and load-bearing: a token whose parent chain is unknown is silently + * dropped, and a crypto missing from the local registry is *synthesised* rather than dropped, so + * assets DADA knows about but the CAL does not still render. Do not tighten without replacing + * that behaviour. + * + * The synthesised entity is validated by `CryptoCurrencySchema`, which also brands `id`. `parse` + * rather than `safeParse` is deliberate: it keeps the existing contract that an unusable currency + * surfaces as a query error instead of being silently dropped. Note this makes the whole response + * fail, so LIVE-35232 should convert it to per-item drop-and-count once that telemetry exists. + */ +export function convertApiAssets( + apiAssets: Record, +): Record { + const result: Record = {}; + for (const [key, asset] of Object.entries(apiAssets)) { + if (asset.type === "token_currency") { + const token = convertApiToken(asset as Parameters[0]); + if (token) result[key] = token; + } else { + const crypto = findCryptoCurrencyById(asset.id); + if (crypto) { + result[key] = crypto; + } else { + result[key] = CryptoCurrencySchema.parse({ + type: "CryptoCurrency" as const, + id: asset.id, + name: asset.name, + ticker: asset.ticker, + units: asset.units, + managerAppName: asset.name, + coinType: asset.coinType ?? 0, + scheme: asset.id.toLowerCase(), + color: "#999999", + family: asset.family ?? asset.id, + explorerViews: [], + symbol: asset.symbol, + disableCountervalue: asset.disableCountervalue, + supportsSegwit: asset.hasSegwit, + ...(asset.chainId ? { ethereumLikeInfo: { chainId: parseInt(asset.chainId, 10) } } : {}), + }); + } + } + } + return result; +} + +export function transformAssetsResponse( + response: RawApiResponse, + meta?: FetchBaseQueryMeta, +): AssetsDataWithPagination { + const enrichedCryptoOrTokenCurrencies = convertApiAssets(response.cryptoOrTokenCurrencies); + + const nextCursor = meta?.response?.headers.get("x-ledger-next") || undefined; + + return { + ...response, + cryptoOrTokenCurrencies: enrichedCryptoOrTokenCurrencies, + pagination: { + nextCursor, + }, + }; +} diff --git a/domain/api/aggregated-assets/src/types.ts b/domain/api/aggregated-assets/src/types.ts new file mode 100644 index 000000000000..5a5928a322d4 --- /dev/null +++ b/domain/api/aggregated-assets/src/types.ts @@ -0,0 +1,65 @@ +import type { CryptoOrTokenCurrency } from "@domain/entity-currency"; +import type { CryptoAssetMeta } from "@domain/entity-aggregated-asset"; +import type { InterestRate } from "@domain/entity-interest-rate"; +import type { CurrenciesOrder, NetworkInfo } from "./schema"; +import type { PartialMarketItemResponse } from "./internals/market"; + +// Types for transformed API response (after transformation) +export interface AssetsData { + /** Grouped crypto assets by meta-currency */ + cryptoAssets: Record; + /** Network information */ + networks: Record; + /** Transformed crypto currencies and token currencies compatible with Ledger Live */ + cryptoOrTokenCurrencies: Record; + /** Interest rates for various currencies */ + interestRates: Record; + /** Market data for currencies */ + markets: Record; + /** Currency ordering information */ + currenciesOrder: CurrenciesOrder; +} + +export enum AssetsDataTags { + Assets = "Assets", +} + +export enum AssetsAdditionalData { + Apy = "apy", + MarketTrend = "marketTrend", +} + +export enum AssetCategory { + Stablecoins = "stablecoins", + Stocks = "stocks", +} + +export interface GetAssetsDataParams { + search?: string; + currencyIds?: string[]; + networkIds?: readonly string[]; + categories?: AssetCategory[]; + useCase?: string; + product: "llm" | "lld"; + version: string; + isStaging?: boolean; + additionalData?: AssetsAdditionalData[]; + includeTestNetworks?: boolean; +} + +export interface PageParam { + cursor?: string; +} + +export interface AssetsDataWithPagination extends AssetsData { + pagination: { + nextCursor?: string; + }; +} + +export interface GetAssetsByCategoryParams { + category: AssetCategory; + product: "llm" | "lld"; + version: string; + isStaging?: boolean; +} diff --git a/domain/api/aggregated-assets/tsconfig.json b/domain/api/aggregated-assets/tsconfig.json index 840231572032..5cbee89e4ef0 100644 --- a/domain/api/aggregated-assets/tsconfig.json +++ b/domain/api/aggregated-assets/tsconfig.json @@ -1,12 +1,12 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "noEmit": true, - "types": ["jest"] + "types": ["jest", "node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "lib"] diff --git a/domain/entity/aggregated-asset/package.json b/domain/entity/aggregated-asset/package.json index 937cbd3332f7..071c0f8d0455 100644 --- a/domain/entity/aggregated-asset/package.json +++ b/domain/entity/aggregated-asset/package.json @@ -12,11 +12,14 @@ "sideEffects": false, "scripts": { "typecheck": "tsc --noEmit", - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "jest --watch", - "coverage": "jest --coverage --passWithNoTests", + "coverage": "jest --coverage", "unimported": "pnpm knip --directory ../../.. -W domain/entity/aggregated-asset" }, + "dependencies": { + "zod": "catalog:" + }, "devDependencies": { "@jest/globals": "catalog:", "@swc/core": "catalog:", diff --git a/domain/entity/aggregated-asset/src/index.ts b/domain/entity/aggregated-asset/src/index.ts index cb0ff5c3b541..cf899952e94e 100644 --- a/domain/entity/aggregated-asset/src/index.ts +++ b/domain/entity/aggregated-asset/src/index.ts @@ -1 +1,2 @@ -export {}; +export * from "./schema"; +export * from "./types"; diff --git a/domain/entity/aggregated-asset/src/schema.test.ts b/domain/entity/aggregated-asset/src/schema.test.ts new file mode 100644 index 000000000000..43b04c7a8599 --- /dev/null +++ b/domain/entity/aggregated-asset/src/schema.test.ts @@ -0,0 +1,37 @@ +import { CryptoAssetMetaSchema } from "./schema"; + +const valid = { + id: "urn:crypto:meta-currency:ethereum", + ticker: "ETH", + name: "Ethereum", + assetsIds: { ethereum: "ethereum", arbitrum: "arbitrum" }, +}; + +describe("CryptoAssetMetaSchema", () => { + it("validates a well-formed aggregated asset", () => { + expect(CryptoAssetMetaSchema.parse(valid)).toEqual(valid); + }); + + it("accepts an asset present on no network", () => { + expect(() => CryptoAssetMetaSchema.parse({ ...valid, assetsIds: {} })).not.toThrow(); + }); + + it("throws when a required field is missing", () => { + for (const key of ["id", "ticker", "name", "assetsIds"] as const) { + const { [key]: _omitted, ...rest } = valid; + expect(() => CryptoAssetMetaSchema.parse(rest)).toThrow(); + } + }); + + it("throws when assetsIds maps to a non-string", () => { + expect(() => CryptoAssetMetaSchema.parse({ ...valid, assetsIds: { ethereum: 42 } })).toThrow(); + }); + + /* + * Empty strings are accepted deliberately: DADA sends them and the current transform throws on + * an empty id rather than dropping the asset. Tightening this is LIVE-35233, not here. + */ + it("accepts an empty id, matching current wire tolerance", () => { + expect(() => CryptoAssetMetaSchema.parse({ ...valid, id: "" })).not.toThrow(); + }); +}); diff --git a/domain/entity/aggregated-asset/src/schema.ts b/domain/entity/aggregated-asset/src/schema.ts new file mode 100644 index 000000000000..984eef6ac265 --- /dev/null +++ b/domain/entity/aggregated-asset/src/schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +/** + * An aggregated asset: one logical asset grouping several per-network currencies. + * + * `assetsIds` maps a network id to the currency id that represents this asset on that network, + * which is what makes it "aggregated" rather than a single currency. + */ +export const CryptoAssetMetaSchema = z.object({ + /** Asset identifier */ + id: z.string(), + /** Asset ticker symbol */ + ticker: z.string(), + /** Asset display name */ + name: z.string(), + /** Map of network IDs to their corresponding asset IDs */ + assetsIds: z.record(z.string(), z.string()), +}); diff --git a/domain/entity/aggregated-asset/src/types.ts b/domain/entity/aggregated-asset/src/types.ts new file mode 100644 index 000000000000..1985869c0470 --- /dev/null +++ b/domain/entity/aggregated-asset/src/types.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; +import { CryptoAssetMetaSchema } from "./schema"; + +/** Canonical aggregated asset inferred from {@link CryptoAssetMetaSchema}. */ +export type CryptoAssetMeta = z.infer; diff --git a/domain/entity/interest-rate/package.json b/domain/entity/interest-rate/package.json index c6e839fd83ae..111d77d45695 100644 --- a/domain/entity/interest-rate/package.json +++ b/domain/entity/interest-rate/package.json @@ -12,11 +12,14 @@ "sideEffects": false, "scripts": { "typecheck": "tsc --noEmit", - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "jest --watch", - "coverage": "jest --coverage --passWithNoTests", + "coverage": "jest --coverage", "unimported": "pnpm knip --directory ../../.. -W domain/entity/interest-rate" }, + "dependencies": { + "zod": "catalog:" + }, "devDependencies": { "@jest/globals": "catalog:", "@swc/core": "catalog:", diff --git a/domain/entity/interest-rate/src/index.ts b/domain/entity/interest-rate/src/index.ts index cb0ff5c3b541..cf899952e94e 100644 --- a/domain/entity/interest-rate/src/index.ts +++ b/domain/entity/interest-rate/src/index.ts @@ -1 +1,2 @@ -export {}; +export * from "./schema"; +export * from "./types"; diff --git a/domain/entity/interest-rate/src/schema.test.ts b/domain/entity/interest-rate/src/schema.test.ts new file mode 100644 index 000000000000..a8673dd7e575 --- /dev/null +++ b/domain/entity/interest-rate/src/schema.test.ts @@ -0,0 +1,56 @@ +import { ApyTypeSchema, InterestRateSchema } from "./schema"; + +const valid = { + currencyId: "bitcoin", + rate: 4.2, + type: "APY", + fetchAt: "2026-07-31T00:00:00.000Z", +}; + +describe("InterestRateSchema", () => { + it("validates a well-formed rate", () => { + expect(InterestRateSchema.parse(valid)).toEqual(valid); + }); + + it("accepts a zero rate", () => { + expect(() => InterestRateSchema.parse({ ...valid, rate: 0 })).not.toThrow(); + }); + + it("throws when a required field is missing", () => { + for (const key of ["currencyId", "rate", "type", "fetchAt"] as const) { + const { [key]: _omitted, ...rest } = valid; + expect(() => InterestRateSchema.parse(rest)).toThrow(); + } + }); + + it("throws when rate is not a number", () => { + expect(() => InterestRateSchema.parse({ ...valid, rate: "4.2" })).toThrow(); + }); + + /* + * `type` is deliberately wider than ApyType. DADA sends kinds the apps do not render, and + * useInterestRatesByCurrencies drops them. Narrowing here would claim a guarantee the wire + * does not give. + */ + it("accepts a rate type outside the ApyType union", () => { + expect(() => InterestRateSchema.parse({ ...valid, type: "STAKING" })).not.toThrow(); + }); + + /* + * fetchAt stays a plain string rather than DateTimeIsoSchema: nothing in the apps reads it, so + * validating the format could only discard otherwise-good rates. + */ + it("does not validate the fetchAt format", () => { + expect(() => InterestRateSchema.parse({ ...valid, fetchAt: "not-a-date" })).not.toThrow(); + }); +}); + +describe("ApyTypeSchema", () => { + it.each(["NRR", "APY", "APR"])("accepts %s", type => { + expect(ApyTypeSchema.parse(type)).toBe(type); + }); + + it.each(["", "apy", "STAKING", "UNKNOWN"])("rejects %p", type => { + expect(() => ApyTypeSchema.parse(type)).toThrow(); + }); +}); diff --git a/domain/entity/interest-rate/src/schema.ts b/domain/entity/interest-rate/src/schema.ts new file mode 100644 index 000000000000..bf303dcd84fb --- /dev/null +++ b/domain/entity/interest-rate/src/schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +/** + * The rate kinds the apps understand. + * + * DADA sends values outside this set, so {@link InterestRateSchema} keeps `type` as a plain string + * and consumers narrow to this union, dropping anything unrecognised. + */ +export const ApyTypeSchema = z.enum(["NRR", "APY", "APR"]); + +/** An interest rate attached to one currency. */ +export const InterestRateSchema = z.object({ + /** Currency identifier */ + currencyId: z.string(), + /** Interest rate value */ + rate: z.number(), + /** Type of rate (NRR, APR, APY, etc.) — intentionally wider than ApyType, see above */ + type: z.string(), + /** Timestamp when the rate was fetched */ + fetchAt: z.string(), +}); diff --git a/domain/entity/interest-rate/src/types.ts b/domain/entity/interest-rate/src/types.ts new file mode 100644 index 000000000000..f5e792b17008 --- /dev/null +++ b/domain/entity/interest-rate/src/types.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; +import { ApyTypeSchema, InterestRateSchema } from "./schema"; + +/** Canonical interest rate inferred from {@link InterestRateSchema}. */ +export type InterestRate = z.infer; + +/** The rate kinds the apps understand, inferred from {@link ApyTypeSchema}. */ +export type ApyType = z.infer; diff --git a/libs/ledger-live-common/package.json b/libs/ledger-live-common/package.json index 50877af69c87..995b6372f372 100644 --- a/libs/ledger-live-common/package.json +++ b/libs/ledger-live-common/package.json @@ -263,14 +263,17 @@ "dependencies": { "@blooo/hw-app-acre": "^1.1.1", "@cardano-foundation/ledgerjs-hw-app-cardano": "^7.1.2", + "@domain/api-aggregated-assets": "workspace:^", "@domain/api-currency-token": "workspace:^", "@domain/api-swap-quotes": "workspace:^", + "@domain/entity-aggregated-asset": "workspace:^", "@domain/entity-client-identity": "workspace:^", "@domain/entity-currency": "workspace:^", "@domain/entity-currency-crypto": "workspace:^", "@domain/entity-currency-fiat": "workspace:^", "@domain/entity-currency-token": "workspace:^", "@domain/entity-currency-unit": "workspace:^", + "@domain/entity-interest-rate": "workspace:^", "@features/platform-env": "workspace:^", "@features/platform-feature-flags": "workspace:^", "@ledgerhq/asset-aggregation": "workspace:^", diff --git a/libs/ledger-live-common/src/dada-client/__mocks__/assets.mock.ts b/libs/ledger-live-common/src/dada-client/__mocks__/assets.mock.ts index 2d782c2d949e..46914559899d 100644 --- a/libs/ledger-live-common/src/dada-client/__mocks__/assets.mock.ts +++ b/libs/ledger-live-common/src/dada-client/__mocks__/assets.mock.ts @@ -1,265 +1 @@ -import { CryptoCurrencyIdSchema } from "@domain/entity-currency-crypto"; -import { TokenCurrencyIdSchema } from "@domain/entity-currency-token"; -const mockInjectiveCurrency = { - type: "CryptoCurrency" as const, - id: CryptoCurrencyIdSchema.parse("injective"), - name: "Injective", - ticker: "INJ", - units: [ - { - name: "INJ", - code: "INJ", - magnitude: 18, - }, - ], - family: "injective", - managerAppName: "Injective", - coinType: 60, - scheme: "injective", - color: "#00F2FE", - explorerViews: [], -}; - -export const mockAssetsData = { - cryptoAssets: { - "urn:crypto:meta-currency:injective_protocol": { - id: "urn:crypto:meta-currency:injective_protocol", - ticker: "INJ", - name: "Injective", - assetsIds: { - injective: "injective", - ethereum: "ethereum/erc20/injective_token", - bsc: "bsc/bep20/injective_protocol", - }, - }, - }, - networks: { - bsc: { id: "bsc", name: "Binance Smart Chain" }, - ethereum: { id: "ethereum", name: "Ethereum" }, - injective: { id: "injective", name: "Injective" }, - }, - cryptoOrTokenCurrencies: { - "bsc/bep20/injective_protocol": { - type: "TokenCurrency" as const, - id: TokenCurrencyIdSchema.parse("bsc/bep20/injective_protocol"), - name: "Injective Protocol", - ticker: "INJ", - contractAddress: "0x0", - parentCurrencyId: CryptoCurrencyIdSchema.parse("bsc"), - tokenType: "bep20", - units: [ - { - name: "INJ", - code: "INJ", - magnitude: 18, - }, - ], - }, - "ethereum/erc20/injective_token": { - type: "TokenCurrency" as const, - id: TokenCurrencyIdSchema.parse("ethereum/erc20/injective_token"), - name: "Injective Token", - ticker: "INJ", - contractAddress: "0x0", - parentCurrencyId: CryptoCurrencyIdSchema.parse("ethereum"), - tokenType: "erc20", - units: [ - { - name: "INJ", - code: "INJ", - magnitude: 18, - }, - ], - }, - injective: mockInjectiveCurrency, - }, - interestRates: {}, - markets: {}, - currenciesOrder: { - key: "marketCap", - order: "desc", - metaCurrencyIds: ["urn:crypto:meta-currency:injective_protocol"], - }, -}; - -export const mockAssetsDataWithPagination = { - ...mockAssetsData, - pagination: { - nextCursor: "cursor-1", - }, -}; - -// Bitcoin mock data -const mockBitcoinCurrency = { - type: "CryptoCurrency" as const, - id: CryptoCurrencyIdSchema.parse("bitcoin"), - name: "Bitcoin", - ticker: "BTC", - units: [ - { - name: "BTC", - code: "BTC", - magnitude: 8, - }, - ], - family: "bitcoin", - managerAppName: "Bitcoin", - coinType: 0, - scheme: "bitcoin", - color: "#FF9900", - explorerViews: [], -}; - -export const mockBitcoinAssetsData = { - cryptoAssets: { - bitcoin: { - id: "bitcoin", - ticker: "BTC", - name: "Bitcoin", - assetsIds: { - bitcoin: "bitcoin", - ethereum: "ethereum/erc20/wrapped_bitcoin", - }, - }, - }, - networks: { - bitcoin: { id: "bitcoin", name: "Bitcoin" }, - ethereum: { id: "ethereum", name: "Ethereum" }, - }, - cryptoOrTokenCurrencies: { - bitcoin: mockBitcoinCurrency, - "ethereum/erc20/wrapped_bitcoin": { - type: "TokenCurrency" as const, - id: TokenCurrencyIdSchema.parse("ethereum/erc20/wrapped_bitcoin"), - name: "Wrapped Bitcoin", - ticker: "WBTC", - contractAddress: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", - parentCurrencyId: CryptoCurrencyIdSchema.parse("ethereum"), - tokenType: "erc20", - units: [ - { - name: "WBTC", - code: "WBTC", - magnitude: 8, - }, - ], - }, - }, - interestRates: {}, - markets: {}, - currenciesOrder: { - key: "marketCap", - order: "desc", - metaCurrencyIds: ["bitcoin"], - }, -}; - -// USDC mock data -export const mockUsdcAssetsData = { - cryptoAssets: { - usdc: { - id: "usdc", - ticker: "USDC", - name: "USD Coin", - assetsIds: { - ethereum: "ethereum/erc20/usd_coin", - polygon: "polygon/erc20/usd_coin", - }, - }, - }, - networks: { - ethereum: { id: "ethereum", name: "Ethereum" }, - polygon: { id: "polygon", name: "Polygon" }, - }, - cryptoOrTokenCurrencies: { - "ethereum/erc20/usd_coin": { - type: "TokenCurrency" as const, - id: TokenCurrencyIdSchema.parse("ethereum/erc20/usd_coin"), - name: "USD Coin", - ticker: "USDC", - contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - parentCurrencyId: CryptoCurrencyIdSchema.parse("ethereum"), - tokenType: "erc20", - units: [ - { - name: "USDC", - code: "USDC", - magnitude: 6, - }, - ], - }, - "polygon/erc20/usd_coin": { - type: "TokenCurrency" as const, - id: TokenCurrencyIdSchema.parse("polygon/erc20/usd_coin"), - name: "USD Coin (Polygon)", - ticker: "USDC", - contractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", - parentCurrencyId: CryptoCurrencyIdSchema.parse("polygon"), - tokenType: "erc20", - units: [ - { - name: "USDC", - code: "USDC", - magnitude: 6, - }, - ], - }, - }, - interestRates: {}, - markets: {}, - currenciesOrder: { - key: "marketCap", - order: "desc", - metaCurrencyIds: ["usdc"], - }, -}; - -export const mockArbitrumTokenAssetsData = { - cryptoAssets: { - arbitrum: { - id: "arbitrum", - ticker: "ARB", - name: "Arbitrum", - assetsIds: { - ethereum: "ethereum/erc20/arbitrum", - arbitrum: "arbitrum", - }, - }, - }, - networks: { - ethereum: { id: "ethereum", name: "Ethereum" }, - arbitrum: { id: "arbitrum", name: "Arbitrum One" }, - }, - cryptoOrTokenCurrencies: { - "ethereum/erc20/arbitrum": { - type: "TokenCurrency" as const, - id: TokenCurrencyIdSchema.parse("ethereum/erc20/arbitrum"), - name: "Arbitrum", - ticker: "ARB", - contractAddress: "0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1", - parentCurrencyId: CryptoCurrencyIdSchema.parse("ethereum"), - tokenType: "erc20", - units: [{ name: "ARB", code: "ARB", magnitude: 18 }], - }, - arbitrum: { - type: "CryptoCurrency" as const, - id: CryptoCurrencyIdSchema.parse("arbitrum"), - name: "Arbitrum One", - ticker: "ETH", - units: [{ name: "ETH", code: "ETH", magnitude: 18 }], - family: "evm", - managerAppName: "Ethereum", - coinType: 60, - scheme: "arbitrum", - color: "#28A0F0", - explorerViews: [], - }, - }, - interestRates: {}, - markets: {}, - currenciesOrder: { - key: "marketCap", - order: "desc", - metaCurrencyIds: ["arbitrum"], - }, -}; +export * from "@domain/api-aggregated-assets/mock"; diff --git a/libs/ledger-live-common/src/dada-client/entities/index.ts b/libs/ledger-live-common/src/dada-client/entities/index.ts index 7b2b7017614b..f35a8b2ab072 100644 --- a/libs/ledger-live-common/src/dada-client/entities/index.ts +++ b/libs/ledger-live-common/src/dada-client/entities/index.ts @@ -1,112 +1,11 @@ -import type { CryptoOrTokenCurrency } from "@domain/entity-currency"; -import { PartialMarketItemResponse } from "../../market/utils/types"; - -// Raw DADA API wire-format shapes for currency assets -export interface ApiTokenCurrency { - type: "token_currency"; - id: string; - contractAddress: string; - name: string; - ticker: string; - units: Array<{ code: string; name: string; magnitude: number }>; - standard: string; - parentCurrency?: string | null; - tokenIdentifier?: string; - symbol?: string; - delisted?: boolean; - disableCountervalue?: boolean; - descriptor?: unknown; -} - -export interface ApiCryptoCurrency { - type: "crypto_currency"; - id: string; - name: string; - ticker: string; - units: Array<{ code: string; name: string; magnitude: number }>; - chainId?: string | null; - confirmationsNeeded?: number; - symbol?: string; - coinType?: number; - family?: string; - hasSegwit?: boolean; - hasTokens?: boolean; - hrp?: string | null; - disableCountervalue?: boolean; -} - -export type ApiAsset = ApiTokenCurrency | ApiCryptoCurrency; - -// Types for crypto asset metadata -export interface CryptoAssetMeta { - /** Asset identifier */ - id: string; - /** Asset ticker symbol */ - ticker: string; - /** Asset display name */ - name: string; - /** Map of network IDs to their corresponding asset IDs */ - assetsIds: Record; -} - -// Types for network information -export interface NetworkInfo { - /** Network identifier */ - id: string; - /** Network display name */ - name: string; -} - -// Types for interest rate data -export interface InterestRate { - /** Currency identifier */ - currencyId: string; - /** Interest rate value */ - rate: number; - /** Type of rate (NRR, APR, APY, etc.) */ - type: string; - /** Timestamp when the rate was fetched */ - fetchAt: string; -} - -// Types for currency ordering -export interface CurrenciesOrder { - /** Sorting key (e.g., "marketCap") */ - key: string; - /** Sort order (e.g., "desc") */ - order: string; - /** Ordered list of meta-currency IDs */ - metaCurrencyIds: string[]; -} - -// Types for raw API response (before transformation) -export interface RawApiResponse { - /** Grouped crypto assets by meta-currency */ - cryptoAssets: Record; - /** Network information */ - networks: Record; - /** Raw crypto currencies and token currencies from API */ - cryptoOrTokenCurrencies: Record; - /** Interest rates for various currencies */ - interestRates: Record; - /** Market data for currencies */ - markets: Record; - /** Currency ordering information */ - currenciesOrder: CurrenciesOrder; -} - -// Types for transformed API response (after transformation) -export interface AssetsData { - /** Grouped crypto assets by meta-currency */ - cryptoAssets: Record; - /** Network information */ - networks: Record; - /** Transformed crypto currencies and token currencies compatible with Ledger Live */ - cryptoOrTokenCurrencies: Record; - /** Interest rates for various currencies */ - interestRates: Record; - /** Market data for currencies */ - markets: Record; - /** Currency ordering information */ - currenciesOrder: CurrenciesOrder; -} +export type { CryptoAssetMeta } from "@domain/entity-aggregated-asset"; +export type { InterestRate } from "@domain/entity-interest-rate"; +export type { + ApiAsset, + ApiCryptoCurrency, + ApiTokenCurrency, + AssetsData, + CurrenciesOrder, + NetworkInfo, + RawApiResponse, +} from "@domain/api-aggregated-assets"; diff --git a/libs/ledger-live-common/src/dada-client/mocks/stablecoins.mock.ts b/libs/ledger-live-common/src/dada-client/mocks/stablecoins.mock.ts index bcbbe4d6a337..0274535c2c2a 100644 --- a/libs/ledger-live-common/src/dada-client/mocks/stablecoins.mock.ts +++ b/libs/ledger-live-common/src/dada-client/mocks/stablecoins.mock.ts @@ -1,45 +1 @@ -const STABLECOIN_TICKERS = [ - "USDT", - "USDC", - "USDS", - "USDE", - "DAI", - "USD1", - "PYUSD", - "PAXG", - "USDG", - "RLUSD", - "USDD", - "TUSD", - "EURC", - "FDUSD", - "CRVUSD", - "FRAX", - "AUSD", - "BUSD", - "EURI", - "GUSD", -]; - -export const mockStablecoinsResponse = { - cryptoAssets: Object.fromEntries( - STABLECOIN_TICKERS.map(ticker => [ - `urn:crypto:meta-currency:${ticker.toLowerCase()}`, - { - id: `urn:crypto:meta-currency:${ticker.toLowerCase()}`, - ticker, - name: ticker, - assetsIds: {}, - }, - ]), - ), - networks: {}, - cryptoOrTokenCurrencies: {}, - interestRates: {}, - markets: {}, - currenciesOrder: { - key: "marketCap", - order: "desc", - metaCurrencyIds: STABLECOIN_TICKERS.map(t => `urn:crypto:meta-currency:${t.toLowerCase()}`), - }, -}; +export * from "@domain/api-aggregated-assets/mock/stablecoins"; diff --git a/libs/ledger-live-common/src/dada-client/mocks/stocks.mock.ts b/libs/ledger-live-common/src/dada-client/mocks/stocks.mock.ts index 51ff2d271701..7a46d71d20da 100644 --- a/libs/ledger-live-common/src/dada-client/mocks/stocks.mock.ts +++ b/libs/ledger-live-common/src/dada-client/mocks/stocks.mock.ts @@ -1,78 +1 @@ -import { CryptoCurrencyIdSchema } from "@domain/entity-currency-crypto"; -import { TokenCurrencyIdSchema } from "@domain/entity-currency-token"; - -export const mockStocksResponse = { - cryptoAssets: { - "urn:crypto:meta-currency:applex": { - id: "urn:crypto:meta-currency:applex", - ticker: "AAPLX", - name: "Apple xStock", - assetsIds: { - solana: "solana/spl/applex", - }, - }, - "urn:crypto:meta-currency:teslax": { - id: "urn:crypto:meta-currency:teslax", - ticker: "TSLAX", - name: "Tesla xStock", - assetsIds: { - solana: "solana/spl/teslax", - }, - }, - }, - networks: { - solana: { id: "solana", name: "Solana" }, - }, - cryptoOrTokenCurrencies: { - "solana/spl/applex": { - type: "TokenCurrency" as const, - id: TokenCurrencyIdSchema.parse("solana/spl/applex"), - name: "Apple xStock", - ticker: "AAPLX", - contractAddress: "XsAAPL000000000000000000000000000000000000", - parentCurrencyId: CryptoCurrencyIdSchema.parse("solana"), - tokenType: "spl", - units: [ - { - name: "AAPLX", - code: "AAPLX", - magnitude: 8, - }, - ], - }, - "solana/spl/teslax": { - type: "TokenCurrency" as const, - id: TokenCurrencyIdSchema.parse("solana/spl/teslax"), - name: "Tesla xStock", - ticker: "TSLAX", - contractAddress: "XsTSLA000000000000000000000000000000000000", - parentCurrencyId: CryptoCurrencyIdSchema.parse("solana"), - tokenType: "spl", - units: [ - { - name: "TSLAX", - code: "TSLAX", - magnitude: 8, - }, - ], - }, - }, - interestRates: {}, - markets: { - "urn:crypto:meta-currency:applex": { - price: 226.4, - marketCap: 3_400_000_000_000, - priceChangePercentage24h: 1.2, - }, - "urn:crypto:meta-currency:teslax": { - price: 248.5, - marketCap: 790_000_000_000, - priceChangePercentage24h: -0.8, - }, - }, - currenciesOrder: { - key: "marketCap", - order: "desc", - metaCurrencyIds: ["urn:crypto:meta-currency:applex", "urn:crypto:meta-currency:teslax"], - }, -}; +export * from "@domain/api-aggregated-assets/mock/stocks"; diff --git a/libs/ledger-live-common/src/dada-client/state-manager/api.ts b/libs/ledger-live-common/src/dada-client/state-manager/api.ts index d4bf4add60f1..f704330b1d3c 100644 --- a/libs/ledger-live-common/src/dada-client/state-manager/api.ts +++ b/libs/ledger-live-common/src/dada-client/state-manager/api.ts @@ -1,342 +1 @@ -import { - createApi, - fetchBaseQuery, - FetchBaseQueryError, - FetchBaseQueryMeta, - QueryReturnValue, -} from "@reduxjs/toolkit/query/react"; -import type { ApiAsset } from "../entities"; -import { CryptoCurrencyIdSchema, findCryptoCurrencyById } from "@domain/entity-currency-crypto"; -import type { CryptoOrTokenCurrency } from "@domain/entity-currency"; -import { convertApiToken } from "@domain/api-currency-token"; -import { RawApiResponse, AssetsData } from "../entities"; -import { getEnv } from "@shared/env"; -import { - AssetsAdditionalData, - AssetsDataTags, - AssetsDataWithPagination, - GetAssetsDataParams, - GetAssetsByCategoryParams, - ONE_DAY_IN_SECONDS, - PageParam, -} from "./types"; -import { chunkCurrencyIds } from "../utils/chunkCurrencyIds"; -import { deepMergeCryptoAssets } from "../utils/deepMergeCryptoAssets"; - -function convertApiAssets( - apiAssets: Record, -): Record { - const result: Record = {}; - for (const [key, asset] of Object.entries(apiAssets)) { - if (asset.type === "token_currency") { - const token = convertApiToken(asset as Parameters[0]); - if (token) result[key] = token; - } else { - const crypto = findCryptoCurrencyById(asset.id); - if (crypto) { - result[key] = crypto; - } else { - result[key] = { - type: "CryptoCurrency" as const, - id: CryptoCurrencyIdSchema.parse(asset.id), - name: asset.name, - ticker: asset.ticker, - units: asset.units, - managerAppName: asset.name, - coinType: asset.coinType ?? 0, - scheme: asset.id.toLowerCase(), - color: "#999999", - family: asset.family ?? asset.id, - explorerViews: [], - symbol: asset.symbol, - disableCountervalue: asset.disableCountervalue, - supportsSegwit: asset.hasSegwit, - ...(asset.chainId ? { ethereumLikeInfo: { chainId: parseInt(asset.chainId, 10) } } : {}), - }; - } - } - } - return result; -} - -const ALLOWED_DADA_HOSTS = new Set(["dada.api.ledger.com", "dada.api.ledger-test.com"]); - -type SettledResult = { status: "fulfilled"; value: T } | { status: "rejected"; reason: unknown }; - -function allSettled(promises: Promise[]): Promise[]> { - return Promise.all( - promises.map(p => - p - .then(value => ({ status: "fulfilled" as const, value })) - .catch(reason => ({ status: "rejected" as const, reason })), - ), - ); -} - -function assertDadaApiUrl(url: URL): void { - if (!ALLOWED_DADA_HOSTS.has(url.hostname)) { - throw new Error(`Blocked request to untrusted host: ${url.hostname}`); - } -} - -function transformAssetsResponse( - response: RawApiResponse, - meta?: FetchBaseQueryMeta, -): AssetsDataWithPagination { - const enrichedCryptoOrTokenCurrencies = convertApiAssets(response.cryptoOrTokenCurrencies); - - const nextCursor = meta?.response?.headers.get("x-ledger-next") || undefined; - - return { - ...response, - cryptoOrTokenCurrencies: enrichedCryptoOrTokenCurrencies, - pagination: { - nextCursor, - }, - }; -} - -function emptyAssetsData(): AssetsData { - return { - cryptoAssets: {}, - networks: {}, - cryptoOrTokenCurrencies: {}, - interestRates: {}, - markets: {}, - currenciesOrder: { metaCurrencyIds: [], key: "", order: "" }, - }; -} - -export function buildAssetsQueryParams( - queryArg: GetAssetsDataParams, - opts?: { pageSize?: number; cursor?: string }, -): Record { - return { - pageSize: opts?.pageSize ?? 100, - ...(opts?.cursor && { cursor: opts.cursor }), - ...(queryArg.useCase && { transaction: queryArg.useCase }), - ...(queryArg.currencyIds && - queryArg.currencyIds.length > 0 && { - currencyIds: queryArg.currencyIds, - }), - ...(queryArg.networkIds && - queryArg.networkIds.length > 0 && { - networkIds: queryArg.networkIds.join(","), - }), - ...(queryArg.categories && - queryArg.categories.length > 0 && { - categories: queryArg.categories.join(","), - }), - ...(queryArg.search && { search: queryArg.search }), - product: queryArg.product, - minVersion: queryArg.version, - ...(queryArg.includeTestNetworks && { includeTestNetworks: queryArg.includeTestNetworks }), - additionalData: queryArg.additionalData || [ - AssetsAdditionalData.Apy, - AssetsAdditionalData.MarketTrend, - ], - }; -} - -function resolveBaseUrl(queryArg: { isStaging?: boolean }): string { - return queryArg.isStaging ? getEnv("DADA_API_STAGING") : getEnv("DADA_API_PROD"); -} - -async function fetchAssetsPage( - baseUrl: string, - queryArg: GetAssetsDataParams, -): Promise { - const params = buildAssetsQueryParams(queryArg); - const url = new URL(`${baseUrl}/assets`); - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - url.searchParams.set(key, Array.isArray(value) ? value.join(",") : String(value)); - } - } - - assertDadaApiUrl(url); - const response = await fetch(url.toString()); - - if (!response.ok) { - throw new Error(`DADA fetch failed: ${response.status} ${response.statusText}`); - } - - const raw: RawApiResponse = await response.json(); - const enrichedCryptoOrTokenCurrencies = convertApiAssets(raw.cryptoOrTokenCurrencies); - - return { - ...raw, - cryptoOrTokenCurrencies: enrichedCryptoOrTokenCurrencies, - }; -} - -async function collectAllByCategory( - queryArg: GetAssetsByCategoryParams, - extract: (data: RawApiResponse) => string[], -): Promise> { - try { - const baseUrl = queryArg.isStaging ? getEnv("DADA_API_STAGING") : getEnv("DADA_API_PROD"); - const collected: string[] = []; - let cursor: string | undefined; - - do { - const url = new URL(`${baseUrl}/assets`); - url.searchParams.set("categories", queryArg.category); - url.searchParams.set("product", queryArg.product); - url.searchParams.set("pageSize", "100"); - url.searchParams.set("minVersion", queryArg.version); - if (cursor) { - url.searchParams.set("cursor", cursor); - } - - assertDadaApiUrl(url); - const response = await fetch(url.toString()); - - if (!response.ok) { - return { - error: { - status: response.status, - data: `Failed to fetch assets by category: ${response.statusText}`, - }, - }; - } - - const data: RawApiResponse = await response.json(); - collected.push(...extract(data)); - cursor = response.headers.get("x-ledger-next") || undefined; - } while (cursor); - - return { data: collected }; - } catch (error) { - return { - error: { - status: "FETCH_ERROR", - error: error instanceof Error ? error.message : "Unknown error", - }, - }; - } -} - -export function fetchAllAssetsByCategory(queryArg: GetAssetsByCategoryParams) { - return collectAllByCategory(queryArg, data => - Object.values(data.cryptoAssets).map(a => a.ticker), - ); -} - -export function fetchAllAssetCurrencyIdsByCategory(queryArg: GetAssetsByCategoryParams) { - return collectAllByCategory(queryArg, data => - Object.values(data.cryptoAssets).flatMap(meta => Object.values(meta.assetsIds)), - ); -} - -export const assetsDataApi = createApi({ - reducerPath: "assetsDataApi", - baseQuery: fetchBaseQuery({ - baseUrl: "", // Will be overridden in query - }), - tagTypes: [AssetsDataTags.Assets], - endpoints: build => ({ - getAssetsData: build.infiniteQuery({ - query: ({ pageParam, queryArg }) => ({ - url: `${resolveBaseUrl(queryArg)}/assets`, - params: buildAssetsQueryParams(queryArg, { cursor: pageParam?.cursor }), - }), - providesTags: [AssetsDataTags.Assets], - transformResponse: transformAssetsResponse, - infiniteQueryOptions: { - initialPageParam: { - cursor: "", - }, - getNextPageParam: lastPage => { - if (lastPage.pagination.nextCursor) { - return { - cursor: lastPage.pagination.nextCursor, - }; - } else { - return undefined; - } - }, - }, - }), - getAssetData: build.query({ - query: queryArg => ({ - url: `${resolveBaseUrl(queryArg)}/assets`, - params: buildAssetsQueryParams(queryArg, { pageSize: 1 }), - }), - providesTags: [AssetsDataTags.Assets], - transformResponse: transformAssetsResponse, - }), - getAssetsByCategory: build.query({ - queryFn: async queryArg => { - return fetchAllAssetsByCategory(queryArg); - }, - keepUnusedDataFor: ONE_DAY_IN_SECONDS, - }), - getAssetCurrencyIdsByCategory: build.query({ - queryFn: async queryArg => { - return fetchAllAssetCurrencyIdsByCategory(queryArg); - }, - keepUnusedDataFor: ONE_DAY_IN_SECONDS, - }), - getChunkedAssetsData: build.query({ - queryFn: async queryArg => { - try { - const chunks = chunkCurrencyIds(queryArg.currencyIds ?? []); - const baseUrl = resolveBaseUrl(queryArg); - - if (chunks.length === 0) { - return { data: emptyAssetsData() }; - } - - const results = await allSettled( - chunks.map(chunkIds => - fetchAssetsPage(baseUrl, { ...queryArg, currencyIds: chunkIds }), - ), - ); - - const responses = results.flatMap(r => (r.status === "fulfilled" ? [r.value] : [])); - - if (responses.length === 0) { - const firstError = results.find(r => r.status === "rejected"); - const reason = firstError?.status === "rejected" ? firstError.reason : undefined; - return { - error: { - status: "FETCH_ERROR", - error: reason instanceof Error ? reason.message : "All DADA chunks failed", - }, - }; - } - - const merged = responses.reduce((acc, res) => { - deepMergeCryptoAssets(acc.cryptoAssets, res.cryptoAssets); - Object.assign(acc.networks, res.networks); - Object.assign(acc.cryptoOrTokenCurrencies, res.cryptoOrTokenCurrencies); - Object.assign(acc.interestRates, res.interestRates); - Object.assign(acc.markets, res.markets); - acc.currenciesOrder.metaCurrencyIds.push(...res.currenciesOrder.metaCurrencyIds); - return acc; - }, emptyAssetsData()); - - return { data: merged }; - } catch (error) { - return { - error: { - status: "FETCH_ERROR", - error: error instanceof Error ? error.message : "Unknown error", - }, - }; - } - }, - providesTags: [AssetsDataTags.Assets], - keepUnusedDataFor: ONE_DAY_IN_SECONDS, - }), - }), -}); - -export const { - useGetAssetsDataInfiniteQuery, - useGetAssetDataQuery, - useGetAssetsByCategoryQuery, - useGetAssetCurrencyIdsByCategoryQuery, - useGetChunkedAssetsDataQuery, -} = assetsDataApi; +export * from "@domain/api-aggregated-assets"; diff --git a/libs/ledger-live-common/src/dada-client/state-manager/types.ts b/libs/ledger-live-common/src/dada-client/state-manager/types.ts index f04637e82b4f..b2cf51a6dcb5 100644 --- a/libs/ledger-live-common/src/dada-client/state-manager/types.ts +++ b/libs/ledger-live-common/src/dada-client/state-manager/types.ts @@ -1,47 +1,13 @@ -import { AssetsData } from "../entities"; - -export enum AssetsDataTags { - Assets = "Assets", -} - -export enum AssetsAdditionalData { - Apy = "apy", - MarketTrend = "marketTrend", -} - -export enum AssetCategory { - Stablecoins = "stablecoins", - Stocks = "stocks", -} - -export interface GetAssetsDataParams { - search?: string; - currencyIds?: string[]; - networkIds?: readonly string[]; - categories?: AssetCategory[]; - useCase?: string; - product: "llm" | "lld"; - version: string; - isStaging?: boolean; - additionalData?: AssetsAdditionalData[]; - includeTestNetworks?: boolean; -} - -export interface PageParam { - cursor?: string; -} - -export interface AssetsDataWithPagination extends AssetsData { - pagination: { - nextCursor?: string; - }; -} - -export const ONE_DAY_IN_SECONDS = 86_400; - -export interface GetAssetsByCategoryParams { - category: AssetCategory; - product: "llm" | "lld"; - version: string; - isStaging?: boolean; -} +export { + AssetCategory, + AssetsAdditionalData, + AssetsDataTags, + ONE_DAY_IN_SECONDS, +} from "@domain/api-aggregated-assets"; +export type { + AssetsData, + AssetsDataWithPagination, + GetAssetsByCategoryParams, + GetAssetsDataParams, + PageParam, +} from "@domain/api-aggregated-assets"; diff --git a/libs/ledger-live-common/src/dada-client/types/trend.ts b/libs/ledger-live-common/src/dada-client/types/trend.ts index 593c8879105a..62fb2b10aea8 100644 --- a/libs/ledger-live-common/src/dada-client/types/trend.ts +++ b/libs/ledger-live-common/src/dada-client/types/trend.ts @@ -1 +1 @@ -export type ApyType = "NRR" | "APY" | "APR"; +export type { ApyType } from "@domain/entity-interest-rate"; diff --git a/libs/ledger-live-common/src/dada-client/utils/__test__/chunkCurrencyIds.test.ts b/libs/ledger-live-common/src/dada-client/utils/__test__/chunkCurrencyIds.test.ts deleted file mode 100644 index 4b51d87b3f5c..000000000000 --- a/libs/ledger-live-common/src/dada-client/utils/__test__/chunkCurrencyIds.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { chunkCurrencyIds } from "../chunkCurrencyIds"; - -const makeIds = (n: number, prefix = "cur") => Array.from({ length: n }, (_, i) => `${prefix}${i}`); - -describe("chunkCurrencyIds", () => { - it("should return an empty array when given no IDs", () => { - expect(chunkCurrencyIds([])).toEqual([]); - }); - - it("should return a single chunk with one ID", () => { - expect(chunkCurrencyIds(["bitcoin"])).toEqual([["bitcoin"]]); - }); - - it("should keep 25 IDs in one chunk with default size", () => { - const ids = makeIds(25); - expect(chunkCurrencyIds(ids).map(c => c.length)).toEqual([25]); - }); - - it("should split 75 IDs into 3 chunks of 25", () => { - const ids = makeIds(75); - const chunks = chunkCurrencyIds(ids); - - expect(chunks.map(c => c.length)).toEqual([25, 25, 25]); - expect(chunks.flat()).toEqual(ids); - }); - - it("should handle a non-divisible count (last chunk smaller)", () => { - const ids = makeIds(30); - const chunks = chunkCurrencyIds(ids); - - expect(chunks.map(c => c.length)).toEqual([25, 5]); - expect(chunks.flat()).toEqual(ids); - }); - - it("should respect a custom chunk size", () => { - const ids = makeIds(10, "id"); - const chunks = chunkCurrencyIds(ids, 3); - - expect(chunks.map(c => c.length)).toEqual([3, 3, 3, 1]); - }); - - it.each([0, -1, NaN, Infinity])("should throw RangeError for invalid size %s", size => { - expect(() => chunkCurrencyIds(["a"], size)).toThrow(RangeError); - }); -}); diff --git a/libs/ledger-live-common/src/dada-client/utils/__test__/deepMergeCryptoAssets.test.ts b/libs/ledger-live-common/src/dada-client/utils/__test__/deepMergeCryptoAssets.test.ts deleted file mode 100644 index cc4502bb4d8d..000000000000 --- a/libs/ledger-live-common/src/dada-client/utils/__test__/deepMergeCryptoAssets.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { deepMergeCryptoAssets } from "../deepMergeCryptoAssets"; -import type { CryptoAssetMeta } from "../../entities"; - -type MetaMap = Record; - -const makeMeta = (id: string, assetsIds: Record): CryptoAssetMeta => ({ - id, - ticker: id.toUpperCase(), - name: id, - assetsIds, -}); - -describe("deepMergeCryptoAssets", () => { - it("should add new meta-currencies from source", () => { - const target: MetaMap = {}; - deepMergeCryptoAssets(target, { eth: makeMeta("eth", { ethereum: "ethereum" }) }); - - expect(target.eth.assetsIds).toEqual({ ethereum: "ethereum" }); - }); - - it("should merge assetsIds when same meta-currency exists in both", () => { - const target: MetaMap = { eth: makeMeta("eth", { ethereum: "ethereum" }) }; - deepMergeCryptoAssets(target, { eth: makeMeta("eth", { arbitrum: "arbitrum", base: "base" }) }); - - expect(target.eth.assetsIds).toEqual({ - ethereum: "ethereum", - arbitrum: "arbitrum", - base: "base", - }); - }); - - it("should overwrite assetsIds entries from source", () => { - const target: MetaMap = { eth: makeMeta("eth", { ethereum: "ethereum-v1" }) }; - deepMergeCryptoAssets(target, { - eth: makeMeta("eth", { ethereum: "ethereum-v2", optimism: "optimism" }), - }); - - expect(target.eth.assetsIds.ethereum).toBe("ethereum-v2"); - expect(target.eth.assetsIds.optimism).toBe("optimism"); - }); - - it("should handle both new and existing meta-currencies in one call", () => { - const target: MetaMap = { eth: makeMeta("eth", { ethereum: "ethereum" }) }; - deepMergeCryptoAssets(target, { - eth: makeMeta("eth", { arbitrum: "arbitrum" }), - btc: makeMeta("btc", { bitcoin: "bitcoin" }), - }); - - expect(Object.keys(target)).toEqual(["eth", "btc"]); - expect(target.eth.assetsIds).toEqual({ ethereum: "ethereum", arbitrum: "arbitrum" }); - expect(target.btc.assetsIds).toEqual({ bitcoin: "bitcoin" }); - }); - - it("should be a no-op for empty source", () => { - const target: MetaMap = { eth: makeMeta("eth", { ethereum: "ethereum" }) }; - deepMergeCryptoAssets(target, {}); - - expect(target.eth.assetsIds).toEqual({ ethereum: "ethereum" }); - }); - - it("should populate empty target from source", () => { - const target: MetaMap = {}; - const source = { btc: makeMeta("btc", { bitcoin: "bitcoin" }) }; - deepMergeCryptoAssets(target, source); - - expect(target.btc).toEqual(source.btc); - }); -}); diff --git a/libs/ledger-live-common/src/dada-client/utils/chunkCurrencyIds.ts b/libs/ledger-live-common/src/dada-client/utils/chunkCurrencyIds.ts deleted file mode 100644 index 02d208e12491..000000000000 --- a/libs/ledger-live-common/src/dada-client/utils/chunkCurrencyIds.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type CurrencyIdChunks = string[][]; - -const DEFAULT_CHUNK_SIZE = 25; - -export function chunkCurrencyIds( - ids: string[], - size: number = DEFAULT_CHUNK_SIZE, -): CurrencyIdChunks { - if (!Number.isFinite(size) || size <= 0) { - throw new RangeError(`chunkCurrencyIds: size must be a positive finite number, got ${size}`); - } - - const chunks: CurrencyIdChunks = []; - - for (let i = 0; i < ids.length; i += size) { - chunks.push(ids.slice(i, i + size)); - } - - return chunks; -} diff --git a/libs/ledger-live-common/src/dada-client/utils/deepMergeCryptoAssets.ts b/libs/ledger-live-common/src/dada-client/utils/deepMergeCryptoAssets.ts deleted file mode 100644 index 952f0c5ce360..000000000000 --- a/libs/ledger-live-common/src/dada-client/utils/deepMergeCryptoAssets.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { CryptoAssetMeta } from "../entities"; - -/** - * Deep-merges two `cryptoAssets` maps so that `assetsIds` entries - * from different pages/chunks are accumulated instead of overwritten. - */ -export function deepMergeCryptoAssets( - target: Record, - source: Record, -): void { - for (const [metaId, meta] of Object.entries(source)) { - if (target[metaId]) { - Object.assign(target[metaId].assetsIds, meta.assetsIds); - } else { - target[metaId] = { ...meta, assetsIds: { ...meta.assetsIds } }; - } - } -} diff --git a/libs/ledger-live-common/src/dada-client/utils/errorUtils.ts b/libs/ledger-live-common/src/dada-client/utils/errorUtils.ts index 5f5c8b261197..6958e7e6b6de 100644 --- a/libs/ledger-live-common/src/dada-client/utils/errorUtils.ts +++ b/libs/ledger-live-common/src/dada-client/utils/errorUtils.ts @@ -1,57 +1,8 @@ -import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; - -/** - * Type guard to check if error is a FetchBaseQueryError - */ -export function isFetchBaseQueryError(error: unknown): error is FetchBaseQueryError { - return typeof error === "object" && error !== null && "status" in error; -} - -/** - * Check if the error is a network connectivity error (no internet, timeout, etc.) - */ -export function isNetworkError(error: unknown): boolean { - if (!isFetchBaseQueryError(error)) { - return false; - } - return error.status === "FETCH_ERROR" || error.status === "TIMEOUT_ERROR"; -} - -/** - * Check if the error is an API error with HTTP status code (4xx, 5xx) - */ -export function isApiError(error: unknown): boolean { - if (!isFetchBaseQueryError(error)) { - return false; - } - return typeof error.status === "number"; -} - -/** - * Get HTTP status code from API error, or undefined if not an API error - */ -export function getApiErrorStatus(error: unknown): number | undefined { - if (!isFetchBaseQueryError(error) || typeof error.status !== "number") { - return undefined; - } - return error.status; -} - -export type ErrorInfo = { - hasError: boolean; - isNetworkError: boolean; - isApiError: boolean; - apiStatus: number | undefined; -}; - -/** - * Parse error into a structured ErrorInfo object - */ -export function parseError(error: unknown): ErrorInfo { - return { - hasError: !!error, - isNetworkError: isNetworkError(error), - isApiError: isApiError(error), - apiStatus: getApiErrorStatus(error), - }; -} +export { + getApiErrorStatus, + isApiError, + isFetchBaseQueryError, + isNetworkError, + parseError, +} from "@domain/api-aggregated-assets"; +export type { ErrorInfo } from "@domain/api-aggregated-assets"; diff --git a/libs/ledger-live-common/src/dada-client/utils/mergeAssetsDataPages.ts b/libs/ledger-live-common/src/dada-client/utils/mergeAssetsDataPages.ts index 22a71e6c22c2..6f9ce4089635 100644 --- a/libs/ledger-live-common/src/dada-client/utils/mergeAssetsDataPages.ts +++ b/libs/ledger-live-common/src/dada-client/utils/mergeAssetsDataPages.ts @@ -1,35 +1 @@ -import { AssetsDataWithPagination } from "../state-manager/types"; - -const emptyData = (): AssetsDataWithPagination => ({ - cryptoAssets: {}, - networks: {}, - cryptoOrTokenCurrencies: {}, - interestRates: {}, - markets: {}, - currenciesOrder: { - metaCurrencyIds: [], - key: "", - order: "", - }, - pagination: { nextCursor: "" }, -}); - -export function mergeAssetsDataPages( - pages: AssetsDataWithPagination[] | undefined, -): AssetsDataWithPagination | undefined { - return pages?.reduce((acc, page) => { - Object.assign(acc.cryptoAssets, page.cryptoAssets); - Object.assign(acc.networks, page.networks); - Object.assign(acc.cryptoOrTokenCurrencies, page.cryptoOrTokenCurrencies); - Object.assign(acc.interestRates, page.interestRates); - Object.assign(acc.markets, page.markets); - - acc.currenciesOrder.metaCurrencyIds.push(...page.currenciesOrder.metaCurrencyIds); - - acc.currenciesOrder.key = page.currenciesOrder.key; - acc.currenciesOrder.order = page.currenciesOrder.order; - acc.pagination.nextCursor = page.pagination.nextCursor; - - return acc; - }, emptyData()); -} +export { mergeAssetsDataPages } from "@domain/api-aggregated-assets"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ab72027c58b..9c9b9842b2a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3514,6 +3514,34 @@ importers: version: '@typescript/typescript6@6.0.2' domain/api/aggregated-assets: + dependencies: + '@domain/api-currency-token': + specifier: workspace:^ + version: link:../currency-token + '@domain/entity-aggregated-asset': + specifier: workspace:* + version: link:../../entity/aggregated-asset + '@domain/entity-currency': + specifier: workspace:^ + version: link:../../entity/currency + '@domain/entity-currency-crypto': + specifier: workspace:^ + version: link:../../entity/currency-crypto + '@domain/entity-currency-token': + specifier: workspace:^ + version: link:../../entity/currency-token + '@domain/entity-interest-rate': + specifier: workspace:* + version: link:../../entity/interest-rate + '@reduxjs/toolkit': + specifier: 'catalog:' + version: 2.11.2(react-redux@9.2.0(react@19.1.4))(react@19.1.4) + '@shared/api-services': + specifier: workspace:* + version: link:../../../shared/api-services + '@shared/env': + specifier: workspace:* + version: link:../../../shared/env devDependencies: '@jest/globals': specifier: 'catalog:' @@ -3533,6 +3561,12 @@ importers: jest: specifier: 'catalog:' version: 30.2.0(@types/node@24.12.0) + react: + specifier: 19.1.4 + version: 19.1.4 + react-redux: + specifier: 'catalog:' + version: 9.2.0(react@19.1.4) typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' @@ -3876,6 +3910,10 @@ importers: version: '@typescript/typescript6@6.0.2' domain/entity/aggregated-asset: + dependencies: + zod: + specifier: 'catalog:' + version: 4.3.6 devDependencies: '@jest/globals': specifier: 'catalog:' @@ -4158,6 +4196,10 @@ importers: version: '@typescript/typescript6@6.0.2' domain/entity/interest-rate: + dependencies: + zod: + specifier: 'catalog:' + version: 4.3.6 devDependencies: '@jest/globals': specifier: 'catalog:' @@ -9586,12 +9628,18 @@ importers: '@cardano-foundation/ledgerjs-hw-app-cardano': specifier: ^7.1.2 version: 7.1.2 + '@domain/api-aggregated-assets': + specifier: workspace:^ + version: link:../../domain/api/aggregated-assets '@domain/api-currency-token': specifier: workspace:^ version: link:../../domain/api/currency-token '@domain/api-swap-quotes': specifier: workspace:^ version: link:../../domain/api/swap-quotes + '@domain/entity-aggregated-asset': + specifier: workspace:^ + version: link:../../domain/entity/aggregated-asset '@domain/entity-client-identity': specifier: workspace:^ version: link:../../domain/entity/client-identity @@ -9610,6 +9658,9 @@ importers: '@domain/entity-currency-unit': specifier: workspace:^ version: link:../../domain/entity/currency-unit + '@domain/entity-interest-rate': + specifier: workspace:^ + version: link:../../domain/entity/interest-rate '@features/platform-env': specifier: workspace:^ version: link:../../features/platform/env diff --git a/shared/api-services/src/services/dada/api.test.ts b/shared/api-services/src/services/dada/api.test.ts new file mode 100644 index 000000000000..5117b91dc2fe --- /dev/null +++ b/shared/api-services/src/services/dada/api.test.ts @@ -0,0 +1,19 @@ +import { dadaApi } from "./api"; + +// Captured at import time: use-case packages inject into this same api object. +const OWN_ENDPOINT_NAMES = Object.keys(dadaApi.endpoints); + +describe("dadaApi", () => { + it("has the correct reducer path", () => { + expect(dadaApi.reducerPath).toBe("assetsDataApi"); + }); + + it("declares no endpoints of its own", () => { + expect(OWN_ENDPOINT_NAMES).toHaveLength(0); + }); + + it("declares no tag types of its own", () => { + // Use cases widen the union with enhanceEndpoints({ addTagTypes }). + expect(dadaApi.util.getRunningQueriesThunk).toBeDefined(); + }); +}); diff --git a/shared/api-services/src/services/dada/api.ts b/shared/api-services/src/services/dada/api.ts new file mode 100644 index 000000000000..d177e53981e1 --- /dev/null +++ b/shared/api-services/src/services/dada/api.ts @@ -0,0 +1,23 @@ +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import { DADA_REDUCER_PATH } from "./constants"; + +/** + * Endpoint-less DADA api. Register it in the store; use cases add endpoints and tags to this same + * object — see {@link https://github.com/LedgerHQ/ledger-live/blob/develop/shared/api-services/README.md}. + * + * Unlike the other services this one has no `extraArgument` contract yet, so it declares no + * `schema.ts`/`types.ts`. DADA endpoints currently build absolute URLs themselves and pick prod or + * staging per request from an `isStaging` query arg, so there is nothing for the base query to own. + * + * TODO: migrate the URL to `extraArgument` so the app resolves prod/staging once at store config, + * matching every other service here. That drops `isStaging` from the query args and touches both + * apps' store setup, so it is deliberately not part of the relocation (LIVE-35226). + */ +export const dadaApi = createApi({ + reducerPath: DADA_REDUCER_PATH, + baseQuery: fetchBaseQuery({ baseUrl: "" }), + tagTypes: [], + endpoints: () => ({}), +}); + +export type DadaApi = typeof dadaApi; diff --git a/shared/api-services/src/services/dada/constants.ts b/shared/api-services/src/services/dada/constants.ts new file mode 100644 index 000000000000..b0a8ea90ccce --- /dev/null +++ b/shared/api-services/src/services/dada/constants.ts @@ -0,0 +1,9 @@ +/** + * RTK Query reducer path for the DADA service. + * + * FROZEN as `assetsDataApi` rather than named after the service: `createCurrencyDataSelector` in the + * feature layer hand-scans `state.assetsDataApi.queries` by string, and Storybook stories preload + * that exact key. Renaming it produces no type error and silently returns `undefined` for every + * market and interest-rate lookup. + */ +export const DADA_REDUCER_PATH = "assetsDataApi"; diff --git a/shared/api-services/src/services/dada/index.ts b/shared/api-services/src/services/dada/index.ts new file mode 100644 index 000000000000..090660259ece --- /dev/null +++ b/shared/api-services/src/services/dada/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export { DADA_REDUCER_PATH } from "./constants"; diff --git a/shared/api-services/src/services/index.ts b/shared/api-services/src/services/index.ts index 70303daee877..d92eaa7cde10 100644 --- a/shared/api-services/src/services/index.ts +++ b/shared/api-services/src/services/index.ts @@ -1,5 +1,6 @@ export * from "./cal"; export * from "./coinmarketcap"; export * from "./countervalues"; +export * from "./dada"; export * from "./push-devices"; export * from "./swap";