New abstract structure
Events for invoices get emitted in rooms
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
# Socket events
|
||||
|
||||
## Invoice status
|
||||
### Requests
|
||||
* `subscribe` - Subscribe to a invoices progress. Returns `true` if successful.
|
||||
* `selector` - Your selector
|
||||
|
||||
In order to receive updates about the a specific invoice, **you have to join the room with the selector.**
|
||||
### Events
|
||||
* `status` - Status changed (see PaymentStatus enum)
|
||||
* `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.
|
||||
*
|
||||
@@ -18,6 +19,14 @@ export const config: IConfig = {
|
||||
transcations: {
|
||||
// If a payment has been made and its value is this amount less, it would be still accepted.
|
||||
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: {
|
||||
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 winston from 'winston';
|
||||
import * as socketio from 'socket.io';
|
||||
import { resolve } from 'path';
|
||||
import { Server } from 'http';
|
||||
|
||||
import { config } from '../config';
|
||||
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 { invoiceRouter } from './routes/invoice';
|
||||
import { userRouter } from './routes/user';
|
||||
import { SocketManager } from './helper/socketio';
|
||||
import { ProviderManager } from './helper/providerManager';
|
||||
|
||||
// Load .env
|
||||
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 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 logger: winston.Logger;
|
||||
@@ -106,7 +108,10 @@ async function run() {
|
||||
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 http = new Server(app);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import got from 'got';
|
||||
import { config } from '../../config';
|
||||
|
||||
import { invoiceScheduler, INVOICE_SECRET, rpcClient } from '../app';
|
||||
import { randomString } from '../helper/crypto';
|
||||
@@ -62,53 +63,55 @@ export async function createInvoice(req: Request, res: Response) {
|
||||
return;
|
||||
}
|
||||
|
||||
rpcClient.request('getnewaddress', ['', 'bech32'], async (err, response) => {
|
||||
if (err) throw err;
|
||||
// Get price
|
||||
// Convert coin symbol to full text in order to query Coin Gecko. eg.: ['btc', 'xmr'] => ['bitcoin', 'monero']
|
||||
let cgFormat = [];
|
||||
|
||||
// Get price
|
||||
// Convert coin symbol to full text in order to query Coin Gecko. eg.: ['btc', 'xmr'] => ['bitcoin', 'monero']
|
||||
let cgFormat = [];
|
||||
paymentMethodsRaw.forEach(coin => {
|
||||
const crypto = findCryptoBySymbol(coin);
|
||||
|
||||
paymentMethodsRaw.forEach(coin => {
|
||||
const crypto = findCryptoBySymbol(coin);
|
||||
if (crypto !== undefined) {
|
||||
cgFormat.push(crypto.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
if (crypto !== undefined) {
|
||||
cgFormat.push(crypto.toLowerCase());
|
||||
}
|
||||
});
|
||||
const request = await got.get(`https://api.coingecko.com/api/v3/simple/price?ids=${cgFormat.join(',')}&vs_currencies=${currency.toLowerCase()}`, {
|
||||
responseType: 'json'
|
||||
});
|
||||
|
||||
const request = await got.get(`https://api.coingecko.com/api/v3/simple/price?ids=${cgFormat.join(',')}&vs_currencies=${currency.toLowerCase()}`, {
|
||||
responseType: 'json'
|
||||
});
|
||||
// Calulate total price, if cart is provided
|
||||
if (cart !== undefined && totalPrice === undefined) {
|
||||
totalPrice = calculateCart(cart);
|
||||
}
|
||||
|
||||
// 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()]) });
|
||||
});
|
||||
|
||||
const dueBy = new Date(Date.now() + 1000 * 60 * 60);
|
||||
|
||||
Invoice.create({
|
||||
selector: randomString(128),
|
||||
paymentMethods,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
cart,
|
||||
currency,
|
||||
totalPrice,
|
||||
dueBy
|
||||
}, (error, invoice: IInvoice) => {
|
||||
if (error) {
|
||||
res.status(500).send({error: error.message});
|
||||
return;
|
||||
}
|
||||
|
||||
let paymentMethods: IPaymentMethod[] = [];
|
||||
Object.keys(request.body).forEach(coin => {
|
||||
paymentMethods.push({ method: CryptoUnits[coin.toUpperCase()], amount: totalPrice / Number(request.body[coin][currency.toLowerCase()]) });
|
||||
});
|
||||
|
||||
Invoice.create({
|
||||
selector: randomString(128),
|
||||
paymentMethods: paymentMethods,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
cart,
|
||||
currency,
|
||||
totalPrice,
|
||||
dueBy: 60,
|
||||
receiveAddress: response.result
|
||||
}, (error, invoice: IInvoice) => {
|
||||
if (error) {
|
||||
res.status(500).send({error: error.message});
|
||||
return;
|
||||
}
|
||||
|
||||
invoiceScheduler.addInvoice(invoice);
|
||||
res.status(200).send({ id: invoice.selector });
|
||||
//invoiceScheduler.addInvoice(invoice);
|
||||
//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
|
||||
export async function cancelPaymnet(req: Request, res: Response) {
|
||||
export async function cancelInvoice(req: Request, res: Response) {
|
||||
const selector = req.params.selector;
|
||||
|
||||
// If an id is provided
|
||||
@@ -183,3 +186,7 @@ export async function cancelPaymnet(req: Request, res: Response) {
|
||||
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 {
|
||||
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) {
|
||||
this.io = io;
|
||||
this.socketInvoice = new Map<string, string>();
|
||||
this.idSocket = new Map<string, Socket>();
|
||||
this.invoiceSocket = new Map<string, Socket>();
|
||||
this.listen();
|
||||
}
|
||||
|
||||
@@ -23,8 +16,6 @@ export class SocketManager {
|
||||
console.log("Listen");
|
||||
|
||||
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.
|
||||
// Return `true` if successful and `false` if not.
|
||||
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]})`);
|
||||
|
||||
this.socketInvoice.set(socket.id, invoice.id);
|
||||
this.invoiceSocket.set(invoice.id, socket);
|
||||
socket.emit('subscribe', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getSocketById(id: string) {
|
||||
return this.idSocket.get(id);
|
||||
}
|
||||
|
||||
async getInvoiceBySocket(socketId: string) {
|
||||
const invoiceId = this.socketInvoice.get(socketId);
|
||||
return await Invoice.findById(invoiceId);
|
||||
}
|
||||
|
||||
getSocketByInvoice(invoice: IInvoice) {
|
||||
return this.invoiceSocket.get(invoice.id);
|
||||
emitInvoiceEvent(invoice: IInvoice, event: string, data: any) {
|
||||
logger.debug(`Broadcast ${data} to room ${invoice.selector}`);
|
||||
this.io.to(invoice.selector).emit(event, data);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,11 @@ export enum FiatUnits {
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -17,29 +17,26 @@ export interface IInvoice extends Document {
|
||||
selector: string;
|
||||
|
||||
// Available payment methods
|
||||
// [{ method: 'btc', amount: 0.0000105 }]
|
||||
// { method: 'btc', amount: 0.0000105 }
|
||||
paymentMethods: IPaymentMethod[];
|
||||
|
||||
// This is the method choosen by the user
|
||||
paymentMethod?: CryptoUnits;
|
||||
|
||||
// Will be created as soon as the user picked one options
|
||||
// 1Kss3e9iPB9vTgWJJZ1SZNkkFKcFJXPz9t
|
||||
receiveAddress: string;
|
||||
|
||||
paidWith?: CryptoUnits;
|
||||
|
||||
// Already paid amount, in case that not the entire amount was paid with once.
|
||||
// 0.000013
|
||||
paid?: number;
|
||||
receiveAddress?: string;
|
||||
|
||||
// Is set when invoice got paid
|
||||
// 3b38c3a215d4e7981e1516b2dcbf76fca58911274d5d55b3d615274d6e10f2c1
|
||||
transcationHashes?: string[];
|
||||
transcationHashes?: string;
|
||||
|
||||
cart?: ICart[];
|
||||
totalPrice?: number;
|
||||
currency: FiatUnits;
|
||||
|
||||
// Time in minutes the user has to pay.
|
||||
// Time left = (createdAt + dueBy) - Date.now() / 1000
|
||||
dueBy: number;
|
||||
// Datetime the user has to pay.
|
||||
dueBy: Date;
|
||||
|
||||
status?: PaymentStatus;
|
||||
|
||||
|
||||
@@ -20,15 +20,14 @@ const schemaPaymentMethods = new Schema({
|
||||
const schemaInvoice = new Schema({
|
||||
selector: { type: String, length: 128, required: true },
|
||||
paymentMethods: [{ type: schemaPaymentMethods, required: true }],
|
||||
receiveAddress: { type: String, required: true },
|
||||
paidWith: { type: String, enum: CryptoUnits },
|
||||
paid: { type: Number, default: 0 },
|
||||
transcationHashes: [{ type: String, required: false }],
|
||||
paymentMethod: { type: String, enum: Object.values(CryptoUnits), required: false },
|
||||
receiveAddress: { type: String, required: false },
|
||||
transcationHashes: { type: String, required: false },
|
||||
cart: [{ type: schemaCart, required: false }],
|
||||
totalPrice: { type: Number, required: false },
|
||||
currency: { type: String, enum: Object.values(FiatUnits), required: true },
|
||||
dueBy: { type: Number, required: true },
|
||||
status: { type: Number, enum: Object.values(PaymentStatus), default: PaymentStatus.PENDING },
|
||||
dueBy: { type: Date, required: true },
|
||||
status: { type: Number, enum: Object.values(PaymentStatus), default: PaymentStatus.REQUESTED },
|
||||
email: { type: String, required: false },
|
||||
successUrl: { 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) {
|
||||
let self = this as IInvoice;
|
||||
|
||||
if (socketManager.getSocketByInvoice(self) === undefined) return;
|
||||
socketManager.getSocketByInvoice(self).emit('status', self.status);
|
||||
socketManager.emitInvoiceEvent(self, 'status', self.status);
|
||||
next();
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import { createInvoice, getInvoice } from "../controllers/invoice";
|
||||
import { createInvoice, getInvoice, getPaymentMethods } from "../controllers/invoice";
|
||||
import { MW_User } from "../controllers/user";
|
||||
|
||||
const invoiceRouter = Router()
|
||||
|
||||
invoiceRouter.get('/paymentmethods', getPaymentMethods);
|
||||
invoiceRouter.get('/:selector', getInvoice);
|
||||
invoiceRouter.get('/', MW_User, getInvoice);
|
||||
invoiceRouter.post('/', MW_User, createInvoice);
|
||||
|
||||
Reference in New Issue
Block a user