From aa2147d50997c47d917c44aa16f2f7f93fc89960 Mon Sep 17 00:00:00 2001 From: Mondei1 Date: Tue, 29 Dec 2020 00:09:40 +0100 Subject: [PATCH] External providers can now be loaded Implemented invoice protocol (see docs) --- config.ts | 3 +- src/app.ts | 7 ++- src/controllers/invoice.ts | 73 +++++++++++++++++++------ src/helper/backendProvider.ts | 6 -- src/helper/invoiceManager.ts | 11 +--- src/helper/providerManager.ts | 33 ++++++++++- src/helper/providers/bitcoinCore.ts | 40 +++++++------- src/helper/socketio.ts | 18 +++++- src/helper/types.ts | 45 ++++++++++----- src/models/invoice/invoice.interface.ts | 2 +- src/models/invoice/invoice.schema.ts | 2 +- src/routes/invoice.ts | 3 +- 12 files changed, 169 insertions(+), 74 deletions(-) diff --git a/config.ts b/config.ts index 255a56f..4ddbc1c 100644 --- a/config.ts +++ b/config.ts @@ -25,7 +25,8 @@ export const config: IConfig = { methods: [ CryptoUnits.BITCOIN, CryptoUnits.DOGECOIN, - CryptoUnits.ETHEREUM + CryptoUnits.ETHEREUM, + CryptoUnits.MONERO ] } } diff --git a/src/app.ts b/src/app.ts index efea964..ae77538 100644 --- a/src/app.ts +++ b/src/app.ts @@ -27,8 +27,9 @@ export const JWT_SECRET = process.env.JWT_SECRET || ""; export const INVOICE_SECRET = process.env.INVOICE_SECRET || ""; export let rpcClient: rpc.HttpClient | undefined = undefined; -export let invoiceScheduler: InvoiceManager | undefined = undefined; +export let invoiceManager: InvoiceManager | undefined = undefined; export let socketManager: SocketManager | undefined = undefined; +export let providerManager: ProviderManager = undefined; export let logger: winston.Logger; @@ -108,10 +109,10 @@ async function run() { logger.debug("At least one admin user already exists, skip."); } - const providerManager = new ProviderManager(resolve('./src/helper/providers')); + providerManager = new ProviderManager(resolve('./src/helper/providers')); providerManager.scan(); - invoiceScheduler = new InvoiceManager(); + invoiceManager = new InvoiceManager(); const app = express(); const http = new Server(app); diff --git a/src/controllers/invoice.ts b/src/controllers/invoice.ts index df315cd..fe6635f 100644 --- a/src/controllers/invoice.ts +++ b/src/controllers/invoice.ts @@ -2,9 +2,9 @@ import { Request, Response } from 'express'; import got from 'got'; import { config } from '../../config'; -import { invoiceScheduler, INVOICE_SECRET, rpcClient } from '../app'; +import { invoiceManager, INVOICE_SECRET, logger, providerManager, rpcClient } from '../app'; import { randomString } from '../helper/crypto'; -import { CryptoUnits, FiatUnits, findCryptoBySymbol, PaymentStatus } from '../helper/types'; +import { CryptoUnits, decimalPlaces, FiatUnits, findCryptoBySymbol, PaymentStatus, roundNumber } from '../helper/types'; import { ICart, IInvoice, IPaymentMethod } from '../models/invoice/invoice.interface'; import { Invoice } from '../models/invoice/invoice.model'; import { calculateCart } from '../models/invoice/invoice.schema'; @@ -24,18 +24,12 @@ export async function createInvoice(req: Request, res: Response) { } } - const paymentMethodsRaw: string[] = req.body.methods; const successUrl: string = req.body.successUrl; const cancelUrl: string = req.body.cancelUrl; const cart: ICart[] = req.body.cart; let currency: FiatUnits = req.body.currency; let totalPrice: number = req.body.totalPrice; - if (paymentMethodsRaw === undefined) { - res.status(400).send({ message: '"paymentMethods" are not provided!' }); - return; - } - if (successUrl === undefined) { res.status(400).send({ message: '"successUrl" is not provided!' }); return; @@ -67,7 +61,7 @@ export async function createInvoice(req: Request, res: Response) { // Convert coin symbol to full text in order to query Coin Gecko. eg.: ['btc', 'xmr'] => ['bitcoin', 'monero'] let cgFormat = []; - paymentMethodsRaw.forEach(coin => { + config.payment.methods.forEach(coin => { const crypto = findCryptoBySymbol(coin); if (crypto !== undefined) { @@ -78,15 +72,22 @@ export async function createInvoice(req: Request, res: Response) { const request = await got.get(`https://api.coingecko.com/api/v3/simple/price?ids=${cgFormat.join(',')}&vs_currencies=${currency.toLowerCase()}`, { responseType: 'json' }); + console.log(request.body); // Calulate total price, if cart is provided if (cart !== undefined && totalPrice === undefined) { totalPrice = calculateCart(cart); } - let paymentMethods: IPaymentMethod[] = []; - config.payment.methods.forEach(coin => { - paymentMethods.push({ method: CryptoUnits[coin.toUpperCase()], amount: totalPrice / Number(request.body[coin][currency.toLowerCase()]) }); + let paymentMethods: IPaymentMethod[] = []; + + cgFormat.forEach(coinFullName => { + console.log(coinFullName); + const coin = CryptoUnits[coinFullName.toUpperCase()]; + + paymentMethods.push({ method: coin, amount: + roundNumber(totalPrice / Number(request.body[coinFullName][currency.toLowerCase()]), decimalPlaces.get(coin)) + }); }); const dueBy = new Date(Date.now() + 1000 * 60 * 60); @@ -131,13 +132,19 @@ export async function getInvoice(req: Request, res: Response) { } if(invoice.status === PaymentStatus.UNCONFIRMED || invoice.status === PaymentStatus.DONE) { - rpcClient.request('gettransaction', [invoice.transcationHashes[0]], (err, message) => { + const transaction = await providerManager.getProvider(invoice.paymentMethod).getTransaction(invoice.transcationHash); + try { let invoiceClone: any = invoice; - console.log(message.result.confirmations); + console.log(transaction.confirmations); - invoiceClone['confirmation'] = message.result.confirmations; + invoiceClone['confirmation'] = transaction.confirmations; res.status(200).send(invoiceClone); - }); + } catch (err) { + if (err) { + logger.error(`There was an error while getting transaction: ${err.message}`); + res.status(500).send(); + } + } } else { res.status(200).send(invoice); } @@ -187,6 +194,40 @@ export async function cancelInvoice(req: Request, res: Response) { } } +// POST /invoice/:selector/setmethod +export async function setPaymentMethod(req: Request, res: Response) { + const method: string = req.body.method; + const selector: string = req.params.selector; + + if (method === undefined || selector === undefined) { + res.status(400).send(); + return; + } + + if (Object.values(CryptoUnits).indexOf(method.toUpperCase() as any) === -1) { + res.status(400).send({ message: 'Unknown payment method' }); + return; + } + + const invoice = await Invoice.findOne({ selector: selector }); + if (invoice === null) { + res.status(404).send(); + return; + } + + + invoice.status = PaymentStatus.PENDING; + invoice.paymentMethod = CryptoUnits[findCryptoBySymbol(method)]; + invoice.receiveAddress = await providerManager.getProvider(invoice.paymentMethod).getNewAddress(); + + await invoice.save(); + + res.status(200).send({ + receiveAddress: invoice.receiveAddress + }); +} + +// GET /invoice/paymentmethods export async function getPaymentMethods(req: Request, res: Response) { res.status(200).send({ methods: config.payment.methods }); } \ No newline at end of file diff --git a/src/helper/backendProvider.ts b/src/helper/backendProvider.ts index e2c1d31..5605333 100644 --- a/src/helper/backendProvider.ts +++ b/src/helper/backendProvider.ts @@ -8,12 +8,6 @@ import { CryptoUnits } from './types'; */ export abstract class BackendProvider { - invoiceManager: InvoiceManager = null; - - constructor (invoiceManager: InvoiceManager) { - this.invoiceManager = invoiceManager; - } - /* Provider information */ abstract readonly NAME: string; abstract readonly DESCRIPTION: string; diff --git a/src/helper/invoiceManager.ts b/src/helper/invoiceManager.ts index aa68b58..c3da691 100644 --- a/src/helper/invoiceManager.ts +++ b/src/helper/invoiceManager.ts @@ -21,6 +21,8 @@ export class InvoiceManager { // Get all pending transcations Invoice.find({ status: PaymentStatus.PENDING }).then(invoices => { + console.log('These are pending', invoices); + this.pendingInvoices = invoices; }); @@ -28,8 +30,6 @@ export class InvoiceManager { Invoice.find({ status: PaymentStatus.UNCONFIRMED }).then(invoices => { this.unconfirmedTranscations = invoices; }); - - this.watchConfirmations(); } /** @@ -75,11 +75,4 @@ export class InvoiceManager { socketManager.emitInvoiceEvent(invoice, 'confirmationUpdate', { count }); return this.knownConfirmations.set(invoice.id, count); } - - /** - * This functions loops over each unconfirmed transaction to check if it reached "trusted" threshold. - */ - private watchConfirmations() { - - } } \ No newline at end of file diff --git a/src/helper/providerManager.ts b/src/helper/providerManager.ts index e39fafd..893b714 100644 --- a/src/helper/providerManager.ts +++ b/src/helper/providerManager.ts @@ -1,20 +1,49 @@ import { readdirSync } from 'fs'; +import { join } from 'path'; +import { invoiceManager, logger } from '../app'; +import { BackendProvider } from './backendProvider'; +import { CryptoUnits } from './types'; export class ProviderManager { providerFilePath: string; + cryptoProvider: Map; constructor(filePath: string) { this.providerFilePath = filePath; + this.cryptoProvider = new Map(); } + getProvider(crypto: CryptoUnits): BackendProvider | undefined { + return this.cryptoProvider.get(crypto); + } + + /** + * Scan & load all found providers + */ scan() { const getDirectories = () => readdirSync(this.providerFilePath, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) + .filter(dirent => dirent.name.endsWith('.ts')) .map(dirent => dirent.name) - console.log(getDirectories()); + getDirectories().forEach(file => { + const absolutePath = join(this.providerFilePath, file); + const providerModule = require(absolutePath); + const provider = new providerModule.Provider() as BackendProvider; + + if (this.cryptoProvider.has(provider.CRYPTO)) { + logger.warn(`Provider ${provider.NAME} will be ignored since there is already another provider active for ${provider.CRYPTO}!`); + return; + } + + this.cryptoProvider.set(provider.CRYPTO, provider); + + // Execute onEnable() function of this provider + provider.onEnable(); + + logger.info(`Loaded provider ${provider.NAME} by ${provider.AUTHOR} (${provider.VERSION}) for ${provider.CRYPTO}`); + }); } } \ No newline at end of file diff --git a/src/helper/providers/bitcoinCore.ts b/src/helper/providers/bitcoinCore.ts index d23f439..6f11200 100644 --- a/src/helper/providers/bitcoinCore.ts +++ b/src/helper/providers/bitcoinCore.ts @@ -1,12 +1,11 @@ import { Socket, Subscriber } from "zeromq"; import { config } from "../../../config"; -import { logger, rpcClient } from "../../app"; +import { invoiceManager, logger, rpcClient } from "../../app"; import { BackendProvider, ITransaction, IRawTransaction } from "../backendProvider"; import { InvoiceManager } from "../invoiceManager"; import { CryptoUnits, PaymentStatus } from "../types"; -export class BitcoinCore implements BackendProvider { - invoiceManager: InvoiceManager; +export class Provider implements BackendProvider { private sock: Subscriber; @@ -17,7 +16,14 @@ export class BitcoinCore implements BackendProvider { CRYPTO = CryptoUnits.BITCOIN; onEnable() { - logger.info('The Bitcoin Core backend is now available!'); + this.sock = new Subscriber(); + this.sock.connect('tcp://127.0.0.1:29000'); + this.sock.subscribe('rawtx'); + + this.listener(); + this.watchConfirmations(); + + //logger.info('The Bitcoin Core backend is now available!'); } async getNewAddress(): Promise { @@ -78,18 +84,14 @@ export class BitcoinCore implements BackendProvider { } async listener() { - this.sock = new Subscriber(); - this.sock.connect('tcp://127.0.0.1:29000'); - this.sock.subscribe('rawtx'); - logger.info('Now listing for incoming transaction to any invoices ...'); for await (const [topic, msg] of this.sock) { const rawtx = msg.toString('hex'); const tx = await this.decodeRawTransaction(rawtx); - + tx.vout.forEach(output => { // Loop over each output and check if the address of one matches the one of an invoice. - this.invoiceManager.getPendingInvoices().forEach(async invoice => { + invoiceManager.getPendingInvoices().forEach(async invoice => { if (output.scriptPubKey.addresses === undefined) return; // Sometimes (weird) transaction don't have any addresses // We found our transaction (https://developer.bitcoin.org/reference/rpc/decoderawtransaction.html) @@ -107,10 +109,10 @@ export class BitcoinCore implements BackendProvider { logger.info(`Sent ${output.value} BTC back to ${senderAddress}`); } else { invoice.status = PaymentStatus.UNCONFIRMED; - invoice.transcationHashes = tx.txid; + invoice.transcationHash = tx.txid; invoice.save(); - this.invoiceManager.upgradeInvoice(invoice); + invoiceManager.upgradeInvoice(invoice); } } }) @@ -121,22 +123,22 @@ export class BitcoinCore implements BackendProvider { async watchConfirmations() { setInterval(() => { - this.invoiceManager.getUnconfirmedTransactions().forEach(async invoice => { - if (invoice.transcationHashes.length === 0) return; + invoiceManager.getUnconfirmedTransactions().forEach(async invoice => { + if (invoice.transcationHash.length === 0) return; let trustworthy = true; // Will be true if all transactions are above threshold. - for (let i = 0; i < invoice.transcationHashes.length; i++) { - const transcation = invoice.transcationHashes[i]; + for (let i = 0; i < invoice.transcationHash.length; i++) { + const transcation = invoice.transcationHash; const tx = await this.getTransaction(transcation); - if (this.invoiceManager.hasConfirmationChanged(invoice, tx.confirmations)) { - this.invoiceManager.setConfirmationCount(invoice, tx.confirmations); + if (invoiceManager.hasConfirmationChanged(invoice, tx.confirmations)) { + invoiceManager.setConfirmationCount(invoice, tx.confirmations); } if (Number(tx.confirmations) > 0) { logger.info(`Transaction (${transcation}) has reached more then 2 confirmations and can now be trusted!`); - this.invoiceManager.removeInvoice(invoice); + invoiceManager.removeInvoice(invoice); } else { trustworthy = false; logger.debug(`Transcation (${transcation}) has not reached his threshold yet.`); diff --git a/src/helper/socketio.ts b/src/helper/socketio.ts index 614921a..a2b7a1f 100644 --- a/src/helper/socketio.ts +++ b/src/helper/socketio.ts @@ -13,8 +13,6 @@ export class SocketManager { } listen() { - console.log("Listen"); - this.io.on('connection', (socket: Socket) => { // The frontend sends his selector, then pick _id and put it in `socketInvoice` map. // Return `true` if successful and `false` if not. @@ -29,6 +27,22 @@ export class SocketManager { logger.info(`Socket ${socket.id} has subscribed to invoice ${invoice.id} (${PaymentStatus[invoice.status]})`); } }); + + socket.on('subscribe', async (data: any) => { + if (data === undefined || data === null) { + socket.emit('subscribe', false); + return; + } + + const invoice = await Invoice.findOne({ selector: data.selector }); + if (invoice === null) { + socket.emit('subscribe', false); + return; + } + + socket.join(invoice.selector); + socket.emit('subscribe', true); + }) }); } diff --git a/src/helper/types.ts b/src/helper/types.ts index 00ae3e5..53646fd 100644 --- a/src/helper/types.ts +++ b/src/helper/types.ts @@ -1,3 +1,5 @@ +import { logger } from "../app"; + export enum CryptoUnits { BITCOIN = 'BTC', BITCOINCASH = 'BCH', @@ -7,9 +9,23 @@ export enum CryptoUnits { MONERO = 'XMR' } +/** + * Get the decimal places by id + */ +export const decimalPlaces = new Map([ + [CryptoUnits.BITCOIN, 8], + [CryptoUnits.BITCOINCASH, 8], + [CryptoUnits.ETHEREUM, 18], + [CryptoUnits.LITECOIN, 8], + [CryptoUnits.DOGECOIN, 8], + [CryptoUnits.MONERO, 12] +]) + export function findCryptoBySymbol(symbol: string): string | null { for (let coin in CryptoUnits) { - if (CryptoUnits[coin] === symbol.toUpperCase()) return coin; + if (CryptoUnits[coin] === symbol.toUpperCase()) { + return coin; + } } return null; } @@ -20,6 +36,11 @@ export enum FiatUnits { } export enum PaymentStatus { + /** + * The payment has been cancelled by the user. + */ + CANCELLED = -2, + /** * The invoice has been requested but the payment method has to be choosen. */ @@ -30,23 +51,21 @@ export enum PaymentStatus { */ PENDING = 0, - /** - * The payment has been paid, but not completly. - */ - PARTIALLY = 1, - /** * The payment has been made but it's not yet confirmed. */ - UNCONFIRMED = 2, + UNCONFIRMED = 1, /** * The payment is completed and the crypto is now available. */ - DONE = 3, + DONE = 2, +} - /** - * The payment has been cancelled by the user. - */ - CANCELLED = 4 -} \ No newline at end of file +// I'll will just leave that here +export function roundNumber(number: number, precision: number) { + var factor = Math.pow(10, precision); + var tmpNumber = number * factor; + var rounded = Math.round(tmpNumber); + return rounded / factor; +}; diff --git a/src/models/invoice/invoice.interface.ts b/src/models/invoice/invoice.interface.ts index 3e76eaa..aced397 100644 --- a/src/models/invoice/invoice.interface.ts +++ b/src/models/invoice/invoice.interface.ts @@ -29,7 +29,7 @@ export interface IInvoice extends Document { // Is set when invoice got paid // 3b38c3a215d4e7981e1516b2dcbf76fca58911274d5d55b3d615274d6e10f2c1 - transcationHashes?: string; + transcationHash?: string; cart?: ICart[]; totalPrice?: number; diff --git a/src/models/invoice/invoice.schema.ts b/src/models/invoice/invoice.schema.ts index 7b4703d..963043b 100644 --- a/src/models/invoice/invoice.schema.ts +++ b/src/models/invoice/invoice.schema.ts @@ -22,7 +22,7 @@ const schemaInvoice = new Schema({ paymentMethods: [{ type: schemaPaymentMethods, required: true }], paymentMethod: { type: String, enum: Object.values(CryptoUnits), required: false }, receiveAddress: { type: String, required: false }, - transcationHashes: { type: String, required: false }, + transcationHash: { type: String, required: false }, cart: [{ type: schemaCart, required: false }], totalPrice: { type: Number, required: false }, currency: { type: String, enum: Object.values(FiatUnits), required: true }, diff --git a/src/routes/invoice.ts b/src/routes/invoice.ts index e4fca3c..8908324 100644 --- a/src/routes/invoice.ts +++ b/src/routes/invoice.ts @@ -1,11 +1,12 @@ import { Router } from "express"; -import { createInvoice, getInvoice, getPaymentMethods } from "../controllers/invoice"; +import { createInvoice, getInvoice, getPaymentMethods, setPaymentMethod } from "../controllers/invoice"; import { MW_User } from "../controllers/user"; const invoiceRouter = Router() invoiceRouter.get('/paymentmethods', getPaymentMethods); invoiceRouter.get('/:selector', getInvoice); +invoiceRouter.post('/:selector/setmethod', setPaymentMethod); invoiceRouter.get('/', MW_User, getInvoice); invoiceRouter.post('/', MW_User, createInvoice);