Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/modules/balances/balances.module.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: FSL-1.1-MIT
import { Module } from '@nestjs/common';
import { BalancesApiManager } from '@/modules/balances/datasources/balances-api.manager';
import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface';
Expand All @@ -20,6 +21,7 @@ import { BalancesService } from '@/modules/balances/routes/balances.service';
import { SafeRepositoryModule } from '@/modules/safe/domain/safe.repository.interface';
import { ChainsModule } from '@/modules/chains/chains.module';
import { TxAuthNetworkModule } from '@/datasources/network/tx-auth.network.module';
import { ZerionModule } from '@/modules/zerion/zerion.module';

@Module({
imports: [
Expand All @@ -28,6 +30,7 @@ import { TxAuthNetworkModule } from '@/datasources/network/tx-auth.network.modul
TxAuthNetworkModule,
ChainsModule,
SafeRepositoryModule,
ZerionModule,
],
controllers: [BalancesController],
providers: [
Expand Down
276 changes: 243 additions & 33 deletions src/modules/balances/datasources/zerion-balances-api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,41 @@
// SPDX-License-Identifier: FSL-1.1-MIT
import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service';
import { ZerionBalancesApi } from '@/modules/balances/datasources/zerion-balances-api.service';
import type { ICacheService } from '@/datasources/cache/cache.service.interface';
import type { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source';
import type { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import type { INetworkService } from '@/datasources/network/network.service.interface';
import { LimitReachedError } from '@/datasources/network/entities/errors/limit-reached.error';
import { balancesProviderBuilder } from '@/modules/chains/domain/entities/__tests__/balances-provider.builder';
import { chainBuilder } from '@/modules/chains/domain/entities/__tests__/chain.builder';
import type { ILoggingService } from '@/logging/logging.interface';
import { rawify } from '@/validation/entities/raw.entity';
import { faker } from '@faker-js/faker';
import { getAddress } from 'viem';
import type { ZerionChainMappingService } from '@/modules/zerion/datasources/zerion-chain-mapping.service';
import { ZodError } from 'zod';

const mockCacheService = jest.mocked({
increment: jest.fn(),
hGet: jest.fn(),
hSet: jest.fn(),
} as jest.MockedObjectDeep<ICacheService>);

const mockLoggingService = {
debug: jest.fn(),
warn: jest.fn(),
} as jest.MockedObjectDeep<ILoggingService>;

const mockNetworkService = jest.mocked({
const mockDataSource = jest.mocked({
get: jest.fn(),
} as jest.MockedObjectDeep<INetworkService>);
} as jest.MockedObjectDeep<CacheFirstDataSource>);

const mockHttpErrorFactory = jest.mocked({
from: jest.fn(),
} as jest.MockedObjectDeep<HttpErrorFactory>);

const mockZerionChainMappingService = jest.mocked({
getNetworkFromChainId: jest.fn(),
} as jest.MockedObjectDeep<ZerionChainMappingService>);

describe('ZerionBalancesApiService', () => {
let service: ZerionBalancesApi;
let fakeConfigurationService: FakeConfigurationService;
const limitPeriodSeconds = 60;
const limitCalls = 10;
const zerionApiKey = faker.string.sample();
const zerionBaseUri = faker.internet.url({ appendSlash: false });
const defaultExpirationTimeInSeconds = faker.number.int();
Expand Down Expand Up @@ -69,19 +73,19 @@ describe('ZerionBalancesApiService', () => {
);
fakeConfigurationService.set(
'balances.providers.zerion.limitPeriodSeconds',
faker.number.int(),
limitPeriodSeconds,
);
fakeConfigurationService.set(
'balances.providers.zerion.limitCalls',
faker.number.int(),
limitCalls,
);

service = new ZerionBalancesApi(
mockCacheService,
mockLoggingService,
mockNetworkService,
fakeConfigurationService,
mockDataSource,
mockHttpErrorFactory,
mockZerionChainMappingService,
);
});

Expand All @@ -106,19 +110,21 @@ describe('ZerionBalancesApiService', () => {
const chain = chainBuilder().with('isTestnet', false).build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
const fiatCode = faker.helpers.arrayElement(supportedFiatCodes);
mockNetworkService.get.mockResolvedValue({
data: rawify({ data: [] }),
status: 200,
});
mockDataSource.get.mockResolvedValue(rawify({ data: [] }));

await service.getBalances({
chain,
safeAddress,
fiatCode,
});

expect(mockNetworkService.get).toHaveBeenCalledWith({
expect(mockDataSource.get).toHaveBeenCalledWith({
cacheDir: expect.objectContaining({
key: `zerion_balances_${safeAddress}`,
field: fiatCode.toUpperCase(),
}),
url: `${zerionBaseUri}/v1/wallets/${safeAddress}/positions`,
notFoundExpireTimeSeconds: notFoundExpirationTimeInSeconds,
networkRequest: {
headers: {
Authorization: `Basic ${zerionApiKey}`,
Expand All @@ -129,26 +135,66 @@ describe('ZerionBalancesApiService', () => {
sort: 'value',
},
},
expireTimeSeconds: defaultExpirationTimeInSeconds,
});
});

it('should get the chainName from ZerionChainMappingService when chain config is missing', async () => {
const mappedChainName = faker.word.sample();
const chain = chainBuilder()
.with('isTestnet', true)
.with('chainId', faker.string.numeric())
.with(
'balancesProvider',
balancesProviderBuilder().with('chainName', null).build(),
)
.build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
const fiatCode = faker.helpers.arrayElement(supportedFiatCodes);
mockZerionChainMappingService.getNetworkFromChainId.mockResolvedValue(
mappedChainName,
);
mockDataSource.get.mockResolvedValue(rawify({ data: [] }));

await service.getBalances({
chain,
safeAddress,
fiatCode,
});

expect(
mockZerionChainMappingService.getNetworkFromChainId,
).toHaveBeenCalledWith(chain.chainId, chain.isTestnet);
expect(mockDataSource.get).toHaveBeenCalledWith(
expect.objectContaining({
networkRequest: expect.objectContaining({
params: expect.objectContaining({
'filter[chain_ids]': mappedChainName,
}),
}),
}),
);
});

it('should include X-Env header for testnet chains', async () => {
const chain = chainBuilder().with('isTestnet', true).build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
const fiatCode = faker.helpers.arrayElement(supportedFiatCodes);
mockNetworkService.get.mockResolvedValue({
data: rawify({ data: [] }),
status: 200,
});
mockDataSource.get.mockResolvedValue(rawify({ data: [] }));

await service.getBalances({
chain,
safeAddress,
fiatCode,
});

expect(mockNetworkService.get).toHaveBeenCalledWith({
expect(mockDataSource.get).toHaveBeenCalledWith({
cacheDir: expect.objectContaining({
key: `zerion_balances_${safeAddress}`,
field: fiatCode.toUpperCase(),
}),
url: `${zerionBaseUri}/v1/wallets/${safeAddress}/positions`,
notFoundExpireTimeSeconds: notFoundExpirationTimeInSeconds,
networkRequest: {
headers: {
Authorization: `Basic ${zerionApiKey}`,
Expand All @@ -160,6 +206,7 @@ describe('ZerionBalancesApiService', () => {
sort: 'value',
},
},
expireTimeSeconds: defaultExpirationTimeInSeconds,
});
});

Expand All @@ -176,16 +223,9 @@ describe('ZerionBalancesApiService', () => {
const safeAddress = getAddress(faker.finance.ethereumAddress());
const fiatCode = faker.helpers.arrayElement(supportedFiatCodes);

// Mock Zerion chains API to return empty results (chain not supported)
mockNetworkService.get
.mockResolvedValueOnce({
data: rawify({ data: [] }),
status: 200,
})
.mockResolvedValueOnce({
data: rawify({ data: [] }),
status: 200,
});
mockZerionChainMappingService.getNetworkFromChainId.mockResolvedValue(
undefined,
);

await expect(
service.getBalances({
Expand All @@ -196,6 +236,176 @@ describe('ZerionBalancesApiService', () => {
).rejects.toThrow(
`Chain ${unsupportedChainId} balances retrieval via Zerion is not configured`,
);
expect(mockDataSource.get).not.toHaveBeenCalled();
});

it('should throw LimitReachedError when rate limit is exceeded', async () => {
const chain = chainBuilder().build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
const fiatCode = faker.helpers.arrayElement(supportedFiatCodes);
mockCacheService.increment.mockResolvedValue(limitCalls + 1);

await expect(
service.getBalances({
chain,
safeAddress,
fiatCode,
}),
).rejects.toBeInstanceOf(LimitReachedError);
expect(mockDataSource.get).not.toHaveBeenCalled();
});

it('should throw ZodError when balances response shape is invalid', async () => {
const chain = chainBuilder().build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
const fiatCode = faker.helpers.arrayElement(supportedFiatCodes);
mockDataSource.get.mockResolvedValue(rawify({ invalid: true }));

await expect(
service.getBalances({
chain,
safeAddress,
fiatCode,
}),
).rejects.toBeInstanceOf(ZodError);
expect(mockHttpErrorFactory.from).not.toHaveBeenCalled();
});

it('should map non-zod errors via HttpErrorFactory', async () => {
const chain = chainBuilder().build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
const fiatCode = faker.helpers.arrayElement(supportedFiatCodes);
const sourceError = new Error('source error');
const mappedError = new Error('mapped error');
mockDataSource.get.mockRejectedValue(sourceError);
mockHttpErrorFactory.from.mockReturnValue(mappedError);

await expect(
service.getBalances({
chain,
safeAddress,
fiatCode,
}),
).rejects.toThrow(mappedError);
expect(mockHttpErrorFactory.from).toHaveBeenCalledWith(sourceError);
});
});

describe('getCollectibles', () => {
it('should include encoded offset when requesting collectibles page', async () => {
const chain = chainBuilder().with('isTestnet', false).build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
const limit = faker.number.int({ min: 1, max: 50 });
const offset = faker.number.int({ min: 1, max: 500 });
const expectedPageAfter = Buffer.from(`"${offset}"`, 'utf8').toString(
'base64',
);

mockDataSource.get.mockResolvedValue(
rawify({
data: [],
links: { next: null },
}),
);

await service.getCollectibles({
chain,
safeAddress,
limit,
offset,
});

expect(mockDataSource.get).toHaveBeenCalledWith({
cacheDir: expect.objectContaining({
key: `zerion_collectibles_${safeAddress}`,
field: `${limit}_${offset}`,
}),
url: `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`,
notFoundExpireTimeSeconds: notFoundExpirationTimeInSeconds,
networkRequest: {
headers: {
Authorization: `Basic ${zerionApiKey}`,
},
params: {
'filter[chain_ids]': chain.balancesProvider.chainName,
sort: '-floor_price',
'page[size]': limit,
'page[after]': expectedPageAfter,
},
},
expireTimeSeconds: defaultExpirationTimeInSeconds,
});
});

it('should decode Zerion next cursor to offset/limit params', async () => {
const chain = chainBuilder().build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
const encodedOffset = Buffer.from('"0"', 'utf8').toString('base64');

mockDataSource.get.mockResolvedValue(
rawify({
data: [],
links: {
next: `${zerionBaseUri}/next?page[size]=10&page[after]=${encodedOffset}`,
},
}),
);

const result = await service.getCollectibles({
chain,
safeAddress,
});

const page = result as unknown as { next: string | null };
expect(page.next).not.toBeNull();
const decodedUrl = new URL(page.next as string);
expect(decodedUrl.searchParams.get('limit')).toBe('10');
expect(decodedUrl.searchParams.get('offset')).toBe('0');
});

it('should throw LimitReachedError when rate limit is exceeded', async () => {
const chain = chainBuilder().build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
mockCacheService.increment.mockResolvedValue(limitCalls + 1);

await expect(
service.getCollectibles({
chain,
safeAddress,
}),
).rejects.toBeInstanceOf(LimitReachedError);
expect(mockDataSource.get).not.toHaveBeenCalled();
});

it('should throw ZodError when collectibles response shape is invalid', async () => {
const chain = chainBuilder().build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
mockDataSource.get.mockResolvedValue(rawify({ data: [] }));

await expect(
service.getCollectibles({
chain,
safeAddress,
}),
).rejects.toBeInstanceOf(ZodError);
expect(mockHttpErrorFactory.from).not.toHaveBeenCalled();
});

it('should map non-zod errors via HttpErrorFactory', async () => {
const chain = chainBuilder().build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
const sourceError = new Error('source error');
const mappedError = new Error('mapped error');
mockDataSource.get.mockRejectedValue(sourceError);
mockHttpErrorFactory.from.mockReturnValue(mappedError);

await expect(
service.getCollectibles({
chain,
safeAddress,
}),
).rejects.toThrow(mappedError);
expect(mockHttpErrorFactory.from).toHaveBeenCalledWith(sourceError);
});
});
});
Loading