Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add IBlockchainApiManager for on-chain interactions #1623

Merged
merged 8 commits into from
Jun 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export default (): ReturnType<typeof configuration> => ({
},
},
},
blockchain: {
infura: {
apiKey: faker.string.hexadecimal({ length: 32 }),
},
},
db: {
postgres: {
host: process.env.POSTGRES_TEST_HOST || 'localhost',
Expand Down
5 changes: 5 additions & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ export default () => ({
},
},
},
blockchain: {
infura: {
apiKey: process.env.INFURA_API_KEY,
},
},
db: {
postgres: {
host: process.env.POSTGRES_HOST || 'localhost',
Expand Down
49 changes: 49 additions & 0 deletions src/datasources/blockchain/blockchain-api.manager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service';
import { BlockchainApiManager } from '@/datasources/blockchain/blockchain-api.manager';
import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder';
import { IConfigApi } from '@/domain/interfaces/config-api.interface';
import { faker } from '@faker-js/faker';

const configApiMock = jest.mocked({
getChain: jest.fn(),
} as jest.MockedObjectDeep<IConfigApi>);

describe('BlockchainApiManager', () => {
let target: BlockchainApiManager;

beforeEach(() => {
jest.resetAllMocks();

const fakeConfigurationService = new FakeConfigurationService();
fakeConfigurationService.set(
'blockchain.infura.apiKey',
faker.string.hexadecimal({ length: 32 }),
);
target = new BlockchainApiManager(fakeConfigurationService, configApiMock);
});

describe('getBlockchainApi', () => {
it('caches the API', async () => {
const chain = chainBuilder().build();
configApiMock.getChain.mockResolvedValue(chain);

const api = await target.getBlockchainApi(chain.chainId);
const cachedApi = await target.getBlockchainApi(chain.chainId);

expect(api).toBe(cachedApi);
});
});

describe('destroyBlockchainApi', () => {
it('destroys the API', async () => {
const chain = chainBuilder().build();
configApiMock.getChain.mockResolvedValue(chain);

const api = await target.getBlockchainApi(chain.chainId);
target.destroyBlockchainApi(chain.chainId);
const cachedApi = await target.getBlockchainApi(chain.chainId);

expect(api).not.toBe(cachedApi);
});
});
});
67 changes: 67 additions & 0 deletions src/datasources/blockchain/blockchain-api.manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { IConfigurationService } from '@/config/configuration.service.interface';
import { Chain as DomainChain } from '@/domain/chains/entities/chain.entity';
import { RpcUriAuthentication } from '@/domain/chains/entities/rpc-uri-authentication.entity';
import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface';
import { IConfigApi } from '@/domain/interfaces/config-api.interface';
import { Inject, Injectable } from '@nestjs/common';
import { Chain, PublicClient, createPublicClient, http } from 'viem';

@Injectable()
export class BlockchainApiManager implements IBlockchainApiManager {
private readonly blockchainApiMap: Record<string, PublicClient> = {};
private readonly infuraApiKey: string;

constructor(
@Inject(IConfigurationService)
private readonly configurationService: IConfigurationService,
@Inject(IConfigApi) private readonly configApi: IConfigApi,
) {
this.infuraApiKey = this.configurationService.getOrThrow<string>(
'blockchain.infura.apiKey',
);
}

async getBlockchainApi(chainId: string): Promise<PublicClient> {
const blockchainApi = this.blockchainApiMap[chainId];
if (blockchainApi) {
return blockchainApi;
}

const chain = await this.configApi.getChain(chainId);
this.blockchainApiMap[chainId] = this.createClient(chain);

return this.blockchainApiMap[chainId];
}

destroyBlockchainApi(chainId: string): void {
if (this.blockchainApiMap?.[chainId]) {
delete this.blockchainApiMap[chainId];
}
Comment on lines +36 to +39
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great. In fact, being strict we should implement the same destroy mechanism in transaction-api.manager.ts, as the Transaction Service URLs can also change (even is it's pretty unusual). Now it requires a CGW restart to get a new configuration for a given TransactionApi datasource. What do you think?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that it's unusual but I think it would be a good pattern to copy. We could even consider creating a specific interface for these managers that we implement in both.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I refactored the TransactionApiManager and BalancesApiManager to follow the same pattern with a common interface in #1640. When that is merged, I will propagate the changes into the BlockchainApiManager as well.

}

private createClient(chain: DomainChain): PublicClient {
return createPublicClient({
chain: this.formatChain(chain),
transport: http(),
});
}

private formatChain(chain: DomainChain): Chain {
return {
id: Number(chain.chainId),
name: chain.chainName,
nativeCurrency: chain.nativeCurrency,
rpcUrls: {
default: {
http: [this.formatRpcUri(chain.rpcUri)],
},
},
};
}

private formatRpcUri(rpcUri: DomainChain['rpcUri']): string {
return rpcUri.authentication === RpcUriAuthentication.ApiKeyPath
? rpcUri.value + this.infuraApiKey
: rpcUri.value;
}
}
18 changes: 18 additions & 0 deletions src/domain/blockchain/blockchain.repository.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { BlockchainRepository } from '@/domain/blockchain/blockchain.repository';
import { BlockchainApiManagerModule } from '@/domain/interfaces/blockchain-api.manager.interface';
import { Module } from '@nestjs/common';

export const IBlockchainRepository = Symbol('IBlockchainRepository');

export interface IBlockchainRepository {
clearClient(chainId: string): void;
}

@Module({
imports: [BlockchainApiManagerModule],
providers: [
{ provide: IBlockchainRepository, useClass: BlockchainRepository },
],
exports: [IBlockchainRepository],
})
export class BlockchainRepositoryModule {}
15 changes: 15 additions & 0 deletions src/domain/blockchain/blockchain.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IBlockchainRepository } from '@/domain/blockchain/blockchain.repository.interface';
import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface';
import { Inject, Injectable } from '@nestjs/common';

@Injectable()
export class BlockchainRepository implements IBlockchainRepository {
constructor(
@Inject(IBlockchainApiManager)
private readonly blockchainApiManager: IBlockchainApiManager,
) {}

clearClient(chainId: string): void {
this.blockchainApiManager.destroyBlockchainApi(chainId);
}
}
21 changes: 21 additions & 0 deletions src/domain/interfaces/blockchain-api.manager.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { BlockchainApiManager } from '@/datasources/blockchain/blockchain-api.manager';
import { ConfigApiModule } from '@/datasources/config-api/config-api.module';
import { PublicClient } from 'viem';
import { Module } from '@nestjs/common';

export const IBlockchainApiManager = Symbol('IBlockchainApiManager');

export interface IBlockchainApiManager {
getBlockchainApi(chainId: string): Promise<PublicClient>;

destroyBlockchainApi(chainId: string): void;
}

@Module({
imports: [ConfigApiModule],
providers: [
{ provide: IBlockchainApiManager, useClass: BlockchainApiManager },
],
exports: [IBlockchainApiManager],
})
export class BlockchainApiManagerModule {}
38 changes: 38 additions & 0 deletions src/routes/cache-hooks/cache-hooks.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-
import { QueuesApiModule } from '@/datasources/queues/queues-api.module';
import { Server } from 'net';
import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder';
import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface';
import { safeCreatedEventBuilder } from '@/routes/cache-hooks/entities/__tests__/safe-created.build';

describe('Post Hook Events (Unit)', () => {
Expand All @@ -35,6 +36,7 @@ describe('Post Hook Events (Unit)', () => {
let fakeCacheService: FakeCacheService;
let networkService: jest.MockedObjectDeep<INetworkService>;
let configurationService: IConfigurationService;
let blockchainApiManager: IBlockchainApiManager;

async function initApp(config: typeof configuration): Promise<void> {
const moduleFixture: TestingModule = await Test.createTestingModule({
Expand All @@ -55,6 +57,9 @@ describe('Post Hook Events (Unit)', () => {

fakeCacheService = moduleFixture.get<FakeCacheService>(CacheService);
configurationService = moduleFixture.get(IConfigurationService);
blockchainApiManager = moduleFixture.get<IBlockchainApiManager>(
IBlockchainApiManager,
);
authToken = configurationService.getOrThrow('auth.token');
safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri');
networkService = moduleFixture.get(NetworkService);
Expand Down Expand Up @@ -863,6 +868,39 @@ describe('Post Hook Events (Unit)', () => {
await expect(fakeCacheService.get(cacheDir)).resolves.toBeUndefined();
});

it.each([
{
type: 'CHAIN_UPDATE',
},
])('$type clears the blockchain client', async (payload) => {
const chainId = faker.string.numeric();
const data = {
chainId: chainId,
...payload,
};
networkService.get.mockImplementation(({ url }) => {
switch (url) {
case `${safeConfigUrl}/api/v1/chains/${chainId}`:
return Promise.resolve({
data: chainBuilder().with('chainId', chainId).build(),
status: 200,
});
default:
return Promise.reject(new Error(`Could not match ${url}`));
}
});
const client = await blockchainApiManager.getBlockchainApi(chainId);

await request(app.getHttpServer())
.post(`/hooks/events`)
.set('Authorization', `Basic ${authToken}`)
.send(data)
.expect(202);

const newClient = await blockchainApiManager.getBlockchainApi(chainId);
expect(client).not.toBe(newClient);
});

it.each([
{
type: 'SAFE_APPS_UPDATE',
Expand Down
2 changes: 2 additions & 0 deletions src/routes/cache-hooks/cache-hooks.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface';
import { MessagesRepositoryModule } from '@/domain/messages/messages.repository.interface';
import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface';
import { QueuesRepositoryModule } from '@/domain/queues/queues-repository.interface';
import { BlockchainRepositoryModule } from '@/domain/blockchain/blockchain.repository.interface';

@Module({
imports: [
BalancesRepositoryModule,
BlockchainRepositoryModule,
ChainsRepositoryModule,
CollectiblesRepositoryModule,
MessagesRepositoryModule,
Expand Down
10 changes: 9 additions & 1 deletion src/routes/cache-hooks/cache-hooks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { IConfigurationService } from '@/config/configuration.service.interface'
import { IQueuesRepository } from '@/domain/queues/queues-repository.interface';
import { ConsumeMessage } from 'amqplib';
import { WebHookSchema } from '@/routes/cache-hooks/entities/schemas/web-hook.schema';
import { IBlockchainRepository } from '@/domain/blockchain/blockchain.repository.interface';

@Injectable()
export class CacheHooksService implements OnModuleInit {
Expand All @@ -21,6 +22,8 @@ export class CacheHooksService implements OnModuleInit {
constructor(
@Inject(IBalancesRepository)
private readonly balancesRepository: IBalancesRepository,
@Inject(IBlockchainRepository)
private readonly blockchainRepository: IBlockchainRepository,
@Inject(IChainsRepository)
private readonly chainsRepository: IChainsRepository,
@Inject(ICollectiblesRepository)
Expand Down Expand Up @@ -314,7 +317,12 @@ export class CacheHooksService implements OnModuleInit {
this._logMessageEvent(event);
break;
case EventType.CHAIN_UPDATE:
promises.push(this.chainsRepository.clearChain(event.chainId));
promises.push(
this.chainsRepository.clearChain(event.chainId).then(() => {
// Clear after updated as RPC may have change
this.blockchainRepository.clearClient(event.chainId);
}),
);
this._logEvent(event);
break;
case EventType.SAFE_APPS_UPDATE:
Expand Down
1 change: 1 addition & 0 deletions test/e2e-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ process.env.ALERTS_PROVIDER_PROJECT = 'fake-project';
process.env.EMAIL_API_APPLICATION_CODE = 'fake-application-code';
process.env.EMAIL_API_FROM_EMAIL = 'changeme@example.com';
process.env.EMAIL_API_KEY = 'fake-api-key';
process.env.INFURA_API_KEY = 'fake-api-key';

// For E2E tests, connect to the test database
process.env.POSTGRES_HOST = 'localhost';
Expand Down