diff --git a/apps/armory/src/managed-data-store/shared/guard/__test__/unit/data-store.guard.spec.ts b/apps/armory/src/managed-data-store/shared/guard/__test__/unit/data-store.guard.spec.ts index 9cdea9609..28b68756b 100644 --- a/apps/armory/src/managed-data-store/shared/guard/__test__/unit/data-store.guard.spec.ts +++ b/apps/armory/src/managed-data-store/shared/guard/__test__/unit/data-store.guard.spec.ts @@ -1,9 +1,8 @@ -import { secret } from '@narval/nestjs-shared' +import { REQUEST_HEADER_CLIENT_ID, REQUEST_HEADER_CLIENT_SECRET, secret } from '@narval/nestjs-shared' import { getPublicKey, privateKeyToJwk } from '@narval/signature' import { ExecutionContext } from '@nestjs/common' import { mock } from 'jest-mock-extended' import { generatePrivateKey } from 'viem/accounts' -import { REQUEST_HEADER_CLIENT_ID, REQUEST_HEADER_CLIENT_SECRET } from '../../../../../armory.constant' import { ClientService } from '../../../../../client/core/service/client.service' import { Client } from '../../../../../client/core/type/client.type' import { ApplicationException } from '../../../../../shared/exception/application.exception' diff --git a/apps/armory/src/orchestration/core/service/__test__/unit/authorization-request.service.spec.ts b/apps/armory/src/orchestration/core/service/__test__/unit/authorization-request.service.spec.ts index c5d5d16d1..0fdd1a2db 100644 --- a/apps/armory/src/orchestration/core/service/__test__/unit/authorization-request.service.spec.ts +++ b/apps/armory/src/orchestration/core/service/__test__/unit/authorization-request.service.spec.ts @@ -1,4 +1,12 @@ -import { LoggerModule, LoggerService, NullLoggerService } from '@narval/nestjs-shared' +import { + LoggerModule, + LoggerService, + MetricService, + NullLoggerService, + OTEL_ATTR_CLIENT_ID, + OpenTelemetryModule, + StatefulMetricService +} from '@narval/nestjs-shared' import { Action, AuthorizationRequest, @@ -44,6 +52,7 @@ describe(AuthorizationRequestService.name, () => { let clusterServiceMock: MockProxy let priceServiceMock: MockProxy let feedServiceMock: MockProxy + let statefulMetricService: StatefulMetricService let service: AuthorizationRequestService const authzRequest: AuthorizationRequest = generateAuthorizationRequest({ @@ -65,7 +74,7 @@ describe(AuthorizationRequestService.name, () => { feedServiceMock = mock() module = await Test.createTestingModule({ - imports: [LoggerModule], + imports: [LoggerModule, OpenTelemetryModule.forTest()], providers: [ AuthorizationRequestService, { @@ -104,6 +113,23 @@ describe(AuthorizationRequestService.name, () => { }).compile() service = module.get(AuthorizationRequestService) + statefulMetricService = module.get(MetricService) + }) + + describe('create', () => { + it('increments create counter metric', async () => { + await service.create(authzRequest) + + expect(statefulMetricService.counters).toEqual([ + { + name: 'authorization_request_create_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: authzRequest.clientId + } + } + ]) + }) }) describe('approve', () => { @@ -234,6 +260,21 @@ describe(AuthorizationRequestService.name, () => { createdAt: expect.any(Date) }) }) + + it('increments evaluation counter metric', async () => { + await service.evaluate(authzRequest) + + expect(statefulMetricService.counters).toEqual([ + { + name: 'authorization_request_evaluation_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: authzRequest.clientId, + 'domain.authorization_request.status': AuthorizationRequestStatus.PERMITTED + } + } + ]) + }) }) describe('process', () => { diff --git a/apps/armory/src/orchestration/core/service/authorization-request.service.ts b/apps/armory/src/orchestration/core/service/authorization-request.service.ts index 41d7b9e17..5de011252 100644 --- a/apps/armory/src/orchestration/core/service/authorization-request.service.ts +++ b/apps/armory/src/orchestration/core/service/authorization-request.service.ts @@ -1,4 +1,4 @@ -import { LoggerService } from '@narval/nestjs-shared' +import { LoggerService, MetricService, OTEL_ATTR_CLIENT_ID } from '@narval/nestjs-shared' import { Action, AuthorizationRequest, @@ -9,7 +9,8 @@ import { JwtString } from '@narval/policy-engine-shared' import { Intent, Intents } from '@narval/transaction-request-intent' -import { HttpStatus, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { Counter } from '@opentelemetry/api' import { v4 as uuid } from 'uuid' import { AUTHORIZATION_REQUEST_PROCESSING_QUEUE_ATTEMPTS, FIAT_ID_USD } from '../../../armory.constant' import { FeedService } from '../../../data-feed/core/service/feed.service' @@ -40,6 +41,9 @@ const getStatus = (decision: string): AuthorizationRequestStatus => { @Injectable() export class AuthorizationRequestService { + private createCounter: Counter + private evaluationCounter: Counter + constructor( private authzRequestRepository: AuthorizationRequestRepository, private authzRequestApprovalRepository: AuthorizationRequestApprovalRepository, @@ -48,10 +52,16 @@ export class AuthorizationRequestService { private priceService: PriceService, private clusterService: ClusterService, private feedService: FeedService, - private logger: LoggerService - ) {} + private logger: LoggerService, + @Inject(MetricService) private metricService: MetricService + ) { + this.createCounter = this.metricService.createCounter('authorization_request_create_count') + this.evaluationCounter = this.metricService.createCounter('authorization_request_evaluation_count') + } async create(input: CreateAuthorizationRequest): Promise { + this.createCounter.add(1, { [OTEL_ATTR_CLIENT_ID]: input.clientId }) + const now = new Date() const authzRequest = await this.authzRequestRepository.create({ @@ -156,6 +166,12 @@ export class AuthorizationRequestService { }) const status = getStatus(evaluation.decision) + + this.evaluationCounter.add(1, { + [OTEL_ATTR_CLIENT_ID]: input.clientId, + 'domain.authorization_request.status': status + }) + // NOTE: we will track the transfer before we update the status to PERMITTED so that we don't have a brief window where a second transfer can come in before the history is tracked. // TODO: (@wcalderipe, 01/02/24) Move to the TransferTrackingService. if (input.request.action === Action.SIGN_TRANSACTION && status === AuthorizationRequestStatus.PERMITTED) { diff --git a/apps/armory/src/shared/decorator/api-client-secret-guard.decorator.ts b/apps/armory/src/shared/decorator/api-client-secret-guard.decorator.ts index 22e398b6d..5a739a3bd 100644 --- a/apps/armory/src/shared/decorator/api-client-secret-guard.decorator.ts +++ b/apps/armory/src/shared/decorator/api-client-secret-guard.decorator.ts @@ -1,6 +1,7 @@ +import { REQUEST_HEADER_CLIENT_SECRET } from '@narval/nestjs-shared' import { UseGuards, applyDecorators } from '@nestjs/common' import { ApiHeader, ApiSecurity } from '@nestjs/swagger' -import { CLIENT_SECRET_SECURITY, REQUEST_HEADER_CLIENT_SECRET } from '../../armory.constant' +import { CLIENT_SECRET_SECURITY } from '../../armory.constant' import { ClientSecretGuard } from '../guard/client-secret.guard' export function ApiClientSecretGuard() { diff --git a/apps/armory/src/shared/guard/__test__/unit/client-secret.guard.spec.ts b/apps/armory/src/shared/guard/__test__/unit/client-secret.guard.spec.ts index 77c9439e6..7a3cedc5a 100644 --- a/apps/armory/src/shared/guard/__test__/unit/client-secret.guard.spec.ts +++ b/apps/armory/src/shared/guard/__test__/unit/client-secret.guard.spec.ts @@ -1,9 +1,8 @@ -import { secret } from '@narval/nestjs-shared' +import { REQUEST_HEADER_CLIENT_ID, REQUEST_HEADER_CLIENT_SECRET, secret } from '@narval/nestjs-shared' import { getPublicKey, privateKeyToJwk } from '@narval/signature' import { ExecutionContext } from '@nestjs/common' import { mock } from 'jest-mock-extended' import { generatePrivateKey } from 'viem/accounts' -import { REQUEST_HEADER_CLIENT_ID, REQUEST_HEADER_CLIENT_SECRET } from '../../../../armory.constant' import { ClientService } from '../../../../client/core/service/client.service' import { Client } from '../../../../client/core/type/client.type' import { ApplicationException } from '../../../exception/application.exception' diff --git a/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts b/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts index b165e1b19..3bce0327e 100644 --- a/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts +++ b/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts @@ -1,6 +1,6 @@ +import { REQUEST_HEADER_CLIENT_ID } from '@narval/nestjs-shared' import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common' import { ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' -import { REQUEST_HEADER_CLIENT_ID } from '../../../../policy-engine.constant' import { ClientId } from '../../../../shared/decorator/client-id.decorator' import { EvaluationService } from '../../../core/service/evaluation.service' import { EvaluationRequestDto } from '../dto/evaluation-request.dto' diff --git a/apps/policy-engine/src/shared/decorator/client-id.decorator.ts b/apps/policy-engine/src/shared/decorator/client-id.decorator.ts index 5a85133b7..ae85ce19d 100644 --- a/apps/policy-engine/src/shared/decorator/client-id.decorator.ts +++ b/apps/policy-engine/src/shared/decorator/client-id.decorator.ts @@ -1,5 +1,5 @@ +import { REQUEST_HEADER_CLIENT_ID } from '@narval/nestjs-shared' import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common' -import { REQUEST_HEADER_CLIENT_ID } from '../../policy-engine.constant' export const factory = (_value: unknown, ctx: ExecutionContext) => { const req = ctx.switchToHttp().getRequest() diff --git a/apps/vault/src/vault/__test__/e2e/account.spec.ts b/apps/vault/src/vault/__test__/e2e/account.spec.ts index eccfecbb5..47fa00208 100644 --- a/apps/vault/src/vault/__test__/e2e/account.spec.ts +++ b/apps/vault/src/vault/__test__/e2e/account.spec.ts @@ -1,7 +1,7 @@ import { Permission } from '@narval/armory-sdk' import { ConfigModule, ConfigService } from '@narval/config-module' import { EncryptionModuleOptionProvider } from '@narval/encryption-module' -import { LoggerModule, secret } from '@narval/nestjs-shared' +import { LoggerModule, REQUEST_HEADER_CLIENT_ID, secret } from '@narval/nestjs-shared' import { Payload, RsaPublicKey, @@ -20,7 +20,6 @@ import { v4 as uuid } from 'uuid' import { ClientModule } from '../../../client/client.module' import { ClientService } from '../../../client/core/service/client.service' import { Config, load } from '../../../main.config' -import { REQUEST_HEADER_CLIENT_ID } from '../../../main.constant' import { TestPrismaService } from '../../../shared/module/persistence/service/test-prisma.service' import { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing' import { Client, Origin } from '../../../shared/type/domain.type' diff --git a/apps/vault/src/vault/__test__/e2e/encryption-key.spec.ts b/apps/vault/src/vault/__test__/e2e/encryption-key.spec.ts index cdc612c01..785fb3279 100644 --- a/apps/vault/src/vault/__test__/e2e/encryption-key.spec.ts +++ b/apps/vault/src/vault/__test__/e2e/encryption-key.spec.ts @@ -1,7 +1,7 @@ import { Permission } from '@narval/armory-sdk' import { ConfigModule, ConfigService } from '@narval/config-module' import { EncryptionModuleOptionProvider } from '@narval/encryption-module' -import { LoggerModule } from '@narval/nestjs-shared' +import { LoggerModule, REQUEST_HEADER_CLIENT_ID } from '@narval/nestjs-shared' import { Payload, SigningAlg, @@ -17,7 +17,6 @@ import { v4 as uuid } from 'uuid' import { ClientModule } from '../../../client/client.module' import { ClientService } from '../../../client/core/service/client.service' import { Config, load } from '../../../main.config' -import { REQUEST_HEADER_CLIENT_ID } from '../../../main.constant' import { TestPrismaService } from '../../../shared/module/persistence/service/test-prisma.service' import { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing' import { Client } from '../../../shared/type/domain.type' diff --git a/apps/vault/src/vault/__test__/e2e/sign.spec.ts b/apps/vault/src/vault/__test__/e2e/sign.spec.ts index 65f01f312..7685a4c82 100644 --- a/apps/vault/src/vault/__test__/e2e/sign.spec.ts +++ b/apps/vault/src/vault/__test__/e2e/sign.spec.ts @@ -1,6 +1,6 @@ import { ConfigModule } from '@narval/config-module' import { EncryptionModuleOptionProvider } from '@narval/encryption-module' -import { LoggerModule } from '@narval/nestjs-shared' +import { LoggerModule, REQUEST_HEADER_CLIENT_ID } from '@narval/nestjs-shared' import { Action, FIXTURE } from '@narval/policy-engine-shared' import { SigningAlg, @@ -22,7 +22,6 @@ import { v4 as uuid } from 'uuid' import { verifyMessage } from 'viem' import { ClientService } from '../../../client/core/service/client.service' import { load } from '../../../main.config' -import { REQUEST_HEADER_CLIENT_ID } from '../../../main.constant' import { KeyValueRepository } from '../../../shared/module/key-value/core/repository/key-value.repository' import { InMemoryKeyValueRepository } from '../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository' import { TestPrismaService } from '../../../shared/module/persistence/service/test-prisma.service' diff --git a/apps/vault/src/vault/__test__/e2e/wallet.spec.ts b/apps/vault/src/vault/__test__/e2e/wallet.spec.ts index f0b8f24dd..63b461cfe 100644 --- a/apps/vault/src/vault/__test__/e2e/wallet.spec.ts +++ b/apps/vault/src/vault/__test__/e2e/wallet.spec.ts @@ -1,7 +1,7 @@ import { Permission, resourceId } from '@narval/armory-sdk' import { ConfigModule, ConfigService } from '@narval/config-module' import { EncryptionModuleOptionProvider } from '@narval/encryption-module' -import { LoggerModule, secret } from '@narval/nestjs-shared' +import { LoggerModule, REQUEST_HEADER_CLIENT_ID, secret } from '@narval/nestjs-shared' import { Alg, Curves, @@ -27,7 +27,6 @@ import { english } from 'viem/accounts' import { ClientModule } from '../../../client/client.module' import { ClientService } from '../../../client/core/service/client.service' import { Config, load } from '../../../main.config' -import { REQUEST_HEADER_CLIENT_ID } from '../../../main.constant' import { TestPrismaService } from '../../../shared/module/persistence/service/test-prisma.service' import { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing' import { Client, Origin } from '../../../shared/type/domain.type' diff --git a/apps/vault/src/vault/core/service/__test__/unit/import.service.spec.ts b/apps/vault/src/vault/core/service/__test__/unit/import.service.spec.ts index 18a0590a5..827c718f3 100644 --- a/apps/vault/src/vault/core/service/__test__/unit/import.service.spec.ts +++ b/apps/vault/src/vault/core/service/__test__/unit/import.service.spec.ts @@ -1,5 +1,12 @@ -import { LoggerModule } from '@narval/nestjs-shared' +import { + LoggerModule, + MetricService, + OTEL_ATTR_CLIENT_ID, + OpenTelemetryModule, + StatefulMetricService +} from '@narval/nestjs-shared' import { Test, TestingModule } from '@nestjs/testing' +import { MockProxy, mock } from 'jest-mock-extended' import { Origin, PrivateAccount } from '../../../../../shared/type/domain.type' import { AccountRepository } from '../../../../persistence/repository/account.repository' import { ImportRepository } from '../../../../persistence/repository/import.repository' @@ -9,12 +16,18 @@ import { KeyGenerationService } from '../../key-generation.service' describe('ImportService', () => { let importService: ImportService let accountRepository: AccountRepository + let statefulMetricService: StatefulMetricService + let importRepositoryMock: MockProxy - const PRIVATE_KEY = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' + const clientId = 'clientId' + const privateKey = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' + const accountId = 'accountId' beforeEach(async () => { + importRepositoryMock = mock() + const module: TestingModule = await Test.createTestingModule({ - imports: [LoggerModule.forTest()], + imports: [LoggerModule.forTest(), OpenTelemetryModule.forTest()], providers: [ ImportService, { @@ -23,15 +36,15 @@ describe('ImportService', () => { // mock the methods of AccountRepository that are used in ImportService // for example: save: jest.fn().mockResolvedValue({ - id: 'accountId', + id: accountId, address: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', - privateKey: PRIVATE_KEY + privateKey }) } }, { provide: ImportRepository, - useValue: {} + useValue: importRepositoryMock }, { provide: KeyGenerationService, @@ -40,16 +53,13 @@ describe('ImportService', () => { ] }).compile() - importService = module.get(ImportService) - accountRepository = module.get(AccountRepository) + importService = module.get(ImportService) + accountRepository = module.get(AccountRepository) + statefulMetricService = module.get(MetricService) }) describe('importPrivateKey', () => { - it('should import private key and return a account', async () => { - const clientId = 'clientId' - const privateKey = PRIVATE_KEY - const accountId = 'accountId' - + it('imports private key and return an account', async () => { const account: PrivateAccount = await importService.importPrivateKey(clientId, privateKey, accountId) expect(account).toEqual({ @@ -66,5 +76,19 @@ describe('ImportService', () => { address: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1' }) }) + + it('increments account import counter metric', async () => { + await importService.importPrivateKey(clientId, privateKey, accountId) + + expect(statefulMetricService.counters).toEqual([ + { + name: 'account_import_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: clientId + } + } + ]) + }) }) }) diff --git a/apps/vault/src/vault/core/service/__test__/unit/key-generation.service.spec.ts b/apps/vault/src/vault/core/service/__test__/unit/key-generation.service.spec.ts index 4f8305ce1..baa34bc03 100644 --- a/apps/vault/src/vault/core/service/__test__/unit/key-generation.service.spec.ts +++ b/apps/vault/src/vault/core/service/__test__/unit/key-generation.service.spec.ts @@ -1,4 +1,10 @@ -import { LoggerModule } from '@narval/nestjs-shared' +import { + LoggerModule, + MetricService, + OTEL_ATTR_CLIENT_ID, + OpenTelemetryModule, + StatefulMetricService +} from '@narval/nestjs-shared' import { RsaPrivateKey, generateJwk, @@ -35,8 +41,8 @@ describe('GenerateService', () => { let accountRepositoryMock: MockProxy let rootKeyRepositoryMock: MockProxy let clientServiceMock: MockProxy - let keyGenerationService: KeyGenerationService + let statefulMetricService: StatefulMetricService const mnemonic = 'legal winner thank year wave sausage worth useful legal winner thank yellow' @@ -63,7 +69,7 @@ describe('GenerateService', () => { }) const module: TestingModule = await Test.createTestingModule({ - imports: [LoggerModule.forTest()], + imports: [LoggerModule.forTest(), OpenTelemetryModule.forTest()], providers: [ KeyGenerationService, { @@ -92,37 +98,71 @@ describe('GenerateService', () => { }).compile() keyGenerationService = module.get(KeyGenerationService) + statefulMetricService = module.get(MetricService) + jest.spyOn(keyGenerationService, 'getIndexes').mockResolvedValue([]) }) - it('returns first derived account from a generated rootKey', async () => { - const { account } = await keyGenerationService.generateWallet('clientId', { curve: 'secp256k1' }) + describe('generateWallet', () => { + it('returns first derived account from a generated rootKey', async () => { + const { account } = await keyGenerationService.generateWallet(clientId, { curve: 'secp256k1' }) - expect(account.derivationPath).toEqual("m/44'/60'/0'/0/0") - }) + expect(account.derivationPath).toEqual("m/44'/60'/0'/0/0") + }) + + it('returns an encrypted backup if client has an RSA backupKey configured', async () => { + const rsaBackupKey = await generateJwk('RS256') - it('returns an encrypted backup if client has an RSA backupKey configured', async () => { - const rsaBackupKey = await generateJwk('RS256') + clientServiceMock.findById.mockResolvedValue({ + ...client, + backupPublicKey: rsaBackupKey + }) - clientServiceMock.findById.mockResolvedValue({ - ...client, - backupPublicKey: rsaBackupKey + const { backup } = await keyGenerationService.generateWallet(clientId, { curve: 'secp256k1' }) + const decryptedMnemonic = await rsaDecrypt(backup as string, rsaBackupKey) + const spaceInMnemonic = decryptedMnemonic.split(' ') + + expect(spaceInMnemonic.length).toBe(12) }) - const { backup } = await keyGenerationService.generateWallet('clientId', { curve: 'secp256k1' }) - const decryptedMnemonic = await rsaDecrypt(backup as string, rsaBackupKey) - const spaceInMnemonic = decryptedMnemonic.split(' ') - expect(spaceInMnemonic.length).toBe(12) - }) + it('saves rootKey to the database', async () => { + await keyGenerationService.generateWallet(clientId, { curve: 'secp256k1' }) - it('saves rootKey to the database', async () => { - await keyGenerationService.generateWallet('clientId', { curve: 'secp256k1' }) - expect(rootKeyRepositoryMock.save).toHaveBeenCalledWith('clientId', { - mnemonic: expect.any(String), - keyId: expect.any(String), - origin: Origin.GENERATED, - curve: 'secp256k1', - keyType: 'local' + expect(rootKeyRepositoryMock.save).toHaveBeenCalledWith(clientId, { + mnemonic: expect.any(String), + keyId: expect.any(String), + origin: Origin.GENERATED, + curve: 'secp256k1', + keyType: 'local' + }) + }) + + it('increments counter metrics', async () => { + await keyGenerationService.generateWallet(clientId, { curve: 'secp256k1' }) + + expect(statefulMetricService.counters).toEqual([ + { + name: 'wallet_generate_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: clientId + } + }, + { + name: 'account_generate_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: clientId + } + }, + { + name: 'account_derive_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: clientId + } + } + ]) }) }) }) diff --git a/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts b/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts index 8c5469fe9..940fe2350 100644 --- a/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts +++ b/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts @@ -1,3 +1,4 @@ +import { MetricService, OTEL_ATTR_CLIENT_ID, OpenTelemetryModule, StatefulMetricService } from '@narval/nestjs-shared' import { Action, Eip712TypedData, Request } from '@narval/policy-engine-shared' import { Jwk, Secp256k1PublicKey, secp256k1PrivateKeyToJwk, verifySecp256k1 } from '@narval/signature' import { Test } from '@nestjs/testing' @@ -22,6 +23,7 @@ import { SigningService } from '../../signing.service' describe('SigningService', () => { let signingService: SigningService let nonceServiceMock: MockProxy + let statefulMetricService: StatefulMetricService const account: PrivateAccount = { id: 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1', @@ -37,6 +39,7 @@ describe('SigningService', () => { nonceServiceMock = mock() const module = await Test.createTestingModule({ + imports: [OpenTelemetryModule.forTest()], providers: [ SigningService, { @@ -52,7 +55,8 @@ describe('SigningService', () => { ] }).compile() - signingService = module.get(SigningService) + signingService = module.get(SigningService) + statefulMetricService = module.get(MetricService) }) const clientId = 'test-client-id' @@ -119,6 +123,20 @@ describe('SigningService', () => { expect(result).toEqual(expectedSignature) }) + + it('increments counter metric', async () => { + await signingService.signTransaction(clientId, request) + + expect(statefulMetricService.counters).toEqual([ + { + name: 'sign_transaction_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: clientId + } + } + ]) + }) }) describe('signMessage', () => { @@ -175,6 +193,20 @@ describe('SigningService', () => { expect(nonceServiceMock.save).toHaveBeenCalledWith(clientId, nonce) }) + + it('increments counter metric', async () => { + await signingService.signMessage(clientId, eip191Request) + + expect(statefulMetricService.counters).toEqual([ + { + name: 'sign_message_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: clientId + } + } + ]) + }) }) describe('signTypedData', () => { @@ -250,6 +282,20 @@ describe('SigningService', () => { expect(nonceServiceMock.save).toHaveBeenCalledWith(clientId, nonce) }) + + it('increments counter metric', async () => { + await signingService.signTypedData(clientId, typedDataRequest) + + expect(statefulMetricService.counters).toEqual([ + { + name: 'sign_typed_data_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: clientId + } + } + ]) + }) }) describe('signUserOperation', () => { @@ -286,10 +332,25 @@ describe('SigningService', () => { '0x687fda1fcebeed665d6f738a2d1a7e952e41434ae010c58aaa9623fe991a0a716d8d0d27d5192aaf7231965c44ae9abbe0c126068ef5e42f201de1138f82f8301b' expect(result).toEqual(expectedSignature) }) + it('saves the nonce on success', async () => { await signingService.signUserOperation(clientId, userOpRequest) expect(nonceServiceMock.save).toHaveBeenCalledWith(clientId, nonce) }) + + it('increments counter metric', async () => { + await signingService.signUserOperation(clientId, userOpRequest) + + expect(statefulMetricService.counters).toEqual([ + { + name: 'sign_user_operation_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: clientId + } + } + ]) + }) }) describe('signRaw', () => { @@ -316,6 +377,20 @@ describe('SigningService', () => { expect(nonceServiceMock.save).toHaveBeenCalledWith(clientId, nonce) }) + + it('increments counter metric', async () => { + await signingService.signRaw(clientId, rawRequest) + + expect(statefulMetricService.counters).toEqual([ + { + name: 'sign_raw_count', + value: 1, + attributes: { + [OTEL_ATTR_CLIENT_ID]: clientId + } + } + ]) + }) }) it('does support round-trip serialization', async () => { diff --git a/apps/vault/src/vault/core/service/import.service.ts b/apps/vault/src/vault/core/service/import.service.ts index 84bf17ba3..5b5a522d0 100644 --- a/apps/vault/src/vault/core/service/import.service.ts +++ b/apps/vault/src/vault/core/service/import.service.ts @@ -102,6 +102,8 @@ export class ImportService { this.logger.log('Importing encrypted private key', { clientId }) + // TODO: (@wcalderipe, 13/11/2024) Add unit test to ensure we're tracking + // the business metrics. this.accountImportCounter.add(1, { [OTEL_ATTR_CLIENT_ID]: clientId }) const privateKey = await this.#decrypt(clientId, encryptedPrivateKey) @@ -128,6 +130,8 @@ export class ImportService { keyId: string backup?: string }> { + // TODO: (@wcalderipe, 13/11/2024) Add unit test to ensure we're tracking + // the business metrics. this.walletImportCounter.add(1, { [OTEL_ATTR_CLIENT_ID]: clientId }) const { keyId: optionalKeyId, encryptedSeed, curve } = body diff --git a/apps/vault/src/vault/core/service/key-generation.service.ts b/apps/vault/src/vault/core/service/key-generation.service.ts index 0f815164c..c2ba6d15b 100644 --- a/apps/vault/src/vault/core/service/key-generation.service.ts +++ b/apps/vault/src/vault/core/service/key-generation.service.ts @@ -30,9 +30,7 @@ type GenerateArgs = { @Injectable() export class KeyGenerationService { private walletGenerateCounter: Counter - private accountGenerateCounter: Counter - private accountDeriveCounter: Counter constructor( @@ -123,17 +121,23 @@ export class KeyGenerationService { clientId: string, { rootKey, path, keyId }: { rootKey: HDKey; path: string; keyId: string } ): Promise { + this.accountDeriveCounter.add(1, { [OTEL_ATTR_CLIENT_ID]: clientId }) + const derivedKey = rootKey.derive(path) const account = await hdKeyToAccount({ key: derivedKey, keyId, path }) + await this.accountRepository.save(clientId, account) + return account } async generateAccount(clientId: string, args: GenerateArgs): Promise { + this.accountGenerateCounter.add(1, { [OTEL_ATTR_CLIENT_ID]: clientId }) + const { keyId, count = 1, derivationPaths = [], rootKey } = args const dbIndexes = await this.getIndexes(clientId, keyId) diff --git a/apps/vault/src/vault/core/service/nonce.service.ts b/apps/vault/src/vault/core/service/nonce.service.ts index 264f01033..06d58abd4 100644 --- a/apps/vault/src/vault/core/service/nonce.service.ts +++ b/apps/vault/src/vault/core/service/nonce.service.ts @@ -6,6 +6,7 @@ import { Collection } from '../../../shared/type/domain.type' @Injectable() export class NonceService { private readonly KEY_PREFIX = Collection.REQUEST_NONCE + constructor(private keyValuService: KeyValueService) {} async save(clientId: string, nonce: string): Promise { diff --git a/apps/vault/src/vault/core/service/signing.service.ts b/apps/vault/src/vault/core/service/signing.service.ts index ece47432b..c599067c6 100644 --- a/apps/vault/src/vault/core/service/signing.service.ts +++ b/apps/vault/src/vault/core/service/signing.service.ts @@ -1,3 +1,4 @@ +import { MetricService, OTEL_ATTR_CLIENT_ID, TraceService } from '@narval/nestjs-shared' import { Action, Hex, @@ -10,10 +11,11 @@ import { getTxType } from '@narval/policy-engine-shared' import { signSecp256k1 } from '@narval/signature' -import { HttpStatus, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { Counter } from '@opentelemetry/api' import { EntryPoint } from 'permissionless/types' import { getUserOperationHash } from 'permissionless/utils' -import { createWalletClient, custom, extractChain, hexToBigInt, hexToBytes, signatureToHex } from 'viem' +import { createWalletClient, custom, extractChain, hexToBytes, signatureToHex } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import * as chains from 'viem/chains' import { ApplicationException } from '../../../shared/exception/application.exception' @@ -23,10 +25,24 @@ import { NonceService } from './nonce.service' @Injectable() export class SigningService { + private signTransactionCounter: Counter + private signMessageCounter: Counter + private signTypedDataCounter: Counter + private signRawCounter: Counter + private signUserOperationCounter: Counter + constructor( private accountRepository: AccountRepository, - private nonceService: NonceService - ) {} + private nonceService: NonceService, + @Inject(TraceService) private traceService: TraceService, + @Inject(MetricService) private metricService: MetricService + ) { + this.signTransactionCounter = this.metricService.createCounter('sign_transaction_count') + this.signMessageCounter = this.metricService.createCounter('sign_message_count') + this.signTypedDataCounter = this.metricService.createCounter('sign_typed_data_count') + this.signRawCounter = this.metricService.createCounter('sign_raw_count') + this.signUserOperationCounter = this.metricService.createCounter('sign_user_operation_count') + } async sign(clientId: string, request: Request): Promise { if (request.action === Action.SIGN_TRANSACTION) { @@ -49,9 +65,13 @@ export class SigningService { } async signUserOperation(clientId: string, action: SignUserOperationAction): Promise { + this.signUserOperationCounter.add(1, { [OTEL_ATTR_CLIENT_ID]: clientId }) + const span = this.traceService.startSpan(`${SigningService.name}.signUserOperation`) + const { userOperation, resourceId } = action const client = await this.buildClient(clientId, resourceId) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { chainId, entryPoint, factoryAddress: _factoryAddress, ...userOpToBeHashed } = userOperation const userOpHash = getUserOperationHash({ @@ -68,23 +88,23 @@ export class SigningService { await this.maybeSaveNonce(clientId, action) + span.end() + return signature } async signTransaction(clientId: string, action: SignTransactionAction): Promise { + this.signTransactionCounter.add(1, { [OTEL_ATTR_CLIENT_ID]: clientId }) + const span = this.traceService.startSpan(`${SigningService.name}.signTransaction`) + const { transactionRequest, resourceId } = action const chain = extractChain({ chains: Object.values(chains), id: transactionRequest.chainId }) const client = await this.buildClient(clientId, resourceId, chain) - - const value = - transactionRequest.value === undefined || transactionRequest.value === '0x' - ? undefined - : hexToBigInt(transactionRequest.value) - const type = getTxType(transactionRequest) + if (type === undefined) { throw new ApplicationException({ message: 'Invalid transaction type', @@ -92,6 +112,7 @@ export class SigningService { context: { transactionRequest } }) } + const txRequest = buildSignableTransactionRequest(transactionRequest) const signature = await client.signTransaction({ ...txRequest, chain }) // /* @@ -110,32 +131,46 @@ export class SigningService { await this.maybeSaveNonce(clientId, action) + span.end() + return signature } async signMessage(clientId: string, action: SignMessageAction): Promise { + this.signMessageCounter.add(1, { [OTEL_ATTR_CLIENT_ID]: clientId }) + const span = this.traceService.startSpan(`${SigningService.name}.signMessage`) + const { message, resourceId } = action const client = await this.buildClient(clientId, resourceId) const signature = await client.signMessage({ message }) await this.maybeSaveNonce(clientId, action) + span.end() + return signature } async signTypedData(clientId: string, action: SignTypedDataAction): Promise { + this.signTypedDataCounter.add(1, { [OTEL_ATTR_CLIENT_ID]: clientId }) + const span = this.traceService.startSpan(`${SigningService.name}.signTypedData`) + const { typedData, resourceId } = action const client = await this.buildClient(clientId, resourceId) const signature = await client.signTypedData(typedData) await this.maybeSaveNonce(clientId, action) + span.end() + return signature } // Sign a raw message; nothing ETH or chain-specific, simply performs an // ecdsa signature on the byte representation of the hex-encoded raw message async signRaw(clientId: string, action: SignRawAction): Promise { + this.signRawCounter.add(1, { [OTEL_ATTR_CLIENT_ID]: clientId }) + const { rawMessage, resourceId } = action const account = await this.findAccount(clientId, resourceId) const message = hexToBytes(rawMessage)