diff --git a/migrations/deprecated/00010_address-books/index.sql b/migrations/deprecated/00010_address-books/index.sql new file mode 100644 index 0000000000..41cc161cc9 --- /dev/null +++ b/migrations/deprecated/00010_address-books/index.sql @@ -0,0 +1,18 @@ +CREATE TABLE address_books ( + id SERIAL PRIMARY KEY, + data BYTEA NOT NULL, + key BYTEA NOT NULL, + iv BYTEA NOT NULL, + account_id INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + CONSTRAINT unique_account UNIQUE (account_id) +); + +CREATE INDEX idx_address_books_account_id ON address_books(account_id); + +CREATE OR REPLACE TRIGGER update_address_books_updated_at +BEFORE UPDATE ON address_books +FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/src/datasources/accounts/address-books/address-books.datasource.module.ts b/src/datasources/accounts/address-books/address-books.datasource.module.ts new file mode 100644 index 0000000000..2c04b3b0b0 --- /dev/null +++ b/src/datasources/accounts/address-books/address-books.datasource.module.ts @@ -0,0 +1,18 @@ +import { AddressBooksDatasource } from '@/datasources/accounts/address-books/address-books.datasource'; +import { AddressBookDbMapper } from '@/datasources/accounts/address-books/entities/address-book.db.mapper'; +import { PostgresDatabaseModule } from '@/datasources/db/v1/postgres-database.module'; +import { IAddressBooksDataSource } from '@/domain/interfaces/address-books.datasource.interface'; +import { Module } from '@nestjs/common'; + +@Module({ + imports: [PostgresDatabaseModule], + providers: [ + AddressBookDbMapper, + { + provide: IAddressBooksDataSource, + useClass: AddressBooksDatasource, + }, + ], + exports: [IAddressBooksDataSource], +}) +export class AddressBooksDatasourceModule {} diff --git a/src/datasources/accounts/address-books/address-books.datasource.spec.ts b/src/datasources/accounts/address-books/address-books.datasource.spec.ts new file mode 100644 index 0000000000..cf30bbb9f0 --- /dev/null +++ b/src/datasources/accounts/address-books/address-books.datasource.spec.ts @@ -0,0 +1,95 @@ +import { TestDbFactory } from '@/__tests__/db.factory'; +import type { IConfigurationService } from '@/config/configuration.service.interface'; +import { AddressBooksDatasource } from '@/datasources/accounts/address-books/address-books.datasource'; +import { AddressBookDbMapper } from '@/datasources/accounts/address-books/entities/address-book.db.mapper'; +import type { EncryptionApiManager } from '@/datasources/accounts/encryption/encryption-api.manager'; +import { LocalEncryptionApiService } from '@/datasources/accounts/encryption/local-encryption-api.service'; +import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; +import { CachedQueryResolver } from '@/datasources/db/v1/cached-query-resolver'; +import { PostgresDatabaseMigrator } from '@/datasources/db/v1/postgres-database.migrator'; +import { createAccountDtoBuilder } from '@/domain/accounts/entities/__tests__/create-account.dto.builder'; +import type { Account } from '@/domain/accounts/entities/account.entity'; +import type { ILoggingService } from '@/logging/logging.interface'; +import { faker } from '@faker-js/faker/.'; +import type postgres from 'postgres'; +import { getAddress } from 'viem'; + +const mockLoggingService = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +} as jest.MockedObjectDeep; + +const mockConfigurationService = jest.mocked({ + getOrThrow: jest.fn(), +} as jest.MockedObjectDeep); + +const mockEncryptionApiManager = jest.mocked({ + getApi: jest.fn(), +} as jest.MockedObjectDeep); + +describe('AddressBooksDataSource', () => { + let fakeCacheService: FakeCacheService; + let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + let target: AddressBooksDatasource; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + fakeCacheService = new FakeCacheService(); + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); + await migrator.migrate(); + mockConfigurationService.getOrThrow.mockImplementation((key) => { + if (key === 'expirationTimeInSeconds.default') return faker.number.int(); + if (key === 'application.isProduction') return false; + if (key === 'accounts.encryption.local.algorithm') return 'aes-256-cbc'; + if (key === 'accounts.encryption.local.key') return 'a'.repeat(64); + if (key === 'accounts.encryption.local.iv') return 'b'.repeat(32); + }); + mockEncryptionApiManager.getApi.mockResolvedValue( + new LocalEncryptionApiService(mockConfigurationService), + ); + + target = new AddressBooksDatasource( + sql, + new CachedQueryResolver(mockLoggingService, fakeCacheService), + mockEncryptionApiManager, + mockConfigurationService, + new AddressBookDbMapper(), + ); + }); + + beforeEach(async () => { + await sql`TRUNCATE TABLE accounts, account_data_settings, address_books CASCADE`; + fakeCacheService.clear(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); + + describe('createAddressBookItem', () => { + it('should create a new address book item', async () => { + const createAccountDto = createAccountDtoBuilder().build(); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address, name, name_hash) VALUES (${createAccountDto.address}, ${createAccountDto.name}, ${faker.string.alphanumeric(32)}) RETURNING *`; + const addressBookItem = await target.createAddressBookItem({ + account, + createAddressBookItemDto: { + // TODO: builder + name: faker.string.alphanumeric(), + address: getAddress(faker.finance.ethereumAddress()), + }, + }); + expect(addressBookItem).toMatchObject({ + id: expect.any(Number), + data: expect.any(Buffer), // TODO: this should be decrypted + accountId: account.id, + }); // TODO: this should be an item, not the whole address book + }); + }); +}); diff --git a/src/datasources/accounts/address-books/address-books.datasource.ts b/src/datasources/accounts/address-books/address-books.datasource.ts new file mode 100644 index 0000000000..1e1c55ff38 --- /dev/null +++ b/src/datasources/accounts/address-books/address-books.datasource.ts @@ -0,0 +1,69 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { AddressBookDbMapper } from '@/datasources/accounts/address-books/entities/address-book.db.mapper'; +import { AddressBook as DbAddressBook } from '@/datasources/accounts/address-books/entities/address-book.entity'; +import { CacheRouter } from '@/datasources/cache/cache.router'; +import { CachedQueryResolver } from '@/datasources/db/v1/cached-query-resolver'; +import { ICachedQueryResolver } from '@/datasources/db/v1/cached-query-resolver.interface'; +import { AddressBook } from '@/domain/accounts/address-books/entities/address-book.entity'; +import { CreateAddressBookItemDto } from '@/domain/accounts/address-books/entities/create-address-book-item.dto.entity'; +import { Account } from '@/domain/accounts/entities/account.entity'; +import { IEncryptionApiManager } from '@/domain/interfaces/encryption-api.manager.interface'; +import { Inject, Injectable } from '@nestjs/common'; +import postgres from 'postgres'; + +@Injectable() +export class AddressBooksDatasource { + private readonly defaultExpirationTimeInSeconds: number; + + constructor( + // @Inject(CacheService) private readonly cacheService: ICacheService, + @Inject('DB_INSTANCE') private readonly sql: postgres.Sql, + @Inject(ICachedQueryResolver) + private readonly cachedQueryResolver: CachedQueryResolver, + @Inject(IEncryptionApiManager) + private readonly encryptionApiManager: IEncryptionApiManager, + // @Inject(LoggingService) private readonly loggingService: ILoggingService, + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + private readonly addressBookMapper: AddressBookDbMapper, + ) { + this.defaultExpirationTimeInSeconds = + this.configurationService.getOrThrow( + 'expirationTimeInSeconds.default', + ); + } + + async createAddressBookItem(args: { + account: Account; + createAddressBookItemDto: CreateAddressBookItemDto; + }): Promise { + // TODO: return AddressBookItem + const cacheDir = CacheRouter.getAddressBookCacheDir(args.account.id); + const [dbAddressBook] = await this.cachedQueryResolver.get( + { + cacheDir, + query: this.sql` + SELECT * FROM address_books WHERE account_id = ${args.account.id} + `, + ttl: this.defaultExpirationTimeInSeconds, + }, + ); + if (!dbAddressBook) { + return this.createAddressBook({ account: args.account }); // TODO: return AddressBookItem + } + return this.addressBookMapper.map(dbAddressBook); + } + + private async createAddressBook(args: { + account: Account; + }): Promise { + const encryptionApi = await this.encryptionApiManager.getApi(); + const encryptedBlob = await encryptionApi.encryptBlob([]); + const [dbAddressBook] = await this.sql` + INSERT INTO address_books (data, key, iv, account_id) + VALUES (${encryptedBlob.encryptedData}, ${encryptedBlob.encryptedDataKey}, ${encryptedBlob.iv}, ${args.account.id}) + RETURNING *; + `; + return this.addressBookMapper.map(dbAddressBook); + } +} diff --git a/src/datasources/accounts/address-books/entities/address-book.db.mapper.ts b/src/datasources/accounts/address-books/entities/address-book.db.mapper.ts new file mode 100644 index 0000000000..1bbdc9f601 --- /dev/null +++ b/src/datasources/accounts/address-books/entities/address-book.db.mapper.ts @@ -0,0 +1,17 @@ +import { AddressBook as DbAddressBook } from '@/datasources/accounts/address-books/entities/address-book.entity'; +import { convertToDate } from '@/datasources/common/utils'; +import { AddressBook } from '@/domain/accounts/address-books/entities/address-book.entity'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AddressBookDbMapper { + map(addressBook: DbAddressBook): AddressBook { + return { + id: addressBook.id, + data: addressBook.data, + accountId: addressBook.account_id, + created_at: convertToDate(addressBook.created_at), + updated_at: convertToDate(addressBook.updated_at), + }; + } +} diff --git a/src/datasources/accounts/address-books/entities/address-book.entity.ts b/src/datasources/accounts/address-books/entities/address-book.entity.ts new file mode 100644 index 0000000000..3ff1e0472e --- /dev/null +++ b/src/datasources/accounts/address-books/entities/address-book.entity.ts @@ -0,0 +1,27 @@ +export class AddressBook { + id: number; + data: object; + key: string; + iv: string; + account_id: number; + created_at: Date; + updated_at: Date; + + constructor( + id: number, + data: object, + key: string, + iv: string, + account_id: number, + created_at: Date, + updated_at: Date, + ) { + this.id = id; + this.data = data; + this.key = key; + this.iv = iv; + this.account_id = account_id; + this.created_at = created_at; + this.updated_at = updated_at; + } +} diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index 17c781d58e..f97cc61283 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -5,6 +5,7 @@ export class CacheRouter { private static readonly ACCOUNT_DATA_SETTINGS_KEY = 'account_data_settings'; private static readonly ACCOUNT_DATA_TYPES_KEY = 'account_data_types'; private static readonly ACCOUNT_KEY = 'account'; + private static readonly ADDRESS_BOOK_KEY = 'address_book'; private static readonly ALL_TRANSACTIONS_KEY = 'all_transactions'; private static readonly AUTH_NONCE_KEY = 'auth_nonce'; private static readonly BACKBONE_KEY = 'backbone'; @@ -544,6 +545,10 @@ export class CacheRouter { ); } + static getAddressBookCacheDir(accountId: number): CacheDir { + return new CacheDir(`${CacheRouter.ADDRESS_BOOK_KEY}_${accountId}`, ''); + } + static getCounterfactualSafeCacheDir( chainId: string, predictedAddress: `0x${string}`, diff --git a/src/domain/accounts/address-books/entities/address-book.entity.ts b/src/domain/accounts/address-books/entities/address-book.entity.ts new file mode 100644 index 0000000000..1c3f0984f7 --- /dev/null +++ b/src/domain/accounts/address-books/entities/address-book.entity.ts @@ -0,0 +1,10 @@ +import { RowSchema } from '@/datasources/db/v1/entities/row.entity'; +import { AccountSchema } from '@/domain/accounts/entities/account.entity'; +import { z } from 'zod'; + +export type AddressBook = z.infer; + +export const AddressBookSchema = RowSchema.extend({ + data: z.object({}), + accountId: AccountSchema.shape.id, +}); diff --git a/src/domain/accounts/address-books/entities/create-address-book-item.dto.entity.ts b/src/domain/accounts/address-books/entities/create-address-book-item.dto.entity.ts new file mode 100644 index 0000000000..89158aa4ad --- /dev/null +++ b/src/domain/accounts/address-books/entities/create-address-book-item.dto.entity.ts @@ -0,0 +1,20 @@ +import { AccountNameSchema } from '@/domain/accounts/entities/schemas/account-name.schema'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { z } from 'zod'; + +export const CreateAddressBookItemDtoSchema = z.object({ + name: AccountNameSchema, + address: AddressSchema, +}); + +export class CreateAddressBookItemDto + implements z.infer +{ + name: string; + address: `0x${string}`; + + constructor(props: CreateAddressBookItemDto) { + this.name = props.name; + this.address = props.address; + } +} diff --git a/src/domain/accounts/address-books/errors/address-book-not-found.error.ts b/src/domain/accounts/address-books/errors/address-book-not-found.error.ts new file mode 100644 index 0000000000..1464947086 --- /dev/null +++ b/src/domain/accounts/address-books/errors/address-book-not-found.error.ts @@ -0,0 +1,7 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class AddressBookNotFoundError extends HttpException { + constructor() { + super(`Address Book not found`, HttpStatus.NOT_FOUND); + } +} diff --git a/src/domain/interfaces/address-books.datasource.interface.ts b/src/domain/interfaces/address-books.datasource.interface.ts new file mode 100644 index 0000000000..b61a126dfa --- /dev/null +++ b/src/domain/interfaces/address-books.datasource.interface.ts @@ -0,0 +1,12 @@ +import type { AddressBook } from '@/domain/accounts/address-books/entities/address-book.entity'; +import type { CreateAddressBookItemDto } from '@/domain/accounts/address-books/entities/create-address-book-item.dto.entity'; +import type { Account } from '@/domain/accounts/entities/account.entity'; + +export const IAddressBooksDataSource = Symbol('IAddressBooksDataSource'); + +export interface IAddressBooksDataSource { + createAddressBookItem(args: { + account: Account; + createAddressBookItemDto: CreateAddressBookItemDto; + }): Promise; +}