diff --git a/.env.sample b/.env.sample index 6983bce77d..4ecd81a471 100644 --- a/.env.sample +++ b/.env.sample @@ -45,6 +45,13 @@ # PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL= # PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY= +# Portfolio Provider - Octav +# The portfolio provider to be used. +# (default=https://octav-api.hasura.app) +# PORTFOLIO_API_BASE_URI= +# The API key to be used. +# PORTFOLIO_API_KEY= + # Relay Provider # The relay provider to be used. # (default='https://api.gelato.digital') diff --git a/src/config/configuration.validator.spec.ts b/src/config/configuration.validator.spec.ts index 0e8a310a7b..37d6ca3017 100644 --- a/src/config/configuration.validator.spec.ts +++ b/src/config/configuration.validator.spec.ts @@ -30,6 +30,7 @@ describe('Configuration validator', () => { PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: faker.internet.email(), PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: faker.string.alphanumeric(), + PORTFOLIO_API_KEY: faker.string.uuid(), RELAY_PROVIDER_API_KEY_OPTIMISM: faker.string.uuid(), RELAY_PROVIDER_API_KEY_BSC: faker.string.uuid(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), @@ -76,6 +77,7 @@ describe('Configuration validator', () => { { key: 'PUSH_NOTIFICATIONS_API_PROJECT' }, { key: 'PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL' }, { key: 'PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY' }, + { key: 'PORTFOLIO_API_KEY' }, { key: 'RELAY_PROVIDER_API_KEY_OPTIMISM' }, { key: 'RELAY_PROVIDER_API_KEY_BSC' }, { key: 'RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN' }, @@ -127,6 +129,7 @@ describe('Configuration validator', () => { faker.internet.email(), PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: faker.string.alphanumeric(), + PORTFOLIO_API_KEY: faker.string.uuid(), RELAY_PROVIDER_API_KEY_OPTIMISM: faker.string.uuid(), RELAY_PROVIDER_API_KEY_BSC: faker.string.uuid(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), @@ -177,6 +180,7 @@ describe('Configuration validator', () => { faker.internet.email(), PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: faker.string.alphanumeric(), + PORTFOLIO_API_KEY: faker.string.uuid(), RELAY_PROVIDER_API_KEY_OPTIMISM: faker.string.uuid(), RELAY_PROVIDER_API_KEY_BSC: faker.string.uuid(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 6ddd4b1481..f2e63e870c 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -196,6 +196,10 @@ export default (): ReturnType => ({ owners: { ownersTtlSeconds: faker.number.int(), }, + portfolio: { + baseUri: faker.internet.url({ appendSlash: false }), + apiKey: faker.string.hexadecimal({ length: 32 }), + }, pushNotifications: { baseUri: faker.internet.url({ appendSlash: false }), project: faker.word.noun(), diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index bb829208c5..22f9ed4790 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -296,6 +296,11 @@ export default () => ({ maxOverviews: parseInt(process.env.MAX_SAFE_OVERVIEWS ?? `${10}`), }, }, + portfolio: { + baseUri: + process.env.PORTFOLIO_API_BASE_URI || 'https://octav-api.hasura.app', + apiKey: process.env.PORTFOLIO_API_KEY, + }, pushNotifications: { baseUri: process.env.PUSH_NOTIFICATIONS_API_BASE_URI || diff --git a/src/config/entities/schemas/configuration.schema.ts b/src/config/entities/schemas/configuration.schema.ts index 66722fd2f4..0f62ec0bf5 100644 --- a/src/config/entities/schemas/configuration.schema.ts +++ b/src/config/entities/schemas/configuration.schema.ts @@ -23,6 +23,7 @@ export const RootConfigurationSchema = z INFURA_API_KEY: z.string(), JWT_ISSUER: z.string(), JWT_SECRET: z.string(), + PORTFOLIO_API_KEY: z.string(), PUSH_NOTIFICATIONS_API_PROJECT: z.string(), PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: z.string().email(), PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: z.string(), diff --git a/src/datasources/portfolio-api/octav-api.service.spec.ts b/src/datasources/portfolio-api/octav-api.service.spec.ts new file mode 100644 index 0000000000..737f8d73e9 --- /dev/null +++ b/src/datasources/portfolio-api/octav-api.service.spec.ts @@ -0,0 +1,114 @@ +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; +import { DataSourceError } from '@/domain/errors/data-source.error'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; +import { OctavApi } from '@/datasources/portfolio-api/octav-api.service'; +import { rawify } from '@/validation/entities/raw.entity'; +import type { INetworkService } from '@/datasources/network/network.service.interface'; +import type { Portfolio } from '@/domain/portfolio/entities/portfolio.entity'; + +const mockNetworkService = jest.mocked({ + get: jest.fn(), +} as jest.MockedObjectDeep); + +describe('OctavApiService', () => { + let target: OctavApi; + let fakeConfigurationService: FakeConfigurationService; + let httpErrorFactory: HttpErrorFactory; + let baseUri: string; + let apiKey: string; + + beforeEach(() => { + jest.resetAllMocks(); + + fakeConfigurationService = new FakeConfigurationService(); + httpErrorFactory = new HttpErrorFactory(); + baseUri = faker.internet.url({ appendSlash: false }); + apiKey = faker.string.sample(); + fakeConfigurationService.set('portfolio.baseUri', baseUri); + fakeConfigurationService.set('portfolio.apiKey', apiKey); + + target = new OctavApi( + fakeConfigurationService, + mockNetworkService, + httpErrorFactory, + ); + }); + + it('should error if baseUri is not defined', () => { + const httpErrorFactory = new HttpErrorFactory(); + const _fakeConfigurationService = new FakeConfigurationService(); + fakeConfigurationService.set('portfolio.apiKey', apiKey); + + expect( + () => + new OctavApi( + _fakeConfigurationService, + mockNetworkService, + httpErrorFactory, + ), + ).toThrow(); + }); + + it('should error if apiKey is not defined', () => { + const httpErrorFactory = new HttpErrorFactory(); + const _fakeConfigurationService = new FakeConfigurationService(); + fakeConfigurationService.set('portfolio.baseUri', baseUri); + + expect( + () => + new OctavApi( + _fakeConfigurationService, + mockNetworkService, + httpErrorFactory, + ), + ).toThrow(); + }); + + describe('getPortfolio', () => { + it('should get portfolio', async () => { + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const portfolio: Portfolio = {}; + mockNetworkService.get.mockResolvedValueOnce({ + status: 200, + data: rawify(portfolio), + }); + + await target.getPortfolio(safeAddress); + + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${baseUri}/api/rest/portfolio`, + networkRequest: { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + params: { + addresses: safeAddress, + includeImages: true, + }, + }, + }); + }); + + it('should forward error', async () => { + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const status = faker.internet.httpStatusCode({ types: ['serverError'] }); + const error = new NetworkResponseError( + new URL(`${baseUri}/api/rest/portfolio`), + { + status, + } as Response, + { + message: 'Unexpected error', + }, + ); + mockNetworkService.get.mockRejectedValueOnce(error); + + await expect(target.getPortfolio(safeAddress)).rejects.toThrow( + new DataSourceError('Unexpected error', status), + ); + }); + }); +}); diff --git a/src/datasources/portfolio-api/octav-api.service.ts b/src/datasources/portfolio-api/octav-api.service.ts new file mode 100644 index 0000000000..bb8f02143c --- /dev/null +++ b/src/datasources/portfolio-api/octav-api.service.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { + NetworkService, + INetworkService, +} from '@/datasources/network/network.service.interface'; +import { IPortfolioApi } from '@/domain/interfaces/portfolio-api.interface'; +import { Portfolio } from '@/domain/portfolio/entities/portfolio.entity'; +import { Raw } from '@/validation/entities/raw.entity'; + +@Injectable() +export class OctavApi implements IPortfolioApi { + private readonly baseUri: string; + private readonly apiKey: string; + + constructor( + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + @Inject(NetworkService) + private readonly networkService: INetworkService, + private readonly httpErrorFactory: HttpErrorFactory, + ) { + this.baseUri = + this.configurationService.getOrThrow('portfolio.baseUri'); + this.apiKey = + this.configurationService.getOrThrow('portfolio.apiKey'); + } + + async getPortfolio(safeAddress: `0x${string}`): Promise> { + try { + const url = `${this.baseUri}/api/rest/portfolio`; + const { data: portfolio } = await this.networkService.get({ + url, + networkRequest: { + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + params: { + addresses: safeAddress, + includeImages: true, + }, + }, + }); + return portfolio; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } +} diff --git a/src/datasources/portfolio-api/portfolio-api.module.ts b/src/datasources/portfolio-api/portfolio-api.module.ts new file mode 100644 index 0000000000..b5a5039d05 --- /dev/null +++ b/src/datasources/portfolio-api/portfolio-api.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { OctavApi } from '@/datasources/portfolio-api/octav-api.service'; +import { IPortfolioApi } from '@/domain/interfaces/portfolio-api.interface'; + +@Module({ + providers: [HttpErrorFactory, { provide: IPortfolioApi, useClass: OctavApi }], + exports: [IPortfolioApi], +}) +export class PortfolioApiModule {} diff --git a/src/domain/interfaces/portfolio-api.interface.ts b/src/domain/interfaces/portfolio-api.interface.ts new file mode 100644 index 0000000000..1438a12f00 --- /dev/null +++ b/src/domain/interfaces/portfolio-api.interface.ts @@ -0,0 +1,8 @@ +import type { Portfolio } from '@/domain/portfolio/entities/portfolio.entity'; +import type { Raw } from '@/validation/entities/raw.entity'; + +export const IPortfolioApi = Symbol('IPortfolioApi'); + +export interface IPortfolioApi { + getPortfolio(safeAddress: `0x${string}`): Promise>; +} diff --git a/src/domain/portfolio/entities/portfolio.entity.spec.ts b/src/domain/portfolio/entities/portfolio.entity.spec.ts new file mode 100644 index 0000000000..a6ab139eb6 --- /dev/null +++ b/src/domain/portfolio/entities/portfolio.entity.spec.ts @@ -0,0 +1 @@ +it.todo('PortfolioSchema'); diff --git a/src/domain/portfolio/entities/portfolio.entity.ts b/src/domain/portfolio/entities/portfolio.entity.ts new file mode 100644 index 0000000000..3b87812ace --- /dev/null +++ b/src/domain/portfolio/entities/portfolio.entity.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const PortfolioSchema = z.unknown(); + +export type Portfolio = z.infer; diff --git a/test/e2e-setup.ts b/test/e2e-setup.ts index a020c9414a..adbf1988d8 100644 --- a/test/e2e-setup.ts +++ b/test/e2e-setup.ts @@ -43,3 +43,6 @@ process.env.PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL = 'email@fake-email.com'; process.env.PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY = 'fake-private-key'; + +// For E2E tests, portfolio API dummy value +process.env.PORTFOLIO_API_KEY = 'fake-api-key';