From b356f3ee70431c6e7761af1c958391b4724be3ca Mon Sep 17 00:00:00 2001 From: Mondei1 Date: Fri, 1 Jan 2021 19:39:38 +0100 Subject: [PATCH] Proper handling of unequal payments - Cryptocurrencies are enabled dynamically - Invoice handler handles now more - Better status change handling --- config.ts | 10 +- src/app.ts | 1 + src/controllers/invoice.ts | 63 +++++---- src/helper/backendProvider.ts | 38 ++++- src/helper/invoiceManager.ts | 178 +++++++++++++++++++++--- src/helper/providerManager.ts | 2 + src/helper/providers/bitcoinCore.ts | 89 ++++++------ src/helper/socketio.ts | 2 +- src/helper/types.ts | 26 +++- src/models/invoice/invoice.interface.ts | 3 + src/models/invoice/invoice.schema.ts | 10 +- src/routes/invoice.ts | 3 +- 12 files changed, 305 insertions(+), 120 deletions(-) diff --git a/config.ts b/config.ts index 4ddbc1c..4704062 100644 --- a/config.ts +++ b/config.ts @@ -21,13 +21,9 @@ export const config: IConfig = { acceptMargin: 0.00000001 }, payment: { - // This is a list of cryptocurrencies that you want to accpet. - methods: [ - CryptoUnits.BITCOIN, - CryptoUnits.DOGECOIN, - CryptoUnits.ETHEREUM, - CryptoUnits.MONERO - ] + // This has to stay empty since it will be filled automatically in runtime. + // If you want to accept a specifc cryptocurrency, add a provider in src/helper/providers + methods: [] } } /** diff --git a/src/app.ts b/src/app.ts index ae77538..39ce229 100644 --- a/src/app.ts +++ b/src/app.ts @@ -37,6 +37,7 @@ async function run() { const { combine, timestamp, label, printf, prettyPrint } = winston.format; const myFormat = printf(({ level, message, label, timestamp }) => { + if (label !== undefined) return `${timestamp} ${level} (${label}) ${message}`; return `${timestamp} ${level} ${message}`; }); diff --git a/src/controllers/invoice.ts b/src/controllers/invoice.ts index fe6635f..6ec3e6f 100644 --- a/src/controllers/invoice.ts +++ b/src/controllers/invoice.ts @@ -90,7 +90,7 @@ export async function createInvoice(req: Request, res: Response) { }); }); - const dueBy = new Date(Date.now() + 1000 * 60 * 60); + const dueBy = new Date(Date.now() + 1000 * 60 * 15); Invoice.create({ selector: randomString(128), @@ -131,23 +131,7 @@ export async function getInvoice(req: Request, res: Response) { return; } - if(invoice.status === PaymentStatus.UNCONFIRMED || invoice.status === PaymentStatus.DONE) { - const transaction = await providerManager.getProvider(invoice.paymentMethod).getTransaction(invoice.transcationHash); - try { - let invoiceClone: any = invoice; - console.log(transaction.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); - } + res.status(200).send(invoice); return; } @@ -176,22 +160,43 @@ export async function getInvoice(req: Request, res: Response) { res.status(200).send(invoices); } +// GET /invoice/:selector/confirmation +export async function getConfirmation(req: Request, res: Response) { + const selector = req.params.selector; + + const invoice = await Invoice.findOne({ selector: selector }); + if (invoice === null) { + res.status(404).send(); + return; + } + + if (invoice.status !== PaymentStatus.UNCONFIRMED) { + res.status(400).send({ message: 'This has no unconfirmed transaction (yet)!' }); + return; + } + + try { + const confirmation = (await providerManager.getProvider(invoice.paymentMethod).getTransaction(invoice.transcationHash)).confirmations; + res.status(200).send({ confirmation }); + } catch (err) { + res.status(500).send(); + logger.error(`Error while getting confirmations for: ${invoice.transcationHash}`); + } +} + // DELETE /invoice/:selector export async function cancelInvoice(req: Request, res: Response) { const selector = req.params.selector; - // If an id is provided - if (selector !== undefined) { - const invoice = await Invoice.findOne({ selector: selector }); - if (invoice === null) { - res.status(404).send(); - return; - } - - invoice.status = PaymentStatus.CANCELLED; - await invoice.save(); + const invoice = await Invoice.findOne({ selector: selector }); + if (invoice === null) { + res.status(404).send(); return; } + + invoice.status = PaymentStatus.CANCELLED; + await invoice.save(); + return; } // POST /invoice/:selector/setmethod @@ -221,6 +226,8 @@ export async function setPaymentMethod(req: Request, res: Response) { invoice.receiveAddress = await providerManager.getProvider(invoice.paymentMethod).getNewAddress(); await invoice.save(); + + invoiceManager.addInvoice(invoice) res.status(200).send({ receiveAddress: invoice.receiveAddress diff --git a/src/helper/backendProvider.ts b/src/helper/backendProvider.ts index 5605333..64fe3d8 100644 --- a/src/helper/backendProvider.ts +++ b/src/helper/backendProvider.ts @@ -1,3 +1,4 @@ +import { IInvoice } from '../models/invoice/invoice.interface'; import { InvoiceManager } from './invoiceManager'; import { CryptoUnits } from './types'; @@ -68,6 +69,26 @@ export abstract class BackendProvider { * Keep track of unconfirmed transactions. */ abstract watchConfirmations(): void; + + /** + * Provided is an array with pending invoices that have to be check. + * + * **Note:** It can happen that you'll get an invoice that is not + * intended for your cryptocurrency. Please check if invoice is + * made for your cryptocurrency. + * + * *Mainly used when LibrePay starts.* + */ + abstract validateInvoices(invoices: IInvoice[]): void; +} + +export interface ITransactionDetails { + address: string; + category: 'send' | 'receive' | 'generate' | 'immature' | 'orphan' + vout: number; + fee: number; + amount: number; + abandoned: boolean } export interface ITransaction { @@ -75,16 +96,19 @@ export interface ITransaction { fee: number; confirmations: number; time: number; // Unix timestamp - details: { - address: string; - category: 'send' | 'receive' | 'generate' | 'immature' | 'orphan' - vout: number; - fee: number; - abandoned: boolean - }[]; + details: ITransactionDetails[]; hex: string; } +// Special interface for RPC call `listreceivedbyaddress` +export interface ITransactionList { + address: string; + amount: number; + confirmation: number; + label: string; + txids: string[]; +} + export interface IRawTransaction { txid: string; hash: string; diff --git a/src/helper/invoiceManager.ts b/src/helper/invoiceManager.ts index c3da691..7c71faa 100644 --- a/src/helper/invoiceManager.ts +++ b/src/helper/invoiceManager.ts @@ -1,10 +1,7 @@ -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"; +import { logger, providerManager, socketManager } from '../app'; +import { IInvoice } from '../models/invoice/invoice.interface'; +import { Invoice } from '../models/invoice/invoice.model'; +import { CryptoUnits, PaymentStatus } from './types'; /** * This invoice manager keeps track of the status of each transaction. @@ -21,27 +18,58 @@ export class InvoiceManager { // Get all pending transcations Invoice.find({ status: PaymentStatus.PENDING }).then(invoices => { - console.log('These are pending', invoices); - - this.pendingInvoices = invoices; + logger.info(`There are ${invoices.length} invoices pending`); + providerManager.getProvider(CryptoUnits.BITCOIN).validateInvoices(invoices); }); // Get all unconfirmed transactions Invoice.find({ status: PaymentStatus.UNCONFIRMED }).then(invoices => { - this.unconfirmedTranscations = invoices; + logger.info(`There are ${invoices.length} invoices unconfirmed`); + providerManager.getProvider(CryptoUnits.BITCOIN).validateInvoices(invoices); }); + + this.expireScheduler(); + } + + /** + * This function is basicly close all invoices that have not been paid in time. + */ + private expireScheduler() { + setInterval(async () => { + const expiredInvoices = await Invoice.find({ + dueBy: { $lte: new Date() }, + $or: [ { status: PaymentStatus.PENDING }, { status: PaymentStatus.REQUESTED } ] + }); + + expiredInvoices.forEach(async eInvoice => { + eInvoice.status = PaymentStatus.TOOLATE; + await eInvoice.save(); + }); + + }, 5_000); } /** * This will add `invoice` to the pending list. + * @param upgrade If `true` then this invoice will be directly added to the unconfirmed invoices. */ - addInvoice(invoice: IInvoice) { - logger.info(`A new invoice has been created: ${invoice.id}`) - this.pendingInvoices.push(invoice); + addInvoice(invoice: IInvoice, upgrade?: boolean) { + // Avoid duplicates + this.removeInvoice(invoice); + + if (upgrade) { + logger.info(`A new unconfirmed invoice has been created: ${invoice.id}`) + this.pendingInvoices.push(invoice); + this.upgradeInvoice(invoice); + } else { + 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); + if (this.unconfirmedTranscations.indexOf(invoice) != -1) this.unconfirmedTranscations.splice(this.unconfirmedTranscations.indexOf(invoice), 1); + if (this.pendingInvoices.indexOf(invoice) != -1) this.pendingInvoices.splice(this.pendingInvoices.indexOf(invoice), 1); } /** @@ -50,9 +78,15 @@ export class InvoiceManager { upgradeInvoice(invoice: IInvoice) { const target = this.pendingInvoices.find(item => { return item.id = invoice.id }); if (target !== undefined) { - this.pendingInvoices.push(invoice); + this.unconfirmedTranscations.push(invoice); this.pendingInvoices.splice(this.pendingInvoices.indexOf(invoice), 1); + } else { + this.unconfirmedTranscations.push(invoice); } + + this.knownConfirmations.set(invoice.id, 0); + invoice.status = PaymentStatus.UNCONFIRMED; + invoice.save(); } getPendingInvoices() { @@ -63,6 +97,20 @@ export class InvoiceManager { return this.unconfirmedTranscations; } + /** + * This will return you the price in the choosen cryptocurrency. + * + * If no payment methods has been choosen yet, you'll get `0` back. + */ + getPriceByInvoice(invoice: IInvoice): number { + if (invoice.paymentMethod === undefined) return 0; + return invoice.paymentMethods.find(method => { return method.method === invoice.paymentMethod }).amount; + } + + /** + * @param confirmations Your confirmation count + * @returns If yours is different then what the manager knows, this function returns `true`. + */ hasConfirmationChanged(invoice: IInvoice, confirmations: number) { return this.knownConfirmations.get(invoice.id) !== confirmations; } @@ -71,8 +119,100 @@ export class InvoiceManager { return this.knownConfirmations.get(invoice.id); } - setConfirmationCount(invoice: IInvoice, count: number) { - socketManager.emitInvoiceEvent(invoice, 'confirmationUpdate', { count }); - return this.knownConfirmations.set(invoice.id, count); + /** + * Notify LibrePay about a confirmation change. If something changed, the user will be notfied. + * + * If the confirmation count is treated as "trusted", then the invoice will be completed. + */ + async setConfirmationCount(invoice: IInvoice, count: number) { + if (this.hasConfirmationChanged(invoice, count)) { + this.knownConfirmations.set(invoice.id, count); + socketManager.emitInvoiceEvent(invoice, 'confirmationUpdate', { count }); + + if (count > 2) { + logger.info(`Transaction (${invoice.transcationHash}) has reached more then 2 confirmations and can now be trusted!`); + const sentFunds = (await providerManager.getProvider(invoice.paymentMethod).getTransaction(invoice.transcationHash)).amount; + + // This transaction sent more then requested funds + if (sentFunds > this.getPriceByInvoice(invoice)) { + invoice.status = PaymentStatus.TOOMUCH; + } else { + invoice.status = PaymentStatus.DONE; + } + + await invoice.save(); // This will trigger a post save hook that will notify the user. + this.removeInvoice(invoice); + } + } + } + + /** + * This method checks if a payment has been made in time and that the right amount was sent. + */ + async validatePayment(invoice: IInvoice, tx: string): Promise { + if (invoice.dueBy.getTime() < Date.now()) { + invoice.status = PaymentStatus.TOOLATE; + await invoice.save(); + + return; // Payment is too late + } + + const txInfo = await providerManager.getProvider(invoice.paymentMethod).getTransaction(tx); + const receivedTranscation = txInfo.details.find(detail => { + return (detail.address == invoice.receiveAddress && detail.amount > 0); // We only want receiving transactions + }); + + const price = this.getPriceByInvoice(invoice); + if (price === undefined) return; + + // Transaction sent enough funds + if (receivedTranscation.amount == price || receivedTranscation.amount > price) { + invoice.transcationHash = tx; + await invoice.save(); + + this.upgradeInvoice(invoice); + } else { + /* **Note** + * Sending funds back is complicated since we can never know who the original sender was to 100%. + * For Bitcoin, a transaction can have more then one input and if this is the case, you can never + * know who the original sender was. Therefore if a customer sent not the right amount, he/she + * should contact the support of the shop. + */ + logger.warning(`Transaction (${tx}) did not sent requested funds. (sent: ${receivedTranscation.amount} BTC, requested: ${price} BTC)`); + invoice.status = PaymentStatus.TOOLITTLE; + this.removeInvoice(invoice); + + await invoice.save(); + + return; + + // This is dead code and only exists because I'm yet unsure what to do with such payments. + let sendBack = receivedTranscation.amount; + + // If the amount was too much, mark invoice as paid and try to send remaining funds back. + if (receivedTranscation.amount > price) { + sendBack = price - txInfo.amount; + + // Sent amount was too much but technically the bill is paid (will get saved in upgradeInvoice) + invoice.transcationHash = tx; + + this.upgradeInvoice(invoice); + } + + // We only have one input, we can be sure that the sender will receive the funds + if (txInfo.details.length === 1) { + if (txInfo.details[0].address.length !== 1) return; + + const txBack = await providerManager.getProvider(invoice.paymentMethod).sendToAddress(txInfo.details[0].address[0], receivedTranscation.amount, null, null, true); + logger.info(`Sent ${receivedTranscation.amount} ${invoice.paymentMethod} back to ${txInfo.details[0].address[0]}: ${txBack}`); + } else { + // If we cannot send the funds back, save transaction id and mark invoice as failed. + invoice.transcationHash = tx; + invoice.status = PaymentStatus.TOOLITTLE; + this.removeInvoice(invoice); + + await invoice.save(); + } + } } } \ No newline at end of file diff --git a/src/helper/providerManager.ts b/src/helper/providerManager.ts index 893b714..59b6004 100644 --- a/src/helper/providerManager.ts +++ b/src/helper/providerManager.ts @@ -1,5 +1,6 @@ import { readdirSync } from 'fs'; import { join } from 'path'; +import { config } from '../../config'; import { invoiceManager, logger } from '../app'; import { BackendProvider } from './backendProvider'; import { CryptoUnits } from './types'; @@ -38,6 +39,7 @@ export class ProviderManager { } this.cryptoProvider.set(provider.CRYPTO, provider); + config.payment.methods.push(provider.CRYPTO); // Execute onEnable() function of this provider provider.onEnable(); diff --git a/src/helper/providers/bitcoinCore.ts b/src/helper/providers/bitcoinCore.ts index 6f11200..ba10cef 100644 --- a/src/helper/providers/bitcoinCore.ts +++ b/src/helper/providers/bitcoinCore.ts @@ -1,9 +1,10 @@ -import { Socket, Subscriber } from "zeromq"; -import { config } from "../../../config"; -import { invoiceManager, logger, rpcClient } from "../../app"; -import { BackendProvider, ITransaction, IRawTransaction } from "../backendProvider"; -import { InvoiceManager } from "../invoiceManager"; -import { CryptoUnits, PaymentStatus } from "../types"; +import { Subscriber } from 'zeromq'; + +import { config } from '../../../config'; +import { invoiceManager, logger, rpcClient } from '../../app'; +import { IInvoice } from '../../models/invoice/invoice.interface'; +import { BackendProvider, IRawTransaction, ITransaction, ITransactionDetails, ITransactionList } from '../backendProvider'; +import { CryptoUnits, PaymentStatus } from '../types'; export class Provider implements BackendProvider { @@ -77,7 +78,9 @@ export class Provider implements BackendProvider { reject(err); return; } - + + console.log('sendToAddress:', decoded.result); + resolve(decoded.result.txid); }); }); @@ -89,31 +92,20 @@ export class Provider implements BackendProvider { const rawtx = msg.toString('hex'); const tx = await this.decodeRawTransaction(rawtx); - 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. - invoiceManager.getPendingInvoices().forEach(async invoice => { + invoiceManager.getPendingInvoices().forEach(async invoice => { if (output.scriptPubKey.addresses === undefined) return; // Sometimes (weird) transaction don't have any addresses + logger.debug(`${output.scriptPubKey.addresses} <-> ${invoice.receiveAddress}`); // 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.transcationHash = tx.txid; - invoice.save(); - - invoiceManager.upgradeInvoice(invoice); - } + invoiceManager.validatePayment(invoice, tx.txid); } }) }); @@ -125,32 +117,35 @@ export class Provider implements BackendProvider { setInterval(() => { 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.transcationHash.length; i++) { - const transcation = invoice.transcationHash; - - const tx = await this.getTransaction(transcation); - - 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!`); - 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. - } + const transcation = invoice.transcationHash; + + const tx = await this.getTransaction(transcation); + invoiceManager.setConfirmationCount(invoice, tx.confirmations); }); }, 2_000); } + + async validateInvoices(invoices: IInvoice[]) { + invoices.forEach(async invoice => { + if (invoice.status === PaymentStatus.DONE || invoice.status === PaymentStatus.CANCELLED) return; + if (invoice.paymentMethod !== CryptoUnits.BITCOIN) return; + + rpcClient.request('listreceivedbyaddress', [0, false, false, invoice.receiveAddress], async (err, message) => { + if (err) { + logger.error(`There was an error while getting transcations of address ${invoice.receiveAddress}: ${err.message}`); + return; + } + + const res = message.result[0] as ITransactionList; + if (res === undefined) return; + + console.log(res); + + res.txids.forEach(async tx => { + invoiceManager.validatePayment(invoice, tx); + }); + }); + }); + } } diff --git a/src/helper/socketio.ts b/src/helper/socketio.ts index a2b7a1f..f92d83c 100644 --- a/src/helper/socketio.ts +++ b/src/helper/socketio.ts @@ -47,7 +47,7 @@ export class SocketManager { } emitInvoiceEvent(invoice: IInvoice, event: string, data: any) { - logger.debug(`Broadcast ${data} to room ${invoice.selector}`); + logger.debug(`Broadcast ${JSON.stringify(data)} to room ${invoice.selector}`); this.io.to(invoice.selector).emit(event, data); } } \ No newline at end of file diff --git a/src/helper/types.ts b/src/helper/types.ts index 53646fd..5a45402 100644 --- a/src/helper/types.ts +++ b/src/helper/types.ts @@ -36,30 +36,46 @@ export enum FiatUnits { } export enum PaymentStatus { + + /** + * The payment has failed because the amount that was sent is less then requested. + */ + TOOLITTLE = -3, + + /** + * The payment has failed because the payment has been issued too late. + */ + TOOLATE = -2, + /** * The payment has been cancelled by the user. */ - CANCELLED = -2, + CANCELLED = -1, /** * The invoice has been requested but the payment method has to be choosen. */ - REQUESTED = -1, + REQUESTED = 0, /** * The payment has not been yet started. The user did not initiated the transfer. */ - PENDING = 0, + PENDING = 1, /** * The payment has been made but it's not yet confirmed. */ - UNCONFIRMED = 1, + UNCONFIRMED = 2, /** * The payment is completed and the crypto is now available. */ - DONE = 2, + DONE = 3, + + /** + * The payment is completed and the crypto is now available but the customer paid too much. + */ + TOOMUCH = 4 } // I'll will just leave that here diff --git a/src/models/invoice/invoice.interface.ts b/src/models/invoice/invoice.interface.ts index aced397..9b38294 100644 --- a/src/models/invoice/invoice.interface.ts +++ b/src/models/invoice/invoice.interface.ts @@ -31,6 +31,9 @@ export interface IInvoice extends Document { // 3b38c3a215d4e7981e1516b2dcbf76fca58911274d5d55b3d615274d6e10f2c1 transcationHash?: string; + // Is provided when transaction is unconfirmed + confirmation?: number; + cart?: ICart[]; totalPrice?: number; currency: FiatUnits; diff --git a/src/models/invoice/invoice.schema.ts b/src/models/invoice/invoice.schema.ts index 963043b..18edc77 100644 --- a/src/models/invoice/invoice.schema.ts +++ b/src/models/invoice/invoice.schema.ts @@ -58,12 +58,12 @@ schemaInvoice.post('validate', function (doc, next) { next(); }); -schemaInvoice.post('save', function(doc, next) { - let self = this as IInvoice; - - socketManager.emitInvoiceEvent(self, 'status', self.status); +function updateStatus(doc: IInvoice, next) { + socketManager.emitInvoiceEvent(doc, 'status', doc.status); next(); -}) +} + +schemaInvoice.post('save', updateStatus); export function calculateCart(cart: ICart[]): number { let totalPrice = 0; diff --git a/src/routes/invoice.ts b/src/routes/invoice.ts index 8908324..69446af 100644 --- a/src/routes/invoice.ts +++ b/src/routes/invoice.ts @@ -1,11 +1,12 @@ import { Router } from "express"; -import { createInvoice, getInvoice, getPaymentMethods, setPaymentMethod } from "../controllers/invoice"; +import { createInvoice, getConfirmation, getInvoice, getPaymentMethods, setPaymentMethod } from "../controllers/invoice"; import { MW_User } from "../controllers/user"; const invoiceRouter = Router() invoiceRouter.get('/paymentmethods', getPaymentMethods); invoiceRouter.get('/:selector', getInvoice); +invoiceRouter.get('/:selector/confirmation', getConfirmation); invoiceRouter.post('/:selector/setmethod', setPaymentMethod); invoiceRouter.get('/', MW_User, getInvoice); invoiceRouter.post('/', MW_User, createInvoice);