Proper handling of unequal payments

- Cryptocurrencies are enabled dynamically
- Invoice handler handles now more
- Better status change handling
This commit is contained in:
2021-01-01 19:39:38 +01:00
parent aa2147d509
commit b356f3ee70
12 changed files with 305 additions and 120 deletions

View File

@@ -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: []
}
}
/**

View File

@@ -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}`;
});

View File

@@ -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
@@ -222,6 +227,8 @@ export async function setPaymentMethod(req: Request, res: Response) {
await invoice.save();
invoiceManager.addInvoice(invoice)
res.status(200).send({
receiveAddress: invoice.receiveAddress
});

View File

@@ -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;

View File

@@ -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<void> {
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();
}
}
}
}

View File

@@ -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();

View File

@@ -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 {
@@ -78,6 +79,8 @@ export class Provider implements BackendProvider {
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 => {
// Loop over each output and check if the address of one matches the one of an 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.
const transcation = invoice.transcationHash;
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 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);
});
});
});
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);