Custom BTCPay donation integration

fixes #122
This commit is contained in:
softsimon
2020-10-07 20:15:42 +07:00
parent 774893f2fc
commit a07a4de255
14 changed files with 359 additions and 15 deletions

View File

@@ -22,5 +22,7 @@
"BISQ_MARKETS_DATA_PATH": "/bisq/seednode-data/btc_mainnet/db",
"SSL": false,
"SSL_CERT_FILE_PATH": "/etc/letsencrypt/live/mysite/fullchain.pem",
"SSL_KEY_FILE_PATH": "/etc/letsencrypt/live/mysite/privkey.pem"
"SSL_KEY_FILE_PATH": "/etc/letsencrypt/live/mysite/privkey.pem",
"BTCPAY_URL": "",
"BTCPAY_AUTH": ""
}

View File

@@ -0,0 +1,125 @@
const config = require('../../mempool-config.json');
import * as request from 'request';
import { DB } from '../database';
class Donations {
private notifyDonationStatusCallback: ((invoiceId: string) => void) | undefined;
private options = {
baseUrl: config.BTCPAY_URL,
headers: {
'Content-Type': 'application/json',
'Authorization': config.BTCPAY_AUTH,
},
};
constructor() { }
setNotfyDonationStatusCallback(fn: any) {
this.notifyDonationStatusCallback = fn;
}
createRequest(amount: number, orderId: string): Promise<any> {
const postData = {
'price': amount,
'orderId': orderId,
'currency': 'BTC',
'itemDesc': 'Sponsor mempool.space',
'notificationUrl': 'https://mempool.space/api/v1/donations-webhook',
'redirectURL': 'https://mempool.space/about'
};
return new Promise((resolve, reject) => {
request.post({
uri: '/invoices',
json: postData,
...this.options,
}, (err, res, body) => {
if (err) { return reject(err); }
const formattedBody = {
id: body.data.id,
amount: parseFloat(body.data.btcPrice),
address: body.data.bitcoinAddress,
};
resolve(formattedBody);
});
});
}
async $handleWebhookRequest(data: any) {
if (!data || !data.id) {
return;
}
const response = await this.getStatus(data.id);
if (response.status === 'complete') {
if (this.notifyDonationStatusCallback) {
this.notifyDonationStatusCallback(data.id);
}
let imageUrl = '';
if (response.orderId !== '') {
try {
imageUrl = await this.$getTwitterImageUrl(response.orderId);
} catch (e) {
console.log('Error fetching twitter image from Hive', e.message);
}
}
this.$addDonationToDatabase(response, imageUrl);
}
}
private getStatus(id: string): Promise<any> {
return new Promise((resolve, reject) => {
request.get({
uri: '/invoices/' + id,
json: true,
...this.options,
}, (err, res, body) => {
if (err) { return reject(err); }
resolve(body.data);
});
});
}
async $getDonationsFromDatabase() {
try {
const connection = await DB.pool.getConnection();
const query = `SELECT handle, imageUrl FROM donations WHERE handle != ''`;
const [rows] = await connection.query<any>(query);
connection.release();
return rows;
} catch (e) {
console.log('$getDonationsFromDatabase() error', e);
}
}
private async $addDonationToDatabase(response: any, imageUrl: string): Promise<void> {
try {
const connection = await DB.pool.getConnection();
const query = `INSERT INTO donations(added, amount, handle, order_id, imageUrl) VALUES (NOW(), ?, ?, ?, ?)`;
const params: (string | number)[] = [
response.btcPaid,
response.orderId,
response.id,
imageUrl,
];
const [result]: any = await connection.query(query, params);
connection.release();
} catch (e) {
console.log('$addDonationToDatabase() error', e);
}
}
private async $getTwitterImageUrl(handle: string): Promise<string> {
return new Promise((resolve, reject) => {
request.get({
uri: `https://api.hive.one/v1/influencers/screen_name/${handle}/?format=json`,
json: true,
}, (err, res, body) => {
if (err) { return reject(err); }
resolve(body.data.imageUrl);
});
});
}
}
export default new Donations();

View File

