diff --git a/packages/phone-number-privacy/combiner/package.json b/packages/phone-number-privacy/combiner/package.json index 4fb952bb45..a49c65a337 100644 --- a/packages/phone-number-privacy/combiner/package.json +++ b/packages/phone-number-privacy/combiner/package.json @@ -48,6 +48,7 @@ "firebase-admin": "^11.10.1", "firebase-functions": "^4.4.1", "knex": "^2.1.0", + "lru-cache": "^10.0.1", "node-fetch": "^2.6.9", "pg": "^8.2.1", "uuid": "^7.0.3" diff --git a/packages/phone-number-privacy/combiner/src/common/tracing-utils.ts b/packages/phone-number-privacy/combiner/src/common/tracing-utils.ts new file mode 100644 index 0000000000..f989c86e56 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/common/tracing-utils.ts @@ -0,0 +1,21 @@ +import opentelemetry, { SpanStatusCode } from '@opentelemetry/api' + +const tracer = opentelemetry.trace.getTracer('combiner-tracer') + +export function traceAsyncFunction(traceName: string, fn: () => Promise): Promise { + return tracer.startActiveSpan(traceName, async (span) => { + try { + const res = await fn() + span.setStatus({ code: SpanStatusCode.OK }) + return res + } catch (err: any) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err instanceof Error ? err.message : undefined, + }) + throw err + } finally { + span.end() + } + }) +} diff --git a/packages/phone-number-privacy/combiner/src/config.ts b/packages/phone-number-privacy/combiner/src/config.ts index 091817d2c2..bbdcbd517a 100644 --- a/packages/phone-number-privacy/combiner/src/config.ts +++ b/packages/phone-number-privacy/combiner/src/config.ts @@ -24,9 +24,11 @@ import { pnpFullNodeTimeoutMs, pnpKeysCurrentVersion, pnpKeysVersions, + pnpMockDek, pnpOdisServicesSigners, pnpOdisServicesTimeoutMilliseconds, pnpServiceName, + pnpShouldMockAccountService, serviceNameConfig, } from './utils/firebase-configs' @@ -61,6 +63,8 @@ export interface OdisConfig { fullNodeTimeoutMs: number fullNodeRetryCount: number fullNodeRetryDelayMs: number + shouldMockAccountService?: boolean + mockDek?: string } export interface CombinerConfig { @@ -185,6 +189,8 @@ if (DEV_MODE) { fullNodeTimeoutMs: pnpFullNodeTimeoutMs.value(), fullNodeRetryCount: pnpFullNodeRetryCount.value(), fullNodeRetryDelayMs: pnpFullNodeDelaysMs.value(), + shouldMockAccountService: pnpShouldMockAccountService.value(), + mockDek: pnpMockDek.value(), }, domains: { serviceName: domainServiceName.value(), diff --git a/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/action.ts b/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/action.ts index 64f31e66ba..8e87562dbf 100644 --- a/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/action.ts +++ b/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/action.ts @@ -1,8 +1,8 @@ import { authenticateUser, CombinerEndpoint, - DataEncryptionKeyFetcher, ErrorMessage, + ErrorType, getSignerEndpoint, hasValidAccountParam, isBodyReasonablySized, @@ -16,13 +16,14 @@ import { Signer, thresholdCallToSigners } from '../../../common/combine' import { errorResult, ResultHandler } from '../../../common/handlers' import { getKeyVersionInfo } from '../../../common/io' import { getCombinerVersion, OdisConfig } from '../../../config' +import { AccountService } from '../../services/account-services' import { logPnpSignerResponseDiscrepancies } from '../../services/log-responses' import { findCombinerQuotaState } from '../../services/threshold-state' export function pnpQuota( signers: Signer[], config: OdisConfig, - dekFetcher: DataEncryptionKeyFetcher + accountService: AccountService ): ResultHandler { return async (request, response) => { const logger = response.locals.logger @@ -31,7 +32,9 @@ export function pnpQuota( return errorResult(400, WarningMessage.INVALID_INPUT) } - if (!(await authenticateUser(request, logger, dekFetcher))) { + const warnings: ErrorType[] = [] + + if (!(await authenticateUser(request, logger, accountService.getAccount, warnings))) { return errorResult(401, WarningMessage.UNAUTHENTICATED_USER) } @@ -47,7 +50,7 @@ export function pnpQuota( responseSchema: PnpQuotaResponseSchema, shouldCheckKeyVersion: false, }) - const warnings = logPnpSignerResponseDiscrepancies(logger, signerResponses) + warnings.push(...logPnpSignerResponseDiscrepancies(logger, signerResponses)) const { threshold } = keyVersionInfo diff --git a/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/action.ts b/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/action.ts index 72c97b3a3a..7eba782fdc 100644 --- a/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/action.ts +++ b/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/action.ts @@ -1,7 +1,6 @@ import { authenticateUser, CombinerEndpoint, - DataEncryptionKeyFetcher, ErrorMessage, ErrorType, getSignerEndpoint, @@ -21,13 +20,14 @@ import { BLSCryptographyClient } from '../../../common/crypto-clients/bls-crypto import { errorResult, ResultHandler } from '../../../common/handlers' import { getKeyVersionInfo, requestHasSupportedKeyVersion } from '../../../common/io' import { getCombinerVersion, OdisConfig } from '../../../config' +import { AccountService } from '../../services/account-services' import { logPnpSignerResponseDiscrepancies } from '../../services/log-responses' import { findCombinerQuotaState } from '../../services/threshold-state' export function pnpSign( signers: Signer[], config: OdisConfig, - dekFetcher: DataEncryptionKeyFetcher + accountService: AccountService ): ResultHandler { return async (request, response) => { const logger = response.locals.logger @@ -39,7 +39,9 @@ export function pnpSign( return errorResult(400, WarningMessage.INVALID_KEY_VERSION_REQUEST) } - if (!(await authenticateUser(request, logger, dekFetcher))) { + const warnings: ErrorType[] = [] + + if (!(await authenticateUser(request, logger, accountService.getAccount, warnings))) { return errorResult(401, WarningMessage.UNAUTHENTICATED_USER) } const keyVersionInfo = getKeyVersionInfo(request, config, logger) @@ -80,7 +82,7 @@ export function pnpSign( processResult ) - const warnings = logPnpSignerResponseDiscrepancies(logger, signerResponses) + warnings.push(...logPnpSignerResponseDiscrepancies(logger, signerResponses)) if (crypto.hasSufficientSignatures()) { try { diff --git a/packages/phone-number-privacy/combiner/src/pnp/services/account-services.ts b/packages/phone-number-privacy/combiner/src/pnp/services/account-services.ts new file mode 100644 index 0000000000..e1ef6a11c5 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/pnp/services/account-services.ts @@ -0,0 +1,90 @@ +import { ContractKit } from '@celo/contractkit' +import { + ErrorMessage, + FULL_NODE_TIMEOUT_IN_MS, + getDataEncryptionKey, + RETRY_COUNT, + RETRY_DELAY_IN_MS, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { LRUCache } from 'lru-cache' +import { OdisError, wrapError } from '../../common/error' +import { traceAsyncFunction } from '../../common/tracing-utils' + +export interface AccountService { + getAccount(address: string): Promise +} + +export interface ContractKitAccountServiceOptions { + fullNodeTimeoutMs: number + fullNodeRetryCount: number + fullNodeRetryDelayMs: number +} + +export class CachingAccountService implements AccountService { + private cache: LRUCache + constructor(baseService: AccountService) { + this.cache = new LRUCache({ + max: 500, + ttl: 5 * 1000, // 5 seconds + allowStale: true, + fetchMethod: async (address: string) => { + return await baseService.getAccount(address) + }, + }) + } + + getAccount = (address: string): Promise => { + return traceAsyncFunction('CachingAccountService - getAccount', async () => { + const dek = await this.cache.fetch(address) + + if (dek === undefined) { + // TODO decide which error ot use here + throw new OdisError(ErrorMessage.FAILURE_TO_GET_DEK) + } + return dek + }) + } +} + +// tslint:disable-next-line:max-classes-per-file +export class ContractKitAccountService implements AccountService { + constructor( + private readonly logger: Logger, + private readonly kit: ContractKit, + private readonly opts: ContractKitAccountServiceOptions = { + fullNodeTimeoutMs: FULL_NODE_TIMEOUT_IN_MS, + fullNodeRetryCount: RETRY_COUNT, + fullNodeRetryDelayMs: RETRY_DELAY_IN_MS, + } + ) {} + + async getAccount(address: string): Promise { + return traceAsyncFunction('ContractKitAccountService - getAccount', async () => { + return wrapError( + getDataEncryptionKey( + address, + this.kit, + this.logger, + this.opts.fullNodeTimeoutMs, + this.opts.fullNodeRetryCount, + this.opts.fullNodeRetryDelayMs + ).catch((err) => { + // TODO could clean this up...quick fix since we weren't incrementing blockchain error counter + this.logger.error({ err, address }, 'failed to get on-chain dek for account') + throw err + }), + ErrorMessage.FAILURE_TO_GET_DEK + ) + }) + } +} + +// tslint:disable-next-line:max-classes-per-file +export class MockAccountService implements AccountService { + constructor(private readonly mockDek: string) {} + + async getAccount(_address: string): Promise { + return this.mockDek + } +} diff --git a/packages/phone-number-privacy/combiner/src/pnp/services/log-responses.ts b/packages/phone-number-privacy/combiner/src/pnp/services/log-responses.ts index 89bd98c268..864d69bac2 100644 --- a/packages/phone-number-privacy/combiner/src/pnp/services/log-responses.ts +++ b/packages/phone-number-privacy/combiner/src/pnp/services/log-responses.ts @@ -1,4 +1,5 @@ import { + ErrorType, PnpQuotaRequest, SignMessageRequest, WarningMessage, @@ -13,8 +14,8 @@ import { export function logPnpSignerResponseDiscrepancies( logger: Logger, responses: Array> -): string[] { - const warnings: string[] = [] +): ErrorType[] { + const warnings: ErrorType[] = [] // TODO responses should all already be successes due to CombineAction receiveSuccess // https://github.com/celo-org/celo-monorepo/issues/9826 diff --git a/packages/phone-number-privacy/combiner/src/server.ts b/packages/phone-number-privacy/combiner/src/server.ts index 2b191f6122..5e67f74266 100644 --- a/packages/phone-number-privacy/combiner/src/server.ts +++ b/packages/phone-number-privacy/combiner/src/server.ts @@ -4,7 +4,6 @@ import { getContractKitWithAgent, KEY_VERSION_HEADER, loggerMiddleware, - newContractKitFetcher, OdisRequest, rootLogger, } from '@celo/phone-number-privacy-common' @@ -26,6 +25,11 @@ import { domainQuota } from './domain/endpoints/quota/action' import { domainSign } from './domain/endpoints/sign/action' import { pnpQuota } from './pnp/endpoints/quota/action' import { pnpSign } from './pnp/endpoints/sign/action' +import { + CachingAccountService, + ContractKitAccountService, + MockAccountService, +} from './pnp/services/account-services' require('events').EventEmitter.defaultMaxListeners = 15 @@ -62,13 +66,15 @@ export function startCombiner(config: CombinerConfig, kit?: ContractKit) { }) }) - const dekFetcher = newContractKitFetcher( - kit, - logger, - config.phoneNumberPrivacy.fullNodeTimeoutMs, - config.phoneNumberPrivacy.fullNodeRetryCount, - config.phoneNumberPrivacy.fullNodeRetryDelayMs - ) + const baseAccountService = config.phoneNumberPrivacy.shouldMockAccountService + ? new MockAccountService(config.phoneNumberPrivacy.mockDek!) + : new ContractKitAccountService(logger, kit, { + fullNodeTimeoutMs: config.phoneNumberPrivacy.fullNodeTimeoutMs, + fullNodeRetryCount: config.phoneNumberPrivacy.fullNodeRetryCount, + fullNodeRetryDelayMs: config.phoneNumberPrivacy.fullNodeRetryDelayMs, + }) + + const accountService = new CachingAccountService(baseAccountService) const pnpSigners: Signer[] = JSON.parse(config.phoneNumberPrivacy.odisServices.signers) const domainSigners: Signer[] = JSON.parse(config.domains.odisServices.signers) @@ -80,7 +86,7 @@ export function startCombiner(config: CombinerConfig, kit?: ContractKit) { createHandler( phoneNumberPrivacy.odisServices.timeoutMilliSeconds, phoneNumberPrivacy.enabled, - pnpQuota(pnpSigners, config.phoneNumberPrivacy, dekFetcher) + pnpQuota(pnpSigners, config.phoneNumberPrivacy, accountService) ) ) app.post( @@ -88,7 +94,7 @@ export function startCombiner(config: CombinerConfig, kit?: ContractKit) { createHandler( phoneNumberPrivacy.odisServices.timeoutMilliSeconds, phoneNumberPrivacy.enabled, - pnpSign(pnpSigners, config.phoneNumberPrivacy, dekFetcher) + pnpSign(pnpSigners, config.phoneNumberPrivacy, accountService) ) ) app.post( diff --git a/packages/phone-number-privacy/combiner/src/utils/firebase-configs.ts b/packages/phone-number-privacy/combiner/src/utils/firebase-configs.ts index 4a76f8b595..6be74bfa4f 100644 --- a/packages/phone-number-privacy/combiner/src/utils/firebase-configs.ts +++ b/packages/phone-number-privacy/combiner/src/utils/firebase-configs.ts @@ -41,6 +41,8 @@ export const pnpFullNodeRetryCount = defineInt('PNP_FULL_NODE_RETRY_COUNT', { export const pnpFullNodeDelaysMs = defineInt('PNP_FULL_NODE_DELAY_MS', { default: RETRY_DELAY_IN_MS, }) +export const pnpShouldMockAccountService = defineBoolean('PNP_SHOULD_MOCK_ACCOUNT_SERVICE') +export const pnpMockDek = defineString('PNP_MOCK_DECK') // Domains export const domainServiceName = defineString('DOMAIN_SERVICE_NAME', {