Skip to content

Commit

Permalink
Init AddressBooksDatasource
Browse files Browse the repository at this point in the history
  • Loading branch information
hectorgomezv committed Nov 17, 2024
1 parent 8f74a8b commit e3b0dd2
Show file tree
Hide file tree
Showing 11 changed files with 298 additions and 0 deletions.
18 changes: 18 additions & 0 deletions migrations/deprecated/00010_address-books/index.sql
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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<ILoggingService>;

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

const mockEncryptionApiManager = jest.mocked({
getApi: jest.fn(),
} as jest.MockedObjectDeep<EncryptionApiManager>);

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
});
});
});
69 changes: 69 additions & 0 deletions src/datasources/accounts/address-books/address-books.datasource.ts
Original file line number Diff line number Diff line change
@@ -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<number>(
'expirationTimeInSeconds.default',
);
}

async createAddressBookItem(args: {
account: Account;
createAddressBookItemDto: CreateAddressBookItemDto;
}): Promise<AddressBook> {
// TODO: return AddressBookItem
const cacheDir = CacheRouter.getAddressBookCacheDir(args.account.id);
const [dbAddressBook] = await this.cachedQueryResolver.get<DbAddressBook[]>(
{
cacheDir,
query: this.sql<DbAddressBook[]>`
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<AddressBook> {
const encryptionApi = await this.encryptionApiManager.getApi();
const encryptedBlob = await encryptionApi.encryptBlob([]);
const [dbAddressBook] = await this.sql<DbAddressBook[]>`
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);
}
}
Original file line number Diff line number Diff line change
@@ -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),
};
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 5 additions & 0 deletions src/datasources/cache/cache.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`,
Expand Down
10 changes: 10 additions & 0 deletions src/domain/accounts/address-books/entities/address-book.entity.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AddressBookSchema>;

export const AddressBookSchema = RowSchema.extend({
data: z.object({}),
accountId: AccountSchema.shape.id,
});
Original file line number Diff line number Diff line change
@@ -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<typeof CreateAddressBookItemDtoSchema>
{
name: string;
address: `0x${string}`;

constructor(props: CreateAddressBookItemDto) {
this.name = props.name;
this.address = props.address;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpException, HttpStatus } from '@nestjs/common';

export class AddressBookNotFoundError extends HttpException {
constructor() {
super(`Address Book not found`, HttpStatus.NOT_FOUND);
}
}
12 changes: 12 additions & 0 deletions src/domain/interfaces/address-books.datasource.interface.ts
Original file line number Diff line number Diff line change
@@ -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<AddressBook>;
}

0 comments on commit e3b0dd2

Please sign in to comment.