diff --git a/packages/backend/src/fee/service.ts b/packages/backend/src/fee/service.ts index 4979cb18a4..8f75ecb23d 100644 --- a/packages/backend/src/fee/service.ts +++ b/packages/backend/src/fee/service.ts @@ -3,6 +3,7 @@ import { BaseService } from '../shared/baseService' import { FeeError } from './errors' import { Fee, FeeType } from './model' import { Pagination, SortOrder } from '../shared/baseModel' +import { CacheDataStore } from '../middleware/cache/data-stores' export interface CreateOptions { assetId: string @@ -21,19 +22,24 @@ export interface FeeService { sortOrder?: SortOrder ): Promise getLatestFee(assetId: string, type: FeeType): Promise + get(id: string): Promise } -type ServiceDependencies = BaseService +interface ServiceDependencies extends BaseService { + feeCache: CacheDataStore +} export async function createFeeService({ logger, - knex + knex, + feeCache }: ServiceDependencies): Promise { const deps: ServiceDependencies = { logger: logger.child({ service: 'FeeService' }), - knex + knex, + feeCache } return { create: (options: CreateOptions) => createFee(deps, options), @@ -43,7 +49,8 @@ export async function createFeeService({ sortOrder = SortOrder.Desc ) => getFeesPage(deps, assetId, pagination, sortOrder), getLatestFee: (assetId: string, type: FeeType) => - getLatestFee(deps, assetId, type) + getLatestFee(deps, assetId, type), + get: (id: string) => getById(deps, id) } } @@ -60,15 +67,42 @@ async function getFeesPage( return await query } +async function getById( + deps: ServiceDependencies, + id: string +): Promise { + const cachedFee = await deps.feeCache.get(id) + + if (cachedFee) { + return cachedFee + } + + const fee = await Fee.query(deps.knex).findById(id) + + if (fee) await deps.feeCache.set(id, fee) + + return fee +} + async function getLatestFee( deps: ServiceDependencies, assetId: string, type: FeeType ): Promise { - return await Fee.query(deps.knex) + const cachedFee = await deps.feeCache.get(`${assetId}${type}`) + + if (cachedFee) { + return cachedFee + } + + const latestFee = await Fee.query(deps.knex) .where({ assetId, type }) .orderBy('createdAt', 'desc') .first() + + if (latestFee) await deps.feeCache.set(`${assetId}${type}`, latestFee) + + return latestFee } async function createFee( @@ -86,12 +120,15 @@ async function createFee( } try { - return await Fee.query(deps.knex).insertAndFetch({ + const fee = await Fee.query(deps.knex).insertAndFetch({ assetId: assetId, type: type, basisPointFee: basisPoints, fixedFee: fixed }) + + await deps.feeCache.set(`${assetId}${type}`, fee) + return fee } catch (error) { if (error instanceof ForeignKeyViolationError) { return FeeError.UnknownAsset diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 91db346566..dae1f10903 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -419,7 +419,8 @@ export function initIocContainer( const knex = await deps.use('knex') return await createFeeService({ logger: logger, - knex: knex + knex: knex, + feeCache: createInMemoryDataStore(config.localCacheDuration) }) }) @@ -513,7 +514,8 @@ export function initIocContainer( walletAddressService: await deps.use('walletAddressService'), quoteService: await deps.use('quoteService'), assetService: await deps.use('assetService'), - telemetry: await deps.use('telemetry') + telemetry: await deps.use('telemetry'), + feeService: await deps.use('feeService') }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 54ede1accb..bbbed972da 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -44,6 +44,8 @@ import { Pagination, SortOrder } from '../../../shared/baseModel' import { FilterString } from '../../../shared/filters' import { IAppConfig } from '../../../config/app' import { AssetService } from '../../../asset/service' +import { Span, trace } from '@opentelemetry/api' +import { FeeService } from '../../../fee/service' export interface OutgoingPaymentService extends WalletAddressSubresourceService { @@ -71,6 +73,7 @@ export interface ServiceDependencies extends BaseService { quoteService: QuoteService assetService: AssetService telemetry: TelemetryService + feeService: FeeService } export async function createOutgoingPaymentService( @@ -159,6 +162,10 @@ async function getOutgoingPayment( outgoingPayment.walletAddress = await deps.walletAddressService.get( outgoingPayment.walletAddressId ) + outgoingPayment.quote.walletAddress = await deps.walletAddressService.get( + outgoingPayment.quote.walletAddressId + ) + const asset = await deps.assetService.get(outgoingPayment.quote.assetId) if (asset) outgoingPayment.quote.asset = asset @@ -236,222 +243,242 @@ async function createOutgoingPayment( deps: ServiceDependencies, options: CreateOutgoingPaymentOptions ): Promise { - const stopTimerOP = deps.telemetry.startTimer( - 'outgoing_payment_service_create_time_ms', - { - callName: 'OutgoingPaymentService:create', - description: 'Time to create an outgoing payment' - } - ) - const { walletAddressId } = options - let quoteId: string - - if (isCreateFromIncomingPayment(options)) { - const stopTimerQuote = deps.telemetry.startTimer( - 'outgoing_payment_service_create_quote_time_ms', - { - callName: 'QuoteService:create', - description: 'Time to create a quote in outgoing payment' - } - ) - const { debitAmount, incomingPayment } = options - const quoteOrError = await deps.quoteService.create({ - receiver: incomingPayment, - debitAmount, - method: 'ilp', - walletAddressId - }) - stopTimerQuote() - - if (isQuoteError(quoteOrError)) { - return quoteErrorToOutgoingPaymentError[quoteOrError] - } - quoteId = quoteOrError.id - } else { - quoteId = options.quoteId - } + const tracer = trace.getTracer('outgoing_payment_service_create') - const grantId = options.grant?.id - try { - return await OutgoingPayment.transaction(deps.knex, async (trx) => { - const stopTimerWA = deps.telemetry.startTimer( - 'outgoing_payment_service_getwalletaddress_time_ms', + return tracer.startActiveSpan( + 'outgoingPaymentService.createOutgoingPayment', + async (span: Span) => { + const stopTimerOP = deps.telemetry.startTimer( + 'outgoing_payment_service_create_time_ms', { - callName: 'WalletAddressService:get', - description: 'Time to get wallet address in outgoing payment' + callName: 'OutgoingPaymentService:create', + description: 'Time to create an outgoing payment' } ) - const walletAddress = await deps.walletAddressService.get(walletAddressId) - stopTimerWA() - if (!walletAddress) { - throw OutgoingPaymentError.UnknownWalletAddress - } - if (!walletAddress.isActive) { - throw OutgoingPaymentError.InactiveWalletAddress - } + const { walletAddressId } = options + let quoteId: string - if (grantId) { - const stopTimerGrant = deps.telemetry.startTimer( - 'outgoing_payment_service_insertgrant_time_ms', + if (isCreateFromIncomingPayment(options)) { + const stopTimerQuote = deps.telemetry.startTimer( + 'outgoing_payment_service_create_quote_time_ms', { - callName: 'OutgoingPaymentGrantModel:insert', - description: 'Time to insert grant in outgoing payment' + callName: 'QuoteService:create', + description: 'Time to create a quote in outgoing payment' } ) - await OutgoingPaymentGrant.query(trx) - .insert({ - id: grantId - }) - .onConflict('id') - .ignore() - stopTimerGrant() - } - const stopTimerInsertPayment = deps.telemetry.startTimer( - 'outgoing_payment_service_insertpayment_time_ms', - { - callName: 'OutgoingPayment.insert', - description: 'Time to insert payment in outgoing payment' - } - ) - const payment = await OutgoingPayment.query(trx) - .insertAndFetch({ - id: quoteId, - walletAddressId: walletAddressId, - client: options.client, - metadata: options.metadata, - state: OutgoingPaymentState.Funding, - grantId + const { debitAmount, incomingPayment } = options + const quoteOrError = await deps.quoteService.create({ + receiver: incomingPayment, + debitAmount, + method: 'ilp', + walletAddressId }) - .withGraphFetched('quote') - payment.walletAddress = await deps.walletAddressService.get( - payment.walletAddressId - ) - const asset = await deps.assetService.get(payment.quote.assetId) - if (asset) payment.quote.asset = asset - - stopTimerInsertPayment() + stopTimerQuote() - if ( - payment.walletAddressId !== payment.quote.walletAddressId || - payment.quote.expiresAt.getTime() <= payment.createdAt.getTime() - ) { - throw OutgoingPaymentError.InvalidQuote + if (isQuoteError(quoteOrError)) { + return quoteErrorToOutgoingPaymentError[quoteOrError] + } + quoteId = quoteOrError.id + } else { + quoteId = options.quoteId } - if (options.grant) { - const stopTimerValidateGrant = deps.telemetry.startTimer( - 'outgoing_payment_service_validate_grant_time_ms', + const grantId = options.grant?.id + try { + const stopTimerWA = deps.telemetry.startTimer( + 'outgoing_payment_service_getwalletaddress_time_ms', { - callName: - 'OutgoingPaymentService:validateGrantAndAddSpentAmountsToPayment', - description: 'Time to validate a grant' + callName: 'WalletAddressService:get', + description: 'Time to get wallet address in outgoing payment' } ) - const isValid = await validateGrantAndAddSpentAmountsToPayment( - deps, - payment, - options.grant, - trx, - options.callback, - options.grantLockTimeoutMs - ) - stopTimerValidateGrant() - if (!isValid) { - throw OutgoingPaymentError.InsufficientGrant + const walletAddress = + await deps.walletAddressService.get(walletAddressId) + stopTimerWA() + if (!walletAddress) { + throw OutgoingPaymentError.UnknownWalletAddress } - } - const stopTimerReceiver = deps.telemetry.startTimer( - 'outgoing_payment_service_getreceiver_time_ms', - { - callName: 'ReceiverService:get', - description: 'Time to retrieve receiver in outgoing payment' + if (!walletAddress.isActive) { + throw OutgoingPaymentError.InactiveWalletAddress } - ) - const receiver = await deps.receiverService.get(payment.receiver) - stopTimerReceiver() - if (!receiver) { - throw OutgoingPaymentError.InvalidQuote - } - const stopTimerPeer = deps.telemetry.startTimer( - 'outgoing_payment_service_getpeer_time_ms', - { - callName: 'PeerService:getByDestinationAddress', - description: 'Time to retrieve peer in outgoing payment' - } - ) - const peer = await deps.peerService.getByDestinationAddress( - receiver.ilpAddress - ) - stopTimerPeer() - const stopTimerPeerUpdate = deps.telemetry.startTimer( - 'outgoing_payment_service_patchpeer_time_ms', - { - callName: 'OutgoingPaymentModel:patch', - description: 'Time to patch peer in outgoing payment' + const quote = await deps.quoteService.get({ id: quoteId }) + if (!quote) { + return OutgoingPaymentError.UnknownQuote } - ) - if (peer) await payment.$query(trx).patch({ peerId: peer.id }) - stopTimerPeerUpdate() - - const stopTimerWebhook = deps.telemetry.startTimer( - 'outgoing_payment_service_webhook_event_time_ms', - { - callName: 'OutgoingPaymentService:sendWebhookEvent', - description: 'Time to add outgoing payment webhook event' + if (quote.feeId) { + quote.fee = await deps.feeService.get(quote.feeId) } - ) - await sendWebhookEvent( - deps, - payment, - OutgoingPaymentEventType.PaymentCreated, - trx - ) - stopTimerWebhook() - const stopTimerAddAmount = deps.telemetry.startTimer( - 'outgoing_payment_service_add_sent_time_ms', - { - callName: 'OutgoingPaymentService:addSentAmount', - description: 'Time to add sent amount to outgoing payment' + const asset = await deps.assetService.get(quote.assetId) + + const stopTimerReceiver = deps.telemetry.startTimer( + 'outgoing_payment_service_getreceiver_time_ms', + { + callName: 'ReceiverService:get', + description: 'Time to retrieve receiver in outgoing payment' + } + ) + + const receiver = await deps.receiverService.get(quote.receiver) + stopTimerReceiver() + if (!receiver) { + throw OutgoingPaymentError.InvalidQuote } - ) + const stopTimerPeer = deps.telemetry.startTimer( + 'outgoing_payment_service_getpeer_time_ms', + { + callName: 'PeerService:getByDestinationAddress', + description: 'Time to retrieve peer in outgoing payment' + } + ) + const peer = await deps.peerService.getByDestinationAddress( + receiver.ilpAddress + ) + stopTimerPeer() + + const payment = await OutgoingPayment.transaction(async (trx) => { + if (grantId) { + const stopTimerGrant = deps.telemetry.startTimer( + 'outgoing_payment_service_insertgrant_time_ms', + { + callName: 'OutgoingPaymentGrantModel:insert', + description: 'Time to insert grant in outgoing payment' + } + ) + await OutgoingPaymentGrant.query(trx) + .insert({ + id: grantId + }) + .onConflict('id') + .ignore() + stopTimerGrant() + } + const stopTimerInsertPayment = deps.telemetry.startTimer( + 'outgoing_payment_service_insertpayment_time_ms', + { + callName: 'OutgoingPayment.insert', + description: 'Time to insert payment in outgoing payment' + } + ) + + const payment = await OutgoingPayment.query(trx).insertAndFetch({ + id: quoteId, + walletAddressId: walletAddressId, + client: options.client, + metadata: options.metadata, + state: OutgoingPaymentState.Funding, + grantId + }) + payment.walletAddress = walletAddress + payment.quote = quote + if (asset) payment.quote.asset = asset - const paymentWithSentAmount = await addSentAmount( - deps, - payment, - BigInt(0) - ) + stopTimerInsertPayment() - stopTimerAddAmount() + if ( + payment.walletAddressId !== payment.quote.walletAddressId || + payment.quote.expiresAt.getTime() <= payment.createdAt.getTime() + ) { + throw OutgoingPaymentError.InvalidQuote + } - return paymentWithSentAmount - }) - } catch (err) { - if (err instanceof UniqueViolationError) { - if (err.constraint === 'outgoingPayments_pkey') { - return OutgoingPaymentError.InvalidQuote - } - } else if (err instanceof ForeignKeyViolationError) { - if (err.constraint === 'outgoingpayments_id_foreign') { - return OutgoingPaymentError.UnknownQuote - } else if ( - err.constraint === 'outgoingpayments_walletaddressid_foreign' - ) { - return OutgoingPaymentError.UnknownWalletAddress + if (options.grant) { + const stopTimerValidateGrant = deps.telemetry.startTimer( + 'outgoing_payment_service_validate_grant_time_ms', + { + callName: + 'OutgoingPaymentService:validateGrantAndAddSpentAmountsToPayment', + description: 'Time to validate a grant' + } + ) + const isValid = await validateGrantAndAddSpentAmountsToPayment( + deps, + payment, + options.grant, + trx, + options.callback, + options.grantLockTimeoutMs + ) + stopTimerValidateGrant() + if (!isValid) { + throw OutgoingPaymentError.InsufficientGrant + } + } + + const stopTimerPeerUpdate = deps.telemetry.startTimer( + 'outgoing_payment_service_patchpeer_time_ms', + { + callName: 'OutgoingPaymentModel:patch', + description: 'Time to patch peer in outgoing payment' + } + ) + if (peer) await payment.$query(trx).patch({ peerId: peer.id }) + stopTimerPeerUpdate() + + const stopTimerWebhook = deps.telemetry.startTimer( + 'outgoing_payment_service_webhook_event_time_ms', + { + callName: 'OutgoingPaymentService:sendWebhookEvent', + description: 'Time to add outgoing payment webhook event' + } + ) + await sendWebhookEvent( + deps, + payment, + OutgoingPaymentEventType.PaymentCreated, + trx + ) + stopTimerWebhook() + + return payment + }) + + const stopTimerAddAmount = deps.telemetry.startTimer( + 'outgoing_payment_service_add_sent_time_ms', + { + callName: 'OutgoingPaymentService:addSentAmount', + description: 'Time to add sent amount to outgoing payment' + } + ) + + const paymentWithSentAmount = await addSentAmount( + deps, + payment, + BigInt(0) + ) + + stopTimerAddAmount() + + return paymentWithSentAmount + } catch (err) { + if (err instanceof UniqueViolationError) { + if (err.constraint === 'outgoingPayments_pkey') { + return OutgoingPaymentError.InvalidQuote + } + } else if (err instanceof ForeignKeyViolationError) { + if (err.constraint === 'outgoingpayments_id_foreign') { + return OutgoingPaymentError.UnknownQuote + } else if ( + err.constraint === 'outgoingpayments_walletaddressid_foreign' + ) { + return OutgoingPaymentError.UnknownWalletAddress + } + } else if (isOutgoingPaymentError(err)) { + return err + } else if (err instanceof knex.KnexTimeoutError) { + deps.logger.error( + { grant: grantId }, + 'Could not create outgoing payment: grant locked' + ) + } + throw err + } finally { + stopTimerOP() + span.end() } - } else if (isOutgoingPaymentError(err)) { - return err - } else if (err instanceof knex.KnexTimeoutError) { - deps.logger.error( - { grant: grantId }, - 'Could not create outgoing payment: grant locked' - ) } - throw err - } finally { - stopTimerOP() - } + ) } function validateAccessLimits( @@ -686,6 +713,9 @@ async function getWalletAddressPage( payment.walletAddress = await deps.walletAddressService.get( payment.walletAddressId ) + payment.quote.walletAddress = await deps.walletAddressService.get( + payment.quote.walletAddressId + ) const asset = await deps.assetService.get(payment.quote.assetId) if (asset) payment.quote.asset = asset } diff --git a/packages/backend/src/open_payments/quote/model.ts b/packages/backend/src/open_payments/quote/model.ts index 3c6bd6d135..16483a954f 100644 --- a/packages/backend/src/open_payments/quote/model.ts +++ b/packages/backend/src/open_payments/quote/model.ts @@ -22,7 +22,7 @@ export class Quote extends WalletAddressSubresource { public estimatedExchangeRate!: number public feeId?: string - public fee?: Fee + public fee?: Fee | null public debitAmountMinusFees?: bigint diff --git a/packages/backend/src/open_payments/quote/service.test.ts b/packages/backend/src/open_payments/quote/service.test.ts index 3840212c2e..eb239231e3 100644 --- a/packages/backend/src/open_payments/quote/service.test.ts +++ b/packages/backend/src/open_payments/quote/service.test.ts @@ -123,6 +123,7 @@ describe('QuoteService', (): void => { afterEach(async (): Promise => { jest.restoreAllMocks() + jest.useRealTimers() await truncateTables(knex) }) @@ -221,6 +222,10 @@ describe('QuoteService', (): void => { .spyOn(paymentMethodHandlerService, 'getQuote') .mockResolvedValueOnce(mockedQuote) + jest.useFakeTimers() + const now = Date.now() + jest.spyOn(global.Date, 'now').mockImplementation(() => now) + const quote = await quoteService.create({ ...options, client @@ -235,8 +240,7 @@ describe('QuoteService', (): void => { receiver: expect.anything(), receiveAmount: options.receiveAmount, debitAmount: options.debitAmount - }), - expect.anything() + }) ) expect(quote).toMatchObject({ @@ -246,9 +250,7 @@ describe('QuoteService', (): void => { receiveAmount: receiveAmount || mockedQuote.receiveAmount, createdAt: expect.any(Date), updatedAt: expect.any(Date), - expiresAt: new Date( - quote.createdAt.getTime() + config.quoteLifespan - ), + expiresAt: new Date(now + config.quoteLifespan), client: client || null }) @@ -320,6 +322,10 @@ describe('QuoteService', (): void => { .spyOn(paymentMethodHandlerService, 'getQuote') .mockResolvedValueOnce(mockedQuote) + jest.useFakeTimers() + const now = Date.now() + jest.spyOn(global.Date, 'now').mockImplementation(() => now) + const quote = await quoteService.create({ ...options, client @@ -332,9 +338,7 @@ describe('QuoteService', (): void => { receiveAmount: incomingAmount, createdAt: expect.any(Date), updatedAt: expect.any(Date), - expiresAt: new Date( - quote.createdAt.getTime() + config.quoteLifespan - ), + expiresAt: new Date(new Date(now + config.quoteLifespan)), client: client || null }) @@ -373,7 +377,7 @@ describe('QuoteService', (): void => { test.each` expiryDate | description ${new Date(new Date().getTime() + Config.quoteLifespan - 2 * 60_000)} | ${"the incoming payment's expirataion date"} - ${new Date(new Date().getTime() + Config.quoteLifespan + 2 * 60_000)} | ${"the quotation's creation date plus its lifespan"} + ${new Date(new Date().getTime() + Config.quoteLifespan + 2 * 60_000)} | ${"the quote's creation date plus its lifespan"} `( 'sets expiry date to $description', async ({ expiryDate }): Promise => { @@ -402,11 +406,13 @@ describe('QuoteService', (): void => { .spyOn(paymentMethodHandlerService, 'getQuote') .mockResolvedValueOnce(mockedQuote) + jest.useFakeTimers() + const now = Date.now() + jest.spyOn(global.Date, 'now').mockImplementation(() => now) + const quote = await quoteService.create(options) assert.ok(!isQuoteError(quote)) - const maxExpiration = new Date( - quote.createdAt.getTime() + config.quoteLifespan - ) + const maxExpiration = new Date(now + config.quoteLifespan) expect(quote).toMatchObject({ walletAddressId: sendingWalletAddress.id, receiver: options.receiver, @@ -764,6 +770,10 @@ describe('QuoteService', (): void => { .spyOn(paymentMethodHandlerService, 'getQuote') .mockResolvedValueOnce(mockedQuote) + jest.useFakeTimers() + const now = Date.now() + jest.spyOn(global.Date, 'now').mockImplementation(() => now) + const quote = await quoteService.create(options) assert.ok(!isQuoteError(quote)) @@ -775,8 +785,7 @@ describe('QuoteService', (): void => { receiver: expect.anything(), receiveAmount: options.receiveAmount, debitAmount: options.debitAmount - }), - expect.anything() + }) ) expect(quote).toMatchObject({ @@ -786,7 +795,7 @@ describe('QuoteService', (): void => { receiveAmount: mockedQuote.receiveAmount, createdAt: expect.any(Date), updatedAt: expect.any(Date), - expiresAt: new Date(quote.createdAt.getTime() + config.quoteLifespan) + expiresAt: new Date(now + config.quoteLifespan) }) await expect( diff --git a/packages/backend/src/open_payments/quote/service.ts b/packages/backend/src/open_payments/quote/service.ts index 957c4065f4..675aae0804 100644 --- a/packages/backend/src/open_payments/quote/service.ts +++ b/packages/backend/src/open_payments/quote/service.ts @@ -14,7 +14,7 @@ import { import { PaymentMethodHandlerService } from '../../payment-method/handler/service' import { IAppConfig } from '../../config/app' import { FeeService } from '../../fee/service' -import { FeeType } from '../../fee/model' +import { Fee, FeeType } from '../../fee/model' import { PaymentMethodHandlerError, PaymentMethodHandlerErrorCode @@ -56,9 +56,7 @@ async function getQuote( deps: ServiceDependencies, options: GetOptions ): Promise { - const quote = await Quote.query(deps.knex) - .get(options) - .withGraphFetched('fee') + const quote = await Quote.query(deps.knex).get(options) if (quote) { const asset = await deps.assetService.get(quote.assetId) if (asset) quote.asset = asset @@ -91,6 +89,19 @@ export type CreateQuoteOptions = | QuoteOptionsWithDebitAmount | QuoteOptionsWithReceiveAmount +interface UnfinalizedQuote { + id: string + walletAddressId: string + assetId: string + receiver: string + debitAmount: Amount + receiveAmount: Amount + client: string | undefined + feeId: string | undefined + fee: Fee | undefined + estimatedExchangeRate: number +} + async function createQuote( deps: ServiceDependencies, options: CreateQuoteOptions @@ -145,89 +156,88 @@ async function createQuote( const paymentMethod = receiver.isLocal ? 'LOCAL' : 'ILP' const quoteId = uuid() - return await Quote.transaction(deps.knex, async (trx) => { - const stopQuoteCreate = deps.telemetry.startTimer( - 'quote_service_create_insert_time_ms', - { - callName: 'QuoteModel.insert', - description: 'Time to insert quote' - } - ) - const stopTimerQuote = deps.telemetry.startTimer( - 'quote_service_create_get_quote_time_ms', - { - callName: 'PaymentMethodHandlerService:getQuote', - description: 'Time to getQuote' - } - ) - const quote = await deps.paymentMethodHandlerService.getQuote( - paymentMethod, - { - quoteId, - walletAddress, - receiver, - receiveAmount: options.receiveAmount, - debitAmount: options.debitAmount - }, - trx - ) - stopTimerQuote() - - const stopTimerFee = deps.telemetry.startTimer( - 'quote_service_create_get_latest_fee_time_ms', - { - callName: 'FeeService:getLatestFee', - description: 'Time to getLatestFee' - } - ) - const sendingFee = await deps.feeService.getLatestFee( - walletAddress.assetId, - FeeType.Sending - ) - stopTimerFee() - - const createdQuote = await Quote.query(trx) - .insertAndFetch({ - id: quoteId, - walletAddressId: options.walletAddressId, - assetId: walletAddress.assetId, - receiver: options.receiver, - debitAmount: quote.debitAmount, - receiveAmount: quote.receiveAmount, - expiresAt: new Date(0), // expiresAt is patched in finalizeQuote - client: options.client, - feeId: sendingFee?.id, - estimatedExchangeRate: quote.estimatedExchangeRate - }) - .withGraphFetched('fee') - const asset = await deps.assetService.get(createdQuote.assetId) - if (asset) createdQuote.asset = asset - - createdQuote.walletAddress = await deps.walletAddressService.get( - createdQuote.walletAddressId - ) - - stopQuoteCreate() - - const stopFinalize = deps.telemetry.startTimer( - 'quote_service_finalize_quote_ms', - { - callName: 'QuoteService:finalizedQuote', - description: 'Time to finalize quote' - } - ) - const finalizedQuote = await finalizeQuote( - { - ...deps, - knex: trx - }, - options, - createdQuote, - receiver - ) - stopFinalize() - return finalizedQuote + const stopTimerFee = deps.telemetry.startTimer( + 'quote_service_create_get_latest_fee_time_ms', + { + callName: 'FeeService:getLatestFee', + description: 'Time to getLatestFee' + } + ) + const sendingFee = await deps.feeService.getLatestFee( + walletAddress.assetId, + FeeType.Sending + ) + stopTimerFee() + + const stopTimerQuote = deps.telemetry.startTimer( + 'quote_service_create_get_quote_time_ms', + { + callName: 'PaymentMethodHandlerService:getQuote', + description: 'Time to getQuote' + } + ) + + // TODO: should getQuote happen inside trx? wasnt in main (was inside but not using trx). + // If so, in the getQuote method, need to not only pass into IlpQuoteDetails but also connector. + // Probably should have IlpQuoteDetails usingt he trx but not sure about the rest (just + // including the IlpQuoteDetails insert would prly require refactor to to that here) + const quote = await deps.paymentMethodHandlerService.getQuote( + paymentMethod, + { + quoteId, + walletAddress, + receiver, + receiveAmount: options.receiveAmount, + debitAmount: options.debitAmount + } + ) + stopTimerQuote() + + const unfinalizedQuote: UnfinalizedQuote = { + id: quoteId, + walletAddressId: options.walletAddressId, + assetId: walletAddress.assetId, + receiver: options.receiver, + debitAmount: quote.debitAmount, + receiveAmount: quote.receiveAmount, + client: options.client, + feeId: sendingFee?.id, + fee: sendingFee, + estimatedExchangeRate: quote.estimatedExchangeRate + } + + const stopFinalize = deps.telemetry.startTimer( + 'quote_service_finalize_quote_ms', + { + callName: 'QuoteService:finalizedQuote', + description: 'Time to finalize quote' + } + ) + const finalQuoteOptions = await finalizeQuote( + deps, + options, + unfinalizedQuote, + receiver + ) + stopFinalize() + + const stopQuoteCreate = deps.telemetry.startTimer( + 'quote_service_create_insert_time_ms', + { + callName: 'QuoteModel.insert', + description: 'Time to insert quote' + } + ) + const createdQuote = await Quote.query(deps.knex).insertAndFetch({ + ...unfinalizedQuote, + ...finalQuoteOptions }) + createdQuote.asset = walletAddress.asset + createdQuote.walletAddress = walletAddress + createdQuote.fee = sendingFee + + stopQuoteCreate() + return createdQuote } catch (err) { if (isQuoteError(err)) { return err @@ -298,7 +308,7 @@ interface CalculateQuoteAmountsWithFeesResult { */ function calculateFixedSendQuoteAmounts( deps: ServiceDependencies, - quote: Quote, + quote: UnfinalizedQuote, maxReceiveAmountValue: bigint ): CalculateQuoteAmountsWithFeesResult { // TODO: derive fee from debitAmount instead? Current behavior/tests may be wrong with basis point fees. @@ -347,13 +357,36 @@ function calculateFixedSendQuoteAmounts( } } +function calculateExpiry( + deps: ServiceDependencies, + quote: UnfinalizedQuote, + receiver: Receiver +): Date { + const quoteExpiry = new Date(Date.now() + deps.config.quoteLifespan) + + const incomingPaymentExpiresEarlier = + receiver.incomingPayment?.expiresAt && + receiver.incomingPayment.expiresAt.getTime() < quoteExpiry.getTime() + + return incomingPaymentExpiresEarlier + ? receiver.incomingPayment!.expiresAt! + : quoteExpiry +} + +interface QuotePatchOptions { + debitAmountMinusFees: bigint + debitAmountValue: bigint + receiveAmountValue: bigint + expiresAt: Date +} + /** * Calculate fixed-delivery quote amounts: receiveAmount is locked, * add fees to the the debitAmount. */ function calculateFixedDeliveryQuoteAmounts( deps: ServiceDependencies, - quote: Quote + quote: UnfinalizedQuote ): CalculateQuoteAmountsWithFeesResult { const fees = quote.fee?.calculate(quote.debitAmount.value) ?? BigInt(0) @@ -379,30 +412,12 @@ function calculateFixedDeliveryQuoteAmounts( } } -function calculateExpiry( - deps: ServiceDependencies, - quote: Quote, - receiver: Receiver -): Date { - const quoteExpiry = new Date( - quote.createdAt.getTime() + deps.config.quoteLifespan - ) - - const incomingPaymentExpiresEarlier = - receiver.incomingPayment?.expiresAt && - receiver.incomingPayment.expiresAt.getTime() < quoteExpiry.getTime() - - return incomingPaymentExpiresEarlier - ? receiver.incomingPayment!.expiresAt! - : quoteExpiry -} - async function finalizeQuote( deps: ServiceDependencies, options: CreateQuoteOptions, - quote: Quote, + quote: UnfinalizedQuote, receiver: Receiver -): Promise { +): Promise { let maxReceiveAmountValue: bigint | undefined if (options.debitAmount) { @@ -437,18 +452,14 @@ async function finalizeQuote( expiresAt: calculateExpiry(deps, quote, receiver) } - await quote.$query(deps.knex).patch(patchOptions) - - return quote + return patchOptions } async function getWalletAddressPage( deps: ServiceDependencies, options: ListOptions ): Promise { - const quotes = await Quote.query(deps.knex) - .list(options) - .withGraphFetched('fee') + const quotes = await Quote.query(deps.knex).list(options) for (const quote of quotes) { const asset = await deps.assetService.get(quote.assetId) if (asset) quote.asset = asset diff --git a/packages/backend/src/tests/quote.ts b/packages/backend/src/tests/quote.ts index 5992a66e8b..2bfc437ca7 100644 --- a/packages/backend/src/tests/quote.ts +++ b/packages/backend/src/tests/quote.ts @@ -171,7 +171,7 @@ export async function createQuote( } const withGraphFetchedExpression = `[${withGraphFetchedArray.join(', ')}]` - return Quote.query() + const quote = await Quote.query() .insertAndFetch({ id: quoteId, walletAddressId, @@ -185,4 +185,7 @@ export async function createQuote( client }) .withGraphFetched(withGraphFetchedExpression) + quote.fee = quote.fee ?? undefined + + return quote } diff --git a/test/performance/scripts/create-local-outgoing-payments.js b/test/performance/scripts/create-local-outgoing-payments.js new file mode 100644 index 0000000000..7cfe14f69f --- /dev/null +++ b/test/performance/scripts/create-local-outgoing-payments.js @@ -0,0 +1,160 @@ +import http from 'k6/http' +import { fail } from 'k6' +import { createHMAC } from 'k6/crypto' +import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js' +import { canonicalize } from '../dist/json-canonicalize.bundle.js' + +export const options = { + vus: 25, + duration: '120s' +} + +const GQL_ENDPOINT = 'http://cloud-nine-wallet-backend:3001/graphql' +const SENDER_WALLET_ADDRESS = + 'https://cloud-nine-wallet-backend/accounts/gfranklin' +const RECEIVER_WALLET_ADDRESS = + 'https://cloud-nine-wallet-backend/accounts/bhamchest' +const SIGNATURE_SECRET = 'iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964=' +const SIGNATURE_VERSION = '1' + +function generateSignedHeaders(requestPayload) { + const timestamp = Date.now() + const payload = `${timestamp}.${canonicalize(requestPayload)}` + const hmac = createHMAC('sha256', SIGNATURE_SECRET) + hmac.update(payload) + const digest = hmac.digest('hex') + + return { + 'Content-Type': 'application/json', + signature: `t=${timestamp}, v${SIGNATURE_VERSION}=${digest}, n=${uuidv4()}` + } +} + +function request(query) { + const headers = generateSignedHeaders(query) + const response = http.post(GQL_ENDPOINT, JSON.stringify(query), { + headers + }) + + if (response.status !== 200) { + fail(`GraphQL Request failed`) + } + return JSON.parse(response.body).data +} + +export function setup() { + const query = { + query: ` + query GetWalletAddresses { + walletAddresses { + edges { + node { + id + url + } + } + } + } + ` + } + + const data = request(query) + const c9WalletAddresses = data.walletAddresses.edges + const senderWalletAddress = c9WalletAddresses.find( + (edge) => edge.node.url === SENDER_WALLET_ADDRESS + ).node + if (!senderWalletAddress) { + fail(`could not find wallet address: ${SENDER_WALLET_ADDRESS}`) + } + const receiverWalletAddress = c9WalletAddresses.find( + (edge) => edge.node.url === RECEIVER_WALLET_ADDRESS + ).node + if (!receiverWalletAddress) { + fail(`could not find wallet address: ${RECEIVER_WALLET_ADDRESS}`) + } + + return { senderWalletAddress, receiverWalletAddress } +} + +// The function that defines VU logic. +// +// See https://grafana.com/docs/k6/latest/examples/get-started-with-k6/ to learn more +// about authoring k6 scripts. +// +export default function (data) { + const { senderWalletAddress, receiverWalletAddress } = data + + const createIncomingPaymentPayload = { + query: ` + mutation CreateIncomingPayment($input: CreateIncomingPaymentInput!) { + createIncomingPayment(input: $input) { + payment { + id + } + } + } + `, + variables: { + input: { + expiresAt: null, + incomingAmount: { + assetCode: 'USD', + assetScale: 2, + value: 1002 + }, + walletAddressId: receiverWalletAddress.id + } + } + } + + const createIncomingPaymentResponse = request(createIncomingPaymentPayload) + const incomingPayment = + createIncomingPaymentResponse.createIncomingPayment.payment + + const createQuotePayload = { + query: ` + mutation CreateQuote($input: CreateQuoteInput!) { + createQuote(input: $input) { + quote { + id + } + } + } + `, + variables: { + input: { + walletAddressId: senderWalletAddress.id, + receiveAmount: null, + receiver: `https://cloud-nine-wallet-backend/incoming-payments/${incomingPayment.id}`, + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: 500 + } + } + } + } + + const createQuoteResponse = request(createQuotePayload) + const quote = createQuoteResponse.createQuote.quote + + const createOutgoingPaymentPayload = { + query: ` + mutation CreateOutgoingPayment($input: CreateOutgoingPaymentInput!) { + createOutgoingPayment(input: $input) { + payment { + id + } + } + } + `, + variables: { + input: { + walletAddressId: senderWalletAddress.id, + quoteId: quote.id + } + } + } + + request(createOutgoingPaymentPayload) +}