External providers can now be loaded
Implemented invoice protocol (see docs)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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<CryptoUnits, BackendProvider>;
|
||||
|
||||
constructor(filePath: string) {
|
||||
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() {
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<string> {
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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, 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 {
|
||||
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
|
||||
}
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface IInvoice extends Document {
|
||||
|
||||
// Is set when invoice got paid
|
||||
// 3b38c3a215d4e7981e1516b2dcbf76fca58911274d5d55b3d615274d6e10f2c1
|
||||
transcationHashes?: string;
|
||||
transcationHash?: string;
|
||||
|
||||
cart?: ICart[];
|
||||
totalPrice?: number;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user