diff --git a/src/datasources/balances-api/balances-api.manager.spec.ts b/src/datasources/balances-api/balances-api.manager.spec.ts index 2bf687b475..5029820314 100644 --- a/src/datasources/balances-api/balances-api.manager.spec.ts +++ b/src/datasources/balances-api/balances-api.manager.spec.ts @@ -158,6 +158,7 @@ describe('Balances API Manager Tests', () => { }); configApiMock.getChain.mockResolvedValue(rawify(chain)); dataSourceMock.get.mockResolvedValue([]); + coingeckoApiMock.getTokenPrices.mockResolvedValue(rawify([])); const balancesApiManager = new BalancesApiManager( configurationService, configApiMock, @@ -204,12 +205,10 @@ describe('Balances API Manager Tests', () => { describe('getFiatCodes checks', () => { it('should return the intersection of all providers supported currencies', async () => { - zerionBalancesApiMock.getFiatCodes.mockResolvedValue([ - 'EUR', - 'GBP', - 'ETH', - ]); - coingeckoApiMock.getFiatCodes.mockResolvedValue(['GBP']); + zerionBalancesApiMock.getFiatCodes.mockResolvedValue( + rawify(['EUR', 'GBP', 'ETH']), + ); + coingeckoApiMock.getFiatCodes.mockResolvedValue(rawify(['GBP'])); const manager = new BalancesApiManager( configurationService, configApiMock, diff --git a/src/datasources/balances-api/balances-api.manager.ts b/src/datasources/balances-api/balances-api.manager.ts index 3e70f89d85..cbd397f9ac 100644 --- a/src/datasources/balances-api/balances-api.manager.ts +++ b/src/datasources/balances-api/balances-api.manager.ts @@ -15,6 +15,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { intersection } from 'lodash'; import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface'; import { ChainSchema } from '@/domain/chains/entities/schemas/chain.schema'; +import { z } from 'zod'; +import { type Raw, rawify } from '@/validation/entities/raw.entity'; @Injectable() export class BalancesApiManager implements IBalancesApiManager { @@ -72,12 +74,12 @@ export class BalancesApiManager implements IBalancesApiManager { } } - async getFiatCodes(): Promise { + async getFiatCodes(): Promise> { const [zerionFiatCodes, safeFiatCodes] = await Promise.all([ this.zerionBalancesApi.getFiatCodes(), this.coingeckoApi.getFiatCodes(), - ]); - return intersection(zerionFiatCodes, safeFiatCodes).sort(); + ]).then(z.array(z.array(z.string())).parse); + return rawify(intersection(zerionFiatCodes, safeFiatCodes).sort()); } private async _getSafeBalancesApi(chainId: string): Promise { diff --git a/src/datasources/balances-api/coingecko-api.service.spec.ts b/src/datasources/balances-api/coingecko-api.service.spec.ts index da70bf8159..c174dd8c4b 100644 --- a/src/datasources/balances-api/coingecko-api.service.spec.ts +++ b/src/datasources/balances-api/coingecko-api.service.spec.ts @@ -3,7 +3,10 @@ import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; import { CoingeckoApi } from '@/datasources/balances-api/coingecko-api.service'; import { faker } from '@faker-js/faker'; import type { CacheFirstDataSource } from '../cache/cache.first.data.source'; -import type { AssetPrice } from '@/datasources/balances-api/entities/asset-price.entity'; +import { + AssetPricesSchema, + type AssetPrice, +} from '@/datasources/balances-api/entities/asset-price.entity'; import type { ICacheService } from '@/datasources/cache/cache.service.interface'; import type { INetworkService } from '@/datasources/network/network.service.interface'; import { sortBy } from 'lodash'; @@ -504,15 +507,17 @@ describe('CoingeckoAPI', () => { status: 200, }); - const assetPrices = await service.getTokenPrices({ - chain, - tokenAddresses: [ - firstTokenAddress, - secondTokenAddress, - thirdTokenAddress, - ], - fiatCode, - }); + const assetPrices = await service + .getTokenPrices({ + chain, + tokenAddresses: [ + firstTokenAddress, + secondTokenAddress, + thirdTokenAddress, + ], + fiatCode, + }) + .then(AssetPricesSchema.parse); expect(sortBy(assetPrices, (i) => Object.keys(i)[0])).toEqual( sortBy( @@ -606,15 +611,17 @@ describe('CoingeckoAPI', () => { status: 200, }); - const assetPrices = await service.getTokenPrices({ - chain, - tokenAddresses: [ - firstTokenAddress, - secondTokenAddress, - thirdTokenAddress, - ], - fiatCode, - }); + const assetPrices = await service + .getTokenPrices({ + chain, + tokenAddresses: [ + firstTokenAddress, + secondTokenAddress, + thirdTokenAddress, + ], + fiatCode, + }) + .then(AssetPricesSchema.parse); expect(sortBy(assetPrices, (i) => Object.keys(i)[0])).toEqual( sortBy( diff --git a/src/datasources/balances-api/coingecko-api.service.ts b/src/datasources/balances-api/coingecko-api.service.ts index a04a7ae4bb..79a96800b8 100644 --- a/src/datasources/balances-api/coingecko-api.service.ts +++ b/src/datasources/balances-api/coingecko-api.service.ts @@ -22,7 +22,7 @@ import { NetworkResponseError } from '@/datasources/network/entities/network.err import { asError } from '@/logging/utils'; import { Chain } from '@/domain/chains/entities/chain.entity'; import { z } from 'zod'; -import type { Raw } from '@/validation/entities/raw.entity'; +import { rawify, type Raw } from '@/validation/entities/raw.entity'; /** * TODO: Move all usage of Raw to NetworkService/CacheFirstDataSource after fully migrated @@ -120,6 +120,7 @@ export class CoingeckoApi implements IPricesApi { async getNativeCoinPrice(args: { chain: Chain; fiatCode: string; + // TODO: Change to Raw when cache service is migrated }): Promise { try { const nativeCoinId = args.chain.pricesProvider.nativeCoin; @@ -177,7 +178,7 @@ export class CoingeckoApi implements IPricesApi { chain: Chain; tokenAddresses: string[]; fiatCode: string; - }): Promise { + }): Promise> { try { const chainName = args.chain.pricesProvider.chainName; if (chainName == null) { @@ -204,18 +205,18 @@ export class CoingeckoApi implements IPricesApi { }) : []; - return [pricesFromCache, pricesFromNetwork].flat(); + return rawify([pricesFromCache, pricesFromNetwork].flat()); } catch (error) { // Error at this level are logged out, but not thrown to the upper layers. // The service won't throw an error if a single token price retrieval fails. this.loggingService.error( `Error getting token prices: ${asError(error)} `, ); - return []; + return rawify([]); } } - async getFiatCodes(): Promise { + async getFiatCodes(): Promise> { try { const cacheDir = CacheRouter.getPriceFiatCodesCacheDir(); const url = `${this.baseUrl}/simple/supported_vs_currencies`; @@ -234,12 +235,12 @@ export class CoingeckoApi implements IPricesApi { expireTimeSeconds: this.defaultExpirationTimeInSeconds, }) .then(z.array(z.string()).parse); - return result.map((item) => item.toUpperCase()); + return rawify(result.map((item) => item.toUpperCase())); } catch (error) { this.loggingService.error( `CoinGecko error getting fiat codes: ${asError(error)} `, ); - return []; + return rawify([]); } } diff --git a/src/datasources/balances-api/entities/asset-price.entity.ts b/src/datasources/balances-api/entities/asset-price.entity.ts index 3be1dcdfb5..5fdc5ed23c 100644 --- a/src/datasources/balances-api/entities/asset-price.entity.ts +++ b/src/datasources/balances-api/entities/asset-price.entity.ts @@ -4,3 +4,5 @@ export type AssetPrice = z.infer; // TODO: Enforce Ethereum address keys (and maybe checksum them) export const AssetPriceSchema = z.record(z.record(z.number().nullable())); + +export const AssetPricesSchema = z.array(AssetPriceSchema); diff --git a/src/datasources/balances-api/prices-api.interface.ts b/src/datasources/balances-api/prices-api.interface.ts index 010fb97a0b..e1dba4ace2 100644 --- a/src/datasources/balances-api/prices-api.interface.ts +++ b/src/datasources/balances-api/prices-api.interface.ts @@ -1,5 +1,6 @@ import type { AssetPrice } from '@/datasources/balances-api/entities/asset-price.entity'; import type { Chain } from '@/domain/chains/entities/chain.entity'; +import type { Raw } from '@/validation/entities/raw.entity'; export const IPricesApi = Symbol('IPricesApi'); @@ -13,7 +14,7 @@ export interface IPricesApi { chain: Chain; tokenAddresses: string[]; fiatCode: string; - }): Promise; + }): Promise>; - getFiatCodes(): Promise; + getFiatCodes(): Promise>; } diff --git a/src/datasources/balances-api/safe-balances-api.service.ts b/src/datasources/balances-api/safe-balances-api.service.ts index 720b3cca39..758cf9bf4a 100644 --- a/src/datasources/balances-api/safe-balances-api.service.ts +++ b/src/datasources/balances-api/safe-balances-api.service.ts @@ -3,7 +3,10 @@ import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.sourc import { CacheRouter } from '@/datasources/cache/cache.router'; import { ICacheService } from '@/datasources/cache/cache.service.interface'; import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; -import { Balance } from '@/domain/balances/entities/balance.entity'; +import { + Balance, + BalancesSchema, +} from '@/domain/balances/entities/balance.entity'; import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; import { getNumberString } from '@/domain/common/utils/utils'; import { Page } from '@/domain/entities/page.entity'; @@ -11,7 +14,13 @@ import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; import { IPricesApi } from '@/datasources/balances-api/prices-api.interface'; import { Injectable } from '@nestjs/common'; import { Chain } from '@/domain/chains/entities/chain.entity'; +import { rawify, type Raw } from '@/validation/entities/raw.entity'; +import { AssetPricesSchema } from '@/datasources/balances-api/entities/asset-price.entity'; +/** + * TODO: Move all usage of Raw to NetworkService/CacheFirstDataSource after fully migrated + * to "Raw" type implementation. + */ @Injectable() export class SafeBalancesApi implements IBalancesApi { private readonly defaultExpirationTimeInSeconds: number; @@ -54,27 +63,33 @@ export class SafeBalancesApi implements IBalancesApi { chain: Chain; trusted?: boolean; excludeSpam?: boolean; - }): Promise { + }): Promise> { try { const cacheDir = CacheRouter.getBalancesCacheDir({ chainId: this.chainId, ...args, }); const url = `${this.baseUrl}/api/v1/safes/${args.safeAddress}/balances/`; - const data = await this.dataSource.get({ - cacheDir, - url, - notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds, - networkRequest: { - params: { - trusted: args.trusted, - exclude_spam: args.excludeSpam, + const data = await this.dataSource + .get>({ + cacheDir, + url, + notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds, + networkRequest: { + params: { + trusted: args.trusted, + exclude_spam: args.excludeSpam, + }, }, - }, - expireTimeSeconds: this.defaultExpirationTimeInSeconds, - }); + expireTimeSeconds: this.defaultExpirationTimeInSeconds, + }) + .then(BalancesSchema.parse); - return this._mapBalances(data, args.fiatCode, args.chain); + return this._mapBalances({ + balances: data, + fiatCode: args.fiatCode, + chain: args.chain, + }); } catch (error) { throw this.httpErrorFactory.from(error); } @@ -94,14 +109,14 @@ export class SafeBalancesApi implements IBalancesApi { offset?: number; trusted?: boolean; excludeSpam?: boolean; - }): Promise> { + }): Promise>> { try { const cacheDir = CacheRouter.getCollectiblesCacheDir({ chainId: this.chainId, ...args, }); const url = `${this.baseUrl}/api/v2/safes/${args.safeAddress}/collectibles/`; - return await this.dataSource.get({ + return await this.dataSource.get>>({ cacheDir, url, notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds, @@ -128,7 +143,7 @@ export class SafeBalancesApi implements IBalancesApi { await this.cacheService.deleteByKey(key); } - async getFiatCodes(): Promise { + async getFiatCodes(): Promise> { return this.coingeckoApi.getFiatCodes(); } @@ -142,41 +157,43 @@ export class SafeBalancesApi implements IBalancesApi { }); } - private async _mapBalances( - balances: Balance[], - fiatCode: string, - chain: Chain, - ): Promise { - const tokenAddresses = balances + private async _mapBalances(args: { + balances: Balance[]; + fiatCode: string; + chain: Chain; + }): Promise> { + const tokenAddresses = args.balances .map((balance) => balance.tokenAddress) .filter((address): address is `0x${string}` => address !== null); - const assetPrices = await this.coingeckoApi.getTokenPrices({ - chain, - fiatCode, - tokenAddresses, - }); + const assetPrices = await this.coingeckoApi + .getTokenPrices({ + chain: args.chain, + fiatCode: args.fiatCode, + tokenAddresses, + }) + .then(AssetPricesSchema.parse); - const lowerCaseAssetPrices = assetPrices?.map((assetPrice) => + const lowerCaseAssetPrices = assetPrices.map((assetPrice) => Object.fromEntries( Object.entries(assetPrice).map(([k, v]) => [k.toLowerCase(), v]), ), ); - return await Promise.all( - balances.map(async (balance) => { + const balances = await Promise.all( + args.balances.map(async (balance) => { const tokenAddress = balance.tokenAddress?.toLowerCase() ?? null; let price: number | null; if (tokenAddress === null) { price = await this.coingeckoApi.getNativeCoinPrice({ - chain, - fiatCode, + chain: args.chain, + fiatCode: args.fiatCode, }); } else { const found = lowerCaseAssetPrices.find( (assetPrice) => assetPrice[tokenAddress], ); - price = found?.[tokenAddress]?.[fiatCode.toLowerCase()] ?? null; + price = found?.[tokenAddress]?.[args.fiatCode.toLowerCase()] ?? null; } const fiatBalance = this._getFiatBalance(price, balance); return { @@ -186,6 +203,8 @@ export class SafeBalancesApi implements IBalancesApi { }; }), ); + + return rawify(balances); } private _getFiatBalance( diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index a7b35d7125..e7b25cf0a4 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -35,12 +35,17 @@ import { Page } from '@/domain/entities/page.entity'; import { DataSourceError } from '@/domain/errors/data-source.error'; import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { rawify, type Raw } from '@/validation/entities/raw.entity'; import { Inject, Injectable } from '@nestjs/common'; import { getAddress } from 'viem'; import { z } from 'zod'; export const IZerionBalancesApi = Symbol('IZerionBalancesApi'); +/** + * TODO: Move all usage of Raw to NetworkService/CacheFirstDataSource after fully migrated + * to "Raw" type implementation. + */ @Injectable() export class ZerionBalancesApi implements IBalancesApi { private static readonly COLLECTIBLES_SORTING = '-floor_price'; @@ -96,7 +101,7 @@ export class ZerionBalancesApi implements IBalancesApi { chain: Chain; safeAddress: `0x${string}`; fiatCode: string; - }): Promise { + }): Promise> { if (!this.fiatCodes.includes(args.fiatCode.toUpperCase())) { throw new DataSourceError( `Unsupported currency code: ${args.fiatCode}`, @@ -166,7 +171,7 @@ export class ZerionBalancesApi implements IBalancesApi { safeAddress: `0x${string}`; limit?: number; offset?: number; - }): Promise> { + }): Promise>> { const cacheDir = CacheRouter.getZerionCollectiblesCacheDir({ ...args, chainId: args.chain.chainId, @@ -219,8 +224,8 @@ export class ZerionBalancesApi implements IBalancesApi { private _mapBalances( chainName: string, zerionBalances: ZerionBalance[], - ): Balance[] { - return zerionBalances + ): Raw { + const balances = zerionBalances .filter((zb) => zb.attributes.flags.displayable) .map((zb) => { const implementation = zb.attributes.fungible_info.implementations.find( @@ -242,11 +247,12 @@ export class ZerionBalancesApi implements IBalancesApi { fiatConversion, }; }); + return rawify(balances); } - async getFiatCodes(): Promise { + async getFiatCodes(): Promise> { // Resolving to conform with interface - return Promise.resolve(this.fiatCodes); + return Promise.resolve(rawify(this.fiatCodes)); } private _mapErc20Balance( @@ -300,15 +306,15 @@ export class ZerionBalancesApi implements IBalancesApi { private _buildCollectiblesPage( next: string | null, data: ZerionCollectible[], - ): Page { + ): Raw> { // Zerion does not provide the items count. // Zerion does not provide a "previous" cursor. - return { + return rawify({ count: null, next: next ? this._decodeZerionPagination(next) : null, previous: null, results: this._mapCollectibles(data), - }; + }); } private _mapCollectibles( diff --git a/src/datasources/errors/http-error-factory.ts b/src/datasources/errors/http-error-factory.ts index 16d2e31d69..bd69892f6c 100644 --- a/src/datasources/errors/http-error-factory.ts +++ b/src/datasources/errors/http-error-factory.ts @@ -15,6 +15,7 @@ import { get } from 'lodash'; @Injectable() export class HttpErrorFactory { from(source: unknown): DataSourceError { + // TODO: Handle instances of ZodError, returning issue from it if (source instanceof NetworkResponseError) { const errorMessage = get(source, 'data.message', 'An error occurred'); return new DataSourceError(errorMessage, source.response.status); diff --git a/src/domain/balances/balances.repository.ts b/src/domain/balances/balances.repository.ts index d52f8d7c34..20447d380f 100644 --- a/src/domain/balances/balances.repository.ts +++ b/src/domain/balances/balances.repository.ts @@ -1,9 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBalancesRepository } from '@/domain/balances/balances.repository.interface'; -import { Balance } from '@/domain/balances/entities/balance.entity'; -import { BalanceSchema } from '@/domain/balances/entities/balance.entity'; +import { + Balance, + BalancesSchema, +} from '@/domain/balances/entities/balance.entity'; import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface'; import { Chain } from '@/domain/chains/entities/chain.entity'; +import { z } from 'zod'; @Injectable() export class BalancesRepository implements IBalancesRepository { @@ -24,7 +27,7 @@ export class BalancesRepository implements IBalancesRepository { args.safeAddress, ); const balances = await api.getBalances(args); - return balances.map((balance) => BalanceSchema.parse(balance)); + return BalancesSchema.parse(balances); } async clearBalances(args: { @@ -39,7 +42,9 @@ export class BalancesRepository implements IBalancesRepository { } async getFiatCodes(): Promise { - return this.balancesApiManager.getFiatCodes(); + return this.balancesApiManager + .getFiatCodes() + .then(z.array(z.string()).parse); } clearApi(chainId: string): void { diff --git a/src/domain/balances/entities/balance.entity.ts b/src/domain/balances/entities/balance.entity.ts index 5ebc104ec3..7b09290761 100644 --- a/src/domain/balances/entities/balance.entity.ts +++ b/src/domain/balances/entities/balance.entity.ts @@ -30,3 +30,5 @@ export const BalanceSchema = z.union([ NativeBalanceSchema.merge(FiatSchema), Erc20BalanceSchema.merge(FiatSchema), ]); + +export const BalancesSchema = z.array(BalanceSchema); diff --git a/src/domain/interfaces/balances-api.interface.ts b/src/domain/interfaces/balances-api.interface.ts index f92b16b595..de129b0f6f 100644 --- a/src/domain/interfaces/balances-api.interface.ts +++ b/src/domain/interfaces/balances-api.interface.ts @@ -2,6 +2,7 @@ import type { Balance } from '@/domain/balances/entities/balance.entity'; import type { Chain } from '@/domain/chains/entities/chain.entity'; import type { Collectible } from '@/domain/collectibles/entities/collectible.entity'; import type { Page } from '@/domain/entities/page.entity'; +import type { Raw } from '@/validation/entities/raw.entity'; export interface IBalancesApi { getBalances(args: { @@ -10,7 +11,7 @@ export interface IBalancesApi { chain: Chain; trusted?: boolean; excludeSpam?: boolean; - }): Promise; + }): Promise>; clearBalances(args: { chainId: string; @@ -24,12 +25,12 @@ export interface IBalancesApi { offset?: number; trusted?: boolean; excludeSpam?: boolean; - }): Promise>; + }): Promise>>; clearCollectibles(args: { chainId: string; safeAddress: `0x${string}`; }): Promise; - getFiatCodes(): Promise; + getFiatCodes(): Promise>; } diff --git a/src/domain/interfaces/balances-api.manager.interface.ts b/src/domain/interfaces/balances-api.manager.interface.ts index afed3057b7..f397b39985 100644 --- a/src/domain/interfaces/balances-api.manager.interface.ts +++ b/src/domain/interfaces/balances-api.manager.interface.ts @@ -1,5 +1,6 @@ import type { IApiManager } from '@/domain/interfaces/api.manager.interface'; import type { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; +import type { Raw } from '@/validation/entities/raw.entity'; export const IBalancesApiManager = Symbol('IBalancesApiManager'); @@ -22,5 +23,5 @@ export interface IBalancesApiManager extends IApiManager { * Gets the list of supported fiat codes. * @returns an alphabetically ordered list of uppercase strings representing the supported fiat codes. */ - getFiatCodes(): Promise; + getFiatCodes(): Promise>; } diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index 3331bd3adc..8e670d516c 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -800,7 +800,7 @@ describe('Balances Controller (Unit)', () => { }); }); - it(`500 error if validation fails`, async () => { + it(`503 error if validation fails`, async () => { const chainId = '1'; const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); @@ -830,13 +830,13 @@ describe('Balances Controller (Unit)', () => { await request(app.getHttpServer()) .get(`/v1/chains/${chainId}/safes/${safeAddress}/balances/usd`) - .expect(500) + .expect(503) .expect({ - statusCode: 500, - message: 'Internal server error', + code: 503, + message: 'Service unavailable', }); - expect(networkService.get.mock.calls.length).toBe(4); + expect(networkService.get.mock.calls.length).toBe(3); }); });