Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add datasource for Safe Decoder Service #2313

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from 2 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/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
@@ -243,6 +243,9 @@ export default (): ReturnType<typeof configuration> => ({
maxSequentialPages: faker.number.int(),
},
},
safeDataDecoder: {
baseUri: faker.internet.url({ appendSlash: false }),
},
safeTransaction: {
useVpcUrl: false,
},
5 changes: 5 additions & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
@@ -364,6 +364,11 @@ export default () => ({
),
},
},
safeDataDecoder: {
baseUri:
process.env.SAFE_DATA_DECODER_BASE_URI ||
'https://safe-decoder.safe.global',
},
safeTransaction: {
useVpcUrl: process.env.USE_TX_SERVICE_VPC_URL?.toLowerCase() === 'true',
},
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { DataDecoderApiManager } from '@/datasources/data-decoder-api/data-decoder-api.manager';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { IDataDecoderApiManager } from '@/domain/interfaces/data-decoder-api.manager.interface';

@Module({
providers: [
{
provide: IDataDecoderApiManager,
useClass: DataDecoderApiManager,
},
HttpErrorFactory,
],
exports: [IDataDecoderApiManager],
})
export class DataDecoderApiManagerModule {}
51 changes: 51 additions & 0 deletions src/datasources/data-decoder-api/data-decoder-api.manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Inject, Injectable } from '@nestjs/common';
import { IConfigurationService } from '@/config/configuration.service.interface';
import { DataDecoderApi } from '@/datasources/data-decoder-api/data-decoder-api.service';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import {
NetworkService,
INetworkService,
} from '@/datasources/network/network.service.interface';
import { IDataDecoderApiManager } from '@/domain/interfaces/data-decoder-api.manager.interface';

@Injectable()
export class DataDecoderApiManager implements IDataDecoderApiManager {
private decoderApiMap: Record<string, DataDecoderApi> = {};

private readonly baseUri: string;

constructor(
@Inject(IConfigurationService)
private readonly configurationService: IConfigurationService,
private readonly httpErrorFactory: HttpErrorFactory,
@Inject(NetworkService)
private readonly networkService: INetworkService,
) {
this.baseUri = this.configurationService.getOrThrow<string>(
'safeDataDecoder.baseUri',
);
}

async getApi(chainId: string): Promise<DataDecoderApi> {
const decoderApi = this.decoderApiMap[chainId];

if (decoderApi !== undefined) {
return Promise.resolve(decoderApi);
}

this.decoderApiMap[chainId] = new DataDecoderApi(
chainId,
this.baseUri,
this.networkService,
this.httpErrorFactory,
);

return Promise.resolve(this.decoderApiMap[chainId]);
}

// We don't need to destroy the API as it is not event-specific
// eslint-disable-next-line @typescript-eslint/no-unused-vars
destroyApi(_: string): void {
throw new Error('Method not implemented');
}
}
13 changes: 13 additions & 0 deletions src/datasources/data-decoder-api/data-decoder-api.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DataDecoderApiManager } from '@/datasources/data-decoder-api/data-decoder-api.manager';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { IDataDecoderApiManager } from '@/domain/interfaces/data-decoder-api.manager.interface';

@Module({
providers: [
HttpErrorFactory,
{ provide: IDataDecoderApiManager, useClass: DataDecoderApiManager },
],
exports: [IDataDecoderApiManager],
})
export class DataDecodedApiModule {}
163 changes: 163 additions & 0 deletions src/datasources/data-decoder-api/data-decoder-api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { faker } from '@faker-js/faker';
import { getAddress } from 'viem';
import { DataDecoderApi } from '@/datasources/data-decoder-api/data-decoder-api.service';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { dataDecodedBuilder } from '@/domain/data-decoder/v2/entities/__tests__/data-decoded.builder';
import { contractBuilder } from '@/domain/data-decoder/v2/entities/__tests__/contract.builder';
import { pageBuilder } from '@/domain/entities/__tests__/page.builder';
import { DataSourceError } from '@/domain/errors/data-source.error';
import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity';
import { rawify } from '@/validation/entities/raw.entity';
import type { INetworkService } from '@/datasources/network/network.service.interface';

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

