From eae95ad823d4a12f7b6f94fc8920f89c28ddd12f Mon Sep 17 00:00:00 2001 From: Blair Currey <12960453+BlairCurrey@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:55:32 -0500 Subject: [PATCH] feat(backend): add local payment (#2857) * feat(backend): add local payment quote migration * feat(backend): WIP seperate ILPModels, LocalQuote, BaseQuote models - trouble updating the services. this may not be the best way. also not sure I can prevent querying for non-local quotes on LocalQuote (and vice-versa for ILPQuote). - the idea behind seperate models was a firm Quote type (it has the ilp specific props or it doesnt instead of 1 type MAYBE having them) - typeguards could work instead but seemed messier. or maybe I can still have seperate quote service methods returning different types? * refactor(backend): change model/services to reflect optional ilp quote details - includes some WIP changes including gql field removal and handling missing ilp quote details * feat(backend): WIP local payment method with getQuote * feat(backend): add local payment method to payment method handler * chore(backend): fix format * feat(backend): stub in control payment handler service with receiver isLocal - stubs in isLocal variable in place of actualy receiver.isLocal property - updates/adds test. new test expectedly fails because there is no way to set the receiver to local yet. can implement after isLocal is added to receiver * feat(backend): local payment .pay * chore: rm comment * chore: WIP debugging wrong sentAmount - for new local payment method service, sentAmount matches debitAmount, whereas is matches receive amount for local/remote ilp payments * chore: rm comment * feat(backend): use receiver.isLocal to control payment method in quote/outgoing payment * fix(backend): added source amount * fix(backend): p2p case (cross currency, local, fixed send) * fix: lint error * chore: rm logs * fix: quote service test * fix: lint errors * refactor(backend): split migrations * refactor(backend): rm migration that was split into many * WIP bruno requests for testing * feat(backend): start rm ilpQuoteDetail join on op where not used * fix(backend): rm unecessary ilpQuoteDetail join * chore(backend): format * fix(backend): dont join op on quote.ilpQuoteDetails on get * fix(backend): rm ilpQuoteDetails join on op cancel * fix(backend): rm unecessary join in op validate grant amount * fix(backend): rm join from fundPayment * fix(backend): rm unecessary join, unused method * fix(backend): fetch ilpQuoteDetails where used instead of joining * chore(backend): move ilpquotedetails dir * chore(backend): rm console.log * fix(backend): rm ilpQuoteDetails joins from quote service * chore(backend): rm console.log * refactor(backend): rename sourceAmount to debitAmountMinusFees * chore(backend): cleanup, rm unused fee method * test(backend): add local payment tests * chore: format * fix(bruno): local open payments requests * test(backend): add integration tests for local payments * chore(backend): cleanup * chore: restore old version of date definition in test * chore: cleanup * fix: rm unused import * test(integration): new case - p2p, fixed-send, local * chore(integration): rename test for consistency * fix(backend): throw error in pay if incoming payment is not pending * feat(backend): simplify migrations * chore(backend): clarify comment * chore(auth): format * refactor(backend): use IlpQuoteDetails model directly in ilp payment method * refactor(backend): rm ilpQuoteDetails service * fix(integration): wa typo * chore: rm bruno test examples * refactor: mv debitAmountMinusFees to fee calc and clarify TODO * Update bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Receiver -remote Incoming Payment-.bru Co-authored-by: Max Kurapov * fix: make timeout required again Making optional depends on single phase transfer * feat: error when post fails in local pay * refactor(backend): add optional quoteId to getQuote args - add runtime check to ilp payment method implementation requireing it * refactor: rm ilp quote details out of quote service * refactor: insert ilp quote details in ilp getQuote * fix(backend): payment handler test * chore(bruno): rename request * chore(integration): rm erroneous todo comment - comment indicated there was a bug where there was not. incoming payment not completing was expected because it doesnt have an amount and didnt expire * fix(backend): local quote amounts, estimatedExchangeRaet * chore(backend): format * refactor(backend): rate convert methods to be explicit --------- Co-authored-by: Max Kurapov --- .../Create Outgoing Payment.bru | 72 ++ .../Create Quote.bru | 66 ++ ...eate Receiver -local Incoming Payment-.bru | 73 ++ .../Get Outgoing Payment.bru | 57 ++ localenv/cloud-nine-wallet/seed.yml | 6 + ...6181643_require_estimated_exchange_rate.js | 30 + .../20240916181659_add_ilp_quote_details.js | 68 ++ .../20240916182716_drop_quote_ilp_fields.js | 49 + .../20240916185330_add_quote_source_amount.js | 19 + packages/backend/src/app.ts | 2 + packages/backend/src/index.ts | 20 +- .../payment/outgoing/lifecycle.ts | 43 +- .../payment/outgoing/service.test.ts | 20 + .../open_payments/payment/outgoing/service.ts | 4 +- .../backend/src/open_payments/quote/model.ts | 64 +- .../src/open_payments/quote/service.test.ts | 149 ++- .../src/open_payments/quote/service.ts | 99 +- .../payment-method/handler/service.test.ts | 71 +- .../src/payment-method/handler/service.ts | 28 +- .../core/factories/rafiki-services.ts | 9 +- .../ilp/connector/core/middleware/balance.ts | 11 +- .../middleware/balance-middleware.test.ts | 7 +- .../payment-method/ilp/quote-details/model.ts | 65 ++ .../src/payment-method/ilp/service.test.ts | 172 +++- .../backend/src/payment-method/ilp/service.ts | 49 +- .../src/payment-method/local/service.test.ts | 654 +++++++++++++ .../src/payment-method/local/service.ts | 304 ++++++ packages/backend/src/rates/service.test.ts | 43 +- packages/backend/src/rates/service.ts | 69 +- packages/backend/src/rates/util.test.ts | 84 +- packages/backend/src/rates/util.ts | 35 +- .../backend/src/telemetry/service.test.ts | 29 +- packages/backend/src/telemetry/service.ts | 10 +- packages/backend/src/tests/quote.ts | 33 +- packages/backend/src/tests/telemetry.ts | 26 +- test/integration/integration.test.ts | 873 +++++++++++------- test/integration/lib/test-actions/admin.ts | 26 +- .../testenv/cloud-nine-wallet/seed.yml | 6 + 38 files changed, 2806 insertions(+), 639 deletions(-) create mode 100644 bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Outgoing Payment.bru create mode 100644 bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Quote.bru create mode 100644 bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Receiver -local Incoming Payment-.bru create mode 100644 bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Get Outgoing Payment.bru create mode 100644 packages/backend/migrations/20240916181643_require_estimated_exchange_rate.js create mode 100644 packages/backend/migrations/20240916181659_add_ilp_quote_details.js create mode 100644 packages/backend/migrations/20240916182716_drop_quote_ilp_fields.js create mode 100644 packages/backend/migrations/20240916185330_add_quote_source_amount.js create mode 100644 packages/backend/src/payment-method/ilp/quote-details/model.ts create mode 100644 packages/backend/src/payment-method/local/service.test.ts create mode 100644 packages/backend/src/payment-method/local/service.ts diff --git a/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Outgoing Payment.bru new file mode 100644 index 0000000000..4bfde2f666 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Outgoing Payment.bru @@ -0,0 +1,72 @@ +meta { + name: Create Outgoing Payment + type: graphql + seq: 3 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation CreateOutgoingPayment($input: CreateOutgoingPaymentInput!) { + createOutgoingPayment(input: $input) { + payment { + createdAt + error + metadata + id + walletAddressId + receiveAmount { + assetCode + assetScale + value + } + receiver + debitAmount { + assetCode + assetScale + value + } + sentAmount { + assetCode + assetScale + value + } + state + stateAttempts + } + } + } +} + +body:graphql:vars { + { + "input": { + "walletAddressId": "{{gfranklinWalletAddressId}}", + "quoteId": "{{quoteId}}" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} + +script:post-response { + const body = res.getBody(); + + if (body?.data) { + bru.setEnvVar("outgoingPaymentId", body.data.createOutgoingPayment.payment.id); + } +} + +tests { + test("Outgoing Payment id is string", function() { + expect(bru.getEnvVar("outgoingPaymentId")).to.be.a("string"); + }) +} diff --git a/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Quote.bru b/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Quote.bru new file mode 100644 index 0000000000..4b293be810 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Quote.bru @@ -0,0 +1,66 @@ +meta { + name: Create Quote + type: graphql + seq: 2 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation CreateQuote($input: CreateQuoteInput!) { + createQuote(input: $input) { + quote { + createdAt + expiresAt + id + walletAddressId + receiveAmount { + assetCode + assetScale + value + } + receiver + debitAmount { + assetCode + assetScale + value + } + } + } + } +} + +body:graphql:vars { + { + "input": { + "walletAddressId": "{{gfranklinWalletAddressId}}", + "receiver": "{{receiverId}}" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + await scripts.loadWalletAddressIdsIntoVariables(); + + scripts.addApiSignatureHeader(); +} + +script:post-response { + const body = res.getBody(); + + if (body?.data) { + bru.setEnvVar("quoteId", body.data.createQuote.quote.id); + } +} + +tests { + test("Quote id is string", function() { + expect(bru.getEnvVar("quoteId")).to.be.a("string"); + }) +} diff --git a/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Receiver -local Incoming Payment-.bru b/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Receiver -local Incoming Payment-.bru new file mode 100644 index 0000000000..f72cbbd483 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Create Receiver -local Incoming Payment-.bru @@ -0,0 +1,73 @@ +meta { + name: Create Receiver -local Incoming Payment- + type: graphql + seq: 1 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation CreateReceiver($input: CreateReceiverInput!) { + createReceiver(input: $input) { + receiver { + completed + createdAt + expiresAt + metadata + id + incomingAmount { + assetCode + assetScale + value + } + walletAddressUrl + receivedAmount { + assetCode + assetScale + value + } + updatedAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "metadata": { + "description": "For lunch!" + }, + "incomingAmount": { + "assetCode": "USD", + "assetScale": 2, + "value": 500 + }, + "walletAddressUrl": "https://cloud-nine-wallet-backend/accounts/bhamchest" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} + +script:post-response { + const body = res.getBody(); + + if (body?.data) { + bru.setEnvVar("receiverId", body.data.createReceiver.receiver.id); + } +} + +tests { + test("Receiver id is string", function() { + expect(bru.getEnvVar("receiverId")).to.be.a("string"); + }) +} diff --git a/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Get Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Get Outgoing Payment.bru new file mode 100644 index 0000000000..cfca035df3 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Admin API - only locally/Peer-to-Peer Local Payment/Get Outgoing Payment.bru @@ -0,0 +1,57 @@ +meta { + name: Get Outgoing Payment + type: graphql + seq: 4 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + query GetOutgoingPayment($id: String!) { + outgoingPayment(id: $id) { + createdAt + error + metadata + id + grantId + walletAddressId + quote { + id + } + receiveAmount { + assetCode + assetScale + value + } + receiver + debitAmount { + assetCode + assetScale + value + } + sentAmount { + assetCode + assetScale + value + } + state + stateAttempts + } + } +} + +body:graphql:vars { + { + "id": "{{outgoingPaymentId}}" + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/localenv/cloud-nine-wallet/seed.yml b/localenv/cloud-nine-wallet/seed.yml index eff7d687df..75ad0398be 100644 --- a/localenv/cloud-nine-wallet/seed.yml +++ b/localenv/cloud-nine-wallet/seed.yml @@ -46,6 +46,12 @@ accounts: path: accounts/broke brunoEnvVar: brokeWalletAddress assetCode: USD + - name: "Luca Rossi" + id: 63dcc665-d946-4263-ac27-d0da1eb08a83 + initialBalance: 50 + path: accounts/lrossi + brunoEnvVar: lrossiWalletAddressId + assetCode: EUR rates: EUR: MXN: 18.78 diff --git a/packages/backend/migrations/20240916181643_require_estimated_exchange_rate.js b/packages/backend/migrations/20240916181643_require_estimated_exchange_rate.js new file mode 100644 index 0000000000..c1c4c180ce --- /dev/null +++ b/packages/backend/migrations/20240916181643_require_estimated_exchange_rate.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +exports.up = function (knex) { + return knex('quotes') + .whereNull('estimatedExchangeRate') + .update({ + estimatedExchangeRate: knex.raw('?? / ??', [ + 'lowEstimatedExchangeRateNumerator', + 'lowEstimatedExchangeRateDenominator' + ]) + }) + .then(() => { + return knex.schema.table('quotes', (table) => { + table.decimal('estimatedExchangeRate', 20, 10).notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.table('quotes', (table) => { + table.decimal('estimatedExchangeRate', 20, 10).nullable().alter() + }) +} diff --git a/packages/backend/migrations/20240916181659_add_ilp_quote_details.js b/packages/backend/migrations/20240916181659_add_ilp_quote_details.js new file mode 100644 index 0000000000..bd84708d2c --- /dev/null +++ b/packages/backend/migrations/20240916181659_add_ilp_quote_details.js @@ -0,0 +1,68 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return ( + knex.schema + // Create new table with columns from "quotes" to migrate + .createTable('ilpQuoteDetails', function (table) { + table.uuid('id').notNullable().primary() + + // quoteId is purposefully not a FK referencing quote.id + // this allows us to create ilpQuoteDetail before quotes in service of + // fully decoupling payment method/quote services. + // https://github.com/interledger/rafiki/pull/2857#discussion_r1825891327 + table.uuid('quoteId').notNullable().unique().index() + + table.bigInteger('maxPacketAmount').notNullable() + table.decimal('minExchangeRateNumerator', 64, 0).notNullable() + table.decimal('minExchangeRateDenominator', 64, 0).notNullable() + table.decimal('lowEstimatedExchangeRateNumerator', 64, 0).notNullable() + table + .decimal('lowEstimatedExchangeRateDenominator', 64, 0) + .notNullable() + table.decimal('highEstimatedExchangeRateNumerator', 64, 0).notNullable() + table + .decimal('highEstimatedExchangeRateDenominator', 64, 0) + .notNullable() + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + }) + .then(() => { + return knex.raw(` + INSERT INTO "ilpQuoteDetails" ( + id, + "quoteId", + "maxPacketAmount", + "minExchangeRateNumerator", + "minExchangeRateDenominator", + "lowEstimatedExchangeRateNumerator", + "lowEstimatedExchangeRateDenominator", + "highEstimatedExchangeRateNumerator", + "highEstimatedExchangeRateDenominator" + ) + SELECT + gen_random_uuid(), + id AS "quoteId", + "maxPacketAmount", + "minExchangeRateNumerator", + "minExchangeRateDenominator", + "lowEstimatedExchangeRateNumerator", + "lowEstimatedExchangeRateDenominator", + "highEstimatedExchangeRateNumerator", + "highEstimatedExchangeRateDenominator" + FROM "quotes"; + `) + }) + ) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('ilpQuoteDetails') +} diff --git a/packages/backend/migrations/20240916182716_drop_quote_ilp_fields.js b/packages/backend/migrations/20240916182716_drop_quote_ilp_fields.js new file mode 100644 index 0000000000..b2f50338db --- /dev/null +++ b/packages/backend/migrations/20240916182716_drop_quote_ilp_fields.js @@ -0,0 +1,49 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('quotes', function (table) { + table.dropColumn('maxPacketAmount') + table.dropColumn('minExchangeRateNumerator') + table.dropColumn('minExchangeRateDenominator') + table.dropColumn('lowEstimatedExchangeRateNumerator') + table.dropColumn('lowEstimatedExchangeRateDenominator') + table.dropColumn('highEstimatedExchangeRateNumerator') + table.dropColumn('highEstimatedExchangeRateDenominator') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema + .alterTable('quotes', function (table) { + // restore columns without not null constraint + table.bigInteger('maxPacketAmount') + table.decimal('minExchangeRateNumerator', 64, 0) + table.decimal('minExchangeRateDenominator', 64, 0) + table.decimal('lowEstimatedExchangeRateNumerator', 64, 0) + table.decimal('lowEstimatedExchangeRateDenominator', 64, 0) + table.decimal('highEstimatedExchangeRateNumerator', 64, 0) + table.decimal('highEstimatedExchangeRateDenominator', 64, 0) + }) + .then(() => { + // Migrate data back to quotes table from ilpQuote + return knex.raw(` + UPDATE "quotes" + SET + "maxPacketAmount" = "ilpQuoteDetails"."maxPacketAmount", + "minExchangeRateNumerator" = "ilpQuoteDetails"."minExchangeRateNumerator", + "minExchangeRateDenominator" = "ilpQuoteDetails"."minExchangeRateDenominator", + "lowEstimatedExchangeRateNumerator" = "ilpQuoteDetails"."lowEstimatedExchangeRateNumerator", + "lowEstimatedExchangeRateDenominator" = "ilpQuoteDetails"."lowEstimatedExchangeRateDenominator", + "highEstimatedExchangeRateNumerator" = "ilpQuoteDetails"."highEstimatedExchangeRateNumerator", + "highEstimatedExchangeRateDenominator" = "ilpQuoteDetails"."highEstimatedExchangeRateDenominator" + FROM "ilpQuoteDetails" + WHERE "quotes"."id" = "ilpQuoteDetails"."quoteId" + `) + }) +} diff --git a/packages/backend/migrations/20240916185330_add_quote_source_amount.js b/packages/backend/migrations/20240916185330_add_quote_source_amount.js new file mode 100644 index 0000000000..646aa70043 --- /dev/null +++ b/packages/backend/migrations/20240916185330_add_quote_source_amount.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('quotes', function (table) { + table.bigInteger('debitAmountMinusFees').nullable() + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('quotes', function (table) { + table.dropColumn('debitAmountMinusFees') + }) +} diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index d4bb95d67c..3bbe8d2662 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -98,6 +98,7 @@ import { } from './open_payments/wallet_address/middleware' import { LoggingPlugin } from './graphql/plugin' +import { LocalPaymentService } from './payment-method/local/service' import { GrantService } from './open_payments/grant/service' import { AuthServerService } from './open_payments/authServer/service' export interface AppContextData { @@ -254,6 +255,7 @@ export interface AppServices { tigerBeetle?: Promise paymentMethodHandlerService: Promise ilpPaymentService: Promise + localPaymentService: Promise } export type AppContainer = IocContract diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 8d31918b48..b746db97d2 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -48,6 +48,10 @@ import { import { createHttpTokenService } from './payment-method/ilp/peer-http-token/service' import { createPeerService } from './payment-method/ilp/peer/service' import { createIlpPaymentService } from './payment-method/ilp/service' +import { + createLocalPaymentService, + ServiceDependencies as LocalPaymentServiceDependencies +} from './payment-method/local/service' import { createSPSPRoutes } from './payment-method/ilp/spsp/routes' import { createStreamCredentialsService } from './payment-method/ilp/stream-credentials/service' import { createRatesService } from './rates/service' @@ -438,11 +442,25 @@ export function initIocContainer( }) }) + container.singleton('localPaymentService', async (deps) => { + const serviceDependencies: LocalPaymentServiceDependencies = { + logger: await deps.use('logger'), + knex: await deps.use('knex'), + config: await deps.use('config'), + ratesService: await deps.use('ratesService'), + accountingService: await deps.use('accountingService'), + incomingPaymentService: await deps.use('incomingPaymentService') + } + + return createLocalPaymentService(serviceDependencies) + }) + container.singleton('paymentMethodHandlerService', async (deps) => { return createPaymentMethodHandlerService({ logger: await deps.use('logger'), knex: await deps.use('knex'), - ilpPaymentService: await deps.use('ilpPaymentService') + ilpPaymentService: await deps.use('ilpPaymentService'), + localPaymentService: await deps.use('localPaymentService') }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts index 7539f03407..4f0034839a 100644 --- a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts +++ b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts @@ -76,16 +76,37 @@ export async function handleSending( throw LifecycleError.BadState } - const stopTimer = deps.telemetry.startTimer('ilp_pay_time_ms', { - description: 'Time to complete an ILP payment', + const stopTimer = deps.telemetry.startTimer('pay_time_ms', { + description: 'Time to complete a payment', callName: 'PaymentMethodHandlerService:pay' }) - await deps.paymentMethodHandlerService.pay('ILP', { - receiver, - outgoingPayment: payment, - finalDebitAmount: maxDebitAmount, - finalReceiveAmount: maxReceiveAmount - }) + if (receiver.isLocal) { + if ( + !payment.quote.debitAmountMinusFees || + payment.quote.debitAmountMinusFees <= BigInt(0) + ) { + deps.logger.error( + { + debitAmountMinusFees: payment.quote.debitAmountMinusFees + }, + 'handleSending: quote.debitAmountMinusFees invalid' + ) + throw LifecycleError.BadState + } + await deps.paymentMethodHandlerService.pay('LOCAL', { + receiver, + outgoingPayment: payment, + finalDebitAmount: payment.quote.debitAmountMinusFees, + finalReceiveAmount: maxReceiveAmount + }) + } else { + await deps.paymentMethodHandlerService.pay('ILP', { + receiver, + outgoingPayment: payment, + finalDebitAmount: maxDebitAmount, + finalReceiveAmount: maxReceiveAmount + }) + } stopTimer() await Promise.all([ @@ -117,11 +138,7 @@ function getAdjustedAmounts( // This is only an approximation of the true amount delivered due to exchange rate variance. Due to connection failures there isn't a reliable way to track that in sync with the amount sent (particularly within ILP payments) // eslint-disable-next-line no-case-declarations const amountDelivered = BigInt( - Math.ceil( - Number(alreadySentAmount) * - (payment.quote.estimatedExchangeRate || - payment.quote.lowEstimatedExchangeRate.valueOf()) - ) + Math.ceil(Number(alreadySentAmount) * payment.quote.estimatedExchangeRate) ) let maxReceiveAmount = payment.receiveAmount.value - amountDelivered 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 240eefcef6..87251cb1ff 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -53,6 +53,7 @@ import { withConfigOverride } from '../../../tests/helpers' import { TelemetryService } from '../../../telemetry/service' import { getPageTests } from '../../../shared/baseModel.test' import { Pagination, SortOrder } from '../../../shared/baseModel' +import { ReceiverService } from '../../receiver/service' describe('OutgoingPaymentService', (): void => { let deps: IocContract @@ -72,6 +73,8 @@ describe('OutgoingPaymentService', (): void => { let amtDelivered: bigint let trx: Knex.Transaction let config: IAppConfig + let receiverService: ReceiverService + let receiverGet: typeof receiverService.get const asset: AssetOptions = { scale: 9, @@ -261,6 +264,7 @@ describe('OutgoingPaymentService', (): void => { telemetryService = (await deps.use('telemetry'))! config = await deps.use('config') knex = appContainer.knex + receiverService = await deps.use('receiverService') }) beforeEach(async (): Promise => { @@ -290,6 +294,22 @@ describe('OutgoingPaymentService', (): void => { receiver = incomingPayment.getUrl(receiverWalletAddress) amtDelivered = BigInt(0) + + // Make receivers non-local by default + receiverGet = receiverService.get + jest + .spyOn(receiverService, 'get') + .mockImplementation(async (url: string) => { + // call original instead of receiverService.get to avoid infinite loop + const receiver = await receiverGet.call(receiverService, url) + if (receiver) { + // "as any" to circumvent "readonly" check (compile time only) to allow overriding "isLocal" here + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(receiver.isLocal as any) = false + return receiver + } + return undefined + }) }) afterEach(async (): Promise => { diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 308057437f..8887235edd 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -528,7 +528,7 @@ async function validateGrantAndAddSpentAmountsToPayment( .andWhereNot({ id: payment.id }) - .withGraphFetched('[quote.asset]') + .withGraphFetched('quote.asset') if (grantPayments.length === 0) { return true @@ -605,7 +605,7 @@ async function fundPayment( const payment = await OutgoingPayment.query(trx) .findById(id) .forUpdate() - .withGraphFetched('[quote.asset]') + .withGraphFetched('quote.asset') if (!payment) return FundingError.UnknownPayment if (payment.state !== OutgoingPaymentState.Funding) { return FundingError.WrongState diff --git a/packages/backend/src/open_payments/quote/model.ts b/packages/backend/src/open_payments/quote/model.ts index 39631f7240..3c6bd6d135 100644 --- a/packages/backend/src/open_payments/quote/model.ts +++ b/packages/backend/src/open_payments/quote/model.ts @@ -1,6 +1,4 @@ import { Model, Pojo } from 'objection' -import * as Pay from '@interledger/pay' - import { Amount, serializeAmount } from '../amount' import { WalletAddress, @@ -15,24 +13,19 @@ export class Quote extends WalletAddressSubresource { public static readonly urlPath = '/quotes' static get virtualAttributes(): string[] { - return [ - 'debitAmount', - 'receiveAmount', - 'minExchangeRate', - 'lowEstimatedExchangeRate', - 'highEstimatedExchangeRate', - 'method' - ] + return ['debitAmount', 'receiveAmount', 'method'] } // Asset id of the sender public assetId!: string public asset!: Asset - public estimatedExchangeRate?: number + public estimatedExchangeRate!: number public feeId?: string public fee?: Fee + public debitAmountMinusFees?: bigint + static get relationMappings() { return { ...super.relationMappings, @@ -96,14 +89,6 @@ export class Quote extends WalletAddressSubresource { this.receiveAmountAssetScale = amount?.assetScale } - public maxPacketAmount!: bigint - private minExchangeRateNumerator!: bigint - private minExchangeRateDenominator!: bigint - private lowEstimatedExchangeRateNumerator!: bigint - private lowEstimatedExchangeRateDenominator!: bigint - private highEstimatedExchangeRateNumerator!: bigint - private highEstimatedExchangeRateDenominator!: bigint - public get maxSourceAmount(): bigint { return this.debitAmountValue } @@ -112,47 +97,6 @@ export class Quote extends WalletAddressSubresource { return this.receiveAmountValue } - public get minExchangeRate(): Pay.Ratio { - return Pay.Ratio.of( - Pay.Int.from(this.minExchangeRateNumerator) as Pay.PositiveInt, - Pay.Int.from(this.minExchangeRateDenominator) as Pay.PositiveInt - ) - } - - public set minExchangeRate(value: Pay.Ratio) { - this.minExchangeRateNumerator = value.a.value - this.minExchangeRateDenominator = value.b.value - } - - public get lowEstimatedExchangeRate(): Pay.Ratio { - return Pay.Ratio.of( - Pay.Int.from(this.lowEstimatedExchangeRateNumerator) as Pay.PositiveInt, - Pay.Int.from(this.lowEstimatedExchangeRateDenominator) as Pay.PositiveInt - ) - } - - public set lowEstimatedExchangeRate(value: Pay.Ratio) { - this.lowEstimatedExchangeRateNumerator = value.a.value - this.lowEstimatedExchangeRateDenominator = value.b.value - } - - // Note that the upper exchange rate bound is *exclusive*. - public get highEstimatedExchangeRate(): Pay.PositiveRatio { - const highEstimatedExchangeRate = Pay.Ratio.of( - Pay.Int.from(this.highEstimatedExchangeRateNumerator) as Pay.PositiveInt, - Pay.Int.from(this.highEstimatedExchangeRateDenominator) as Pay.PositiveInt - ) - if (!highEstimatedExchangeRate.isPositive()) { - throw new Error('high estimated exchange rate is not positive') - } - return highEstimatedExchangeRate - } - - public set highEstimatedExchangeRate(value: Pay.PositiveRatio) { - this.highEstimatedExchangeRateNumerator = value.a.value - this.highEstimatedExchangeRateDenominator = value.b.value - } - public get method(): 'ilp' { return 'ilp' } diff --git a/packages/backend/src/open_payments/quote/service.test.ts b/packages/backend/src/open_payments/quote/service.test.ts index 4bddd700f5..9e9c8712bf 100644 --- a/packages/backend/src/open_payments/quote/service.test.ts +++ b/packages/backend/src/open_payments/quote/service.test.ts @@ -30,11 +30,11 @@ import { Asset } from '../../asset/model' import { PaymentMethodHandlerService } from '../../payment-method/handler/service' import { ReceiverService } from '../receiver/service' import { createReceiver } from '../../tests/receiver' -import * as Pay from '@interledger/pay' import { PaymentMethodHandlerError, PaymentMethodHandlerErrorCode } from '../../payment-method/handler/errors' +import { Receiver } from '../receiver/model' describe('QuoteService', (): void => { let deps: IocContract @@ -46,6 +46,13 @@ describe('QuoteService', (): void => { let sendingWalletAddress: MockWalletAddress let receivingWalletAddress: MockWalletAddress let config: IAppConfig + let receiverGet: typeof receiverService.get + let receiverGetSpy: jest.SpyInstance< + Promise, + [url: string], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + > const asset: AssetOptions = { scale: 9, @@ -93,6 +100,22 @@ describe('QuoteService', (): void => { assetId: destinationAssetId, mockServerPort: appContainer.openPaymentsPort }) + + // Make receivers non-local by default + receiverGet = receiverService.get + receiverGetSpy = jest + .spyOn(receiverService, 'get') + .mockImplementation(async (url: string) => { + // call original instead of receiverService.get to avoid infinite loop + const receiver = await receiverGet.call(receiverService, url) + if (receiver) { + // "as any" to circumvent "readonly" check (compile time only) to allow overriding "isLocal" here + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(receiver.isLocal as any) = false + return receiver + } + return undefined + }) }) afterEach(async (): Promise => { @@ -191,7 +214,7 @@ describe('QuoteService', (): void => { }) }) - jest + const getQuoteSpy = jest .spyOn(paymentMethodHandlerService, 'getQuote') .mockResolvedValueOnce(mockedQuote) @@ -201,12 +224,23 @@ describe('QuoteService', (): void => { }) assert.ok(!isQuoteError(quote)) + expect(getQuoteSpy).toHaveBeenCalledTimes(1) + expect(getQuoteSpy).toHaveBeenCalledWith( + 'ILP', + expect.objectContaining({ + walletAddress: sendingWalletAddress, + receiver: expect.anything(), + receiveAmount: options.receiveAmount, + debitAmount: options.debitAmount + }), + expect.anything() + ) + expect(quote).toMatchObject({ walletAddressId: sendingWalletAddress.id, receiver: options.receiver, debitAmount: debitAmount || mockedQuote.debitAmount, receiveAmount: receiveAmount || mockedQuote.receiveAmount, - maxPacketAmount: BigInt('9223372036854775807'), createdAt: expect.any(Date), updatedAt: expect.any(Date), expiresAt: new Date( @@ -291,7 +325,6 @@ describe('QuoteService', (): void => { expect(quote).toMatchObject({ ...options, - maxPacketAmount: BigInt('9223372036854775807'), debitAmount: mockedQuote.debitAmount, receiveAmount: incomingAmount, createdAt: expect.any(Date), @@ -386,52 +419,6 @@ describe('QuoteService', (): void => { } ) - test('creates a quote with large exchange rate amounts', async (): Promise => { - const receiveAmountValue = 100n - const receiver = await createReceiver(deps, receivingWalletAddress, { - incomingAmount: { - assetCode: receivingWalletAddress.asset.code, - assetScale: receivingWalletAddress.asset.scale, - value: receiveAmountValue - } - }) - - const mockedQuote = mockQuote( - { - receiver, - walletAddress: sendingWalletAddress, - receiveAmountValue - }, - { - additionalFields: { - maxPacketAmount: Pay.Int.MAX_U64, - lowEstimatedExchangeRate: Pay.Ratio.from(10 ** 20), - highEstimatedExchangeRate: Pay.Ratio.from(10 ** 20), - minExchangeRate: Pay.Ratio.from(10 ** 20) - } - } - ) - - jest - .spyOn(paymentMethodHandlerService, 'getQuote') - .mockResolvedValueOnce(mockedQuote) - - await expect( - quoteService.create({ - walletAddressId: sendingWalletAddress.id, - receiver: receiver.incomingPayment!.id, - method: 'ilp' - }) - ).resolves.toMatchObject({ - debitAmount: mockedQuote.debitAmount, - receiveAmount: receiver.incomingAmount, - maxPacketAmount: BigInt('9223372036854775807'), - lowEstimatedExchangeRate: Pay.Ratio.from(10 ** 20), - highEstimatedExchangeRate: Pay.Ratio.from(10 ** 20), - minExchangeRate: Pay.Ratio.from(10 ** 20) - }) - }) - test('fails on unknown wallet address', async (): Promise => { await expect( quoteService.create({ @@ -744,5 +731,67 @@ describe('QuoteService', (): void => { ).resolves.toEqual(QuoteError.NonPositiveReceiveAmount) }) }) + + describe('Local Receiver', (): void => { + beforeEach(() => { + receiverGetSpy.mockRestore() + }) + test('Local receiver uses local payment method', async () => { + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: receivingWalletAddress.id, + incomingAmount + }) + + const options: CreateQuoteOptions = { + walletAddressId: sendingWalletAddress.id, + receiver: incomingPayment.getUrl(receivingWalletAddress), + method: 'ilp' + } + + const mockedQuote = mockQuote({ + receiver: (await receiverService.get( + incomingPayment.getUrl(receivingWalletAddress) + ))!, + walletAddress: sendingWalletAddress, + exchangeRate: 0.5, + debitAmountValue: debitAmount.value + }) + + const getQuoteSpy = jest + .spyOn(paymentMethodHandlerService, 'getQuote') + .mockResolvedValueOnce(mockedQuote) + + const quote = await quoteService.create(options) + assert.ok(!isQuoteError(quote)) + + expect(getQuoteSpy).toHaveBeenCalledTimes(1) + expect(getQuoteSpy).toHaveBeenCalledWith( + 'LOCAL', + expect.objectContaining({ + walletAddress: sendingWalletAddress, + receiver: expect.anything(), + receiveAmount: options.receiveAmount, + debitAmount: options.debitAmount + }), + expect.anything() + ) + + expect(quote).toMatchObject({ + walletAddressId: sendingWalletAddress.id, + receiver: options.receiver, + debitAmount: mockedQuote.debitAmount, + receiveAmount: mockedQuote.receiveAmount, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + expiresAt: new Date(quote.createdAt.getTime() + config.quoteLifespan) + }) + + await expect( + quoteService.get({ + id: quote.id + }) + ).resolves.toEqual(quote) + }) + }) }) }) diff --git a/packages/backend/src/open_payments/quote/service.ts b/packages/backend/src/open_payments/quote/service.ts index 67b3ebdc45..b8e96145af 100644 --- a/packages/backend/src/open_payments/quote/service.ts +++ b/packages/backend/src/open_payments/quote/service.ts @@ -1,5 +1,4 @@ import { TransactionOrKnex } from 'objection' -import * as Pay from '@interledger/pay' import { BaseService } from '../../shared/baseService' import { QuoteError, isQuoteError } from './errors' @@ -20,10 +19,9 @@ import { PaymentMethodHandlerError, PaymentMethodHandlerErrorCode } from '../../payment-method/handler/errors' +import { v4 as uuid } from 'uuid' import { TelemetryService } from '../../telemetry/service' -const MAX_INT64 = BigInt('9223372036854775807') - export interface QuoteService extends WalletAddressSubresourceService { create(options: CreateQuoteOptions): Promise } @@ -131,37 +129,10 @@ async function createQuote( } ) const receiver = await resolveReceiver(deps, options) - stopTimerReceiver - - 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('ILP', { - walletAddress, - receiver, - receiveAmount: options.receiveAmount, - debitAmount: options.debitAmount - }) - stopTimerQuote() + stopTimerReceiver() - const maxPacketAmount = quote.additionalFields.maxPacketAmount as bigint - - 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 paymentMethod = receiver.isLocal ? 'LOCAL' : 'ILP' + const quoteId = uuid() return await Quote.transaction(deps.knex, async (trx) => { const stopQuoteCreate = deps.telemetry.startTimer( @@ -171,20 +142,47 @@ async function createQuote( 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, - maxPacketAmount: - MAX_INT64 < maxPacketAmount ? MAX_INT64 : maxPacketAmount, // Cap at MAX_INT64 because of postgres type limits. - minExchangeRate: quote.additionalFields.minExchangeRate as Pay.Ratio, - lowEstimatedExchangeRate: quote.additionalFields - .lowEstimatedExchangeRate as Pay.Ratio, - highEstimatedExchangeRate: quote.additionalFields - .highEstimatedExchangeRate as Pay.PositiveRatio, expiresAt: new Date(0), // expiresAt is patched in finalizeQuote client: options.client, feeId: sendingFee?.id, @@ -273,6 +271,7 @@ export async function resolveReceiver( interface CalculateQuoteAmountsWithFeesResult { receiveAmountValue: bigint debitAmountValue: bigint + debitAmountMinusFees: bigint } /** @@ -284,10 +283,10 @@ function calculateFixedSendQuoteAmounts( quote: Quote, maxReceiveAmountValue: bigint ): CalculateQuoteAmountsWithFeesResult { + // TODO: derive fee from debitAmount instead? Current behavior/tests may be wrong with basis point fees. const fees = quote.fee?.calculate(quote.receiveAmount.value) ?? BigInt(0) - const estimatedExchangeRate = - quote.estimatedExchangeRate || quote.lowEstimatedExchangeRate.valueOf() + const { estimatedExchangeRate } = quote const exchangeAdjustedFees = BigInt( Math.ceil(Number(fees) * estimatedExchangeRate) @@ -307,9 +306,15 @@ function calculateFixedSendQuoteAmounts( throw QuoteError.InvalidAmount } + const debitAmountMinusFees = + quote.debitAmount.value - + (quote.fee?.calculate(quote.debitAmount.value) ?? 0n) + deps.logger.debug( { + 'quote.receiveAmount.value': quote.receiveAmount.value, debitAmountValue: quote.debitAmount.value, + debitAmountMinusFees, receiveAmountValue, fees, exchangeAdjustedFees @@ -319,6 +324,7 @@ function calculateFixedSendQuoteAmounts( return { debitAmountValue: quote.debitAmount.value, + debitAmountMinusFees, receiveAmountValue } } @@ -350,6 +356,7 @@ function calculateFixedDeliveryQuoteAmounts( return { debitAmountValue, + debitAmountMinusFees: quote.debitAmount.value, receiveAmountValue: quote.receiveAmount.value } } @@ -400,11 +407,13 @@ async function finalizeQuote( `Calculating ${maxReceiveAmountValue ? 'fixed-send' : 'fixed-delivery'} quote amount with fees` ) - const { debitAmountValue, receiveAmountValue } = maxReceiveAmountValue - ? calculateFixedSendQuoteAmounts(deps, quote, maxReceiveAmountValue) - : calculateFixedDeliveryQuoteAmounts(deps, quote) + const { debitAmountValue, debitAmountMinusFees, receiveAmountValue } = + maxReceiveAmountValue + ? calculateFixedSendQuoteAmounts(deps, quote, maxReceiveAmountValue) + : calculateFixedDeliveryQuoteAmounts(deps, quote) const patchOptions = { + debitAmountMinusFees, debitAmountValue, receiveAmountValue, expiresAt: calculateExpiry(deps, quote, receiver) diff --git a/packages/backend/src/payment-method/handler/service.test.ts b/packages/backend/src/payment-method/handler/service.test.ts index 2a80feed5c..254963967c 100644 --- a/packages/backend/src/payment-method/handler/service.test.ts +++ b/packages/backend/src/payment-method/handler/service.test.ts @@ -15,12 +15,15 @@ import { createReceiver } from '../../tests/receiver' import { IlpPaymentService } from '../ilp/service' import { truncateTables } from '../../tests/tableManager' import { createOutgoingPaymentWithReceiver } from '../../tests/outgoingPayment' +import { LocalPaymentService } from '../local/service' +import { v4 as uuid } from 'uuid' describe('PaymentMethodHandlerService', (): void => { let deps: IocContract let appContainer: TestContainer let paymentMethodHandlerService: PaymentMethodHandlerService let ilpPaymentService: IlpPaymentService + let localPaymentService: LocalPaymentService beforeAll(async (): Promise => { deps = initIocContainer(Config) @@ -28,6 +31,7 @@ describe('PaymentMethodHandlerService', (): void => { paymentMethodHandlerService = await deps.use('paymentMethodHandlerService') ilpPaymentService = await deps.use('ilpPaymentService') + localPaymentService = await deps.use('localPaymentService') }) afterEach(async (): Promise => { @@ -47,6 +51,7 @@ describe('PaymentMethodHandlerService', (): void => { }) const options: StartQuoteOptions = { + quoteId: uuid(), walletAddress, receiver: await createReceiver(deps, walletAddress), debitAmount: { @@ -62,7 +67,37 @@ describe('PaymentMethodHandlerService', (): void => { await paymentMethodHandlerService.getQuote('ILP', options) - expect(ilpPaymentServiceGetQuoteSpy).toHaveBeenCalledWith(options) + expect(ilpPaymentServiceGetQuoteSpy).toHaveBeenCalledWith( + options, + undefined + ) + }) + test('calls localPaymentService for local payment type', async (): Promise => { + const asset = await createAsset(deps) + const walletAddress = await createWalletAddress(deps, { + assetId: asset.id + }) + + const options: StartQuoteOptions = { + walletAddress, + receiver: await createReceiver(deps, walletAddress), + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + } + + const localPaymentServiceGetQuoteSpy = jest + .spyOn(localPaymentService, 'getQuote') + .mockImplementationOnce(jest.fn()) + + await paymentMethodHandlerService.getQuote('LOCAL', options) + + expect(localPaymentServiceGetQuoteSpy).toHaveBeenCalledWith( + options, + undefined + ) }) }) @@ -101,5 +136,39 @@ describe('PaymentMethodHandlerService', (): void => { expect(ilpPaymentServicePaySpy).toHaveBeenCalledWith(options) }) + test('calls localPaymentService for local payment type', async (): Promise => { + const asset = await createAsset(deps) + const walletAddress = await createWalletAddress(deps, { + assetId: asset.id + }) + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddress, + receivingWalletAddress: walletAddress, + method: 'ilp', + quoteOptions: { + debitAmount: { + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale, + value: 100n + } + } + }) + + const options: PayOptions = { + receiver, + outgoingPayment, + finalDebitAmount: outgoingPayment.debitAmount.value, + finalReceiveAmount: outgoingPayment.receiveAmount.value + } + + const localPaymentServicePaySpy = jest + .spyOn(localPaymentService, 'pay') + .mockImplementationOnce(jest.fn()) + + await paymentMethodHandlerService.pay('LOCAL', options) + + expect(localPaymentServicePaySpy).toHaveBeenCalledWith(options) + }) }) }) diff --git a/packages/backend/src/payment-method/handler/service.ts b/packages/backend/src/payment-method/handler/service.ts index 670d46ac4a..281007bee9 100644 --- a/packages/backend/src/payment-method/handler/service.ts +++ b/packages/backend/src/payment-method/handler/service.ts @@ -4,8 +4,11 @@ import { WalletAddress } from '../../open_payments/wallet_address/model' import { Receiver } from '../../open_payments/receiver/model' import { BaseService } from '../../shared/baseService' import { IlpPaymentService } from '../ilp/service' +import { LocalPaymentService } from '../local/service' +import { Transaction } from 'objection' export interface StartQuoteOptions { + quoteId?: string walletAddress: WalletAddress debitAmount?: Amount receiveAmount?: Amount @@ -18,7 +21,6 @@ export interface PaymentQuote { debitAmount: Amount receiveAmount: Amount estimatedExchangeRate: number - additionalFields: Record } export interface PayOptions { @@ -29,28 +31,34 @@ export interface PayOptions { } export interface PaymentMethodService { - getQuote(quoteOptions: StartQuoteOptions): Promise + getQuote( + quoteOptions: StartQuoteOptions, + trx?: Transaction + ): Promise pay(payOptions: PayOptions): Promise } -export type PaymentMethod = 'ILP' +export type PaymentMethod = 'ILP' | 'LOCAL' export interface PaymentMethodHandlerService { getQuote( method: PaymentMethod, - quoteOptions: StartQuoteOptions + quoteOptions: StartQuoteOptions, + trx?: Transaction ): Promise pay(method: PaymentMethod, payOptions: PayOptions): Promise } interface ServiceDependencies extends BaseService { ilpPaymentService: IlpPaymentService + localPaymentService: LocalPaymentService } export async function createPaymentMethodHandlerService({ logger, knex, - ilpPaymentService + ilpPaymentService, + localPaymentService }: ServiceDependencies): Promise { const log = logger.child({ service: 'PaymentMethodHandlerService' @@ -58,16 +66,18 @@ export async function createPaymentMethodHandlerService({ const deps: ServiceDependencies = { logger: log, knex, - ilpPaymentService + ilpPaymentService, + localPaymentService } const paymentMethods: { [key in PaymentMethod]: PaymentMethodService } = { - ILP: deps.ilpPaymentService + ILP: deps.ilpPaymentService, + LOCAL: deps.localPaymentService } return { - getQuote: (method, quoteOptions) => - paymentMethods[method].getQuote(quoteOptions), + getQuote: (method, quoteOptions, trx) => + paymentMethods[method].getQuote(quoteOptions, trx), pay: (method, payOptions) => paymentMethods[method].pay(payOptions) } } diff --git a/packages/backend/src/payment-method/ilp/connector/core/factories/rafiki-services.ts b/packages/backend/src/payment-method/ilp/connector/core/factories/rafiki-services.ts index 561c2486bc..d1aa2392d8 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/factories/rafiki-services.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/factories/rafiki-services.ts @@ -49,7 +49,14 @@ export const RafikiServicesFactory = Factory.define( await accounting._getByIncomingToken(token) })) .attr('rates', { - convert: async (opts) => opts.sourceAmount, + convertSource: async (opts) => ({ + amount: opts.sourceAmount, + scaledExchangeRate: 1 + }), + convertDestination: async (opts) => ({ + amount: opts.destinationAmount, + scaledExchangeRate: 1 + }), rates: () => { throw new Error('unimplemented') } 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 512ef863d1..f8beb0b487 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 @@ -6,6 +6,7 @@ import { } from '../../../../../accounting/errors' import { Transaction, TransferType } from '../../../../../accounting/service' import { Config as AppConfig } from '../../../../../config/app' +import { isConvertError } from '../../../../../rates/service' const { CannotReceiveError, InsufficientLiquidityError } = Errors export function createBalanceMiddleware(): ILPMiddleware { @@ -35,13 +36,12 @@ export function createBalanceMiddleware(): ILPMiddleware { } const sourceAmount = BigInt(amount) - const destinationAmountOrError = await services.rates.convert({ + const destinationAmountOrError = await services.rates.convertSource({ sourceAmount, sourceAsset: accounts.incoming.asset, destinationAsset: accounts.outgoing.asset }) - if (typeof destinationAmountOrError !== 'bigint') { - // ConvertError + if (isConvertError(destinationAmountOrError)) { logger.error( { amount, @@ -55,8 +55,9 @@ export function createBalanceMiddleware(): ILPMiddleware { `Exchange rate error: ${destinationAmountOrError}` ) } + const { amount: destinationAmount } = destinationAmountOrError - request.prepare.amount = destinationAmountOrError.toString() + request.prepare.amount = destinationAmount.toString() if (state.unfulfillable) { await next() @@ -71,7 +72,7 @@ export function createBalanceMiddleware(): ILPMiddleware { sourceAccount: accounts.incoming, destinationAccount: accounts.outgoing, sourceAmount, - destinationAmount: destinationAmountOrError, + destinationAmount, transferType: TransferType.TRANSFER, timeout: AppConfig.tigerBeetleTwoPhaseTimeout } diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/balance-middleware.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/balance-middleware.test.ts index c3661b7fb5..3f4163eddb 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/balance-middleware.test.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/balance-middleware.test.ts @@ -73,9 +73,10 @@ describe('Balance Middleware', function () { ctx.response.fulfill = fulfill }) const destinationAmount = BigInt(200) - jest - .spyOn(rates, 'convert') - .mockImplementationOnce(async () => destinationAmount) + jest.spyOn(rates, 'convertSource').mockImplementationOnce(async () => ({ + amount: destinationAmount, + scaledExchangeRate: 1 + })) await expect(middleware(ctx, next)).resolves.toBeUndefined() diff --git a/packages/backend/src/payment-method/ilp/quote-details/model.ts b/packages/backend/src/payment-method/ilp/quote-details/model.ts new file mode 100644 index 0000000000..b0d8ed4ac3 --- /dev/null +++ b/packages/backend/src/payment-method/ilp/quote-details/model.ts @@ -0,0 +1,65 @@ +import { BaseModel } from '../../../shared/baseModel' +import * as Pay from '@interledger/pay' + +export class IlpQuoteDetails extends BaseModel { + public static readonly tableName = 'ilpQuoteDetails' + + static get virtualAttributes(): string[] { + return [ + 'minExchangeRate', + 'lowEstimatedExchangeRate', + 'highEstimatedExchangeRate' + ] + } + + public quoteId!: string + + public maxPacketAmount!: bigint + public minExchangeRateNumerator!: bigint + public minExchangeRateDenominator!: bigint + public lowEstimatedExchangeRateNumerator!: bigint + public lowEstimatedExchangeRateDenominator!: bigint + public highEstimatedExchangeRateNumerator!: bigint + public highEstimatedExchangeRateDenominator!: bigint + + public get minExchangeRate(): Pay.Ratio { + return Pay.Ratio.of( + Pay.Int.from(this.minExchangeRateNumerator) as Pay.PositiveInt, + Pay.Int.from(this.minExchangeRateDenominator) as Pay.PositiveInt + ) + } + + public set minExchangeRate(value: Pay.Ratio) { + this.minExchangeRateNumerator = value.a.value + this.minExchangeRateDenominator = value.b.value + } + + public get lowEstimatedExchangeRate(): Pay.Ratio { + return Pay.Ratio.of( + Pay.Int.from(this.lowEstimatedExchangeRateNumerator) as Pay.PositiveInt, + Pay.Int.from(this.lowEstimatedExchangeRateDenominator) as Pay.PositiveInt + ) + } + + public set lowEstimatedExchangeRate(value: Pay.Ratio) { + this.lowEstimatedExchangeRateNumerator = value.a.value + this.lowEstimatedExchangeRateDenominator = value.b.value + } + + // Note that the upper exchange rate bound is *exclusive*. + public get highEstimatedExchangeRate(): Pay.PositiveRatio { + const highEstimatedExchangeRate = Pay.Ratio.of( + Pay.Int.from(this.highEstimatedExchangeRateNumerator) as Pay.PositiveInt, + Pay.Int.from(this.highEstimatedExchangeRateDenominator) as Pay.PositiveInt + ) + if (!highEstimatedExchangeRate.isPositive()) { + throw new Error('high estimated exchange rate is not positive') + } + return highEstimatedExchangeRate + } + + public set highEstimatedExchangeRate(value: Pay.PositiveRatio) { + this.highEstimatedExchangeRateNumerator = value.a.value + this.highEstimatedExchangeRateDenominator = value.b.value + } +} diff --git a/packages/backend/src/payment-method/ilp/service.test.ts b/packages/backend/src/payment-method/ilp/service.test.ts index 5aa742821c..b2f37b7025 100644 --- a/packages/backend/src/payment-method/ilp/service.test.ts +++ b/packages/backend/src/payment-method/ilp/service.test.ts @@ -11,6 +11,8 @@ import { withConfigOverride } from '../../tests/helpers' import { StartQuoteOptions } from '../handler/service' import { WalletAddress } from '../../open_payments/wallet_address/model' import * as Pay from '@interledger/pay' +import { Ratio, Int, PaymentType } from '@interledger/pay' +import assert from 'assert' import { createReceiver } from '../../tests/receiver' import { mockRatesApi } from '../../tests/rates' @@ -23,6 +25,8 @@ import { AccountingService } from '../../accounting/service' import { IncomingPayment } from '../../open_payments/payment/incoming/model' import { truncateTables } from '../../tests/tableManager' import { createOutgoingPaymentWithReceiver } from '../../tests/outgoingPayment' +import { v4 as uuid } from 'uuid' +import { IlpQuoteDetails } from './quote-details/model' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -90,6 +94,7 @@ describe('IlpPaymentService', (): void => { const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { + quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD']), debitAmount: { @@ -108,6 +113,156 @@ describe('IlpPaymentService', (): void => { ratesScope.done() }) + test('inserts ilpQuoteDetails', async (): Promise => { + const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const quoteId = uuid() + const options: StartQuoteOptions = { + quoteId, + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD']), + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + } + + const highEstimatedExchangeRate = Ratio.of(Int.ONE, Int.TWO) + const lowEstimatedExchangeRate = Ratio.from(0.5) + const minExchangeRate = Ratio.from(0.5) + + assert(highEstimatedExchangeRate) + assert(lowEstimatedExchangeRate) + assert(minExchangeRate) + + const mockIlpQuote = { + paymentType: PaymentType.FixedDelivery, + maxSourceAmount: BigInt(500), + minDeliveryAmount: BigInt(400), + highEstimatedExchangeRate, + lowEstimatedExchangeRate, + minExchangeRate, + maxPacketAmount: BigInt('9223372036854775807') + } + + jest.spyOn(Pay, 'startQuote').mockResolvedValue(mockIlpQuote) + + await ilpPaymentService.getQuote(options) + + const ilpQuoteDetails = await IlpQuoteDetails.query() + .where({ quoteId }) + .first() + + ilpQuoteDetails?.lowEstimatedExchangeRate + + expect(ilpQuoteDetails).toMatchObject({ + quoteId, + maxPacketAmount: mockIlpQuote.maxPacketAmount, + minExchangeRate: mockIlpQuote.minExchangeRate, + minExchangeRateNumerator: mockIlpQuote.minExchangeRate.a.toString(), + minExchangeRateDenominator: mockIlpQuote.minExchangeRate.b.toString(), + lowEstimatedExchangeRate: mockIlpQuote.lowEstimatedExchangeRate, + lowEstimatedExchangeRateNumerator: + mockIlpQuote.lowEstimatedExchangeRate.a.toString(), + lowEstimatedExchangeRateDenominator: + mockIlpQuote.lowEstimatedExchangeRate.b.toString(), + highEstimatedExchangeRate: mockIlpQuote.highEstimatedExchangeRate, + highEstimatedExchangeRateNumerator: + mockIlpQuote.highEstimatedExchangeRate.a.toString(), + highEstimatedExchangeRateDenominator: + mockIlpQuote.highEstimatedExchangeRate.b.toString() + }) + ratesScope.done() + }) + + test('creates a quote with large exchange rate amounts', async (): Promise => { + const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const quoteId = uuid() + const options: StartQuoteOptions = { + quoteId, + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD']), + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + } + + const highEstimatedExchangeRate = Ratio.of(Int.MAX_U64, Int.ONE) + const lowEstimatedExchangeRate = Ratio.from(10 ** 20) + const minExchangeRate = Ratio.from(0.5) + + assert(highEstimatedExchangeRate) + assert(lowEstimatedExchangeRate) + assert(minExchangeRate) + + const mockIlpQuote = { + paymentType: PaymentType.FixedDelivery, + maxSourceAmount: BigInt(500), + minDeliveryAmount: BigInt(400), + highEstimatedExchangeRate, + lowEstimatedExchangeRate, + minExchangeRate, + maxPacketAmount: BigInt('9223372036854775807') + } + + jest.spyOn(Pay, 'startQuote').mockResolvedValue(mockIlpQuote) + + await ilpPaymentService.getQuote(options) + + const ilpQuoteDetails = await IlpQuoteDetails.query() + .where({ quoteId }) + .first() + + ilpQuoteDetails?.lowEstimatedExchangeRate + + expect(ilpQuoteDetails).toMatchObject({ + quoteId, + maxPacketAmount: mockIlpQuote.maxPacketAmount, + minExchangeRate: mockIlpQuote.minExchangeRate, + minExchangeRateNumerator: mockIlpQuote.minExchangeRate.a.toString(), + minExchangeRateDenominator: mockIlpQuote.minExchangeRate.b.toString(), + lowEstimatedExchangeRate: mockIlpQuote.lowEstimatedExchangeRate, + lowEstimatedExchangeRateNumerator: + mockIlpQuote.lowEstimatedExchangeRate.a.toString(), + lowEstimatedExchangeRateDenominator: + mockIlpQuote.lowEstimatedExchangeRate.b.toString(), + highEstimatedExchangeRate: mockIlpQuote.highEstimatedExchangeRate, + highEstimatedExchangeRateNumerator: + mockIlpQuote.highEstimatedExchangeRate.a.toString(), + highEstimatedExchangeRateDenominator: + mockIlpQuote.highEstimatedExchangeRate.b.toString() + }) + ratesScope.done() + }) + + test('Throws if quoteId is not provided', async (): Promise => { + const options: StartQuoteOptions = { + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD']), + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + } + + expect.assertions(4) + try { + await ilpPaymentService.getQuote(options) + } catch (error) { + expect(error).toBeInstanceOf(PaymentMethodHandlerError) + expect((error as PaymentMethodHandlerError).message).toBe( + 'Received error during ILP quoting' + ) + expect((error as PaymentMethodHandlerError).description).toBe( + 'quoteId is required for ILP quotes' + ) + expect((error as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + test('fails on rate service error', async (): Promise => { const ratesService = await deps.use('ratesService') jest @@ -117,6 +272,7 @@ describe('IlpPaymentService', (): void => { expect.assertions(4) try { await ilpPaymentService.getQuote({ + quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD']), debitAmount: { @@ -141,6 +297,7 @@ describe('IlpPaymentService', (): void => { const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { + quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD']), debitAmount: { @@ -163,13 +320,7 @@ describe('IlpPaymentService', (): void => { assetScale: 2, value: 99n }, - estimatedExchangeRate: expect.any(Number), - additionalFields: { - minExchangeRate: expect.any(Pay.Ratio), - highEstimatedExchangeRate: expect.any(Pay.Ratio), - lowEstimatedExchangeRate: expect.any(Pay.Ratio), - maxPacketAmount: BigInt(Pay.Int.MAX_U64.toString()) - } + estimatedExchangeRate: expect.any(Number) }) ratesScope.done() }) @@ -184,6 +335,7 @@ describe('IlpPaymentService', (): void => { } const options: StartQuoteOptions = { + quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD'], { incomingAmount @@ -218,6 +370,7 @@ describe('IlpPaymentService', (): void => { expect.assertions(4) try { await ilpPaymentService.getQuote({ + quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD']), debitAmount: { @@ -243,6 +396,7 @@ describe('IlpPaymentService', (): void => { const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { + quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD']) } @@ -272,6 +426,7 @@ describe('IlpPaymentService', (): void => { const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { + quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD'], { incomingAmount: { @@ -311,6 +466,7 @@ describe('IlpPaymentService', (): void => { const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { + quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD']) } @@ -371,6 +527,7 @@ describe('IlpPaymentService', (): void => { const sendingWalletAddress = walletAddressMap[debitAssetCode] const options: StartQuoteOptions = { + quoteId: uuid(), walletAddress: sendingWalletAddress, receiver: await createReceiver(deps, receivingWalletAddress), receiveAmount: { @@ -430,6 +587,7 @@ describe('IlpPaymentService', (): void => { const sendingWalletAddress = walletAddressMap[debitAssetCode] const options: StartQuoteOptions = { + quoteId: uuid(), walletAddress: sendingWalletAddress, receiver: await createReceiver(deps, receivingWalletAddress), debitAmount: { diff --git a/packages/backend/src/payment-method/ilp/service.ts b/packages/backend/src/payment-method/ilp/service.ts index 665c92add8..017ab8bdf2 100644 --- a/packages/backend/src/payment-method/ilp/service.ts +++ b/packages/backend/src/payment-method/ilp/service.ts @@ -15,6 +15,10 @@ import { PaymentMethodHandlerErrorCode } from '../handler/errors' import { TelemetryService } from '../../telemetry/service' +import { IlpQuoteDetails } from './quote-details/model' +import { Transaction } from 'objection' + +const MAX_INT64 = BigInt('9223372036854775807') export interface IlpPaymentService extends PaymentMethodService {} @@ -34,15 +38,22 @@ export async function createIlpPaymentService( } return { - getQuote: (quoteOptions) => getQuote(deps, quoteOptions), + getQuote: (quoteOptions, trx) => getQuote(deps, quoteOptions, trx), pay: (payOptions) => pay(deps, payOptions) } } async function getQuote( deps: ServiceDependencies, - options: StartQuoteOptions + options: StartQuoteOptions, + trx?: Transaction ): Promise { + if (!options.quoteId) { + throw new PaymentMethodHandlerError('Received error during ILP quoting', { + description: 'quoteId is required for ILP quotes', + retryable: false + }) + } const stopTimerRates = deps.telemetry.startTimer( 'ilp_get_quote_rate_time_ms', { @@ -171,6 +182,18 @@ async function getQuote( }) } + await IlpQuoteDetails.query(trx ?? deps.knex).insert({ + quoteId: options.quoteId, + lowEstimatedExchangeRate: ilpQuote.lowEstimatedExchangeRate, + highEstimatedExchangeRate: ilpQuote.highEstimatedExchangeRate, + minExchangeRate: ilpQuote.minExchangeRate, + maxPacketAmount: + // Cap at MAX_INT64 because of postgres type limits + MAX_INT64 < ilpQuote.maxPacketAmount + ? MAX_INT64 + : ilpQuote.maxPacketAmount + }) + return { receiver: options.receiver, walletAddress: options.walletAddress, @@ -184,12 +207,6 @@ async function getQuote( value: ilpQuote.minDeliveryAmount, assetCode: options.receiver.assetCode, assetScale: options.receiver.assetScale - }, - additionalFields: { - lowEstimatedExchangeRate: ilpQuote.lowEstimatedExchangeRate, - highEstimatedExchangeRate: ilpQuote.highEstimatedExchangeRate, - minExchangeRate: ilpQuote.minExchangeRate, - maxPacketAmount: ilpQuote.maxPacketAmount } } } finally { @@ -255,12 +272,26 @@ async function pay( }) } + const ilpQuoteDetails = await IlpQuoteDetails.query(deps.knex) + .where('quoteId', outgoingPayment.quote.id) + .first() + + if (!ilpQuoteDetails) { + throw new PaymentMethodHandlerError( + 'Could not find required ILP Quote Details', + { + description: 'ILP Quote Details not found', + retryable: false + } + ) + } + const { lowEstimatedExchangeRate, highEstimatedExchangeRate, minExchangeRate, maxPacketAmount - } = outgoingPayment.quote + } = ilpQuoteDetails const quote: Pay.Quote = { maxPacketAmount, diff --git a/packages/backend/src/payment-method/local/service.test.ts b/packages/backend/src/payment-method/local/service.test.ts new file mode 100644 index 0000000000..94cff4100e --- /dev/null +++ b/packages/backend/src/payment-method/local/service.test.ts @@ -0,0 +1,654 @@ +import { LocalPaymentService } from './service' +import { initIocContainer } from '../../' +import { createTestApp, TestContainer } from '../../tests/app' +import { Config } from '../../config/app' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' +import { createAsset } from '../../tests/asset' +import { createWalletAddress } from '../../tests/walletAddress' +import { Asset } from '../../asset/model' +import { StartQuoteOptions } from '../handler/service' +import { WalletAddress } from '../../open_payments/wallet_address/model' + +import { createReceiver } from '../../tests/receiver' +import { mockRatesApi } from '../../tests/rates' +import { AccountingService, Transaction } from '../../accounting/service' +import { truncateTables } from '../../tests/tableManager' +import { createOutgoingPaymentWithReceiver } from '../../tests/outgoingPayment' +import { OutgoingPayment } from '../../open_payments/payment/outgoing/model' +import { + IncomingPayment, + IncomingPaymentState +} from '../../open_payments/payment/incoming/model' +import { IncomingPaymentService } from '../../open_payments/payment/incoming/service' +import { errorToMessage, TransferError } from '../../accounting/errors' +import { PaymentMethodHandlerError } from '../handler/errors' +import { ConvertError } from '../../rates/service' + +const nock = (global as unknown as { nock: typeof import('nock') }).nock + +describe('LocalPaymentService', (): void => { + let deps: IocContract + let appContainer: TestContainer + let localPaymentService: LocalPaymentService + let accountingService: AccountingService + let incomingPaymentService: IncomingPaymentService + + const exchangeRatesUrl = 'https://example-rates.com' + + const assetMap: Record = {} + const walletAddressMap: Record = {} + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + exchangeRatesUrl, + exchangeRatesLifetime: 0 + }) + appContainer = await createTestApp(deps) + + localPaymentService = await deps.use('localPaymentService') + accountingService = await deps.use('accountingService') + incomingPaymentService = await deps.use('incomingPaymentService') + }) + + beforeEach(async (): Promise => { + assetMap['USD'] = await createAsset(deps, { + code: 'USD', + scale: 2 + }) + + assetMap['EUR'] = await createAsset(deps, { + code: 'EUR', + scale: 2 + }) + + walletAddressMap['USD'] = await createWalletAddress(deps, { + assetId: assetMap['USD'].id + }) + + walletAddressMap['EUR'] = await createWalletAddress(deps, { + assetId: assetMap['EUR'].id + }) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + jest.restoreAllMocks() + + nock.cleanAll() + nock.abortPendingRequests() + nock.restore() + nock.activate() + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('getQuote', (): void => { + test('fails on unknown rate service error', async (): Promise => { + const ratesService = await deps.use('ratesService') + jest + .spyOn(ratesService, 'convertSource') + .mockImplementation(() => Promise.reject(new Error('fail'))) + + expect.assertions(4) + try { + await localPaymentService.getQuote({ + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD']), + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + }) + } catch (err) { + expect(err).toBeInstanceOf(PaymentMethodHandlerError) + expect((err as PaymentMethodHandlerError).message).toBe( + 'Received error during local quoting' + ) + expect((err as PaymentMethodHandlerError).description).toBe( + 'Unknown error while attempting to convert rates' + ) + expect((err as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + + test('fails on rate service error', async (): Promise => { + const ratesService = await deps.use('ratesService') + jest + .spyOn(ratesService, 'convertSource') + .mockImplementation(() => + Promise.resolve(ConvertError.InvalidDestinationPrice) + ) + + expect.assertions(4) + try { + await localPaymentService.getQuote({ + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD']), + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + }) + } catch (err) { + expect(err).toBeInstanceOf(PaymentMethodHandlerError) + expect((err as PaymentMethodHandlerError).message).toBe( + 'Received error during local quoting' + ) + expect((err as PaymentMethodHandlerError).description).toBe( + 'Failed to convert debitAmount to receive amount' + ) + expect((err as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + + test('returns all fields correctly', async (): Promise => { + const options: StartQuoteOptions = { + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD']), + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + } + + await expect(localPaymentService.getQuote(options)).resolves.toEqual({ + receiver: options.receiver, + walletAddress: options.walletAddress, + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: 100n + }, + receiveAmount: { + assetCode: 'USD', + assetScale: 2, + value: 100n + }, + estimatedExchangeRate: 1 + }) + }) + + test('fails if debit amount is non-positive', async (): Promise => { + expect.assertions(4) + try { + await localPaymentService.getQuote({ + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD']), + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: 0n + } + }) + } catch (err) { + expect(err).toBeInstanceOf(PaymentMethodHandlerError) + expect((err as PaymentMethodHandlerError).message).toBe( + 'Received error during local quoting' + ) + expect((err as PaymentMethodHandlerError).description).toBe( + 'debit amount of local quote is non-positive' + ) + expect((err as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + test('fails if receive amount is non-positive', async (): Promise => { + const ratesService = await deps.use('ratesService') + jest + .spyOn(ratesService, 'convertDestination') + .mockImplementation(() => + Promise.resolve({ amount: 100n, scaledExchangeRate: 1 }) + ) + expect.assertions(4) + try { + await localPaymentService.getQuote({ + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD']), + receiveAmount: { + assetCode: 'USD', + assetScale: 2, + value: 0n + } + }) + } catch (err) { + expect(err).toBeInstanceOf(PaymentMethodHandlerError) + expect((err as PaymentMethodHandlerError).message).toBe( + 'Received error during local quoting' + ) + expect((err as PaymentMethodHandlerError).description).toBe( + 'receive amount of local quote is non-positive' + ) + expect((err as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + + test('uses receiver.incomingAmount if receiveAmount is not provided', async (): Promise => { + const incomingAmount = { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + + const options: StartQuoteOptions = { + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD'], { + incomingAmount + }) + } + + await expect( + localPaymentService.getQuote(options) + ).resolves.toMatchObject({ + receiveAmount: { + assetCode: 'USD', + assetScale: 2, + value: incomingAmount.value + } + }) + }) + + describe('successfully gets local quote', (): void => { + describe('with incomingAmount', () => { + test.each` + incomingAssetCode | incomingAmountValue | debitAssetCode | expectedDebitAmount | exchangeRate | description + ${'USD'} | ${100n} | ${'USD'} | ${100n} | ${null} | ${'local currency'} + ${'EUR'} | ${100n} | ${'USD'} | ${100n} | ${1.0} | ${'cross currency, same rate'} + ${'EUR'} | ${100n} | ${'USD'} | ${112n} | ${0.9} | ${'cross currency, exchange rate < 1'} + ${'EUR'} | ${100n} | ${'USD'} | ${50n} | ${2.0} | ${'cross currency, exchange rate > 1'} + `( + '$description', + async ({ + incomingAssetCode, + incomingAmountValue, + debitAssetCode, + expectedDebitAmount, + exchangeRate + }): Promise => { + let ratesScope + + if (incomingAssetCode !== debitAssetCode) { + ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + [incomingAssetCode]: exchangeRate + })) + } + + const receivingWalletAddress = walletAddressMap[incomingAssetCode] + const sendingWalletAddress = walletAddressMap[debitAssetCode] + + const options: StartQuoteOptions = { + walletAddress: sendingWalletAddress, + receiver: await createReceiver(deps, receivingWalletAddress), + receiveAmount: { + assetCode: receivingWalletAddress.asset.code, + assetScale: receivingWalletAddress.asset.scale, + value: incomingAmountValue + } + } + + const quote = await localPaymentService.getQuote(options) + + expect(quote).toMatchObject({ + debitAmount: { + assetCode: sendingWalletAddress.asset.code, + assetScale: sendingWalletAddress.asset.scale, + value: expectedDebitAmount + }, + receiveAmount: { + assetCode: receivingWalletAddress.asset.code, + assetScale: receivingWalletAddress.asset.scale, + value: incomingAmountValue + } + }) + ratesScope && ratesScope.done() + } + ) + }) + + describe('with debitAmount', () => { + test.each` + debitAssetCode | debitAmountValue | incomingAssetCode | expectedReceiveAmount | exchangeRate | description + ${'USD'} | ${100n} | ${'USD'} | ${100n} | ${null} | ${'local currency'} + ${'EUR'} | ${100n} | ${'USD'} | ${100n} | ${1.0} | ${'cross currency, same rate'} + ${'USD'} | ${100n} | ${'EUR'} | ${90n} | ${0.9} | ${'cross currency, exchange rate < 1'} + ${'USD'} | ${100n} | ${'EUR'} | ${200n} | ${2.0} | ${'cross currency, exchange rate > 1'} + `( + '$description', + async ({ + incomingAssetCode, + debitAmountValue, + debitAssetCode, + expectedReceiveAmount, + exchangeRate + }): Promise => { + let ratesScope + + if (debitAssetCode !== incomingAssetCode) { + ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + [incomingAssetCode]: exchangeRate + })) + } + + const receivingWalletAddress = walletAddressMap[incomingAssetCode] + const sendingWalletAddress = walletAddressMap[debitAssetCode] + + const options: StartQuoteOptions = { + walletAddress: sendingWalletAddress, + receiver: await createReceiver(deps, receivingWalletAddress), + debitAmount: { + assetCode: sendingWalletAddress.asset.code, + assetScale: sendingWalletAddress.asset.scale, + value: debitAmountValue + } + } + + const quote = await localPaymentService.getQuote(options) + + expect(quote).toMatchObject({ + debitAmount: { + assetCode: sendingWalletAddress.asset.code, + assetScale: sendingWalletAddress.asset.scale, + value: debitAmountValue + }, + receiveAmount: { + assetCode: receivingWalletAddress.asset.code, + assetScale: receivingWalletAddress.asset.scale, + value: expectedReceiveAmount + } + }) + ratesScope && ratesScope.done() + } + ) + }) + }) + }) + + describe('pay', (): void => { + async function validateBalances( + outgoingPayment: OutgoingPayment, + incomingPayment: IncomingPayment, + { + amountSent, + amountReceived + }: { + amountSent: bigint + amountReceived: bigint + } + ) { + await expect( + accountingService.getTotalSent(outgoingPayment.id) + ).resolves.toBe(amountSent) + await expect( + accountingService.getTotalReceived(incomingPayment.id) + ).resolves.toEqual(amountReceived) + } + + test('succesfully make local payment', async (): Promise => { + const { incomingPayment, receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + } + }) + + const payResponse = await localPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 100n, + finalReceiveAmount: 100n + }) + + expect(payResponse).toBe(undefined) + + await validateBalances(outgoingPayment, incomingPayment, { + amountSent: 100n, + amountReceived: 100n + }) + }) + + test('throws error if incoming payment is not found', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + } + }) + + jest.spyOn(incomingPaymentService, 'get').mockResolvedValueOnce(undefined) + + expect.assertions(4) + try { + await localPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 100n, + finalReceiveAmount: 100n + }) + } catch (err) { + expect(err).toBeInstanceOf(PaymentMethodHandlerError) + expect((err as PaymentMethodHandlerError).message).toBe( + 'Received error during local payment' + ) + expect((err as PaymentMethodHandlerError).description).toBe( + 'Incoming payment not found from receiver' + ) + expect((err as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + + test('throws error if incoming payment state is not pending', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + } + }) + + jest.spyOn(incomingPaymentService, 'get').mockResolvedValueOnce({ + state: IncomingPaymentState.Processing + } as IncomingPayment) + + expect.assertions(4) + try { + await localPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 100n, + finalReceiveAmount: 100n + }) + } catch (err) { + expect(err).toBeInstanceOf(PaymentMethodHandlerError) + expect((err as PaymentMethodHandlerError).message).toBe( + 'Bad Incoming Payment State' + ) + expect((err as PaymentMethodHandlerError).description).toBe( + `Incoming Payment state should be ${IncomingPaymentState.Pending}` + ) + expect((err as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + + test('throws InsufficientBalance when balance is insufficient', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + } + }) + + jest + .spyOn(accountingService, 'createTransfer') + .mockResolvedValueOnce(TransferError.InsufficientBalance) + + expect.assertions(4) + try { + await localPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 100n, + finalReceiveAmount: 100n + }) + } catch (err) { + expect(err).toBeInstanceOf(PaymentMethodHandlerError) + expect((err as PaymentMethodHandlerError).message).toBe( + 'Received error during local payment' + ) + expect((err as PaymentMethodHandlerError).description).toBe( + errorToMessage[TransferError.InsufficientBalance] + ) + expect((err as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + + test('throws InsufficientLiquidityError when liquidity is insufficient', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + } + }) + + jest + .spyOn(accountingService, 'createTransfer') + .mockResolvedValueOnce(TransferError.InsufficientLiquidity) + + expect.assertions(4) + try { + await localPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 100n, + finalReceiveAmount: 100n + }) + } catch (err) { + expect(err).toBeInstanceOf(PaymentMethodHandlerError) + expect((err as PaymentMethodHandlerError).message).toBe( + 'Received error during local payment' + ) + expect((err as PaymentMethodHandlerError).description).toBe( + errorToMessage[TransferError.InsufficientLiquidity] + ) + expect((err as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + + test('throws generic error for unknown transfer error', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + } + }) + + jest + .spyOn(accountingService, 'createTransfer') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce('UnknownError' as any) + + expect.assertions(4) + try { + await localPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 100n, + finalReceiveAmount: 100n + }) + } catch (err) { + expect(err).toBeInstanceOf(PaymentMethodHandlerError) + expect((err as PaymentMethodHandlerError).message).toBe( + 'Received error during local payment' + ) + expect((err as PaymentMethodHandlerError).description).toBe( + 'Unknown error while trying to create transfer' + ) + expect((err as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + + test('throws error when transfer post fails', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + } + }) + + jest.spyOn(accountingService, 'createTransfer').mockResolvedValueOnce({ + post: () => Promise.resolve(TransferError.UnknownTransfer) + } as Transaction) + + expect.assertions(4) + try { + await localPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 100n, + finalReceiveAmount: 100n + }) + } catch (err) { + expect(err).toBeInstanceOf(PaymentMethodHandlerError) + expect((err as PaymentMethodHandlerError).message).toBe( + 'Received error during local payment' + ) + expect((err as PaymentMethodHandlerError).description).toBe( + errorToMessage[TransferError.UnknownTransfer] + ) + expect((err as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + }) +}) diff --git a/packages/backend/src/payment-method/local/service.ts b/packages/backend/src/payment-method/local/service.ts new file mode 100644 index 0000000000..9521b00cef --- /dev/null +++ b/packages/backend/src/payment-method/local/service.ts @@ -0,0 +1,304 @@ +import { BaseService } from '../../shared/baseService' +import { + PaymentQuote, + PaymentMethodService, + StartQuoteOptions, + PayOptions +} from '../handler/service' +import { + ConvertError, + isConvertError, + RateConvertDestinationOpts, + RateConvertSourceOpts, + RatesService +} from '../../rates/service' +import { IAppConfig } from '../../config/app' +import { + PaymentMethodHandlerError, + PaymentMethodHandlerErrorCode +} from '../handler/errors' +import { + AccountingService, + LiquidityAccountType, + TransferOptions, + TransferType +} from '../../accounting/service' +import { + AccountAlreadyExistsError, + errorToMessage, + isTransferError, + TransferError +} from '../../accounting/errors' +import { IncomingPaymentService } from '../../open_payments/payment/incoming/service' +import { IncomingPaymentState } from '../../open_payments/payment/incoming/model' +import { ConvertResults } from '../../rates/util' + +export interface LocalPaymentService extends PaymentMethodService {} + +export interface ServiceDependencies extends BaseService { + config: IAppConfig + ratesService: RatesService + accountingService: AccountingService + incomingPaymentService: IncomingPaymentService +} + +export async function createLocalPaymentService( + deps_: ServiceDependencies +): Promise { + const deps: ServiceDependencies = { + ...deps_, + logger: deps_.logger.child({ service: 'LocalPaymentService' }) + } + + return { + getQuote: (quoteOptions) => getQuote(deps, quoteOptions), + pay: (payOptions) => pay(deps, payOptions) + } +} + +async function getQuote( + deps: ServiceDependencies, + options: StartQuoteOptions +): Promise { + const { receiver, debitAmount, receiveAmount, walletAddress } = options + + let debitAmountValue: bigint + let receiveAmountValue: bigint + let exchangeRate: number + + const convert = async ( + opts: RateConvertSourceOpts | RateConvertDestinationOpts + ): Promise => { + let convertResults: ConvertResults | ConvertError + try { + convertResults = + 'sourceAmount' in opts + ? await deps.ratesService.convertSource(opts) + : await deps.ratesService.convertDestination(opts) + } catch (err) { + deps.logger.error( + { opts, err }, + 'Unknown error while attempting to convert rates' + ) + throw new PaymentMethodHandlerError( + 'Received error during local quoting', + { + description: 'Unknown error while attempting to convert rates', + retryable: false + } + ) + } + return convertResults + } + + if (debitAmount) { + debitAmountValue = debitAmount.value + const convertResults = await convert({ + sourceAmount: debitAmountValue, + sourceAsset: { + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + destinationAsset: { + code: receiver.assetCode, + scale: receiver.assetScale + } + }) + if (isConvertError(convertResults)) { + throw new PaymentMethodHandlerError( + 'Received error during local quoting', + { + description: 'Failed to convert debitAmount to receive amount', + retryable: false + } + ) + } + receiveAmountValue = convertResults.amount + exchangeRate = convertResults.scaledExchangeRate + } else if (receiveAmount) { + receiveAmountValue = receiveAmount.value + const convertResults = await convert({ + destinationAmount: receiveAmountValue, + sourceAsset: { + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + destinationAsset: { + code: receiveAmount.assetCode, + scale: receiveAmount.assetScale + } + }) + if (isConvertError(convertResults)) { + throw new PaymentMethodHandlerError( + 'Received error during local quoting', + { + description: 'Failed to convert receiveAmount to debitAmount', + retryable: false + } + ) + } + debitAmountValue = convertResults.amount + 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 + }, + destinationAsset: { + code: receiver.incomingAmount.assetCode, + scale: receiver.incomingAmount.assetScale + } + }) + if (isConvertError(convertResults)) { + throw new PaymentMethodHandlerError( + 'Received error during local quoting', + { + description: + 'Failed to convert receiver.incomingAmount to debitAmount', + retryable: false + } + ) + } + debitAmountValue = convertResults.amount + exchangeRate = convertResults.scaledExchangeRate + } else { + throw new PaymentMethodHandlerError('Received error during local quoting', { + description: 'No value provided to get quote from', + retryable: false + }) + } + + if (debitAmountValue <= BigInt(0)) { + throw new PaymentMethodHandlerError('Received error during local quoting', { + description: 'debit amount of local quote is non-positive', + retryable: false + }) + } + + if (receiveAmountValue <= BigInt(0)) { + throw new PaymentMethodHandlerError('Received error during local quoting', { + description: 'receive amount of local quote is non-positive', + retryable: false, + code: PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount + }) + } + + return { + receiver: options.receiver, + walletAddress: options.walletAddress, + estimatedExchangeRate: exchangeRate, + debitAmount: { + value: debitAmountValue, + assetCode: options.walletAddress.asset.code, + assetScale: options.walletAddress.asset.scale + }, + receiveAmount: { + value: receiveAmountValue, + assetCode: options.receiver.assetCode, + assetScale: options.receiver.assetScale + } + } +} + +async function pay( + deps: ServiceDependencies, + options: PayOptions +): Promise { + const { outgoingPayment, receiver, finalReceiveAmount, finalDebitAmount } = + options + + const incomingPaymentId = receiver.incomingPayment.id.split('/').pop() + if (!incomingPaymentId) { + throw new PaymentMethodHandlerError('Received error during local payment', { + description: 'Failed to parse incoming payment on receiver', + retryable: false + }) + } + const incomingPayment = await deps.incomingPaymentService.get({ + id: incomingPaymentId + }) + if (!incomingPayment) { + throw new PaymentMethodHandlerError('Received error during local payment', { + description: 'Incoming payment not found from receiver', + retryable: false + }) + } + if (incomingPayment.state !== IncomingPaymentState.Pending) { + throw new PaymentMethodHandlerError('Bad Incoming Payment State', { + description: `Incoming Payment state should be ${IncomingPaymentState.Pending}`, + retryable: false + }) + } + + try { + await deps.accountingService.createLiquidityAccount( + incomingPayment, + LiquidityAccountType.INCOMING + ) + } catch (err) { + if (!(err instanceof AccountAlreadyExistsError)) { + deps.logger.error( + { incomingPayment, err }, + 'Failed to create liquidity account for local incoming payment' + ) + throw new PaymentMethodHandlerError( + 'Received error during local payment', + { + description: 'Unknown error while trying to create liquidity account', + retryable: false + } + ) + } + } + + const transferOptions: TransferOptions = { + sourceAccount: outgoingPayment, + destinationAccount: incomingPayment, + sourceAmount: finalDebitAmount, + destinationAmount: finalReceiveAmount, + transferType: TransferType.TRANSFER, + // TODO: remove timeout after implementing single phase transfer + // https://github.com/interledger/rafiki/issues/2629 + timeout: deps.config.tigerBeetleTwoPhaseTimeout + } + + const trxOrError = + await deps.accountingService.createTransfer(transferOptions) + + if (isTransferError(trxOrError)) { + deps.logger.error( + { transferOptions, transferError: trxOrError }, + 'Could not create transfer' + ) + switch (trxOrError) { + case TransferError.InsufficientBalance: + case TransferError.InsufficientLiquidity: + throw new PaymentMethodHandlerError( + 'Received error during local payment', + { + description: errorToMessage[trxOrError], + retryable: false + } + ) + default: + throw new PaymentMethodHandlerError( + 'Received error during local payment', + { + description: 'Unknown error while trying to create transfer', + retryable: false + } + ) + } + } + const transferError = await trxOrError.post() + + if (isTransferError(transferError)) { + throw new PaymentMethodHandlerError('Received error during local payment', { + description: errorToMessage[transferError], + retryable: false + }) + } +} diff --git a/packages/backend/src/rates/service.test.ts b/packages/backend/src/rates/service.test.ts index 8145a3cd56..fb7a709cca 100644 --- a/packages/backend/src/rates/service.test.ts +++ b/packages/backend/src/rates/service.test.ts @@ -59,7 +59,7 @@ describe('Rates service', function () { await appContainer.shutdown() }) - describe('convert', () => { + describe('convertSource', () => { beforeAll(() => { mockRatesApi(exchangeRatesUrl, (base) => { apiRequestCount++ @@ -76,68 +76,83 @@ describe('Rates service', function () { it('returns the source amount when assets are alike', async () => { await expect( - service.convert({ + service.convertSource({ sourceAmount: 1234n, sourceAsset: { code: 'USD', scale: 9 }, destinationAsset: { code: 'USD', scale: 9 } }) - ).resolves.toBe(1234n) + ).resolves.toEqual({ + amount: 1234n, + scaledExchangeRate: 1 + }) expect(apiRequestCount).toBe(0) }) it('scales the source amount when currencies are alike but scales are different', async () => { await expect( - service.convert({ + service.convertSource({ sourceAmount: 123n, sourceAsset: { code: 'USD', scale: 9 }, destinationAsset: { code: 'USD', scale: 12 } }) - ).resolves.toBe(123_000n) + ).resolves.toEqual({ + amount: 123_000n, + scaledExchangeRate: 1000 + }) await expect( - service.convert({ + service.convertSource({ sourceAmount: 123456n, sourceAsset: { code: 'USD', scale: 12 }, destinationAsset: { code: 'USD', scale: 9 } }) - ).resolves.toBe(123n) + ).resolves.toEqual({ + amount: 123n, + scaledExchangeRate: 0.001 + }) expect(apiRequestCount).toBe(0) }) it('returns the converted amount when assets are different', async () => { const sourceAmount = 500 await expect( - service.convert({ + service.convertSource({ sourceAmount: BigInt(sourceAmount), sourceAsset: { code: 'USD', scale: 2 }, destinationAsset: { code: 'EUR', scale: 2 } }) - ).resolves.toBe(BigInt(sourceAmount * exampleRates.USD.EUR)) + ).resolves.toEqual({ + amount: BigInt(sourceAmount * exampleRates.USD.EUR), + scaledExchangeRate: exampleRates.USD.EUR + }) await expect( - service.convert({ + service.convertSource({ sourceAmount: BigInt(sourceAmount), sourceAsset: { code: 'EUR', scale: 2 }, destinationAsset: { code: 'USD', scale: 2 } }) - ).resolves.toBe(BigInt(sourceAmount * exampleRates.EUR.USD)) + ).resolves.toEqual({ + amount: BigInt(sourceAmount * exampleRates.EUR.USD), + scaledExchangeRate: exampleRates.EUR.USD + }) }) it('returns an error when an asset price is invalid', async () => { await expect( - service.convert({ + service.convertSource({ sourceAmount: 1234n, sourceAsset: { code: 'USD', scale: 2 }, destinationAsset: { code: 'MISSING', scale: 2 } }) ).resolves.toBe(ConvertError.InvalidDestinationPrice) await expect( - service.convert({ + service.convertSource({ sourceAmount: 1234n, sourceAsset: { code: 'USD', scale: 2 }, destinationAsset: { code: 'ZERO', scale: 2 } }) ).resolves.toBe(ConvertError.InvalidDestinationPrice) await expect( - service.convert({ + service.convertSource({ sourceAmount: 1234n, sourceAsset: { code: 'USD', scale: 2 }, destinationAsset: { code: 'NEGATIVE', scale: 2 } diff --git a/packages/backend/src/rates/service.ts b/packages/backend/src/rates/service.ts index f4cae1f819..e7d55d6a72 100644 --- a/packages/backend/src/rates/service.ts +++ b/packages/backend/src/rates/service.ts @@ -1,6 +1,12 @@ import { BaseService } from '../shared/baseService' import Axios, { AxiosInstance, isAxiosError } from 'axios' -import { convert, ConvertOptions } from './util' +import { + ConvertResults, + ConvertSourceOptions, + ConvertDestinationOptions, + convertDestination, + convertSource +} from './util' import { createInMemoryDataStore } from '../middleware/cache/data-stores/in-memory' import { CacheDataStore } from '../middleware/cache/data-stores' @@ -11,11 +17,20 @@ export interface Rates { rates: Record } +export type RateConvertSourceOpts = Omit +export type RateConvertDestinationOpts = Omit< + ConvertDestinationOptions, + 'exchangeRate' +> + export interface RatesService { rates(baseAssetCode: string): Promise - convert( - opts: Omit - ): Promise + convertSource( + opts: RateConvertSourceOpts + ): Promise + convertDestination( + opts: RateConvertDestinationOpts + ): Promise } interface ServiceDependencies extends BaseService { @@ -51,22 +66,48 @@ class RatesServiceImpl implements RatesService { this.cachedRates = createInMemoryDataStore(deps.exchangeRatesLifetime) } - async convert( - opts: Omit - ): Promise { - const sameCode = opts.sourceAsset.code === opts.destinationAsset.code - const sameScale = opts.sourceAsset.scale === opts.destinationAsset.scale - if (sameCode && sameScale) return opts.sourceAmount - if (sameCode) return convert({ exchangeRate: 1.0, ...opts }) + async convert( + opts: T, + convertFn: ( + opts: T & { exchangeRate: number } + ) => ConvertResults | ConvertError + ): Promise { + const { sourceAsset, destinationAsset } = opts + const sameCode = sourceAsset.code === destinationAsset.code + const sameScale = sourceAsset.scale === destinationAsset.scale + + if (sameCode && sameScale) { + const amount = + 'sourceAmount' in opts ? opts.sourceAmount : opts.destinationAmount + return { + amount, + scaledExchangeRate: 1 + } + } - const { rates } = await this.getRates(opts.sourceAsset.code) + if (sameCode) { + return convertFn({ ...opts, exchangeRate: 1.0 }) + } - const destinationExchangeRate = rates[opts.destinationAsset.code] + const { rates } = await this.getRates(sourceAsset.code) + const destinationExchangeRate = rates[destinationAsset.code] if (!destinationExchangeRate || !isValidPrice(destinationExchangeRate)) { return ConvertError.InvalidDestinationPrice } - return convert({ exchangeRate: destinationExchangeRate, ...opts }) + return convertFn({ ...opts, exchangeRate: destinationExchangeRate }) + } + + async convertSource( + opts: RateConvertSourceOpts + ): Promise { + return this.convert(opts, convertSource) + } + + async convertDestination( + opts: RateConvertDestinationOpts + ): Promise { + return this.convert(opts, convertDestination) } async rates(baseAssetCode: string): Promise { diff --git a/packages/backend/src/rates/util.test.ts b/packages/backend/src/rates/util.test.ts index eb2526f074..129f9a4631 100644 --- a/packages/backend/src/rates/util.test.ts +++ b/packages/backend/src/rates/util.test.ts @@ -1,16 +1,16 @@ -import { convert, Asset } from './util' +import { convertSource, Asset, convertDestination } from './util' describe('Rates util', () => { describe('convert', () => { describe('convert same scales', () => { test.each` - exchangeRate | sourceAmount | assetScale | expectedResult | description - ${1.5} | ${100n} | ${9} | ${150n} | ${'exchange rate above 1'} - ${1.1602} | ${12345n} | ${2} | ${14323n} | ${'exchange rate above 1 with rounding up'} - ${1.1602} | ${10001n} | ${2} | ${11603n} | ${'exchange rate above 1 with rounding down'} - ${0.5} | ${100n} | ${9} | ${50n} | ${'exchange rate below 1'} - ${0.5} | ${101n} | ${9} | ${51n} | ${'exchange rate below 1 with rounding up'} - ${0.8611} | ${1000n} | ${2} | ${861n} | ${'exchange rate below 1 with rounding down'} + exchangeRate | sourceAmount | assetScale | expectedResult | description + ${1.5} | ${100n} | ${9} | ${{ amount: 150n, scaledExchangeRate: 1.5 }} | ${'exchange rate above 1'} + ${1.1602} | ${12345n} | ${2} | ${{ amount: 14323n, scaledExchangeRate: 1.1602 }} | ${'exchange rate above 1 with rounding up'} + ${1.1602} | ${10001n} | ${2} | ${{ amount: 11603n, scaledExchangeRate: 1.1602 }} | ${'exchange rate above 1 with rounding down'} + ${0.5} | ${100n} | ${9} | ${{ amount: 50n, scaledExchangeRate: 0.5 }} | ${'exchange rate below 1'} + ${0.5} | ${101n} | ${9} | ${{ amount: 51n, scaledExchangeRate: 0.5 }} | ${'exchange rate below 1 with rounding up'} + ${0.8611} | ${1000n} | ${2} | ${{ amount: 861n, scaledExchangeRate: 0.8611 }} | ${'exchange rate below 1 with rounding down'} `( '$description', async ({ @@ -20,22 +20,22 @@ describe('Rates util', () => { expectedResult }): Promise => { expect( - convert({ + convertSource({ exchangeRate, sourceAmount, sourceAsset: createAsset(assetScale), destinationAsset: createAsset(assetScale) }) - ).toBe(expectedResult) + ).toEqual(expectedResult) } ) }) describe('convert different scales', () => { test.each` - exchangeRate | sourceAmount | sourceAssetScale | destinationAssetScale | expectedResult | description - ${1.5} | ${100n} | ${9} | ${12} | ${150_000n} | ${'convert scale from low to high'} - ${1.5} | ${100_000n} | ${12} | ${9} | ${150n} | ${'convert scale from high to low'} + exchangeRate | sourceAmount | sourceAssetScale | destinationAssetScale | expectedResult | description + ${1.5} | ${100n} | ${9} | ${12} | ${{ amount: 150_000n, scaledExchangeRate: 1500 }} | ${'convert scale from low to high'} + ${1.5} | ${100_000n} | ${12} | ${9} | ${{ amount: 150n, scaledExchangeRate: 0.0015 }} | ${'convert scale from high to low'} `( '$description', async ({ @@ -46,13 +46,67 @@ describe('Rates util', () => { expectedResult }): Promise => { expect( - convert({ + convertSource({ exchangeRate, sourceAmount, sourceAsset: createAsset(sourceAssetScale), destinationAsset: createAsset(destinationAssetScale) }) - ).toBe(expectedResult) + ).toEqual(expectedResult) + } + ) + }) + }) + describe('convert reverse', () => { + describe('convert same scales', () => { + test.each` + exchangeRate | destinationAmount | assetScale | expectedResult | description + ${2.0} | ${100n} | ${9} | ${{ amount: 50n, scaledExchangeRate: 2.0 }} | ${'exchange rate above 1'} + ${1.1602} | ${12345n} | ${2} | ${{ amount: 10641n, scaledExchangeRate: 1.1602 }} | ${'exchange rate above 1 with rounding up'} + ${0.5} | ${100n} | ${9} | ${{ amount: 200n, scaledExchangeRate: 0.5 }} | ${'exchange rate below 1'} + ${0.8611} | ${1000n} | ${2} | ${{ amount: 1162n, scaledExchangeRate: 0.8611 }} | ${'exchange rate below 1 with rounding up'} + `( + '$description', + async ({ + exchangeRate, + destinationAmount, + assetScale, + expectedResult + }): Promise => { + expect( + convertDestination({ + exchangeRate, + destinationAmount, + sourceAsset: createAsset(assetScale), + destinationAsset: createAsset(assetScale) + }) + ).toEqual(expectedResult) + } + ) + }) + + describe('convert different scales', () => { + test.each` + exchangeRate | destinationAmount | sourceAssetScale | destinationAssetScale | expectedResult | description + ${2.0} | ${100n} | ${9} | ${12} | ${{ amount: 50_000n, scaledExchangeRate: 0.002 }} | ${'convert scale from low to high'} + ${2.0} | ${100_000n} | ${12} | ${9} | ${{ amount: 50n, scaledExchangeRate: 2000 }} | ${'convert scale from high to low'} + `( + '$description', + async ({ + exchangeRate, + destinationAmount, + sourceAssetScale, + destinationAssetScale, + expectedResult + }): Promise => { + expect( + convertDestination({ + exchangeRate, + destinationAmount, + sourceAsset: createAsset(sourceAssetScale), + destinationAsset: createAsset(destinationAssetScale) + }) + ).toEqual(expectedResult) } ) }) diff --git a/packages/backend/src/rates/util.ts b/packages/backend/src/rates/util.ts index 5cb911ce63..5f5d35c3da 100644 --- a/packages/backend/src/rates/util.ts +++ b/packages/backend/src/rates/util.ts @@ -1,4 +1,4 @@ -export interface ConvertOptions { +export interface ConvertSourceOptions { // The raw exchange rate, not including the scale difference. exchangeRate: number sourceAmount: bigint @@ -6,14 +6,43 @@ export interface ConvertOptions { destinationAsset: Asset } +export interface ConvertDestinationOptions { + exchangeRate: number + destinationAmount: bigint + sourceAsset: Asset + destinationAsset: Asset +} + export interface Asset { code: string scale: number } -export function convert(opts: ConvertOptions): bigint { +export interface ConvertResults { + amount: bigint + scaledExchangeRate: number +} + +export function convertSource(opts: ConvertSourceOptions): ConvertResults { const scaleDiff = opts.destinationAsset.scale - opts.sourceAsset.scale const scaledExchangeRate = opts.exchangeRate * 10 ** scaleDiff - return BigInt(Math.round(Number(opts.sourceAmount) * scaledExchangeRate)) + return { + amount: BigInt(Math.round(Number(opts.sourceAmount) * scaledExchangeRate)), + scaledExchangeRate + } +} + +export function convertDestination( + opts: ConvertDestinationOptions +): ConvertResults { + const scaleDiff = opts.sourceAsset.scale - opts.destinationAsset.scale + const scaledExchangeRate = opts.exchangeRate * 10 ** scaleDiff + + return { + amount: BigInt( + Math.ceil(Number(opts.destinationAmount) / scaledExchangeRate) + ), + scaledExchangeRate + } } diff --git a/packages/backend/src/telemetry/service.test.ts b/packages/backend/src/telemetry/service.test.ts index fa148ab942..c8e59f025f 100644 --- a/packages/backend/src/telemetry/service.test.ts +++ b/packages/backend/src/telemetry/service.test.ts @@ -151,7 +151,7 @@ describe('Telemetry Service', () => { describe('incrementCounterWithTransactionAmountDifference', () => { it('should not record fee when there is no fee value', async () => { - const spyAseConvert = jest.spyOn(aseRatesService, 'convert') + const spyAseConvert = jest.spyOn(aseRatesService, 'convertSource') const spyIncCounter = jest.spyOn(telemetryService, 'incrementCounter') await telemetryService.incrementCounterWithTransactionAmountDifference( @@ -173,7 +173,7 @@ describe('Telemetry Service', () => { }) it('should not record fee negative fee value', async () => { - const spyConvert = jest.spyOn(aseRatesService, 'convert') + const spyConvert = jest.spyOn(aseRatesService, 'convertSource') const spyIncCounter = jest.spyOn(telemetryService, 'incrementCounter') await telemetryService.incrementCounterWithTransactionAmountDifference( @@ -195,7 +195,7 @@ describe('Telemetry Service', () => { }) it('should not record zero amounts', async () => { - const spyConvert = jest.spyOn(aseRatesService, 'convert') + const spyConvert = jest.spyOn(aseRatesService, 'convertSource') const spyIncCounter = jest.spyOn(telemetryService, 'incrementCounter') await telemetryService.incrementCounterWithTransactionAmountDifference( @@ -217,7 +217,7 @@ describe('Telemetry Service', () => { }) it('should record since it is a valid fee', async () => { - const spyConvert = jest.spyOn(aseRatesService, 'convert') + const spyConvert = jest.spyOn(aseRatesService, 'convertSource') const spyIncCounter = jest.spyOn(telemetryService, 'incrementCounter') const source = { @@ -261,7 +261,7 @@ describe('Telemetry Service', () => { }) it('should record since it is a valid fee for different assets', async () => { - const spyConvert = jest.spyOn(aseRatesService, 'convert') + const spyConvert = jest.spyOn(aseRatesService, 'convertSource') const spyIncCounter = jest.spyOn(telemetryService, 'incrementCounter') const source = { @@ -308,13 +308,15 @@ describe('Telemetry Service', () => { describe('incrementCounterWithTransactionAmount', () => { it('should try to convert using aseRatesService and fallback to internalRatesService', async () => { const aseConvertSpy = jest - .spyOn(aseRatesService, 'convert') + .spyOn(aseRatesService, 'convertSource') .mockImplementation(() => Promise.resolve(ConvertError.InvalidDestinationPrice) ) const internalConvertSpy = jest - .spyOn(internalRatesService, 'convert') - .mockImplementation(() => Promise.resolve(10000n)) + .spyOn(internalRatesService, 'convertSource') + .mockImplementation(() => + Promise.resolve({ amount: 10_000n, scaledExchangeRate: 1 }) + ) await telemetryService.incrementCounterWithTransactionAmount( 'test_counter', @@ -331,9 +333,14 @@ describe('Telemetry Service', () => { it('should not call the fallback internalRatesService if aseRatesService call is successful', async () => { const aseConvertSpy = jest - .spyOn(aseRatesService, 'convert') - .mockImplementation(() => Promise.resolve(500n)) - const internalConvertSpy = jest.spyOn(internalRatesService, 'convert') + .spyOn(aseRatesService, 'convertSource') + .mockImplementation(() => + Promise.resolve({ amount: 500n, scaledExchangeRate: 1 }) + ) + const internalConvertSpy = jest.spyOn( + internalRatesService, + 'convertSource' + ) await telemetryService.incrementCounterWithTransactionAmount( 'test_counter', diff --git a/packages/backend/src/telemetry/service.ts b/packages/backend/src/telemetry/service.ts index dd705b2548..30426df973 100644 --- a/packages/backend/src/telemetry/service.ts +++ b/packages/backend/src/telemetry/service.ts @@ -2,7 +2,7 @@ import { Counter, Histogram, MetricOptions, metrics } from '@opentelemetry/api' import { MeterProvider } from '@opentelemetry/sdk-metrics' import { RatesService, isConvertError } from '../rates/service' -import { ConvertOptions } from '../rates/util' +import { ConvertSourceOptions } from '../rates/util' import { BaseService } from '../shared/baseService' import { privacy } from './privacy' @@ -144,7 +144,7 @@ export class TelemetryServiceImpl implements TelemetryService { return } - const diff = BigInt(convertedSource - convertedDestination) + const diff = BigInt(convertedSource.amount - convertedDestination.amount) if (diff === 0n) return if (diff < 0n) { @@ -196,14 +196,14 @@ export class TelemetryServiceImpl implements TelemetryService { } private async convertAmount( - convertOptions: Pick + convertOptions: Pick ) { const destinationAsset = { code: this.deps.baseAssetCode, scale: this.deps.baseScale } - let converted = await this.aseRatesService.convert({ + let converted = await this.aseRatesService.convertSource({ ...convertOptions, destinationAsset }) @@ -211,7 +211,7 @@ export class TelemetryServiceImpl implements TelemetryService { this.deps.logger.error( `Unable to convert amount from provided rates: ${converted}` ) - converted = await this.internalRatesService.convert({ + converted = await this.internalRatesService.convertSource({ ...convertOptions, destinationAsset }) diff --git a/packages/backend/src/tests/quote.ts b/packages/backend/src/tests/quote.ts index 40dfe78db1..bf6e844d28 100644 --- a/packages/backend/src/tests/quote.ts +++ b/packages/backend/src/tests/quote.ts @@ -9,6 +9,8 @@ import { CreateQuoteOptions } from '../open_payments/quote/service' import { PaymentQuote } from '../payment-method/handler/service' import { WalletAddress } from '../open_payments/wallet_address/model' import { Receiver } from '../open_payments/receiver/model' +import { IlpQuoteDetails } from '../payment-method/ilp/quote-details/model' +import { v4 as uuid } from 'uuid' export type CreateTestQuoteOptions = CreateQuoteOptions & { exchangeRate?: number @@ -48,12 +50,6 @@ export function mockQuote( : BigInt(Math.ceil(Number(args.debitAmountValue) * exchangeRate)) }, estimatedExchangeRate: exchangeRate, - additionalFields: { - maxPacketAmount: BigInt(Pay.Int.MAX_U64.toString()), - lowEstimatedExchangeRate: Pay.Ratio.from(exchangeRate ?? 1), - highEstimatedExchangeRate: Pay.Ratio.from(exchangeRate ?? 1), - minExchangeRate: Pay.Ratio.from(exchangeRate ?? 1) - }, ...overrides } } @@ -158,32 +154,35 @@ export async function createQuote( } } + const quoteId = uuid() + await IlpQuoteDetails.query().insert({ + quoteId, + lowEstimatedExchangeRate: Pay.Ratio.from(exchangeRate), + highEstimatedExchangeRate: Pay.Ratio.from( + exchangeRate + 0.000000000001 + ) as unknown as Pay.PositiveRatio, + minExchangeRate: Pay.Ratio.from(exchangeRate * 0.99), + maxPacketAmount: BigInt('9223372036854775807') + }) + const withGraphFetchedArray = ['asset', 'walletAddress'] if (withFee) { withGraphFetchedArray.push('fee') } const withGraphFetchedExpression = `[${withGraphFetchedArray.join(', ')}]` - const ilpData = { - lowEstimatedExchangeRate: Pay.Ratio.from(exchangeRate) as Pay.PositiveRatio, - highEstimatedExchangeRate: Pay.Ratio.from( - exchangeRate + 0.000000000001 - ) as Pay.PositiveRatio, - minExchangeRate: Pay.Ratio.from(exchangeRate * 0.99) as Pay.PositiveRatio, - maxPacketAmount: BigInt('9223372036854775807') - } - return await Quote.query() .insertAndFetch({ + id: quoteId, walletAddressId, assetId: walletAddress.assetId, receiver: receiverUrl, debitAmount, + debitAmountMinusFees: debitAmount.value, receiveAmount, estimatedExchangeRate: exchangeRate, expiresAt: new Date(Date.now() + config.quoteLifespan), - client, - ...ilpData + client }) .withGraphFetched(withGraphFetchedExpression) } diff --git a/packages/backend/src/tests/telemetry.ts b/packages/backend/src/tests/telemetry.ts index 2e13a6b5ba..d07ad46fc8 100644 --- a/packages/backend/src/tests/telemetry.ts +++ b/packages/backend/src/tests/telemetry.ts @@ -1,14 +1,22 @@ import { Counter, Histogram } from '@opentelemetry/api' import { TelemetryService } from '../telemetry/service' -import { ConvertError, Rates, RatesService } from '../rates/service' -import { ConvertOptions } from '../rates/util' +import { + ConvertError, + isConvertError, + Rates, + RatesService +} from '../rates/service' +import { ConvertResults, ConvertSourceOptions } from '../rates/util' export const mockCounter = { add: jest.fn() } as Counter export const mockHistogram = { record: jest.fn() } as Histogram export class MockRatesService implements RatesService { - async convert(): Promise { - return BigInt(10000) + async convertSource(): Promise { + return { amount: BigInt(10000), scaledExchangeRate: 1.0 } + } + async convertDestination(): Promise { + return { amount: BigInt(10000), scaledExchangeRate: 1.0 } } async rates(): Promise { return { @@ -45,11 +53,11 @@ export class MockTelemetryService implements TelemetryService { } public async convertAmount( - _convertOptions: Omit - ): Promise { - let converted = await this.aseRatesService.convert() - if (typeof converted !== 'bigint' && converted in ConvertError) { - converted = await this.internalRatesService.convert() + _convertOptions: Omit + ): Promise { + let converted = await this.aseRatesService.convertSource() + if (isConvertError(converted)) { + converted = await this.internalRatesService.convertSource() } return Promise.resolve(converted) } diff --git a/test/integration/integration.test.ts b/test/integration/integration.test.ts index a54177bc7d..d83df3e03f 100644 --- a/test/integration/integration.test.ts +++ b/test/integration/integration.test.ts @@ -11,7 +11,6 @@ jest.setTimeout(20_000) describe('Integration tests', (): void => { let c9: MockASE let hlb: MockASE - let testActions: TestActions beforeAll(async () => { try { @@ -23,8 +22,6 @@ describe('Integration tests', (): void => { // https://github.com/jestjs/jest/issues/2713 process.exit(1) } - - testActions = createTestActions({ sendingASE: c9, receivingASE: hlb }) }) afterAll(async () => { @@ -68,368 +65,586 @@ describe('Integration tests', (): void => { }) }) - // Series of requests depending on eachother describe('Flows', () => { - test('Open Payments with Continuation via Polling', async (): Promise => { - const { - grantRequestIncomingPayment, - createIncomingPayment, - grantRequestQuote, - createQuote, - grantRequestOutgoingPayment, - pollGrantContinue, - createOutgoingPayment, - getOutgoingPayment, - getPublicIncomingPayment - } = testActions.openPayments - const { consentInteraction } = testActions - - const receiverWalletAddressUrl = - 'https://happy-life-bank-test-backend:4100/accounts/pfry' - const senderWalletAddressUrl = - 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' - const amountValueToSend = '100' - - const receiverWalletAddress = await c9.opClient.walletAddress.get({ - url: receiverWalletAddressUrl + describe('Remote', () => { + let testActions: TestActions + + beforeAll(async () => { + testActions = createTestActions({ sendingASE: c9, receivingASE: hlb }) }) - expect(receiverWalletAddress.id).toBe(receiverWalletAddressUrl) - const senderWalletAddress = await c9.opClient.walletAddress.get({ - url: senderWalletAddressUrl + test('Open Payments with Continuation via Polling', async (): Promise => { + const { + grantRequestIncomingPayment, + createIncomingPayment, + grantRequestQuote, + createQuote, + grantRequestOutgoingPayment, + pollGrantContinue, + createOutgoingPayment, + getOutgoingPayment, + getPublicIncomingPayment + } = testActions.openPayments + const { consentInteraction } = testActions + + const receiverWalletAddressUrl = + 'https://happy-life-bank-test-backend:4100/accounts/pfry' + const senderWalletAddressUrl = + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' + const amountValueToSend = '100' + + const receiverWalletAddress = await c9.opClient.walletAddress.get({ + url: receiverWalletAddressUrl + }) + expect(receiverWalletAddress.id).toBe(receiverWalletAddressUrl) + + const senderWalletAddress = await c9.opClient.walletAddress.get({ + url: senderWalletAddressUrl + }) + expect(senderWalletAddress.id).toBe(senderWalletAddressUrl) + + let incomingPaymentGrant + try { + incomingPaymentGrant = await grantRequestIncomingPayment( + receiverWalletAddress + ) + } catch (err) { + console.log('ERROR: ', err) + throw err + } + + const incomingPayment = await createIncomingPayment( + receiverWalletAddress, + incomingPaymentGrant.access_token.value, + { amountValueToSend } + ) + const quoteGrant = await grantRequestQuote(senderWalletAddress) + const quote = await createQuote( + senderWalletAddress, + quoteGrant.access_token.value, + incomingPayment + ) + const outgoingPaymentGrant = await grantRequestOutgoingPayment( + senderWalletAddress, + { + debitAmount: quote.debitAmount, + receiveAmount: quote.receiveAmount + } + ) + await consentInteraction(outgoingPaymentGrant, senderWalletAddress) + const grantContinue = await pollGrantContinue(outgoingPaymentGrant) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddress, + grantContinue, + { + metadata: {}, + quoteId: quote.id + } + ) + const outgoingPayment_ = await getOutgoingPayment( + outgoingPayment.id, + grantContinue + ) + + expect(outgoingPayment_.receiveAmount.value).toBe(amountValueToSend) + expect(outgoingPayment_.sentAmount.value).toBe(amountValueToSend) + + await getPublicIncomingPayment(incomingPayment.id, amountValueToSend) + + const incomingPayment_ = await hlb.opClient.incomingPayment.getPublic({ + url: incomingPayment.id + }) + assert(incomingPayment_.receivedAmount) + expect(incomingPayment_.receivedAmount.value).toBe(amountValueToSend) }) - expect(senderWalletAddress.id).toBe(senderWalletAddressUrl) + test('Open Payments with Continuation via finish method', async (): Promise => { + const { + grantRequestIncomingPayment, + createIncomingPayment, + grantRequestQuote, + createQuote, + grantRequestOutgoingPayment, + grantContinue, + createOutgoingPayment, + getOutgoingPayment, + getPublicIncomingPayment + } = testActions.openPayments + const { consentInteractionWithInteractRef } = testActions + + const receiverWalletAddressUrl = + 'https://happy-life-bank-test-backend:4100/accounts/pfry' + const senderWalletAddressUrl = + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' + const amountValueToSend = '100' - let incomingPaymentGrant - try { - incomingPaymentGrant = await grantRequestIncomingPayment( + const receiverWalletAddress = await c9.opClient.walletAddress.get({ + url: receiverWalletAddressUrl + }) + expect(receiverWalletAddress.id).toBe(receiverWalletAddressUrl) + + const senderWalletAddress = await c9.opClient.walletAddress.get({ + url: senderWalletAddressUrl + }) + expect(senderWalletAddress.id).toBe(senderWalletAddressUrl) + + const incomingPaymentGrant = await grantRequestIncomingPayment( receiverWalletAddress ) - } catch (err) { - console.log('ERROR: ', err) - throw err - } + const incomingPayment = await createIncomingPayment( + receiverWalletAddress, + incomingPaymentGrant.access_token.value, + { amountValueToSend } + ) + const quoteGrant = await grantRequestQuote(senderWalletAddress) + const quote = await createQuote( + senderWalletAddress, + quoteGrant.access_token.value, + incomingPayment + ) + const outgoingPaymentGrant = await grantRequestOutgoingPayment( + senderWalletAddress, + { + debitAmount: quote.debitAmount, + receiveAmount: quote.receiveAmount + }, + { + method: 'redirect', + uri: 'https://example.com', + nonce: '456' + } + ) + const interactRef = await consentInteractionWithInteractRef( + outgoingPaymentGrant, + senderWalletAddress + ) + const finalizedGrant = await grantContinue( + outgoingPaymentGrant, + interactRef + ) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddress, + finalizedGrant, + { + metadata: {}, + quoteId: quote.id + } + ) + const outgoingPayment_ = await getOutgoingPayment( + outgoingPayment.id, + finalizedGrant + ) - const incomingPayment = await createIncomingPayment( - receiverWalletAddress, - incomingPaymentGrant.access_token.value, - { amountValueToSend } - ) - const quoteGrant = await grantRequestQuote(senderWalletAddress) - const quote = await createQuote( - senderWalletAddress, - quoteGrant.access_token.value, - incomingPayment - ) - const outgoingPaymentGrant = await grantRequestOutgoingPayment( - senderWalletAddress, - { - debitAmount: quote.debitAmount, - receiveAmount: quote.receiveAmount + expect(outgoingPayment_.receiveAmount.value).toBe(amountValueToSend) + expect(outgoingPayment_.sentAmount.value).toBe(amountValueToSend) + + await getPublicIncomingPayment(incomingPayment.id, amountValueToSend) + }) + test('Open Payments without Quote', async (): Promise => { + const { + grantRequestIncomingPayment, + createIncomingPayment, + grantRequestOutgoingPayment, + pollGrantContinue, + createOutgoingPayment, + getOutgoingPayment + } = testActions.openPayments + const { consentInteraction } = testActions + + const receiverWalletAddressUrl = + 'https://happy-life-bank-test-backend:4100/accounts/pfry' + const senderWalletAddressUrl = + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' + + const receiverWalletAddress = await c9.opClient.walletAddress.get({ + url: receiverWalletAddressUrl + }) + expect(receiverWalletAddress.id).toBe(receiverWalletAddressUrl) + + const senderWalletAddress = await c9.opClient.walletAddress.get({ + url: senderWalletAddressUrl + }) + expect(senderWalletAddress.id).toBe(senderWalletAddressUrl) + + const debitAmount = { + assetCode: senderWalletAddress.assetCode, + assetScale: senderWalletAddress.assetScale, + value: '500' } - ) - await consentInteraction(outgoingPaymentGrant, senderWalletAddress) - const grantContinue = await pollGrantContinue(outgoingPaymentGrant) - const outgoingPayment = await createOutgoingPayment( - senderWalletAddress, - grantContinue, - { - metadata: {}, - quoteId: quote.id + + const incomingPaymentGrant = await grantRequestIncomingPayment( + receiverWalletAddress + ) + const incomingPayment = await createIncomingPayment( + receiverWalletAddress, + incomingPaymentGrant.access_token.value + ) + + const outgoingPaymentGrant = await grantRequestOutgoingPayment( + senderWalletAddress, + { + debitAmount, + receiveAmount: debitAmount + } + ) + await consentInteraction(outgoingPaymentGrant, senderWalletAddress) + const grantContinue = await pollGrantContinue(outgoingPaymentGrant) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddress, + grantContinue, + { + incomingPayment: incomingPayment.id, + debitAmount + } + ) + + const outgoingPayment_ = await getOutgoingPayment( + outgoingPayment.id, + grantContinue + ) + + expect(outgoingPayment_.debitAmount).toMatchObject(debitAmount) + }) + test('Peer to Peer', async (): Promise => { + const { + createReceiver, + createQuote, + createOutgoingPayment, + getOutgoingPayment, + getIncomingPayment + } = testActions.admin + + const senderWalletAddress = await c9.accounts.getByWalletAddressUrl( + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' + ) + assert(senderWalletAddress?.walletAddressID) + const senderWalletAddressId = senderWalletAddress.walletAddressID + const value = '500' + const createReceiverInput = { + metadata: { + description: 'For lunch!' + }, + incomingAmount: { + assetCode: 'USD', + assetScale: 2, + value: value as unknown as bigint + }, + walletAddressUrl: + 'https://happy-life-bank-test-backend:4100/accounts/pfry' } - ) - const outgoingPayment_ = await getOutgoingPayment( - outgoingPayment.id, - grantContinue - ) - expect(outgoingPayment_.receiveAmount.value).toBe(amountValueToSend) - expect(outgoingPayment_.sentAmount.value).toBe(amountValueToSend) + const receiver = await createReceiver(createReceiverInput) + const quote = await createQuote({ + walletAddressId: senderWalletAddressId, + receiver: receiver.id + }) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddressId, + quote + ) + const outgoingPayment_ = await getOutgoingPayment( + outgoingPayment.id, + value + ) + expect(outgoingPayment_.sentAmount.value).toBe(BigInt(value)) + + const incomingPaymentId = receiver.id.split('/').slice(-1)[0] + const incomingPayment = await getIncomingPayment(incomingPaymentId) + expect(incomingPayment.receivedAmount.value).toBe(BigInt(value)) + expect(incomingPayment.state).toBe(IncomingPaymentState.Completed) + }) + test('Peer to Peer - Cross Currency', async (): Promise => { + const { + createReceiver, + createQuote, + createOutgoingPayment, + getOutgoingPayment, + getIncomingPayment + } = testActions.admin + + const senderWalletAddress = await c9.accounts.getByWalletAddressUrl( + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' + ) + assert(senderWalletAddress) + const senderAssetCode = senderWalletAddress.assetCode + const senderWalletAddressId = senderWalletAddress.walletAddressID + const value = '500' + const createReceiverInput = { + metadata: { + description: 'cross-currency' + }, + incomingAmount: { + assetCode: 'EUR', + assetScale: 2, + value: value as unknown as bigint + }, + walletAddressUrl: + 'https://happy-life-bank-test-backend:4100/accounts/lars' + } + + const receiver = await createReceiver(createReceiverInput) + assert(receiver.incomingAmount) + + const quote = await createQuote({ + walletAddressId: senderWalletAddressId, + receiver: receiver.id + }) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddressId, + quote + ) + const completedOutgoingPayment = await getOutgoingPayment( + outgoingPayment.id, + value + ) - await getPublicIncomingPayment(incomingPayment.id, amountValueToSend) + const receiverAssetCode = receiver.incomingAmount.assetCode + const exchangeRate = + hlb.config.seed.rates[senderAssetCode][receiverAssetCode] + const fee = c9.config.seed.fees.find((fee: Fee) => fee.asset === 'USD') + + // Expected amounts depend on the configuration of asset codes, scale, exchange rate, and fees. + assert(receiverAssetCode === 'EUR') + assert(senderAssetCode === 'USD') + assert( + receiver.incomingAmount.assetScale === senderWalletAddress.assetScale + ) + assert(senderWalletAddress.assetScale === 2) + assert(exchangeRate === 0.91) + assert(fee) + assert(fee.fixed === 100) + assert(fee.basisPoints === 200) + assert(fee.asset === 'USD') + assert(fee.scale === 2) + expect(completedOutgoingPayment.receiveAmount).toMatchObject({ + assetCode: 'EUR', + assetScale: 2, + value: 500n + }) + expect(completedOutgoingPayment.debitAmount).toMatchObject({ + assetCode: 'USD', + assetScale: 2, + value: 668n + }) + expect(completedOutgoingPayment.sentAmount).toMatchObject({ + assetCode: 'USD', + assetScale: 2, + value: 550n + }) - const incomingPayment_ = await hlb.opClient.incomingPayment.getPublic({ - url: incomingPayment.id + const incomingPaymentId = receiver.id.split('/').slice(-1)[0] + const incomingPayment = await getIncomingPayment(incomingPaymentId) + expect(incomingPayment.receivedAmount).toMatchObject({ + assetCode: 'EUR', + assetScale: 2, + value: 501n + }) + expect(incomingPayment.state).toBe(IncomingPaymentState.Completed) }) - assert(incomingPayment_.receivedAmount) - expect(incomingPayment_.receivedAmount.value).toBe(amountValueToSend) }) - test('Open Payments with Continuation via finish method', async (): Promise => { - const { - grantRequestIncomingPayment, - createIncomingPayment, - grantRequestQuote, - createQuote, - grantRequestOutgoingPayment, - grantContinue, - createOutgoingPayment, - getOutgoingPayment, - getPublicIncomingPayment - } = testActions.openPayments - const { consentInteractionWithInteractRef } = testActions - - const receiverWalletAddressUrl = - 'https://happy-life-bank-test-backend:4100/accounts/pfry' - const senderWalletAddressUrl = - 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' - const amountValueToSend = '100' - - const receiverWalletAddress = await c9.opClient.walletAddress.get({ - url: receiverWalletAddressUrl - }) - expect(receiverWalletAddress.id).toBe(receiverWalletAddressUrl) + describe('Local', () => { + let testActions: TestActions - const senderWalletAddress = await c9.opClient.walletAddress.get({ - url: senderWalletAddressUrl + beforeAll(async () => { + testActions = createTestActions({ sendingASE: c9, receivingASE: c9 }) }) - expect(senderWalletAddress.id).toBe(senderWalletAddressUrl) - const incomingPaymentGrant = await grantRequestIncomingPayment( - receiverWalletAddress - ) - const incomingPayment = await createIncomingPayment( - receiverWalletAddress, - incomingPaymentGrant.access_token.value, - { amountValueToSend } - ) - const quoteGrant = await grantRequestQuote(senderWalletAddress) - const quote = await createQuote( - senderWalletAddress, - quoteGrant.access_token.value, - incomingPayment - ) - const outgoingPaymentGrant = await grantRequestOutgoingPayment( - senderWalletAddress, - { - debitAmount: quote.debitAmount, - receiveAmount: quote.receiveAmount - }, - { - method: 'redirect', - uri: 'https://example.com', - nonce: '456' - } - ) - const interactRef = await consentInteractionWithInteractRef( - outgoingPaymentGrant, - senderWalletAddress - ) - const finalizedGrant = await grantContinue( - outgoingPaymentGrant, - interactRef - ) - const outgoingPayment = await createOutgoingPayment( - senderWalletAddress, - finalizedGrant, - { - metadata: {}, - quoteId: quote.id + test('Peer to Peer', async (): Promise => { + const { + createReceiver, + createQuote, + createOutgoingPayment, + getOutgoingPayment, + getIncomingPayment + } = testActions.admin + + const senderWalletAddress = await c9.accounts.getByWalletAddressUrl( + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' + ) + assert(senderWalletAddress?.walletAddressID) + const senderWalletAddressId = senderWalletAddress.walletAddressID + const value = '500' + const createReceiverInput = { + metadata: { + description: 'For lunch!' + }, + incomingAmount: { + assetCode: 'USD', + assetScale: 2, + value: value as unknown as bigint + }, + walletAddressUrl: + 'https://cloud-nine-wallet-test-backend:3100/accounts/bhamchest' } - ) - const outgoingPayment_ = await getOutgoingPayment( - outgoingPayment.id, - finalizedGrant - ) - expect(outgoingPayment_.receiveAmount.value).toBe(amountValueToSend) - expect(outgoingPayment_.sentAmount.value).toBe(amountValueToSend) + const receiver = await createReceiver(createReceiverInput) + const quote = await createQuote({ + walletAddressId: senderWalletAddressId, + receiver: receiver.id + }) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddressId, + quote + ) + const outgoingPayment_ = await getOutgoingPayment( + outgoingPayment.id, + value + ) + expect(outgoingPayment_.sentAmount.value).toBe(BigInt(value)) - await getPublicIncomingPayment(incomingPayment.id, amountValueToSend) - }) - test('Open Payments without Quote', async (): Promise => { - const { - grantRequestIncomingPayment, - createIncomingPayment, - grantRequestOutgoingPayment, - pollGrantContinue, - createOutgoingPayment, - getOutgoingPayment - } = testActions.openPayments - const { consentInteraction } = testActions - - const receiverWalletAddressUrl = - 'https://happy-life-bank-test-backend:4100/accounts/pfry' - const senderWalletAddressUrl = - 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' - - const receiverWalletAddress = await c9.opClient.walletAddress.get({ - url: receiverWalletAddressUrl + const incomingPaymentId = receiver.id.split('/').slice(-1)[0] + const incomingPayment = await getIncomingPayment(incomingPaymentId) + expect(incomingPayment.receivedAmount.value).toBe(BigInt(value)) + expect(incomingPayment.state).toBe(IncomingPaymentState.Completed) }) - expect(receiverWalletAddress.id).toBe(receiverWalletAddressUrl) - const senderWalletAddress = await c9.opClient.walletAddress.get({ - url: senderWalletAddressUrl - }) - expect(senderWalletAddress.id).toBe(senderWalletAddressUrl) + test('Peer to Peer - Fixed Send', async (): Promise => { + const { + createReceiver, + createQuote, + createOutgoingPayment, + getOutgoingPayment, + getIncomingPayment + } = testActions.admin + + const receiver = await createReceiver({ + metadata: { + description: 'For lunch!' + }, + walletAddressUrl: + 'https://cloud-nine-wallet-test-backend:3100/accounts/bhamchest' + }) + expect(receiver.id).toBeDefined() - const debitAmount = { - assetCode: senderWalletAddress.assetCode, - assetScale: senderWalletAddress.assetScale, - value: '500' - } + const senderWalletAddress = await c9.accounts.getByWalletAddressUrl( + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' + ) + assert(senderWalletAddress?.walletAddressID) + const senderWalletAddressId = senderWalletAddress.walletAddressID + + const createQuoteInput = { + walletAddressId: senderWalletAddressId, + receiver: receiver.id, + debitAmount: { + assetCode: 'USD', + assetScale: 2, + value: '500' as unknown as bigint + } + } + const quote = await createQuote(createQuoteInput) + expect(quote.id).toBeDefined() - const incomingPaymentGrant = await grantRequestIncomingPayment( - receiverWalletAddress - ) - const incomingPayment = await createIncomingPayment( - receiverWalletAddress, - incomingPaymentGrant.access_token.value - ) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddressId, + quote + ) + expect(outgoingPayment.id).toBeDefined() - const outgoingPaymentGrant = await grantRequestOutgoingPayment( - senderWalletAddress, - { - debitAmount, - receiveAmount: debitAmount - } - ) - await consentInteraction(outgoingPaymentGrant, senderWalletAddress) - const grantContinue = await pollGrantContinue(outgoingPaymentGrant) - const outgoingPayment = await createOutgoingPayment( - senderWalletAddress, - grantContinue, - { - incomingPayment: incomingPayment.id, - debitAmount - } - ) + const outgoingPayment_ = await getOutgoingPayment( + outgoingPayment.id, + String(quote.receiveAmount.value) + ) + expect(outgoingPayment_.sentAmount.value).toBe( + BigInt(quote.receiveAmount.value) + ) - const outgoingPayment_ = await getOutgoingPayment( - outgoingPayment.id, - grantContinue - ) + expect(outgoingPayment_.state).toBe('COMPLETED') - expect(outgoingPayment_.debitAmount).toMatchObject(debitAmount) - }) - test('Peer to Peer', async (): Promise => { - const { - createReceiver, - createQuote, - createOutgoingPayment, - getOutgoingPayment, - getIncomingPayment - } = testActions.admin - - const senderWalletAddress = await c9.accounts.getByWalletAddressUrl( - 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' - ) - assert(senderWalletAddress?.walletAddressID) - const senderWalletAddressId = senderWalletAddress.walletAddressID - const value = '500' - const createReceiverInput = { - metadata: { - description: 'For lunch!' - }, - incomingAmount: { + const incomingPaymentId = receiver.id.split('/').slice(-1)[0] + const incomingPayment = await getIncomingPayment(incomingPaymentId) + + expect(incomingPayment.receivedAmount).toMatchObject({ assetCode: 'USD', assetScale: 2, - value: value as unknown as bigint - }, - walletAddressUrl: - 'https://happy-life-bank-test-backend:4100/accounts/pfry' - } + value: BigInt(quote.receiveAmount.value) + }) + }) - const receiver = await createReceiver(createReceiverInput) - const quote = await createQuote(senderWalletAddressId, receiver) - const outgoingPayment = await createOutgoingPayment( - senderWalletAddressId, - quote - ) - const outgoingPayment_ = await getOutgoingPayment( - outgoingPayment.id, - value - ) - expect(outgoingPayment_.sentAmount.value).toBe(BigInt(value)) + test('Peer to Peer - Cross Currency', async (): Promise => { + const { + createReceiver, + createQuote, + createOutgoingPayment, + getOutgoingPayment, + getIncomingPayment + } = testActions.admin + + const senderWalletAddress = await c9.accounts.getByWalletAddressUrl( + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' + ) + assert(senderWalletAddress) + const senderAssetCode = senderWalletAddress.assetCode + const senderWalletAddressId = senderWalletAddress.walletAddressID + const createReceiverInput = { + metadata: { + description: 'cross-currency' + }, + incomingAmount: { + assetCode: 'EUR', + assetScale: 2, + value: '500' as unknown as bigint + }, + walletAddressUrl: + 'https://cloud-nine-wallet-test-backend:3100/accounts/lrossi' + } + const receiver = await createReceiver(createReceiverInput) + assert(receiver.incomingAmount) - const incomingPaymentId = receiver.id.split('/').slice(-1)[0] - const incomingPayment = await getIncomingPayment(incomingPaymentId) - expect(incomingPayment.receivedAmount.value).toBe(BigInt(value)) - expect(incomingPayment.state).toBe(IncomingPaymentState.Completed) - }) - test('Peer to Peer - Cross Currency', async (): Promise => { - const { - createReceiver, - createQuote, - createOutgoingPayment, - getOutgoingPayment, - getIncomingPayment - } = testActions.admin - - const senderWalletAddress = await c9.accounts.getByWalletAddressUrl( - 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' - ) - assert(senderWalletAddress) - const senderAssetCode = senderWalletAddress.assetCode - const senderWalletAddressId = senderWalletAddress.walletAddressID - const value = '500' - const createReceiverInput = { - metadata: { - description: 'cross-currency' - }, - incomingAmount: { - assetCode: 'EUR', - assetScale: 2, - value: value as unknown as bigint - }, - walletAddressUrl: - 'https://happy-life-bank-test-backend:4100/accounts/lars' - } - - const receiver = await createReceiver(createReceiverInput) - assert(receiver.incomingAmount) - - const quote = await createQuote(senderWalletAddressId, receiver) - const outgoingPayment = await createOutgoingPayment( - senderWalletAddressId, - quote - ) - const completedOutgoingPayment = await getOutgoingPayment( - outgoingPayment.id, - value - ) + const quote = await createQuote({ + walletAddressId: senderWalletAddressId, + receiver: receiver.id + }) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddressId, + quote + ) + const completedOutgoingPayment = await getOutgoingPayment( + outgoingPayment.id, + String(createReceiverInput.incomingAmount.value) + ) - const receiverAssetCode = receiver.incomingAmount.assetCode - const exchangeRate = - hlb.config.seed.rates[senderAssetCode][receiverAssetCode] - const fee = c9.config.seed.fees.find((fee: Fee) => fee.asset === 'USD') + const receiverAssetCode = receiver.incomingAmount.assetCode + const exchangeRate = + c9.config.seed.rates[senderAssetCode][receiverAssetCode] + const fee = c9.config.seed.fees.find((fee: Fee) => fee.asset === 'USD') - // Expected amounts depend on the configuration of asset codes, scale, exchange rate, and fees. - assert(receiverAssetCode === 'EUR') - assert(senderAssetCode === 'USD') - assert( - receiver.incomingAmount.assetScale === senderWalletAddress.assetScale - ) - assert(senderWalletAddress.assetScale === 2) - assert(exchangeRate === 0.91) - assert(fee) - assert(fee.fixed === 100) - assert(fee.basisPoints === 200) - assert(fee.asset === 'USD') - assert(fee.scale === 2) - expect(completedOutgoingPayment.receiveAmount).toMatchObject({ - assetCode: 'EUR', - assetScale: 2, - value: 500n - }) - expect(completedOutgoingPayment.debitAmount).toMatchObject({ - assetCode: 'USD', - assetScale: 2, - value: 668n - }) - expect(completedOutgoingPayment.sentAmount).toMatchObject({ - assetCode: 'USD', - assetScale: 2, - value: 550n - }) + // Expected amounts depend on the configuration of asset codes, scale, exchange rate, and fees. + assert(receiverAssetCode === 'EUR') + assert(senderAssetCode === 'USD') + assert( + receiver.incomingAmount.assetScale === senderWalletAddress.assetScale + ) + assert(senderWalletAddress.assetScale === 2) + assert(exchangeRate === 0.91) + assert(fee) + assert(fee.fixed === 100) + assert(fee.basisPoints === 200) + assert(fee.asset === 'USD') + assert(fee.scale === 2) + expect(completedOutgoingPayment.receiveAmount).toMatchObject({ + assetCode: 'EUR', + assetScale: 2, + value: 500n + }) + expect(completedOutgoingPayment.debitAmount).toMatchObject({ + assetCode: 'USD', + assetScale: 2, + value: 661n + }) + expect(completedOutgoingPayment.sentAmount).toMatchObject({ + assetCode: 'USD', + assetScale: 2, + value: 550n + }) - const incomingPaymentId = receiver.id.split('/').slice(-1)[0] - const incomingPayment = await getIncomingPayment(incomingPaymentId) - expect(incomingPayment.receivedAmount).toMatchObject({ - assetCode: 'EUR', - assetScale: 2, - value: 501n + const incomingPaymentId = receiver.id.split('/').slice(-1)[0] + const incomingPayment = await getIncomingPayment(incomingPaymentId) + expect(incomingPayment.receivedAmount).toMatchObject({ + assetCode: 'EUR', + assetScale: 2, + value: 500n + }) + expect(incomingPayment.state).toBe(IncomingPaymentState.Completed) }) - expect(incomingPayment.state).toBe(IncomingPaymentState.Completed) }) }) }) diff --git a/test/integration/lib/test-actions/admin.ts b/test/integration/lib/test-actions/admin.ts index 15dc6114d6..21ca18a1ec 100644 --- a/test/integration/lib/test-actions/admin.ts +++ b/test/integration/lib/test-actions/admin.ts @@ -5,7 +5,8 @@ import { OutgoingPayment, OutgoingPaymentState, CreateReceiverInput, - IncomingPayment + IncomingPayment, + CreateQuoteInput } from '../generated/graphql' import { MockASE } from '../mock-ase' import { pollCondition } from '../utils' @@ -17,8 +18,8 @@ interface AdminActionsDeps { } export interface AdminActions { - createReceiver(createReceiverInput: CreateReceiverInput): Promise - createQuote(senderWalletAddressId: string, receiver: Receiver): Promise + createReceiver(input: CreateReceiverInput): Promise + createQuote(input: CreateQuoteInput): Promise createOutgoingPayment( senderWalletAddressId: string, quote: Quote @@ -32,10 +33,8 @@ export interface AdminActions { export function createAdminActions(deps: AdminActionsDeps): AdminActions { return { - createReceiver: (createReceiverInput) => - createReceiver(deps, createReceiverInput), - createQuote: (senderWalletAddressId, receiver) => - createQuote(deps, senderWalletAddressId, receiver), + createReceiver: (input) => createReceiver(deps, input), + createQuote: (input) => createQuote(deps, input), createOutgoingPayment: (senderWalletAddressId, quote) => createOutgoingPayment(deps, senderWalletAddressId, quote), getIncomingPayment: (incomingPaymentId) => @@ -47,15 +46,14 @@ export function createAdminActions(deps: AdminActionsDeps): AdminActions { async function createReceiver( deps: AdminActionsDeps, - createReceiverInput: CreateReceiverInput + input: CreateReceiverInput ): Promise { const { receivingASE, sendingASE } = deps const handleWebhookEventSpy = jest.spyOn( receivingASE.integrationServer.webhookEventHandler, 'handleWebhookEvent' ) - const response = - await sendingASE.adminClient.createReceiver(createReceiverInput) + const response = await sendingASE.adminClient.createReceiver(input) assert(response.receiver) @@ -80,14 +78,10 @@ async function createReceiver( } async function createQuote( deps: AdminActionsDeps, - senderWalletAddressId: string, - receiver: Receiver + input: CreateQuoteInput ): Promise { const { sendingASE } = deps - const response = await sendingASE.adminClient.createQuote({ - walletAddressId: senderWalletAddressId, - receiver: receiver.id - }) + const response = await sendingASE.adminClient.createQuote(input) assert(response.quote) diff --git a/test/integration/testenv/cloud-nine-wallet/seed.yml b/test/integration/testenv/cloud-nine-wallet/seed.yml index cd55ae9b87..1b987fec45 100644 --- a/test/integration/testenv/cloud-nine-wallet/seed.yml +++ b/test/integration/testenv/cloud-nine-wallet/seed.yml @@ -40,6 +40,12 @@ accounts: path: accounts/wbdc postmanEnvVar: wbdcWalletAddress assetCode: USD + - name: "Luca Rossi" + id: 63dcc665-d946-4263-ac27-d0da1eb08a83 + initialBalance: 50 + path: accounts/lrossi + brunoEnvVar: lrossiWalletAddressId + assetCode: EUR rates: EUR: MXN: 18.78