Skip to content

Commit

Permalink
Add SafeBalancesApi (safe-global#1151)
Browse files Browse the repository at this point in the history
- Moves `TransactionApi` balances-related functions (`getBalances`, `clearBalances`, `getCollectibles`, `clearCollectibles`) to `SafeBalancesApi`.
- Adds `SafeBalancesApi` routing (between different chain-specific Transaction Services) to `BalancesApiManager` (heavily inspired by `TransactionApiManager`).
- Unifies naming to stick to the interface (between `clearLocalBalances` and `clearBalances`).
- Simplifies both `BalancesRepository` and `CollectiblesRepository`, as some complexity is pushed to `BalancesApiManager`.
  • Loading branch information
hectorgomezv authored Feb 16, 2024
1 parent 4fa2d6d commit e0ab5ef
Show file tree
Hide file tree
Showing 13 changed files with 282 additions and 202 deletions.
111 changes: 104 additions & 7 deletions src/datasources/balances-api/balances-api.manager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
import { IConfigurationService } from '@/config/configuration.service.interface';
import { BalancesApiManager } from '@/datasources/balances-api/balances-api.manager';
import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source';
import { ICacheService } from '@/datasources/cache/cache.service.interface';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder';
import { IBalancesApi } from '@/domain/interfaces/balances-api.interface';
import { IConfigApi } from '@/domain/interfaces/config-api.interface';
import { faker } from '@faker-js/faker';

const configurationService = {
getOrThrow: jest.fn(),
get: jest.fn(),
} as IConfigurationService;
} as jest.MockedObjectDeep<IConfigurationService>;

const configurationServiceMock = jest.mocked(configurationService);

const configApi = {
getChain: jest.fn(),
} as jest.MockedObjectDeep<IConfigApi>;

const configApiMock = jest.mocked(configApi);

const dataSource = {
get: jest.fn(),
} as jest.MockedObjectDeep<CacheFirstDataSource>;

const dataSourceMock = jest.mocked(dataSource);

const cacheService = {} as jest.MockedObjectDeep<ICacheService>;
const httpErrorFactory = {} as jest.MockedObjectDeep<HttpErrorFactory>;

const zerionBalancesApi = {
getBalances: jest.fn(),
clearBalances: jest.fn(),
Expand All @@ -31,6 +51,10 @@ describe('Balances API Manager Tests', () => {
it('should return true if the chain is included in the balance-externalized chains', () => {
const manager = new BalancesApiManager(
configurationService,
configApiMock,
dataSourceMock,
cacheService,
httpErrorFactory,
zerionBalancesApiMock,
);
expect(manager.useExternalApi('1')).toEqual(true);
Expand All @@ -40,27 +64,96 @@ describe('Balances API Manager Tests', () => {
it('should return false if the chain is included in the balance-externalized chains', () => {
const manager = new BalancesApiManager(
configurationService,
configApiMock,
dataSourceMock,
cacheService,
httpErrorFactory,
zerionBalancesApiMock,
);
expect(manager.useExternalApi('4')).toEqual(false);
});
});

describe('getBalancesApi checks', () => {
it('should return the Zerion API', () => {
it('should return the Zerion API', async () => {
const manager = new BalancesApiManager(
configurationService,
configApiMock,
dataSourceMock,
cacheService,
httpErrorFactory,
zerionBalancesApiMock,
);
expect(manager.getBalancesApi('2')).toEqual(zerionBalancesApi);

const result = await manager.getBalancesApi('2');

expect(result).toEqual(zerionBalancesApi);
});

it('should throw an error if no API is found for the input chainId', () => {
const manager = new BalancesApiManager(
const txServiceUrl = faker.internet.url({ appendSlash: false });
const vpcTxServiceUrl = faker.internet.url({ appendSlash: false });

/**
* In the following tests, getBalances is used to check the parameters to
* which {@link CacheFirstDataSource} was called with.
*/
it.each([
[true, vpcTxServiceUrl],
[false, txServiceUrl],
])('vpcUrl is %s', async (useVpcUrl, expectedUrl) => {
const zerionChainIds = ['1', '2', '3'];
const chain = chainBuilder()
.with('chainId', '4')
.with('transactionService', txServiceUrl)
.with('vpcTransactionService', vpcTxServiceUrl)
.build();
const expirationTimeInSeconds = faker.number.int();
const notFoundExpireTimeSeconds = faker.number.int();
configurationServiceMock.getOrThrow.mockImplementation((key) => {
if (key === 'safeTransaction.useVpcUrl') return useVpcUrl;
else if (key === 'expirationTimeInSeconds.default')
return expirationTimeInSeconds;
else if (key === 'expirationTimeInSeconds.notFound.default')
return notFoundExpireTimeSeconds;
else if (key === 'features.zerionBalancesChainIds')
return zerionChainIds;
throw new Error(`Unexpected key: ${key}`);
});
configApiMock.getChain.mockResolvedValue(chain);
const balancesApiManager = new BalancesApiManager(
configurationService,
configApiMock,
dataSourceMock,
cacheService,
httpErrorFactory,
zerionBalancesApiMock,
);
expect(() => manager.getBalancesApi('5')).toThrow();

const safeBalancesApi = await balancesApiManager.getBalancesApi(
chain.chainId,
);
const safeAddress = faker.finance.ethereumAddress();
const trusted = faker.datatype.boolean();
const excludeSpam = faker.datatype.boolean();

await safeBalancesApi.getBalances({
safeAddress,
trusted,
excludeSpam,
});

expect(dataSourceMock.get).toHaveBeenCalledWith({
cacheDir: expect.anything(),
url: `${expectedUrl}/api/v1/safes/${safeAddress}/balances/`,
notFoundExpireTimeSeconds: notFoundExpireTimeSeconds,
expireTimeSeconds: expirationTimeInSeconds,
networkRequest: expect.objectContaining({
params: expect.objectContaining({
trusted: trusted,
exclude_spam: excludeSpam,
}),
}),
});
});
});

Expand All @@ -69,6 +162,10 @@ describe('Balances API Manager Tests', () => {
zerionBalancesApiMock.getFiatCodes.mockReturnValue(['EUR', 'GBP', 'ETH']);
const manager = new BalancesApiManager(
configurationService,
configApiMock,
dataSourceMock,
cacheService,
httpErrorFactory,
zerionBalancesApiMock,
);

Expand Down
46 changes: 38 additions & 8 deletions src/datasources/balances-api/balances-api.manager.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,72 @@
import { IConfigurationService } from '@/config/configuration.service.interface';
import { SafeBalancesApi } from '@/datasources/balances-api/safe-balances-api.service';
import { IZerionBalancesApi } from '@/datasources/balances-api/zerion-balances-api.service';
import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source';
import {
CacheService,
ICacheService,
} from '@/datasources/cache/cache.service.interface';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { IBalancesApi } from '@/domain/interfaces/balances-api.interface';
import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface';
import { IConfigApi } from '@/domain/interfaces/config-api.interface';
import { Inject, Injectable } from '@nestjs/common';

@Injectable()
export class BalancesApiManager implements IBalancesApiManager {
private readonly zerionBalancesChainIds: string[];
private safeBalancesApiMap: Record<string, SafeBalancesApi> = {};
private readonly zerionChainIds: string[];
private readonly zerionBalancesApi: IBalancesApi;
private readonly useVpcUrl: boolean;

constructor(
@Inject(IConfigurationService)
private readonly configurationService: IConfigurationService,
@Inject(IConfigApi) private readonly configApi: IConfigApi,
private readonly dataSource: CacheFirstDataSource,
@Inject(CacheService) private readonly cacheService: ICacheService,
private readonly httpErrorFactory: HttpErrorFactory,
@Inject(IZerionBalancesApi) zerionBalancesApi: IBalancesApi,
) {
this.zerionBalancesChainIds = this.configurationService.getOrThrow<
string[]
>('features.zerionBalancesChainIds');
this.zerionChainIds = this.configurationService.getOrThrow<string[]>(
'features.zerionBalancesChainIds',
);
this.useVpcUrl = this.configurationService.getOrThrow<boolean>(
'safeTransaction.useVpcUrl',
);

this.zerionBalancesApi = zerionBalancesApi;
}

useExternalApi(chainId: string): boolean {
return this.zerionBalancesChainIds.includes(chainId);
return this.zerionChainIds.includes(chainId);
}

getBalancesApi(chainId: string): IBalancesApi {
async getBalancesApi(chainId: string): Promise<IBalancesApi> {
if (this._isSupportedByZerion(chainId)) {
return this.zerionBalancesApi;
}
throw new Error(`Chain ID ${chainId} balances provider is not configured`);

const safeBalancesApi = this.safeBalancesApiMap[chainId];
if (safeBalancesApi !== undefined) return safeBalancesApi;

const chain = await this.configApi.getChain(chainId);
this.safeBalancesApiMap[chainId] = new SafeBalancesApi(
chainId,
this.useVpcUrl ? chain.vpcTransactionService : chain.transactionService,
this.dataSource,
this.cacheService,
this.configurationService,
this.httpErrorFactory,
);
return this.safeBalancesApiMap[chainId];
}

getFiatCodes(): string[] {
return this.zerionBalancesApi.getFiatCodes().sort();
}

private _isSupportedByZerion(chainId: string): boolean {
return this.zerionBalancesChainIds.includes(chainId);
return this.zerionChainIds.includes(chainId);
}
}
117 changes: 117 additions & 0 deletions src/datasources/balances-api/safe-balances-api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { IConfigurationService } from '@/config/configuration.service.interface';
import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source';
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 { Collectible } from '@/domain/collectibles/entities/collectible.entity';
import { Page } from '@/domain/entities/page.entity';
import { IBalancesApi } from '@/domain/interfaces/balances-api.interface';
import { Injectable } from '@nestjs/common';

@Injectable()
export class SafeBalancesApi implements IBalancesApi {
private readonly defaultExpirationTimeInSeconds: number;
private readonly defaultNotFoundExpirationTimeSeconds: number;

constructor(
private readonly chainId: string,
private readonly baseUrl: string,
private readonly dataSource: CacheFirstDataSource,
private readonly cacheService: ICacheService,
private readonly configurationService: IConfigurationService,
private readonly httpErrorFactory: HttpErrorFactory,
) {
this.defaultExpirationTimeInSeconds =
this.configurationService.getOrThrow<number>(
'expirationTimeInSeconds.default',
);
this.defaultNotFoundExpirationTimeSeconds =
this.configurationService.getOrThrow<number>(
'expirationTimeInSeconds.notFound.default',
);
}

async getBalances(args: {
safeAddress: string;
trusted?: boolean;
excludeSpam?: boolean;
}): Promise<Balance[]> {
try {
const cacheDir = CacheRouter.getBalancesCacheDir({
chainId: this.chainId,
...args,
});
const url = `${this.baseUrl}/api/v1/safes/${args.safeAddress}/balances/`;
return await this.dataSource.get({
cacheDir,
url,
notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds,
networkRequest: {
params: {
trusted: args.trusted,
exclude_spam: args.excludeSpam,
},
},
expireTimeSeconds: this.defaultExpirationTimeInSeconds,
});
} catch (error) {
throw this.httpErrorFactory.from(error);
}
}

async clearBalances(args: { safeAddress: string }): Promise<void> {
const key = CacheRouter.getBalancesCacheKey({
chainId: this.chainId,
safeAddress: args.safeAddress,
});
await this.cacheService.deleteByKey(key);
}

async getCollectibles(args: {
safeAddress: string;
limit?: number;
offset?: number;
trusted?: boolean;
excludeSpam?: boolean;
}): Promise<Page<Collectible>> {
try {
const cacheDir = CacheRouter.getCollectiblesCacheDir({
chainId: this.chainId,
...args,
});
const url = `${this.baseUrl}/api/v2/safes/${args.safeAddress}/collectibles/`;
return await this.dataSource.get({
cacheDir,
url,
notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds,
networkRequest: {
params: {
limit: args.limit,
offset: args.offset,
trusted: args.trusted,
exclude_spam: args.excludeSpam,
},
},
expireTimeSeconds: this.defaultExpirationTimeInSeconds,
});
} catch (error) {
throw this.httpErrorFactory.from(error);
}
}

async clearCollectibles(args: { safeAddress: string }): Promise<void> {
const key = CacheRouter.getCollectiblesKey({
chainId: this.chainId,
safeAddress: args.safeAddress,
});
await this.cacheService.deleteByKey(key);
}

/**
* No fiat prices are available from this provider.
*/
getFiatCodes(): string[] {
return [];
}
}
1 change: 0 additions & 1 deletion src/datasources/cache/cache.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export class CacheRouter {
);
}

// TODO: remove this prefixed key if eventually only one balances provider is used
static getZerionBalancesCacheKey(args: {
chainId: string;
safeAddress: string;
Expand Down
Loading

0 comments on commit e0ab5ef

Please sign in to comment.