describe('DataDecoderApi', () => {
const baseUrl = faker.internet.url({ appendSlash: false });
let chainId: string;
let target: DataDecoderApi;

beforeEach(() => {
jest.resetAllMocks();

const httpErrorFactory = new HttpErrorFactory();
chainId = faker.string.numeric();
target = new DataDecoderApi(
chainId,
baseUrl,
mockNetworkService,
httpErrorFactory,
);
});

describe('getDataDecoded', () => {
it('should return the decoded data', async () => {
const dataDecoded = dataDecodedBuilder().build();
const to = getAddress(faker.finance.ethereumAddress());
const data = faker.string.hexadecimal() as `0x${string}`;
const getDataDecodedUrl = `${baseUrl}/api/v1/data-decoder`;
mockNetworkService.post.mockImplementation(({ url }) => {
if (url === getDataDecodedUrl) {
return Promise.resolve({ status: 200, data: rawify(dataDecoded) });
}
throw new Error('Unexpected URL');
});

const actual = await target.getDecodedData({ data, to });

expect(actual).toStrictEqual(dataDecoded);
expect(mockNetworkService.post).toHaveBeenCalledTimes(1);
expect(mockNetworkService.post).toHaveBeenCalledWith({
url: getDataDecodedUrl,
data: { chainId: Number(chainId), to, data },
});
});

it('should forward an error', async () => {
const to = getAddress(faker.finance.ethereumAddress());
const data = faker.string.hexadecimal() as `0x${string}`;
const errorMessage = faker.word.words();
const statusCode = faker.internet.httpStatusCode({
types: ['clientError', 'serverError'],
});
const expected = new DataSourceError(errorMessage, statusCode);
const getDataDecodedUrl = `${baseUrl}/api/v1/data-decoder`;
mockNetworkService.post.mockImplementation(({ url }) => {
if (url === getDataDecodedUrl) {
return Promise.reject(
new NetworkResponseError(
new URL(getDataDecodedUrl),
{
status: statusCode,
} as Response,
new Error(errorMessage),
),
);
}
throw new Error('Unexpected URL');
});

await expect(target.getDecodedData({ data, to })).rejects.toThrow(
expected,
);

expect(mockNetworkService.post).toHaveBeenCalledTimes(1);
expect(mockNetworkService.post).toHaveBeenCalledWith({
url: getDataDecodedUrl,
data: { chainId: Number(chainId), to, data },
});
});
});

describe('getContracts', () => {
it('should return the contracts', async () => {
const contract = contractBuilder().build();
const contractPage = pageBuilder().with('results', [contract]).build();
const getContractsUrl = `${baseUrl}/api/v1/contracts/${contract.address}`;
mockNetworkService.get.mockImplementation(({ url }) => {
if (url === getContractsUrl) {
return Promise.resolve({ status: 200, data: rawify(contractPage) });
}
throw new Error('Unexpected URL');
});

const actual = await target.getContracts({ address: contract.address });

expect(actual).toStrictEqual(contractPage);
expect(mockNetworkService.get).toHaveBeenCalledTimes(1);
expect(mockNetworkService.get).toHaveBeenCalledWith({
url: getContractsUrl,
networkRequest: {
params: {
chain_ids: Number(chainId),
limit: undefined,
offset: undefined,
},
},
});
});

it('should forward an error', async () => {
const contract = contractBuilder().build();
const errorMessage = faker.word.words();
const statusCode = faker.internet.httpStatusCode({
types: ['clientError', 'serverError'],
});
const expected = new DataSourceError(errorMessage, statusCode);
const getContractsUrl = `${baseUrl}/api/v1/contracts/${contract.address}`;
mockNetworkService.get.mockImplementation(({ url }) => {
if (url === getContractsUrl) {
return Promise.reject(
new NetworkResponseError(
new URL(getContractsUrl),
{
status: statusCode,
} as Response,
new Error(errorMessage),
),
);
}
throw new Error('Unexpected URL');
});

await expect(
target.getContracts({ address: contract.address }),
).rejects.toThrow(expected);

expect(mockNetworkService.get).toHaveBeenCalledTimes(1);
expect(mockNetworkService.get).toHaveBeenCalledWith({
url: getContractsUrl,
networkRequest: {
params: {
chain_ids: Number(chainId),
limit: undefined,
offset: undefined,
},
},
});
});
});
});
63 changes: 63 additions & 0 deletions src/datasources/data-decoder-api/data-decoder-api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import type { INetworkService } from '@/datasources/network/network.service.interface';
import type { DataDecoded } from '@/domain/data-decoder/v2/entities/data-decoded.entity';
import type { Contract } from '@/domain/data-decoder/v2/entities/contract.entity';
import type { Page } from '@/domain/entities/page.entity';
import type { IDataDecoderApi } from '@/domain/interfaces/data-decoder-api.interface';
import type { Raw } from '@/validation/entities/raw.entity';

