diff --git a/migrations/00004_counterfactual-safes/index.sql b/migrations/00004_counterfactual-safes/index.sql new file mode 100644 index 0000000000..e76b6bac77 --- /dev/null +++ b/migrations/00004_counterfactual-safes/index.sql @@ -0,0 +1,23 @@ +DROP TABLE IF EXISTS counterfactual_safes CASCADE; + +CREATE TABLE counterfactual_safes ( + id SERIAL PRIMARY KEY, + chain_id VARCHAR(32) NOT NULL, + creator VARCHAR(42) NOT NULL, + fallback_handler VARCHAR(42) NOT NULL, + owners VARCHAR(42)[] NOT NULL, + predicted_address VARCHAR(42) NOT NULL, + salt_nonce VARCHAR(255) NOT NULL, + singleton_address VARCHAR(42) NOT NULL, + threshold INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + account_id INTEGER NOT NULL, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + CONSTRAINT unique_chain_address UNIQUE (account_id, chain_id, predicted_address) +); + +CREATE OR REPLACE TRIGGER update_counterfactual_safes_updated_at +BEFORE UPDATE ON counterfactual_safes +FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/00004_notifications/index.sql b/migrations/00005_notifications/index.sql similarity index 93% rename from migrations/00004_notifications/index.sql rename to migrations/00005_notifications/index.sql index 8f0c74ad20..ea56c2c2fb 100644 --- a/migrations/00004_notifications/index.sql +++ b/migrations/00005_notifications/index.sql @@ -12,20 +12,14 @@ CREATE TABLE notification_types( name VARCHAR(255) NOT NULL UNIQUE ); --- TODO: Confirm these types INSERT INTO notification_types (name) VALUES + ('CONFIRMATION_REQUEST'), -- PENDING_MULTISIG_TRANSACTION ('DELETED_MULTISIG_TRANSACTION'), ('EXECUTED_MULTISIG_TRANSACTION'), ('INCOMING_ETHER'), ('INCOMING_TOKEN'), - ('MESSAGE_CREATED'), - ('MODULE_TRANSACTION'), - ('NEW_CONFIRMATION'), - ('MESSAGE_CONFIRMATION'), - ('OUTGOING_ETHER'), - ('OUTGOING_TOKEN'), - ('PENDING_MULTISIG_TRANSACTION'), - ('SAFE_CREATED'); + ('MESSAGE_CONFIRMATION_REQUEST'), -- MESSAGE_CREATED + ('MODULE_TRANSACTION'); ---------------------------------------------------------------------- -- Chain-specific Safe notification preferences for a given account -- diff --git a/migrations/__tests__/00004_counterfactual-safes.spec.ts b/migrations/__tests__/00004_counterfactual-safes.spec.ts new file mode 100644 index 0000000000..fc641dd85e --- /dev/null +++ b/migrations/__tests__/00004_counterfactual-safes.spec.ts @@ -0,0 +1,236 @@ +import { TestDbFactory } from '@/__tests__/db.factory'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { faker } from '@faker-js/faker'; +import postgres from 'postgres'; +import { getAddress } from 'viem'; + +interface AccountRow { + id: number; + group_id: number; + created_at: Date; + updated_at: Date; + address: `0x${string}`; +} + +interface CounterfactualSafesRow { + created_at: Date; + updated_at: Date; + id: number; + chain_id: string; + creator: `0x${string}`; + fallback_handler: `0x${string}`; + owners: `0x${string}`[]; + predicted_address: `0x${string}`; + salt_nonce: string; + singleton_address: `0x${string}`; + threshold: number; + account_id: number; +} + +describe('Migration 00004_counterfactual-safes', () => { + let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); + }); + + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); + + it('runs successfully', async () => { + const result = await migrator.test({ + migration: '00004_counterfactual-safes', + after: async (sql: postgres.Sql) => { + return { + account_data_types: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'counterfactual_safes'`, + rows: await sql`SELECT * FROM account_data_settings`, + }, + }; + }, + }); + + expect(result.after).toStrictEqual({ + account_data_types: { + columns: expect.arrayContaining([ + { column_name: 'id' }, + { column_name: 'created_at' }, + { column_name: 'updated_at' }, + { column_name: 'chain_id' }, + { column_name: 'creator' }, + { column_name: 'fallback_handler' }, + { column_name: 'owners' }, + { column_name: 'predicted_address' }, + { column_name: 'salt_nonce' }, + { column_name: 'singleton_address' }, + { column_name: 'threshold' }, + { column_name: 'account_id' }, + ]), + rows: [], + }, + }); + }); + + it('should add one CounterfactualSafe and update its row timestamps', async () => { + const accountAddress = getAddress(faker.finance.ethereumAddress()); + let accountRows: AccountRow[] = []; + let counterfactualSafes: Partial[] = []; + + const { + after: counterfactualSafesRows, + }: { after: CounterfactualSafesRow[] } = await migrator.test({ + migration: '00004_counterfactual-safes', + after: async (sql: postgres.Sql): Promise => { + accountRows = await sql< + AccountRow[] + >`INSERT INTO accounts (address) VALUES (${accountAddress}) RETURNING *;`; + counterfactualSafes = [ + { + chain_id: faker.string.numeric(), + creator: accountAddress, + fallback_handler: getAddress(faker.finance.ethereumAddress()), + owners: [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ], + predicted_address: getAddress(faker.finance.ethereumAddress()), + salt_nonce: faker.string.numeric(), + singleton_address: getAddress(faker.finance.ethereumAddress()), + threshold: faker.number.int({ min: 1, max: 10 }), + account_id: accountRows[0].id, + }, + ]; + return sql< + CounterfactualSafesRow[] + >`INSERT INTO counterfactual_safes ${sql(counterfactualSafes)} RETURNING *`; + }, + }); + + expect(counterfactualSafesRows[0]).toMatchObject({ + chain_id: counterfactualSafes[0].chain_id, + creator: counterfactualSafes[0].creator, + fallback_handler: counterfactualSafes[0].fallback_handler, + owners: counterfactualSafes[0].owners, + predicted_address: counterfactualSafes[0].predicted_address, + salt_nonce: counterfactualSafes[0].salt_nonce, + singleton_address: counterfactualSafes[0].singleton_address, + threshold: counterfactualSafes[0].threshold, + account_id: accountRows[0].id, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }); + // created_at and updated_at should be the same after the row is created + const createdAt = new Date(counterfactualSafesRows[0].created_at); + const updatedAt = new Date(counterfactualSafesRows[0].updated_at); + expect(createdAt).toBeInstanceOf(Date); + expect(createdAt).toStrictEqual(updatedAt); + // only updated_at should be updated after the row is updated + const afterUpdate = await sql< + CounterfactualSafesRow[] + >`UPDATE counterfactual_safes + SET threshold = 4 + WHERE account_id = ${accountRows[0].id} + RETURNING *;`; + const updatedAtAfterUpdate = new Date(afterUpdate[0].updated_at); + const createdAtAfterUpdate = new Date(afterUpdate[0].created_at); + expect(createdAtAfterUpdate).toStrictEqual(createdAt); + expect(updatedAtAfterUpdate.getTime()).toBeGreaterThan(createdAt.getTime()); + }); + + it('should trigger a cascade delete when the referenced account is deleted', async () => { + const accountAddress = getAddress(faker.finance.ethereumAddress()); + let accountRows: AccountRow[] = []; + + const { + after: counterfactualSafesRows, + }: { after: CounterfactualSafesRow[] } = await migrator.test({ + migration: '00004_counterfactual-safes', + after: async (sql: postgres.Sql): Promise => { + accountRows = await sql< + AccountRow[] + >`INSERT INTO accounts (address) VALUES (${accountAddress}) RETURNING *;`; + await sql< + CounterfactualSafesRow[] + >`INSERT INTO counterfactual_safes ${sql([ + { + chain_id: faker.string.numeric(), + creator: accountAddress, + fallback_handler: getAddress(faker.finance.ethereumAddress()), + owners: [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ], + predicted_address: getAddress(faker.finance.ethereumAddress()), + salt_nonce: faker.string.numeric(), + singleton_address: getAddress(faker.finance.ethereumAddress()), + threshold: faker.number.int({ min: 1, max: 10 }), + account_id: accountRows[0].id, + }, + ])}`; + await sql`DELETE FROM accounts WHERE id = ${accountRows[0].id};`; + return sql< + CounterfactualSafesRow[] + >`SELECT * FROM counterfactual_safes WHERE account_id = ${accountRows[0].id}`; + }, + }); + + expect(counterfactualSafesRows).toHaveLength(0); + }); + + it('should throw an error if the unique(account_id, chain_id, predicted_address) constraint is violated', async () => { + const accountAddress = getAddress(faker.finance.ethereumAddress()); + let accountRows: AccountRow[] = []; + + await migrator.test({ + migration: '00004_counterfactual-safes', + after: async (sql: postgres.Sql) => { + accountRows = await sql< + AccountRow[] + >`INSERT INTO accounts (address) VALUES (${accountAddress}) RETURNING *;`; + const predicted_address = getAddress(faker.finance.ethereumAddress()); + const chain_id = faker.string.numeric(); + await sql< + CounterfactualSafesRow[] + >`INSERT INTO counterfactual_safes ${sql([ + { + chain_id, + creator: accountAddress, + fallback_handler: getAddress(faker.finance.ethereumAddress()), + owners: [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ], + predicted_address, + salt_nonce: faker.string.numeric(), + singleton_address: getAddress(faker.finance.ethereumAddress()), + threshold: faker.number.int({ min: 1, max: 10 }), + account_id: accountRows[0].id, + }, + ])}`; + await expect( + sql`INSERT INTO counterfactual_safes ${sql([ + { + chain_id, + creator: accountAddress, + fallback_handler: getAddress(faker.finance.ethereumAddress()), + owners: [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ], + predicted_address, + salt_nonce: faker.string.numeric(), + singleton_address: getAddress(faker.finance.ethereumAddress()), + threshold: faker.number.int({ min: 1, max: 10 }), + account_id: accountRows[0].id, + }, + ])}`, + ).rejects.toThrow('duplicate key value violates unique constraint'); + }, + }); + }); +}); diff --git a/migrations/__tests__/00004_notifications.spec.ts b/migrations/__tests__/00005_notifications.spec.ts similarity index 90% rename from migrations/__tests__/00004_notifications.spec.ts rename to migrations/__tests__/00005_notifications.spec.ts index fa6c26d33a..c12a1e4a1c 100644 --- a/migrations/__tests__/00004_notifications.spec.ts +++ b/migrations/__tests__/00005_notifications.spec.ts @@ -38,7 +38,7 @@ type NotificationChannelConfigurationsRow = { updated_at: Date; }; -describe('Migration 00004_notifications', () => { +describe('Migration 00005_notifications', () => { let sql: postgres.Sql; let migrator: PostgresDatabaseMigrator; const testDbFactory = new TestDbFactory(); @@ -54,7 +54,7 @@ describe('Migration 00004_notifications', () => { it('runs successfully', async () => { const result = await migrator.test({ - migration: '00004_notifications', + migration: '00005_notifications', after: async (sql: postgres.Sql) => { return { notification_types: { @@ -122,6 +122,10 @@ describe('Migration 00004_notifications', () => { { column_name: 'name' }, ]), rows: [ + { + id: expect.any(Number), + name: 'CONFIRMATION_REQUEST', + }, { id: expect.any(Number), name: 'DELETED_MULTISIG_TRANSACTION', @@ -140,36 +144,12 @@ describe('Migration 00004_notifications', () => { }, { id: expect.any(Number), - name: 'MESSAGE_CREATED', + name: 'MESSAGE_CONFIRMATION_REQUEST', }, { id: expect.any(Number), name: 'MODULE_TRANSACTION', }, - { - id: expect.any(Number), - name: 'NEW_CONFIRMATION', - }, - { - id: expect.any(Number), - name: 'MESSAGE_CONFIRMATION', - }, - { - id: expect.any(Number), - name: 'OUTGOING_ETHER', - }, - { - id: expect.any(Number), - name: 'OUTGOING_TOKEN', - }, - { - id: expect.any(Number), - name: 'PENDING_MULTISIG_TRANSACTION', - }, - { - id: expect.any(Number), - name: 'SAFE_CREATED', - }, ], }, notification_channels: { @@ -201,7 +181,7 @@ describe('Migration 00004_notifications', () => { it('should upsert the row timestamps of notification_subscriptions on insertion/update', async () => { const afterInsert = await migrator.test({ - migration: '00004_notifications', + migration: '00005_notifications', after: async (sql: postgres.Sql) => { await sql.begin(async (transaction) => { // Create account @@ -268,7 +248,7 @@ describe('Migration 00004_notifications', () => { it('should upsert the row timestamps of notification_channel_configurations on insertion/update', async () => { const afterInsert = await migrator.test({ - migration: '00004_notifications', + migration: '00005_notifications', after: async (sql: postgres.Sql) => { await sql.begin(async (transaction) => { // Create account @@ -350,7 +330,7 @@ describe('Migration 00004_notifications', () => { it('should prevent duplicate subscriptions in notification_subscriptions', async () => { await migrator.test({ - migration: '00004_notifications', + migration: '00005_notifications', after: async (sql) => { await sql.begin(async (transaction) => { // Create account @@ -374,7 +354,7 @@ describe('Migration 00004_notifications', () => { it('should delete the subscription and configuration if the account is deleted', async () => { const result = await migrator.test({ - migration: '00004_notifications', + migration: '00005_notifications', after: async (sql) => { await sql.begin(async (transaction) => { // Create account @@ -417,6 +397,10 @@ describe('Migration 00004_notifications', () => { expect(result.after).toStrictEqual({ notification_types: [ + { + id: expect.any(Number), + name: 'CONFIRMATION_REQUEST', + }, { id: expect.any(Number), name: 'DELETED_MULTISIG_TRANSACTION', @@ -435,36 +419,12 @@ describe('Migration 00004_notifications', () => { }, { id: expect.any(Number), - name: 'MESSAGE_CREATED', + name: 'MESSAGE_CONFIRMATION_REQUEST', }, { id: expect.any(Number), name: 'MODULE_TRANSACTION', }, - { - id: expect.any(Number), - name: 'NEW_CONFIRMATION', - }, - { - id: expect.any(Number), - name: 'MESSAGE_CONFIRMATION', - }, - { - id: expect.any(Number), - name: 'OUTGOING_ETHER', - }, - { - id: expect.any(Number), - name: 'OUTGOING_TOKEN', - }, - { - id: expect.any(Number), - name: 'PENDING_MULTISIG_TRANSACTION', - }, - { - id: expect.any(Number), - name: 'SAFE_CREATED', - }, ], // No subscriptions should exist notification_subscriptions: [], @@ -481,7 +441,7 @@ describe('Migration 00004_notifications', () => { it('should delete the notification_channel_configuration if the notification_channel is deleted', async () => { const result = await migrator.test({ - migration: '00004_notifications', + migration: '00005_notifications', after: async (sql) => { await sql.begin(async (transaction) => { // Create account @@ -531,6 +491,10 @@ describe('Migration 00004_notifications', () => { expect(result.after).toStrictEqual({ notification_types: [ + { + id: expect.any(Number), + name: 'CONFIRMATION_REQUEST', + }, { id: expect.any(Number), name: 'DELETED_MULTISIG_TRANSACTION', @@ -549,36 +513,12 @@ describe('Migration 00004_notifications', () => { }, { id: expect.any(Number), - name: 'MESSAGE_CREATED', + name: 'MESSAGE_CONFIRMATION_REQUEST', }, { id: expect.any(Number), name: 'MODULE_TRANSACTION', }, - { - id: expect.any(Number), - name: 'NEW_CONFIRMATION', - }, - { - id: expect.any(Number), - name: 'MESSAGE_CONFIRMATION', - }, - { - id: expect.any(Number), - name: 'OUTGOING_ETHER', - }, - { - id: expect.any(Number), - name: 'OUTGOING_TOKEN', - }, - { - id: expect.any(Number), - name: 'PENDING_MULTISIG_TRANSACTION', - }, - { - id: expect.any(Number), - name: 'SAFE_CREATED', - }, ], notification_subscriptions: [ { diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index c55c3b125d..604e707bd9 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -2,6 +2,7 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; +import { MAX_TTL } from '@/datasources/cache/constants'; import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/account-data-type.builder'; @@ -200,14 +201,43 @@ describe('AccountsDatasource tests', () => { }), ); + // store settings and counterfactual safes in the cache + const accountDataSettingsCacheDir = new CacheDir( + `account_data_settings_${address}`, + '', + ); + await fakeCacheService.set( + accountDataSettingsCacheDir, + faker.string.alpha(), + MAX_TTL, + ); + const counterfactualSafesCacheDir = new CacheDir( + `counterfactual_safes_${address}`, + '', + ); + await fakeCacheService.set( + counterfactualSafesCacheDir, + faker.string.alpha(), + MAX_TTL, + ); + // the account is deleted from the database and the cache await expect(target.deleteAccount(address)).resolves.not.toThrow(); await expect(target.getAccount(address)).rejects.toThrow(); - const cached = await fakeCacheService.get( - new CacheDir(`account_${address}`, ''), - ); + const accountCacheDir = new CacheDir(`account_${address}`, ''); + const cached = await fakeCacheService.get(accountCacheDir); expect(cached).toBeUndefined(); + // the settings and counterfactual safes are deleted from the cache + const accountDataSettingsCached = await fakeCacheService.get( + accountDataSettingsCacheDir, + ); + expect(accountDataSettingsCached).toBeUndefined(); + const counterfactualSafesCached = await fakeCacheService.get( + counterfactualSafesCacheDir, + ); + expect(counterfactualSafesCached).toBeUndefined(); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { type: 'cache_hit', @@ -450,10 +480,10 @@ describe('AccountsDatasource tests', () => { .with('accountDataSettings', accountDataSettings) .build(); - const actual = await target.upsertAccountDataSettings( + const actual = await target.upsertAccountDataSettings({ address, upsertAccountDataSettingsDto, - ); + }); const expected = accountDataSettings.map((accountDataSetting) => ({ account_id: account.id, @@ -483,10 +513,10 @@ describe('AccountsDatasource tests', () => { .with('accountDataSettings', accountDataSettings) .build(); - await target.upsertAccountDataSettings( + await target.upsertAccountDataSettings({ address, upsertAccountDataSettingsDto, - ); + }); // check the account data settings are stored in the cache const cacheDir = new CacheDir(`account_data_settings_${address}`, ''); @@ -521,10 +551,10 @@ describe('AccountsDatasource tests', () => { .with('accountDataSettings', accountDataSettings) .build(); - const beforeUpdate = await target.upsertAccountDataSettings( + const beforeUpdate = await target.upsertAccountDataSettings({ address, upsertAccountDataSettingsDto, - ); + }); expect(beforeUpdate).toStrictEqual( expect.arrayContaining( @@ -547,10 +577,10 @@ describe('AccountsDatasource tests', () => { .with('accountDataSettings', accountDataSettings2) .build(); - const afterUpdate = await target.upsertAccountDataSettings( + const afterUpdate = await target.upsertAccountDataSettings({ address, - upsertAccountDataSettingsDto2, - ); + upsertAccountDataSettingsDto: upsertAccountDataSettingsDto2, + }); expect(afterUpdate).toStrictEqual( expect.arrayContaining( @@ -582,7 +612,10 @@ describe('AccountsDatasource tests', () => { .build(); await expect( - target.upsertAccountDataSettings(address, upsertAccountDataSettingsDto), + target.upsertAccountDataSettings({ + address, + upsertAccountDataSettingsDto, + }), ).rejects.toThrow('Error getting account.'); }); @@ -608,7 +641,10 @@ describe('AccountsDatasource tests', () => { }); await expect( - target.upsertAccountDataSettings(address, upsertAccountDataSettingsDto), + target.upsertAccountDataSettings({ + address, + upsertAccountDataSettingsDto, + }), ).rejects.toThrow('Data types not found or not active.'); }); @@ -630,7 +666,10 @@ describe('AccountsDatasource tests', () => { .build(); await expect( - target.upsertAccountDataSettings(address, upsertAccountDataSettingsDto), + target.upsertAccountDataSettings({ + address, + upsertAccountDataSettingsDto, + }), ).rejects.toThrow(`Data types not found or not active.`); }); }); diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts index a2d275329b..5e5067e1df 100644 --- a/src/datasources/accounts/accounts.datasource.ts +++ b/src/datasources/accounts/accounts.datasource.ts @@ -5,7 +5,7 @@ import { ICacheService, } from '@/datasources/cache/cache.service.interface'; import { MAX_TTL } from '@/datasources/cache/constants'; -import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import { getFromCacheOrExecuteAndCache } from '@/datasources/db/utils'; import { AccountDataSetting } from '@/domain/accounts/entities/account-data-setting.entity'; import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; @@ -16,7 +16,6 @@ import { asError } from '@/logging/utils'; import { Inject, Injectable, - InternalServerErrorException, NotFoundException, OnModuleInit, UnprocessableEntityException, @@ -71,7 +70,9 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { async getAccount(address: `0x${string}`): Promise { const cacheDir = CacheRouter.getAccountCacheDir(address); - const [account] = await this.getFromCacheOrExecuteAndCache( + const [account] = await getFromCacheOrExecuteAndCache( + this.loggingService, + this.cacheService, cacheDir, this.sql`SELECT * FROM accounts WHERE address = ${address}`, this.defaultExpirationTimeInSeconds, @@ -94,14 +95,20 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { ); } } finally { - const { key } = CacheRouter.getAccountCacheDir(address); - await this.cacheService.deleteByKey(key); + const keys = [ + CacheRouter.getAccountCacheDir(address).key, + CacheRouter.getAccountDataSettingsCacheDir(address).key, + CacheRouter.getCounterfactualSafesCacheDir(address).key, + ]; + await Promise.all(keys.map((key) => this.cacheService.deleteByKey(key))); } } async getDataTypes(): Promise { const cacheDir = CacheRouter.getAccountDataTypesCacheDir(); - return this.getFromCacheOrExecuteAndCache( + return getFromCacheOrExecuteAndCache( + this.loggingService, + this.cacheService, cacheDir, this.sql`SELECT * FROM account_data_types`, MAX_TTL, @@ -113,7 +120,9 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { ): Promise { const account = await this.getAccount(address); const cacheDir = CacheRouter.getAccountDataSettingsCacheDir(address); - return this.getFromCacheOrExecuteAndCache( + return getFromCacheOrExecuteAndCache( + this.loggingService, + this.cacheService, cacheDir, this.sql` SELECT ads.* FROM account_data_settings ads INNER JOIN account_data_types adt @@ -134,13 +143,13 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { * @param upsertAccountDataSettings {@link UpsertAccountDataSettingsDto} object. * @returns {Array} inserted account data settings. */ - async upsertAccountDataSettings( - address: `0x${string}`, - upsertAccountDataSettings: UpsertAccountDataSettingsDto, - ): Promise { - const { accountDataSettings } = upsertAccountDataSettings; + async upsertAccountDataSettings(args: { + address: `0x${string}`; + upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto; + }): Promise { + const { accountDataSettings } = args.upsertAccountDataSettingsDto; await this.checkDataTypes(accountDataSettings); - const account = await this.getAccount(address); + const account = await this.getAccount(args.address); const result = await this.sql.begin(async (sql) => { await Promise.all( @@ -160,7 +169,7 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { SELECT * FROM account_data_settings WHERE account_id = ${account.id}`; }); - const cacheDir = CacheRouter.getAccountDataSettingsCacheDir(address); + const cacheDir = CacheRouter.getAccountDataSettingsCacheDir(args.address); await this.cacheService.set( cacheDir, JSON.stringify(result), @@ -186,39 +195,4 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { ); } } - - /** - * Returns the content from cache or executes the query and caches the result. - * If the specified {@link CacheDir} is empty, the query is executed and the result is cached. - * If the specified {@link CacheDir} is not empty, the pointed content is returned. - * - * @param cacheDir {@link CacheDir} to use for caching - * @param query query to execute - * @param ttl time to live for the cache - * @returns content from cache or query result - */ - private async getFromCacheOrExecuteAndCache( - cacheDir: CacheDir, - query: postgres.PendingQuery, - ttl: number, - ): Promise { - const { key, field } = cacheDir; - const cached = await this.cacheService.get(cacheDir); - if (cached != null) { - this.loggingService.debug({ type: 'cache_hit', key, field }); - return JSON.parse(cached); - } - this.loggingService.debug({ type: 'cache_miss', key, field }); - - // log & hide database errors - const result = await query.catch((e) => { - this.loggingService.error(asError(e).message); - throw new InternalServerErrorException(); - }); - - if (result.count > 0) { - await this.cacheService.set(cacheDir, JSON.stringify(result), ttl); - } - return result; - } } diff --git a/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts new file mode 100644 index 0000000000..957b89e087 --- /dev/null +++ b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts @@ -0,0 +1,420 @@ +import { TestDbFactory } from '@/__tests__/db.factory'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { CounterfactualSafesDatasource } from '@/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource'; +import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; +import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { createCounterfactualSafeDtoBuilder } from '@/domain/accounts/counterfactual-safes/entities/__tests__/create-counterfactual-safe.dto.entity.builder'; +import { accountBuilder } from '@/domain/accounts/entities/__tests__/account.builder'; +import { Account } from '@/domain/accounts/entities/account.entity'; +import { ILoggingService } from '@/logging/logging.interface'; +import { faker } from '@faker-js/faker'; +import 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); + +describe('CounterfactualSafesDatasource tests', () => { + let fakeCacheService: FakeCacheService; + let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + let target: CounterfactualSafesDatasource; + 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(); + }); + + target = new CounterfactualSafesDatasource( + fakeCacheService, + sql, + mockLoggingService, + mockConfigurationService, + ); + }); + + afterEach(async () => { + await sql`TRUNCATE TABLE accounts, account_data_settings, counterfactual_safes CASCADE`; + fakeCacheService.clear(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); + + describe('createCounterfactualSafe', () => { + it('should create a Counterfactual Safe', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const createCounterfactualSafeDto = + createCounterfactualSafeDtoBuilder().build(); + + const actual = await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto, + }); + expect(actual).toStrictEqual( + expect.objectContaining({ + id: expect.any(Number), + chain_id: createCounterfactualSafeDto.chainId, + creator: account.address, + fallback_handler: createCounterfactualSafeDto.fallbackHandler, + owners: createCounterfactualSafeDto.owners, + predicted_address: createCounterfactualSafeDto.predictedAddress, + salt_nonce: createCounterfactualSafeDto.saltNonce, + singleton_address: createCounterfactualSafeDto.singletonAddress, + threshold: createCounterfactualSafeDto.threshold, + account_id: account.id, + }), + ); + }); + + it('should delete the cache for the account Counterfactual Safes', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + await target.getCounterfactualSafesForAccount(account); + const cacheDir = new CacheDir(`counterfactual_safes_${address}`, ''); + await fakeCacheService.set( + cacheDir, + JSON.stringify([]), + faker.number.int(), + ); + + // the cache is cleared after creating a new CF Safe for the same account + await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + expect(await fakeCacheService.get(cacheDir)).toBeUndefined(); + }); + }); + + describe('getCounterfactualSafe', () => { + it('should get a Counterfactual Safe', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafe = await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + + const actual = await target.getCounterfactualSafe({ + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }); + expect(actual).toStrictEqual(counterfactualSafe); + }); + + it('returns a Counterfactual Safe from cache', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafe = await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + + // first call is not cached + const actual = await target.getCounterfactualSafe({ + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }); + await target.getCounterfactualSafe({ + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }); + + expect(actual).toStrictEqual(counterfactualSafe); + const cacheDir = new CacheDir( + `${counterfactualSafe.chain_id}_counterfactual_safe_${counterfactualSafe.predicted_address}`, + '', + ); + const cacheContent = await fakeCacheService.get(cacheDir); + expect(JSON.parse(cacheContent as string)).toHaveLength(1); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_miss', + key: `${counterfactualSafe.chain_id}_counterfactual_safe_${counterfactualSafe.predicted_address}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(2, { + type: 'cache_hit', + key: `${counterfactualSafe.chain_id}_counterfactual_safe_${counterfactualSafe.predicted_address}`, + field: '', + }); + }); + + it('should not cache if the Counterfactual Safe is not found', async () => { + const counterfactualSafe = createCounterfactualSafeDtoBuilder().build(); + + // should not cache the Counterfactual Safe + await expect( + target.getCounterfactualSafe({ + chainId: counterfactualSafe.chainId, + predictedAddress: counterfactualSafe.predictedAddress, + }), + ).rejects.toThrow('Error getting Counterfactual Safe.'); + await expect( + target.getCounterfactualSafe({ + chainId: counterfactualSafe.chainId, + predictedAddress: counterfactualSafe.predictedAddress, + }), + ).rejects.toThrow('Error getting Counterfactual Safe.'); + + const cacheDir = new CacheDir( + `${counterfactualSafe.chainId}_counterfactual_safe_${counterfactualSafe.predictedAddress}`, + '', + ); + expect(await fakeCacheService.get(cacheDir)).toBeUndefined(); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_miss', + key: `${counterfactualSafe.chainId}_counterfactual_safe_${counterfactualSafe.predictedAddress}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(2, { + type: 'cache_miss', + key: `${counterfactualSafe.chainId}_counterfactual_safe_${counterfactualSafe.predictedAddress}`, + field: '', + }); + }); + }); + + describe('getCounterfactualSafesForAccount', () => { + it('should get the Counterfactual Safes for an account', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafes = await Promise.all([ + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '1') + .build(), + }), + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '2') + .build(), + }), + ]); + + const actual = await target.getCounterfactualSafesForAccount(account); + expect(actual).toStrictEqual(expect.arrayContaining(counterfactualSafes)); + }); + + it('should get the Counterfactual Safes for an account from cache', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafes = await Promise.all([ + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '1') + .build(), + }), + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '2') + .build(), + }), + ]); + + // first call is not cached + const actual = await target.getCounterfactualSafesForAccount(account); + await target.getCounterfactualSafesForAccount(account); + + expect(actual).toStrictEqual(expect.arrayContaining(counterfactualSafes)); + const cacheDir = new CacheDir(`counterfactual_safes_${address}`, ''); + const cacheContent = await fakeCacheService.get(cacheDir); + expect(JSON.parse(cacheContent as string)).toHaveLength(2); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_miss', + key: `counterfactual_safes_${account.address}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(2, { + type: 'cache_hit', + key: `counterfactual_safes_${account.address}`, + field: '', + }); + }); + }); + + describe('deleteCounterfactualSafe', () => { + it('should delete a Counterfactual Safe', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafe = await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + + await expect( + target.deleteCounterfactualSafe({ + account, + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }), + ).resolves.not.toThrow(); + + expect(mockLoggingService.debug).not.toHaveBeenCalled(); + }); + + it('should not throw if no Counterfactual Safe is found', async () => { + await expect( + target.deleteCounterfactualSafe({ + account: accountBuilder().build(), + chainId: faker.string.numeric({ length: 6 }), + predictedAddress: getAddress(faker.finance.ethereumAddress()), + }), + ).resolves.not.toThrow(); + + expect(mockLoggingService.debug).toHaveBeenCalledTimes(1); + }); + + it('should clear the cache on Counterfactual Safe deletion', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafe = await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + + // the Counterfactual Safe is cached + await target.getCounterfactualSafe({ + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }); + const cacheDir = new CacheDir( + `${counterfactualSafe.chain_id}_counterfactual_safe_${counterfactualSafe.predicted_address}`, + '', + ); + const beforeDeletion = await fakeCacheService.get(cacheDir); + expect(JSON.parse(beforeDeletion as string)).toHaveLength(1); + + // the counterfactualSafe is deleted from the database and the cache + await expect( + target.deleteCounterfactualSafe({ + account, + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }), + ).resolves.not.toThrow(); + await expect( + target.getCounterfactualSafe({ + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }), + ).rejects.toThrow(); + + const afterDeletion = await fakeCacheService.get(cacheDir); + expect(afterDeletion).toBeUndefined(); + const cacheDirByAddress = new CacheDir( + `counterfactual_safes_${address}`, + '', + ); + const cachedByAddress = await fakeCacheService.get(cacheDirByAddress); + expect(cachedByAddress).toBeUndefined(); + }); + }); + + describe('deleteCounterfactualSafesForAccount', () => { + it('should delete all the Counterfactual Safes for an account', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafes = await Promise.all([ + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '1') + .build(), + }), + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '2') + .build(), + }), + ]); + + // store data in the cache dirs + const counterfactualSafesCacheDir = new CacheDir( + `counterfactual_safes_${address}`, + faker.string.alpha(), + ); + const counterfactualSafeCacheDirs = [ + new CacheDir( + `counterfactual_safe_${counterfactualSafes[0].id}`, + faker.string.alpha(), + ), + new CacheDir( + `counterfactual_safe_${counterfactualSafes[1].id}`, + faker.string.alpha(), + ), + ]; + + await expect( + target.deleteCounterfactualSafesForAccount(account), + ).resolves.not.toThrow(); + + // database is cleared + const actual = await target.getCounterfactualSafesForAccount(account); + expect(actual).toHaveLength(0); + // cache is cleared + expect( + await fakeCacheService.get(counterfactualSafesCacheDir), + ).toBeUndefined(); + expect( + await fakeCacheService.get(counterfactualSafeCacheDirs[0]), + ).toBeUndefined(); + expect( + await fakeCacheService.get(counterfactualSafeCacheDirs[1]), + ).toBeUndefined(); + }); + }); +}); diff --git a/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts new file mode 100644 index 0000000000..7ea2854128 --- /dev/null +++ b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts @@ -0,0 +1,163 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { CacheRouter } from '@/datasources/cache/cache.router'; +import { + CacheService, + ICacheService, +} from '@/datasources/cache/cache.service.interface'; +import { getFromCacheOrExecuteAndCache } from '@/datasources/db/utils'; +import { CounterfactualSafe } from '@/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity'; +import { CreateCounterfactualSafeDto } from '@/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity'; +import { Account } from '@/domain/accounts/entities/account.entity'; +import { ICounterfactualSafesDatasource } from '@/domain/interfaces/counterfactual-safes.datasource.interface'; +import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import postgres from 'postgres'; + +@Injectable() +export class CounterfactualSafesDatasource + implements ICounterfactualSafesDatasource +{ + private readonly defaultExpirationTimeInSeconds: number; + + constructor( + @Inject(CacheService) private readonly cacheService: ICacheService, + @Inject('DB_INSTANCE') private readonly sql: postgres.Sql, + @Inject(LoggingService) private readonly loggingService: ILoggingService, + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + ) { + this.defaultExpirationTimeInSeconds = + this.configurationService.getOrThrow( + 'expirationTimeInSeconds.default', + ); + } + + // TODO: the repository calling this function should: + // - check the AccountDataSettings to see if counterfactual-safes is enabled. + // - check the AccountDataType to see if it's active. + async createCounterfactualSafe(args: { + account: Account; + createCounterfactualSafeDto: CreateCounterfactualSafeDto; + }): Promise { + const [counterfactualSafe] = await this.sql` + INSERT INTO counterfactual_safes + ${this.sql([this.mapCreationDtoToRow(args.account, args.createCounterfactualSafeDto)])} + RETURNING *`; + const { key } = CacheRouter.getCounterfactualSafesCacheDir( + args.account.address, + ); + await this.cacheService.deleteByKey(key); + return counterfactualSafe; + } + + async getCounterfactualSafe(args: { + chainId: string; + predictedAddress: `0x${string}`; + }): Promise { + const cacheDir = CacheRouter.getCounterfactualSafeCacheDir( + args.chainId, + args.predictedAddress, + ); + const [counterfactualSafe] = await getFromCacheOrExecuteAndCache< + CounterfactualSafe[] + >( + this.loggingService, + this.cacheService, + cacheDir, + this.sql` + SELECT * FROM counterfactual_safes WHERE chain_id = ${args.chainId} AND predicted_address = ${args.predictedAddress}`, + this.defaultExpirationTimeInSeconds, + ); + + if (!counterfactualSafe) { + throw new NotFoundException('Error getting Counterfactual Safe.'); + } + + return counterfactualSafe; + } + + getCounterfactualSafesForAccount( + account: Account, + ): Promise { + const cacheDir = CacheRouter.getCounterfactualSafesCacheDir( + account.address, + ); + return getFromCacheOrExecuteAndCache( + this.loggingService, + this.cacheService, + cacheDir, + this.sql` + SELECT * FROM counterfactual_safes WHERE account_id = ${account.id}`, + this.defaultExpirationTimeInSeconds, + ); + } + + async deleteCounterfactualSafe(args: { + account: Account; + chainId: string; + predictedAddress: `0x${string}`; + }): Promise { + try { + const { count } = await this + .sql`DELETE FROM counterfactual_safes WHERE chain_id = ${args.chainId} AND predicted_address = ${args.predictedAddress} AND account_id = ${args.account.id}`; + if (count === 0) { + this.loggingService.debug( + `Error deleting Counterfactual Safe (${args.chainId}, ${args.predictedAddress}): not found`, + ); + } + } finally { + await Promise.all([ + this.cacheService.deleteByKey( + CacheRouter.getCounterfactualSafeCacheDir( + args.chainId, + args.predictedAddress, + ).key, + ), + this.cacheService.deleteByKey( + CacheRouter.getCounterfactualSafesCacheDir(args.account.address).key, + ), + ]); + } + } + + async deleteCounterfactualSafesForAccount(account: Account): Promise { + let deleted: CounterfactualSafe[] = []; + try { + const rows = await this.sql< + CounterfactualSafe[] + >`DELETE FROM counterfactual_safes WHERE account_id = ${account.id} RETURNING *`; + deleted = rows; + } finally { + await this.cacheService.deleteByKey( + CacheRouter.getCounterfactualSafesCacheDir(account.address).key, + ); + await Promise.all( + deleted.map((row) => { + return this.cacheService.deleteByKey( + CacheRouter.getCounterfactualSafeCacheDir( + row.chain_id, + row.predicted_address, + ).key, + ); + }), + ); + } + } + + private mapCreationDtoToRow( + account: Account, + createCounterfactualSafeDto: CreateCounterfactualSafeDto, + ): Partial { + return { + account_id: account.id, + chain_id: createCounterfactualSafeDto.chainId, + creator: account.address, + fallback_handler: createCounterfactualSafeDto.fallbackHandler, + owners: createCounterfactualSafeDto.owners, + predicted_address: createCounterfactualSafeDto.predictedAddress, + salt_nonce: createCounterfactualSafeDto.saltNonce, + singleton_address: createCounterfactualSafeDto.singletonAddress, + threshold: createCounterfactualSafeDto.threshold, + }; + } +} diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index f3716d85ae..ef45c3a098 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -10,6 +10,8 @@ export class CacheRouter { private static readonly CHAIN_KEY = 'chain'; private static readonly CHAINS_KEY = 'chains'; private static readonly CONTRACT_KEY = 'contract'; + private static readonly COUNTERFACTUAL_SAFE_KEY = 'counterfactual_safe'; + private static readonly COUNTERFACTUAL_SAFES_KEY = 'counterfactual_safes'; private static readonly CREATION_TRANSACTION_KEY = 'creation_transaction'; private static readonly DELEGATES_KEY = 'delegates'; private static readonly FIREBASE_OAUTH2_TOKEN_KEY = 'firebase_oauth2_token'; @@ -513,4 +515,21 @@ export class CacheRouter { '', ); } + + static getCounterfactualSafeCacheDir( + chainId: string, + predictedAddress: `0x${string}`, + ): CacheDir { + return new CacheDir( + `${chainId}_${CacheRouter.COUNTERFACTUAL_SAFE_KEY}_${predictedAddress}`, + '', + ); + } + + static getCounterfactualSafesCacheDir(address: `0x${string}`): CacheDir { + return new CacheDir( + `${CacheRouter.COUNTERFACTUAL_SAFES_KEY}_${address}`, + '', + ); + } } diff --git a/src/datasources/db/utils.ts b/src/datasources/db/utils.ts new file mode 100644 index 0000000000..ebc7da1104 --- /dev/null +++ b/src/datasources/db/utils.ts @@ -0,0 +1,48 @@ +import { ICacheService } from '@/datasources/cache/cache.service.interface'; +import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import { ILoggingService } from '@/logging/logging.interface'; +import { asError } from '@/logging/utils'; +import { InternalServerErrorException } from '@nestjs/common'; +import postgres from 'postgres'; + +/** + * Returns the content from cache or executes the query and caches the result. + * If the specified {@link CacheDir} is empty, the query is executed and the result is cached. + * If the specified {@link CacheDir} is not empty, the pointed content is returned. + * + * @param loggingService {@link ILoggingService} to use for logging + * @param cacheService {@link ICacheService} to use for caching + * @param cacheDir {@link CacheDir} to use for caching + * @param query query to execute + * @param ttl time to live for the cache + * @returns content from cache or query result + */ +// TODO: add tests +export async function getFromCacheOrExecuteAndCache< + T extends postgres.MaybeRow[], +>( + loggingService: ILoggingService, + cacheService: ICacheService, + cacheDir: CacheDir, + query: postgres.PendingQuery, + ttl: number, +): Promise { + const { key, field } = cacheDir; + const cached = await cacheService.get(cacheDir); + if (cached != null) { + loggingService.debug({ type: 'cache_hit', key, field }); + return JSON.parse(cached); + } + loggingService.debug({ type: 'cache_miss', key, field }); + + // log & hide database errors + const result = await query.catch((e) => { + loggingService.error(asError(e).message); + throw new InternalServerErrorException(); + }); + + if (result.count > 0) { + await cacheService.set(cacheDir, JSON.stringify(result), ttl); + } + return result; +} diff --git a/src/domain/accounts/accounts.repository.interface.ts b/src/domain/accounts/accounts.repository.interface.ts index 5a55c299ec..eeba431a97 100644 --- a/src/domain/accounts/accounts.repository.interface.ts +++ b/src/domain/accounts/accounts.repository.interface.ts @@ -35,7 +35,7 @@ export interface IAccountsRepository { upsertAccountDataSettings(args: { authPayload: AuthPayload; address: `0x${string}`; - upsertAccountDataSettings: UpsertAccountDataSettingsDto; + upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto; }): Promise; } diff --git a/src/domain/accounts/accounts.repository.ts b/src/domain/accounts/accounts.repository.ts index b61259addb..44062ff923 100644 --- a/src/domain/accounts/accounts.repository.ts +++ b/src/domain/accounts/accounts.repository.ts @@ -46,7 +46,6 @@ export class AccountsRepository implements IAccountsRepository { if (!args.authPayload.isForSigner(args.address)) { throw new UnauthorizedException(); } - // TODO: trigger a cascade deletion of the account-associated data. return this.datasource.deleteAccount(args.address); } @@ -68,19 +67,19 @@ export class AccountsRepository implements IAccountsRepository { async upsertAccountDataSettings(args: { authPayload: AuthPayload; address: `0x${string}`; - upsertAccountDataSettings: UpsertAccountDataSettingsDto; + upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto; }): Promise { - const { address, upsertAccountDataSettings } = args; + const { address, upsertAccountDataSettingsDto } = args; if (!args.authPayload.isForSigner(args.address)) { throw new UnauthorizedException(); } - if (upsertAccountDataSettings.accountDataSettings.length === 0) { + if (upsertAccountDataSettingsDto.accountDataSettings.length === 0) { return []; } - return this.datasource.upsertAccountDataSettings( + return this.datasource.upsertAccountDataSettings({ address, - upsertAccountDataSettings, - ); + upsertAccountDataSettingsDto, + }); } } diff --git a/src/domain/accounts/counterfactual-safes/entities/__tests__/create-counterfactual-safe.dto.entity.builder.ts b/src/domain/accounts/counterfactual-safes/entities/__tests__/create-counterfactual-safe.dto.entity.builder.ts new file mode 100644 index 0000000000..bdb29a120a --- /dev/null +++ b/src/domain/accounts/counterfactual-safes/entities/__tests__/create-counterfactual-safe.dto.entity.builder.ts @@ -0,0 +1,18 @@ +import { IBuilder, Builder } from '@/__tests__/builder'; +import { CreateCounterfactualSafeDto } from '@/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +export function createCounterfactualSafeDtoBuilder(): IBuilder { + return new Builder() + .with('chainId', faker.string.numeric({ length: 6 })) + .with('fallbackHandler', getAddress(faker.finance.ethereumAddress())) + .with('owners', [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]) + .with('predictedAddress', getAddress(faker.finance.ethereumAddress())) + .with('saltNonce', faker.string.hexadecimal()) + .with('singletonAddress', getAddress(faker.finance.ethereumAddress())) + .with('threshold', faker.number.int({ min: 1, max: 10 })); +} diff --git a/src/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity.ts b/src/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity.ts new file mode 100644 index 0000000000..eba2fc241f --- /dev/null +++ b/src/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity.ts @@ -0,0 +1,18 @@ +import { RowSchema } from '@/datasources/db/entities/row.entity'; +import { AccountSchema } from '@/domain/accounts/entities/account.entity'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { z } from 'zod'; + +export type CounterfactualSafe = z.infer; + +export const CounterfactualSafeSchema = RowSchema.extend({ + chain_id: z.string(), + creator: AddressSchema, + fallback_handler: AddressSchema, + owners: z.array(AddressSchema).min(1), + predicted_address: AddressSchema, + salt_nonce: z.string(), + singleton_address: AddressSchema, + threshold: z.number().int().gte(1), + account_id: AccountSchema.shape.id, +}); diff --git a/src/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity.ts b/src/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity.ts new file mode 100644 index 0000000000..0509c87b0d --- /dev/null +++ b/src/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity.ts @@ -0,0 +1,34 @@ +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { z } from 'zod'; + +export class CreateCounterfactualSafeDto + implements z.infer +{ + chainId: string; + fallbackHandler: `0x${string}`; + owners: `0x${string}`[]; + predictedAddress: `0x${string}`; + saltNonce: string; + singletonAddress: `0x${string}`; + threshold: number; + + constructor(props: CreateCounterfactualSafeDto) { + this.chainId = props.chainId; + this.fallbackHandler = props.fallbackHandler; + this.owners = props.owners; + this.predictedAddress = props.predictedAddress; + this.saltNonce = props.saltNonce; + this.singletonAddress = props.singletonAddress; + this.threshold = props.threshold; + } +} + +export const CreateCounterfactualSafeDtoSchema = z.object({ + chainId: z.string(), + fallbackHandler: AddressSchema, + owners: z.array(AddressSchema).min(1), + predictedAddress: AddressSchema, + saltNonce: z.string(), + singletonAddress: AddressSchema, + threshold: z.number().int().gte(1), +}); diff --git a/src/domain/accounts/entities/__tests__/account.builder.ts b/src/domain/accounts/entities/__tests__/account.builder.ts index 1740dd1c75..29ff1dd6db 100644 --- a/src/domain/accounts/entities/__tests__/account.builder.ts +++ b/src/domain/accounts/entities/__tests__/account.builder.ts @@ -5,8 +5,8 @@ import { getAddress } from 'viem'; export function accountBuilder(): IBuilder { return new Builder() - .with('id', faker.number.int()) - .with('group_id', faker.number.int()) + .with('id', faker.number.int({ max: 1_000_000 })) + .with('group_id', faker.number.int({ max: 1_000_000 })) .with('address', getAddress(faker.finance.ethereumAddress())) .with('created_at', faker.date.recent()) .with('updated_at', faker.date.recent()); diff --git a/src/domain/interfaces/accounts.datasource.interface.ts b/src/domain/interfaces/accounts.datasource.interface.ts index 3b3fdac598..aa4f9969d0 100644 --- a/src/domain/interfaces/accounts.datasource.interface.ts +++ b/src/domain/interfaces/accounts.datasource.interface.ts @@ -16,8 +16,8 @@ export interface IAccountsDatasource { getAccountDataSettings(address: `0x${string}`): Promise; - upsertAccountDataSettings( - address: `0x${string}`, - upsertAccountDataSettings: UpsertAccountDataSettingsDto, - ): Promise; + upsertAccountDataSettings(args: { + address: `0x${string}`; + upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto; + }): Promise; } diff --git a/src/domain/interfaces/counterfactual-safes.datasource.interface.ts b/src/domain/interfaces/counterfactual-safes.datasource.interface.ts new file mode 100644 index 0000000000..a2d62b7200 --- /dev/null +++ b/src/domain/interfaces/counterfactual-safes.datasource.interface.ts @@ -0,0 +1,31 @@ +import { CounterfactualSafe } from '@/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity'; +import { CreateCounterfactualSafeDto } from '@/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity'; +import { Account } from '@/domain/accounts/entities/account.entity'; + +export const ICounterfactualSafesDatasource = Symbol( + 'ICounterfactualSafesDatasource', +); + +export interface ICounterfactualSafesDatasource { + createCounterfactualSafe(args: { + account: Account; + createCounterfactualSafeDto: CreateCounterfactualSafeDto; + }): Promise; + + getCounterfactualSafe(args: { + chainId: string; + predictedAddress: `0x${string}`; + }): Promise; + + getCounterfactualSafesForAccount( + account: Account, + ): Promise; + + deleteCounterfactualSafe(args: { + account: Account; + chainId: string; + predictedAddress: `0x${string}`; + }): Promise; + + deleteCounterfactualSafesForAccount(account: Account): Promise; +} diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index f38415f194..8dbaf22d82 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -638,10 +638,10 @@ describe('AccountsController', () => { expect(accountDataSource.upsertAccountDataSettings).toHaveBeenCalledTimes( 1, ); - expect(accountDataSource.upsertAccountDataSettings).toHaveBeenCalledWith( + expect(accountDataSource.upsertAccountDataSettings).toHaveBeenCalledWith({ address, upsertAccountDataSettingsDto, - ); + }); }); it('should accept a empty array of data settings', async () => { diff --git a/src/routes/accounts/accounts.service.ts b/src/routes/accounts/accounts.service.ts index deb317071d..06c4b029ac 100644 --- a/src/routes/accounts/accounts.service.ts +++ b/src/routes/accounts/accounts.service.ts @@ -82,7 +82,7 @@ export class AccountsService { this.accountsRepository.upsertAccountDataSettings({ authPayload: args.authPayload, address: args.address, - upsertAccountDataSettings: { + upsertAccountDataSettingsDto: { accountDataSettings: args.upsertAccountDataSettingsDto.accountDataSettings, },