diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Continuation Request.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Continuation Request.bru index 01d39ebf3f..f650f7d138 100644 --- a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Continuation Request.bru +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Continuation Request.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/continue/{{continueId}} + url: {{senderTenantOpenPaymentsAuthHost}}/continue/{{continueId}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Outgoing Payment.bru index 6014dda378..b8527f9042 100644 --- a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Outgoing Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsHost}}/outgoing-payments + url: {{senderTenantOpenPaymentsHost}}/outgoing-payments body: json auth: none } @@ -16,8 +16,8 @@ headers { body:json { { - "walletAddress": "{{senderWalletAddress}}", - "quoteId": "{{senderWalletAddress}}/quotes/{{quoteId}}", + "walletAddress": "{{senderTenantWalletAddress}}", + "quoteId": "{{senderTenantWalletAddress}}/quotes/{{quoteId}}", "metadata": { "description": "Free Money!" } diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get Outgoing Payment.bru index 4946e1b040..17e3671e8c 100644 --- a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get Outgoing Payment.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{senderOpenPaymentsHost}}/outgoing-payments/{{outgoingPaymentId}} + url: {{senderTenantOpenPaymentsHost}}/outgoing-payments/{{outgoingPaymentId}} body: none auth: none } diff --git a/localenv/cloud-ten-wallet/docker-compose.yml b/localenv/cloud-ten-wallet/docker-compose.yml index f6a6207e27..9b732a6deb 100644 --- a/localenv/cloud-ten-wallet/docker-compose.yml +++ b/localenv/cloud-ten-wallet/docker-compose.yml @@ -22,7 +22,7 @@ services: GRAPHQL_URL: http://cloud-nine-wallet-backend:3001/graphql SIGNATURE_VERSION: 1 SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= - IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= + IDP_SECRET: ue3ixgIiWLIlWOd4w5KO78scYpFH+vHuCJ33lnjgzEg= DISPLAY_NAME: Cloud Ten Wallet DISPLAY_ICON: wallet-icon.svg OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 diff --git a/localenv/happy-life-bank/seed.yml b/localenv/happy-life-bank/seed.yml index b4c175fca4..3eb4824dc9 100644 --- a/localenv/happy-life-bank/seed.yml +++ b/localenv/happy-life-bank/seed.yml @@ -27,7 +27,7 @@ peers: outgoing: test-USD-happy-life-bank-cloud-nine-wallet - initialLiquidity: '1000000000000' peerUrl: http://cloud-nine-wallet-backend:3002 - peerIlpAddress: test.cloud-nine-wallet + peerIlpAddress: test.cloud-nine-wallet.bc293b79-8609-47bd-b914-6438b470aff8 liquidityThreshold: 1000000 tokens: incoming: diff --git a/localenv/mock-account-servicing-entity/app/routes/mock-idp._index.tsx b/localenv/mock-account-servicing-entity/app/routes/mock-idp._index.tsx index ac913a98a8..11874360bd 100644 --- a/localenv/mock-account-servicing-entity/app/routes/mock-idp._index.tsx +++ b/localenv/mock-account-servicing-entity/app/routes/mock-idp._index.tsx @@ -31,7 +31,10 @@ interface GrantAmount { } export function loader() { - return json({ defaultIdpSecret: CONFIG.idpSecret }) + return json({ + defaultIdpSecret: CONFIG.idpSecret, + isTenant: process.env.IS_TENANT === 'true' + }) } function ConsentScreenBody({ @@ -207,13 +210,14 @@ type ConsentScreenProps = { // In production, ensure that secrets are handled securely and are not exposed to the client-side code. export default function ConsentScreen({ idpSecretParam }: ConsentScreenProps) { + const { defaultIdpSecret, isTenant } = useLoaderData() const [ctx, setCtx] = useState({ ready: false, thirdPartyName: '', thirdPartyUri: '', interactId: 'demo-interact-id', nonce: 'demo-interact-nonce', - returnUrl: 'http://localhost:3030/mock-idp/consent?', + returnUrl: `http://localhost:${isTenant ? 5030 : 3030}/mock-idp/consent?`, //TODO returnUrl: 'http://localhost:3030/mock-idp/consent?interactid=demo-interact-id&nonce=demo-interact-nonce', accesses: null, outgoingPaymentAccess: null, @@ -225,7 +229,6 @@ export default function ConsentScreen({ idpSecretParam }: ConsentScreenProps) { const queryParams = new URLSearchParams(location.search) const instanceConfig: InstanceConfig = useOutletContext() - const { defaultIdpSecret } = useLoaderData() const idpSecret = idpSecretParam ? idpSecretParam : defaultIdpSecret useEffect(() => { diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index cfa367113e..9952324002 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -110,6 +110,8 @@ import { import { TenantService } from './tenants/service' import { AuthServiceClient } from './auth-service-client/client' import { TenantSettingService } from './tenants/settings/service' +import { StreamCredentialsService } from './payment-method/ilp/stream-credentials/service' +import { PaymentMethodProviderService } from './payment-method/provider/service' export interface AppContextData { logger: Logger @@ -278,6 +280,8 @@ export interface AppServices { tenantService: Promise authServiceClient: AuthServiceClient tenantSettingService: Promise + streamCredentialsService: Promise + paymentMethodProviderService: Promise } export type AppContainer = IocContract diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 484195615d..73cbdfbab6 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -76,6 +76,7 @@ import { createInMemoryDataStore } from './middleware/cache/data-stores/in-memor import { createTenantService } from './tenants/service' import { AuthServiceClient } from './auth-service-client/client' import { createTenantSettingService } from './tenants/settings/service' +import { createPaymentMethodProviderService } from './payment-method/provider/service' BigInt.prototype.toJSON = function () { return this.toString() @@ -249,6 +250,16 @@ export function initIocContainer( return createTenantSettingService({ logger, knex }) }) + container.singleton('paymentMethodProviderService', async (deps) => { + return createPaymentMethodProviderService({ + logger: await deps.use('logger'), + knex: await deps.use('knex'), + config: await deps.use('config'), + streamCredentialsService: await deps.use('streamCredentialsService'), + tenantSettingsService: await deps.use('tenantSettingService') + }) + }) + container.singleton('ratesService', async (deps) => { const config = await deps.use('config') return createRatesService({ @@ -460,7 +471,9 @@ export function initIocContainer( config: await deps.use('config'), logger: await deps.use('logger'), incomingPaymentService: await deps.use('incomingPaymentService'), - streamCredentialsService: await deps.use('streamCredentialsService') + paymentMethodProviderService: await deps.use( + 'paymentMethodProviderService' + ) }) }) container.singleton('walletAddressRoutes', async (deps) => { @@ -478,24 +491,24 @@ export function initIocContainer( }) }) container.singleton('streamCredentialsService', async (deps) => { - const config = await deps.use('config') return await createStreamCredentialsService({ logger: await deps.use('logger'), - openPaymentsUrl: config.openPaymentsUrl, - streamServer: await deps.use('streamServer') + config: await deps.use('config') }) }) container.singleton('receiverService', async (deps) => { return await createReceiverService({ logger: await deps.use('logger'), config: await deps.use('config'), - streamCredentialsService: await deps.use('streamCredentialsService'), incomingPaymentService: await deps.use('incomingPaymentService'), walletAddressService: await deps.use('walletAddressService'), remoteIncomingPaymentService: await deps.use( 'remoteIncomingPaymentService' ), - telemetry: await deps.use('telemetry') + telemetry: await deps.use('telemetry'), + paymentMethodProviderService: await deps.use( + 'paymentMethodProviderService' + ) }) }) @@ -510,15 +523,16 @@ export function initIocContainer( const config = await deps.use('config') return await createConnectorService({ logger: await deps.use('logger'), + config: await deps.use('config'), redis: await deps.use('redis'), accountingService: await deps.use('accountingService'), walletAddressService: await deps.use('walletAddressService'), incomingPaymentService: await deps.use('incomingPaymentService'), peerService: await deps.use('peerService'), ratesService: await deps.use('ratesService'), - streamServer: await deps.use('streamServer'), ilpAddress: config.ilpAddress, - telemetry: await deps.use('telemetry') + telemetry: await deps.use('telemetry'), + tenantSettingService: await deps.use('tenantSettingService') }) }) diff --git a/packages/backend/src/open_payments/payment/incoming/model.test.ts b/packages/backend/src/open_payments/payment/incoming/model.test.ts index cfe0352d9e..2e9b41fdac 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.test.ts @@ -6,17 +6,16 @@ import { AppServices } from '../../../app' import { createIncomingPayment } from '../../../tests/incomingPayment' import { createWalletAddress } from '../../../tests/walletAddress' import { truncateTables } from '../../../tests/tableManager' -import { IlpStreamCredentials } from '../../../payment-method/ilp/stream-credentials/service' import { serializeAmount } from '../../amount' import { IlpAddress } from 'ilp-packet' import { IncomingPayment, IncomingPaymentEvent, IncomingPaymentEventType, - IncomingPaymentState, IncomingPaymentEventError } from './model' import { WalletAddress } from '../../wallet_address/model' +import { OpenPaymentsPaymentMethod } from '../../../payment-method/provider/service' describe('Models', (): void => { let deps: IocContract @@ -80,16 +79,19 @@ describe('Models', (): void => { describe('toOpenPaymentsTypeWithMethods', () => { test('returns incoming payment with payment methods', async () => { - const streamCredentials: IlpStreamCredentials = { - ilpAddress: 'test.ilp' as IlpAddress, - sharedSecret: Buffer.from('') - } + const paymentMethods: OpenPaymentsPaymentMethod[] = [ + { + type: 'ilp', + ilpAddress: 'test.ilp' as IlpAddress, + sharedSecret: '' + } + ] expect( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + paymentMethods ) ).toEqual({ id: `${baseUrl}/${Config.operatorTenantId}${IncomingPayment.urlPath}/${incomingPayment.id}`, @@ -112,67 +114,6 @@ describe('Models', (): void => { ] }) }) - - test('returns incoming payment with empty methods when stream credentials are undefined', async () => { - expect( - incomingPayment.toOpenPaymentsTypeWithMethods( - config.openPaymentsUrl, - walletAddress - ) - ).toEqual({ - id: `${baseUrl}/${Config.operatorTenantId}${IncomingPayment.urlPath}/${incomingPayment.id}`, - walletAddress: walletAddress.address, - completed: incomingPayment.completed, - receivedAmount: serializeAmount(incomingPayment.receivedAmount), - incomingAmount: incomingPayment.incomingAmount - ? serializeAmount(incomingPayment.incomingAmount) - : undefined, - expiresAt: incomingPayment.expiresAt.toISOString(), - metadata: incomingPayment.metadata ?? undefined, - updatedAt: incomingPayment.updatedAt.toISOString(), - createdAt: incomingPayment.createdAt.toISOString(), - methods: [] - }) - }) - - test.each([IncomingPaymentState.Completed, IncomingPaymentState.Expired])( - 'returns incoming payment with existing methods if payment state is %s', - async (paymentState): Promise => { - incomingPayment.state = paymentState - - const streamCredentials: IlpStreamCredentials = { - ilpAddress: 'test.ilp' as IlpAddress, - sharedSecret: Buffer.from('') - } - - expect( - incomingPayment.toOpenPaymentsTypeWithMethods( - config.openPaymentsUrl, - walletAddress, - streamCredentials - ) - ).toMatchObject({ - id: `${baseUrl}/${Config.operatorTenantId}${IncomingPayment.urlPath}/${incomingPayment.id}`, - walletAddress: walletAddress.address, - completed: incomingPayment.completed, - receivedAmount: serializeAmount(incomingPayment.receivedAmount), - incomingAmount: incomingPayment.incomingAmount - ? serializeAmount(incomingPayment.incomingAmount) - : undefined, - expiresAt: incomingPayment.expiresAt.toISOString(), - metadata: incomingPayment.metadata ?? undefined, - updatedAt: incomingPayment.updatedAt.toISOString(), - createdAt: incomingPayment.createdAt.toISOString(), - methods: [ - expect.objectContaining({ - type: 'ilp', - ilpAddress: streamCredentials.ilpAddress, - sharedSecret: expect.any(String) - }) - ] - }) - } - ) }) }) diff --git a/packages/backend/src/open_payments/payment/incoming/model.ts b/packages/backend/src/open_payments/payment/incoming/model.ts index a3d77b3016..f70a765484 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.ts @@ -1,7 +1,6 @@ import { Model, QueryContext } from 'objection' import { Amount, AmountJSON, serializeAmount } from '../../amount' -import { IlpStreamCredentials } from '../../../payment-method/ilp/stream-credentials/service' import { WalletAddress, WalletAddressSubresource @@ -14,7 +13,7 @@ import { IncomingPayment as OpenPaymentsIncomingPayment, IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethod } from '@interledger/open-payments' -import base64url from 'base64url' +import { OpenPaymentsPaymentMethod } from '../../../payment-method/provider/service' export enum IncomingPaymentEventType { IncomingPaymentCreated = 'incoming_payment.created', @@ -242,19 +241,11 @@ export class IncomingPayment public toOpenPaymentsTypeWithMethods( resourceServerUrl: string, walletAddress: WalletAddress, - ilpStreamCredentials?: IlpStreamCredentials + paymentMethods: OpenPaymentsPaymentMethod[] ): OpenPaymentsIncomingPaymentWithPaymentMethod { return { ...this.toOpenPaymentsType(resourceServerUrl, walletAddress), - methods: !ilpStreamCredentials - ? [] - : [ - { - type: 'ilp', - ilpAddress: ilpStreamCredentials.ilpAddress, - sharedSecret: base64url(ilpStreamCredentials.sharedSecret) - } - ] + methods: paymentMethods } } 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 cb2f85cb2f..4e08002de6 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.test.ts @@ -23,6 +23,7 @@ import { Asset } from '../../../asset/model' import { IncomingPaymentError, errorToHTTPCode, errorToMessage } from './errors' import { IncomingPaymentService } from './service' import { IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethods } from '@interledger/open-payments' +import { PaymentMethodProviderService } from '../../../payment-method/provider/service' describe('Incoming Payment Routes', (): void => { let deps: IocContract @@ -30,6 +31,7 @@ describe('Incoming Payment Routes', (): void => { let config: IAppConfig let incomingPaymentRoutes: IncomingPaymentRoutes let incomingPaymentService: IncomingPaymentService + let paymentMethodProviderService: PaymentMethodProviderService let tenantId: string beforeAll(async (): Promise => { @@ -52,6 +54,9 @@ describe('Incoming Payment Routes', (): void => { config = await deps.use('config') incomingPaymentRoutes = await deps.use('incomingPaymentRoutes') incomingPaymentService = await deps.use('incomingPaymentService') + paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) expiresAt = new Date(Date.now() + 30_000) asset = await createAsset(deps) @@ -91,8 +96,14 @@ describe('Incoming Payment Routes', (): void => { metadata, tenantId }), - get: (ctx) => - incomingPaymentRoutes.get(ctx as ReadContextWithAuthenticatedStatus), + get: (ctx) => { + jest + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) + return incomingPaymentRoutes.get( + ctx as ReadContextWithAuthenticatedStatus + ) + }, getBody: (incomingPayment, list) => { const response: Partial = { @@ -110,15 +121,7 @@ describe('Incoming Payment Routes', (): void => { } if (!list) { - response.methods = [ - { - type: 'ilp', - ilpAddress: expect.stringMatching( - /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ - ), - sharedSecret: expect.any(String) - } - ] + response.methods = [] } return response }, @@ -180,19 +183,14 @@ describe('Incoming Payment Routes', (): void => { walletAddress }) + jest + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) await expect(incomingPaymentRoutes.get(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.body).toMatchObject({ - methods: [ - { - type: 'ilp', - ilpAddress: expect.stringMatching( - /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ - ), - sharedSecret: expect.any(String) - } - ] + id: `${baseUrl}/${tenantId}/incoming-payments/${incomingPayment.id}` }) }) }) @@ -262,6 +260,9 @@ describe('Incoming Payment Routes', (): void => { }) const incomingPaymentService = await deps.use('incomingPaymentService') const createSpy = jest.spyOn(incomingPaymentService, 'create') + jest + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) await expect(incomingPaymentRoutes.create(ctx)).resolves.toBeUndefined() expect(createSpy).toHaveBeenCalledWith({ walletAddressId: walletAddress.id, @@ -292,15 +293,7 @@ describe('Incoming Payment Routes', (): void => { }, metadata, completed: false, - methods: [ - { - type: 'ilp', - ilpAddress: expect.stringMatching( - /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ - ), - sharedSecret: expect.any(String) - } - ] + methods: [] }) } ) diff --git a/packages/backend/src/open_payments/payment/incoming/routes.ts b/packages/backend/src/open_payments/payment/incoming/routes.ts index e9e36ccb0a..00152b9126 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.ts @@ -15,15 +15,15 @@ import { } from './errors' import { AmountJSON, parseAmount } from '../../amount' import { listSubresource } from '../../wallet_address/routes' -import { StreamCredentialsService } from '../../../payment-method/ilp/stream-credentials/service' import { AccessAction } from '@interledger/open-payments' import { OpenPaymentsServerRouteError } from '../../route-errors' +import { PaymentMethodProviderService } from '../../../payment-method/provider/service' interface ServiceDependencies { config: IAppConfig logger: Logger incomingPaymentService: IncomingPaymentService - streamCredentialsService: StreamCredentialsService + paymentMethodProviderService: PaymentMethodProviderService } export type ReadContextWithAuthenticatedStatus = ReadContext & @@ -109,14 +109,14 @@ async function getIncomingPaymentPrivate( ) } - const streamCredentials = incomingPayment.isExpiredOrComplete() - ? undefined - : deps.streamCredentialsService.get(incomingPayment) + const paymentMethods = incomingPayment.isExpiredOrComplete() + ? [] + : await deps.paymentMethodProviderService.getPaymentMethods(incomingPayment) ctx.body = incomingPayment.toOpenPaymentsTypeWithMethods( deps.config.openPaymentsUrl, ctx.walletAddress, - streamCredentials + paymentMethods ) } @@ -153,15 +153,16 @@ async function createIncomingPayment( errorToMessage[incomingPaymentOrError] ) } + const paymentMethods = + await deps.paymentMethodProviderService.getPaymentMethods( + incomingPaymentOrError + ) ctx.status = 201 - const streamCredentials = deps.streamCredentialsService.get( - incomingPaymentOrError - ) ctx.body = incomingPaymentOrError.toOpenPaymentsTypeWithMethods( deps.config.openPaymentsUrl, ctx.walletAddress, - streamCredentials + paymentMethods ) } 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 503ae0d228..2d2a372ef0 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -667,7 +667,8 @@ describe('OutgoingPaymentService', (): void => { const walletAddressId = receiverWalletAddress.id const incomingPaymentUrl = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, - receiverWalletAddress + receiverWalletAddress, + [] ).id const debitAmount = { value: BigInt(123), @@ -725,7 +726,8 @@ describe('OutgoingPaymentService', (): void => { debitAmount, incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, - receiverWalletAddress + receiverWalletAddress, + [] ).id, grant } @@ -772,7 +774,8 @@ describe('OutgoingPaymentService', (): void => { debitAmount, incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, - receiverWalletAddress + receiverWalletAddress, + [] ).id, grant } @@ -793,7 +796,8 @@ describe('OutgoingPaymentService', (): void => { const walletAddressId = receiverWalletAddress.id const incomingPaymentUrl = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, - receiverWalletAddress + receiverWalletAddress, + [] ).id const debitAmount = { value: BigInt(123), diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 50d33ad4a8..569d9792dd 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -349,10 +349,15 @@ async function createOutgoingPayment( description: 'Time to retrieve peer in outgoing payment' } ) - const peer = await deps.peerService.getByDestinationAddress( - receiver.ilpAddress, - tenantId + const ilpPaymentMethod = receiver.paymentMethods.find( + (method) => method.type === 'ilp' ) + const peer = ilpPaymentMethod + ? await deps.peerService.getByDestinationAddress( + ilpPaymentMethod.ilpAddress, + tenantId + ) + : undefined stopTimerPeer() const payment = await OutgoingPayment.transaction(async (trx) => { diff --git a/packages/backend/src/open_payments/receiver/model.test.ts b/packages/backend/src/open_payments/receiver/model.test.ts index ecab56a8db..c47f12a158 100644 --- a/packages/backend/src/open_payments/receiver/model.test.ts +++ b/packages/backend/src/open_payments/receiver/model.test.ts @@ -6,26 +6,17 @@ import { AppServices } from '../../app' import { createIncomingPayment } from '../../tests/incomingPayment' import { createWalletAddress } from '../../tests/walletAddress' import { truncateTables } from '../../tests/tableManager' -import { - IlpStreamCredentials, - StreamCredentialsService -} from '../../payment-method/ilp/stream-credentials/service' import { Receiver } from './model' import { IncomingPaymentState } from '../payment/incoming/model' -import assert from 'assert' -import base64url from 'base64url' -import { IlpAddress } from 'ilp-packet' describe('Receiver Model', (): void => { let deps: IocContract let appContainer: TestContainer - let streamCredentialsService: StreamCredentialsService let config: IAppConfig beforeAll(async (): Promise => { deps = initIocContainer(Config) appContainer = await createTestApp(deps) - streamCredentialsService = await deps.use('streamCredentialsService') config = await deps.use('config') }) @@ -49,14 +40,11 @@ describe('Receiver Model', (): void => { }) const isLocal = true - const streamCredentials = streamCredentialsService.get(incomingPayment) - assert(streamCredentials) - const receiver = new Receiver( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + [] ), isLocal ) @@ -64,8 +52,6 @@ describe('Receiver Model', (): void => { expect(receiver).toEqual({ assetCode: incomingPayment.asset.code, assetScale: incomingPayment.asset.scale, - ilpAddress: expect.any(String), - sharedSecret: expect.any(Buffer), incomingPayment: { id: incomingPayment.getUrl(config.openPaymentsUrl), walletAddress: walletAddress.address, @@ -75,13 +61,7 @@ describe('Receiver Model', (): void => { receivedAmount: incomingPayment.receivedAmount, incomingAmount: incomingPayment.incomingAmount, expiresAt: incomingPayment.expiresAt, - methods: [ - { - type: 'ilp', - ilpAddress: streamCredentials.ilpAddress, - sharedSecret: base64url(streamCredentials.sharedSecret) - } - ] + methods: [] }, isLocal }) @@ -97,16 +77,12 @@ describe('Receiver Model', (): void => { }) incomingPayment.state = IncomingPaymentState.Completed - const streamCredentials: IlpStreamCredentials = { - ilpAddress: 'test.ilp' as IlpAddress, - sharedSecret: Buffer.from('') - } const openPaymentsIncomingPayment = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + [] ) expect( @@ -124,44 +100,18 @@ describe('Receiver Model', (): void => { }) incomingPayment.expiresAt = new Date(Date.now() - 1) - const streamCredentials = streamCredentialsService.get(incomingPayment) - assert(streamCredentials) + const openPaymentsIncomingPayment = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + [] ) expect( () => new Receiver(openPaymentsIncomingPayment, false) ).not.toThrow() }) - - test('throws if stream credentials has invalid ILP address', async () => { - const walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId - }) - const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id, - tenantId: Config.operatorTenantId - }) - - const streamCredentials = streamCredentialsService.get(incomingPayment) - assert(streamCredentials) - ;(streamCredentials.ilpAddress as string) = 'not base 64 encoded' - - const openPaymentsIncomingPayment = - incomingPayment.toOpenPaymentsTypeWithMethods( - config.openPaymentsUrl, - walletAddress, - streamCredentials - ) - - expect(() => new Receiver(openPaymentsIncomingPayment, false)).toThrow( - 'Invalid ILP address on ilp payment method' - ) - }) }) describe('isActive', () => { @@ -173,16 +123,12 @@ describe('Receiver Model', (): void => { }) incomingPayment.state = IncomingPaymentState.Completed - const streamCredentials: IlpStreamCredentials = { - ilpAddress: 'test.ilp' as IlpAddress, - sharedSecret: Buffer.from('') - } const openPaymentsIncomingPayment = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + [] ) const receiver = new Receiver(openPaymentsIncomingPayment, false) @@ -198,13 +144,11 @@ describe('Receiver Model', (): void => { }) incomingPayment.expiresAt = new Date(Date.now() - 1) - const streamCredentials = streamCredentialsService.get(incomingPayment) - assert(streamCredentials) const openPaymentsIncomingPayment = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + [] ) const receiver = new Receiver(openPaymentsIncomingPayment, false) diff --git a/packages/backend/src/open_payments/receiver/model.ts b/packages/backend/src/open_payments/receiver/model.ts index 49579a77b5..b5da06b98c 100644 --- a/packages/backend/src/open_payments/receiver/model.ts +++ b/packages/backend/src/open_payments/receiver/model.ts @@ -1,10 +1,7 @@ -import { Counter, ResolvedPayment } from '@interledger/pay' -import base64url from 'base64url' - import { Amount, parseAmount } from '../amount' import { AssetOptions } from '../../asset/service' import { IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethod } from '@interledger/open-payments' -import { IlpAddress, isValidIlpAddress } from 'ilp-packet' +import { OpenPaymentsPaymentMethod } from '../../payment-method/provider/service' type ReceiverIncomingPayment = Readonly< Omit< @@ -24,8 +21,6 @@ type ReceiverIncomingPayment = Readonly< > export class Receiver { - public readonly ilpAddress: IlpAddress - public readonly sharedSecret: Buffer public readonly assetCode: string public readonly assetScale: number public readonly incomingPayment: ReceiverIncomingPayment @@ -44,19 +39,6 @@ export class Receiver { : undefined const receivedAmount = parseAmount(incomingPayment.receivedAmount) - // TODO: handle multiple payment methods - const ilpMethod = incomingPayment.methods?.find( - (method) => method.type === 'ilp' - ) - if (!ilpMethod) { - throw new Error('Cannot create receiver from unsupported payment method') - } - if (!isValidIlpAddress(ilpMethod.ilpAddress)) { - throw new Error('Invalid ILP address on ilp payment method') - } - - this.ilpAddress = ilpMethod.ilpAddress - this.sharedSecret = base64url.toBuffer(ilpMethod.sharedSecret) this.assetCode = incomingPayment.receivedAmount.assetCode this.assetScale = incomingPayment.receivedAmount.assetScale @@ -100,13 +82,8 @@ export class Receiver { return undefined } - public toResolvedPayment(): ResolvedPayment { - return { - destinationAsset: this.asset, - destinationAddress: this.ilpAddress, - sharedSecret: this.sharedSecret, - requestCounter: Counter.from(0) as Counter - } + public get paymentMethods(): OpenPaymentsPaymentMethod[] { + return this.incomingPayment.methods } public isActive(): boolean { diff --git a/packages/backend/src/open_payments/receiver/service.test.ts b/packages/backend/src/open_payments/receiver/service.test.ts index 92a3c615b6..cd2ae3db7b 100644 --- a/packages/backend/src/open_payments/receiver/service.test.ts +++ b/packages/backend/src/open_payments/receiver/service.test.ts @@ -32,7 +32,7 @@ import { RemoteIncomingPaymentError } from '../payment/incoming_remote/errors' import assert from 'assert' import { Receiver } from './model' import { IncomingPayment } from '../payment/incoming/model' -import { StreamCredentialsService } from '../../payment-method/ilp/stream-credentials/service' +import { PaymentMethodProviderService } from '../../payment-method/provider/service' describe('Receiver Service', (): void => { let deps: IocContract @@ -42,7 +42,7 @@ describe('Receiver Service', (): void => { let incomingPaymentService: IncomingPaymentService let knex: Knex let walletAddressService: WalletAddressService - let streamCredentialsService: StreamCredentialsService + let paymentMethodProviderService: PaymentMethodProviderService let remoteIncomingPaymentService: RemoteIncomingPaymentService let serviceDeps: ServiceDependencies let tenantId: string @@ -54,7 +54,9 @@ describe('Receiver Service', (): void => { receiverService = await deps.use('receiverService') incomingPaymentService = await deps.use('incomingPaymentService') walletAddressService = await deps.use('walletAddressService') - streamCredentialsService = await deps.use('streamCredentialsService') + paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) remoteIncomingPaymentService = await deps.use( 'remoteIncomingPaymentService' ) @@ -66,7 +68,7 @@ describe('Receiver Service', (): void => { incomingPaymentService, remoteIncomingPaymentService, walletAddressService, - streamCredentialsService, + paymentMethodProviderService, telemetry: await deps.use('telemetry') } tenantId = Config.operatorTenantId @@ -98,13 +100,15 @@ describe('Receiver Service', (): void => { tenantId: Config.operatorTenantId }) + jest + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) + await expect( receiverService.get(incomingPayment.getUrl(config.openPaymentsUrl)) ).resolves.toEqual({ assetCode: incomingPayment.receivedAmount.assetCode, assetScale: incomingPayment.receivedAmount.assetScale, - ilpAddress: expect.any(String), - sharedSecret: expect.any(Buffer), incomingPayment: { id: incomingPayment.getUrl(config.openPaymentsUrl), walletAddress: walletAddress.address, @@ -115,13 +119,7 @@ describe('Receiver Service', (): void => { expiresAt: incomingPayment.expiresAt, createdAt: incomingPayment.createdAt, updatedAt: incomingPayment.updatedAt, - methods: [ - { - type: 'ilp', - ilpAddress: expect.any(String), - sharedSecret: expect.any(String) - } - ] + methods: [] }, isLocal: true }) @@ -170,7 +168,7 @@ describe('Receiver Service', (): void => { ) }) - test('returns object without methods if stream credentials could not be generated', async () => { + test('returns object with empty payment methods if payment methods could not be generated', async () => { const walletAddress = await createWalletAddress(deps) const incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id, @@ -182,8 +180,8 @@ describe('Receiver Service', (): void => { .mockResolvedValueOnce(incomingPayment) jest - .spyOn(streamCredentialsService, 'get') - .mockReturnValueOnce(undefined) + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) await expect( getLocalIncomingPayment( @@ -214,8 +212,6 @@ describe('Receiver Service', (): void => { ).resolves.toEqual({ assetCode: mockedIncomingPayment.receivedAmount.assetCode, assetScale: mockedIncomingPayment.receivedAmount.assetScale, - ilpAddress: mockedIncomingPayment.methods[0].ilpAddress, - sharedSecret: expect.any(Buffer), incomingPayment: { id: mockedIncomingPayment.id, walletAddress: mockedIncomingPayment.walletAddress, @@ -278,8 +274,6 @@ describe('Receiver Service', (): void => { ).resolves.toEqual({ assetCode: mockedIncomingPayment.receivedAmount.assetCode, assetScale: mockedIncomingPayment.receivedAmount.assetScale, - ilpAddress: mockedIncomingPayment.methods[0].ilpAddress, - sharedSecret: expect.any(Buffer), incomingPayment: { id: mockedIncomingPayment.id, walletAddress: mockedIncomingPayment.walletAddress, @@ -350,6 +344,17 @@ describe('Receiver Service', (): void => { remoteIncomingPaymentService, 'create' ) + + jest + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([ + { + type: 'ilp', + ilpAddress: 'test.rafiki', + sharedSecret: 'secret' + } + ]) + const receiver = await receiverService.create({ walletAddressUrl: walletAddress.address, incomingAmount, @@ -362,8 +367,6 @@ describe('Receiver Service', (): void => { expect(receiver).toEqual({ assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, - ilpAddress: receiver.ilpAddress, - sharedSecret: expect.any(Buffer), incomingPayment: { id: receiver.incomingPayment?.id, walletAddress: receiver.incomingPayment?.walletAddress, @@ -374,13 +377,7 @@ describe('Receiver Service', (): void => { updatedAt: receiver.incomingPayment?.updatedAt, createdAt: receiver.incomingPayment?.createdAt, expiresAt: receiver.incomingPayment?.expiresAt, - methods: [ - { - type: 'ilp', - ilpAddress: receiver.ilpAddress, - sharedSecret: expect.any(String) - } - ] + methods: receiver.paymentMethods }, isLocal: true }) @@ -409,10 +406,10 @@ describe('Receiver Service', (): void => { ).resolves.toEqual(ReceiverError.InvalidAmount) }) - test('throws error if stream credentials could not be generated', async () => { + test('throws error if could not generate any payment methods', async () => { jest - .spyOn(streamCredentialsService, 'get') - .mockReturnValueOnce(undefined) + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) await expect( receiverService.create({ @@ -420,7 +417,7 @@ describe('Receiver Service', (): void => { tenantId }) ).rejects.toThrow( - 'Could not get stream credentials for local incoming payment' + 'Could not get any payment methods during local incoming payment creation' ) }) }) @@ -471,8 +468,6 @@ describe('Receiver Service', (): void => { expect(receiver).toEqual({ assetCode: mockedIncomingPayment.receivedAmount.assetCode, assetScale: mockedIncomingPayment.receivedAmount.assetScale, - ilpAddress: mockedIncomingPayment.methods[0].ilpAddress, - sharedSecret: expect.any(Buffer), incomingPayment: { id: mockedIncomingPayment.id, walletAddress: mockedIncomingPayment.walletAddress, diff --git a/packages/backend/src/open_payments/receiver/service.ts b/packages/backend/src/open_payments/receiver/service.ts index a269c41597..d9858fdbbd 100644 --- a/packages/backend/src/open_payments/receiver/service.ts +++ b/packages/backend/src/open_payments/receiver/service.ts @@ -1,5 +1,4 @@ import { IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethods } from '@interledger/open-payments' -import { StreamCredentialsService } from '../../payment-method/ilp/stream-credentials/service' import { WalletAddressService } from '../wallet_address/service' import { BaseService } from '../../shared/baseService' import { IncomingPaymentService } from '../payment/incoming/service' @@ -16,6 +15,7 @@ import { import { isRemoteIncomingPaymentError } from '../payment/incoming_remote/errors' import { TelemetryService } from '../../telemetry/service' import { IAppConfig } from '../../config/app' +import { PaymentMethodProviderService } from '../../payment-method/provider/service' interface CreateReceiverArgs { walletAddressUrl: string @@ -33,11 +33,11 @@ export interface ReceiverService { export interface ServiceDependencies extends BaseService { config: IAppConfig - streamCredentialsService: StreamCredentialsService incomingPaymentService: IncomingPaymentService walletAddressService: WalletAddressService remoteIncomingPaymentService: RemoteIncomingPaymentService telemetry: TelemetryService + paymentMethodProviderService: PaymentMethodProviderService } const INCOMING_PAYMENT_URL_REGEX = @@ -126,14 +126,18 @@ async function createLocalIncomingPayment( return incomingPaymentOrError } - const streamCredentials = deps.streamCredentialsService.get( - incomingPaymentOrError - ) + const paymentMethods = + await deps.paymentMethodProviderService.getPaymentMethods( + incomingPaymentOrError + ) - if (!streamCredentials) { + if (paymentMethods.length === 0) { const errorMessage = - 'Could not get stream credentials for local incoming payment' - deps.logger.error({ err: incomingPaymentOrError }, errorMessage) + 'Could not get any payment methods during local incoming payment creation' + deps.logger.error( + { incomingPaymentId: incomingPaymentOrError.id }, + errorMessage + ) throw new Error(errorMessage) } @@ -141,7 +145,7 @@ async function createLocalIncomingPayment( return incomingPaymentOrError.toOpenPaymentsTypeWithMethods( deps.config.openPaymentsUrl, walletAddress, - streamCredentials + paymentMethods ) } @@ -213,12 +217,13 @@ export async function getLocalIncomingPayment( throw new Error(errorMessage) } - const streamCredentials = deps.streamCredentialsService.get(incomingPayment) + const paymentMethods = + await deps.paymentMethodProviderService.getPaymentMethods(incomingPayment) return incomingPayment.toOpenPaymentsTypeWithMethods( deps.config.openPaymentsUrl, incomingPayment.walletAddress, - streamCredentials + paymentMethods ) } diff --git a/packages/backend/src/payment-method/ilp/connector/core/controllers/stream.ts b/packages/backend/src/payment-method/ilp/connector/core/controllers/stream.ts index bdc2937fd9..c6fb86b21f 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/controllers/stream.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/controllers/stream.ts @@ -1,5 +1,6 @@ import { isIlpReply } from 'ilp-packet' import { ILPContext, ILPMiddleware } from '../rafiki' +import { StreamState } from '../middleware' const CONNECTION_EXPIRY = 60 * 10 // seconds @@ -9,21 +10,22 @@ export const streamReceivedKey = (connectionId: string): string => export function createStreamController(): ILPMiddleware { return async function ( - ctx: ILPContext, + ctx: ILPContext, next: () => Promise ): Promise { - const { logger, redis, streamServer } = ctx.services + const { logger, redis } = ctx.services const { request, response } = ctx if ( ctx.accounts.outgoing.http || - !streamServer.decodePaymentTag(request.prepare.destination) // XXX mark this earlier in the middleware pipeline + !ctx.state.streamDestination || + !ctx.state.streamServer ) { await next() return } - const moneyOrReply = streamServer.createReply(request.prepare) + const moneyOrReply = ctx.state.streamServer.createReply(request.prepare) if (isIlpReply(moneyOrReply)) { response.reply = moneyOrReply return 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 d1aa2392d8..1d26243141 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 @@ -1,11 +1,10 @@ -import * as crypto from 'crypto' import { Factory } from 'rosie' import { Redis } from 'ioredis' -import { StreamServer } from '@interledger/stream-receiver' import { RafikiServices } from '../rafiki' import { MockAccountingService } from '../test/mocks/accounting-service' import { TestLoggerFactory } from './test-logger' import { MockTelemetryService } from '../../../../../tests/telemetry' +import { Config } from '../../../../../config/app' interface MockRafikiServices extends RafikiServices { accounting: MockAccountingService @@ -19,6 +18,9 @@ export const RafikiServicesFactory = Factory.define( // return new InMemoryRouter(peers, { ilpAddress: 'test.rafiki' }) //}) .option('ilpAddress', 'test.rafiki') + .attr('config', () => { + return Config + }) .attr('accounting', () => { return new MockAccountingService() }) @@ -42,6 +44,26 @@ export const RafikiServicesFactory = Factory.define( } }) ) + .attr('tenantSettingService', () => ({ + get: async () => { + return [] + }, + create: async () => { + throw new Error('unimplemented') + }, + update: async () => { + throw new Error('unimplemented') + }, + delete: async () => { + throw new Error('unimplemented') + }, + getPage: async () => { + throw new Error('unimplemented') + }, + getSettingsByPrefix: async () => { + throw new Error('unimplemented') + } + })) .attr('peers', ['accounting'], (accounting: MockAccountingService) => ({ getByDestinationAddress: async (address: string) => await accounting._getByDestinationAddress(address), @@ -70,12 +92,3 @@ export const RafikiServicesFactory = Factory.define( stringNumbers: true }) ) - .attr( - 'streamServer', - ['ilpAddress'], - (ilpAddress: string) => - new StreamServer({ - serverAddress: ilpAddress, - serverSecret: crypto.randomBytes(32) - }) - ) diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts index 337cda4324..35a2d3551d 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts @@ -2,7 +2,6 @@ import { Errors } from 'ilp-packet' import { AccountAlreadyExistsError } from '../../../../../accounting/errors' import { LiquidityAccountType } from '../../../../../accounting/service' import { IncomingPaymentState } from '../../../../../open_payments/payment/incoming/model' -import { validateId } from '../../../../../shared/utils' import { ILPContext, ILPMiddleware, @@ -10,12 +9,11 @@ import { OutgoingAccount } from '../rafiki' import { AuthState } from './auth' +import { StreamState } from './stream-address' -const UUID_LENGTH = 36 - -export function createAccountMiddleware(serverAddress: string): ILPMiddleware { +export function createAccountMiddleware(): ILPMiddleware { return async function account( - ctx: ILPContext, + ctx: ILPContext, next: () => Promise ): Promise { const createLiquidityAccount = async ( @@ -111,20 +109,6 @@ export function createAccountMiddleware(serverAddress: string): ILPMiddleware { if (peer) { return peer } - if ( - address.startsWith(serverAddress + '.') && - (address.length === serverAddress.length + 1 + UUID_LENGTH || - address[serverAddress.length + 1 + UUID_LENGTH] === '.') - ) { - const accountId = address.slice( - serverAddress.length + 1, - serverAddress.length + 1 + UUID_LENGTH - ) - if (validateId(accountId)) { - // TODO: Look up direct ILP access account - // return await accounts.get(accountId) - } - } } const outgoingAccount = await getAccountByDestinationAddress() diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts index 17b7ba5869..df52b5de1d 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts @@ -1,20 +1,61 @@ +import { StreamServer } from '@interledger/stream-receiver' import { ILPMiddleware, ILPContext } from '../rafiki' +import { TenantSettingKeys } from '../../../../../tenants/settings/model' +import { AuthState } from './auth' + +export interface StreamState { + streamDestination?: string + streamServer?: StreamServer +} export function createStreamAddressMiddleware(): ILPMiddleware { return async ( - { request, services: { streamServer, telemetry }, state }: ILPContext, + ctx: ILPContext, next: () => Promise ): Promise => { - const stopTimer = telemetry.startTimer( + const stopTimer = ctx.services.telemetry.startTimer( 'create_stream_address_middleware_decode_tag', { callName: 'createStreamAddressMiddleware:decodePaymentTag' } ) - const { destination } = request.prepare - // To preserve sender privacy, the accountId wasn't included in the original destination address. - state.streamDestination = streamServer.decodePaymentTag(destination) + + const tenantIlpAddress = await getIlpAddressForTenant(ctx) + + ctx.state.streamServer = tenantIlpAddress + ? new StreamServer({ + serverSecret: ctx.services.config.streamSecret, + serverAddress: tenantIlpAddress + }) + : undefined + + ctx.state.streamDestination = + ctx.state.streamServer?.decodePaymentTag( + ctx.request.prepare.destination + ) || undefined + stopTimer() await next() } } + +export async function getIlpAddressForTenant( + ctx: ILPContext +): Promise { + const tenantId = ctx.state.incomingAccount?.tenantId + + if (!tenantId) { + return undefined + } + + if (tenantId === ctx.services.config.operatorTenantId) { + return ctx.services.config.ilpAddress + } + + const tenantSettings = await ctx.services.tenantSettingService.get({ + tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name + }) + + return tenantSettings.length > 0 ? tenantSettings[0].value : undefined +} diff --git a/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts b/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts index 1a0384ded4..ddbedb29ce 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts @@ -1,6 +1,5 @@ import * as http from 'http' /* eslint-disable @typescript-eslint/no-explicit-any */ -import { StreamServer } from '@interledger/stream-receiver' import { Errors } from 'ilp-packet' import { Redis } from 'ioredis' import Koa, { Middleware } from 'koa' @@ -26,6 +25,8 @@ import { incrementFulfillOrRejectPacketCount, incrementAmount } from './telemetry' +import { IAppConfig } from '../../../../config/app' +import { TenantSettingService } from '../../../../tenants/settings/service' // Model classes that represent an Interledger sender, receiver, or // connector SHOULD implement this ConnectorAccount interface. @@ -71,7 +72,8 @@ export interface RafikiServices { peers: PeerService rates: RatesService redis: Redis - streamServer: StreamServer + tenantSettingService: TenantSettingService + config: IAppConfig } export type HttpContextMixin = { @@ -107,7 +109,6 @@ export type ILPContext = { } export class Rafiki { - private streamServer: StreamServer private redis: Redis private publicServer: Koa = new Koa() @@ -119,10 +120,12 @@ export class Rafiki { this.redis = config.redis const logger = config.logger - this.streamServer = config.streamServer - const { redis, streamServer } = this + const { redis } = this // Set global context that exposes services this.publicServer.context.services = { + get config(): IAppConfig { + return config.config + }, get incomingPayments(): IncomingPaymentService { return config.incomingPayments }, @@ -135,9 +138,6 @@ export class Rafiki { get redis(): Redis { return redis }, - get streamServer(): StreamServer { - return streamServer - }, get accounting(): AccountingService { return config.accounting }, @@ -147,6 +147,9 @@ export class Rafiki { get telemetry(): TelemetryService { return config.telemetry }, + get tenantSettingService(): TenantSettingService { + return config.tenantSettingService + }, logger } diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/controllers/stream-controller.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/controllers/stream-controller.test.ts index e0159727b1..c266bd999a 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/test/controllers/stream-controller.test.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/test/controllers/stream-controller.test.ts @@ -17,6 +17,7 @@ import { RafikiServicesFactory } from '../../factories' import { ZeroCopyIlpPrepare } from '../../middleware/ilp-packet' +import { StreamServer } from '@interledger/stream-receiver' const sha256 = (preimage: string | Buffer): Buffer => crypto.createHash('sha256').update(preimage).digest() @@ -35,12 +36,22 @@ describe('Stream Controller', function () { test('constructs a reply for a receive account', async () => { const bob = IncomingPaymentAccountFactory.build() - const { ilpAddress, sharedSecret } = - services.streamServer.generateCredentials({ - paymentTag: 'foo' - }) + + const streamServer = new StreamServer({ + serverAddress: services.config.ilpAddress, + serverSecret: services.config.streamSecret + }) + + const { ilpAddress, sharedSecret } = streamServer.generateCredentials({ + paymentTag: 'foo' + }) + const ctx = createILPContext({ services, + state: { + streamServer, + streamDestination: streamServer.decodePaymentTag(ilpAddress) + }, accounts: { get incoming() { return alice diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts index 0ea2d1dc8f..e669118cab 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts @@ -15,7 +15,6 @@ import { createILPContext } from '../../utils' import { Peer } from '../../../../peer/model' describe('Account Middleware', () => { - const ADDRESS = 'test.rafiki' const incomingAccount = IncomingPeerFactory.build({ id: 'incomingPeer' }) @@ -31,7 +30,7 @@ describe('Account Middleware', () => { }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { incomingAccount }, @@ -54,7 +53,7 @@ describe('Account Middleware', () => { id: 'outgoingIncomingPayment' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -80,7 +79,7 @@ describe('Account Middleware', () => { id: 'spspFallback' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -112,7 +111,7 @@ describe('Account Middleware', () => { .mockResolvedValueOnce(outgoingAccount as unknown as Peer) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -145,7 +144,7 @@ describe('Account Middleware', () => { state: 'COMPLETED' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -166,7 +165,7 @@ describe('Account Middleware', () => { }) test('return an error when the destination account unknown', async () => { - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -193,7 +192,7 @@ describe('Account Middleware', () => { state: 'COMPLETED' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -225,7 +224,7 @@ describe('Account Middleware', () => { state: 'PENDING' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -268,7 +267,7 @@ describe('Account Middleware', () => { id: 'spspFallback' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/stream-address.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/stream-address.test.ts index 8882a19f90..35426d0baa 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/stream-address.test.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/stream-address.test.ts @@ -1,36 +1,124 @@ import { createILPContext } from '../../utils' -import { ZeroCopyIlpPrepare } from '../..' +import { + AuthState, + ILPContext, + IncomingAccount, + ZeroCopyIlpPrepare +} from '../..' import { IlpPrepareFactory, RafikiServicesFactory } from '../../factories' -import { createStreamAddressMiddleware } from '../../middleware/stream-address' +import { + StreamState, + createStreamAddressMiddleware, + getIlpAddressForTenant +} from '../../middleware/stream-address' +import { StreamServer } from '@interledger/stream-receiver' +import { + TenantSetting, + TenantSettingKeys +} from '../../../../../../tenants/settings/model' describe('Stream Address Middleware', function () { const services = RafikiServicesFactory.build() - const ctx = createILPContext({ services }) + + function makeIlpContext(): ILPContext { + return createILPContext({ services }) + } + const middleware = createStreamAddressMiddleware() - test('skips non-stream packets', async () => { - const prepare = IlpPrepareFactory.build() - ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) - const next = jest.fn() + describe('createStreamAddressMiddleware', function () { + test('skips non-stream packets', async () => { + const prepare = IlpPrepareFactory.build() + const ctx = makeIlpContext() + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect(next).toHaveBeenCalledTimes(1) + expect(ctx.state.streamDestination).toBeUndefined() + expect(ctx.state.streamServer).toBeUndefined() + }) + + test('sets "state.streamDestination" of stream packets', async () => { + const ctx = makeIlpContext() + const streamServer = new StreamServer({ + serverAddress: ctx.services.config.ilpAddress, + serverSecret: ctx.services.config.streamSecret + }) + + const prepare = IlpPrepareFactory.build({ + destination: streamServer.generateCredentials({ + paymentTag: 'bob' + }).ilpAddress + }) + + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + ctx.state.incomingAccount = { + tenantId: ctx.services.config.operatorTenantId + } as IncomingAccount + const next = jest.fn() - await expect(middleware(ctx, next)).resolves.toBeUndefined() + await expect(middleware(ctx, next)).resolves.toBeUndefined() - expect(next).toHaveBeenCalledTimes(1) - expect(ctx.state.streamDestination).toBeUndefined() + expect(next).toHaveBeenCalledTimes(1) + expect(ctx.state.streamDestination).toBe('bob') + expect(ctx.state.streamServer).toBeDefined() + }) }) - test('sets "state.streamDestination" of stream packets', async () => { - const prepare = IlpPrepareFactory.build({ - destination: services.streamServer.generateCredentials({ - paymentTag: 'bob' - }).ilpAddress + describe('getIlpAddressForTenant', function () { + test('returns undefined if no state.incomingAccount set', async () => { + const ctx = makeIlpContext() + + await expect(getIlpAddressForTenant(ctx)).resolves.toBeUndefined() }) - ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) - const next = jest.fn() + test('returns operator tenant ILP address if equals incomingAccount tenantId', async () => { + const ctx = makeIlpContext() + ctx.state.incomingAccount = { + tenantId: ctx.services.config.operatorTenantId + } as IncomingAccount - await expect(middleware(ctx, next)).resolves.toBeUndefined() + await expect(getIlpAddressForTenant(ctx)).resolves.toBe( + ctx.services.config.ilpAddress + ) + }) + + test('returns non-operator tenant ILP address', async () => { + const tenantId = crypto.randomUUID() + const tenantIlpAddress = 'test.rafiki' + const ctx = makeIlpContext() + + ctx.state.incomingAccount = { + tenantId: tenantId + } as IncomingAccount + + jest + .spyOn(ctx.services.tenantSettingService, 'get') + .mockResolvedValueOnce([ + { + tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name, + value: tenantIlpAddress + } + ] as TenantSetting[]) + + await expect(getIlpAddressForTenant(ctx)).resolves.toBe(tenantIlpAddress) + }) + + test('returns undefined if missing ILP address tenant setting', async () => { + const tenantId = crypto.randomUUID() + const ctx = makeIlpContext() + + ctx.state.incomingAccount = { + tenantId: tenantId + } as IncomingAccount - expect(next).toHaveBeenCalledTimes(1) - expect(ctx.state['streamDestination']).toBe('bob') + jest + .spyOn(ctx.services.tenantSettingService, 'get') + .mockResolvedValueOnce([]) + + await expect(getIlpAddressForTenant(ctx)).resolves.toBeUndefined() + }) }) }) diff --git a/packages/backend/src/payment-method/ilp/connector/index.ts b/packages/backend/src/payment-method/ilp/connector/index.ts index 8dd2f27f0d..05a3091439 100644 --- a/packages/backend/src/payment-method/ilp/connector/index.ts +++ b/packages/backend/src/payment-method/ilp/connector/index.ts @@ -1,4 +1,3 @@ -import { StreamServer } from '@interledger/stream-receiver' import { Redis } from 'ioredis' import { AccountingService } from '../../../accounting/service' @@ -28,30 +27,34 @@ import { createStreamController } from './core' import { TelemetryService } from '../../../telemetry/service' +import { TenantSettingService } from '../../../tenants/settings/service' +import { IAppConfig } from '../../../config/app' interface ServiceDependencies extends BaseService { + config: IAppConfig redis: Redis ratesService: RatesService accountingService: AccountingService walletAddressService: WalletAddressService incomingPaymentService: IncomingPaymentService peerService: PeerService - streamServer: StreamServer ilpAddress: string telemetry: TelemetryService + tenantSettingService: TenantSettingService } export async function createConnectorService({ logger, + config, redis, ratesService, accountingService, walletAddressService, incomingPaymentService, peerService, - streamServer, ilpAddress, - telemetry + telemetry, + tenantSettingService }: ServiceDependencies): Promise { return createApp( { @@ -59,20 +62,21 @@ export async function createConnectorService({ logger: logger.child({ service: 'ConnectorService' }), + config, accounting: accountingService, walletAddresses: walletAddressService, incomingPayments: incomingPaymentService, peers: peerService, redis, rates: ratesService, - streamServer, - telemetry + telemetry, + tenantSettingService }, compose([ // Incoming Rules createIncomingErrorHandlerMiddleware(ilpAddress), createStreamAddressMiddleware(), - createAccountMiddleware(ilpAddress), + createAccountMiddleware(), createIncomingMaxPacketAmountMiddleware(), createIncomingRateLimitMiddleware({}), createIncomingThroughputMiddleware(), diff --git a/packages/backend/src/payment-method/ilp/peer/service.test.ts b/packages/backend/src/payment-method/ilp/peer/service.test.ts index 44086b1183..1b8d7b0533 100644 --- a/packages/backend/src/payment-method/ilp/peer/service.test.ts +++ b/packages/backend/src/payment-method/ilp/peer/service.test.ts @@ -431,7 +431,7 @@ describe('Peer Service', (): void => { }) await expect( - peerService.getByDestinationAddress('test.rafiki', tenantId) + peerService.getByDestinationAddress('test.rafiki', tenantId, asset.id) ).resolves.toEqual(peer) await expect( peerService.getByDestinationAddress( @@ -441,6 +441,24 @@ describe('Peer Service', (): void => { ) ).resolves.toEqual(peerWithSecondAsset) }) + + test('returns peer with longest prefix match for ILP address', async (): Promise => { + const peer = await createPeer(deps, { + staticIlpAddress: 'test.rafiki', + assetId: asset.id + }) + + const peerWithLongerPrefixMatch = await createPeer(deps, { + staticIlpAddress: 'test.rafiki.account' + }) + + await expect( + peerService.getByDestinationAddress( + 'test.rafiki.account.12345', + peer.tenantId + ) + ).resolves.toEqual(peerWithLongerPrefixMatch) + }) }) describe('Get Peer by Incoming Token', (): void => { diff --git a/packages/backend/src/payment-method/ilp/peer/service.ts b/packages/backend/src/payment-method/ilp/peer/service.ts index 0aaef8938b..6f16dd1fed 100644 --- a/packages/backend/src/payment-method/ilp/peer/service.ts +++ b/packages/backend/src/payment-method/ilp/peer/service.ts @@ -22,6 +22,7 @@ import { BaseService } from '../../../shared/baseService' import { isValidHttpUrl } from '../../../shared/utils' import { v4 as uuid } from 'uuid' import { TransferError } from '../../../accounting/errors' +import PrefixMap from '../connector/ilp-routing/lib/prefix-map' export interface HttpOptions { incoming?: { @@ -373,7 +374,9 @@ async function getPeerByDestinationAddress( peerQuery.andWhere('tenantId', tenantId) } - const peer = await peerQuery.first() + const peers = await peerQuery + const peer = getByLongestPrefixMatch(peers, destinationAddress) + if (peer) { const asset = await deps.assetService.get(peer.assetId) if (asset) peer.asset = asset @@ -381,6 +384,18 @@ async function getPeerByDestinationAddress( return peer || undefined } +function getByLongestPrefixMatch( + peers: Peer[], + destinationAddress: string +): Peer | undefined { + const map = new PrefixMap() + for (const peer of peers) { + map.insert(peer.staticIlpAddress, peer) + } + + return map.resolve(destinationAddress) +} + async function getPeerByIncomingToken( deps: ServiceDependencies, token: string diff --git a/packages/backend/src/payment-method/ilp/service.test.ts b/packages/backend/src/payment-method/ilp/service.test.ts index f4819f13c9..eae5d5e28b 100644 --- a/packages/backend/src/payment-method/ilp/service.test.ts +++ b/packages/backend/src/payment-method/ilp/service.test.ts @@ -1,4 +1,8 @@ -import { IlpPaymentService, retryableIlpErrors } from './service' +import { + IlpPaymentService, + resolveIlpDestination, + retryableIlpErrors +} from './service' import { initIocContainer } from '../../' import { createTestApp, TestContainer } from '../../tests/app' import { IAppConfig, Config } from '../../config/app' @@ -594,6 +598,42 @@ describe('IlpPaymentService', (): void => { }) }) + test('throws if invalid ILP destination', async (): Promise => { + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) + + const incomingAmount = { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + + const options: StartQuoteOptions = { + quoteId: uuid(), + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD'], { + incomingAmount, + tenantId: Config.operatorTenantId, + paymentMethods: [] + }) + } + + expect.assertions(4) + try { + await ilpPaymentService.getQuote(options) + } catch (error) { + expect(error).toBeInstanceOf(PaymentMethodHandlerError) + expect((error as PaymentMethodHandlerError).message).toBe( + 'Invalid ILP payment method on receiver' + ) + expect((error as PaymentMethodHandlerError).description).toBe( + 'No ILP payment method found in receiver' + ) + expect((error as PaymentMethodHandlerError).retryable).toBe(false) + } + + ratesScope.done() + }) + describe('successfully gets ilp quote', (): void => { describe('with incomingAmount', () => { test.each` @@ -920,6 +960,43 @@ describe('IlpPaymentService', (): void => { }) }) + test('throws if invalid ILP destination', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + tenantId, + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + }, + receiverPaymentMethods: [] + }) + + expect.assertions(4) + try { + await ilpPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 50n, + finalReceiveAmount: 0n + }) + } catch (error) { + expect(error).toBeInstanceOf(PaymentMethodHandlerError) + expect((error as PaymentMethodHandlerError).message).toBe( + 'Could not start ILP streaming' + ) + expect((error as PaymentMethodHandlerError).description).toBe( + 'Invalid finalReceiveAmount' + ) + expect((error as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + test('throws retryable ILP error', async (): Promise => { const { receiver, outgoingPayment } = await createOutgoingPaymentWithReceiver(deps, { @@ -1000,4 +1077,68 @@ describe('IlpPaymentService', (): void => { } }) }) + + describe('resolveIlpDestination', (): void => { + test('throws if missing payment methods on receiver', async (): Promise => { + const incomingAmount = { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + + const receiver = await createReceiver(deps, walletAddressMap['USD'], { + incomingAmount, + tenantId: Config.operatorTenantId, + paymentMethods: [] + }) + + expect.assertions(4) + try { + resolveIlpDestination(receiver) + } catch (error) { + expect(error).toBeInstanceOf(PaymentMethodHandlerError) + expect((error as PaymentMethodHandlerError).message).toBe( + 'Invalid ILP payment method on receiver' + ) + expect((error as PaymentMethodHandlerError).description).toBe( + 'No ILP payment method found in receiver' + ) + expect((error as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + + test('throws if invalid ILP address for receiver', async (): Promise => { + const incomingAmount = { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + + const receiver = await createReceiver(deps, walletAddressMap['USD'], { + incomingAmount, + tenantId: Config.operatorTenantId, + paymentMethods: [ + { + type: 'ilp', + ilpAddress: '', + sharedSecret: '' + } + ] + }) + + expect.assertions(4) + try { + resolveIlpDestination(receiver) + } catch (error) { + expect(error).toBeInstanceOf(PaymentMethodHandlerError) + expect((error as PaymentMethodHandlerError).message).toBe( + 'Invalid ILP payment method on receiver' + ) + expect((error as PaymentMethodHandlerError).description).toBe( + 'Invalid ILP address for ILP payment method' + ) + expect((error as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + }) }) diff --git a/packages/backend/src/payment-method/ilp/service.ts b/packages/backend/src/payment-method/ilp/service.ts index 72afb57cc5..68fdfcd891 100644 --- a/packages/backend/src/payment-method/ilp/service.ts +++ b/packages/backend/src/payment-method/ilp/service.ts @@ -17,6 +17,9 @@ import { import { TelemetryService } from '../../telemetry/service' import { IlpQuoteDetails } from './quote-details/model' import { Transaction } from 'objection' +import { Receiver } from '../../open_payments/receiver/model' +import { IlpAddress, isValidIlpAddress } from 'ilp-packet' +import base64url from 'base64url' const MAX_INT64 = BigInt('9223372036854775807') @@ -89,7 +92,7 @@ async function getQuote( unfulfillable: true }) stopTimerPlugin() - const destination = options.receiver.toResolvedPayment() + const destination = resolveIlpDestination(options.receiver) try { const stopTimerConnect = deps.telemetry.startTimer( @@ -340,7 +343,7 @@ async function pay( sourceAccount: outgoingPayment }) - const destination = receiver.toResolvedPayment() + const destination = resolveIlpDestination(receiver) try { const receipt = await Pay.pay({ plugin, destination, quote }) @@ -395,6 +398,39 @@ async function pay( } } +export function resolveIlpDestination(receiver: Receiver): Pay.ResolvedPayment { + const ilpPaymentMethod = receiver.paymentMethods.find( + (method) => method.type === 'ilp' + ) + + if (!ilpPaymentMethod) { + throw new PaymentMethodHandlerError( + 'Invalid ILP payment method on receiver', + { + description: 'No ILP payment method found in receiver', + retryable: false + } + ) + } + + if (!isValidIlpAddress(ilpPaymentMethod.ilpAddress)) { + throw new PaymentMethodHandlerError( + 'Invalid ILP payment method on receiver', + { + description: 'Invalid ILP address for ILP payment method', + retryable: false + } + ) + } + + return { + destinationAddress: ilpPaymentMethod.ilpAddress as IlpAddress, + destinationAsset: receiver.asset, + sharedSecret: base64url.toBuffer(ilpPaymentMethod.sharedSecret), + requestCounter: Pay.Counter.from(0) as Pay.Counter + } +} + export function canRetryError(err: Error | Pay.PaymentError): boolean { return err instanceof Error || !!retryableIlpErrors[err] } diff --git a/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts b/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts index 4b72243c1c..ef716de5c8 100644 --- a/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts +++ b/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts @@ -1,39 +1,21 @@ -import { Knex } from 'knex' import { createTestApp, TestContainer } from '../../../tests/app' import { StreamCredentialsService } from './service' -import { IncomingPayment } from '../../../open_payments/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 { createWalletAddress } from '../../../tests/walletAddress' import { truncateTables } from '../../../tests/tableManager' import assert from 'assert' -import { IncomingPaymentState } from '../../../graphql/generated/graphql' describe('Stream Credentials Service', (): void => { let deps: IocContract let appContainer: TestContainer let streamCredentialsService: StreamCredentialsService - let knex: Knex - let incomingPayment: IncomingPayment beforeAll(async (): Promise => { deps = await initIocContainer(Config) appContainer = await createTestApp(deps) streamCredentialsService = await deps.use('streamCredentialsService') - knex = appContainer.knex - }) - - beforeEach(async (): Promise => { - const { id: walletAddressId } = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId - }) - incomingPayment = await createIncomingPayment(deps, { - walletAddressId, - tenantId: Config.operatorTenantId - }) }) afterEach(async (): Promise => { @@ -45,8 +27,15 @@ describe('Stream Credentials Service', (): void => { }) describe('get', (): void => { - test('returns stream credentials for incoming payment', (): void => { - const credentials = streamCredentialsService.get(incomingPayment) + test('generates stream credentials', (): void => { + const credentials = streamCredentialsService.get({ + ilpAddress: 'test.rafiki', + paymentTag: crypto.randomUUID(), + asset: { + code: 'USD', + scale: 2 + } + }) assert.ok(credentials) expect(credentials).toMatchObject({ ilpAddress: expect.stringMatching(/^test\.rafiki\.[a-zA-Z0-9_-]{95}$/), @@ -54,25 +43,24 @@ describe('Stream Credentials Service', (): void => { }) }) - test.each` - state - ${IncomingPaymentState.Completed} - ${IncomingPaymentState.Expired} - `( - `returns stream credentials for $state incoming payment`, - async ({ state }): Promise => { - await incomingPayment.$query(knex).patch({ - state, - expiresAt: - state === IncomingPaymentState.Expired ? new Date() : undefined - }) - expect(streamCredentialsService.get(incomingPayment)).toMatchObject({ - ilpAddress: expect.stringMatching( - /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ - ), - sharedSecret: expect.any(Buffer) - }) + test('generates different stream credentials for a different ilpAddress', (): void => { + const args = { + paymentTag: crypto.randomUUID(), + asset: { + code: 'USD', + scale: 2 + } } - ) + + expect( + streamCredentialsService.get({ ...args, ilpAddress: 'test.rafiki' }) + ?.ilpAddress + ).not.toEqual( + streamCredentialsService.get({ + ...args, + ilpAddress: 'test.rafiki.sub-account' + })?.ilpAddress + ) + }) }) }) diff --git a/packages/backend/src/payment-method/ilp/stream-credentials/service.ts b/packages/backend/src/payment-method/ilp/stream-credentials/service.ts index 25ba8261cd..04903136d8 100644 --- a/packages/backend/src/payment-method/ilp/stream-credentials/service.ts +++ b/packages/backend/src/payment-method/ilp/stream-credentials/service.ts @@ -1,17 +1,25 @@ import { StreamServer } from '@interledger/stream-receiver' import { BaseService } from '../../../shared/baseService' -import { IncomingPayment } from '../../../open_payments/payment/incoming/model' import { StreamCredentials as IlpStreamCredentials } from '@interledger/stream-receiver' +import { IAppConfig } from '../../../config/app' export { IlpStreamCredentials } +interface GetStreamCredentialsArgs { + ilpAddress: string + paymentTag: string + asset: { + scale: number + code: string + } +} + export interface StreamCredentialsService { - get(payment: IncomingPayment): IlpStreamCredentials | undefined + get(args: GetStreamCredentialsArgs): IlpStreamCredentials | undefined } export interface ServiceDependencies extends BaseService { - openPaymentsUrl: string - streamServer: StreamServer + config: IAppConfig } export async function createStreamCredentialsService( @@ -25,20 +33,22 @@ export async function createStreamCredentialsService( logger: log } return { - get: (payment) => getStreamCredentials(deps, payment) + get: (args) => getStreamCredentials(deps, args) } } function getStreamCredentials( deps: ServiceDependencies, - payment: IncomingPayment + args: GetStreamCredentialsArgs ): IlpStreamCredentials | undefined { - const credentials = deps.streamServer.generateCredentials({ - paymentTag: payment.id, - asset: { - code: payment.asset.code, - scale: payment.asset.scale - } + const streamServer = new StreamServer({ + serverSecret: deps.config.streamSecret, + serverAddress: args.ilpAddress + }) + + const credentials = streamServer.generateCredentials({ + paymentTag: args.paymentTag, + asset: args.asset }) return credentials } diff --git a/packages/backend/src/payment-method/provider/service.test.ts b/packages/backend/src/payment-method/provider/service.test.ts new file mode 100644 index 0000000000..76e5f05202 --- /dev/null +++ b/packages/backend/src/payment-method/provider/service.test.ts @@ -0,0 +1,179 @@ +import { PaymentMethodProviderService } 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 { truncateTables } from '../../tests/tableManager' +import { createIncomingPayment } from '../../tests/incomingPayment' +import { StreamCredentialsService } from '../ilp/stream-credentials/service' +import { IlpAddress } from 'ilp-packet' +import base64url from 'base64url' +import { TenantSettingService } from '../../tenants/settings/service' +import { TenantSetting, TenantSettingKeys } from '../../tenants/settings/model' +import { IncomingPayment } from '../../open_payments/payment/incoming/model' + +describe('PaymentMethodProviderService', (): void => { + let deps: IocContract + let appContainer: TestContainer + let paymentMethodProviderService: PaymentMethodProviderService + let streamCredentialsService: StreamCredentialsService + let tenantSettingService: TenantSettingService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + + paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) + streamCredentialsService = await deps.use('streamCredentialsService') + tenantSettingService = await deps.use('tenantSettingService') + }) + + afterEach(async (): Promise => { + jest.restoreAllMocks() + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('getPaymentMethods', (): void => { + const tenantId = Config.operatorTenantId + let incomingPayment: IncomingPayment + + beforeEach(async (): Promise => { + const asset = await createAsset(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId, + assetId: asset.id + }) + incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId + }) + }) + test('returns payment methods with ILP payment method for operator tenant', async (): Promise => { + const tenantSettingsServiceGetSpy = jest.spyOn( + tenantSettingService, + 'get' + ) + + const streamCredentialsServiceGetSpy = jest + .spyOn(streamCredentialsService, 'get') + .mockImplementationOnce(({ ilpAddress }) => ({ + ilpAddress: ilpAddress as IlpAddress, + sharedSecret: Buffer.from('secret') + })) + + await expect( + paymentMethodProviderService.getPaymentMethods(incomingPayment) + ).resolves.toEqual([ + { + type: 'ilp', + ilpAddress: 'test.rafiki', + sharedSecret: base64url(Buffer.from('secret')) + } + ]) + + expect(tenantSettingsServiceGetSpy).not.toHaveBeenCalled() + expect(streamCredentialsServiceGetSpy).toHaveBeenCalledWith({ + paymentTag: incomingPayment.id, + ilpAddress: 'test.rafiki', + asset: { + code: incomingPayment.asset.code, + scale: incomingPayment.asset.scale + } + }) + }) + + test('returns payment methods with ILP payment method for non-operator tenant', async (): Promise => { + const tenantId = crypto.randomUUID() + const tenantSettingsServiceGetSpy = jest + .spyOn(tenantSettingService, 'get') + .mockResolvedValueOnce([ + { + tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name, + value: 'test.rafiki' + } + ] as TenantSetting[]) + + const streamCredentialsServiceGetSpy = jest + .spyOn(streamCredentialsService, 'get') + .mockImplementationOnce(({ ilpAddress }) => ({ + ilpAddress: ilpAddress as IlpAddress, + sharedSecret: Buffer.from('secret') + })) + + await expect( + paymentMethodProviderService.getPaymentMethods({ + ...incomingPayment, + tenantId + } as IncomingPayment) + ).resolves.toEqual([ + { + type: 'ilp', + ilpAddress: 'test.rafiki', + sharedSecret: base64url(Buffer.from('secret')) + } + ]) + + expect(tenantSettingsServiceGetSpy).toHaveBeenCalledWith({ + tenantId: tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name + }) + expect(streamCredentialsServiceGetSpy).toHaveBeenCalledWith({ + paymentTag: incomingPayment.id, + ilpAddress: 'test.rafiki', + asset: { + code: incomingPayment.asset.code, + scale: incomingPayment.asset.scale + } + }) + }) + + test('does not return ILP payment method when missing ILP address in tenant settings for non-operator tenant', async (): Promise => { + const tenantId = crypto.randomUUID() + const tenantSettingsServiceGetSpy = jest + .spyOn(tenantSettingService, 'get') + .mockResolvedValueOnce([]) + + await expect( + paymentMethodProviderService.getPaymentMethods({ + ...incomingPayment, + tenantId + } as IncomingPayment) + ).resolves.toEqual([]) + + expect(tenantSettingsServiceGetSpy).toHaveBeenCalledWith({ + tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name + }) + }) + + test('does not return ILP payment method when failed to generate stream credentials', async (): Promise => { + const streamCredentialsServiceGetSpy = jest + .spyOn(streamCredentialsService, 'get') + .mockImplementationOnce(() => undefined) + + await expect( + paymentMethodProviderService.getPaymentMethods(incomingPayment) + ).resolves.toEqual([]) + + expect(streamCredentialsServiceGetSpy).toHaveBeenCalledWith({ + paymentTag: incomingPayment.id, + ilpAddress: 'test.rafiki', + asset: { + code: incomingPayment.asset.code, + scale: incomingPayment.asset.scale + } + }) + }) + }) +}) diff --git a/packages/backend/src/payment-method/provider/service.ts b/packages/backend/src/payment-method/provider/service.ts new file mode 100644 index 0000000000..59cd3ab7b8 --- /dev/null +++ b/packages/backend/src/payment-method/provider/service.ts @@ -0,0 +1,119 @@ +import { BaseService } from '../../shared/baseService' +import { IncomingPayment } from '../../open_payments/payment/incoming/model' +import { StreamCredentialsService } from '../ilp/stream-credentials/service' +import { TenantSettingService } from '../../tenants/settings/service' +import { TenantSettingKeys } from '../../tenants/settings/model' +import base64url from 'base64url' +import { IAppConfig } from '../../config/app' + +interface BasePaymentMethod { + type: 'ilp' +} + +interface IlpPaymentMethod extends BasePaymentMethod { + type: 'ilp' + ilpAddress: string + sharedSecret: string +} + +export type OpenPaymentsPaymentMethod = IlpPaymentMethod + +export interface PaymentMethodProviderService { + getPaymentMethods( + incomingPayment: IncomingPayment + ): Promise +} + +interface ServiceDependencies extends BaseService { + streamCredentialsService: StreamCredentialsService + tenantSettingsService: TenantSettingService + config: IAppConfig +} + +export async function createPaymentMethodProviderService({ + logger, + knex, + config, + streamCredentialsService, + tenantSettingsService +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'PaymentMethodProvider' + }) + const deps: ServiceDependencies = { + logger: log, + knex, + config, + streamCredentialsService, + tenantSettingsService + } + + return { + getPaymentMethods: (incomingPayment) => + getPaymentMethods(deps, incomingPayment) + } +} + +async function getPaymentMethods( + deps: ServiceDependencies, + incomingPayment: IncomingPayment +): Promise { + const ilpPaymentMethod = await generateIlpPaymentMethod(deps, incomingPayment) + + return ilpPaymentMethod ? [ilpPaymentMethod] : [] +} + +async function generateIlpPaymentMethod( + deps: ServiceDependencies, + incomingPayment: IncomingPayment +): Promise { + let tenantIlpAddress + + if (deps.config.operatorTenantId === incomingPayment.tenantId) { + tenantIlpAddress = deps.config.ilpAddress + } else { + const tenantSettings = await deps.tenantSettingsService.get({ + tenantId: incomingPayment.tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name + }) + + if (tenantSettings.length === 0) { + deps.logger.error( + { + tenantId: incomingPayment.tenantId, + incomingPaymentId: incomingPayment.id + }, + 'Could not get tenant settings for tenant when generating ILP payment method' + ) + return + } + + tenantIlpAddress = tenantSettings[0].value + } + + const ilpStreamCredentials = deps.streamCredentialsService.get({ + paymentTag: incomingPayment.id, + ilpAddress: tenantIlpAddress, + asset: { + code: incomingPayment.asset.code, + scale: incomingPayment.asset.scale + } + }) + + if (!ilpStreamCredentials) { + deps.logger.error( + { + tenantId: incomingPayment.tenantId, + incomingPaymentId: incomingPayment.id + }, + 'Could not get generate ILP STREAM credentials when generating ILP payment method' + ) + return + } + + return { + type: 'ilp', + ilpAddress: ilpStreamCredentials.ilpAddress, + sharedSecret: base64url(ilpStreamCredentials.sharedSecret) + } +} diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts index 2d4681c29d..94e0d8ed97 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -5,8 +5,8 @@ import { TransactionOrKnex } from 'objection' import { Pagination, SortOrder } from '../shared/baseModel' import { CacheDataStore } from '../middleware/cache/data-stores' import type { AuthServiceClient } from '../auth-service-client/client' -import { TenantSettingService } from './settings/service' -import { TenantSetting } from './settings/model' +import { KeyValuePair, TenantSettingService } from './settings/service' +import { TenantSetting, TenantSettingKeys } from './settings/model' import type { IAppConfig } from '../config/app' import { isTenantError, TenantError } from './errors' import { TenantSettingInput } from '../graphql/generated/graphql' @@ -123,6 +123,13 @@ async function createTenant( setting: TenantSetting.default() } + const defaultIlpAddressSetting: KeyValuePair = { + key: TenantSettingKeys.ILP_ADDRESS.name, + value: `${deps.config.ilpAddress}.${tenant.id}` + } + + createInitialTenantSettingsOptions.setting.push(defaultIlpAddressSetting) + if (settings) { createInitialTenantSettingsOptions.setting = createInitialTenantSettingsOptions.setting.concat(settings) diff --git a/packages/backend/src/tenants/settings/model.ts b/packages/backend/src/tenants/settings/model.ts index 7b1b257d2e..b64cd5b6dc 100644 --- a/packages/backend/src/tenants/settings/model.ts +++ b/packages/backend/src/tenants/settings/model.ts @@ -1,6 +1,7 @@ import { Pojo } from 'objection' import { BaseModel } from '../../shared/baseModel' import { KeyValuePair } from './service' +import { isValidIlpAddress } from 'ilp-packet' interface TenantSettingKeyType { name: string @@ -12,7 +13,8 @@ export const TenantSettingKeys: { [key: string]: TenantSettingKeyType } = { WEBHOOK_URL: { name: 'WEBHOOK_URL' }, WEBHOOK_TIMEOUT: { name: 'WEBHOOK_TIMEOUT', default: 2000 }, WEBHOOK_MAX_RETRY: { name: 'WEBHOOK_MAX_RETRY', default: 10 }, - WALLET_ADDRESS_URL: { name: 'WALLET_ADDRESS_URL' } + WALLET_ADDRESS_URL: { name: 'WALLET_ADDRESS_URL' }, + ILP_ADDRESS: { name: 'ILP_ADDRESS' } } export class TenantSetting extends BaseModel { @@ -57,7 +59,8 @@ const TENANT_KEY_MAPPING = { [TenantSettingKeys.WEBHOOK_MAX_RETRY.name]: 'webhookMaxRetry', [TenantSettingKeys.WEBHOOK_TIMEOUT.name]: 'webhookTimeout', [TenantSettingKeys.WEBHOOK_URL.name]: 'webhookUrl', - [TenantSettingKeys.WALLET_ADDRESS_URL.name]: 'walletAddressUrl' + [TenantSettingKeys.WALLET_ADDRESS_URL.name]: 'walletAddressUrl', + [TenantSettingKeys.ILP_ADDRESS.name]: 'ilpAddress' } as const export type FormattedTenantSettings = Record< @@ -84,6 +87,10 @@ const validateUrlTenantSetting = (url: string): boolean => { } } +const validateIlpAddressTenantSetting = (ilpAddress: string): boolean => { + return isValidIlpAddress(ilpAddress) +} + const validateNonNegativeTenantSetting = (numberString: string): boolean => { return !!(Number.isFinite(Number(numberString)) && Number(numberString) > -1) } @@ -97,5 +104,6 @@ export const TENANT_SETTING_VALIDATORS = { [TenantSettingKeys.WEBHOOK_MAX_RETRY.name]: validateNonNegativeTenantSetting, [TenantSettingKeys.WEBHOOK_TIMEOUT.name]: validatePositiveTenantSetting, [TenantSettingKeys.WEBHOOK_URL.name]: validateUrlTenantSetting, - [TenantSettingKeys.WALLET_ADDRESS_URL.name]: validateUrlTenantSetting + [TenantSettingKeys.WALLET_ADDRESS_URL.name]: validateUrlTenantSetting, + [TenantSettingKeys.ILP_ADDRESS.name]: validateIlpAddressTenantSetting } diff --git a/packages/backend/src/tenants/settings/service.test.ts b/packages/backend/src/tenants/settings/service.test.ts index 83c0bb9b92..27f86eb507 100644 --- a/packages/backend/src/tenants/settings/service.test.ts +++ b/packages/backend/src/tenants/settings/service.test.ts @@ -244,6 +244,44 @@ describe('TenantSetting Service', (): void => { tenantSettingService.create(negativeOption) ).resolves.toEqual(TenantSettingError.InvalidSetting) }) + + test('accepts valid ILP address for ILP address tenant setting', async (): Promise => { + const invalidIlpAddressSetting: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.ILP_ADDRESS.name, + value: 'test.net' + } + ] + } + + await expect( + tenantSettingService.create(invalidIlpAddressSetting) + ).resolves.toEqual([ + expect.objectContaining({ + tenantId: tenant.id, + key: TenantSettingKeys.ILP_ADDRESS.name, + value: 'test.net' + }) + ]) + }) + + test('cannot use invalid ILP address for ILP address tenant setting', async (): Promise => { + const invalidIlpAddressSetting: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.ILP_ADDRESS.name, + value: 'test' + } + ] + } + + await expect( + tenantSettingService.create(invalidIlpAddressSetting) + ).resolves.toEqual(TenantSettingError.InvalidSetting) + }) }) describe('get', () => { diff --git a/packages/backend/src/tests/outgoingPayment.ts b/packages/backend/src/tests/outgoingPayment.ts index 2bb3bc3d2f..c0e20eaba1 100644 --- a/packages/backend/src/tests/outgoingPayment.ts +++ b/packages/backend/src/tests/outgoingPayment.ts @@ -14,6 +14,7 @@ import { IncomingPayment } from '../open_payments/payment/incoming/model' import { createIncomingPayment } from './incomingPayment' import assert from 'assert' import { Config } from '../config/app' +import { OpenPaymentsPaymentMethod } from '../payment-method/provider/service' export type CreateTestQuoteAndOutgoingPaymentOptions = Omit< CreateOutgoingPaymentOptions & CreateTestQuoteOptions, @@ -39,10 +40,11 @@ export async function createOutgoingPayment( const outgoingPaymentService = await deps.use('outgoingPaymentService') const config = await deps.use('config') const receiverService = await deps.use('receiverService') + const paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) if (options.validDestination === false) { const walletAddressService = await deps.use('walletAddressService') - const streamServer = await deps.use('streamServer') - const streamCredentials = streamServer.generateCredentials() const walletAddress = await walletAddressService.get( options.walletAddressId ) @@ -61,7 +63,9 @@ export async function createOutgoingPayment( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + await paymentMethodProviderService.getPaymentMethods( + incomingPayment + ) ), false ) @@ -96,6 +100,7 @@ interface CreateOutgoingPaymentWithReceiverArgs { > sendingWalletAddress: WalletAddress fundOutgoingPayment?: boolean + receiverPaymentMethods?: OpenPaymentsPaymentMethod[] } interface CreateOutgoingPaymentWithReceiverResponse { @@ -126,14 +131,16 @@ export async function createOutgoingPaymentWithReceiver( }) const config = await deps.use('config') - const streamCredentialsService = await deps.use('streamCredentialsService') - const streamCredentials = await streamCredentialsService.get(incomingPayment) + const paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) const receiver = new Receiver( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, args.receivingWalletAddress, - streamCredentials + args.receiverPaymentMethods || + (await paymentMethodProviderService.getPaymentMethods(incomingPayment)) ), false ) diff --git a/packages/backend/src/tests/receiver.ts b/packages/backend/src/tests/receiver.ts index ed226303c7..f391cc79e3 100644 --- a/packages/backend/src/tests/receiver.ts +++ b/packages/backend/src/tests/receiver.ts @@ -5,26 +5,37 @@ import { CreateIncomingPaymentOptions } from '../open_payments/payment/incoming/ import { WalletAddress } from '../open_payments/wallet_address/model' import { Receiver } from '../open_payments/receiver/model' import { createIncomingPayment } from './incomingPayment' +import { OpenPaymentsPaymentMethod } from '../payment-method/provider/service' + +type CreateReceiverOptions = Omit< + CreateIncomingPaymentOptions, + 'walletAddressId' +> & { + paymentMethods?: OpenPaymentsPaymentMethod[] +} export async function createReceiver( deps: IocContract, walletAddress: WalletAddress, - options?: Omit + options?: CreateReceiverOptions ): Promise { const config = await deps.use('config') + const paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) + const incomingPayment = await createIncomingPayment(deps, { ...options, walletAddressId: walletAddress.id, tenantId: options?.tenantId ?? config.operatorTenantId }) - const streamCredentialsService = await deps.use('streamCredentialsService') - return new Receiver( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentialsService.get(incomingPayment)! + options?.paymentMethods || + (await paymentMethodProviderService.getPaymentMethods(incomingPayment)) ), false )