export class DataDecoderApi implements IDataDecoderApi {
constructor(
private readonly chainId: string,
private readonly baseUrl: string,
private readonly networkService: INetworkService,
private readonly httpErrorFactory: HttpErrorFactory,
) {}

public async getDecodedData(args: {
data: `0x${string}`;
to: `0x${string}`;
}): Promise<Raw<DataDecoded>> {
try {
const url = `${this.baseUrl}/api/v1/data-decoder`;
const { data: dataDecoded } = await this.networkService.post<DataDecoded>(
{
url,
data: {
chainId: Number(this.chainId),
to: args.to,
data: args.data,
},
},
);
return dataDecoded;
} catch (error) {
throw this.httpErrorFactory.from(error);
}
}

public async getContracts(args: {
address: `0x${string}`;
limit?: number;
offset?: number;
}): Promise<Raw<Page<Contract>>> {
try {
const url = `${this.baseUrl}/api/v1/contracts/${args.address}`;
const { data: contracts } = await this.networkService.get<Page<Contract>>(
{
url,
networkRequest: {
params: {
chain_ids: Number(this.chainId),
limit: args.limit,
offset: args.offset,
},
},
},
);
return contracts;
} catch (error) {
throw this.httpErrorFactory.from(error);
}
}
}
18 changes: 18 additions & 0 deletions src/domain/data-decoder/v2/data-decoder.repository.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Contract } from '@/domain/data-decoder/v2/entities/contract.entity';
import type { DataDecoded } from '@/domain/data-decoder/v2/entities/data-decoded.entity';
import type { Page } from '@/domain/entities/page.entity';

export const IDataDecoderRepository = Symbol('IDataDecoderRepository');

export interface IDataDecoderRepository {
getDecodedData(args: {
chainId: string;
data: `0x${string}`;
to: `0x${string}`;
}): Promise<DataDecoded>;

getContracts(args: {
chainId: string;
address: `0x${string}`;
}): Promise<Page<Contract>>;
}
16 changes: 16 additions & 0 deletions src/domain/data-decoder/v2/data-decoder.repository.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { DataDecoderApiManagerModule } from '@/datasources/data-decoder-api/data-decoder-api.manager.module';
import { DataDecoderRepository } from '@/domain/data-decoder/v2/data-decoder.repository';
import { IDataDecoderRepository } from '@/domain/data-decoder/v2/data-decoder.repository.interface';

@Module({
imports: [DataDecoderApiManagerModule],
providers: [
{
provide: IDataDecoderRepository,
useClass: DataDecoderRepository,
},
],
exports: [IDataDecoderRepository],
})
export class DataDecoderRepositoryModule {}
Loading