From 416afe8615e633363c25e6eea3aad5055be3bc25 Mon Sep 17 00:00:00 2001 From: Max Kurapov Date: Tue, 17 Oct 2023 12:54:05 +0200 Subject: [PATCH] feat: add ilp-peers --- .../20231016141155_create_ilp_peers_table.js | 54 +++++ packages/backend/src/app.ts | 2 + packages/backend/src/index.ts | 8 + .../src/payment-method/ilp/ilp-peer/errors.ts | 26 +++ .../src/payment-method/ilp/ilp-peer/model.ts | 26 +++ .../ilp/ilp-peer/service.test.ts | 211 ++++++++++++++++++ .../payment-method/ilp/ilp-peer/service.ts | 162 ++++++++++++++ 7 files changed, 489 insertions(+) create mode 100644 packages/backend/migrations/20231016141155_create_ilp_peers_table.js create mode 100644 packages/backend/src/payment-method/ilp/ilp-peer/errors.ts create mode 100644 packages/backend/src/payment-method/ilp/ilp-peer/model.ts create mode 100644 packages/backend/src/payment-method/ilp/ilp-peer/service.test.ts create mode 100644 packages/backend/src/payment-method/ilp/ilp-peer/service.ts diff --git a/packages/backend/migrations/20231016141155_create_ilp_peers_table.js b/packages/backend/migrations/20231016141155_create_ilp_peers_table.js new file mode 100644 index 0000000000..df79df8a1b --- /dev/null +++ b/packages/backend/migrations/20231016141155_create_ilp_peers_table.js @@ -0,0 +1,54 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { v4: uuid } = require('uuid') + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .createTable('ilpPeers', function (table) { + table.uuid('id').notNullable().primary() + + table.uuid('peerId').notNullable().index().unique() + table.foreign('peerId').references('peers.id') + + table.bigInteger('maxPacketAmount').nullable() + + table.string('staticIlpAddress').notNullable().index() + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + }) + .then(() => + knex('peers').select( + 'id', + 'staticIlpAddress', + 'maxPacketAmount', + 'createdAt', + 'updatedAt' + ) + ) + .then((rows) => { + if (rows.length > 0) { + return knex('ilpPeers').insert( + rows.map((r) => ({ + id: uuid(), + peerId: r.id, + staticIlpAddress: r.staticIlpAddress, + maxPacketAmount: r.maxPacketAmount, + createdAt: r.createdAt, + updatedAt: r.updatedAt + })) + ) + } + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('ilpPeers') +} diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 024a4c7a28..a363797085 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -86,6 +86,7 @@ import { Rafiki as ConnectorApp } from './payment-method/ilp/connector/core' import { AxiosInstance } from 'axios' import { PaymentMethodHandlerService } from './payment-method/handler/service' import { IlpPaymentService } from './payment-method/ilp/service' +import { IlpPeerService } from './payment-method/ilp/ilp-peer/service' export interface AppContextData { logger: Logger @@ -229,6 +230,7 @@ export interface AppServices { tigerbeetle: Promise paymentMethodHandlerService: Promise ilpPaymentService: Promise + ilpPeerService: Promise } export type AppContainer = IocContract diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index ce5172d8b8..071d1bd4af 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -49,6 +49,7 @@ import { createAutoPeeringRoutes } from './payment-method/ilp/auto-peering/route import axios from 'axios' import { createIlpPaymentService } from './payment-method/ilp/service' import { createPaymentMethodHandlerService } from './payment-method/handler/service' +import { createIlpPeerService } from './payment-method/ilp/ilp-peer/service' BigInt.prototype.toJSON = function () { return this.toString() @@ -452,6 +453,13 @@ export function initIocContainer( }) }) + container.singleton('ilpPeerService', async (deps) => { + return createIlpPeerService({ + knex: await deps.use('knex'), + logger: await deps.use('logger') + }) + }) + return container } diff --git a/packages/backend/src/payment-method/ilp/ilp-peer/errors.ts b/packages/backend/src/payment-method/ilp/ilp-peer/errors.ts new file mode 100644 index 0000000000..f35b59d3e3 --- /dev/null +++ b/packages/backend/src/payment-method/ilp/ilp-peer/errors.ts @@ -0,0 +1,26 @@ +export enum IlpPeerError { + DuplicateIlpPeer = 'DuplicateIlpPeer', + UnknownPeer = 'UnknownPeer', + InvalidStaticIlpAddress = 'InvalidStaticIlpAddress' +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +export const isIlpPeerError = (o: any): o is IlpPeerError => + Object.values(IlpPeerError).includes(o) + +export const errorToCode: { + [key in IlpPeerError]: number +} = { + [IlpPeerError.DuplicateIlpPeer]: 409, + [IlpPeerError.InvalidStaticIlpAddress]: 400, + [IlpPeerError.UnknownPeer]: 404 +} + +export const errorToMessage: { + [key in IlpPeerError]: string +} = { + [IlpPeerError.DuplicateIlpPeer]: + 'duplicate peer found for same ILP address and asset', + [IlpPeerError.InvalidStaticIlpAddress]: 'invalid ILP address', + [IlpPeerError.UnknownPeer]: 'unknown peer' +} diff --git a/packages/backend/src/payment-method/ilp/ilp-peer/model.ts b/packages/backend/src/payment-method/ilp/ilp-peer/model.ts new file mode 100644 index 0000000000..3f7d5f7fc0 --- /dev/null +++ b/packages/backend/src/payment-method/ilp/ilp-peer/model.ts @@ -0,0 +1,26 @@ +import { Model } from 'objection' +import { BaseModel } from '../../../shared/baseModel' +import { Peer } from '../peer/model' + +export class IlpPeer extends BaseModel { + public static get tableName(): string { + return 'ilpPeers' + } + + static relationMappings = { + peer: { + relation: Model.HasOneRelation, + modelClass: Peer, + join: { + from: 'ilpPeers.peerId', + to: 'peers.id' + } + } + } + + public peerId!: string + public peer!: Peer + + public maxPacketAmount?: bigint + public staticIlpAddress!: string +} diff --git a/packages/backend/src/payment-method/ilp/ilp-peer/service.test.ts b/packages/backend/src/payment-method/ilp/ilp-peer/service.test.ts new file mode 100644 index 0000000000..2faf7506a0 --- /dev/null +++ b/packages/backend/src/payment-method/ilp/ilp-peer/service.test.ts @@ -0,0 +1,211 @@ +import assert from 'assert' +import { v4 as uuid } from 'uuid' + +import { createTestApp, TestContainer } from '../../../tests/app' +import { Config } from '../../../config/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../../..' +import { AppServices } from '../../../app' +import { createPeer } from '../../../tests/peer' +import { truncateTables } from '../../../tests/tableManager' +import { CreateArgs, IlpPeerService, UpdateArgs } from './service' +import { Peer } from '../peer/model' +import { IlpPeerError, isIlpPeerError } from './errors' +import { createAsset } from '../../../tests/asset' + +describe('Ilp Peer Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let ilpPeerService: IlpPeerService + let peer: Peer + + const randomIlpPeer = (override?: Partial): CreateArgs => ({ + peerId: peer.id, + maxPacketAmount: BigInt(100), + staticIlpAddress: 'test.' + uuid(), + ...override + }) + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + ilpPeerService = await deps.use('ilpPeerService') + }) + + beforeEach(async (): Promise => { + peer = await createPeer(deps) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('Create/Get Peer', (): void => { + test('A peer can be created with all settings', async (): Promise => { + const args = randomIlpPeer() + const ilpPeer = await ilpPeerService.create(args) + + assert.ok(!isIlpPeerError(ilpPeer)) + expect(ilpPeer).toMatchObject({ + peer, + maxPacketAmount: args.maxPacketAmount, + staticIlpAddress: args.staticIlpAddress + }) + const retrievedPeer = await ilpPeerService.get(ilpPeer.id) + expect(retrievedPeer).toEqual(ilpPeer) + }) + + test('Cannot create an ILP peer for unknown peer', async (): Promise => { + const args = randomIlpPeer() + await expect( + ilpPeerService.create({ + ...args, + peerId: uuid() + }) + ).resolves.toEqual(IlpPeerError.UnknownPeer) + }) + + test('Cannot create a peer with duplicate ILP address and peer', async (): Promise => { + const args = randomIlpPeer() + const ilpPeer = await ilpPeerService.create(args) + assert.ok(!isIlpPeerError(ilpPeer)) + + await expect(ilpPeerService.create(args)).resolves.toEqual( + IlpPeerError.DuplicateIlpPeer + ) + }) + }) + + describe('Update Peer', (): void => { + test.each` + staticIlpAddress | maxPacketAmount | description + ${undefined} | ${1000n} | ${'with just maxPacketAmount'} + ${`test.${uuid()}`} | ${undefined} | ${'with just staticIlpAddress'} + ${`test.${uuid()}`} | ${1000n} | ${'with maxPacketAmount and staticIlpAddress'} + `( + 'Can update a peer $description', + async ({ staticIlpAddress, maxPacketAmount }): Promise => { + const args = randomIlpPeer() + const originalIlpPeer = await ilpPeerService.create(args) + assert.ok(!isIlpPeerError(originalIlpPeer)) + + const updateArgs: UpdateArgs = { + id: originalIlpPeer.id, + maxPacketAmount, + staticIlpAddress + } + + const expectedPeer = { + peerId: args.peerId, + maxPacketAmount: + updateArgs.maxPacketAmount || originalIlpPeer.maxPacketAmount, + staticIlpAddress: + updateArgs.staticIlpAddress || originalIlpPeer.staticIlpAddress + } + await expect(ilpPeerService.update(updateArgs)).resolves.toMatchObject( + expectedPeer + ) + await expect( + ilpPeerService.get(originalIlpPeer.id) + ).resolves.toMatchObject(expectedPeer) + } + ) + + test('Cannot update nonexistent peer', async (): Promise => { + const updateArgs: UpdateArgs = { + id: uuid(), + maxPacketAmount: BigInt(2) + } + + await expect(ilpPeerService.update(updateArgs)).resolves.toEqual( + IlpPeerError.UnknownPeer + ) + }) + + test('Returns error for invalid static ILP address', async (): Promise => { + const args = randomIlpPeer() + const originalIlpPeer = await ilpPeerService.create(args) + assert.ok(!isIlpPeerError(originalIlpPeer)) + + const updateArgs: UpdateArgs = { + id: originalIlpPeer.id, + staticIlpAddress: 'test.hello!' + } + await expect(ilpPeerService.update(updateArgs)).resolves.toEqual( + IlpPeerError.InvalidStaticIlpAddress + ) + await expect(ilpPeerService.get(originalIlpPeer.id)).resolves.toEqual( + originalIlpPeer + ) + }) + }) + + describe('Get Peer By ILP Address', (): void => { + test('Can retrieve peer by ILP address', async (): Promise => { + const args = randomIlpPeer() + const ilpPeer = await ilpPeerService.create(args) + + assert.ok(!isIlpPeerError(ilpPeer)) + await expect( + ilpPeerService.getByDestinationAddress(ilpPeer.staticIlpAddress) + ).resolves.toEqual(ilpPeer) + + await expect( + ilpPeerService.getByDestinationAddress( + ilpPeer.staticIlpAddress + '.suffix' + ) + ).resolves.toEqual(ilpPeer) + + await expect( + ilpPeerService.getByDestinationAddress( + ilpPeer.staticIlpAddress + 'suffix' + ) + ).resolves.toBeUndefined() + }) + + test('Returns undefined if no account exists with address', async (): Promise => { + await expect( + ilpPeerService.getByDestinationAddress('test.nope') + ).resolves.toBeUndefined() + }) + + test('Properly escapes Postgres pattern "_" wildcards in the static address', async (): Promise => { + const args = randomIlpPeer() + + await ilpPeerService.create({ + ...args, + staticIlpAddress: 'test.rafiki_with_wildcards' + }) + await expect( + ilpPeerService.getByDestinationAddress('test.rafiki-with-wildcards') + ).resolves.toBeUndefined() + }) + + test('returns peer by ILP address and asset', async (): Promise => { + const staticIlpAddress = 'test.rafiki' + const args = randomIlpPeer({ + staticIlpAddress + }) + + const ilpPeer = await ilpPeerService.create(args) + + const secondAsset = await createAsset(deps) + + const ilpPeerWithSecondAsset = await ilpPeerService.create({ + staticIlpAddress, + peerId: (await createPeer(deps, { assetId: secondAsset.id })).id + }) + + await expect( + ilpPeerService.getByDestinationAddress('test.rafiki') + ).resolves.toEqual(ilpPeer) + await expect( + ilpPeerService.getByDestinationAddress('test.rafiki', secondAsset.id) + ).resolves.toEqual(ilpPeerWithSecondAsset) + }) + }) +}) diff --git a/packages/backend/src/payment-method/ilp/ilp-peer/service.ts b/packages/backend/src/payment-method/ilp/ilp-peer/service.ts new file mode 100644 index 0000000000..b8099ba762 --- /dev/null +++ b/packages/backend/src/payment-method/ilp/ilp-peer/service.ts @@ -0,0 +1,162 @@ +import { + ForeignKeyViolationError, + UniqueViolationError, + NotFoundError, + raw, + TransactionOrKnex +} from 'objection' +import { isValidIlpAddress } from 'ilp-packet' + +import { IlpPeerError } from './errors' +import { IlpPeer } from './model' + +import { BaseService } from '../../../shared/baseService' + +export interface CreateArgs { + peerId: string + maxPacketAmount?: bigint + staticIlpAddress: string +} + +export interface UpdateArgs { + id: string + maxPacketAmount?: bigint + staticIlpAddress?: string +} + +export interface IlpPeerService { + get(id: string): Promise + create(options: CreateArgs): Promise + update(options: UpdateArgs): Promise + getByDestinationAddress( + address: string, + assetId?: string + ): Promise +} + +interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex +} + +export async function createIlpPeerService({ + logger, + knex +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'IlpPeerService' + }) + const deps: ServiceDependencies = { + logger: log, + knex + } + return { + get: (id) => getIlpPeer(deps, id), + create: (options) => create(deps, options), + update: (options) => update(deps, options), + getByDestinationAddress: (destinationAddress, assetId) => + getByDestinationAddress(deps, destinationAddress, assetId) + } +} + +async function getIlpPeer( + deps: ServiceDependencies, + id: string +): Promise { + return IlpPeer.query(deps.knex).findById(id).withGraphFetched('peer.asset') +} + +async function create( + deps: ServiceDependencies, + args: CreateArgs, + trx?: TransactionOrKnex +): Promise { + if (!isValidIlpAddress(args.staticIlpAddress)) { + return IlpPeerError.InvalidStaticIlpAddress + } + + try { + return await IlpPeer.query(trx || deps.knex) + .insertAndFetch({ + peerId: args.peerId, + staticIlpAddress: args.staticIlpAddress, + maxPacketAmount: args.maxPacketAmount + }) + .withGraphFetched('peer.asset') + } catch (err) { + if (err instanceof ForeignKeyViolationError) { + if (err.constraint === 'ilppeers_peerid_foreign') { + return IlpPeerError.UnknownPeer + } + } else if (err instanceof UniqueViolationError) { + return IlpPeerError.DuplicateIlpPeer + } + throw err + } +} + +async function update( + deps: ServiceDependencies, + args: UpdateArgs, + trx?: TransactionOrKnex +): Promise { + if (args.staticIlpAddress && !isValidIlpAddress(args.staticIlpAddress)) { + return IlpPeerError.InvalidStaticIlpAddress + } + + if (!deps.knex) { + throw new Error('Knex undefined') + } + + try { + return await IlpPeer.query(trx || deps.knex) + .patchAndFetchById(args.id, args) + .withGraphFetched('peer.asset') + .throwIfNotFound() + } catch (err) { + if (err instanceof NotFoundError) { + return IlpPeerError.UnknownPeer + } + throw err + } +} + +async function getByDestinationAddress( + deps: ServiceDependencies, + 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 ilpPeerQuery = IlpPeer.query(deps.knex) + .withGraphJoined('peer.asset') + .where( + raw('?', [destinationAddress]), + 'like', + // "_" is a Postgres pattern wildcard (matching any one character), and must be escaped. + // See: https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-LIKE + raw("REPLACE(REPLACE(??, '_', '\\\\_'), '%', '\\\\%') || '%'", [ + 'ilpPeers.staticIlpAddress' + ]) + ) + .andWhere((builder) => { + builder + .where( + raw('length(??)', ['ilpPeers.staticIlpAddress']), + destinationAddress.length + ) + .orWhere( + raw('substring(?, length(??)+1, 1)', [ + destinationAddress, + 'ilpPeers.staticIlpAddress' + ]), + '.' + ) + }) + + if (assetId) { + ilpPeerQuery.andWhere('peer.assetId', assetId) + } + + return ilpPeerQuery.first() +}