New abstract structure
Events for invoices get emitted in rooms
This commit is contained in:
@@ -1,10 +1,7 @@
|
|||||||
# Socket events
|
# Socket events
|
||||||
|
|
||||||
## Invoice status
|
## Invoice status
|
||||||
### Requests
|
In order to receive updates about the a specific invoice, **you have to join the room with the selector.**
|
||||||
* `subscribe` - Subscribe to a invoices progress. Returns `true` if successful.
|
|
||||||
* `selector` - Your selector
|
|
||||||
|
|
||||||
### Events
|
### Events
|
||||||
* `status` - Status changed (see PaymentStatus enum)
|
* `status` - Status changed (see PaymentStatus enum)
|
||||||
* `confirmationUpdate` - When there is a new confirmation on the transaction
|
* `confirmationUpdate` - When there is a new confirmation on the transaction
|
||||||
|
|||||||
12
config.ts
12
config.ts
@@ -1,3 +1,4 @@
|
|||||||
|
import { CryptoUnits } from './src/helper/types';
|
||||||
/**
|
/**
|
||||||
* Here you can change various settings like database credentials, http settings and more.
|
* Here you can change various settings like database credentials, http settings and more.
|
||||||
*
|
*
|
||||||
@@ -18,6 +19,14 @@ export const config: IConfig = {
|
|||||||
transcations: {
|
transcations: {
|
||||||
// If a payment has been made and its value is this amount less, it would be still accepted.
|
// If a payment has been made and its value is this amount less, it would be still accepted.
|
||||||
acceptMargin: 0.00000001
|
acceptMargin: 0.00000001
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
// This is a list of cryptocurrencies that you want to accpet.
|
||||||
|
methods: [
|
||||||
|
CryptoUnits.BITCOIN,
|
||||||
|
CryptoUnits.DOGECOIN,
|
||||||
|
CryptoUnits.ETHEREUM
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -40,5 +49,8 @@ export interface IConfig {
|
|||||||
},
|
},
|
||||||
transcations: {
|
transcations: {
|
||||||
acceptMargin: number
|
acceptMargin: number
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
methods: CryptoUnits[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
11
src/app.ts
11
src/app.ts
@@ -6,15 +6,17 @@ import * as rpc from 'jayson';
|
|||||||
import * as mongoose from 'mongoose';
|
import * as mongoose from 'mongoose';
|
||||||
import * as winston from 'winston';
|
import * as winston from 'winston';
|
||||||
import * as socketio from 'socket.io';
|
import * as socketio from 'socket.io';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { Server } from 'http';
|
import { Server } from 'http';
|
||||||
|
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import { hashPassword, randomPepper, randomString } from './helper/crypto';
|
import { hashPassword, randomPepper, randomString } from './helper/crypto';
|
||||||
import { InvoiceScheduler } from './helper/invoiceScheduler';
|
import { InvoiceManager } from './helper/invoiceManager';
|
||||||
import { User } from './models/user/user.model';
|
import { User } from './models/user/user.model';
|
||||||
import { invoiceRouter } from './routes/invoice';
|
import { invoiceRouter } from './routes/invoice';
|
||||||
import { userRouter } from './routes/user';
|
import { userRouter } from './routes/user';
|
||||||
import { SocketManager } from './helper/socketio';
|
import { SocketManager } from './helper/socketio';
|
||||||
|
import { ProviderManager } from './helper/providerManager';
|
||||||
|
|
||||||
// Load .env
|
// Load .env
|
||||||
dconfig({ debug: true, encoding: 'UTF-8' });
|
dconfig({ debug: true, encoding: 'UTF-8' });
|
||||||
@@ -25,7 +27,7 @@ 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: InvoiceScheduler | undefined = undefined;
|
export let invoiceScheduler: InvoiceManager | undefined = undefined;
|
||||||
export let socketManager: SocketManager | undefined = undefined;
|
export let socketManager: SocketManager | undefined = undefined;
|
||||||
|
|
||||||
export let logger: winston.Logger;
|
export let logger: winston.Logger;
|
||||||
@@ -106,7 +108,10 @@ async function run() {
|
|||||||
logger.debug("At least one admin user already exists, skip.");
|
logger.debug("At least one admin user already exists, skip.");
|
||||||
}
|
}
|
||||||
|
|
||||||
invoiceScheduler = new InvoiceScheduler();
|
const providerManager = new ProviderManager(resolve('./src/helper/providers'));
|
||||||
|
providerManager.scan();
|
||||||
|
|
||||||
|
invoiceScheduler = new InvoiceManager();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const http = new Server(app);
|
const http = new Server(app);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import got from 'got';
|
import got from 'got';
|
||||||
|
import { config } from '../../config';
|
||||||
|
|
||||||
import { invoiceScheduler, INVOICE_SECRET, rpcClient } from '../app';
|
import { invoiceScheduler, INVOICE_SECRET, rpcClient } from '../app';
|
||||||
import { randomString } from '../helper/crypto';
|
import { randomString } from '../helper/crypto';
|
||||||
@@ -62,9 +63,6 @@ export async function createInvoice(req: Request, res: Response) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
rpcClient.request('getnewaddress', ['', 'bech32'], async (err, response) => {
|
|
||||||
if (err) throw err;
|
|
||||||
|
|
||||||
// Get price
|
// Get price
|
||||||
// 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 = [];
|
||||||
@@ -87,28 +85,33 @@ export async function createInvoice(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let paymentMethods: IPaymentMethod[] = [];
|
let paymentMethods: IPaymentMethod[] = [];
|
||||||
Object.keys(request.body).forEach(coin => {
|
config.payment.methods.forEach(coin => {
|
||||||
paymentMethods.push({ method: CryptoUnits[coin.toUpperCase()], amount: totalPrice / Number(request.body[coin][currency.toLowerCase()]) });
|
paymentMethods.push({ method: CryptoUnits[coin.toUpperCase()], amount: totalPrice / Number(request.body[coin][currency.toLowerCase()]) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dueBy = new Date(Date.now() + 1000 * 60 * 60);
|
||||||
|
|
||||||
Invoice.create({
|
Invoice.create({
|
||||||
selector: randomString(128),
|
selector: randomString(128),
|
||||||
paymentMethods: paymentMethods,
|
paymentMethods,
|
||||||
successUrl,
|
successUrl,
|
||||||
cancelUrl,
|
cancelUrl,
|
||||||
cart,
|
cart,
|
||||||
currency,
|
currency,
|
||||||
totalPrice,
|
totalPrice,
|
||||||
dueBy: 60,
|
dueBy
|
||||||
receiveAddress: response.result
|
|
||||||
}, (error, invoice: IInvoice) => {
|
}, (error, invoice: IInvoice) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
res.status(500).send({error: error.message});
|
res.status(500).send({error: error.message});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
invoiceScheduler.addInvoice(invoice);
|
//invoiceScheduler.addInvoice(invoice);
|
||||||
res.status(200).send({ id: invoice.selector });
|
//res.status(200).send({ id: invoice.selector });
|
||||||
|
res.status(200).send({
|
||||||
|
methods: paymentMethods,
|
||||||
|
selector: invoice.selector,
|
||||||
|
expireDate: invoice.dueBy
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,7 +170,7 @@ export async function getInvoice(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /invoice/:selector
|
// DELETE /invoice/:selector
|
||||||
export async function cancelPaymnet(req: Request, res: Response) {
|
export async function cancelInvoice(req: Request, res: Response) {
|
||||||
const selector = req.params.selector;
|
const selector = req.params.selector;
|
||||||
|
|
||||||
// If an id is provided
|
// If an id is provided
|
||||||
@@ -183,3 +186,7 @@ export async function cancelPaymnet(req: Request, res: Response) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPaymentMethods(req: Request, res: Response) {
|
||||||
|
res.status(200).send({ methods: config.payment.methods });
|
||||||
|
}
|
||||||
112
src/helper/backendProvider.ts
Normal file
112
src/helper/backendProvider.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { InvoiceManager } from './invoiceManager';
|
||||||
|
import { CryptoUnits } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This backend provider class is required to write your own backends.
|
||||||
|
*
|
||||||
|
* *By default LibrePay supports Bitcoin Core.*
|
||||||
|
*/
|
||||||
|
export abstract class BackendProvider {
|
||||||
|
|
||||||
|
invoiceManager: InvoiceManager = null;
|
||||||
|
|
||||||
|
constructor (invoiceManager: InvoiceManager) {
|
||||||
|
this.invoiceManager = invoiceManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provider information */
|
||||||
|
abstract readonly NAME: string;
|
||||||
|
abstract readonly DESCRIPTION: string;
|
||||||
|
abstract readonly VERSION: string;
|
||||||
|
abstract readonly AUTHOR: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cryptocurrency that this providers supports.
|
||||||
|
*/
|
||||||
|
abstract readonly CRYPTO: CryptoUnits;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function gets called when this provider gets activated.
|
||||||
|
*/
|
||||||
|
abstract onEnable(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new address to receive new funds.
|
||||||
|
*/
|
||||||
|
abstract getNewAddress(): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a transaction from the blockchain.
|
||||||
|
* @param txId Hash of the transcation you're looking for.
|
||||||
|
* @returns See https://developer.bitcoin.org/reference/rpc/gettransaction.html for reference
|
||||||
|
*/
|
||||||
|
abstract getTransaction(txId: string): Promise<ITransaction>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a raw transcation that was broadcasted in the network.
|
||||||
|
* @param rawTx Raw transcation
|
||||||
|
* @returns See https://developer.bitcoin.org/reference/rpc/decoderawtransaction.html for reference
|
||||||
|
*/
|
||||||
|
abstract decodeRawTransaction(rawTx: string): Promise<IRawTransaction>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send funds to a specific address.
|
||||||
|
* @param recipient Address of the recipient
|
||||||
|
* @param amount Amount of coins to transfer
|
||||||
|
* @param comment Comment what this transaction is about
|
||||||
|
* @param commentTo Comment on who is receiving it
|
||||||
|
* @param subtractFeeFromAmount The fee will be deducted from the amount being sent
|
||||||
|
* @returns The transcation id
|
||||||
|
*/
|
||||||
|
abstract sendToAddress(
|
||||||
|
recipient: string,
|
||||||
|
amount: number,
|
||||||
|
comment?: string,
|
||||||
|
commentTo?: string,
|
||||||
|
subtractFeeFromAmount?: boolean): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for new transactions by the network.
|
||||||
|
*/
|
||||||
|
abstract listener(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep track of unconfirmed transactions.
|
||||||
|
*/
|
||||||
|
abstract watchConfirmations(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITransaction {
|
||||||
|
amount: number;
|
||||||
|
fee: number;
|
||||||
|
confirmations: number;
|
||||||
|
time: number; // Unix timestamp
|
||||||
|
details: {
|
||||||
|
address: string;
|
||||||
|
category: 'send' | 'receive' | 'generate' | 'immature' | 'orphan'
|
||||||
|
vout: number;
|
||||||
|
fee: number;
|
||||||
|
abandoned: boolean
|
||||||
|
}[];
|
||||||
|
hex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRawTransaction {
|
||||||
|
txid: string;
|
||||||
|
hash: string;
|
||||||
|
size: number;
|
||||||
|
vsize: number;
|
||||||
|
weight: number;
|
||||||
|
version: number;
|
||||||
|
vin: {
|
||||||
|
txid: string;
|
||||||
|
vout: number;
|
||||||
|
}[];
|
||||||
|
vout: {
|
||||||
|
value: number;
|
||||||
|
n: number;
|
||||||
|
scriptPubKey: {
|
||||||
|
addresses: string[];
|
||||||
|
}
|
||||||
|
}[];
|
||||||
|
}
|
||||||
85
src/helper/invoiceManager.ts
Normal file
85
src/helper/invoiceManager.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { IInvoice } from "../models/invoice/invoice.interface";
|
||||||
|
import { Subscriber } from 'zeromq';
|
||||||
|
import { logger, rpcClient, socketManager } from "../app";
|
||||||
|
import { invoiceRouter } from "../routes/invoice";
|
||||||
|
import { Invoice } from "../models/invoice/invoice.model";
|
||||||
|
import { CryptoUnits, PaymentStatus } from "./types";
|
||||||
|
import { config } from "../../config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This invoice manager keeps track of the status of each transaction.
|
||||||
|
*/
|
||||||
|
export class InvoiceManager {
|
||||||
|
private pendingInvoices: IInvoice[];
|
||||||
|
private unconfirmedTranscations: IInvoice[];
|
||||||
|
private knownConfirmations: Map<string, number>; // Invoice id / confirmation count
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.unconfirmedTranscations = [];
|
||||||
|
this.pendingInvoices = [];
|
||||||
|
this.knownConfirmations = new Map<string, number>();
|
||||||
|
|
||||||
|
// Get all pending transcations
|
||||||
|
Invoice.find({ status: PaymentStatus.PENDING }).then(invoices => {
|
||||||
|
this.pendingInvoices = invoices;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all unconfirmed transactions
|
||||||
|
Invoice.find({ status: PaymentStatus.UNCONFIRMED }).then(invoices => {
|
||||||
|
this.unconfirmedTranscations = invoices;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.watchConfirmations();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This will add `invoice` to the pending list.
|
||||||
|
*/
|
||||||
|
addInvoice(invoice: IInvoice) {
|
||||||
|
logger.info(`A new invoice has been created: ${invoice.id}`)
|
||||||
|
this.pendingInvoices.push(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeInvoice(invoice: IInvoice) {
|
||||||
|
this.unconfirmedTranscations.splice(this.unconfirmedTranscations.indexOf(invoice), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrade a pending invoice up to an unconfirmed invoice.
|
||||||
|
*/
|
||||||
|
upgradeInvoice(invoice: IInvoice) {
|
||||||
|
const target = this.pendingInvoices.find(item => { return item.id = invoice.id });
|
||||||
|
if (target !== undefined) {
|
||||||
|
this.pendingInvoices.push(invoice);
|
||||||
|
this.pendingInvoices.splice(this.pendingInvoices.indexOf(invoice), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingInvoices() {
|
||||||
|
return this.pendingInvoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUnconfirmedTransactions() {
|
||||||
|
return this.unconfirmedTranscations;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasConfirmationChanged(invoice: IInvoice, confirmations: number) {
|
||||||
|
return this.knownConfirmations.get(invoice.id) !== confirmations;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfirmationCount(invoice: IInvoice) {
|
||||||
|
return this.knownConfirmations.get(invoice.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfirmationCount(invoice: IInvoice, count: number) {
|
||||||
|
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,134 +0,0 @@
|
|||||||
import { IInvoice } from "../models/invoice/invoice.interface";
|
|
||||||
import { Subscriber } from 'zeromq';
|
|
||||||
import { logger, rpcClient, socketManager } from "../app";
|
|
||||||
import { invoiceRouter } from "../routes/invoice";
|
|
||||||
import { Invoice } from "../models/invoice/invoice.model";
|
|
||||||
import { CryptoUnits, PaymentStatus } from "./types";
|
|
||||||
import { config } from "../../config";
|
|
||||||
|
|
||||||
export class InvoiceScheduler {
|
|
||||||
private pendingInvoices: IInvoice[];
|
|
||||||
private unconfirmedTranscations: IInvoice[];
|
|
||||||
private knownConfirmations: Map<string, number>; // Invoice id / confirmation cound
|
|
||||||
private sock: Subscriber;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.unconfirmedTranscations = [];
|
|
||||||
this.pendingInvoices = [];
|
|
||||||
this.knownConfirmations = new Map<string, number>();
|
|
||||||
|
|
||||||
// Get all pending transcations
|
|
||||||
Invoice.find({ status: PaymentStatus.PENDING }).then(invoices => {
|
|
||||||
this.pendingInvoices = invoices;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all unconfirmed transactions
|
|
||||||
Invoice.find({ status: PaymentStatus.UNCONFIRMED }).then(invoices => {
|
|
||||||
this.unconfirmedTranscations = invoices;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sock = new Subscriber();
|
|
||||||
this.sock.connect('tcp://127.0.0.1:29000');
|
|
||||||
this.listen();
|
|
||||||
this.watchConfirmations();
|
|
||||||
}
|
|
||||||
|
|
||||||
addInvoice(invoice: IInvoice) {
|
|
||||||
logger.info(`A new invoice has been created: ${invoice.id}`)
|
|
||||||
this.pendingInvoices.push(invoice);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function waits for Bitcoin Core to respond with raw TX.
|
|
||||||
*/
|
|
||||||
private async listen() {
|
|
||||||
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');
|
|
||||||
//logger.debug(`New tx: ${rawtx}`);
|
|
||||||
rpcClient.request('decoderawtransaction', [rawtx], (err, decoded) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error(`Error while decoding raw tx: ${err.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded.result.vout.forEach(output => {
|
|
||||||
// Loop over each output and check if the address of one matches the one of an invoice.
|
|
||||||
this.pendingInvoices.forEach(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)
|
|
||||||
if (output.scriptPubKey.addresses.indexOf(invoice.receiveAddress) !== -1) {
|
|
||||||
invoice.paid += output.value;
|
|
||||||
logger.info(`Transcation for invoice ${invoice.id} received! (${decoded.result.hash})`);
|
|
||||||
|
|
||||||
// Change state in database
|
|
||||||
const price = invoice.paymentMethods.find((item) => { return item.method === CryptoUnits.BITCOIN }).amount;
|
|
||||||
if (invoice.paid < price - config.transcations.acceptMargin) {
|
|
||||||
const left = price - output.value;
|
|
||||||
invoice.status = PaymentStatus.PARTIALLY;
|
|
||||||
invoice.save();
|
|
||||||
logger.info(`Transcation for invoice ${invoice.id} received but there are still ${left} BTC missing (${decoded.result.hash})`);
|
|
||||||
} else {
|
|
||||||
invoice.status = PaymentStatus.UNCONFIRMED;
|
|
||||||
invoice.transcationHashes.push(decoded.result.txid);
|
|
||||||
invoice.save();
|
|
||||||
|
|
||||||
// Push to array & remove from pending
|
|
||||||
this.unconfirmedTranscations.push(invoice);
|
|
||||||
this.pendingInvoices.splice(this.pendingInvoices.indexOf(invoice), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This functions loops over each unconfirmed transaction to check if it reached "trusted" threshold.
|
|
||||||
*/
|
|
||||||
private watchConfirmations() {
|
|
||||||
setInterval(() => {
|
|
||||||
this.unconfirmedTranscations.forEach(invoice => {
|
|
||||||
if (invoice.transcationHashes.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];
|
|
||||||
|
|
||||||
rpcClient.request('gettransaction', [transcation], (err, message) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error(`Error while fetching confirmation state of ${transcation}: ${err.message}`);
|
|
||||||
trustworthy = false;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.knownConfirmations.get(invoice.id) != message.result.confirmations) {
|
|
||||||
this.knownConfirmations.set(invoice.id, message.result.confirmations);
|
|
||||||
socketManager.getSocketByInvoice(invoice).emit('confirmationUpdate', { count: Number(message.result.confirmations) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number(message.result.confirmations) > 0) {
|
|
||||||
logger.info(`Transaction (${transcation}) has reached more then 2 confirmations and can now be trusted!`);
|
|
||||||
|
|
||||||
this.unconfirmedTranscations.splice(this.unconfirmedTranscations.indexOf(invoice), 1);
|
|
||||||
} else {
|
|
||||||
trustworthy = false;
|
|
||||||
logger.debug(`Transcation (${transcation}) has not reached his threshold yet.`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trustworthy) {
|
|
||||||
invoice.status = PaymentStatus.DONE;
|
|
||||||
invoice.save(); // This will trigger a post save hook that will notify the user.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 2_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
src/helper/providerManager.ts
Normal file
20
src/helper/providerManager.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { readdirSync } from 'fs';
|
||||||
|
|
||||||
|
export class ProviderManager {
|
||||||
|
|
||||||
|
providerFilePath: string;
|
||||||
|
|
||||||
|
constructor(filePath: string) {
|
||||||
|
this.providerFilePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
scan() {
|
||||||
|
const getDirectories = () =>
|
||||||
|
readdirSync(this.providerFilePath, { withFileTypes: true })
|
||||||
|
.filter(dirent => dirent.isDirectory())
|
||||||
|
.map(dirent => dirent.name)
|
||||||
|
|
||||||
|
console.log(getDirectories());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
154
src/helper/providers/bitcoinCore.ts
Normal file
154
src/helper/providers/bitcoinCore.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Socket, Subscriber } from "zeromq";
|
||||||
|
import { config } from "../../../config";
|
||||||
|
import { 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;
|
||||||
|
|
||||||
|
private sock: Subscriber;
|
||||||
|
|
||||||
|
NAME = 'Bitcoin Core';
|
||||||
|
DESCRIPTION = 'This provider communicates with the Bitcoin Core application.';
|
||||||
|
AUTHOR = 'LibrePay Team';
|
||||||
|
VERSION = '0.1';
|
||||||
|
CRYPTO = CryptoUnits.BITCOIN;
|
||||||
|
|
||||||
|
onEnable() {
|
||||||
|
logger.info('The Bitcoin Core backend is now available!');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNewAddress(): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
rpcClient.request('getnewaddress', ['', 'bech32'], async (err, message) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(message.result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransaction(txId: string): Promise<ITransaction> {
|
||||||
|
return new Promise<ITransaction>((resolve, reject) => {
|
||||||
|
rpcClient.request('gettransaction', [txId], (err, message) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(message.result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async decodeRawTransaction(rawTx: string): Promise<IRawTransaction> {
|
||||||
|
return new Promise<IRawTransaction>((resolve, reject) => {
|
||||||
|
rpcClient.request('decoderawtransaction', [rawTx], (err, decoded) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(decoded.result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendToAddress(
|
||||||
|
recipient: string,
|
||||||
|
amount: number,
|
||||||
|
comment?: string,
|
||||||
|
commentTo?: string,
|
||||||
|
subtractFeeFromAmount?: boolean): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
rpcClient.request('sendtoaddress', [recipient, amount, comment, commentTo, subtractFeeFromAmount], (err, decoded) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(decoded.result.txid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
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)
|
||||||
|
if (output.scriptPubKey.addresses.indexOf(invoice.receiveAddress) !== -1) {
|
||||||
|
const senderAddress = output.scriptPubKey.addresses[output.scriptPubKey.addresses.indexOf(invoice.receiveAddress)];
|
||||||
|
logger.info(`Transcation for invoice ${invoice.id} received! (${tx.hash})`);
|
||||||
|
|
||||||
|
// Change state in database
|
||||||
|
const price = invoice.paymentMethods.find((item) => { return item.method === CryptoUnits.BITCOIN }).amount;
|
||||||
|
if (output.value < price - config.transcations.acceptMargin) {
|
||||||
|
const left = price - output.value;
|
||||||
|
logger.info(`Transcation for invoice ${invoice.id} received but there are ${left} BTC missing (${tx.hash}).`);
|
||||||
|
|
||||||
|
const txBack = await this.sendToAddress(senderAddress, output.value, null, null, true);
|
||||||
|
logger.info(`Sent ${output.value} BTC back to ${senderAddress}`);
|
||||||
|
} else {
|
||||||
|
invoice.status = PaymentStatus.UNCONFIRMED;
|
||||||
|
invoice.transcationHashes = tx.txid;
|
||||||
|
invoice.save();
|
||||||
|
|
||||||
|
this.invoiceManager.upgradeInvoice(invoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async watchConfirmations() {
|
||||||
|
setInterval(() => {
|
||||||
|
this.invoiceManager.getUnconfirmedTransactions().forEach(async invoice => {
|
||||||
|
if (invoice.transcationHashes.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];
|
||||||
|
|
||||||
|
const tx = await this.getTransaction(transcation);
|
||||||
|
|
||||||
|
if (this.invoiceManager.hasConfirmationChanged(invoice, tx.confirmations)) {
|
||||||
|
this.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);
|
||||||
|
} else {
|
||||||
|
trustworthy = false;
|
||||||
|
logger.debug(`Transcation (${transcation}) has not reached his threshold yet.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trustworthy) {
|
||||||
|
invoice.status = PaymentStatus.DONE;
|
||||||
|
invoice.save(); // This will trigger a post save hook that will notify the user.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 2_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,15 +7,8 @@ import { PaymentStatus } from "./types";
|
|||||||
export class SocketManager {
|
export class SocketManager {
|
||||||
io: Server;
|
io: Server;
|
||||||
|
|
||||||
private socketInvoice: Map<string, string>; // Socket ID / _id
|
|
||||||
private idSocket: Map<string, Socket>; // Socket ID / Socket
|
|
||||||
private invoiceSocket: Map<string, Socket>; // _id / Socket
|
|
||||||
|
|
||||||
constructor(io: Server) {
|
constructor(io: Server) {
|
||||||
this.io = io;
|
this.io = io;
|
||||||
this.socketInvoice = new Map<string, string>();
|
|
||||||
this.idSocket = new Map<string, Socket>();
|
|
||||||
this.invoiceSocket = new Map<string, Socket>();
|
|
||||||
this.listen();
|
this.listen();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +16,6 @@ export class SocketManager {
|
|||||||
console.log("Listen");
|
console.log("Listen");
|
||||||
|
|
||||||
this.io.on('connection', (socket: Socket) => {
|
this.io.on('connection', (socket: Socket) => {
|
||||||
this.idSocket.set(socket.id, 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.
|
||||||
socket.on('subscribe', async data => {
|
socket.on('subscribe', async data => {
|
||||||
@@ -36,25 +27,13 @@ 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]})`);
|
||||||
|
|
||||||
this.socketInvoice.set(socket.id, invoice.id);
|
|
||||||
this.invoiceSocket.set(invoice.id, socket);
|
|
||||||
socket.emit('subscribe', true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getSocketById(id: string) {
|
emitInvoiceEvent(invoice: IInvoice, event: string, data: any) {
|
||||||
return this.idSocket.get(id);
|
logger.debug(`Broadcast ${data} to room ${invoice.selector}`);
|
||||||
}
|
this.io.to(invoice.selector).emit(event, data);
|
||||||
|
|
||||||
async getInvoiceBySocket(socketId: string) {
|
|
||||||
const invoiceId = this.socketInvoice.get(socketId);
|
|
||||||
return await Invoice.findById(invoiceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSocketByInvoice(invoice: IInvoice) {
|
|
||||||
return this.invoiceSocket.get(invoice.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,11 @@ export enum FiatUnits {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum PaymentStatus {
|
export enum PaymentStatus {
|
||||||
|
/**
|
||||||
|
* The invoice has been requested but the payment method has to be choosen.
|
||||||
|
*/
|
||||||
|
REQUESTED = -1,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The payment has not been yet started. The user did not initiated the transfer.
|
* The payment has not been yet started. The user did not initiated the transfer.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,29 +17,26 @@ export interface IInvoice extends Document {
|
|||||||
selector: string;
|
selector: string;
|
||||||
|
|
||||||
// Available payment methods
|
// Available payment methods
|
||||||
// [{ method: 'btc', amount: 0.0000105 }]
|
// { method: 'btc', amount: 0.0000105 }
|
||||||
paymentMethods: IPaymentMethod[];
|
paymentMethods: IPaymentMethod[];
|
||||||
|
|
||||||
|
// This is the method choosen by the user
|
||||||
|
paymentMethod?: CryptoUnits;
|
||||||
|
|
||||||
|
// Will be created as soon as the user picked one options
|
||||||
// 1Kss3e9iPB9vTgWJJZ1SZNkkFKcFJXPz9t
|
// 1Kss3e9iPB9vTgWJJZ1SZNkkFKcFJXPz9t
|
||||||
receiveAddress: string;
|
receiveAddress?: string;
|
||||||
|
|
||||||
paidWith?: CryptoUnits;
|
|
||||||
|
|
||||||
// Already paid amount, in case that not the entire amount was paid with once.
|
|
||||||
// 0.000013
|
|
||||||
paid?: number;
|
|
||||||
|
|
||||||
// Is set when invoice got paid
|
// Is set when invoice got paid
|
||||||
// 3b38c3a215d4e7981e1516b2dcbf76fca58911274d5d55b3d615274d6e10f2c1
|
// 3b38c3a215d4e7981e1516b2dcbf76fca58911274d5d55b3d615274d6e10f2c1
|
||||||
transcationHashes?: string[];
|
transcationHashes?: string;
|
||||||
|
|
||||||
cart?: ICart[];
|
cart?: ICart[];
|
||||||
totalPrice?: number;
|
totalPrice?: number;
|
||||||
currency: FiatUnits;
|
currency: FiatUnits;
|
||||||
|
|
||||||
// Time in minutes the user has to pay.
|
// Datetime the user has to pay.
|
||||||
// Time left = (createdAt + dueBy) - Date.now() / 1000
|
dueBy: Date;
|
||||||
dueBy: number;
|
|
||||||
|
|
||||||
status?: PaymentStatus;
|
status?: PaymentStatus;
|
||||||
|
|
||||||
|
|||||||
@@ -20,15 +20,14 @@ const schemaPaymentMethods = new Schema({
|
|||||||
const schemaInvoice = new Schema({
|
const schemaInvoice = new Schema({
|
||||||
selector: { type: String, length: 128, required: true },
|
selector: { type: String, length: 128, required: true },
|
||||||
paymentMethods: [{ type: schemaPaymentMethods, required: true }],
|
paymentMethods: [{ type: schemaPaymentMethods, required: true }],
|
||||||
receiveAddress: { type: String, required: true },
|
paymentMethod: { type: String, enum: Object.values(CryptoUnits), required: false },
|
||||||
paidWith: { type: String, enum: CryptoUnits },
|
receiveAddress: { type: String, required: false },
|
||||||
paid: { type: Number, default: 0 },
|
transcationHashes: { type: String, required: false },
|
||||||
transcationHashes: [{ 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 },
|
||||||
dueBy: { type: Number, required: true },
|
dueBy: { type: Date, required: true },
|
||||||
status: { type: Number, enum: Object.values(PaymentStatus), default: PaymentStatus.PENDING },
|
status: { type: Number, enum: Object.values(PaymentStatus), default: PaymentStatus.REQUESTED },
|
||||||
email: { type: String, required: false },
|
email: { type: String, required: false },
|
||||||
successUrl: { type: String, match: urlRegex, required: false },
|
successUrl: { type: String, match: urlRegex, required: false },
|
||||||
cancelUrl: { type: String, match: urlRegex, required: false }
|
cancelUrl: { type: String, match: urlRegex, required: false }
|
||||||
@@ -62,8 +61,7 @@ schemaInvoice.post('validate', function (doc, next) {
|
|||||||
schemaInvoice.post('save', function(doc, next) {
|
schemaInvoice.post('save', function(doc, next) {
|
||||||
let self = this as IInvoice;
|
let self = this as IInvoice;
|
||||||
|
|
||||||
if (socketManager.getSocketByInvoice(self) === undefined) return;
|
socketManager.emitInvoiceEvent(self, 'status', self.status);
|
||||||
socketManager.getSocketByInvoice(self).emit('status', self.status);
|
|
||||||
next();
|
next();
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { createInvoice, getInvoice } from "../controllers/invoice";
|
import { createInvoice, getInvoice, getPaymentMethods } 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('/:selector', getInvoice);
|
invoiceRouter.get('/:selector', getInvoice);
|
||||||
invoiceRouter.get('/', MW_User, getInvoice);
|
invoiceRouter.get('/', MW_User, getInvoice);
|
||||||
invoiceRouter.post('/', MW_User, createInvoice);
|
invoiceRouter.post('/', MW_User, createInvoice);
|
||||||
|
|||||||
Reference in New Issue
Block a user