@@ -99,6 +99,10 @@ class WebsocketHandler {
response['pong'] = true;
}
if (parsedMessage['track-donation'] && parsedMessage['track-donation'].length === 22) {
client['track-donation'] = parsedMessage['track-donation'];
}
if (Object.keys(response).length) {
client.send(JSON.stringify(response));
}
@@ -109,6 +113,21 @@ class WebsocketHandler {
});
}
handleNewDonation(id: string) {
if (!this.wss) {
throw new Error('WebSocket.Server is not set');
}
this.wss.clients.forEach((client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
if (client['track-donation'] === id) {
client.send(JSON.stringify({ donationConfirmed: true }));
}
});
}
handleNewStatistic(stats: OptimizedStatistic) {
if (!this.wss) {
throw new Error('WebSocket.Server is not set');

View File

@@ -18,6 +18,7 @@ import websocketHandler from './api/websocket-handler';
import fiatConversion from './api/fiat-conversion';
import bisq from './api/bisq/bisq';
import bisqMarkets from './api/bisq/markets';
import donations from './api/donations';
class Server {
private wss: WebSocket.Server | undefined;
@@ -62,7 +63,9 @@ class Server {
res.setHeader('Access-Control-Allow-Origin', '*');
next();
})
.use(compression());
.use(compression())
.use(express.urlencoded({ extended: true }))
.use(express.json());
if (config.SSL === true) {
const credentials = {
@@ -122,6 +125,7 @@ class Server {
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
donations.setNotfyDonationStatusCallback(websocketHandler.handleNewDonation.bind(websocketHandler));
}
setUpHttpApiRoutes() {
@@ -163,6 +167,14 @@ class Server {
.get(config.API_ENDPOINT + 'bisq/markets/volumes', routes.getBisqMarketVolumes.bind(routes))
;
}
if (config.BTCPAY_URL) {
this.app
.get(config.API_ENDPOINT + 'donations', routes.getDonations.bind(routes))
.post(config.API_ENDPOINT + 'donations', routes.createDonationRequest.bind(routes))
.post(config.API_ENDPOINT + 'donations-webhook', routes.donationWebhook.bind(routes))
;
}
}
}

View File

@@ -9,6 +9,7 @@ import bisq from './api/bisq/bisq';
import bisqMarket from './api/bisq/markets-api';
import { RequiredSpec } from './interfaces';
import { MarketsApiError } from './api/bisq/interfaces';
import donations from './api/donations';
class Routes {
private cache = {};
@@ -98,6 +99,55 @@ class Routes {
res.json(backendInfo.getBackendInfo());
}
public async createDonationRequest(req: Request, res: Response) {
const constraints: RequiredSpec = {
'amount': {
required: true,
types: ['@float']
},
'orderId': {
required: true,
types: ['@string']
}
};
const p = this.parseRequestParameters(req.body, constraints);
if (p.error) {
res.status(400).send(p.error);
return;
}
if (p.amount < 0.01) {
res.status(400).send('Amount needs to be at least 0.01');
return;
}
try {
const result = await donations.createRequest(p.amount, p.orderId);
res.json(result);
} catch (e) {
res.status(500).send(e.message);
}
}
public async getDonations(req: Request, res: Response) {
try {
const result = await donations.$getDonationsFromDatabase();
res.json(result);
} catch (e) {
res.status(500).send(e.message);
}
}
public async donationWebhook(req: Request, res: Response) {
try {
donations.$handleWebhookRequest(req.body);
res.end();
} catch (e) {
res.status(500).send(e);
}
}
public getBisqStats(req: Request, res: Response) {
const result = bisq.getStats();
res.json(result);
@@ -173,7 +223,7 @@ class Routes {
},
};
const p = this.parseRequestParameters(req, constraints);
const p = this.parseRequestParameters(req.query, constraints);
if (p.error) {
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
return;
@@ -195,7 +245,7 @@ class Routes {
},
};
const p = this.parseRequestParameters(req, constraints);
const p = this.parseRequestParameters(req.query, constraints);
if (p.error) {
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
return;
@@ -254,7 +304,7 @@ class Routes {
}
};
const p = this.parseRequestParameters(req, constraints);
const p = this.parseRequestParameters(req.query, constraints);
if (p.error) {
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
return;
@@ -281,7 +331,7 @@ class Routes {
},
};
const p = this.parseRequestParameters(req, constraints);
const p = this.parseRequestParameters(req.query, constraints);
if (p.error) {
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
return;
@@ -323,7 +373,7 @@ class Routes {
},
};
const p = this.parseRequestParameters(req, constraints);
const p = this.parseRequestParameters(req.query, constraints);
if (p.error) {
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
return;
@@ -365,7 +415,7 @@ class Routes {
},
};
const p = this.parseRequestParameters(req, constraints);
const p = this.parseRequestParameters(req.query, constraints);
if (p.error) {
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
return;
@@ -387,7 +437,7 @@ class Routes {
},
};
const p = this.parseRequestParameters(req, constraints);
const p = this.parseRequestParameters(req.query, constraints);
if (p.error) {
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
return;
@@ -401,15 +451,15 @@ class Routes {
}
}
private parseRequestParameters(req: Request, params: RequiredSpec): { [name: string]: any; } {
private parseRequestParameters(requestParams: object, params: RequiredSpec): { [name: string]: any; } {
const final = {};
for (const i in params) {
if (params.hasOwnProperty(i)) {
if (params[i].required && !req.query[i]) {
if (params[i].required && requestParams[i] === undefined) {
return { error: i + ' parameter missing'};
}
if (typeof req.query[i] === 'string') {
const str = (req.query[i] || '').toString().toLowerCase();
if (typeof requestParams[i] === 'string') {
const str = (requestParams[i] || '').toString().toLowerCase();
if (params[i].types.indexOf('@number') > -1) {
const number = parseInt((str).toString(), 10);
final[i] = number;
@@ -422,6 +472,8 @@ class Routes {
} else {
return { error: i + ' parameter invalid'};
}
} else if (typeof requestParams[i] === 'number') {
final[i] = requestParams[i];
}
}
}