diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 001762defb..7cf13e7b81 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -588,8 +588,7 @@ export class App { const router = new Router() router.use(bodyParser()) - - router.get('/', autoPeeringRoutes.get) + router.post('/', autoPeeringRoutes.acceptPeerRequest) koa.use(router.routes()) diff --git a/packages/backend/src/auto-peering/errors.ts b/packages/backend/src/auto-peering/errors.ts new file mode 100644 index 0000000000..cc91bf5c49 --- /dev/null +++ b/packages/backend/src/auto-peering/errors.ts @@ -0,0 +1,38 @@ +export enum AutoPeeringError { + InvalidIlpConfiguration = 'InvalidIlpConfiguration', + InvalidPeerIlpConfiguration = 'InvalidPeerIlpConfiguration', + UnknownAsset = 'UnknownAsset', + PeerUnsupportedAsset = 'PeerUnsupportedAsset', + InvalidPeerUrl = 'InvalidPeerUrl', + InvalidPeeringRequest = 'InvalidPeeringRequest', + DuplicatePeer = 'DuplicatePeer' +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +export const isAutoPeeringError = (o: any): o is AutoPeeringError => + Object.values(AutoPeeringError).includes(o) + +export const errorToCode: { + [key in AutoPeeringError]: number +} = { + [AutoPeeringError.InvalidIlpConfiguration]: 400, + [AutoPeeringError.InvalidPeerIlpConfiguration]: 400, + [AutoPeeringError.UnknownAsset]: 404, + [AutoPeeringError.PeerUnsupportedAsset]: 400, + [AutoPeeringError.InvalidPeerUrl]: 400, + [AutoPeeringError.InvalidPeeringRequest]: 400, + [AutoPeeringError.DuplicatePeer]: 409 +} + +export const errorToMessage: { + [key in AutoPeeringError]: string +} = { + [AutoPeeringError.InvalidIlpConfiguration]: + 'The ILP configuration is misconfigured', + [AutoPeeringError.InvalidPeerIlpConfiguration]: `Requested peer's ILP configuration is misconfigured`, + [AutoPeeringError.UnknownAsset]: 'Unknown asset', + [AutoPeeringError.PeerUnsupportedAsset]: 'Peer does not support asset', + [AutoPeeringError.InvalidPeerUrl]: 'Peer URL is invalid', + [AutoPeeringError.InvalidPeeringRequest]: 'Invalid peering request', + [AutoPeeringError.DuplicatePeer]: 'Duplicate peer' +} diff --git a/packages/backend/src/auto-peering/routes.test.ts b/packages/backend/src/auto-peering/routes.test.ts index f674780a06..26279061f2 100644 --- a/packages/backend/src/auto-peering/routes.test.ts +++ b/packages/backend/src/auto-peering/routes.test.ts @@ -1,22 +1,25 @@ import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '..' -import { AppContext, AppServices } from '../app' -import { Config } from '../config/app' +import { AppServices } from '../app' +import { Config, IAppConfig } from '../config/app' import { createTestApp, TestContainer } from '../tests/app' import { createAsset } from '../tests/asset' import { createContext } from '../tests/context' import { truncateTables } from '../tests/tableManager' -import { AutoPeeringRoutes } from './routes' +import { AutoPeeringError, errorToCode, errorToMessage } from './errors' +import { AutoPeeringRoutes, PeerRequestContext } from './routes' describe('Auto Peering Routes', (): void => { let deps: IocContract let appContainer: TestContainer let autoPeeringRoutes: AutoPeeringRoutes + let config: IAppConfig beforeAll(async (): Promise => { deps = initIocContainer({ ...Config, enableAutoPeering: true }) appContainer = await createTestApp(deps) autoPeeringRoutes = await deps.use('autoPeeringRoutes') + config = await deps.use('config') }) afterEach(async (): Promise => { @@ -27,41 +30,56 @@ describe('Auto Peering Routes', (): void => { await appContainer.shutdown() }) - describe('get', (): void => { - test('returns peering details with assets', async (): Promise => { - const assets = await Promise.all([ - createAsset(deps), - createAsset(deps), - createAsset(deps) - ]) + describe('acceptPeerRequest', (): void => { + test('returns peering details', async (): Promise => { + const asset = await createAsset(deps) - const ctx = createContext({ + const ctx = createContext({ headers: { Accept: 'application/json' }, - url: `/` + url: `/`, + body: { + staticIlpAddress: 'test.rafiki-money', + ilpConnectorAddress: 'http://peer.rafiki.money', + asset: { code: asset.code, scale: asset.scale }, + httpToken: 'someHttpToken', + maxPacketAmount: 1000, + name: 'Rafiki Money' + } }) - await expect(autoPeeringRoutes.get(ctx)).resolves.toBeUndefined() + await expect( + autoPeeringRoutes.acceptPeerRequest(ctx) + ).resolves.toBeUndefined() + expect(ctx.status).toBe(200) expect(ctx.body).toEqual({ - ilpAddress: Config.ilpAddress, - assets: expect.arrayContaining( - assets.map((asset) => ({ - code: asset.code, - scale: asset.scale - })) - ) + staticIlpAddress: config.ilpAddress, + ilpConnectorAddress: config.ilpConnectorAddress, + httpToken: expect.any(String), + name: config.instanceName }) }) - test('returns peering details without assets', async (): Promise => { - const ctx = createContext({ + test('properly handles error', async (): Promise => { + const ctx = createContext({ headers: { Accept: 'application/json' }, - url: `/` + url: `/`, + body: { + staticIlpAddress: 'test.rafiki-money', + ilpConnectorAddress: 'http://peer.rafiki.money', + asset: { code: 'ABC', scale: 2 }, + httpToken: 'someHttpToken' + } }) - await expect(autoPeeringRoutes.get(ctx)).resolves.toBeUndefined() + await expect( + autoPeeringRoutes.acceptPeerRequest(ctx) + ).resolves.toBeUndefined() + expect(ctx.status).toBe(errorToCode[AutoPeeringError.UnknownAsset]) expect(ctx.body).toEqual({ - ilpAddress: Config.ilpAddress, - assets: [] + error: { + code: errorToCode[AutoPeeringError.UnknownAsset], + message: errorToMessage[AutoPeeringError.UnknownAsset] + } }) }) }) diff --git a/packages/backend/src/auto-peering/routes.ts b/packages/backend/src/auto-peering/routes.ts index 7c89dcaca2..1d5f6ff076 100644 --- a/packages/backend/src/auto-peering/routes.ts +++ b/packages/backend/src/auto-peering/routes.ts @@ -1,13 +1,29 @@ import { AppContext } from '../app' import { BaseService } from '../shared/baseService' +import { errorToCode, errorToMessage, isAutoPeeringError } from './errors' import { AutoPeeringService } from './service' +interface PeeringRequestArgs { + staticIlpAddress: string + ilpConnectorAddress: string + asset: { code: string; scale: number } + httpToken: string + maxPacketAmount?: number + name?: string +} + +export type PeerRequestContext = Exclude & { + request: { + body: PeeringRequestArgs + } +} + export interface ServiceDependencies extends BaseService { autoPeeringService: AutoPeeringService } export interface AutoPeeringRoutes { - get(ctx: AppContext): Promise + acceptPeerRequest(ctx: PeerRequestContext): Promise } export async function createAutoPeeringRoutes( @@ -21,15 +37,28 @@ export async function createAutoPeeringRoutes( } return { - get: (ctx: AppContext) => getPeeringDetails(deps, ctx) + acceptPeerRequest: (ctx: PeerRequestContext) => acceptPeerRequest(deps, ctx) } } -async function getPeeringDetails( +async function acceptPeerRequest( deps: ServiceDependencies, - ctx: AppContext + ctx: PeerRequestContext ): Promise { - const peeringDetails = await deps.autoPeeringService.getPeeringDetails() + const peeringDetailsOrError = + await deps.autoPeeringService.acceptPeeringRequest(ctx.request.body) - ctx.body = peeringDetails + if (isAutoPeeringError(peeringDetailsOrError)) { + const errorCode = errorToCode[peeringDetailsOrError] + ctx.status = errorCode + ctx.body = { + error: { + code: errorCode, + message: errorToMessage[peeringDetailsOrError] + } + } + } else { + ctx.status = 200 + ctx.body = peeringDetailsOrError + } } diff --git a/packages/backend/src/auto-peering/service.test.ts b/packages/backend/src/auto-peering/service.test.ts index 9414d5f33a..0020642b9c 100644 --- a/packages/backend/src/auto-peering/service.test.ts +++ b/packages/backend/src/auto-peering/service.test.ts @@ -1,21 +1,28 @@ +import assert from 'assert' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '..' import { AppServices } from '../app' -import { Config } from '../config/app' +import { Config, IAppConfig } from '../config/app' import { createTestApp, TestContainer } from '../tests/app' import { createAsset } from '../tests/asset' import { truncateTables } from '../tests/tableManager' +import { AutoPeeringError, isAutoPeeringError } from './errors' import { AutoPeeringService } from './service' +import { PeerService } from '../peer/service' describe('Auto Peering Service', (): void => { let deps: IocContract let appContainer: TestContainer + let config: IAppConfig let autoPeeringService: AutoPeeringService + let peerService: PeerService beforeAll(async (): Promise => { deps = initIocContainer({ ...Config, enableAutoPeering: true }) appContainer = await createTestApp(deps) + config = await deps.use('config') autoPeeringService = await deps.use('autoPeeringService') + peerService = await deps.use('peerService') }) afterEach(async (): Promise => { @@ -26,30 +33,152 @@ describe('Auto Peering Service', (): void => { await appContainer.shutdown() }) - describe('getPeeringDetails', (): void => { - test('returns peering details', async (): Promise => { - const assets = await Promise.all([ - createAsset(deps), - createAsset(deps), - createAsset(deps) - ]) - - expect(autoPeeringService.getPeeringDetails()).resolves.toEqual({ - ilpAddress: Config.ilpAddress, - assets: expect.arrayContaining( - assets.map((asset) => ({ - code: asset.code, - scale: asset.scale - })) - ) + describe('acceptPeeringRequest', () => { + test('creates peer and resolves connection details', async (): Promise => { + const asset = await createAsset(deps) + + const args = { + staticIlpAddress: 'test.rafiki-money', + ilpConnectorAddress: 'http://peer.rafiki.money', + asset: { code: asset.code, scale: asset.scale }, + httpToken: 'someHttpToken', + name: 'Rafiki Money', + maxPacketAmount: 1000 + } + + const peerCreateSpy = jest.spyOn(peerService, 'create') + + await expect( + autoPeeringService.acceptPeeringRequest(args) + ).resolves.toEqual({ + staticIlpAddress: config.ilpAddress, + ilpConnectorAddress: config.ilpConnectorAddress, + httpToken: expect.any(String), + name: config.instanceName + }) + expect(peerCreateSpy).toHaveBeenCalledWith({ + staticIlpAddress: args.staticIlpAddress, + assetId: asset.id, + maxPacketAmount: BigInt(args.maxPacketAmount), + name: args.name, + http: { + incoming: { authTokens: [args.httpToken] }, + outgoing: { + authToken: expect.any(String), + endpoint: args.ilpConnectorAddress + } + } }) }) - test('returns peering details with no assets', async (): Promise => { - expect(autoPeeringService.getPeeringDetails()).resolves.toEqual({ - ilpAddress: Config.ilpAddress, - assets: [] + test('updates connection details if duplicate peer request', async (): Promise => { + const asset = await createAsset(deps) + + const args = { + staticIlpAddress: 'test.rafiki-money', + ilpConnectorAddress: 'http://peer.rafiki.money', + asset: { code: asset.code, scale: asset.scale }, + httpToken: 'someHttpToken', + name: 'Rafiki Money' + } + + const peerUpdateSpy = jest.spyOn(peerService, 'update') + + await expect( + autoPeeringService.acceptPeeringRequest(args) + ).resolves.toEqual({ + staticIlpAddress: config.ilpAddress, + ilpConnectorAddress: config.ilpConnectorAddress, + httpToken: expect.any(String), + name: config.instanceName }) + expect(peerUpdateSpy).toHaveBeenCalledTimes(0) + + await expect( + autoPeeringService.acceptPeeringRequest(args) + ).resolves.toEqual({ + staticIlpAddress: config.ilpAddress, + ilpConnectorAddress: config.ilpConnectorAddress, + httpToken: expect.any(String), + name: config.instanceName + }) + expect(peerUpdateSpy).toHaveBeenCalledWith({ + id: expect.any(String), + name: args.name, + http: { + incoming: { authTokens: [args.httpToken] }, + outgoing: { + authToken: expect.any(String), + endpoint: args.ilpConnectorAddress + } + } + }) + expect(peerUpdateSpy).toHaveBeenCalledTimes(1) + }) + + test('returns error if unknown asset', async (): Promise => { + const args = { + staticIlpAddress: 'test.rafiki-money', + ilpConnectorAddress: 'http://peer.rafiki.money', + asset: { code: 'USD', scale: 2 }, + httpToken: 'someHttpToken' + } + + await expect(autoPeeringService.acceptPeeringRequest(args)).resolves.toBe( + AutoPeeringError.UnknownAsset + ) + }) + + test('returns error if invalid ILP connector address', async (): Promise => { + const asset = await createAsset(deps) + + const args = { + staticIlpAddress: 'test.rafiki-money', + ilpConnectorAddress: 'invalid', + asset: { code: asset.code, scale: asset.scale }, + httpToken: 'someHttpToken' + } + + await expect( + autoPeeringService.acceptPeeringRequest(args) + ).resolves.toEqual(AutoPeeringError.InvalidPeerIlpConfiguration) + }) + + test('returns error if invalid static ILP address', async (): Promise => { + const asset = await createAsset(deps) + + const args = { + staticIlpAddress: 'invalid', + ilpConnectorAddress: 'http://peer.rafiki.money', + asset: { code: asset.code, scale: asset.scale }, + httpToken: 'someHttpToken' + } + + await expect( + autoPeeringService.acceptPeeringRequest(args) + ).resolves.toEqual(AutoPeeringError.InvalidPeerIlpConfiguration) + }) + + test('returns error if other peer creation error', async (): Promise => { + const asset = await createAsset(deps) + + const args = { + staticIlpAddress: 'test.rafiki-money', + ilpConnectorAddress: 'http://peer.rafiki.money', + asset: { code: asset.code, scale: asset.scale }, + httpToken: 'someHttpToken' + } + + assert.ok( + !isAutoPeeringError(await autoPeeringService.acceptPeeringRequest(args)) + ) + + await expect( + autoPeeringService.acceptPeeringRequest({ + ...args, + staticIlpAddress: 'test.other-rafiki' // expecting to fail on DuplicateIncomingToken + }) + ).resolves.toBe(AutoPeeringError.InvalidPeeringRequest) }) }) }) diff --git a/packages/backend/src/auto-peering/service.ts b/packages/backend/src/auto-peering/service.ts index 462db1e672..3517ad18dd 100644 --- a/packages/backend/src/auto-peering/service.ts +++ b/packages/backend/src/auto-peering/service.ts @@ -1,18 +1,47 @@ import { AssetService } from '../asset/service' import { IAppConfig } from '../config/app' +import { isPeerError, PeerError } from '../peer/errors' +import { PeerService } from '../peer/service' import { BaseService } from '../shared/baseService' +import { AutoPeeringError } from './errors' +import { v4 as uuid } from 'uuid' +import { Peer } from '../peer/model' -export interface PeeringDetails { - ilpAddress: string - assets: { code: string; scale: number }[] +interface PeeringDetails { + staticIlpAddress: string + ilpConnectorAddress: string + httpToken: string + name: string +} + +interface PeeringRequestArgs { + staticIlpAddress: string + ilpConnectorAddress: string + asset: { code: string; scale: number } + httpToken: string + maxPacketAmount?: number + name?: string +} + +interface UpdatePeerArgs { + staticIlpAddress: string + ilpConnectorAddress: string + assetId: string + incomingHttpToken: string + outgoingHttpToken: string + maxPacketAmount?: number + name?: string } export interface AutoPeeringService { - getPeeringDetails(): Promise + acceptPeeringRequest( + args: PeeringRequestArgs + ): Promise } export interface ServiceDependencies extends BaseService { assetService: AssetService + peerService: PeerService config: IAppConfig } @@ -27,20 +56,117 @@ export async function createAutoPeeringService( } return { - getPeeringDetails: () => getPeeringDetails(deps) + acceptPeeringRequest: (args: PeeringRequestArgs) => + acceptPeeringRequest(deps, args) } } -async function getPeeringDetails( - deps: ServiceDependencies -): Promise { +async function acceptPeeringRequest( + deps: ServiceDependencies, + args: PeeringRequestArgs +): Promise { const assets = await deps.assetService.getAll() + const asset = assets.find( + ({ code, scale }) => code === args.asset.code && scale === args.asset.scale + ) + + if (!asset) { + return AutoPeeringError.UnknownAsset + } + + const outgoingHttpToken = uuid() + + const createdPeerOrError = await deps.peerService.create({ + maxPacketAmount: args.maxPacketAmount + ? BigInt(args.maxPacketAmount) + : undefined, + name: args.name, + staticIlpAddress: args.staticIlpAddress, + assetId: asset.id, + http: { + incoming: { + authTokens: [args.httpToken] + }, + outgoing: { + endpoint: args.ilpConnectorAddress, + authToken: outgoingHttpToken + } + } + }) + + const isDuplicatePeeringRequest = + isPeerError(createdPeerOrError) && + createdPeerOrError === PeerError.DuplicatePeer + + const peerOrError = isDuplicatePeeringRequest + ? await updatePeer(deps, { + maxPacketAmount: args.maxPacketAmount, + name: args.name, + staticIlpAddress: args.staticIlpAddress, + assetId: asset.id, + outgoingHttpToken, + incomingHttpToken: args.httpToken, + ilpConnectorAddress: args.ilpConnectorAddress + }) + : createdPeerOrError + + return peeringDetailsOrError(deps, peerOrError) +} + +async function updatePeer( + deps: ServiceDependencies, + args: UpdatePeerArgs +): Promise { + const peer = await deps.peerService.getByDestinationAddress( + args.staticIlpAddress, + args.assetId + ) + + if (!peer) { + deps.logger.error({ request: args }, 'could not find peer by ILP address') + return PeerError.UnknownPeer + } + + return deps.peerService.update({ + id: peer.id, + maxPacketAmount: args.maxPacketAmount + ? BigInt(args.maxPacketAmount) + : undefined, + name: args.name, + http: { + incoming: { authTokens: [args.incomingHttpToken] }, + outgoing: { + authToken: args.outgoingHttpToken, + endpoint: args.ilpConnectorAddress + } + } + }) +} + +async function peeringDetailsOrError( + deps: ServiceDependencies, + peerOrError: Peer | PeerError +): Promise { + if (isPeerError(peerOrError)) { + if ( + peerOrError === PeerError.InvalidHTTPEndpoint || + peerOrError === PeerError.InvalidStaticIlpAddress + ) { + return AutoPeeringError.InvalidPeerIlpConfiguration + } else { + deps.logger.error( + { error: peerOrError }, + 'Could not accept peering request' + ) + return AutoPeeringError.InvalidPeeringRequest + } + } + return { - ilpAddress: deps.config.ilpAddress, - assets: assets.map((asset) => ({ - code: asset.code, - scale: asset.scale - })) + ilpConnectorAddress: deps.config.ilpConnectorAddress, + staticIlpAddress: deps.config.ilpAddress, + httpToken: peerOrError.http.outgoing.authToken, + name: deps.config.instanceName } } diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index dce6a162e3..8f461119f2 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -54,6 +54,11 @@ export const Config = { envString('REDIS_TLS_CERT_FILE_PATH', '') ), ilpAddress: envString('ILP_ADDRESS', 'test.rafiki'), + ilpConnectorAddress: envString( + 'ILP_CONNECTOR_ADDRESS', + 'http://127.0.0.1:3002' + ), + instanceName: envString('INSTANCE_NAME', 'Rafiki'), streamSecret: process.env.STREAM_SECRET ? Buffer.from(process.env.STREAM_SECRET, 'base64') : crypto.randomBytes(32), diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 08ad360432..c02a4a2496 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -416,6 +416,7 @@ export function initIocContainer( logger: await deps.use('logger'), knex: await deps.use('knex'), assetService: await deps.use('assetService'), + peerService: await deps.use('peerService'), config: await deps.use('config') }) }) diff --git a/packages/backend/src/peer/service.test.ts b/packages/backend/src/peer/service.test.ts index 36f1e8a7a8..9c7c84ac34 100644 --- a/packages/backend/src/peer/service.test.ts +++ b/packages/backend/src/peer/service.test.ts @@ -350,6 +350,28 @@ describe('Peer Service', (): void => { peerService.getByDestinationAddress('test.rafiki-with-wildcards') ).resolves.toBeUndefined() }) + + test('returns peer by ILP address and asset', async (): Promise => { + const staticIlpAddress = 'test.rafiki' + + const peer = await createPeer(deps, { + staticIlpAddress, + assetId: asset.id + }) + + const secondAsset = await createAsset(deps) + const peerWithSecondAsset = await createPeer(deps, { + staticIlpAddress, + assetId: secondAsset.id + }) + + await expect( + peerService.getByDestinationAddress('test.rafiki') + ).resolves.toEqual(peer) + await expect( + peerService.getByDestinationAddress('test.rafiki', secondAsset.id) + ).resolves.toEqual(peerWithSecondAsset) + }) }) describe('Get Peer by Incoming Token', (): void => { diff --git a/packages/backend/src/peer/service.ts b/packages/backend/src/peer/service.ts index a439f42540..83e70086ff 100644 --- a/packages/backend/src/peer/service.ts +++ b/packages/backend/src/peer/service.ts @@ -48,7 +48,10 @@ export interface PeerService { get(id: string): Promise create(options: CreateOptions): Promise update(options: UpdateOptions): Promise - getByDestinationAddress(address: string): Promise + getByDestinationAddress( + address: string, + assetId?: string + ): Promise getByIncomingToken(token: string): Promise getPage(pagination?: Pagination): Promise delete(id: string): Promise @@ -82,8 +85,8 @@ export async function createPeerService({ get: (id) => getPeer(deps, id), create: (options) => createPeer(deps, options), update: (options) => updatePeer(deps, options), - getByDestinationAddress: (destinationAddress) => - getPeerByDestinationAddress(deps, destinationAddress), + getByDestinationAddress: (destinationAddress, assetId) => + getPeerByDestinationAddress(deps, destinationAddress, assetId), getByIncomingToken: (token) => getPeerByIncomingToken(deps, token), getPage: (pagination?) => getPeersPage(deps, pagination), delete: (id) => deletePeer(deps, id) @@ -238,12 +241,13 @@ async function addIncomingHttpTokens({ async function getPeerByDestinationAddress( deps: ServiceDependencies, - destinationAddress: string + destinationAddress: string, + assetId?: string ): Promise { // This query does the equivalent of the following regex // for `staticIlpAddress`s in the accounts table: // new RegExp('^' + staticIlpAddress + '($|\\.)')).test(destinationAddress) - const peer = await Peer.query(deps.knex) + const peerQuery = Peer.query(deps.knex) .withGraphJoined('asset') .where( raw('?', [destinationAddress]), @@ -268,7 +272,13 @@ async function getPeerByDestinationAddress( '.' ) }) - .first() + + if (assetId) { + peerQuery.andWhere('assetId', assetId) + } + + const peer = await peerQuery.first() + return peer || undefined }