From e96d44e692660babd4e6c45d6e36178e9ee01f86 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Thu, 15 Sep 2022 08:04:11 -0500 Subject: [PATCH] feat(backend): add Open Payments client service (#595) * fix(backend): return correct ilpStreamConnection type Update ilp-packet version to match types. * chore(backend): add configurable DEV_ACCESS_TOKEN to bypass introspection * feat(backend): add Open Payments client service Replace calls to Pay.setupPayment Downgrade axios to v0.26.1 * chore(backend): rename QuoteError.InvalidDestination to InvalidReceiver * chore(backend): delete completed/expired incoming payment connectionId * fix(backend): pass correct host to ConnectionService Remove publicHost from OutgoingPaymentService. --- docs/transaction-api.md | 2 +- ...28162503_create_incoming_payments_table.js | 2 +- packages/backend/package.json | 5 +- packages/backend/src/config/app.ts | 3 +- packages/backend/src/index.ts | 19 +- .../src/open_payments/auth/middleware.test.ts | 8 + .../src/open_payments/auth/middleware.ts | 7 + .../src/open_payments/client/service.test.ts | 109 ++++++++++ .../src/open_payments/client/service.ts | 70 +++++++ .../open_payments/connection/routes.test.ts | 36 +++- .../src/open_payments/connection/routes.ts | 12 +- .../open_payments/connection/service.test.ts | 105 ++++++++++ .../src/open_payments/connection/service.ts | 73 ++++++- .../open_payments/payment/incoming/model.ts | 55 +++-- .../payment/incoming/routes.test.ts | 19 +- .../open_payments/payment/incoming/routes.ts | 40 ++-- .../payment/incoming/service.test.ts | 47 +++-- .../open_payments/payment/outgoing/errors.ts | 1 - .../payment/outgoing/lifecycle.ts | 65 +++--- .../payment/outgoing/routes.test.ts | 1 - .../payment/outgoing/service.test.ts | 38 +++- .../open_payments/payment/outgoing/service.ts | 42 ++-- .../backend/src/open_payments/quote/errors.ts | 6 +- .../src/open_payments/quote/routes.test.ts | 1 - .../src/open_payments/quote/service.test.ts | 18 +- .../src/open_payments/quote/service.ts | 194 +++++++++--------- .../src/open_payments/shared/receiver.ts | 56 +++++ .../backend/src/shared/pagination.test.ts | 2 +- packages/backend/src/tests/outgoingPayment.ts | 21 +- pnpm-lock.yaml | 16 +- 30 files changed, 793 insertions(+), 280 deletions(-) create mode 100644 packages/backend/src/open_payments/client/service.test.ts create mode 100644 packages/backend/src/open_payments/client/service.ts create mode 100644 packages/backend/src/open_payments/connection/service.test.ts create mode 100644 packages/backend/src/open_payments/shared/receiver.ts diff --git a/docs/transaction-api.md b/docs/transaction-api.md index b0227b78de..f9faed6237 100644 --- a/docs/transaction-api.md +++ b/docs/transaction-api.md @@ -192,7 +192,7 @@ The payment must be created with `quoteId`. - `FIXED_SEND`: Fixed source amount. - `FIXED_DELIVERY`: Incoming payment, fixed delivery amount. -### `Incoming Payment` +### `IncomingPayment` | Name | Optional | Type | Description | | :-------------------------- | :------- | :--------------------- | :---------------------------------------------------------------------------------------- | diff --git a/packages/backend/migrations/20220228162503_create_incoming_payments_table.js b/packages/backend/migrations/20220228162503_create_incoming_payments_table.js index 0d53bf3dff..b3ce17d8bc 100644 --- a/packages/backend/migrations/20220228162503_create_incoming_payments_table.js +++ b/packages/backend/migrations/20220228162503_create_incoming_payments_table.js @@ -10,7 +10,7 @@ exports.up = function (knex) { table.bigInteger('incomingAmountValue').nullable() table.string('state').notNullable() table.string('externalRef').nullable() - table.uuid('connectionId').notNullable() + table.uuid('connectionId').nullable() table.uuid('assetId').notNullable() table.foreign('assetId').references('assets.id') diff --git a/packages/backend/package.json b/packages/backend/package.json index c2e869005c..cc55363d1b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -29,7 +29,6 @@ "apollo-server": "^3.10.1", "auth": "workspace:../auth", "cross-fetch": "^3.1.4", - "ilp-packet": "^3.1.2", "ilp-protocol-ildcp": "^2.2.2", "ilp-protocol-stream": "^2.7.0", "jest-openapi": "^0.14.2", @@ -59,7 +58,7 @@ "add": "^2.0.6", "ajv": "^8.11.0", "apollo-server-koa": "^3.10.1", - "axios": "^0.27.2", + "axios": "0.26.1", "base64url": "^3.0.1", "bcrypt": "^5.0.1", "extensible-error": "^1.0.2", @@ -67,7 +66,7 @@ "graphql": "^16.6.0", "graphql-scalars": "^1.18.0", "graphql-tools": "^8.3.3", - "ilp-packet": "^3.1.2", + "ilp-packet": "3.1.4-alpha.1", "ilp-protocol-ccp": "^1.2.2", "ilp-protocol-ildcp": "^2.2.2", "ioredis": "^5.2.2", diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 70e24e7324..b49c6498c0 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -50,6 +50,7 @@ export const Config = { nonceRedisKey: envString('NONCE_REDIS_KEY', 'nonceToProject'), adminKey: envString('ADMIN_KEY', 'qwertyuiop1234567890'), sessionLength: envInt('SESSION_LENGTH', 30), // in minutes + devAccessToken: envString('DEV_ACCESS_TOKEN', 'dev-access-token'), ilpAddress: envString('ILP_ADDRESS', 'test.rafiki'), streamSecret: process.env.STREAM_SECRET @@ -102,7 +103,7 @@ export const Config = { openPaymentsSpec: envString( 'OPEN_PAYMENTS_SPEC', - 'https://raw.githubusercontent.com/interledger/open-payments/6a410518c2fe054286bb0a55b0964d80596fea5a/open-api-spec.yaml' + 'https://raw.githubusercontent.com/interledger/open-payments/bc90cb63e99e56b85abe25f2018393c7b21f6648/open-api-spec.yaml' ), authServerSpec: envString( 'AUTH_SERVER_SPEC', diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 73cbf87a27..06078e35f2 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -24,6 +24,7 @@ import { createAssetService } from './asset/service' import { createAccountingService } from './accounting/service' import { createPeerService } from './peer/service' import { createAuthService } from './open_payments/auth/service' +import { createOpenPaymentsClientService } from './open_payments/client/service' import { createPaymentPointerService } from './open_payments/payment_pointer/service' import { createSPSPRoutes } from './spsp/routes' import { createClientKeysRoutes } from './clientKeys/routes' @@ -185,6 +186,15 @@ export function initIocContainer( logger: await deps.use('logger') }) }) + container.singleton('openPaymentsClientService', async (deps) => { + const config = await deps.use('config') + return await createOpenPaymentsClientService({ + logger: await deps.use('logger'), + // TODO: https://github.com/interledger/rafiki/issues/583 + accessToken: config.devAccessToken, + openApi: await deps.use('openApi') + }) + }) container.singleton('incomingPaymentService', async (deps) => { return await createIncomingPaymentService({ logger: await deps.use('logger'), @@ -213,14 +223,15 @@ export function initIocContainer( }) }) container.singleton('connectionService', async (deps) => { + const config = await deps.use('config') return await createConnectionService({ logger: await deps.use('logger'), + openPaymentsUrl: config.openPaymentsUrl, streamServer: await deps.use('streamServer') }) }) container.singleton('connectionRoutes', async (deps) => { return createConnectionRoutes({ - config: await deps.use('config'), logger: await deps.use('logger'), incomingPaymentService: await deps.use('incomingPaymentService'), connectionService: await deps.use('connectionService') @@ -272,6 +283,7 @@ export function initIocContainer( logger: await deps.use('logger'), knex: await deps.use('knex'), makeIlpPlugin: await deps.use('makeIlpPlugin'), + clientService: await deps.use('openPaymentsClientService'), paymentPointerService: await deps.use('paymentPointerService'), ratesService: await deps.use('ratesService') }) @@ -284,14 +296,13 @@ export function initIocContainer( }) }) container.singleton('outgoingPaymentService', async (deps) => { - const config = await deps.use('config') return await createOutgoingPaymentService({ logger: await deps.use('logger'), knex: await deps.use('knex'), accountingService: await deps.use('accountingService'), + clientService: await deps.use('openPaymentsClientService'), makeIlpPlugin: await deps.use('makeIlpPlugin'), - peerService: await deps.use('peerService'), - publicHost: config.publicHost + peerService: await deps.use('peerService') }) }) container.singleton('outgoingPaymentRoutes', async (deps) => { diff --git a/packages/backend/src/open_payments/auth/middleware.test.ts b/packages/backend/src/open_payments/auth/middleware.test.ts index 463808b75f..6cb326e447 100644 --- a/packages/backend/src/open_payments/auth/middleware.test.ts +++ b/packages/backend/src/open_payments/auth/middleware.test.ts @@ -200,4 +200,12 @@ describe('Auth Middleware', (): void => { scope.isDone() } ) + test('bypasses token introspection for configured DEV_ACCESS_TOKEN', async (): Promise => { + ctx.headers.authorization = `GNAP ${Config.devAccessToken}` + const authService = await deps.use('authService') + const introspectSpy = jest.spyOn(authService, 'introspect') + await expect(middleware(ctx, next)).resolves.toBeUndefined() + expect(introspectSpy).not.toHaveBeenCalled() + expect(next).toHaveBeenCalled() + }) }) diff --git a/packages/backend/src/open_payments/auth/middleware.ts b/packages/backend/src/open_payments/auth/middleware.ts index a1de0ef7da..0e0bd8a38a 100644 --- a/packages/backend/src/open_payments/auth/middleware.ts +++ b/packages/backend/src/open_payments/auth/middleware.ts @@ -19,6 +19,13 @@ export function createAuthMiddleware({ ctx.throw(401, 'Unauthorized') } const token = parts[1] + if ( + process.env.NODE_ENV !== 'production' && + token === config.devAccessToken + ) { + await next() + return + } const authService = await ctx.container.use('authService') const grant = await authService.introspect(token) if (!grant || !grant.active) { diff --git a/packages/backend/src/open_payments/client/service.test.ts b/packages/backend/src/open_payments/client/service.test.ts new file mode 100644 index 0000000000..5c0aff6b63 --- /dev/null +++ b/packages/backend/src/open_payments/client/service.test.ts @@ -0,0 +1,109 @@ +import { IocContract } from '@adonisjs/fold' +import { faker } from '@faker-js/faker' +import { Knex } from 'knex' +import nock from 'nock' +import { URL } from 'url' +import { v4 as uuid } from 'uuid' + +import { OpenPaymentsClientService } from './service' +import { createTestApp, TestContainer } from '../../tests/app' +import { Config } from '../../config/app' +import { initIocContainer } from '../..' +import { AppServices } from '../../app' +import { createIncomingPayment } from '../../tests/incomingPayment' +import { createPaymentPointer } from '../../tests/paymentPointer' +import { truncateTables } from '../../tests/tableManager' + +describe('Open Payments Client Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let clientService: OpenPaymentsClientService + let knex: Knex + + beforeAll(async (): Promise => { + deps = await initIocContainer(Config) + appContainer = await createTestApp(deps) + clientService = await deps.use('openPaymentsClientService') + knex = await deps.use('knex') + }) + + afterEach(async (): Promise => { + jest.useRealTimers() + await truncateTables(knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + describe('incomingPayment.get', (): void => { + test.each` + incomingAmount | description | externalRef + ${undefined} | ${undefined} | ${undefined} + ${BigInt(123)} | ${'Test'} | ${'#123'} + `( + 'resolves incoming payment from Open Payments server', + async ({ incomingAmount, description, externalRef }): Promise => { + const paymentPointer = await createPaymentPointer(deps, { + mockServerPort: appContainer.openPaymentsPort + }) + const incomingPayment = await createIncomingPayment(deps, { + paymentPointerId: paymentPointer.id, + description, + incomingAmount: incomingAmount && { + value: incomingAmount, + assetCode: paymentPointer.asset.code, + assetScale: paymentPointer.asset.scale + }, + externalRef + }) + const resolvedPayment = await clientService.incomingPayment.get( + incomingPayment.url + ) + paymentPointer.scope.isDone() + expect(resolvedPayment).toEqual({ + ...incomingPayment.toJSON(), + id: incomingPayment.url, + paymentPointer: incomingPayment.paymentPointer.url, + ilpStreamConnection: { + id: `${Config.openPaymentsUrl}/connections/${incomingPayment.connectionId}`, + ilpAddress: expect.any(String), + sharedSecret: expect.any(String) + } + }) + } + ) + test.each` + statusCode + ${404} + ${500} + `( + 'returns undefined for unsuccessful request ($statusCode)', + async ({ statusCode }): Promise => { + const incomingPaymentUrl = new URL( + `${faker.internet.url()}/incoming-payments/${uuid()}` + ) + const scope = nock(incomingPaymentUrl.host) + .get(incomingPaymentUrl.pathname) + .reply(statusCode) + scope.isDone() + await expect( + clientService.incomingPayment.get(incomingPaymentUrl.href) + ).resolves.toBeUndefined() + } + ) + test('returns undefined for invalid incoming payment response', async (): Promise => { + const incomingPaymentUrl = new URL( + `${faker.internet.url()}/incoming-payments/${uuid()}` + ) + const scope = nock(incomingPaymentUrl.host) + .get(incomingPaymentUrl.pathname) + .reply(200, () => ({ + validPayment: 0 + })) + scope.isDone() + await expect( + clientService.incomingPayment.get(incomingPaymentUrl.href) + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/packages/backend/src/open_payments/client/service.ts b/packages/backend/src/open_payments/client/service.ts new file mode 100644 index 0000000000..05133cd9e1 --- /dev/null +++ b/packages/backend/src/open_payments/client/service.ts @@ -0,0 +1,70 @@ +import axios from 'axios' +import { OpenAPI, HttpMethod, ValidateFunction } from 'openapi' + +import { BaseService } from '../../shared/baseService' +import { IncomingPaymentJSON } from '../payment/incoming/model' + +const REQUEST_TIMEOUT = 5_000 // millseconds + +export interface OpenPaymentsClientService { + incomingPayment: { + get(url: string): Promise + } +} + +export interface ServiceDependencies extends BaseService { + accessToken: string + openApi: OpenAPI + validateResponse: ValidateFunction +} + +export async function createOpenPaymentsClientService( + deps_: Omit +): Promise { + const log = deps_.logger.child({ + service: 'OpenPaymentsClientService' + }) + const validateResponse = + deps_.openApi.createResponseValidator({ + path: '/incoming-payments/{incomingPaymentId}', + method: HttpMethod.GET + }) + const deps: ServiceDependencies = { + ...deps_, + logger: log, + validateResponse + } + return { + incomingPayment: { + get: (url) => getIncomingPayment(deps, url) + } + } +} + +export async function getIncomingPayment( + deps: ServiceDependencies, + url: string +): Promise { + const requestHeaders = { + Authorization: `GNAP ${deps.accessToken}`, + 'Content-Type': 'application/json' + } + try { + const { status, data } = await axios.get(url, { + headers: requestHeaders, + timeout: REQUEST_TIMEOUT, + validateStatus: (status) => status === 200 + }) + if ( + !deps.validateResponse({ + status, + body: data + }) + ) { + throw new Error('unreachable') + } + return data + } catch (_) { + return undefined + } +} diff --git a/packages/backend/src/open_payments/connection/routes.test.ts b/packages/backend/src/open_payments/connection/routes.test.ts index c34d9b7359..e67cb265bd 100644 --- a/packages/backend/src/open_payments/connection/routes.test.ts +++ b/packages/backend/src/open_payments/connection/routes.test.ts @@ -11,7 +11,10 @@ import { initIocContainer } from '../../' import { ConnectionRoutes } from './routes' import { createContext } from '../../tests/context' import { PaymentPointer } from '../payment_pointer/model' -import { IncomingPayment } from '../payment/incoming/model' +import { + IncomingPayment, + IncomingPaymentState +} from '../payment/incoming/model' import { createIncomingPayment } from '../../tests/incomingPayment' import { createPaymentPointer } from '../../tests/paymentPointer' import base64url from 'base64url' @@ -25,7 +28,6 @@ describe('Connection Routes', (): void => { beforeAll(async (): Promise => { config = Config - config.publicHost = 'https://wallet.example' config.authServerGrantUrl = 'https://auth.wallet.example/authorize' deps = await initIocContainer(config) appContainer = await createTestApp(deps) @@ -82,6 +84,34 @@ describe('Connection Routes', (): void => { ) }) + test.each` + state + ${IncomingPaymentState.Completed} + ${IncomingPaymentState.Expired} + `( + `returns 404 for $state incoming payment`, + async ({ state }): Promise => { + await incomingPayment.$query(knex).patch({ + state, + expiresAt: + state === IncomingPaymentState.Expired ? new Date() : undefined + }) + const ctx = createContext( + { + headers: { Accept: 'application/json' }, + url: `/connections/${incomingPayment.connectionId}` + }, + { + connectionId: incomingPayment.connectionId + } + ) + await expect(connectionRoutes.get(ctx)).rejects.toHaveProperty( + 'status', + 404 + ) + } + ) + test('returns 200 for correct connection id', async (): Promise => { const ctx = createContext( { @@ -100,7 +130,7 @@ describe('Connection Routes', (): void => { ] expect(ctx.body).toEqual({ - id: `${config.publicHost}/connections/${incomingPayment.connectionId}`, + id: `${config.openPaymentsUrl}/connections/${incomingPayment.connectionId}`, ilpAddress: expect.stringMatching(/^test\.rafiki\.[a-zA-Z0-9_-]{95}$/), sharedSecret }) diff --git a/packages/backend/src/open_payments/connection/routes.ts b/packages/backend/src/open_payments/connection/routes.ts index a35312ae7d..333803e8df 100644 --- a/packages/backend/src/open_payments/connection/routes.ts +++ b/packages/backend/src/open_payments/connection/routes.ts @@ -1,12 +1,9 @@ -import base64url from 'base64url' import { Logger } from 'pino' import { ReadContext } from '../../app' -import { IAppConfig } from '../../config/app' import { IncomingPaymentService } from '../payment/incoming/service' import { ConnectionService } from './service' interface ServiceDependencies { - config: IAppConfig logger: Logger incomingPaymentService: IncomingPaymentService connectionService: ConnectionService @@ -36,10 +33,7 @@ async function getConnection( const incomingPayment = await deps.incomingPaymentService.getByConnection(id) if (!incomingPayment) return ctx.throw(404) - const streamCredentials = deps.connectionService.get(incomingPayment) - ctx.body = { - id: `${deps.config.publicHost}/connections/${id}`, - ilpAddress: streamCredentials.ilpAddress, - sharedSecret: base64url(streamCredentials.sharedSecret) - } + const connection = deps.connectionService.get(incomingPayment) + if (!connection) return ctx.throw(404) + ctx.body = connection.toJSON() } diff --git a/packages/backend/src/open_payments/connection/service.test.ts b/packages/backend/src/open_payments/connection/service.test.ts new file mode 100644 index 0000000000..0b1a74adc0 --- /dev/null +++ b/packages/backend/src/open_payments/connection/service.test.ts @@ -0,0 +1,105 @@ +import base64url from 'base64url' +import { Knex } from 'knex' + +import { createTestApp, TestContainer } from '../../tests/app' +import { ConnectionService } from './service' +import { + IncomingPayment, + IncomingPaymentState +} from '../payment/incoming/model' +import { Config } from '../../config/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../..' +import { AppServices } from '../../app' +import { createIncomingPayment } from '../../tests/incomingPayment' +import { createPaymentPointer } from '../../tests/paymentPointer' +import { truncateTables } from '../../tests/tableManager' + +describe('Connection Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let connectionService: ConnectionService + let knex: Knex + let incomingPayment: IncomingPayment + + beforeAll(async (): Promise => { + deps = await initIocContainer(Config) + appContainer = await createTestApp(deps) + connectionService = await deps.use('connectionService') + knex = await deps.use('knex') + }) + + beforeEach(async (): Promise => { + const { id: paymentPointerId } = await createPaymentPointer(deps) + incomingPayment = await createIncomingPayment(deps, { + paymentPointerId + }) + }) + + afterEach(async (): Promise => { + await truncateTables(knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('get', (): void => { + test('returns connection for incoming payment', (): void => { + const connection = connectionService.get(incomingPayment) + expect(connection).toMatchObject({ + id: incomingPayment.connectionId, + ilpAddress: expect.stringMatching(/^test\.rafiki\.[a-zA-Z0-9_-]{95}$/), + sharedSecret: expect.any(Buffer) + }) + expect(connection.url).toEqual( + `${Config.openPaymentsUrl}/connections/${incomingPayment.connectionId}` + ) + expect(connection.toJSON()).toEqual({ + id: connection.url, + ilpAddress: connection.ilpAddress, + sharedSecret: base64url(connection.sharedSecret) + }) + }) + + test.each` + state + ${IncomingPaymentState.Completed} + ${IncomingPaymentState.Expired} + `( + `returns undefined for $state incoming payment`, + async ({ state }): Promise => { + await incomingPayment.$query(knex).patch({ + state, + expiresAt: + state === IncomingPaymentState.Expired ? new Date() : undefined + }) + expect(connectionService.get(incomingPayment)).toBeUndefined() + } + ) + }) + + describe('getUrl', (): void => { + test('returns connection url for incoming payment', (): void => { + expect(connectionService.getUrl(incomingPayment)).toEqual( + `${Config.openPaymentsUrl}/connections/${incomingPayment.connectionId}` + ) + }) + + test.each` + state + ${IncomingPaymentState.Completed} + ${IncomingPaymentState.Expired} + `( + `returns undefined for $state incoming payment`, + async ({ state }): Promise => { + await incomingPayment.$query(knex).patch({ + state, + expiresAt: + state === IncomingPaymentState.Expired ? new Date() : undefined + }) + expect(connectionService.get(incomingPayment)).toBeUndefined() + } + ) + }) +}) diff --git a/packages/backend/src/open_payments/connection/service.ts b/packages/backend/src/open_payments/connection/service.ts index 05c1014e93..611dca3ce7 100644 --- a/packages/backend/src/open_payments/connection/service.ts +++ b/packages/backend/src/open_payments/connection/service.ts @@ -1,12 +1,55 @@ +import base64url from 'base64url' +import { IlpAddress } from 'ilp-packet' import { StreamCredentials, StreamServer } from '@interledger/stream-receiver' + import { BaseService } from '../../shared/baseService' import { IncomingPayment } from '../payment/incoming/model' +export interface ConnectionOptions extends StreamCredentials { + id: string + openPaymentsUrl: string +} + +export type ConnectionJSON = { + id: string + ilpAddress: IlpAddress + sharedSecret: string +} + +export class Connection { + constructor(options: ConnectionOptions) { + this.id = options.id + this.ilpAddress = options.ilpAddress + this.sharedSecret = options.sharedSecret + this.openPaymentsUrl = options.openPaymentsUrl + } + + public readonly id: string + public readonly ilpAddress: IlpAddress + public readonly sharedSecret: Buffer + + private readonly openPaymentsUrl: string + + public get url(): string { + return `${this.openPaymentsUrl}/connections/${this.id}` + } + + public toJSON(): ConnectionJSON { + return { + id: this.url, + ilpAddress: this.ilpAddress, + sharedSecret: base64url(this.sharedSecret) + } + } +} + export interface ConnectionService { - get(payment: IncomingPayment): StreamCredentials + get(payment: IncomingPayment): Connection | undefined + getUrl(payment: IncomingPayment): string | undefined } export interface ServiceDependencies extends BaseService { + openPaymentsUrl: string streamServer: StreamServer } @@ -21,19 +64,39 @@ export async function createConnectionService( logger: log } return { - get: (payment) => getStreamCredentials(deps, payment) + get: (payment) => getConnection(deps, payment), + getUrl: (payment) => getConnectionUrl(deps, payment) } } -function getStreamCredentials( +function getConnection( deps: ServiceDependencies, payment: IncomingPayment -) { - return deps.streamServer.generateCredentials({ +): Connection | undefined { + if (!payment.connectionId) { + return undefined + } + const { ilpAddress, sharedSecret } = deps.streamServer.generateCredentials({ paymentTag: payment.id, asset: { code: payment.asset.code, scale: payment.asset.scale } }) + return new Connection({ + id: payment.connectionId, + ilpAddress, + sharedSecret, + openPaymentsUrl: deps.openPaymentsUrl + }) +} + +function getConnectionUrl( + deps: ServiceDependencies, + payment: IncomingPayment +): string | undefined { + if (!payment.connectionId) { + return undefined + } + return `${deps.openPaymentsUrl}/connections/${payment.connectionId}` } diff --git a/packages/backend/src/open_payments/payment/incoming/model.ts b/packages/backend/src/open_payments/payment/incoming/model.ts index b428606591..a87c7282e6 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.ts @@ -1,7 +1,8 @@ -import { Model, Pojo } from 'objection' +import { Model, ModelOptions, Pojo, QueryContext } from 'objection' import { v4 as uuid } from 'uuid' import { Amount, AmountJSON } from '../../amount' +import { ConnectionJSON } from '../../connection/service' import { PaymentPointer } from '../../payment_pointer/model' import { Asset } from '../../../asset/model' import { LiquidityAccount, OnCreditOptions } from '../../../accounting/service' @@ -38,8 +39,6 @@ export interface IncomingPaymentResponse { receivedAmount: AmountJSON externalRef?: string completed: boolean - ilpAddress?: string - sharedSecret?: string } export type IncomingPaymentData = { @@ -89,7 +88,8 @@ export class IncomingPayment public expiresAt!: Date public state!: IncomingPaymentState public externalRef?: string - public connectionId!: string + // The "| null" is necessary so that `$beforeUpdate` can modify a patch to remove the connectionId. If `$beforeUpdate` set `error = undefined`, the patch would ignore the modification. + public connectionId?: string | null public processAt!: Date | null @@ -203,41 +203,58 @@ export class IncomingPayment this.connectionId = this.connectionId || uuid() } + public $beforeUpdate(opts: ModelOptions, queryContext: QueryContext): void { + super.$beforeUpdate(opts, queryContext) + if ( + [IncomingPaymentState.Completed, IncomingPaymentState.Expired].includes( + this.state + ) + ) { + this.connectionId = null + } + } + $formatJson(json: Pojo): Pojo { json = super.$formatJson(json) - return { + const payment: Pojo = { id: json.id, - incomingAmount: this.incomingAmount - ? { - ...json.incomingAmount, - value: json.incomingAmount.value.toString() - } - : null, receivedAmount: { ...json.receivedAmount, value: json.receivedAmount.value.toString() }, completed: json.completed, - description: json.description, - externalRef: json.externalRef, createdAt: json.createdAt, updatedAt: json.updatedAt, - expiresAt: json.expiresAt.toISOString(), - ilpStreamConnection: json.connectionId + expiresAt: json.expiresAt.toISOString() + } + if (json.incomingAmount) { + payment.incomingAmount = { + ...json.incomingAmount, + value: json.incomingAmount.value.toString() + } } + if (json.description) { + payment.description = json.description + } + if (json.externalRef) { + payment.externalRef = json.externalRef + } + return payment } } +// TODO: disallow undefined +// https://github.com/interledger/rafiki/issues/594 export type IncomingPaymentJSON = { id: string paymentPointer: string - incomingAmount: AmountJSON | null + incomingAmount?: AmountJSON receivedAmount: AmountJSON completed: boolean - description: string | null - externalRef: string | null + description?: string + externalRef?: string createdAt: string updatedAt: string expiresAt: string - ilpStreamConnection: string + ilpStreamConnection?: ConnectionJSON | string } diff --git a/packages/backend/src/open_payments/payment/incoming/routes.test.ts b/packages/backend/src/open_payments/payment/incoming/routes.test.ts index 374d088de6..67c463d809 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.test.ts @@ -5,6 +5,7 @@ import { Knex } from 'knex' import { v4 as uuid } from 'uuid' import { createContext } from '../../../tests/context' +import { Amount } from '../../amount' import { PaymentPointer } from '../../payment_pointer/model' import { createTestApp, TestContainer } from '../../../tests/app' import { Config, IAppConfig } from '../../../config/app' @@ -18,12 +19,11 @@ import { ListContext } from '../../../app' import { truncateTables } from '../../../tests/tableManager' -import { IncomingPayment } from './model' +import { IncomingPayment, IncomingPaymentJSON } from './model' import { IncomingPaymentRoutes, CreateBody, MAX_EXPIRY } from './routes' import { AppContext } from '../../../app' import { createIncomingPayment } from '../../../tests/incomingPayment' import { createPaymentPointer } from '../../../tests/paymentPointer' -import { Amount } from '@interledger/pay/dist/src/open-payments' import { listTests } from '../../../shared/routes.test' describe('Incoming Payment Routes', (): void => { @@ -56,7 +56,6 @@ describe('Incoming Payment Routes', (): void => { beforeAll(async (): Promise => { config = Config - config.publicHost = 'https://wallet.example' deps = await initIocContainer(config) appContainer = await createTestApp(deps) knex = await deps.use('knex') @@ -166,7 +165,7 @@ describe('Incoming Payment Routes', (): void => { }, externalRef: '#123', ilpStreamConnection: { - id: `${config.publicHost}/connections/${incomingPayment.connectionId}`, + id: `${config.openPaymentsUrl}/connections/${incomingPayment.connectionId}`, ilpAddress: expect.stringMatching( /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ ), @@ -246,7 +245,7 @@ describe('Incoming Payment Routes', (): void => { externalRef, completed: false, ilpStreamConnection: { - id: `${config.publicHost}/connections/${connectionId}`, + id: `${config.openPaymentsUrl}/connections/${connectionId}`, ilpAddress: expect.stringMatching( /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ ), @@ -281,6 +280,11 @@ describe('Incoming Payment Routes', (): void => { ) ctx.paymentPointer = paymentPointer await expect(incomingPaymentRoutes.complete(ctx)).resolves.toBeUndefined() + // Delete undefined ilpStreamConnection to satisfy toSatisfyApiSpec + expect( + (ctx.body as IncomingPaymentJSON).ilpStreamConnection + ).toBeUndefined() + delete (ctx.body as IncomingPaymentJSON).ilpStreamConnection expect(ctx.response).toSatisfyApiSpec() expect(ctx.body).toEqual({ id: incomingPayment.url, @@ -300,8 +304,7 @@ describe('Incoming Payment Routes', (): void => { assetScale: asset.scale }, externalRef: '#123', - completed: true, - ilpStreamConnection: `${config.publicHost}/connections/${incomingPayment.connectionId}` + completed: true }) }) }) @@ -329,7 +332,7 @@ describe('Incoming Payment Routes', (): void => { expiresAt: expiresAt.toISOString(), createdAt: payment.createdAt.toISOString(), updatedAt: payment.updatedAt.toISOString(), - ilpStreamConnection: `${config.publicHost}/connections/${payment.connectionId}` + ilpStreamConnection: `${config.openPaymentsUrl}/connections/${payment.connectionId}` } }, list: (ctx: ListContext) => incomingPaymentRoutes.list(ctx) diff --git a/packages/backend/src/open_payments/payment/incoming/routes.ts b/packages/backend/src/open_payments/payment/incoming/routes.ts index edda220d27..f6d0823fdf 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.ts @@ -1,5 +1,3 @@ -import base64url from 'base64url' -import { StreamCredentials } from '@interledger/stream-receiver' import { Logger } from 'pino' import { ReadContext, @@ -22,7 +20,7 @@ import { parsePaginationQueryParameters } from '../../../shared/pagination' import { Pagination } from '../../../shared/baseModel' -import { ConnectionService } from '../../connection/service' +import { ConnectionJSON, ConnectionService } from '../../connection/service' // Don't allow creating an incoming payment too far out. Incoming payments with no payments before they expire are cleaned up, since incoming payments creation is unauthenticated. // TODO what is a good default value for this? @@ -71,8 +69,8 @@ async function getIncomingPayment( ctx.throw(500, 'Error trying to get incoming payment') } if (!incomingPayment) return ctx.throw(404) - const streamCredentials = deps.connectionService.get(incomingPayment) - ctx.body = incomingPaymentToBody(deps, incomingPayment, streamCredentials) + const connection = deps.connectionService.get(incomingPayment) + ctx.body = incomingPaymentToBody(deps, incomingPayment, connection?.toJSON()) } export type CreateBody = { @@ -111,11 +109,11 @@ async function createIncomingPayment( } ctx.status = 201 - const streamCredentials = deps.connectionService.get(incomingPaymentOrError) + const connection = deps.connectionService.get(incomingPaymentOrError) ctx.body = incomingPaymentToBody( deps, incomingPaymentOrError, - streamCredentials + connection?.toJSON() ) } @@ -162,7 +160,11 @@ async function listIncomingPayments( const result = { pagination: pageInfo, result: page.map((item: IncomingPayment) => { - return incomingPaymentToBody(deps, item) + return incomingPaymentToBody( + deps, + item, + deps.connectionService.getUrl(item) + ) }) } ctx.body = result @@ -174,20 +176,12 @@ async function listIncomingPayments( function incomingPaymentToBody( deps: ServiceDependencies, incomingPayment: IncomingPayment, - streamCredentials?: StreamCredentials + ilpStreamConnection?: ConnectionJSON | string ) { - return Object.fromEntries( - Object.entries({ - ...incomingPayment.toJSON(), - id: incomingPayment.url, - paymentPointer: incomingPayment.paymentPointer.url, - ilpStreamConnection: streamCredentials - ? { - id: `${deps.config.publicHost}/connections/${incomingPayment.connectionId}`, - ilpAddress: streamCredentials.ilpAddress, - sharedSecret: base64url(streamCredentials.sharedSecret) - } - : `${deps.config.publicHost}/connections/${incomingPayment.connectionId}` - }).filter(([_, v]) => v != null) - ) + return { + ...incomingPayment.toJSON(), + id: incomingPayment.url, + paymentPointer: incomingPayment.paymentPointer.url, + ilpStreamConnection + } } diff --git a/packages/backend/src/open_payments/payment/incoming/service.test.ts b/packages/backend/src/open_payments/payment/incoming/service.test.ts index cd7ba27136..3d8243c723 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.test.ts @@ -224,13 +224,15 @@ describe('Incoming Payment Service', (): void => { ).resolves.toMatchObject({ id: incomingPayment.id, state: IncomingPaymentState.Completed, - processAt: new Date(now.getTime() + 30_000) + processAt: new Date(now.getTime() + 30_000), + connectionId: null }) await expect( incomingPaymentService.get(incomingPayment.id) ).resolves.toMatchObject({ state: IncomingPaymentState.Completed, - processAt: new Date(now.getTime() + 30_000) + processAt: new Date(now.getTime() + 30_000), + connectionId: null }) }) }) @@ -291,7 +293,8 @@ describe('Incoming Payment Service', (): void => { incomingPaymentService.get(incomingPayment.id) ).resolves.toMatchObject({ state: IncomingPaymentState.Expired, - processAt: new Date(now.getTime() + 30_000) + processAt: new Date(now.getTime() + 30_000), + connectionId: null }) }) @@ -361,12 +364,14 @@ describe('Incoming Payment Service', (): void => { incomingPayment = (await incomingPaymentService.get( incomingPayment.id )) as IncomingPayment - expect(incomingPayment.processAt).not.toBeNull() - if (eventType === IncomingPaymentEventType.IncomingPaymentExpired) { - expect(incomingPayment.state).toBe(IncomingPaymentState.Expired) - } else { - expect(incomingPayment.state).toBe(IncomingPaymentState.Completed) - } + expect(incomingPayment).toMatchObject({ + state: + eventType === IncomingPaymentEventType.IncomingPaymentExpired + ? IncomingPaymentState.Expired + : IncomingPaymentState.Completed, + processAt: expect.any(Date), + connectionId: null + }) await expect( accountingService.getTotalReceived(incomingPayment.id) ).resolves.toEqual(amountReceived) @@ -450,13 +455,15 @@ describe('Incoming Payment Service', (): void => { ).resolves.toMatchObject({ id: incomingPayment.id, state: IncomingPaymentState.Completed, - processAt: new Date(now.getTime() + 30_000) + processAt: new Date(now.getTime() + 30_000), + connectionId: null }) await expect( incomingPaymentService.get(incomingPayment.id) ).resolves.toMatchObject({ state: IncomingPaymentState.Completed, - processAt: new Date(now.getTime() + 30_000) + processAt: new Date(now.getTime() + 30_000), + connectionId: null }) }) @@ -480,13 +487,15 @@ describe('Incoming Payment Service', (): void => { ).resolves.toMatchObject({ id: incomingPayment.id, state: IncomingPaymentState.Completed, - processAt: new Date(incomingPayment.expiresAt.getTime()) + processAt: new Date(incomingPayment.expiresAt.getTime()), + connectionId: null }) await expect( incomingPaymentService.get(incomingPayment.id) ).resolves.toMatchObject({ state: IncomingPaymentState.Completed, - processAt: new Date(incomingPayment.expiresAt.getTime()) + processAt: new Date(incomingPayment.expiresAt.getTime()), + connectionId: null }) }) @@ -507,7 +516,8 @@ describe('Incoming Payment Service', (): void => { await expect( incomingPaymentService.get(incomingPayment.id) ).resolves.toMatchObject({ - state: IncomingPaymentState.Expired + state: IncomingPaymentState.Expired, + connectionId: null }) await expect( incomingPaymentService.complete(incomingPayment.id) @@ -515,7 +525,8 @@ describe('Incoming Payment Service', (): void => { await expect( incomingPaymentService.get(incomingPayment.id) ).resolves.toMatchObject({ - state: IncomingPaymentState.Expired + state: IncomingPaymentState.Expired, + connectionId: null }) }) @@ -526,7 +537,8 @@ describe('Incoming Payment Service', (): void => { await expect( incomingPaymentService.get(incomingPayment.id) ).resolves.toMatchObject({ - state: IncomingPaymentState.Completed + state: IncomingPaymentState.Completed, + connectionId: null }) await expect( incomingPaymentService.complete(incomingPayment.id) @@ -534,7 +546,8 @@ describe('Incoming Payment Service', (): void => { await expect( incomingPaymentService.get(incomingPayment.id) ).resolves.toMatchObject({ - state: IncomingPaymentState.Completed + state: IncomingPaymentState.Completed, + connectionId: null }) }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/errors.ts b/packages/backend/src/open_payments/payment/outgoing/errors.ts index d771829dda..18a1810ef0 100644 --- a/packages/backend/src/open_payments/payment/outgoing/errors.ts +++ b/packages/backend/src/open_payments/payment/outgoing/errors.ts @@ -66,7 +66,6 @@ const retryablePaymentErrors: { [paymentError in PaymentError]?: boolean } = { // Lifecycle errors PricesUnavailable: true, // From @interledger/pay's PaymentError: - QueryFailed: true, ConnectorError: true, EstablishmentFailed: true, InsufficientExchangeRate: true, diff --git a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts index 960c7cb115..d1478ed044 100644 --- a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts +++ b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts @@ -8,6 +8,8 @@ import { PaymentEventType } from './model' import { ServiceDependencies } from './service' +import { IncomingPaymentJSON } from '../incoming/model' +import { isReceiver, toResolvedPayment } from '../../shared/receiver' import { IlpPlugin } from '../../../shared/ilp_plugin' // "payment" is locked by the "deps.knex" transaction. @@ -18,16 +20,15 @@ export async function handleSending( ): Promise { if (!payment.quote) throw LifecycleError.MissingQuote - const destination = await Pay.setupPayment({ - plugin, - destinationPayment: payment.receiver - }) + const incomingPayment = await deps.clientService.incomingPayment.get( + payment.receiver + ) - if (!destination.destinationPaymentDetails) { + if (!incomingPayment) { throw LifecycleError.MissingIncomingPayment } - validateAssets(deps, payment, destination) + validateAssets(deps, payment, incomingPayment) // TODO: Query Tigerbeetle transfers by code to distinguish sending debits from withdrawals const amountSent = await deps.accountingService.getTotalSent(payment.id) @@ -35,14 +36,27 @@ export async function handleSending( throw LifecycleError.MissingBalance } + if (!isReceiver(incomingPayment)) { + // Payment is already (unexpectedly) done. Maybe this is a retry and the previous attempt failed to save the state to Postgres. Or the incoming payment could have been paid by a totally different payment in the time since the quote. + deps.logger.warn( + { + amountSent, + incomingPayment + }, + 'handleSending payment was already paid' + ) + await handleCompleted(deps, payment) + return + } + // Due to SENDING→SENDING retries, the quote's amount parameters may need adjusting. const newMaxSourceAmount = payment.sendAmount.value - amountSent let newMinDeliveryAmount - if (destination.destinationPaymentDetails.incomingAmount) { + if (incomingPayment.incomingAmount) { newMinDeliveryAmount = - destination.destinationPaymentDetails.incomingAmount.value - - destination.destinationPaymentDetails.receivedAmount.value + BigInt(incomingPayment.incomingAmount.value) - + BigInt(incomingPayment.receivedAmount.value) } else { // This is only an approximation of the true amount delivered due to exchange rate variance. The true amount delivered is returned on stream response packets, but due to connection failures there isn't a reliable way to track that in sync with the amount sent. // eslint-disable-next-line no-case-declarations @@ -55,13 +69,13 @@ export async function handleSending( } if (newMinDeliveryAmount <= BigInt(0)) { - // Payment is already (unexpectedly) done. Maybe this is a retry and the previous attempt failed to save the state to Postgres. Or the invoice could have been paid by a totally different payment in the time since the quote. + // Payment is already (unexpectedly) done. Maybe this is a retry and the previous attempt failed to save the state to Postgres. Or the incoming payment could have been paid by a totally different payment in the time since the quote. deps.logger.warn( { newMaxSourceAmount, newMinDeliveryAmount, amountSent, - incomingPayment: destination.destinationPaymentDetails + incomingPayment }, 'handleSending payment was already paid' ) @@ -97,6 +111,7 @@ export async function handleSending( minExchangeRate } + const destination = toResolvedPayment(incomingPayment) const receipt = await Pay.pay({ plugin, destination, quote }).finally(() => { return Pay.closeConnection(plugin, destination).catch((err) => { // Ignore connection close failures, all of the money was delivered. @@ -186,24 +201,22 @@ export const sendWebhookEvent = async ( const validateAssets = ( deps: ServiceDependencies, payment: OutgoingPayment, - destination: Pay.ResolvedPayment + receiver: IncomingPaymentJSON ): void => { if (payment.assetId !== payment.paymentPointer?.assetId) { throw LifecycleError.SourceAssetConflict } - if (payment.receiveAmount) { - if ( - payment.receiveAmount.assetScale !== destination.destinationAsset.scale || - payment.receiveAmount.assetCode !== destination.destinationAsset.code - ) { - deps.logger.warn( - { - oldAsset: payment.receiveAmount, - newAsset: destination.destinationAsset - }, - 'destination asset changed' - ) - throw Pay.PaymentError.DestinationAssetConflict - } + if ( + payment.receiveAmount.assetScale !== receiver.receivedAmount.assetScale || + payment.receiveAmount.assetCode !== receiver.receivedAmount.assetCode + ) { + deps.logger.warn( + { + oldAsset: payment.receiveAmount, + newAsset: receiver.receivedAmount + }, + 'receiver asset changed' + ) + throw Pay.PaymentError.DestinationAssetConflict } } diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts index 7115ac28c1..6e01fe297e 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts @@ -54,7 +54,6 @@ describe('Outgoing Payment Routes', (): void => { beforeAll(async (): Promise => { config = Config - config.publicHost = 'https://wallet.example' deps = await initIocContainer(config) appContainer = await createTestApp(deps) knex = await deps.use('knex') 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 6549ff7a29..22bbadc1da 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -34,6 +34,7 @@ import { PaymentEventType } from './model' import { RETRY_BACKOFF_SECONDS } from './worker' +import { IncomingPaymentState } from '../incoming/model' import { isTransferError } from '../../../accounting/errors' import { AccountingService, TransferOptions } from '../../../accounting/service' import { AssetOptions } from '../../../asset/service' @@ -152,6 +153,11 @@ describe('OutgoingPaymentService', (): void => { amount }) ).resolves.toBeUndefined() + await incomingPayment.onCredit({ + totalReceived: await accountingService.getTotalReceived( + incomingPayment.id + ) + }) } function trackAmountDelivered(sourcePaymentPointerId: string): void { @@ -402,6 +408,36 @@ describe('OutgoingPaymentService', (): void => { }) ).resolves.toEqual(OutgoingPaymentError.InvalidQuote) }) + it.each` + state + ${IncomingPaymentState.Completed} + ${IncomingPaymentState.Expired} + `( + `fails to create on $state quote receiver`, + async ({ state }): Promise => { + const quote = await createQuote(deps, { + paymentPointerId, + receiver, + sendAmount + }) + const incomingPaymentService = await deps.use('incomingPaymentService') + const incomingPayment = await incomingPaymentService.get( + getIncomingPaymentId(receiver) + ) + assert.ok(incomingPayment) + await incomingPayment.$query(knex).patch({ + state, + expiresAt: + state === IncomingPaymentState.Expired ? new Date() : undefined + }) + await expect( + outgoingPaymentService.create({ + paymentPointerId, + quoteId: quote.id + }) + ).resolves.toEqual(OutgoingPaymentError.InvalidQuote) + } + ) test('fails to create if grant is locked', async () => { const grant = new Grant({ active: true, @@ -776,12 +812,10 @@ describe('OutgoingPaymentService', (): void => { receiveAmount }) - let scope: nock.Scope | undefined const payment = await processNext( paymentId, OutgoingPaymentState.Completed ) - scope?.isDone() if (!payment.sendAmount) throw 'no sendAmount' const amountSent = payment.receiveAmount.value * BigInt(2) await expectOutcome(payment, { diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index f9bacc5b73..5837ab3f32 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -1,6 +1,5 @@ import assert from 'assert' import { ForeignKeyViolationError, TransactionOrKnex } from 'objection' -import * as Pay from '@interledger/pay' import { Pagination } from '../../../shared/baseModel' import { BaseService } from '../../../shared/baseService' @@ -18,6 +17,8 @@ import { import { AccountingService } from '../../../accounting/service' import { PeerService } from '../../../peer/service' import { Grant, AccessLimits, getInterval } from '../../auth/grant' +import { OpenPaymentsClientService } from '../../client/service' +import { isReceiver } from '../../shared/receiver' import { IlpPlugin, IlpPluginOptions } from '../../../shared/ilp_plugin' import { sendWebhookEvent } from './lifecycle' import * as worker from './worker' @@ -46,9 +47,9 @@ export interface OutgoingPaymentService { export interface ServiceDependencies extends BaseService { knex: TransactionOrKnex accountingService: AccountingService + clientService: OpenPaymentsClientService peerService: PeerService makeIlpPlugin: (options: IlpPluginOptions) => IlpPlugin - publicHost: string } export async function createOutgoingPaymentService( @@ -138,32 +139,17 @@ async function createOutgoingPayment( throw OutgoingPaymentError.InsufficientGrant } } - const plugin = deps.makeIlpPlugin({ - sourceAccount: { - id: payment.paymentPointerId, - asset: { - id: payment.assetId, - ledger: payment.asset.ledger - } - }, - unfulfillable: true - }) - try { - await plugin.connect() - const destination = await Pay.setupPayment({ - plugin, - destinationPayment: payment.receiver - }) - const peer = await deps.peerService.getByDestinationAddress( - destination.destinationAddress - ) - if (peer) { - await payment.$query(trx).patch({ peerId: peer.id }) - } - } finally { - plugin.disconnect().catch((err: Error) => { - deps.logger.warn({ error: err.message }, 'error disconnecting plugin') - }) + const incomingPayment = await deps.clientService.incomingPayment.get( + payment.receiver + ) + if (!isReceiver(incomingPayment)) { + throw OutgoingPaymentError.InvalidQuote + } + const peer = await deps.peerService.getByDestinationAddress( + incomingPayment.ilpStreamConnection.ilpAddress + ) + if (peer) { + await payment.$query(trx).patch({ peerId: peer.id }) } await sendWebhookEvent( diff --git a/packages/backend/src/open_payments/quote/errors.ts b/packages/backend/src/open_payments/quote/errors.ts index b056451e83..e08629f6c7 100644 --- a/packages/backend/src/open_payments/quote/errors.ts +++ b/packages/backend/src/open_payments/quote/errors.ts @@ -1,7 +1,7 @@ export enum QuoteError { UnknownPaymentPointer = 'UnknownPaymentPointer', InvalidAmount = 'InvalidAmount', - InvalidDestination = 'InvalidDestination' + InvalidReceiver = 'InvalidReceiver' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -13,7 +13,7 @@ export const errorToCode: { } = { [QuoteError.UnknownPaymentPointer]: 404, [QuoteError.InvalidAmount]: 400, - [QuoteError.InvalidDestination]: 400 + [QuoteError.InvalidReceiver]: 400 } export const errorToMessage: { @@ -21,5 +21,5 @@ export const errorToMessage: { } = { [QuoteError.UnknownPaymentPointer]: 'unknown payment pointer', [QuoteError.InvalidAmount]: 'invalid amount', - [QuoteError.InvalidDestination]: 'invalid destination' + [QuoteError.InvalidReceiver]: 'invalid receiver' } diff --git a/packages/backend/src/open_payments/quote/routes.test.ts b/packages/backend/src/open_payments/quote/routes.test.ts index ea8672c31b..bbf0b35836 100644 --- a/packages/backend/src/open_payments/quote/routes.test.ts +++ b/packages/backend/src/open_payments/quote/routes.test.ts @@ -54,7 +54,6 @@ describe('Quote Routes', (): void => { beforeAll(async (): Promise => { config = Config - config.publicHost = 'https://wallet.example' deps = await initIocContainer(config) appContainer = await createTestApp(deps) knex = await deps.use('knex') diff --git a/packages/backend/src/open_payments/quote/service.test.ts b/packages/backend/src/open_payments/quote/service.test.ts index 254b83bcca..22f3ea02ba 100644 --- a/packages/backend/src/open_payments/quote/service.test.ts +++ b/packages/backend/src/open_payments/quote/service.test.ts @@ -246,7 +246,7 @@ describe('QuoteService', (): void => { await expect(quoteService.create(options)).resolves.toEqual( toPaymentPointer ? QuoteError.InvalidAmount - : QuoteError.InvalidDestination + : QuoteError.InvalidReceiver ) }) } else { @@ -475,12 +475,12 @@ describe('QuoteService', (): void => { if (!toPaymentPointer) { test.each` - state | error - ${IncomingPaymentState.Completed} | ${Pay.PaymentError.IncomingPaymentCompleted} - ${IncomingPaymentState.Expired} | ${Pay.PaymentError.IncomingPaymentExpired} + state + ${IncomingPaymentState.Completed} + ${IncomingPaymentState.Expired} `( - 'throws on $state receiver', - async ({ state, error }): Promise => { + `returns ${QuoteError.InvalidReceiver} on $state receiver`, + async ({ state }): Promise => { await incomingPayment.$query(knex).patch({ state, expiresAt: @@ -488,8 +488,8 @@ describe('QuoteService', (): void => { ? new Date() : undefined }) - await expect(quoteService.create(options)).rejects.toEqual( - error + await expect(quoteService.create(options)).resolves.toEqual( + QuoteError.InvalidReceiver ) } ) @@ -519,7 +519,7 @@ describe('QuoteService', (): void => { }/incoming-payments/${uuid()}`, sendAmount }) - ).resolves.toEqual(QuoteError.InvalidDestination) + ).resolves.toEqual(QuoteError.InvalidReceiver) }) test.each` diff --git a/packages/backend/src/open_payments/quote/service.ts b/packages/backend/src/open_payments/quote/service.ts index 53b04dc7b1..600d58350b 100644 --- a/packages/backend/src/open_payments/quote/service.ts +++ b/packages/backend/src/open_payments/quote/service.ts @@ -9,7 +9,10 @@ import { BaseService } from '../../shared/baseService' import { QuoteError, isQuoteError } from './errors' import { Quote } from './model' import { Amount } from '../amount' +import { OpenPaymentsClientService } from '../client/service' +import { PaymentPointer } from '../payment_pointer/model' import { PaymentPointerService } from '../payment_pointer/service' +import { isReceiver, Receiver, toResolvedPayment } from '../shared/receiver' import { RatesService } from '../../rates/service' import { IlpPlugin, IlpPluginOptions } from '../../shared/ilp_plugin' @@ -31,6 +34,7 @@ export interface ServiceDependencies extends BaseService { quoteLifespan: number // milliseconds signatureSecret?: string signatureVersion: number + clientService: OpenPaymentsClientService paymentPointerService: PaymentPointerService ratesService: RatesService makeIlpPlugin: (options: IlpPluginOptions) => IlpPlugin @@ -92,33 +96,15 @@ async function createQuote( } } - const plugin = deps.makeIlpPlugin({ - sourceAccount: paymentPointer, - unfulfillable: true - }) - try { - await plugin.connect() - - const destination = await resolveDestination(deps, options, plugin) - - const receivingPaymentValue = destination.destinationPaymentDetails - ?.incomingAmount - ? destination.destinationPaymentDetails.incomingAmount.value - - destination.destinationPaymentDetails.receivedAmount.value - : undefined - - const ilpQuote = await startQuote(deps, options, { - plugin, - destination, - sourceAsset: { - scale: paymentPointer.asset.scale, - code: paymentPointer.asset.code - } + const incomingPayment = await resolveReceiver(deps, options) + const ilpQuote = await startQuote(deps, { + ...options, + paymentPointer, + incomingPayment }) return await Quote.transaction(deps.knex, async (trx) => { - assert.ok(destination.destinationPaymentDetails) const quote = await Quote.query(trx) .insertAndFetch({ paymentPointerId: options.paymentPointerId, @@ -131,8 +117,8 @@ async function createQuote( }, receiveAmount: { value: ilpQuote.minDeliveryAmount, - assetCode: destination.destinationAsset.code, - assetScale: destination.destinationAsset.scale + assetCode: incomingPayment.receivedAmount.assetCode, + assetScale: incomingPayment.receivedAmount.assetScale }, // Cap at MAX_INT64 because of postgres type limits. maxPacketAmount: @@ -149,6 +135,10 @@ async function createQuote( let maxReceiveAmountValue: bigint | undefined if (options.sendAmount) { + const receivingPaymentValue = incomingPayment?.incomingAmount + ? BigInt(incomingPayment.incomingAmount.value) - + BigInt(incomingPayment.receivedAmount.value) + : undefined maxReceiveAmountValue = receivingPaymentValue && receivingPaymentValue < quote.receiveAmount.value @@ -170,109 +160,111 @@ async function createQuote( return err } throw err - } finally { - plugin.disconnect().catch((err: Error) => { - deps.logger.warn({ error: err.message }, 'error disconnecting plugin') - }) } } -export async function resolveDestination( +export async function resolveReceiver( deps: ServiceDependencies, - options: CreateQuoteOptions, - plugin: IlpPlugin -): Promise { - let destination: Pay.ResolvedPayment - try { - destination = await Pay.setupPayment({ - plugin, - destinationPayment: options.receiver - }) - } catch (err) { - if (err === Pay.PaymentError.QueryFailed) { - throw QuoteError.InvalidDestination - } - throw err - } - if (!destination.destinationPaymentDetails) { - deps.logger.warn( - { - options - }, - 'missing incoming payment' - ) - throw new Error('missing incoming payment') + options: CreateQuoteOptions +): Promise { + const incomingPayment = await deps.clientService.incomingPayment.get( + options.receiver + ) + if (!incomingPayment || !isReceiver(incomingPayment)) { + throw QuoteError.InvalidReceiver } - if (options.receiveAmount) { if ( - options.receiveAmount.assetScale !== destination.destinationAsset.scale || - options.receiveAmount.assetCode !== destination.destinationAsset.code + options.receiveAmount.assetScale !== + incomingPayment.receivedAmount.assetScale || + options.receiveAmount.assetCode !== + incomingPayment.receivedAmount.assetCode ) { throw QuoteError.InvalidAmount } - if (destination.destinationPaymentDetails.incomingAmount) { + if (incomingPayment.incomingAmount) { const receivingPaymentValue = - destination.destinationPaymentDetails.incomingAmount.value - - destination.destinationPaymentDetails.receivedAmount.value + BigInt(incomingPayment.incomingAmount.value) - + BigInt(incomingPayment.receivedAmount.value) if (receivingPaymentValue < options.receiveAmount.value) { throw QuoteError.InvalidAmount } } - } else if ( - !options.sendAmount && - !destination.destinationPaymentDetails.incomingAmount - ) { - throw QuoteError.InvalidDestination + } else if (!options.sendAmount && !incomingPayment.incomingAmount) { + throw QuoteError.InvalidReceiver } - return destination + return incomingPayment +} + +export interface StartQuoteOptions { + paymentPointer: PaymentPointer + sendAmount?: Amount + receiveAmount?: Amount + incomingPayment: Receiver } export async function startQuote( deps: ServiceDependencies, - options: CreateQuoteOptions, - quoteOptions: Pay.QuoteOptions + options: StartQuoteOptions ): Promise { const prices = await deps.ratesService.prices().catch((_err: Error) => { throw new Error('missing prices') }) - assert.ok(quoteOptions.destination.destinationPaymentDetails) - if (options.sendAmount) { - quoteOptions.amountToSend = options.sendAmount.value - quoteOptions.destination.destinationPaymentDetails.incomingAmount = - undefined - } else if (options.receiveAmount) { - quoteOptions.amountToDeliver = options.receiveAmount.value - quoteOptions.destination.destinationPaymentDetails.incomingAmount = - undefined - } - const quote = await Pay.startQuote({ - ...quoteOptions, - slippage: deps.slippage, - prices - }).finally(() => { - return Pay.closeConnection( - quoteOptions.plugin, - quoteOptions.destination - ).catch((err) => { - deps.logger.warn( - { - destination: quoteOptions.destination.destinationAddress, - error: err.message - }, - 'close quote connection failed' - ) - }) + + const plugin = deps.makeIlpPlugin({ + sourceAccount: options.paymentPointer, + unfulfillable: true }) - // Pay.startQuote should return PaymentError.InvalidSourceAmount or - // PaymentError.InvalidDestinationAmount for non-positive amounts. - // Outgoing payments' sendAmount or receiveAmount should never be - // zero or negative. - assert.ok(quote.maxSourceAmount > BigInt(0)) - assert.ok(quote.minDeliveryAmount > BigInt(0)) + try { + await plugin.connect() + const quoteOptions: Pay.QuoteOptions = { + plugin, + destination: toResolvedPayment(options.incomingPayment), + sourceAsset: { + scale: options.paymentPointer.asset.scale, + code: options.paymentPointer.asset.code + } + } + if (options.sendAmount) { + quoteOptions.amountToSend = options.sendAmount.value + } else { + quoteOptions.amountToDeliver = + options.receiveAmount?.value || + BigInt(options.incomingPayment.incomingAmount.value) + } + const quote = await Pay.startQuote({ + ...quoteOptions, + slippage: deps.slippage, + prices + }).finally(() => { + return Pay.closeConnection( + quoteOptions.plugin, + quoteOptions.destination + ).catch((err) => { + deps.logger.warn( + { + destination: quoteOptions.destination.destinationAddress, + error: err.message + }, + 'close quote connection failed' + ) + }) + }) + + // Pay.startQuote should return PaymentError.InvalidSourceAmount or + // PaymentError.InvalidDestinationAmount for non-positive amounts. + // Outgoing payments' sendAmount or receiveAmount should never be + // zero or negative. + assert.ok(quote.maxSourceAmount > BigInt(0)) + assert.ok(quote.minDeliveryAmount > BigInt(0)) - return quote + return quote + } finally { + plugin.disconnect().catch((err: Error) => { + deps.logger.warn({ error: err.message }, 'error disconnecting plugin') + }) + } } export async function finalizeQuote( diff --git a/packages/backend/src/open_payments/shared/receiver.ts b/packages/backend/src/open_payments/shared/receiver.ts new file mode 100644 index 0000000000..227e341f17 --- /dev/null +++ b/packages/backend/src/open_payments/shared/receiver.ts @@ -0,0 +1,56 @@ +import { Counter, ResolvedPayment } from '@interledger/pay' +import base64url from 'base64url' + +import { ConnectionJSON } from '../connection/service' +import { IncomingPaymentJSON } from '../payment/incoming/model' + +export type Receiver = Omit & { + ilpStreamConnection: ConnectionJSON +} + +export const isReceiver = ( + incomingPayment: IncomingPaymentJSON +): incomingPayment is Receiver => { + if (incomingPayment.completed) { + return false + } + if ( + incomingPayment.expiresAt && + new Date(incomingPayment.expiresAt).getTime() <= Date.now() + ) { + return false + } + if (incomingPayment.incomingAmount) { + if ( + incomingPayment.incomingAmount.assetCode !== + incomingPayment.receivedAmount.assetCode || + incomingPayment.incomingAmount.assetScale !== + incomingPayment.receivedAmount.assetScale + ) { + return false + } + if ( + BigInt(incomingPayment.incomingAmount.value) <= + BigInt(incomingPayment.receivedAmount.value) + ) { + return false + } + } + return typeof incomingPayment.ilpStreamConnection === 'object' +} + +export const toResolvedPayment = ( + incomingPayment: Receiver +): ResolvedPayment => { + return { + destinationAsset: { + code: incomingPayment.receivedAmount.assetCode, + scale: incomingPayment.receivedAmount.assetScale + }, + destinationAddress: incomingPayment.ilpStreamConnection.ilpAddress, + sharedSecret: base64url.toBuffer( + incomingPayment.ilpStreamConnection.sharedSecret + ), + requestCounter: Counter.from(0) + } +} diff --git a/packages/backend/src/shared/pagination.test.ts b/packages/backend/src/shared/pagination.test.ts index b9e7b49eaa..e50f66e436 100644 --- a/packages/backend/src/shared/pagination.test.ts +++ b/packages/backend/src/shared/pagination.test.ts @@ -3,6 +3,7 @@ import { Knex } from 'knex' import { IocContract } from '@adonisjs/fold' import { AppServices } from '../app' import { createTestApp, TestContainer } from '../tests/app' +import { Amount } from '../open_payments/amount' import { PaymentPointer } from '../open_payments/payment_pointer/model' import { IncomingPaymentService } from '../open_payments/payment/incoming/service' import { Config, IAppConfig } from '../config/app' @@ -15,7 +16,6 @@ import { createIncomingPayment } from '../tests/incomingPayment' import { createQuote } from '../tests/quote' import { createOutgoingPayment } from '../tests/outgoingPayment' import { createPaymentPointer } from '../tests/paymentPointer' -import { Amount } from '@interledger/pay/dist/src/open-payments' import { getPageInfo, parsePaginationQueryParameters } from './pagination' import { AssetService } from '../asset/service' import { PeerService } from '../peer/service' diff --git a/packages/backend/src/tests/outgoingPayment.ts b/packages/backend/src/tests/outgoingPayment.ts index 27c3e092a8..38fd64f4db 100644 --- a/packages/backend/src/tests/outgoingPayment.ts +++ b/packages/backend/src/tests/outgoingPayment.ts @@ -1,6 +1,6 @@ import assert from 'assert' +import base64url from 'base64url' import { IocContract } from '@adonisjs/fold' -import * as Pay from '@interledger/pay' import { createQuote, CreateTestQuoteOptions } from './quote' import { AppServices } from '../app' @@ -17,18 +17,21 @@ export async function createOutgoingPayment( ): Promise { const quote = await createQuote(deps, options) const outgoingPaymentService = await deps.use('outgoingPaymentService') + const clientService = await deps.use('openPaymentsClientService') if (options.validDestination === false) { const streamServer = await deps.use('streamServer') const { ilpAddress, sharedSecret } = streamServer.generateCredentials() - jest.spyOn(Pay, 'setupPayment').mockResolvedValueOnce({ - destinationAsset: { - code: quote.receiveAmount.assetCode, - scale: quote.receiveAmount.assetScale + jest.spyOn(clientService.incomingPayment, 'get').mockResolvedValueOnce({ + id: options.receiver, + receivedAmount: { + value: BigInt(0), + assetCode: quote.receiveAmount.assetCode, + assetScale: quote.receiveAmount.assetScale }, - destinationAddress: ilpAddress, - sharedSecret, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - requestCounter: Pay.Counter.from(0)! + ilpStreamConnection: { + ilpAddress, + sharedSecret: base64url(sharedSecret) + } }) } const outgoingPaymentOrError = await outgoingPaymentService.create({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cbdafe0ce..b35775d3bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,7 +143,7 @@ importers: apollo-server: ^3.10.1 apollo-server-koa: ^3.10.1 auth: workspace:../auth - axios: ^0.27.2 + axios: 0.26.1 base64url: ^3.0.1 bcrypt: ^5.0.1 cross-fetch: ^3.1.4 @@ -152,7 +152,7 @@ importers: graphql: ^16.6.0 graphql-scalars: ^1.18.0 graphql-tools: ^8.3.3 - ilp-packet: ^3.1.2 + ilp-packet: 3.1.4-alpha.1 ilp-protocol-ccp: ^1.2.2 ilp-protocol-ildcp: ^2.2.2 ilp-protocol-stream: ^2.7.0 @@ -200,7 +200,7 @@ importers: add: 2.0.6 ajv: 8.11.0 apollo-server-koa: 3.10.1_graphql@16.6.0+koa@2.13.4 - axios: 0.27.2 + axios: 0.26.1 base64url: 3.0.1 bcrypt: 5.0.1 extensible-error: 1.0.2 @@ -208,7 +208,7 @@ importers: graphql: 16.6.0 graphql-scalars: 1.18.0_graphql@16.6.0 graphql-tools: 8.3.3_onqnqwb3ubg5opvemcqf7c2qhy - ilp-packet: 3.1.3 + ilp-packet: 3.1.4-alpha.1 ilp-protocol-ccp: 1.2.3 ilp-protocol-ildcp: 2.2.3 ioredis: 5.2.2 @@ -4941,6 +4941,14 @@ packages: - debug dev: true + /axios/0.26.1: + resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==} + dependencies: + follow-redirects: 1.15.1 + transitivePeerDependencies: + - debug + dev: false + /axios/0.27.2: resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} dependencies: