From 4b4cb62e458b6ad410c98b6d8eaddb6c07b3dc90 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 29 Jan 2025 16:28:11 +0100 Subject: [PATCH] Add datasource for Safe Decoder Service --- .../entities/__tests__/configuration.ts | 3 + src/config/entities/configuration.ts | 5 + .../data-decoder-api.manager.module.ts | 16 + .../data-decoder-api.manager.ts | 51 +++ .../data-decoder-api.module.ts | 13 + .../data-decoder-api.service.spec.ts | 163 ++++++++ .../data-decoder-api.service.ts | 63 ++++ .../v2/data-decoder.repository.interface.ts | 18 + .../v2/data-decoder.repository.module.ts | 16 + .../v2/data-decoder.repository.ts | 39 ++ .../v2/entities/__tests__/contract.builder.ts | 30 ++ .../__tests__/data-decoded.builder.ts | 53 +++ .../v2/entities/contract.entity.spec.ts | 252 +++++++++++++ .../v2/entities/contract.entity.ts | 32 ++ .../v2/entities/data-decoded.entity.spec.ts | 355 ++++++++++++++++++ .../v2/entities/data-decoded.entity.ts | 55 +++ .../interfaces/data-decoder-api.interface.ts | 19 + .../data-decoder-api.manager.interface.ts | 7 + 18 files changed, 1190 insertions(+) create mode 100644 src/datasources/data-decoder-api/data-decoder-api.manager.module.ts create mode 100644 src/datasources/data-decoder-api/data-decoder-api.manager.ts create mode 100644 src/datasources/data-decoder-api/data-decoder-api.module.ts create mode 100644 src/datasources/data-decoder-api/data-decoder-api.service.spec.ts create mode 100644 src/datasources/data-decoder-api/data-decoder-api.service.ts create mode 100644 src/domain/data-decoder/v2/data-decoder.repository.interface.ts create mode 100644 src/domain/data-decoder/v2/data-decoder.repository.module.ts create mode 100644 src/domain/data-decoder/v2/data-decoder.repository.ts create mode 100644 src/domain/data-decoder/v2/entities/__tests__/contract.builder.ts create mode 100644 src/domain/data-decoder/v2/entities/__tests__/data-decoded.builder.ts create mode 100644 src/domain/data-decoder/v2/entities/contract.entity.spec.ts create mode 100644 src/domain/data-decoder/v2/entities/contract.entity.ts create mode 100644 src/domain/data-decoder/v2/entities/data-decoded.entity.spec.ts create mode 100644 src/domain/data-decoder/v2/entities/data-decoded.entity.ts create mode 100644 src/domain/interfaces/data-decoder-api.interface.ts create mode 100644 src/domain/interfaces/data-decoder-api.manager.interface.ts diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 4bda17f309..0c41aba9aa 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -243,6 +243,9 @@ export default (): ReturnType => ({ maxSequentialPages: faker.number.int(), }, }, + safeDataDecoder: { + baseUri: faker.internet.url({ appendSlash: false }), + }, safeTransaction: { useVpcUrl: false, }, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 4562198a71..b0041dd433 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -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', }, diff --git a/src/datasources/data-decoder-api/data-decoder-api.manager.module.ts b/src/datasources/data-decoder-api/data-decoder-api.manager.module.ts new file mode 100644 index 0000000000..97526cf7c7 --- /dev/null +++ b/src/datasources/data-decoder-api/data-decoder-api.manager.module.ts @@ -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 {} diff --git a/src/datasources/data-decoder-api/data-decoder-api.manager.ts b/src/datasources/data-decoder-api/data-decoder-api.manager.ts new file mode 100644 index 0000000000..5524ba27eb --- /dev/null +++ b/src/datasources/data-decoder-api/data-decoder-api.manager.ts @@ -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 = {}; + + 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( + 'safeDataDecoder.baseUri', + ); + } + + async getApi(chainId: string): Promise { + 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'); + } +} diff --git a/src/datasources/data-decoder-api/data-decoder-api.module.ts b/src/datasources/data-decoder-api/data-decoder-api.module.ts new file mode 100644 index 0000000000..3977237e39 --- /dev/null +++ b/src/datasources/data-decoder-api/data-decoder-api.module.ts @@ -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 {} diff --git a/src/datasources/data-decoder-api/data-decoder-api.service.spec.ts b/src/datasources/data-decoder-api/data-decoder-api.service.spec.ts new file mode 100644 index 0000000000..33b46ae7d3 --- /dev/null +++ b/src/datasources/data-decoder-api/data-decoder-api.service.spec.ts @@ -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); + +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, + }, + }, + }); + }); + }); +}); diff --git a/src/datasources/data-decoder-api/data-decoder-api.service.ts b/src/datasources/data-decoder-api/data-decoder-api.service.ts new file mode 100644 index 0000000000..921459ae4a --- /dev/null +++ b/src/datasources/data-decoder-api/data-decoder-api.service.ts @@ -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> { + try { + const url = `${this.baseUrl}/api/v1/data-decoder`; + const { data: dataDecoded } = await this.networkService.post( + { + 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>> { + try { + const url = `${this.baseUrl}/api/v1/contracts/${args.address}`; + const { data: contracts } = await this.networkService.get>( + { + url, + networkRequest: { + params: { + chain_ids: Number(this.chainId), + limit: args.limit, + offset: args.offset, + }, + }, + }, + ); + return contracts; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } +} diff --git a/src/domain/data-decoder/v2/data-decoder.repository.interface.ts b/src/domain/data-decoder/v2/data-decoder.repository.interface.ts new file mode 100644 index 0000000000..4c21a79a07 --- /dev/null +++ b/src/domain/data-decoder/v2/data-decoder.repository.interface.ts @@ -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; + + getContracts(args: { + chainId: string; + address: `0x${string}`; + }): Promise>; +} diff --git a/src/domain/data-decoder/v2/data-decoder.repository.module.ts b/src/domain/data-decoder/v2/data-decoder.repository.module.ts new file mode 100644 index 0000000000..f29adc2b6a --- /dev/null +++ b/src/domain/data-decoder/v2/data-decoder.repository.module.ts @@ -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 {} diff --git a/src/domain/data-decoder/v2/data-decoder.repository.ts b/src/domain/data-decoder/v2/data-decoder.repository.ts new file mode 100644 index 0000000000..f18606122d --- /dev/null +++ b/src/domain/data-decoder/v2/data-decoder.repository.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + DataDecoded, + DataDecodedSchema, +} from '@/domain/data-decoder/v2/entities/data-decoded.entity'; +import { IDataDecoderRepository } from '@/domain/data-decoder/v2/data-decoder.repository.interface'; +import { IDataDecoderApiManager } from '@/domain/interfaces/data-decoder-api.manager.interface'; +import { Page } from '@/domain/entities/page.entity'; +import { + Contract, + ContractPageSchema, +} from '@/domain/data-decoder/v2/entities/contract.entity'; + +@Injectable() +export class DataDecoderRepository implements IDataDecoderRepository { + constructor( + @Inject(IDataDecoderApiManager) + private readonly dataDecoderApiManager: IDataDecoderApiManager, + ) {} + + public async getDecodedData(args: { + chainId: string; + data: `0x${string}`; + to: `0x${string}`; + }): Promise { + const api = await this.dataDecoderApiManager.getApi(args.chainId); + const dataDecoded = await api.getDecodedData(args); + return DataDecodedSchema.parse(dataDecoded); + } + + public async getContracts(args: { + chainId: string; + address: `0x${string}`; + }): Promise> { + const api = await this.dataDecoderApiManager.getApi(args.chainId); + const contracts = await api.getContracts(args); + return ContractPageSchema.parse(contracts); + } +} diff --git a/src/domain/data-decoder/v2/entities/__tests__/contract.builder.ts b/src/domain/data-decoder/v2/entities/__tests__/contract.builder.ts new file mode 100644 index 0000000000..201ae9091f --- /dev/null +++ b/src/domain/data-decoder/v2/entities/__tests__/contract.builder.ts @@ -0,0 +1,30 @@ +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { fakeJson } from '@/__tests__/faker'; +import { Builder } from '@/__tests__/builder'; +import type { IBuilder } from '@/__tests__/builder'; +import type { Contract } from '@/domain/data-decoder/v2/entities/contract.entity'; + +export function projectBuilder(): IBuilder> { + return new Builder>() + .with('description', faker.lorem.sentence()) + .with('logo_file', faker.internet.url()); +} + +export function abiBuilder(): IBuilder { + return new Builder() + .with('abi_json', [JSON.parse(fakeJson()) as Record]) + .with('abi_hash', faker.string.hexadecimal() as `0x${string}`) + .with('modified', faker.date.past()); +} + +export function contractBuilder(): IBuilder { + return new Builder() + .with('address', getAddress(faker.finance.ethereumAddress())) + .with('name', faker.word.noun()) + .with('display_name', faker.word.noun()) + .with('chain_id', faker.number.int() as unknown as `${number}`) + .with('project', projectBuilder().build()) + .with('abi', abiBuilder().build()) + .with('modified', faker.date.past()); +} diff --git a/src/domain/data-decoder/v2/entities/__tests__/data-decoded.builder.ts b/src/domain/data-decoder/v2/entities/__tests__/data-decoded.builder.ts new file mode 100644 index 0000000000..bf5824d2a8 --- /dev/null +++ b/src/domain/data-decoder/v2/entities/__tests__/data-decoded.builder.ts @@ -0,0 +1,53 @@ +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { Accuracy } from '@/domain/data-decoder/v2/entities/data-decoded.entity'; +import { Builder } from '@/__tests__/builder'; +import type { IBuilder } from '@/__tests__/builder'; +import type { + DataDecoded, + MultisendSchema, +} from '@/domain/data-decoder/v2/entities/data-decoded.entity'; +import type { z } from 'zod'; + +export function multisendBuilder(): IBuilder> { + return ( + new Builder>() + .with('operation', faker.helpers.arrayElement([0, 1])) + .with('value', faker.string.numeric() as `0x${string}`) + // Prevent call stack exceeded + .with('data_decoded', null) + .with('to', getAddress(faker.finance.ethereumAddress())) + .with('data', faker.string.hexadecimal() as `0x${string}`) + ); +} + +export function parameterBuilder(): IBuilder< + DataDecoded['parameters'][number] +> { + const valueDecoded = faker.datatype.boolean() + ? faker.helpers.multiple(() => multisendBuilder().build(), { + count: { min: 1, max: 3 }, + }) + : baseDataDecodedBuilder().build(); + return new Builder() + .with('name', faker.word.noun()) + .with('type', faker.word.noun()) + .with('value', faker.string.hexadecimal() as `0x${string}`) + .with('value_decoded', valueDecoded); +} + +export function baseDataDecodedBuilder(): IBuilder< + Omit +> { + return new Builder() + .with('method', faker.word.noun()) + .with('parameters', [parameterBuilder().build()]); +} + +export function dataDecodedBuilder(): IBuilder { + const baseDataDecoded = baseDataDecodedBuilder().build(); + return new Builder() + .with('method', baseDataDecoded.method) + .with('parameters', baseDataDecoded.parameters) + .with('accuracy', faker.helpers.arrayElement(Accuracy)); +} diff --git a/src/domain/data-decoder/v2/entities/contract.entity.spec.ts b/src/domain/data-decoder/v2/entities/contract.entity.spec.ts new file mode 100644 index 0000000000..244bcdb52e --- /dev/null +++ b/src/domain/data-decoder/v2/entities/contract.entity.spec.ts @@ -0,0 +1,252 @@ +import { faker } from '@faker-js/faker'; +import { + abiBuilder, + contractBuilder, + projectBuilder, +} from '@/domain/data-decoder/v2/entities/__tests__/contract.builder'; +import { + AbiSchema, + ContractSchema, + ProjectSchema, +} from '@/domain/data-decoder/v2/entities/contract.entity'; +import { getAddress } from 'viem'; + +describe('Contract', () => { + describe('ProjectSchema', () => { + it('should validate a Project', () => { + const project = projectBuilder().build(); + + const result = ProjectSchema.safeParse(project); + + expect(result.success).toBe(true); + }); + + it('should require a valid URL for logo_file', () => { + const project = projectBuilder() + .with('logo_file', faker.string.numeric()) + .build(); + + const result = ProjectSchema.safeParse(project); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_string', + message: 'Invalid url', + path: ['logo_file'], + validation: 'url', + }, + ]); + }); + + it('should not validate an invalid Project', () => { + const project = { invalid: 'project' }; + + const result = ProjectSchema.safeParse(project); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['description'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['logo_file'], + received: 'undefined', + }, + ]); + }); + }); + + describe('AbiSchema', () => { + it('should validate an Abi', () => { + const abi = abiBuilder().build(); + + const result = AbiSchema.safeParse(abi); + + expect(result.success).toBe(true); + }); + + it('should expect an array of objects for abi_json', () => { + const abi = abiBuilder() + .with('abi_json', [ + faker.string.numeric() as unknown as Record, + ]) + .build(); + + const result = AbiSchema.safeParse(abi); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'object', + message: 'Expected object, received string', + path: ['abi_json', 0], + received: 'string', + }, + ]); + }); + + it('should require a valid hex string for abi_hash', () => { + const abi = abiBuilder() + .with('abi_hash', faker.string.numeric() as `0x${string}`) + .build(); + + const result = AbiSchema.safeParse(abi); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'custom', + message: 'Invalid "0x" notated hex string', + path: ['abi_hash'], + }, + ]); + }); + + it('should coerce modified to date', () => { + const date = faker.date.past(); + const abi = abiBuilder() + .with('modified', date.toISOString() as unknown as Date) + .build(); + + const result = AbiSchema.safeParse(abi); + + expect(result.success && result.data.modified).toStrictEqual(date); + }); + + it('should not validate an invalid Abi', () => { + const abi = { invalid: 'abi' }; + + const result = AbiSchema.safeParse(abi); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'array', + message: 'Required', + path: ['abi_json'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['abi_hash'], + received: 'undefined', + }, + { + code: 'invalid_date', + message: 'Invalid date', + path: ['modified'], + }, + ]); + }); + }); + + describe('ContractSchema', () => { + it('should validate a Contract', () => { + const contract = contractBuilder().build(); + + const result = ContractSchema.safeParse(contract); + + expect(result.success).toBe(true); + }); + + it('should checksum the address', () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase(); + const contract = contractBuilder() + .with('address', nonChecksummedAddress as `0x${string}`) + .build(); + + const result = ContractSchema.safeParse(contract); + + expect(result.success && result.data.address).toStrictEqual( + getAddress(nonChecksummedAddress), + ); + }); + + it('should expect a numeric chain_id, coercing it to a string', () => { + const chainId = faker.number.int(); + const contract = contractBuilder() + .with('chain_id', chainId as unknown as `${number}`) + .build(); + + const result = ContractSchema.safeParse(contract); + + expect(result.success && result.data.chain_id).toBe(`${chainId}`); + }); + + it('should coerce modified to date', () => { + const date = faker.date.past(); + const contract = contractBuilder() + .with('modified', date.toISOString() as unknown as Date) + .build(); + + const result = ContractSchema.safeParse(contract); + + expect(result.success && result.data.modified).toStrictEqual(date); + }); + + it('should not validate an invalid Contract', () => { + const contract = { invalid: 'contract' }; + + const result = ContractSchema.safeParse(contract); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['address'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['name'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['display_name'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'number', + message: 'Required', + path: ['chain_id'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'object', + message: 'Required', + path: ['project'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'object', + message: 'Required', + path: ['abi'], + received: 'undefined', + }, + { + code: 'invalid_date', + message: 'Invalid date', + path: ['modified'], + }, + ]); + }); + }); +}); diff --git a/src/domain/data-decoder/v2/entities/contract.entity.ts b/src/domain/data-decoder/v2/entities/contract.entity.ts new file mode 100644 index 0000000000..8849527508 --- /dev/null +++ b/src/domain/data-decoder/v2/entities/contract.entity.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { HexSchema } from '@/validation/entities/schemas/hex.schema'; + +export const ProjectSchema = z.object({ + description: z.string(), + logo_file: z.string().url(), +}); + +export const AbiSchema = z.object({ + // We could use abitype here, but we don't consume the ABI/it would increase entity complexity + abi_json: z.array(z.record(z.unknown())), + abi_hash: HexSchema, + modified: z.coerce.date(), +}); + +export const ContractSchema = z.object({ + address: AddressSchema, + name: z.string(), + display_name: z.string().nullable(), + chain_id: z.number().transform(String), + project: ProjectSchema.nullable(), + abi: AbiSchema, + modified: z.coerce.date(), +}); + +export type Contract = z.infer; + +export const ContractPageSchema = buildPageSchema(ContractSchema); + +export type ContractPage = z.infer; diff --git a/src/domain/data-decoder/v2/entities/data-decoded.entity.spec.ts b/src/domain/data-decoder/v2/entities/data-decoded.entity.spec.ts new file mode 100644 index 0000000000..f20bb48e10 --- /dev/null +++ b/src/domain/data-decoder/v2/entities/data-decoded.entity.spec.ts @@ -0,0 +1,355 @@ +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { ZodError } from 'zod'; +import { + baseDataDecodedBuilder, + dataDecodedBuilder, + multisendBuilder, + parameterBuilder, +} from '@/domain/data-decoder/v2/entities/__tests__/data-decoded.builder'; +import { + BaseDataDecodedSchema, + DataDecodedSchema, + MultisendSchema, + ParameterSchema, + ValueDecodedSchema, +} from '@/domain/data-decoder/v2/entities/data-decoded.entity'; +import type { Accuracy } from '@/domain/data-decoder/v2/entities/data-decoded.entity'; +import type { Operation } from '@/domain/safe/entities/operation.entity'; + +describe('DataDecoded', () => { + describe('MultisendSchema', () => { + it('should validate a Multisend', () => { + const multisend = multisendBuilder().build(); + + const result = MultisendSchema.safeParse(multisend); + + expect(result.success).toBe(true); + }); + + it('should expect a valid operation', () => { + const multisend = multisendBuilder() + .with('operation', faker.number.int({ min: 2 }) as Operation) + .build(); + + const result = MultisendSchema.safeParse(multisend); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_enum_value', + message: `Invalid enum value. Expected 0 | 1, received '${multisend.operation}'`, + options: [0, 1], + path: ['operation'], + received: multisend.operation, + }, + ]); + }); + + it('should expect a numeric string value', () => { + const multisend = multisendBuilder() + .with('value', faker.string.hexadecimal()) + .build(); + + const result = MultisendSchema.safeParse(multisend); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'custom', + message: 'Invalid base-10 numeric string', + path: ['value'], + }, + ]); + }); + + it('should checksum the to', () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase(); + const multisend = multisendBuilder() + .with('to', nonChecksummedAddress as `0x${string}`) + .build(); + + const result = MultisendSchema.safeParse(multisend); + + expect(result.success && result.data.to).toBe( + getAddress(nonChecksummedAddress), + ); + }); + + it('should expect hex data', () => { + const multisend = multisendBuilder() + .with('data', faker.string.alpha() as `0x${string}`) + .build(); + + const result = MultisendSchema.safeParse(multisend); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'custom', + message: 'Invalid "0x" notated hex string', + path: ['data'], + }, + ]); + }); + + it('should not validate an invalid Multisend', () => { + const multisend = { invalid: 'multisend' }; + + const result = MultisendSchema.safeParse(multisend); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: '0 | 1', + message: 'Required', + path: ['operation'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['value'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'object', + message: 'Required', + path: ['data_decoded'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['to'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['data'], + received: 'undefined', + }, + ]); + }); + }); + + describe('ValueDecodedSchema', () => { + it('should validate a Multisend', () => { + const multisend = multisendBuilder().build(); + + const result = ValueDecodedSchema.safeParse([multisend]); + + expect(result.success).toBe(true); + }); + + it('should validate a BaseDataDecoded', () => { + const baseDataDecoded = baseDataDecodedBuilder().build(); + + const result = ValueDecodedSchema.safeParse(baseDataDecoded); + + expect(result.success).toBe(true); + }); + + it('should not validate an invalid ValueDecoded', () => { + const valueDecoded = { invalid: 'valueDecoded' }; + + const result = ValueDecodedSchema.safeParse(valueDecoded); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_union', + message: 'Invalid input', + path: [], + unionErrors: [ + new ZodError([ + { + code: 'invalid_type', + expected: 'array', + received: 'object', + path: [], + message: 'Expected array, received object', + }, + ]), + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['method'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'array', + received: 'undefined', + path: ['parameters'], + message: 'Required', + }, + ]), + ], + }, + ]); + }); + }); + + describe('ParameterSchema', () => { + it('should validate a Parameter', () => { + const parameter = parameterBuilder().build(); + + const result = ParameterSchema.safeParse(parameter); + + expect(result.success).toBe(true); + }); + + it('should expect hex value', () => { + const parameter = parameterBuilder() + .with('value', faker.string.alpha() as `0x${string}`) + .build(); + + const result = ParameterSchema.safeParse(parameter); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'custom', + message: 'Invalid "0x" notated hex string', + path: ['value'], + }, + ]); + }); + + it('should not validate an invalid Parameter', () => { + const parameter = { invalid: 'parameter' }; + + const result = ParameterSchema.safeParse(parameter); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['name'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['type'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['value'], + received: 'undefined', + }, + { + code: 'invalid_union', + message: 'Invalid input', + path: ['value_decoded'], + unionErrors: [ + new ZodError([ + { + code: 'invalid_type', + expected: 'array', + received: 'undefined', + path: ['value_decoded'], + message: 'Required', + }, + ]), + new ZodError([ + { + code: 'invalid_type', + expected: 'object', + received: 'undefined', + path: ['value_decoded'], + message: 'Required', + }, + ]), + ], + }, + ]); + }); + }); + + describe('BaseDataDecodedSchema', () => { + it('should validate a BaseDataDecoded', () => { + const baseDataDecoded = baseDataDecodedBuilder().build(); + + const result = BaseDataDecodedSchema.safeParse(baseDataDecoded); + + expect(result.success).toBe(true); + }); + + it('should not validate an invalid BaseDataDecoded', () => { + const baseDataDecoded = { invalid: 'baseDataDecoded' }; + + const result = BaseDataDecodedSchema.safeParse(baseDataDecoded); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['method'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'array', + message: 'Required', + path: ['parameters'], + received: 'undefined', + }, + ]); + }); + }); + + describe('DataDecodedSchema', () => { + it('should validate a DataDecoded', () => { + const dataDecoded = dataDecodedBuilder().build(); + + const result = DataDecodedSchema.safeParse(dataDecoded); + + expect(result.success).toBe(true); + }); + + it('should catch invalid accuracy, assigning it as UNKNOWN', () => { + const dataDecoded = dataDecodedBuilder() + .with('accuracy', 'invalid' as (typeof Accuracy)[number]) + .build(); + + const result = DataDecodedSchema.safeParse(dataDecoded); + + expect(result.success && result.data.accuracy).toBe('UNKNOWN'); + }); + + it('should not validate an invalid DataDecoded', () => { + const dataDecoded = { invalid: 'dataDecoded' }; + + const result = DataDecodedSchema.safeParse(dataDecoded); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['method'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'array', + message: 'Required', + path: ['parameters'], + received: 'undefined', + }, + ]); + }); + }); +}); diff --git a/src/domain/data-decoder/v2/entities/data-decoded.entity.ts b/src/domain/data-decoder/v2/entities/data-decoded.entity.ts new file mode 100644 index 0000000000..c7d98f3c71 --- /dev/null +++ b/src/domain/data-decoder/v2/entities/data-decoded.entity.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; +import { AddressSchema as _AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { HexSchema as _HexSchema } from '@/validation/entities/schemas/hex.schema'; +import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; +import { Operation } from '@/domain/safe/entities/operation.entity'; + +// ZodEffects cannot be recursively inferred and need be casted +const AddressSchema = _AddressSchema as z.ZodType<`0x${string}`>; +const HexSchema = _HexSchema as z.ZodType<`0x${string}`>; + +export const MultisendSchema = z.object({ + operation: z.nativeEnum(Operation), + value: NumericStringSchema, + data_decoded: z.lazy(() => BaseDataDecodedSchema.nullable()), + to: AddressSchema, + data: HexSchema.nullable(), +}); + +export const ValueDecodedSchema = z.union([ + z.array(z.lazy(() => MultisendSchema)), + z.lazy(() => BaseDataDecodedSchema), +]); + +export const ParameterSchema = z.object({ + name: z.string(), + type: z.string(), + value: HexSchema, + value_decoded: ValueDecodedSchema.nullable(), +}); + +export const BaseDataDecodedSchemaShape = { + method: z.string(), + parameters: z.array(z.lazy(() => ParameterSchema)), +}; + +// We need explicitly define ZodType due to recursion +export const BaseDataDecodedSchema: z.ZodType<{ + method: string; + parameters: Array>; +}> = z.lazy(() => z.object(BaseDataDecodedSchemaShape)); + +export const Accuracy = [ + 'FULL_MATCH', // Matched contract and chain ID + 'PARTIAL_MATCH', // Matched contract + 'ONLY_FUNCTION_MATCH', // Matched function from another contract + 'NO_MATCH', // Selector cannot be decoded +] as const; + +export const DataDecodedSchema = z.lazy(() => + z.object(BaseDataDecodedSchemaShape).extend({ + accuracy: z.enum([...Accuracy, 'UNKNOWN']).catch('UNKNOWN'), + }), +); + +export type DataDecoded = z.infer; diff --git a/src/domain/interfaces/data-decoder-api.interface.ts b/src/domain/interfaces/data-decoder-api.interface.ts new file mode 100644 index 0000000000..3584c027e7 --- /dev/null +++ b/src/domain/interfaces/data-decoder-api.interface.ts @@ -0,0 +1,19 @@ +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'; +import type { Raw } from '@/validation/entities/raw.entity'; + +export const IDataDecoderApi = Symbol('IDataDecoderApi'); + +export interface IDataDecoderApi { + getDecodedData(args: { + data: `0x${string}`; + to: `0x${string}`; + }): Promise>; + + getContracts(args: { + address: `0x${string}`; + limit?: number; + offset?: number; + }): Promise>>; +} diff --git a/src/domain/interfaces/data-decoder-api.manager.interface.ts b/src/domain/interfaces/data-decoder-api.manager.interface.ts new file mode 100644 index 0000000000..20241db166 --- /dev/null +++ b/src/domain/interfaces/data-decoder-api.manager.interface.ts @@ -0,0 +1,7 @@ +import type { IApiManager } from '@/domain/interfaces/api.manager.interface'; +import type { IDataDecoderApi } from '@/domain/interfaces/data-decoder-api.interface'; + +export const IDataDecoderApiManager = Symbol('IDataDecoderApiManager'); + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface IDataDecoderApiManager extends IApiManager {}