diff --git a/lib/BoltzMiddleware.ts b/lib/BoltzMiddleware.ts index f773fd0..0cac066 100644 --- a/lib/BoltzMiddleware.ts +++ b/lib/BoltzMiddleware.ts @@ -55,7 +55,7 @@ class BoltzMiddleware { await this.service.init(this.config.pairs); await this.notifications.init(); - this.api.init(); + await this.api.init(); } } diff --git a/lib/api/Api.ts b/lib/api/Api.ts index 4795fe2..fedcdb3 100644 --- a/lib/api/Api.ts +++ b/lib/api/Api.ts @@ -13,6 +13,7 @@ type ApiConfig = { class Api { private app: Application; + private controller: Controller; constructor(private logger: Logger, private config: ApiConfig, service: Service) { this.app = express(); @@ -20,11 +21,13 @@ class Api { this.app.use(cors()); this.app.use(express.json()); - const controller = new Controller(logger, service); - this.registerRoutes(controller); + this.controller = new Controller(logger, service); + this.registerRoutes(this.controller); } - public init = () => { + public init = async () => { + await this.controller.init(); + this.app.listen(this.config.port, this.config.host, () => { this.logger.info(`API server listening on: ${this.config.host}:${this.config.port}`); }); diff --git a/lib/api/Controller.ts b/lib/api/Controller.ts index 127f2d8..89c5a48 100644 --- a/lib/api/Controller.ts +++ b/lib/api/Controller.ts @@ -2,16 +2,17 @@ import { Request, Response } from 'express'; import Logger from '../Logger'; import { stringify } from '../Utils'; import Service from '../service/Service'; +import { SwapUpdateEvent } from '../consts/Enums'; class Controller { // A map between the ids and HTTP responses of all pending swaps private pendingSwaps = new Map(); // A map between the ids and statuses of the swaps - private pendingSwapInfos = new Map(); + private pendingSwapInfos = new Map(); constructor(private logger: Logger, private service: Service) { - this.service.on('swap.update', (id: string, message: object) => { + this.service.on('swap.update', (id, message) => { this.pendingSwapInfos.set(id, message); const response = this.pendingSwaps.get(id); @@ -23,6 +24,32 @@ class Controller { }); } + public init = async () => { + // Get the latest status of all swaps in the database + const [swaps, reverseSwaps] = await Promise.all([ + this.service.swapRepository.getSwaps(), + this.service.reverseSwapRepository.getReverseSwaps(), + ]); + + swaps.forEach((swap) => { + if (swap.status) { + this.pendingSwapInfos.set(swap.id, { event: swap.status }); + } + }); + + reverseSwaps.forEach((reverseSwap) => { + if (reverseSwap.status) { + const event = reverseSwap.status; + + if (event !== SwapUpdateEvent.InvoiceSettled) { + this.pendingSwapInfos.set(reverseSwap.id, { event }); + } else { + this.pendingSwapInfos.set(reverseSwap.id, { event, preimage: reverseSwap.preimage! }); + } + } + }); + } + // GET requests public getPairs = (_req: Request, res: Response) => { this.successResponse(res, this.service.getPairs()); @@ -131,9 +158,9 @@ class Controller { ]); res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no', + 'Cache-Control': 'no-cache', + 'Content-Type': 'text/event-stream', Connection: 'keep-alive', }); diff --git a/lib/boltz/BoltzClient.ts b/lib/boltz/BoltzClient.ts index 74dd9c4..ccaf721 100644 --- a/lib/boltz/BoltzClient.ts +++ b/lib/boltz/BoltzClient.ts @@ -228,7 +228,7 @@ class BoltzClient extends BaseClient { this.transactionSubscription = this.boltz.subscribeTransactions(new boltzrpc.SubscribeTransactionsRequest(), this.meta) .on('data', (response: boltzrpc.SubscribeTransactionsResponse) => { - this.logger.silly(`Found transaction to address ${response.getOutputAddress()} confirmed: ${response.getTransactionHash()}`); + this.logger.debug(`Found transaction to address ${response.getOutputAddress()} confirmed: ${response.getTransactionHash()}`); this.emit('transaction.confirmed', response.getTransactionHash(), response.getOutputAddress()); }) .on('error', async (error) => { @@ -253,19 +253,19 @@ class BoltzClient extends BaseClient { switch (response.getEvent()) { case boltzrpc.InvoiceEvent.PAID: - this.logger.silly(`Invoice paid: ${invoice}`); + this.logger.debug(`Invoice paid: ${invoice}`); this.emit('invoice.paid', invoice); break; case boltzrpc.InvoiceEvent.FAILED_TO_PAY: - this.logger.silly(`Failed to pay invoice: ${invoice}`); + this.logger.debug(`Failed to pay invoice: ${invoice}`); this.emit('invoice.failedToPay', invoice); break; case boltzrpc.InvoiceEvent.SETTLED: - this.logger.silly(`Invoice settled: ${invoice}`); + this.logger.debug(`Invoice settled: ${invoice}`); this.emit('invoice.settled', invoice, response.getPreimage()); break; @@ -290,7 +290,7 @@ class BoltzClient extends BaseClient { this.refundsSubscription = this.boltz.subscribeRefunds(new boltzrpc.SubscribeRefundsRequest(), this.meta) .on('data', (response: boltzrpc.SubscribeRefundsResponse) => { const lockupTransactionHash = response.getLockupTransactionHash(); - this.logger.silly(`Refunded lockup transaction: ${lockupTransactionHash}`); + this.logger.debug(`Refunded lockup transaction: ${lockupTransactionHash}`); this.emit('refund', lockupTransactionHash); }) .on('error', async (error) => { diff --git a/lib/consts/Database.ts b/lib/consts/Database.ts index 58e779d..e0c4fb9 100644 --- a/lib/consts/Database.ts +++ b/lib/consts/Database.ts @@ -25,3 +25,27 @@ export type PairAttributes = PairFactory & { }; export type PairInstance = PairAttributes & Sequelize.Instance; + +type Swap = { + id: string; + pair: string; + status?: string; + invoice: string; +}; + +export type SwapFactory = Swap & { + lockupAddress: string; +}; + +export type SwapAttributes = SwapFactory; + +export type SwapInstance = SwapFactory & Sequelize.Instance; + +export type ReverseSwapFactory = Swap & { + preimage?: string; + transactionId: string; +}; + +export type ReverseSwapAttributes = ReverseSwapFactory; + +export type ReverseSwapInstance = ReverseSwapFactory & Sequelize.Instance; diff --git a/lib/consts/Types.ts b/lib/consts/Types.ts index ef4ed8e..da16794 100644 --- a/lib/consts/Types.ts +++ b/lib/consts/Types.ts @@ -1,4 +1,12 @@ +import { SwapUpdateEvent } from './Enums'; + export type Error = { message: string; code: string; }; + +export type SwapUpdate = { + event: SwapUpdateEvent, + + preimage?: string; +}; diff --git a/lib/db/Database.ts b/lib/db/Database.ts index e65279e..0056133 100644 --- a/lib/db/Database.ts +++ b/lib/db/Database.ts @@ -1,11 +1,13 @@ import fs from 'fs'; import path from 'path'; import Sequelize from 'sequelize'; -import * as db from '../consts/Database'; import Logger from '../Logger'; +import * as db from '../consts/Database'; type Models = { Pair: Sequelize.Model; + Swap: Sequelize.Model; + ReverseSwap: Sequelize.Model; }; class Database { @@ -35,8 +37,11 @@ class Database { throw error; } + await this.models.Pair.sync(), + await Promise.all([ - this.models.Pair.sync(), + this.models.Swap.sync(), + this.models.ReverseSwap.sync(), ]); } diff --git a/lib/db/models/Pair.ts b/lib/db/models/Pair.ts index 8b4504f..a67a9fe 100644 --- a/lib/db/models/Pair.ts +++ b/lib/db/models/Pair.ts @@ -1,6 +1,6 @@ import Sequelize from 'sequelize'; -import * as db from '../../consts/Database'; import { getPairId } from '../../Utils'; +import * as db from '../../consts/Database'; export default (sequelize: Sequelize.Sequelize, dataTypes: Sequelize.DataTypes) => { const attributes: db.SequelizeAttributes = { diff --git a/lib/db/models/ReverseSwap.ts b/lib/db/models/ReverseSwap.ts new file mode 100644 index 0000000..a739e6b --- /dev/null +++ b/lib/db/models/ReverseSwap.ts @@ -0,0 +1,27 @@ +import Sequelize from 'sequelize'; +import * as db from '../../consts/Database'; + +export default (sequelize: Sequelize.Sequelize, dataTypes: Sequelize.DataTypes) => { + const attributes: db.SequelizeAttributes = { + id: { type: dataTypes.STRING, primaryKey: true, allowNull: false }, + pair: { type: dataTypes.STRING, allowNull: false }, + status: { type: dataTypes.STRING, allowNull: true }, + invoice: { type: dataTypes.STRING, allowNull: false }, + preimage: { type: dataTypes.STRING, allowNull: true }, + transactionId: { type: dataTypes.STRING, allowNull: false }, + }; + + const options: Sequelize.DefineOptions = { + tableName: 'reverseSwaps', + }; + + const ReverseSwap = sequelize.define('ReverseSwap', attributes, options); + + ReverseSwap.associate = (models: Sequelize.Models) => { + models.ReverseSwap.belongsTo(models.Pair, { + foreignKey: 'pair', + }); + }; + + return ReverseSwap; +}; diff --git a/lib/db/models/Swap.ts b/lib/db/models/Swap.ts new file mode 100644 index 0000000..b29a585 --- /dev/null +++ b/lib/db/models/Swap.ts @@ -0,0 +1,32 @@ +import Sequelize from 'sequelize'; +import * as db from '../../consts/Database'; + +export default (sequelize: Sequelize.Sequelize, dataTypes: Sequelize.DataTypes) => { + const attributes: db.SequelizeAttributes = { + id: { type: dataTypes.STRING, primaryKey: true, allowNull: false }, + pair: { type: dataTypes.STRING, allowNull: false }, + status: { type: dataTypes.STRING, allowNull: true }, + invoice: { type: dataTypes.STRING, unique: true, allowNull: false }, + lockupAddress: { type: dataTypes.STRING, allowNull: false }, + }; + + const options: Sequelize.DefineOptions = { + tableName: 'swaps', + indexes: [ + { + unique: true, + fields: ['id', 'invoice'], + }, + ], + }; + + const Swap = sequelize.define('Swap', attributes, options); + + Swap.associate = (models: Sequelize.Models) => { + models.Swap.belongsTo(models.Pair, { + foreignKey: 'pair', + }); + }; + + return Swap; +}; diff --git a/lib/service/Errors.ts b/lib/service/Errors.ts index dc4df32..be816b1 100644 --- a/lib/service/Errors.ts +++ b/lib/service/Errors.ts @@ -23,4 +23,8 @@ export default { message: `${amount} is less than minimal ${minimalAmount}`, code: concatErrorCode(ErrorCodePrefix.Service, 4), }), + SWAP_WITH_INVOICE_EXISTS_ALREADY: (invoice: string): Error => ({ + message: `a swap with the invoice ${invoice} exists already`, + code: concatErrorCode(ErrorCodePrefix.Service, 5), + }), }; diff --git a/lib/service/ReverseSwapRepository.ts b/lib/service/ReverseSwapRepository.ts new file mode 100644 index 0000000..cc029b3 --- /dev/null +++ b/lib/service/ReverseSwapRepository.ts @@ -0,0 +1,33 @@ +import { WhereOptions } from 'sequelize'; +import { Models } from '../db/Database'; +import * as db from '../consts/Database'; + +class ReverseSwapRepository { + constructor(private models: Models) {} + + public getReverseSwaps = async () => { + return this.models.ReverseSwap.findAll({}); + } + + public getReverseSwap = async (options: WhereOptions) => { + return this.models.ReverseSwap.findOne({ + where: options, + }); + } + + public addReverseSwap = async (reverseSwap: db.ReverseSwapFactory) => { + return this.models.ReverseSwap.create(reverseSwap); + } + + public setReverseSwapStatus = async (reverseSwap: db.ReverseSwapInstance, status: string) => { + return reverseSwap.update({ + status, + }); + } + + public updateReverseSwap = async (reverseSwap: db.ReverseSwapInstance, keys: object) => { + return reverseSwap.update(keys); + } +} + +export default ReverseSwapRepository; diff --git a/lib/service/Service.ts b/lib/service/Service.ts index 65e3b60..d2a5a18 100644 --- a/lib/service/Service.ts +++ b/lib/service/Service.ts @@ -3,15 +3,18 @@ import { EventEmitter } from 'events'; import Errors from './Errors'; import Logger from '../Logger'; import Database from '../db/Database'; +import { SwapUpdate } from '../consts/Types'; +import SwapRepository from './SwapRepository'; import PairRepository from './PairRepository'; import BoltzClient from '../boltz/BoltzClient'; import RateProvider from '../rates/RateProvider'; import { SwapUpdateEvent } from '../consts/Enums'; import { encodeBip21 } from './PaymentRequestUtils'; -import { PairInstance, PairFactory } from '../consts/Database'; +import ReverseSwapRepository from './ReverseSwapRepository'; import { CurrencyConfig } from '../notifications/NotificationProvider'; import { OrderSide, OutputType, CurrencyInfo } from '../proto/boltzrpc_pb'; import { splitPairId, stringify, generateId, mapToObject, satoshisToWholeCoins } from '../Utils'; +import { PairInstance, PairFactory, SwapInstance, ReverseSwapInstance } from '../consts/Database'; type PairConfig = { base: string; @@ -27,39 +30,19 @@ type Pair = { quote: string; }; -type PendingSwap = { - invoice: string; - lockupAddress: string; -}; - -type PendingReverseSwap = { - invoice: string; - transactionHash: string; -}; - -type SwapUpdate = { - event: SwapUpdateEvent, - - invoice?: string; - preimage?: string; - - transactionId?: string; -}; - interface Service { - on(event: 'swap.update', listener: (id: string, update: SwapUpdate) => void): this; - emit(event: 'swap.update', id: string, update: SwapUpdate): boolean; + on(event: 'swap.update', listener: (id: string, message: SwapUpdate) => void): this; + emit(event: 'swap.update', id: string, message: SwapUpdate): boolean; } +// TODO: do not override invoice settled status with transaction confirmed and invoice paid with with transaction confirmed class Service extends EventEmitter { - // A map between the ids and details of all pending swaps - private pendingSwaps = new Map(); + public swapRepository: SwapRepository; + public reverseSwapRepository: ReverseSwapRepository; - // A map between the ids and details of all pending reverse swaps - private pendingReverseSwaps = new Map(); + private pairRepository: PairRepository; private rateProvider: RateProvider; - private pairRepository: PairRepository; private pairs = new Map(); @@ -72,8 +55,11 @@ class Service extends EventEmitter { super(); - this.rateProvider = new RateProvider(this.logger, rateInterval, currencies); this.pairRepository = new PairRepository(db.models); + this.swapRepository = new SwapRepository(db.models); + this.reverseSwapRepository = new ReverseSwapRepository(db.models); + + this.rateProvider = new RateProvider(this.logger, rateInterval, currencies); } public init = async (pairs: PairConfig[]) => { @@ -242,10 +228,16 @@ class Service extends EventEmitter { const id = generateId(6); - this.pendingSwaps.set(id, { - invoice, - lockupAddress: address, - }); + try { + await this.swapRepository.addSwap({ + id, + invoice, + pair: pairId, + lockupAddress: address, + }); + } catch (error) { + throw Errors.SWAP_WITH_INVOICE_EXISTS_ALREADY(invoice); + } return { id, @@ -286,9 +278,11 @@ class Service extends EventEmitter { const id = generateId(6); - this.pendingReverseSwaps.set(id, { + await this.reverseSwapRepository.addReverseSwap({ + id, invoice, - transactionHash: lockupTransactionHash, + pair: pairId, + transactionId: lockupTransactionHash, }); return { @@ -356,88 +350,89 @@ class Service extends EventEmitter { } private listenTransactions = () => { - const emitTransactionConfirmed = (id: string, transactionHash: string) => - this.emit('swap.update', id, { - event: SwapUpdateEvent.TransactionConfirmed, - transactionId: transactionHash, + this.boltz.on('transaction.confirmed', async (transactionId: string, outputAddress: string) => { + const swap = await this.swapRepository.getSwap({ + lockupAddress: outputAddress, }); - // Listen to events of the Boltz client - this.boltz.on('transaction.confirmed', (transactionHash: string, outputAddress: string) => { - for (const [id, swap] of this.pendingSwaps) { - if (swap.lockupAddress === outputAddress) { - emitTransactionConfirmed(id, transactionHash); - - break; + if (swap) { + if (!swap.status) { + await this.updateSwapStatus(swap, SwapUpdateEvent.TransactionConfirmed, this.swapRepository.setSwapStatus); } } - for (const [id, swap] of this.pendingReverseSwaps) { - if (swap.transactionHash === transactionHash) { - emitTransactionConfirmed(id, transactionHash); + const reverseSwap = await this.reverseSwapRepository.getReverseSwap({ + transactionId, + }); - break; + if (reverseSwap) { + if (!reverseSwap.status) { + await this.updateSwapStatus( + reverseSwap, + SwapUpdateEvent.TransactionConfirmed, + this.reverseSwapRepository.setReverseSwapStatus, + ); } } }); } private listenInvoices = () => { - this.boltz.on('invoice.paid', (invoice: string) => { - for (const [id, swap] of this.pendingSwaps) { - if (swap.invoice === invoice) { - this.emit('swap.update', id, { - invoice, - event: SwapUpdateEvent.InvoicePaid, - }); - - break; - } - } + this.boltz.on('invoice.paid', async (invoice: string) => { + const swap = await this.swapRepository.getSwap({ + invoice, + }); + + await this.updateSwapStatus(swap, SwapUpdateEvent.InvoicePaid, this.swapRepository.setSwapStatus); }); - this.boltz.on('invoice.failedToPay', (invoice: string) => { - for (const [id, swap] of this.pendingSwaps) { - if (swap.invoice === invoice) { - this.emit('swap.update', id, { - invoice, - event: SwapUpdateEvent.InvoiceFailedToPay, - }); + this.boltz.on('invoice.failedToPay', async (invoice: string) => { + const swap = await this.swapRepository.getSwap({ + invoice, + }); - break; - } - } + await this.updateSwapStatus(swap, SwapUpdateEvent.InvoiceFailedToPay, this.swapRepository.setSwapStatus); }); - this.boltz.on('invoice.settled', (invoice: string, preimage: string) => { - for (const [id, reverseSwap] of this.pendingReverseSwaps) { - if (reverseSwap.invoice === invoice) { - this.emit('swap.update', id, { - invoice, + this.boltz.on('invoice.settled', async (invoice: string, preimage: string) => { + const reverseSwap = await this.reverseSwapRepository.getReverseSwap({ + invoice, + }); + + if (reverseSwap) { + await this.reverseSwapRepository.updateReverseSwap( + reverseSwap, + { preimage, - event: SwapUpdateEvent.InvoiceSettled, - }); + status: SwapUpdateEvent.InvoiceSettled, + }, + ); - break; - } + this.emit('swap.update', reverseSwap.id, { preimage, event: SwapUpdateEvent.InvoiceSettled }); } }); } private listenRefunds = () => { - this.boltz.on('refund', (lockupTransactionHash: string) => { - for (const [id, swap] of this.pendingReverseSwaps) { - if (swap.transactionHash === lockupTransactionHash) { - this.emit('swap.update', id, { - event: SwapUpdateEvent.TransactionRefunded, - transactionId: lockupTransactionHash, - }); - - break; - } - } + this.boltz.on('refund', async (transactionId: string) => { + const reverseSwap = await this.reverseSwapRepository.getReverseSwap({ + transactionId, + }); + + await this.updateSwapStatus( + reverseSwap, + SwapUpdateEvent.TransactionRefunded, + this.reverseSwapRepository.setReverseSwapStatus, + ); }); } + + private updateSwapStatus = async (instance: T | null, event: SwapUpdateEvent, databaseUpdate: (instance: T, status: string) => Promise) => { + if (instance) { + await databaseUpdate(instance, event); + this.emit('swap.update', instance['id'], { event }); + } + } } export default Service; diff --git a/lib/service/SwapRepository.ts b/lib/service/SwapRepository.ts new file mode 100644 index 0000000..9dc6f76 --- /dev/null +++ b/lib/service/SwapRepository.ts @@ -0,0 +1,29 @@ +import { WhereOptions } from 'sequelize'; +import { Models } from '../db/Database'; +import * as db from '../consts/Database'; + +class SwapRepository { + constructor(private models: Models) {} + + public getSwaps = async () => { + return this.models.Swap.findAll({}); + } + + public getSwap = async (options: WhereOptions) => { + return this.models.Swap.findOne({ + where: options, + }); + } + + public addSwap = async (swap: db.SwapFactory) => { + return this.models.Swap.create(swap); + } + + public setSwapStatus = async (swap: db.SwapInstance, status: string) => { + return swap.update({ + status, + }); + } +} + +export default SwapRepository;