External providers can now be loaded

Implemented invoice protocol (see docs)
This commit is contained in:
2020-12-29 00:09:40 +01:00
parent 0b3502d81f
commit aa2147d509
12 changed files with 169 additions and 74 deletions

View File

@@ -25,7 +25,8 @@ export const config: IConfig = {
methods: [ methods: [
CryptoUnits.BITCOIN, CryptoUnits.BITCOIN,
CryptoUnits.DOGECOIN, CryptoUnits.DOGECOIN,
CryptoUnits.ETHEREUM CryptoUnits.ETHEREUM,
CryptoUnits.MONERO
] ]
} }
} }

View File

@@ -27,8 +27,9 @@ export const JWT_SECRET = process.env.JWT_SECRET || "";
export const INVOICE_SECRET = process.env.INVOICE_SECRET || ""; export const INVOICE_SECRET = process.env.INVOICE_SECRET || "";
export let rpcClient: rpc.HttpClient | undefined = undefined; 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 socketManager: SocketManager | undefined = undefined;
export let providerManager: ProviderManager = undefined;
export let logger: winston.Logger; export let logger: winston.Logger;
@@ -108,10 +109,10 @@ async function run() {
logger.debug("At least one admin user already exists, skip."); 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(); providerManager.scan();
invoiceScheduler = new InvoiceManager(); invoiceManager = new InvoiceManager();
const app = express(); const app = express();
const http = new Server(app); const http = new Server(app);

View File

@@ -2,9 +2,9 @@ import { Request, Response } from 'express';
import got from 'got'; import got from 'got';
import { config } from '../../config'; 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 { 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 { ICart, IInvoice, IPaymentMethod } from '../models/invoice/invoice.interface';
import { Invoice } from '../models/invoice/invoice.model'; import { Invoice } from '../models/invoice/invoice.model';
import { calculateCart } from '../models/invoice/invoice.schema'; 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 successUrl: string = req.body.successUrl;
const cancelUrl: string = req.body.cancelUrl; const cancelUrl: string = req.body.cancelUrl;
const cart: ICart[] = req.body.cart; const cart: ICart[] = req.body.cart;
let currency: FiatUnits = req.body.currency; let currency: FiatUnits = req.body.currency;
let totalPrice: number = req.body.totalPrice; let totalPrice: number = req.body.totalPrice;
if (paymentMethodsRaw === undefined) {
res.status(400).send({ message: '"paymentMethods" are not provided!' });
return;
}
if (successUrl === undefined) { if (successUrl === undefined) {
res.status(400).send({ message: '"successUrl" is not provided!' }); res.status(400).send({ message: '"successUrl" is not provided!' });
return; 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'] // Convert coin symbol to full text in order to query Coin Gecko. eg.: ['btc', 'xmr'] => ['bitcoin', 'monero']
let cgFormat = []; let cgFormat = [];
paymentMethodsRaw.forEach(coin => { config.payment.methods.forEach(coin => {
const crypto = findCryptoBySymbol(coin); const crypto = findCryptoBySymbol(coin);
if (crypto !== undefined) { if (crypto !== undefined) {
@@ -78,6 +72,7 @@ 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()}`, { const request = await got.get(`https://api.coingecko.com/api/v3/simple/price?ids=${cgFormat.join(',')}&vs_currencies=${currency.toLowerCase()}`, {
responseType: 'json' responseType: 'json'
}); });
console.log(request.body);
// Calulate total price, if cart is provided // Calulate total price, if cart is provided
if (cart !== undefined && totalPrice === undefined) { if (cart !== undefined && totalPrice === undefined) {
@@ -85,8 +80,14 @@ export async function createInvoice(req: Request, res: Response) {
} }
let paymentMethods: IPaymentMethod[] = []; let paymentMethods: IPaymentMethod[] = [];
config.payment.methods.forEach(coin => {
paymentMethods.push({ method: CryptoUnits[coin.toUpperCase()], amount: totalPrice / Number(request.body[coin][currency.toLowerCase()]) }); 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); 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) { 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; 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); 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 { } else {
res.status(200).send(invoice); 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) { export async function getPaymentMethods(req: Request, res: Response) {
res.status(200).send({ methods: config.payment.methods }); res.status(200).send({ methods: config.payment.methods });
} }

View File

@@ -8,12 +8,6 @@ import { CryptoUnits } from './types';
*/ */
export abstract class BackendProvider { export abstract class BackendProvider {
invoiceManager: InvoiceManager = null;
constructor (invoiceManager: InvoiceManager) {
this.invoiceManager = invoiceManager;
}
/* Provider information */ /* Provider information */
abstract readonly NAME: string; abstract readonly NAME: string;
abstract readonly DESCRIPTION: string; abstract readonly DESCRIPTION: string;

View File

@@ -21,6 +21,8 @@ export class InvoiceManager {
// Get all pending transcations // Get all pending transcations
Invoice.find({ status: PaymentStatus.PENDING }).then(invoices => { Invoice.find({ status: PaymentStatus.PENDING }).then(invoices => {
console.log('These are pending', invoices);
this.pendingInvoices = invoices; this.pendingInvoices = invoices;
}); });
@@ -28,8 +30,6 @@ export class InvoiceManager {
Invoice.find({ status: PaymentStatus.UNCONFIRMED }).then(invoices => { Invoice.find({ status: PaymentStatus.UNCONFIRMED }).then(invoices => {
this.unconfirmedTranscations = invoices; this.unconfirmedTranscations = invoices;
}); });
this.watchConfirmations();
} }
/** /**
@@ -75,11 +75,4 @@ export class InvoiceManager {
socketManager.emitInvoiceEvent(invoice, 'confirmationUpdate', { count }); socketManager.emitInvoiceEvent(invoice, 'confirmationUpdate', { count });
return this.knownConfirmations.set(invoice.id, count); return this.knownConfirmations.set(invoice.id, count);
} }
/**
* This functions loops over each unconfirmed transaction to check if it reached "trusted" threshold.
*/
private watchConfirmations() {
}
} }

View File

@@ -1,20 +1,49 @@
import { readdirSync } from 'fs'; import { readdirSync } from 'fs';
import { join } from 'path';
import { invoiceManager, logger } from '../app';
import { BackendProvider } from './backendProvider';
import { CryptoUnits } from './types';
export class ProviderManager { export class ProviderManager {
providerFilePath: string; providerFilePath: string;
cryptoProvider: Map<CryptoUnits, BackendProvider>;
constructor(filePath: string) { constructor(filePath: string) {
this.providerFilePath = filePath; this.providerFilePath = filePath;
this.cryptoProvider = new Map<CryptoUnits, BackendProvider>();
} }
getProvider(crypto: CryptoUnits): BackendProvider | undefined {
return this.cryptoProvider.get(crypto);
}
/**
* Scan & load all found providers
*/
scan() { scan() {
const getDirectories = () => const getDirectories = () =>
readdirSync(this.providerFilePath, { withFileTypes: true }) readdirSync(this.providerFilePath, { withFileTypes: true })
.filter(dirent => dirent.isDirectory()) .filter(dirent => dirent.name.endsWith('.ts'))
.map(dirent => dirent.name) .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}`);
});
} }
} }

View File

@@ -1,12 +1,11 @@
import { Socket, Subscriber } from "zeromq"; import { Socket, Subscriber } from "zeromq";
import { config } from "../../../config"; import { config } from "../../../config";
import { logger, rpcClient } from "../../app"; import { invoiceManager, logger, rpcClient } from "../../app";
import { BackendProvider, ITransaction, IRawTransaction } from "../backendProvider"; import { BackendProvider, ITransaction, IRawTransaction } from "../backendProvider";
import { InvoiceManager } from "../invoiceManager"; import { InvoiceManager } from "../invoiceManager";
import { CryptoUnits, PaymentStatus } from "../types"; import { CryptoUnits, PaymentStatus } from "../types";
export class BitcoinCore implements BackendProvider { export class Provider implements BackendProvider {
invoiceManager: InvoiceManager;
private sock: Subscriber; private sock: Subscriber;
@@ -17,7 +16,14 @@ export class BitcoinCore implements BackendProvider {
CRYPTO = CryptoUnits.BITCOIN; CRYPTO = CryptoUnits.BITCOIN;
onEnable() { 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<string> { async getNewAddress(): Promise<string> {
@@ -78,10 +84,6 @@ export class BitcoinCore implements BackendProvider {
} }
async listener() { 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 ...'); logger.info('Now listing for incoming transaction to any invoices ...');
for await (const [topic, msg] of this.sock) { for await (const [topic, msg] of this.sock) {
const rawtx = msg.toString('hex'); const rawtx = msg.toString('hex');
@@ -89,7 +91,7 @@ export class BitcoinCore implements BackendProvider {
tx.vout.forEach(output => { tx.vout.forEach(output => {
// Loop over each output and check if the address of one matches the one of an invoice. // 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 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) // 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}`); logger.info(`Sent ${output.value} BTC back to ${senderAddress}`);
} else { } else {
invoice.status = PaymentStatus.UNCONFIRMED; invoice.status = PaymentStatus.UNCONFIRMED;
invoice.transcationHashes = tx.txid; invoice.transcationHash = tx.txid;
invoice.save(); invoice.save();
this.invoiceManager.upgradeInvoice(invoice); invoiceManager.upgradeInvoice(invoice);
} }
} }
}) })
@@ -121,22 +123,22 @@ export class BitcoinCore implements BackendProvider {
async watchConfirmations() { async watchConfirmations() {
setInterval(() => { setInterval(() => {
this.invoiceManager.getUnconfirmedTransactions().forEach(async invoice => { invoiceManager.getUnconfirmedTransactions().forEach(async invoice => {
if (invoice.transcationHashes.length === 0) return; if (invoice.transcationHash.length === 0) return;
let trustworthy = true; // Will be true if all transactions are above threshold. let trustworthy = true; // Will be true if all transactions are above threshold.
for (let i = 0; i < invoice.transcationHashes.length; i++) { for (let i = 0; i < invoice.transcationHash.length; i++) {
const transcation = invoice.transcationHashes[i]; const transcation = invoice.transcationHash;
const tx = await this.getTransaction(transcation); const tx = await this.getTransaction(transcation);
if (this.invoiceManager.hasConfirmationChanged(invoice, tx.confirmations)) { if (invoiceManager.hasConfirmationChanged(invoice, tx.confirmations)) {
this.invoiceManager.setConfirmationCount(invoice, tx.confirmations); invoiceManager.setConfirmationCount(invoice, tx.confirmations);
} }
if (Number(tx.confirmations) > 0) { if (Number(tx.confirmations) > 0) {
logger.info(`Transaction (${transcation}) has reached more then 2 confirmations and can now be trusted!`); logger.info(`Transaction (${transcation}) has reached more then 2 confirmations and can now be trusted!`);
this.invoiceManager.removeInvoice(invoice); invoiceManager.removeInvoice(invoice);
} else { } else {
trustworthy = false; trustworthy = false;
logger.debug(`Transcation (${transcation}) has not reached his threshold yet.`); logger.debug(`Transcation (${transcation}) has not reached his threshold yet.`);

View File

@@ -13,8 +13,6 @@ export class SocketManager {
} }
listen() { listen() {
console.log("Listen");
this.io.on('connection', (socket: Socket) => { this.io.on('connection', (socket: Socket) => {
// The frontend sends his selector, then pick _id and put it in `socketInvoice` map. // The frontend sends his selector, then pick _id and put it in `socketInvoice` map.
// Return `true` if successful and `false` if not. // 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]})`); 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);
})
}); });
} }

View File

@@ -1,3 +1,5 @@
import { logger } from "../app";
export enum CryptoUnits { export enum CryptoUnits {
BITCOIN = 'BTC', BITCOIN = 'BTC',
BITCOINCASH = 'BCH', BITCOINCASH = 'BCH',
@@ -7,9 +9,23 @@ export enum CryptoUnits {
MONERO = 'XMR' MONERO = 'XMR'
} }
/**
* Get the decimal places by id
*/
export const decimalPlaces = new Map<CryptoUnits, number>([
[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 { export function findCryptoBySymbol(symbol: string): string | null {
for (let coin in CryptoUnits) { for (let coin in CryptoUnits) {
if (CryptoUnits[coin] === symbol.toUpperCase()) return coin; if (CryptoUnits[coin] === symbol.toUpperCase()) {
return coin;
}
} }
return null; return null;
} }
@@ -20,6 +36,11 @@ export enum FiatUnits {
} }
export enum PaymentStatus { 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. * The invoice has been requested but the payment method has to be choosen.
*/ */
@@ -30,23 +51,21 @@ export enum PaymentStatus {
*/ */
PENDING = 0, PENDING = 0,
/**
* The payment has been paid, but not completly.
*/
PARTIALLY = 1,
/** /**
* The payment has been made but it's not yet confirmed. * 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. * The payment is completed and the crypto is now available.
*/ */
DONE = 3, DONE = 2,
/**
* The payment has been cancelled by the user.
*/
CANCELLED = 4
} }
// 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;
};

View File

@@ -29,7 +29,7 @@ export interface IInvoice extends Document {
// Is set when invoice got paid // Is set when invoice got paid
// 3b38c3a215d4e7981e1516b2dcbf76fca58911274d5d55b3d615274d6e10f2c1 // 3b38c3a215d4e7981e1516b2dcbf76fca58911274d5d55b3d615274d6e10f2c1
transcationHashes?: string; transcationHash?: string;
cart?: ICart[]; cart?: ICart[];
totalPrice?: number; totalPrice?: number;

View File

@@ -22,7 +22,7 @@ const schemaInvoice = new Schema({
paymentMethods: [{ type: schemaPaymentMethods, required: true }], paymentMethods: [{ type: schemaPaymentMethods, required: true }],
paymentMethod: { type: String, enum: Object.values(CryptoUnits), required: false }, paymentMethod: { type: String, enum: Object.values(CryptoUnits), required: false },
receiveAddress: { type: String, required: false }, receiveAddress: { type: String, required: false },
transcationHashes: { type: String, required: false }, transcationHash: { type: String, required: false },
cart: [{ type: schemaCart, required: false }], cart: [{ type: schemaCart, required: false }],
totalPrice: { type: Number, required: false }, totalPrice: { type: Number, required: false },
currency: { type: String, enum: Object.values(FiatUnits), required: true }, currency: { type: String, enum: Object.values(FiatUnits), required: true },

View File

@@ -1,11 +1,12 @@
import { Router } from "express"; 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"; import { MW_User } from "../controllers/user";
const invoiceRouter = Router() const invoiceRouter = Router()
invoiceRouter.get('/paymentmethods', getPaymentMethods); invoiceRouter.get('/paymentmethods', getPaymentMethods);
invoiceRouter.get('/:selector', getInvoice); invoiceRouter.get('/:selector', getInvoice);
invoiceRouter.post('/:selector/setmethod', setPaymentMethod);
invoiceRouter.get('/', MW_User, getInvoice); invoiceRouter.get('/', MW_User, getInvoice);
invoiceRouter.post('/', MW_User, createInvoice); invoiceRouter.post('/', MW_User, createInvoice);