diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 7de52b6086..ce355189ed 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -68,8 +68,8 @@ services: STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= WEBHOOK_URL: http://happy-life-bank/webhooks - OPEN_PAYMENTS_URL: ${HAPPY_LIFE_BANK_OPEN_PAYMENTS_URL:-http://happy-life-bank-backend} EXCHANGE_RATES_URL: http://happy-life-bank/rates + OPEN_PAYMENTS_URL: ${HAPPY_LIFE_BANK_OPEN_PAYMENTS_URL:-http://happy-life-bank-backend} REDIS_URL: redis://shared-redis:6379/2 WALLET_ADDRESS_URL: ${HAPPY_LIFE_BANK_WALLET_ADDRESS_URL:-https://happy-life-bank-backend/.well-known/pay} ENABLE_TELEMETRY: true diff --git a/packages/backend/jest.env.js b/packages/backend/jest.env.js index 4a8435dd72..14b2fdd839 100644 --- a/packages/backend/jest.env.js +++ b/packages/backend/jest.env.js @@ -15,3 +15,4 @@ process.env.AUTH_ADMIN_API_URL = 'http://127.0.0.1:3003/graphql' process.env.AUTH_ADMIN_API_SECRET = 'test-secret' process.env.OPERATOR_TENANT_ID = 'cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d' process.env.API_SECRET = 'KQEXlZO65jUJXakXnLxGO7dk387mt71G9tZ42rULSNU=' +process.env.EXCHANGE_RATES_URL = 'http://example.com/rates' diff --git a/packages/backend/src/asset/errors.ts b/packages/backend/src/asset/errors.ts index 36334ab1fc..011137eab4 100644 --- a/packages/backend/src/asset/errors.ts +++ b/packages/backend/src/asset/errors.ts @@ -3,7 +3,8 @@ import { GraphQLErrorCode } from '../graphql/errors' export enum AssetError { DuplicateAsset = 'DuplicateAsset', UnknownAsset = 'UnknownAsset', - CannotDeleteInUseAsset = 'CannotDeleteInUseAsset' + CannotDeleteInUseAsset = 'CannotDeleteInUseAsset', + NoRatesForAsset = 'NoRatesForAsset' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -15,7 +16,8 @@ export const errorToCode: { } = { [AssetError.UnknownAsset]: GraphQLErrorCode.NotFound, [AssetError.DuplicateAsset]: GraphQLErrorCode.Duplicate, - [AssetError.CannotDeleteInUseAsset]: GraphQLErrorCode.Forbidden + [AssetError.CannotDeleteInUseAsset]: GraphQLErrorCode.Forbidden, + [AssetError.NoRatesForAsset]: GraphQLErrorCode.Forbidden } export const errorToMessage: { @@ -23,5 +25,6 @@ export const errorToMessage: { } = { [AssetError.UnknownAsset]: 'Asset not found', [AssetError.DuplicateAsset]: 'Asset already exists', - [AssetError.CannotDeleteInUseAsset]: 'Cannot delete! Asset in use.' + [AssetError.CannotDeleteInUseAsset]: 'Cannot delete! Asset in use.', + [AssetError.NoRatesForAsset]: 'Cannot create! Exchange rates URL not defined.' } diff --git a/packages/backend/src/asset/service.test.ts b/packages/backend/src/asset/service.test.ts index 3d8e328f41..df93c6bd24 100644 --- a/packages/backend/src/asset/service.test.ts +++ b/packages/backend/src/asset/service.test.ts @@ -9,8 +9,8 @@ import { Pagination, SortOrder } from '../shared/baseModel' import { getPageTests } from '../shared/baseModel.test' import { createTestApp, TestContainer } from '../tests/app' import { createAsset, randomAsset } from '../tests/asset' -import { truncateTables } from '../tests/tableManager' -import { Config } from '../config/app' +import { truncateTable, truncateTables } from '../tests/tableManager' +import { Config, IAppConfig } from '../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' @@ -21,6 +21,11 @@ import { isWalletAddressError } from '../open_payments/wallet_address/errors' import { PeerService } from '../payment-method/ilp/peer/service' import { isPeerError } from '../payment-method/ilp/peer/errors' import { CacheDataStore } from '../middleware/cache/data-stores' +import { + CreateOptions, + TenantSettingService +} from '../tenants/settings/service' +import { exchangeRatesSetting } from '../tests/tenantSettings' describe('Asset Service', (): void => { let deps: IocContract @@ -28,15 +33,36 @@ describe('Asset Service', (): void => { let assetService: AssetService let peerService: PeerService let walletAddressService: WalletAddressService + let tenantSettingService: TenantSettingService + let config: IAppConfig beforeAll(async (): Promise => { deps = initIocContainer(Config) appContainer = await createTestApp(deps) + config = await deps.use('config') assetService = await deps.use('assetService') walletAddressService = await deps.use('walletAddressService') + tenantSettingService = await deps.use('tenantSettingService') peerService = await deps.use('peerService') }) + beforeEach(async (): Promise => { + const createOptions: CreateOptions = { + tenantId: Config.operatorTenantId, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = await tenantSettingService.create(createOptions) + + expect(tenantSetting).toEqual([ + expect.objectContaining({ + tenantId: Config.operatorTenantId, + key: createOptions.setting[0].key, + value: createOptions.setting[0].value + }) + ]) + }) + afterEach(async (): Promise => { await truncateTables(deps) }) @@ -125,6 +151,25 @@ describe('Asset Service', (): void => { ) }) + test('Cannot create more than one asset if no exchange rates URL is set', async (): Promise => { + await truncateTable(appContainer.knex, 'tenantSettings') + config.operatorExchangeRatesUrl = undefined + const firstAssetOptions = { + ...randomAsset(), + tenantId: Config.operatorTenantId + } + await expect( + assetService.create(firstAssetOptions) + ).resolves.toMatchObject(firstAssetOptions) + const secondAssetOptions = { + ...randomAsset(), + tenantId: Config.operatorTenantId + } + await expect(assetService.create(secondAssetOptions)).resolves.toEqual( + AssetError.NoRatesForAsset + ) + }) + test('Cannot create asset with scale > 255', async (): Promise => { const options = { code: 'ABC', diff --git a/packages/backend/src/asset/service.ts b/packages/backend/src/asset/service.ts index 9d68d1f4f7..96b429294d 100644 --- a/packages/backend/src/asset/service.ts +++ b/packages/backend/src/asset/service.ts @@ -8,6 +8,9 @@ import { AccountingService, LiquidityAccountType } from '../accounting/service' import { WalletAddress } from '../open_payments/wallet_address/model' import { Peer } from '../payment-method/ilp/peer/model' import { CacheDataStore } from '../middleware/cache/data-stores' +import { TenantSettingService } from '../tenants/settings/service' +import { TenantSettingKeys } from '../tenants/settings/model' +import { IAppConfig } from '../config/app' export interface AssetOptions { code: string @@ -56,14 +59,18 @@ export interface AssetService { } interface ServiceDependencies extends BaseService { + config: IAppConfig accountingService: AccountingService + tenantSettingService: TenantSettingService assetCache: CacheDataStore } export async function createAssetService({ + config, logger, knex, accountingService, + tenantSettingService, assetCache }: ServiceDependencies): Promise { const log = logger.child({ @@ -71,9 +78,11 @@ export async function createAssetService({ }) const deps: ServiceDependencies = { + config, logger: log, knex, accountingService, + tenantSettingService, assetCache } @@ -99,13 +108,27 @@ async function createAsset( }: CreateOptions ): Promise { try { - // check if exists but deleted | by code-scale - const deletedAsset = await Asset.query(deps.knex) - .whereNotNull('deletedAt') - .where('code', code) - .andWhere('scale', scale) + const assets = await Asset.query(deps.knex) .andWhere('tenantId', tenantId) - .first() + .select('*') + + const sameCodeAssets = assets.find((asset) => asset.code === code) + if (!sameCodeAssets && assets.length > 0) { + const exchangeUrlSetting = await deps.tenantSettingService.get({ + tenantId, + key: TenantSettingKeys.EXCHANGE_RATES_URL.name + }) + + const tenantExchangeRatesUrl = exchangeUrlSetting[0]?.value + if (!tenantExchangeRatesUrl && !deps.config.operatorExchangeRatesUrl) { + return AssetError.NoRatesForAsset + } + } + + const deletedAsset = assets.find( + (asset) => + asset.deletedAt !== null && asset.code === code && asset.scale === scale + ) if (deletedAsset) { // if found, enable diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 0834b5713d..238b8e655e 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -115,9 +115,8 @@ export const Config = { 5 ), - exchangeRatesUrl: process.env.EXCHANGE_RATES_URL, // optional exchangeRatesLifetime: +(process.env.EXCHANGE_RATES_LIFETIME || 15_000), - + operatorExchangeRatesUrl: process.env.EXCHANGE_RATES_URL, // optional slippage: envFloat('SLIPPAGE', 0.01), quoteLifespan: envInt('QUOTE_LIFESPAN', 5 * 60_000), // milliseconds diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 922f375c52..d84b7c2c7b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -253,16 +253,20 @@ export function initIocContainer( const config = await deps.use('config') return createRatesService({ logger: await deps.use('logger'), - exchangeRatesUrl: config.exchangeRatesUrl, - exchangeRatesLifetime: config.exchangeRatesLifetime + operatorTenantId: config.operatorTenantId, + operatorExchangeRatesUrl: config.operatorExchangeRatesUrl, + exchangeRatesLifetime: config.exchangeRatesLifetime, + tenantSettingService: await deps.use('tenantSettingService') }) }) container.singleton('internalRatesService', async (deps) => { return createRatesService({ logger: await deps.use('logger'), - exchangeRatesUrl: config.telemetryExchangeRatesUrl, - exchangeRatesLifetime: config.telemetryExchangeRatesLifetime + operatorTenantId: config.operatorTenantId, + operatorExchangeRatesUrl: config.telemetryExchangeRatesUrl, + exchangeRatesLifetime: config.telemetryExchangeRatesLifetime, + tenantSettingService: await deps.use('tenantSettingService') }) }) @@ -330,10 +334,13 @@ export function initIocContainer( container.singleton('assetService', async (deps) => { const logger = await deps.use('logger') const knex = await deps.use('knex') + const config = await deps.use('config') return await createAssetService({ + config: config, logger: logger, knex: knex, accountingService: await deps.use('accountingService'), + tenantSettingService: await deps.use('tenantSettingService'), assetCache: await deps.use('assetCache') }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts index 4f0034839a..ec9d87782a 100644 --- a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts +++ b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts @@ -117,6 +117,7 @@ export async function handleSending( 'transaction_fee_amounts', payment.sentAmount, payment.receiveAmount, + payment.tenantId, { description: 'Amount sent through the network as fees', valueType: ValueType.DOUBLE diff --git a/packages/backend/src/open_payments/payment/outgoing/service.test.ts b/packages/backend/src/open_payments/payment/outgoing/service.test.ts index 4329933f3e..ae6e86fd54 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -55,6 +55,11 @@ import { getPageTests } from '../../../shared/baseModel.test' import { Pagination, SortOrder } from '../../../shared/baseModel' import { ReceiverService } from '../../receiver/service' import { WalletAddressService } from '../../wallet_address/service' +import { CreateOptions } from '../../../tenants/settings/service' +import { + createTenantSettings, + exchangeRatesSetting +} from '../../../tests/tenantSettings' describe('OutgoingPaymentService', (): void => { let deps: IocContract @@ -248,15 +253,8 @@ describe('OutgoingPaymentService', (): void => { } beforeAll(async (): Promise => { - const exchangeRatesUrl = 'https://test.rates' - - mockRatesApi(exchangeRatesUrl, () => ({ - XRP: exchangeRate - })) - deps = await initIocContainer({ ...Config, - exchangeRatesUrl, enableTelemetry: true, localCacheDuration: 0 }) @@ -274,6 +272,16 @@ describe('OutgoingPaymentService', (): void => { beforeEach(async (): Promise => { tenantId = config.operatorTenantId + const createOptions: CreateOptions = { + tenantId, + setting: [exchangeRatesSetting()] + } + const tenantSetting = createTenantSettings(deps, createOptions) + const tenantExchangeRatesUrl = (await tenantSetting).value + mockRatesApi(tenantExchangeRatesUrl, () => ({ + XRP: exchangeRate + })) + const { id: sendAssetId } = await createAsset(deps, asset) assetId = sendAssetId const walletAddress = await createWalletAddress(deps, { diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts index 5023b3d6cd..7574ff6bdf 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts @@ -40,11 +40,14 @@ export function createBalanceMiddleware(): ILPMiddleware { } const sourceAmount = BigInt(amount) - const destinationAmountOrError = await services.rates.convertSource({ - sourceAmount, - sourceAsset: accounts.incoming.asset, - destinationAsset: accounts.outgoing.asset - }) + const destinationAmountOrError = await services.rates.convertSource( + { + sourceAmount, + sourceAsset: accounts.incoming.asset, + destinationAsset: accounts.outgoing.asset + }, + accounts.outgoing.tenantId + ) if (isConvertError(destinationAmountOrError)) { logger.error( { diff --git a/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts b/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts index 6740d21886..1a0384ded4 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts @@ -36,6 +36,7 @@ import { // ../../peer/model export interface ConnectorAccount extends LiquidityAccount { asset: LiquidityAccount['asset'] & AssetOptions + tenantId: string } export interface IncomingAccount extends ConnectorAccount { @@ -205,7 +206,8 @@ export class Rafiki { response, code, scale, - telemetry + telemetry, + sourceAccount.tenantId ) return response.rawReply } diff --git a/packages/backend/src/payment-method/ilp/connector/core/telemetry.ts b/packages/backend/src/payment-method/ilp/connector/core/telemetry.ts index 6c7a17031e..9cd401971a 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/telemetry.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/telemetry.ts @@ -39,7 +39,8 @@ export async function incrementAmount( response: IlpResponse, code: string, scale: number, - telemetry: TelemetryService + telemetry: TelemetryService, + tenantId?: string ): Promise { if (!unfulfillable && Number(prepareAmount) && response.fulfill) { const value = BigInt(prepareAmount) @@ -50,6 +51,7 @@ export async function incrementAmount( assetCode: code, assetScale: scale }, + tenantId, { description: 'Amount sent through the network', valueType: ValueType.DOUBLE diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/telemetry.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/telemetry.test.ts index f3ab472683..6fad868724 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/test/telemetry.test.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/test/telemetry.test.ts @@ -176,7 +176,12 @@ describe('Connector Core Telemetry', () => { telemetryService ) - expect(incrementCounterSpy).toHaveBeenCalledWith(name, amount, attributes) + expect(incrementCounterSpy).toHaveBeenCalledWith( + name, + amount, + undefined, + attributes + ) }) it('incrementAmount should not increment when the prepare is unfulfillable', () => { diff --git a/packages/backend/src/payment-method/ilp/service.test.ts b/packages/backend/src/payment-method/ilp/service.test.ts index 29f380ba61..d3ae43db7e 100644 --- a/packages/backend/src/payment-method/ilp/service.test.ts +++ b/packages/backend/src/payment-method/ilp/service.test.ts @@ -27,6 +27,11 @@ import { truncateTables } from '../../tests/tableManager' import { createOutgoingPaymentWithReceiver } from '../../tests/outgoingPayment' import { v4 as uuid } from 'uuid' import { IlpQuoteDetails } from './quote-details/model' +import { CreateOptions } from '../../tenants/settings/service' +import { + createTenantSettings, + exchangeRatesSetting +} from '../../tests/tenantSettings' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -38,7 +43,7 @@ describe('IlpPaymentService', (): void => { let config: IAppConfig let tenantId: string - const exchangeRatesUrl = 'https://example-rates.com' + let tenantExchangeRatesUrl: string const assetMap: Record = {} const walletAddressMap: Record = {} @@ -46,7 +51,6 @@ describe('IlpPaymentService', (): void => { beforeAll(async (): Promise => { deps = initIocContainer({ ...Config, - exchangeRatesUrl, exchangeRatesLifetime: 0 }) appContainer = await createTestApp(deps) @@ -77,6 +81,14 @@ describe('IlpPaymentService', (): void => { tenantId, assetId: assetMap['EUR'].id }) + + const createOptions: CreateOptions = { + tenantId, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = createTenantSettings(deps, createOptions) + tenantExchangeRatesUrl = (await tenantSetting).value }) afterEach(async (): Promise => { @@ -95,7 +107,7 @@ describe('IlpPaymentService', (): void => { describe('getQuote', (): void => { test('calls rates service with correct base asset', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { quoteId: uuid(), @@ -113,12 +125,12 @@ describe('IlpPaymentService', (): void => { await ilpPaymentService.getQuote(options) - expect(ratesServiceSpy).toHaveBeenCalledWith('USD') + expect(ratesServiceSpy).toHaveBeenCalledWith('USD', tenantId) ratesScope.done() }) test('inserts ilpQuoteDetails', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const quoteId = uuid() const options: StartQuoteOptions = { quoteId, @@ -180,7 +192,7 @@ describe('IlpPaymentService', (): void => { }) test('creates a quote with large exchange rate amounts', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const quoteId = uuid() const options: StartQuoteOptions = { quoteId, @@ -298,7 +310,7 @@ describe('IlpPaymentService', (): void => { }) test('returns all fields correctly', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { quoteId: uuid(), @@ -330,7 +342,7 @@ describe('IlpPaymentService', (): void => { }) test('uses receiver.incomingAmount if receiveAmount is not provided', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const incomingAmount = { assetCode: 'USD', @@ -370,7 +382,7 @@ describe('IlpPaymentService', (): void => { () => config, { slippage: 101 }, async () => { - mockRatesApi(exchangeRatesUrl, () => ({})) + mockRatesApi(tenantExchangeRatesUrl, () => ({})) expect.assertions(4) try { @@ -398,7 +410,7 @@ describe('IlpPaymentService', (): void => { )()) test('throws if quote returns invalid maxSourceAmount', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { quoteId: uuid(), @@ -428,7 +440,7 @@ describe('IlpPaymentService', (): void => { }) test('throws if quote returns invalid minDeliveryAmount', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { quoteId: uuid(), @@ -469,7 +481,7 @@ describe('IlpPaymentService', (): void => { }) test('throws if quote returns with a non-positive estimated delivery amount', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { quoteId: uuid(), @@ -524,7 +536,7 @@ describe('IlpPaymentService', (): void => { () => config, { slippage }, async () => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ [incomingAssetCode]: exchangeRate })) @@ -584,7 +596,7 @@ describe('IlpPaymentService', (): void => { () => config, { slippage }, async () => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ [incomingAssetCode]: exchangeRate })) diff --git a/packages/backend/src/payment-method/ilp/service.ts b/packages/backend/src/payment-method/ilp/service.ts index 017ab8bdf2..146534675e 100644 --- a/packages/backend/src/payment-method/ilp/service.ts +++ b/packages/backend/src/payment-method/ilp/service.ts @@ -64,7 +64,10 @@ async function getQuote( let rates try { - rates = await deps.ratesService.rates(options.walletAddress.asset.code) + rates = await deps.ratesService.rates( + options.walletAddress.asset.code, + options.walletAddress.tenantId + ) } catch (_err) { throw new PaymentMethodHandlerError('Received error during ILP quoting', { description: 'Could not get rates from service', diff --git a/packages/backend/src/payment-method/local/service.test.ts b/packages/backend/src/payment-method/local/service.test.ts index 4729d2d973..b4cef7bdd6 100644 --- a/packages/backend/src/payment-method/local/service.test.ts +++ b/packages/backend/src/payment-method/local/service.test.ts @@ -24,6 +24,11 @@ import { IncomingPaymentService } from '../../open_payments/payment/incoming/ser import { errorToMessage, TransferError } from '../../accounting/errors' import { PaymentMethodHandlerError } from '../handler/errors' import { ConvertError } from '../../rates/service' +import { + createTenantSettings, + exchangeRatesSetting +} from '../../tests/tenantSettings' +import { CreateOptions } from '../../tenants/settings/service' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -35,7 +40,7 @@ describe('LocalPaymentService', (): void => { let incomingPaymentService: IncomingPaymentService let tenantId: string - const exchangeRatesUrl = 'https://example-rates.com' + let tenantExchangeRatesUrl: string const assetMap: Record = {} const walletAddressMap: Record = {} @@ -43,7 +48,6 @@ describe('LocalPaymentService', (): void => { beforeAll(async (): Promise => { deps = initIocContainer({ ...Config, - exchangeRatesUrl, exchangeRatesLifetime: 0 }) appContainer = await createTestApp(deps) @@ -84,6 +88,14 @@ describe('LocalPaymentService', (): void => { tenantId, assetId: assetMap['EUR'].id }) + + const createOptions: CreateOptions = { + tenantId, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = createTenantSettings(deps, createOptions) + tenantExchangeRatesUrl = (await tenantSetting).value }) afterEach(async (): Promise => { @@ -290,7 +302,7 @@ describe('LocalPaymentService', (): void => { let ratesScope if (incomingAssetCode !== debitAssetCode) { - ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ [incomingAssetCode]: exchangeRate })) } @@ -346,7 +358,7 @@ describe('LocalPaymentService', (): void => { let ratesScope if (debitAssetCode !== incomingAssetCode) { - ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ [incomingAssetCode]: exchangeRate })) } diff --git a/packages/backend/src/payment-method/local/service.ts b/packages/backend/src/payment-method/local/service.ts index 9521b00cef..0fe0b24c3d 100644 --- a/packages/backend/src/payment-method/local/service.ts +++ b/packages/backend/src/payment-method/local/service.ts @@ -67,14 +67,15 @@ async function getQuote( let exchangeRate: number const convert = async ( - opts: RateConvertSourceOpts | RateConvertDestinationOpts + opts: RateConvertSourceOpts | RateConvertDestinationOpts, + tenantId?: string ): Promise => { let convertResults: ConvertResults | ConvertError try { convertResults = 'sourceAmount' in opts - ? await deps.ratesService.convertSource(opts) - : await deps.ratesService.convertDestination(opts) + ? await deps.ratesService.convertSource(opts, tenantId) + : await deps.ratesService.convertDestination(opts, tenantId) } catch (err) { deps.logger.error( { opts, err }, @@ -93,17 +94,20 @@ async function getQuote( if (debitAmount) { debitAmountValue = debitAmount.value - const convertResults = await convert({ - sourceAmount: debitAmountValue, - sourceAsset: { - code: walletAddress.asset.code, - scale: walletAddress.asset.scale + const convertResults = await convert( + { + sourceAmount: debitAmountValue, + sourceAsset: { + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + destinationAsset: { + code: receiver.assetCode, + scale: receiver.assetScale + } }, - destinationAsset: { - code: receiver.assetCode, - scale: receiver.assetScale - } - }) + options.walletAddress.tenantId + ) if (isConvertError(convertResults)) { throw new PaymentMethodHandlerError( 'Received error during local quoting', @@ -117,17 +121,20 @@ async function getQuote( exchangeRate = convertResults.scaledExchangeRate } else if (receiveAmount) { receiveAmountValue = receiveAmount.value - const convertResults = await convert({ - destinationAmount: receiveAmountValue, - sourceAsset: { - code: walletAddress.asset.code, - scale: walletAddress.asset.scale + const convertResults = await convert( + { + destinationAmount: receiveAmountValue, + sourceAsset: { + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + destinationAsset: { + code: receiveAmount.assetCode, + scale: receiveAmount.assetScale + } }, - destinationAsset: { - code: receiveAmount.assetCode, - scale: receiveAmount.assetScale - } - }) + options.walletAddress.tenantId + ) if (isConvertError(convertResults)) { throw new PaymentMethodHandlerError( 'Received error during local quoting', @@ -141,17 +148,20 @@ async function getQuote( exchangeRate = convertResults.scaledExchangeRate } else if (receiver.incomingAmount) { receiveAmountValue = receiver.incomingAmount.value - const convertResults = await convert({ - destinationAmount: receiveAmountValue, - sourceAsset: { - code: walletAddress.asset.code, - scale: walletAddress.asset.scale + const convertResults = await convert( + { + destinationAmount: receiveAmountValue, + sourceAsset: { + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + destinationAsset: { + code: receiver.incomingAmount.assetCode, + scale: receiver.incomingAmount.assetScale + } }, - destinationAsset: { - code: receiver.incomingAmount.assetCode, - scale: receiver.incomingAmount.assetScale - } - }) + options.walletAddress.tenantId + ) if (isConvertError(convertResults)) { throw new PaymentMethodHandlerError( 'Received error during local quoting', diff --git a/packages/backend/src/rates/service.test.ts b/packages/backend/src/rates/service.test.ts index 5107a30cb9..42880c2ca9 100644 --- a/packages/backend/src/rates/service.test.ts +++ b/packages/backend/src/rates/service.test.ts @@ -7,16 +7,23 @@ import { AppServices } from '../app' import { CacheDataStore } from '../middleware/cache/data-stores' import { mockRatesApi } from '../tests/rates' import { AxiosInstance } from 'axios' +import { + createTenantSettings, + exchangeRatesSetting +} from '../tests/tenantSettings' +import { CreateOptions } from '../tenants/settings/service' const nock = (global as unknown as { nock: typeof import('nock') }).nock describe('Rates service', function () { + let tenantId: string let deps: IocContract let appContainer: TestContainer let service: RatesService let apiRequestCount = 0 const exchangeRatesLifetime = 100 - const exchangeRatesUrl = 'http://example-rates.com' + + let tenantExchangeRatesUrl: string const exampleRates = { USD: { @@ -36,9 +43,18 @@ describe('Rates service', function () { beforeAll(async (): Promise => { deps = initIocContainer({ ...Config, - exchangeRatesUrl, exchangeRatesLifetime }) + tenantId = Config.operatorTenantId + const createOptions: CreateOptions = { + tenantId: Config.operatorTenantId, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = createTenantSettings(deps, createOptions) + tenantExchangeRatesUrl = (await tenantSetting).value + + expect(tenantExchangeRatesUrl).not.toBe(undefined) appContainer = await createTestApp(deps) service = await deps.use('ratesService') @@ -46,7 +62,7 @@ describe('Rates service', function () { beforeEach(async (): Promise => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;((service as any).cachedRates as CacheDataStore).deleteAll() + ;((service as any).cache as CacheDataStore).deleteAll() apiRequestCount = 0 }) @@ -61,7 +77,7 @@ describe('Rates service', function () { describe('convertSource', () => { beforeAll(() => { - mockRatesApi(exchangeRatesUrl, (base) => { + mockRatesApi(tenantExchangeRatesUrl, (base) => { apiRequestCount++ return exampleRates[base as keyof typeof exampleRates] }) @@ -76,11 +92,14 @@ describe('Rates service', function () { it('returns the source amount when assets are alike', async () => { await expect( - service.convertSource({ - sourceAmount: 1234n, - sourceAsset: { code: 'USD', scale: 9 }, - destinationAsset: { code: 'USD', scale: 9 } - }) + service.convertSource( + { + sourceAmount: 1234n, + sourceAsset: { code: 'USD', scale: 9 }, + destinationAsset: { code: 'USD', scale: 9 } + }, + tenantId + ) ).resolves.toEqual({ amount: 1234n, scaledExchangeRate: 1 @@ -90,21 +109,27 @@ describe('Rates service', function () { it('scales the source amount when currencies are alike but scales are different', async () => { await expect( - service.convertSource({ - sourceAmount: 123n, - sourceAsset: { code: 'USD', scale: 9 }, - destinationAsset: { code: 'USD', scale: 12 } - }) + service.convertSource( + { + sourceAmount: 123n, + sourceAsset: { code: 'USD', scale: 9 }, + destinationAsset: { code: 'USD', scale: 12 } + }, + tenantId + ) ).resolves.toEqual({ amount: 123_000n, scaledExchangeRate: 1000 }) await expect( - service.convertSource({ - sourceAmount: 123456n, - sourceAsset: { code: 'USD', scale: 12 }, - destinationAsset: { code: 'USD', scale: 9 } - }) + service.convertSource( + { + sourceAmount: 123456n, + sourceAsset: { code: 'USD', scale: 12 }, + destinationAsset: { code: 'USD', scale: 9 } + }, + tenantId + ) ).resolves.toEqual({ amount: 123n, scaledExchangeRate: 0.001 @@ -115,21 +140,27 @@ describe('Rates service', function () { it('returns the converted amount when assets are different', async () => { const sourceAmount = 500 await expect( - service.convertSource({ - sourceAmount: BigInt(sourceAmount), - sourceAsset: { code: 'USD', scale: 2 }, - destinationAsset: { code: 'EUR', scale: 2 } - }) + service.convertSource( + { + sourceAmount: BigInt(sourceAmount), + sourceAsset: { code: 'USD', scale: 2 }, + destinationAsset: { code: 'EUR', scale: 2 } + }, + tenantId + ) ).resolves.toEqual({ amount: BigInt(sourceAmount * exampleRates.USD.EUR), scaledExchangeRate: exampleRates.USD.EUR }) await expect( - service.convertSource({ - sourceAmount: BigInt(sourceAmount), - sourceAsset: { code: 'EUR', scale: 2 }, - destinationAsset: { code: 'USD', scale: 2 } - }) + service.convertSource( + { + sourceAmount: BigInt(sourceAmount), + sourceAsset: { code: 'EUR', scale: 2 }, + destinationAsset: { code: 'USD', scale: 2 } + }, + tenantId + ) ).resolves.toEqual({ amount: BigInt(sourceAmount * exampleRates.EUR.USD), scaledExchangeRate: exampleRates.EUR.USD @@ -138,32 +169,41 @@ describe('Rates service', function () { it('returns an error when an asset price is invalid', async () => { await expect( - service.convertSource({ - sourceAmount: 1234n, - sourceAsset: { code: 'USD', scale: 2 }, - destinationAsset: { code: 'MISSING', scale: 2 } - }) + service.convertSource( + { + sourceAmount: 1234n, + sourceAsset: { code: 'USD', scale: 2 }, + destinationAsset: { code: 'MISSING', scale: 2 } + }, + tenantId + ) ).resolves.toBe(ConvertError.InvalidDestinationPrice) await expect( - service.convertSource({ - sourceAmount: 1234n, - sourceAsset: { code: 'USD', scale: 2 }, - destinationAsset: { code: 'ZERO', scale: 2 } - }) + service.convertSource( + { + sourceAmount: 1234n, + sourceAsset: { code: 'USD', scale: 2 }, + destinationAsset: { code: 'ZERO', scale: 2 } + }, + tenantId + ) ).resolves.toBe(ConvertError.InvalidDestinationPrice) await expect( - service.convertSource({ - sourceAmount: 1234n, - sourceAsset: { code: 'USD', scale: 2 }, - destinationAsset: { code: 'NEGATIVE', scale: 2 } - }) + service.convertSource( + { + sourceAmount: 1234n, + sourceAsset: { code: 'USD', scale: 2 }, + destinationAsset: { code: 'NEGATIVE', scale: 2 } + }, + tenantId + ) ).resolves.toBe(ConvertError.InvalidDestinationPrice) }) }) describe('rates', function () { beforeAll(() => { - mockRatesApi(exchangeRatesUrl, (base) => { + mockRatesApi(tenantExchangeRatesUrl, (base) => { apiRequestCount++ return exampleRates[base as keyof typeof exampleRates] }) @@ -196,9 +236,9 @@ describe('Rates service', function () { it('handles concurrent requests for same asset code', async () => { await expect( Promise.all([ - service.rates('USD'), - service.rates('USD'), - service.rates('USD') + service.rates('USD', tenantId), + service.rates('USD', tenantId), + service.rates('USD', tenantId) ]) ).resolves.toEqual([usdRates, usdRates, usdRates]) expect(apiRequestCount).toBe(1) @@ -207,33 +247,33 @@ describe('Rates service', function () { it('handles concurrent requests for different asset codes', async () => { await expect( Promise.all([ - service.rates('USD'), - service.rates('USD'), - service.rates('EUR'), - service.rates('EUR') + service.rates('USD', tenantId), + service.rates('USD', tenantId), + service.rates('EUR', tenantId), + service.rates('EUR', tenantId) ]) ).resolves.toEqual([usdRates, usdRates, eurRates, eurRates]) expect(apiRequestCount).toBe(2) }) it('returns cached request for same asset code', async () => { - await expect(service.rates('USD')).resolves.toEqual(usdRates) - await expect(service.rates('USD')).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) expect(apiRequestCount).toBe(1) }) it('returns cached request for different asset codes', async () => { - await expect(service.rates('USD')).resolves.toEqual(usdRates) - await expect(service.rates('USD')).resolves.toEqual(usdRates) - await expect(service.rates('EUR')).resolves.toEqual(eurRates) - await expect(service.rates('EUR')).resolves.toEqual(eurRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) + await expect(service.rates('EUR', tenantId)).resolves.toEqual(eurRates) + await expect(service.rates('EUR', tenantId)).resolves.toEqual(eurRates) expect(apiRequestCount).toBe(2) }) it('returns new rates after cache expires', async () => { - await expect(service.rates('USD')).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) jest.advanceTimersByTime(exchangeRatesLifetime + 1) - await expect(service.rates('USD')).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) expect(apiRequestCount).toBe(2) }) @@ -248,10 +288,10 @@ describe('Rates service', function () { throw new Error() }) - await expect(service.rates('USD')).rejects.toThrow( + await expect(service.rates('USD', tenantId)).rejects.toThrow( 'Could not fetch rates' ) - await expect(service.rates('USD')).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) expect(apiRequestCount).toBe(2) }) }) diff --git a/packages/backend/src/rates/service.ts b/packages/backend/src/rates/service.ts index 759dd0e47b..dd556d8d38 100644 --- a/packages/backend/src/rates/service.ts +++ b/packages/backend/src/rates/service.ts @@ -9,6 +9,8 @@ import { } from './util' import { createInMemoryDataStore } from '../middleware/cache/data-stores/in-memory' import { CacheDataStore } from '../middleware/cache/data-stores' +import { TenantSettingService } from '../tenants/settings/service' +import { TenantSettingKeys } from '../tenants/settings/model' const REQUEST_TIMEOUT = 5_000 // millseconds @@ -24,20 +26,27 @@ export type RateConvertDestinationOpts = Omit< > export interface RatesService { - rates(baseAssetCode: string): Promise + rates(baseAssetCode: string, tenantId?: string): Promise convertSource( - opts: RateConvertSourceOpts + opts: RateConvertSourceOpts, + tenantId?: string ): Promise convertDestination( - opts: RateConvertDestinationOpts + opts: RateConvertDestinationOpts, + tenantId?: string ): Promise } interface ServiceDependencies extends BaseService { - // If `url` is not set, the connector cannot convert between currencies. - exchangeRatesUrl?: string + readonly operatorTenantId: string + // Default exchange rates `url` of the operator. + // If tenant doesn't set a specific url in the db, this one will be used. + // In case neither tenant nor operator doesn't set an exchange url, the connector cannot convert between currencies. + operatorExchangeRatesUrl?: string // Duration (milliseconds) that the fetched rates are valid. exchangeRatesLifetime: number + // Used for getting the exchange rates URL from db. + tenantSettingService: TenantSettingService } export enum ConvertError { @@ -54,8 +63,9 @@ export function createRatesService(deps: ServiceDependencies): RatesService { class RatesServiceImpl implements RatesService { private axios: AxiosInstance - private cachedRates: CacheDataStore + private cache: CacheDataStore private inProgressRequests: Record> = {} + private readonly URL_CACHE_PREFIX = 'url:' constructor(private deps: ServiceDependencies) { this.axios = Axios.create({ @@ -63,14 +73,15 @@ class RatesServiceImpl implements RatesService { validateStatus: (status) => status === 200 }) - this.cachedRates = createInMemoryDataStore(deps.exchangeRatesLifetime) + this.cache = createInMemoryDataStore(deps.exchangeRatesLifetime) } async convert( opts: T, convertFn: ( opts: T & { exchangeRate: number } - ) => ConvertResults | ConvertError + ) => ConvertResults | ConvertError, + tenantId: string ): Promise { const { sourceAsset, destinationAsset } = opts const sameCode = sourceAsset.code === destinationAsset.code @@ -89,7 +100,7 @@ class RatesServiceImpl implements RatesService { return convertFn({ ...opts, exchangeRate: 1.0 }) } - const { rates } = await this.getRates(sourceAsset.code) + const { rates } = await this.getRates(sourceAsset.code, tenantId) const destinationExchangeRate = rates[destinationAsset.code] if (!destinationExchangeRate || !isValidPrice(destinationExchangeRate)) { return ConvertError.InvalidDestinationPrice @@ -99,42 +110,59 @@ class RatesServiceImpl implements RatesService { } async convertSource( - opts: RateConvertSourceOpts + opts: RateConvertSourceOpts, + tenantId?: string ): Promise { - return this.convert(opts, convertSource) + return this.convert( + opts, + convertSource, + tenantId ?? this.deps.operatorTenantId + ) } async convertDestination( - opts: RateConvertDestinationOpts + opts: RateConvertDestinationOpts, + tenantId?: string ): Promise { - return this.convert(opts, convertDestination) + return this.convert( + opts, + convertDestination, + tenantId ?? this.deps.operatorTenantId + ) } - async rates(baseAssetCode: string): Promise { - return this.getRates(baseAssetCode) + async rates(baseAssetCode: string, tenantId?: string): Promise { + return this.getRates(baseAssetCode, tenantId ?? this.deps.operatorTenantId) } - private async getRates(baseAssetCode: string): Promise { - const cachedRate = await this.cachedRates.get(baseAssetCode) + private async getRates( + baseAssetCode: string, + tenantId: string + ): Promise { + const ratesCacheKey = `${tenantId}:${baseAssetCode}` + const cachedRate = await this.cache.get(ratesCacheKey) if (cachedRate) { return JSON.parse(cachedRate) } - return await this.fetchNewRatesAndCache(baseAssetCode) + return await this.fetchNewRatesAndCache(baseAssetCode, tenantId) } - private async fetchNewRatesAndCache(baseAssetCode: string): Promise { - const inProgressRequest = this.inProgressRequests[baseAssetCode] - - if (!inProgressRequest) { - this.inProgressRequests[baseAssetCode] = this.fetchNewRates(baseAssetCode) + private async fetchNewRatesAndCache( + baseAssetCode: string, + tenantId: string + ): Promise { + const ratesCacheKey = `${tenantId}:${baseAssetCode}` + if (this.inProgressRequests[ratesCacheKey] === undefined) { + this.inProgressRequests[ratesCacheKey] = this.fetchNewRates( + baseAssetCode, + tenantId + ) } - try { - const rates = await this.inProgressRequests[baseAssetCode] - - await this.cachedRates.set(baseAssetCode, JSON.stringify(rates)) + const rates = await this.inProgressRequests[ratesCacheKey] + await this.cache.set(ratesCacheKey, JSON.stringify(rates)) return rates } catch (err) { const errorMessage = 'Could not fetch rates' @@ -148,19 +176,23 @@ class RatesServiceImpl implements RatesService { errorStatus: err.status } : { err }), - url: this.deps.exchangeRatesUrl + baseAssetCode }, errorMessage ) throw new Error(errorMessage) } finally { - delete this.inProgressRequests[baseAssetCode] + delete this.inProgressRequests[ratesCacheKey] } } - private async fetchNewRates(baseAssetCode: string): Promise { - const url = this.deps.exchangeRatesUrl + private async fetchNewRates( + baseAssetCode: string, + tenantId: string + ): Promise { + const url = await this.getExchangeRatesUrl(tenantId) + if (!url) { return { base: baseAssetCode, rates: {} } } @@ -175,6 +207,37 @@ class RatesServiceImpl implements RatesService { return { base, rates } } + private async getExchangeRatesUrl( + tenantId: string + ): Promise { + const urlCacheKey = `${this.URL_CACHE_PREFIX}${tenantId}` + const cachedUrl = await this.cache.get(urlCacheKey) + if (cachedUrl) { + return cachedUrl + } + + try { + const exchangeUrlSetting = await this.deps.tenantSettingService.get({ + tenantId, + key: TenantSettingKeys.EXCHANGE_RATES_URL.name + }) + + const tenantExchangeRatesUrl = exchangeUrlSetting[0]?.value + if (!tenantExchangeRatesUrl) { + return this.deps.operatorExchangeRatesUrl + } + + await this.cache.set(urlCacheKey, tenantExchangeRatesUrl) + + return tenantExchangeRatesUrl + } catch (error) { + this.deps.logger.error( + { error }, + 'Failed to get exchange rates URL from database' + ) + } + } + private checkBaseAsset(asset: unknown): void { let errorMessage: string | undefined if (!asset) { diff --git a/packages/backend/src/telemetry/service.test.ts b/packages/backend/src/telemetry/service.test.ts index c599b2d335..7ad67261b4 100644 --- a/packages/backend/src/telemetry/service.test.ts +++ b/packages/backend/src/telemetry/service.test.ts @@ -14,6 +14,11 @@ import { Counter, Histogram } from '@opentelemetry/api' import { privacy } from './privacy' import { mockRatesApi } from '../tests/rates' import { ConvertResults } from '../rates/util' +import { + createTenantSettings, + exchangeRatesSetting +} from '../tests/tenantSettings' +import { CreateOptions } from '../tenants/settings/service' jest.mock('@opentelemetry/api', () => ({ ...jest.requireActual('@opentelemetry/api'), @@ -36,7 +41,7 @@ jest.mock('@opentelemetry/sdk-metrics', () => ({ })) describe('Telemetry Service', () => { - describe('Telemtry Enabled', () => { + describe('Telemetry Enabled', () => { let deps: IocContract let appContainer: TestContainer let telemetryService: TelemetryService @@ -44,7 +49,8 @@ describe('Telemetry Service', () => { let internalRatesService: RatesService let apiRequestCount = 0 - const exchangeRatesUrl = 'http://example-rates.com' + + const tenantId = Config.operatorTenantId const exampleRates = { USD: { @@ -59,7 +65,6 @@ describe('Telemetry Service', () => { deps = initIocContainer({ ...Config, enableTelemetry: true, - telemetryExchangeRatesUrl: 'http://example-rates.com', telemetryExchangeRatesLifetime: 100, openTelemetryCollectors: [] }) @@ -69,7 +74,15 @@ describe('Telemetry Service', () => { aseRatesService = await deps.use('ratesService') internalRatesService = await deps.use('internalRatesService') - mockRatesApi(exchangeRatesUrl, (base) => { + const createOptions: CreateOptions = { + tenantId, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = createTenantSettings(deps, createOptions) + const operatorExchangeRatesUrl = (await tenantSetting).value + + mockRatesApi(operatorExchangeRatesUrl, (base) => { apiRequestCount++ return exampleRates[base as keyof typeof exampleRates] }) @@ -166,7 +179,8 @@ describe('Telemetry Service', () => { value: 100n, assetCode: 'USD', assetScale: 2 - } + }, + tenantId ) expect(spyAseConvert).toHaveBeenCalled() @@ -245,7 +259,8 @@ describe('Telemetry Service', () => { expect.objectContaining({ sourceAmount: source.value, sourceAsset: { code: source.assetCode, scale: source.assetScale } - }) + }), + undefined ) expect(spyConvert).toHaveBeenNthCalledWith( 2, @@ -255,7 +270,8 @@ describe('Telemetry Service', () => { code: destination.assetCode, scale: destination.assetScale } - }) + }), + undefined ) // Ensure the [incrementCounter] was called with the correct calculated value. Expected 5000 due to scale = 4. expect(spyIncCounter).toHaveBeenCalledWith(name, 5000, {}) @@ -289,7 +305,8 @@ describe('Telemetry Service', () => { expect.objectContaining({ sourceAmount: source.value, sourceAsset: { code: source.assetCode, scale: source.assetScale } - }) + }), + undefined ) expect(spyConvert).toHaveBeenNthCalledWith( 2, @@ -299,7 +316,8 @@ describe('Telemetry Service', () => { code: destination.assetCode, scale: destination.assetScale } - }) + }), + undefined ) expect(spyIncCounter).toHaveBeenCalledWith(name, 4400, {}) expect(apiRequestCount).toBe(1) @@ -382,8 +400,7 @@ describe('Telemetry Service', () => { value: 100n, assetCode: 'USD', assetScale: 2 - }, - undefined + } ) expect(applyPrivacySpy).toHaveBeenCalledWith(Number(convertedAmount)) @@ -417,6 +434,7 @@ describe('Telemetry Service', () => { assetScale: 2 }, undefined, + undefined, false ) @@ -480,6 +498,7 @@ describe('Telemetry Service', () => { assetCode: 'USD', assetScale: 2 }, + undefined, { attribute: 'metric attribute' } ) diff --git a/packages/backend/src/telemetry/service.ts b/packages/backend/src/telemetry/service.ts index 0075845ec4..3c80719a7d 100644 --- a/packages/backend/src/telemetry/service.ts +++ b/packages/backend/src/telemetry/service.ts @@ -1,6 +1,5 @@ import { Counter, Histogram, MetricOptions, metrics } from '@opentelemetry/api' import { MeterProvider } from '@opentelemetry/sdk-metrics' - import { RatesService, isConvertError } from '../rates/service' import { ConvertSourceOptions } from '../rates/util' import { BaseService } from '../shared/baseService' @@ -16,6 +15,7 @@ export interface TelemetryService { incrementCounterWithTransactionAmount( name: string, amount: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes?: Record, preservePrivacy?: boolean ): Promise @@ -23,6 +23,7 @@ export interface TelemetryService { name: string, amountSource: { value: bigint; assetCode: string; assetScale: number }, amountDestination: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes?: Record ): Promise recordHistogram( @@ -113,30 +114,37 @@ export class TelemetryServiceImpl implements TelemetryService { name: string, amountSource: { value: bigint; assetCode: string; assetScale: number }, amountDestination: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes: Record = {} ): Promise { if (!amountSource.value || !amountDestination.value) return - const convertedSource = await this.convertAmount({ - sourceAmount: amountSource.value, - sourceAsset: { - code: amountSource.assetCode, - scale: amountSource.assetScale - } - }) + const convertedSource = await this.convertAmount( + { + sourceAmount: amountSource.value, + sourceAsset: { + code: amountSource.assetCode, + scale: amountSource.assetScale + } + }, + tenantId + ) if (isConvertError(convertedSource)) { this.deps.logger.error( `Unable to convert source amount: ${convertedSource}` ) return } - const convertedDestination = await this.convertAmount({ - sourceAmount: amountDestination.value, - sourceAsset: { - code: amountDestination.assetCode, - scale: amountDestination.assetScale - } - }) + const convertedDestination = await this.convertAmount( + { + sourceAmount: amountDestination.value, + sourceAsset: { + code: amountDestination.assetCode, + scale: amountDestination.assetScale + } + }, + tenantId + ) if (isConvertError(convertedDestination)) { this.deps.logger.error( `Unable to convert destination amount: ${convertedSource}` @@ -159,15 +167,19 @@ export class TelemetryServiceImpl implements TelemetryService { public async incrementCounterWithTransactionAmount( name: string, amount: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes: Record = {}, preservePrivacy = true ): Promise { const { value, assetCode, assetScale } = amount try { - const converted = await this.convertAmount({ - sourceAmount: value, - sourceAsset: { code: assetCode, scale: assetScale } - }) + const converted = await this.convertAmount( + { + sourceAmount: value, + sourceAsset: { code: assetCode, scale: assetScale } + }, + tenantId + ) if (isConvertError(converted)) { this.deps.logger.error(`Unable to convert amount: ${converted}`) return @@ -195,17 +207,21 @@ export class TelemetryServiceImpl implements TelemetryService { } private async convertAmount( - convertOptions: Pick + convertOptions: Pick, + tenantId?: string ) { const destinationAsset = { code: this.deps.baseAssetCode, scale: this.deps.baseScale } - let converted = await this.aseRatesService.convertSource({ - ...convertOptions, - destinationAsset - }) + let converted = await this.aseRatesService.convertSource( + { + ...convertOptions, + destinationAsset + }, + tenantId + ) if (isConvertError(converted)) { this.deps.logger.error( `Unable to convert amount from provided rates: ${converted}` @@ -263,6 +279,7 @@ export class NoopTelemetryServiceImpl implements TelemetryService { name: string, amountSource: { value: bigint; assetCode: string; assetScale: number }, amountDestination: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes?: Record ): Promise { // do nothing @@ -271,6 +288,7 @@ export class NoopTelemetryServiceImpl implements TelemetryService { public async incrementCounterWithTransactionAmount( name: string, amount: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes: Record = {}, preservePrivacy = true ): Promise { diff --git a/packages/backend/src/tenants/settings/service.ts b/packages/backend/src/tenants/settings/service.ts index ddd4a7e231..bf2abb1935 100644 --- a/packages/backend/src/tenants/settings/service.ts +++ b/packages/backend/src/tenants/settings/service.ts @@ -31,7 +31,7 @@ export interface ExtraOptions { } export interface TenantSettingService { - get: (options: GetOptions) => Promise + get: (options: GetOptions) => Promise create: ( options: CreateOptions, extra?: ExtraOptions @@ -75,7 +75,7 @@ export async function createTenantSettingService( async function getTenantSettings( deps: ServiceDependencies, options: GetOptions -): Promise { +): Promise { return TenantSetting.query(deps.knex).whereNull('deletedAt').andWhere(options) }