diff --git a/args/schema.sql b/args/schema.sql index c8a52e6..54edc52 100644 --- a/args/schema.sql +++ b/args/schema.sql @@ -247,6 +247,45 @@ CREATE TABLE CloseBondTransact( FOREIGN KEY (bond_id) REFERENCES BlockchainBonds(bond_id) ); +CREATE TABLE BobsFund( + fund_id VARCHAR(128) NOT NULL, + begin_date DATE NOT NULL, + btc_base DECIMAL(16, 2) NOT NULL, + usd_base DECIMAL(16, 2) NOT NULL, + fee DECIMAL(6, 5) NOT NULL, + duration VARCHAR(10) NOT NULL, + tapout_window VARCHAR(10), + tapout_interval VARCHAR(40), + PRIMARY KEY(fund_id) +); + +CREATE TABLE BobsFundInvestments( + fund_id VARCHAR(128) NOT NULL, + floID CHAR(34) NOT NULL, + amount_in DECIMAL(16, 2) NOT NULL, + close_id VARCHAR(128), + amount_out DECIMAL(16, 2), + PRIMARY KEY(fund_id, floID), + FOREIGN KEY (fund_id) REFERENCES BobsFund(fund_id) +); + +CREATE TABLE CloseFundTransact( + id INT NOT NULL AUTO_INCREMENT, + fund_id VARCHAR(128) NOT NULL, + floID CHAR(34) NOT NULL, + amount DECIMAL(16, 2) NOT NULL, + end_date DATE NOT NULL, + ref_sign VARCHAR(180) NOT NULL, + btc_net DECIMAL(16, 2) NOT NULL, + usd_net DECIMAL(16, 2) NOT NULL, + txid VARCHAR(128), + close_id VARCHAR(128), + status VARCHAR(50) NOT NULL, + KEY(id), + PRIMARY KEY(fund_id, floID), + FOREIGN KEY (fund_id) REFERENCES BobsFund(fund_id) +); + CREATE TABLE DirectConvert( id INT NOT NULL AUTO_INCREMENT, floID CHAR(34) NOT NULL, diff --git a/docs/scripts/floExchangeAPI.js b/docs/scripts/floExchangeAPI.js index 91a4418..984d1b0 100644 --- a/docs/scripts/floExchangeAPI.js +++ b/docs/scripts/floExchangeAPI.js @@ -1339,6 +1339,37 @@ }) } + exchangeAPI.closeBobsFundInvestment = function (fund_id, floID, privKey) { + return new Promise((resolve, reject) => { + if (!floCrypto.verifyPrivKey(privKey, floID)) + return reject(ExchangeError(ExchangeError.BAD_REQUEST_CODE, "Invalid Private Key", errorCode.INVALID_PRIVATE_KEY)); + let request = { + floID: floID, + fund_id: fund_id, + timestamp: Date.now() + }; + request.pubKey = floCrypto.getPubKeyHex(privKey); + request.sign = signRequest({ + type: "close_bobs_fund", + fund_id: data.fund_id, + timestamp: data.timestamp + }, privKey); + console.debug(request); + + fetch_api('/close-bobs-fund-investment', { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }).then(result => { + responseParse(result) + .then(result => resolve(result)) + .catch(error => reject(error)) + }).catch(error => reject(error)) + }) + } + exchangeAPI.init = function refreshDataFromBlockchain(adminID = floGlobals.adminID, appName = floGlobals.application) { return new Promise((resolve, reject) => { let nodes, assets, tags, lastTx; diff --git a/src/app.js b/src/app.js index 4892120..d1ff2f6 100644 --- a/src/app.js +++ b/src/app.js @@ -35,7 +35,7 @@ module.exports = function App(secret, DB) { })); */ - app.use(function(req, res, next) { + app.use(function (req, res, next) { res.setHeader('Access-Control-Allow-Origin', "*"); // Request methods you wish to allow res.setHeader('Access-Control-Allow-Methods', 'GET, POST'); @@ -91,6 +91,7 @@ module.exports = function App(secret, DB) { //close blockchain-bond app.post('/close-blockchain-bonds', Request.CloseBlockchainBond); + app.post('/close-bobs-fund-investment', Request.CloseBobsFund); //Manage user tags (Access to trusted IDs only) app.post('/add-tag', Request.AddUserTag); diff --git a/src/request.js b/src/request.js index ed9a932..c6216a0 100644 --- a/src/request.js +++ b/src/request.js @@ -3,6 +3,7 @@ const market = require("./market"); const conversion = require('./services/conversion'); const blokchain_bonds = require("./services/bonds"); +const bobs_fund = require("./services/bobs-fund"); const { SIGN_EXPIRE_TIME, @@ -335,6 +336,18 @@ function CloseBlockchainBond(req, res) { }, () => blokchain_bonds.closeBond(data.bond_id, data.floID, `${data.timestamp}.${data.sign}`)); } +function CloseBobsFund(req, res) { + let data = req.body; + if (!data.pubKey) + res.status(INVALID.e_code).send(INVALID.str(eCode.MISSING_PARAMETER, "Public key missing")); + else + processRequest(res, data.floID, data.pubKey, data.sign, "Conversion", { + type: "close_bobs_fund", + fund_id: data.fund_id, + timestamp: data.timestamp + }, () => bobs_fund.closeFund(data.fund_id, data.floID, `${data.timestamp}.${data.sign}`)); +} + /* Public Requests */ function GetLoginCode(req, res) { @@ -531,6 +544,7 @@ module.exports = { ConvertTo, ConvertFrom, CloseBlockchainBond, + CloseBobsFund, set trustedIDs(ids) { trustedIDs = ids; }, diff --git a/src/services/bobs-fund.js b/src/services/bobs-fund.js new file mode 100644 index 0000000..75fbf77 --- /dev/null +++ b/src/services/bobs-fund.js @@ -0,0 +1,286 @@ +'use strict'; + +const eCode = require('../../docs/scripts/floExchangeAPI').errorCode; +const getRate = require('./conversion').getRate; + +var DB; //container for database + +const bobsFund = (function () { + const productStr = "Bobs Fund"; + + const magnitude = m => { + switch (m) { + case "thousand": return 1000; + case "lakh": case "lakhs": return 100000; + case "million": return 1000000; + case "crore": case "crores": return 10000000; + default: return null; + } + } + const parseNumber = (str) => { + let n = 0, + g = 0; + str.toLowerCase().replace(/,/g, '').split(" ").forEach(s => { + if (!isNaN(s)) + g = parseFloat(s); + else { + let m = magnitude(s); + if (m !== null) { + n += m * g; + g = 0; + } + } + }); + return n + g; + } + const parsePeriod = (str) => { + let P = '', n = 0; + str.toLowerCase().replace(/,/g, '').split(" ").forEach(s => { + if (!isNaN(s)) + n = parseFloat(s); + else switch (s) { + case "year(s)": case "year": case "years": P += (n + 'Y'); n = 0; break; + case "month(s)": case "month": case "months": P += (n + 'M'); n = 0; break; + case "day(s)": case "day": case "days": P += (n + 'D'); n = 0; break; + } + }); + return P; + } + const dateFormat = (date = null) => { + let d = (date ? new Date(date) : new Date()).toDateString(); + return [d.substring(8, 10), d.substring(4, 7), d.substring(11, 15)].join(" "); + } + + const dateAdder = function (start_date, duration) { + let date = new Date(start_date); + let y = parseInt(duration.match(/\d+Y/)), + m = parseInt(duration.match(/\d+M/)), + d = parseInt(duration.match(/\d+D/)); + if (!isNaN(y)) + date.setFullYear(date.getFullYear() + y); + if (!isNaN(m)) + date.setMonth(date.getMonth() + m); + if (!isNaN(d)) + date.setDate(date.getDate() + d); + return date; + } + + function calcNetValue(BTC_base, BTC_net, USD_base, USD_net, amount, fee) { + let gain, interest, net; + gain = (BTC_net - BTC_base) / BTC_base; + interest = gain * (1 - fee) + net = amount / USD_base; + net += net * interest; + return net * USD_net; + } + + function stringify_main(BTC_base, USD_base, start_date, duration, investments, fee = 0, tapoutWindow = null, tapoutInterval = null) { + let result = [ + `${productStr}`, + `Base Value: ${BTC_base} USD`, + `USD INR rate at start: ${USD_base}`, + `Start date: ${dateFormat(start_date)}`, + `Duration: ${duration}`, + `Management Fee: ${fee != 0 ? fee + "%" : "0 (Zero)"}` + ]; + if (tapoutInterval) { + if (Array.isArray(tapoutInterval)) { + let x = tapoutInterval.pop(), + y = tapoutInterval.join(", ") + tapoutInterval = `${y} and ${x}` + } + result.push(`Tapout availability: ${tapoutWindow} after ${tapoutInterval}`); + } + result.push(`Investment(s) (INR): ${investments.map(f => `${f[0].trim()}-${f[1].trim()}`).join("; ")}`); + return result.join("|"); + } + + function stringify_continue(fundID, investments) { + return [ + `${productStr}`, + `continue: ${fundID}`, + `Investment(s) (INR): ${investments.map(f => `${f[0].trim()}-${f[1].trim()}`).join("; ")}` + ].join("|"); + } + + function parse_main(data) { + let funds = {}; + if (!Array.isArray(data)) + data = [data]; + data.forEach(fd => { + let cont = /continue: [a-z0-9]{64}\|/.test(fd); + fd.data.split("|").forEach(d => { + d = d.split(': '); + switch (d[0].toLowerCase()) { + case "start date": + cont ? null : funds["start_date"] = d[1]; break; + case "base value": + cont ? null : funds["BTC_base"] = parseNumber(d[1].slice(0, -4)); break; + case "usd inr rate at start": + cont ? null : funds["USD_base"] = parseFloat(d[1]); break; + case "duration": + cont ? null : funds["duration"] = parsePeriod(d[1]); break; + case "management fee": + cont ? null : funds["fee"] = parseFloat(d[1]); break; + case "tapout availability": + let x = d[1].toLowerCase().split("after") + funds["tapoutInterval"] = x[1].match(/\d+ [a-z]+/gi).map(y => parsePeriod(y)) + funds["topoutWindow"] = parsePeriod(x[0]); break; + case "invesment(s) (inr)": + case "investment(s) (inr)": + funds["amounts"] = funds["amounts"] || []; + funds["amounts"].push(d[1].split("; ").map(a => { + a = a.split("-"); + return [a[0], parseNumber(a[1])] + })); break; + } + }); + }) + return funds; + } + + function stringify_end(fund_id, floID, end_date, BTC_net, USD_net, amount, ref_sign, payment_ref) { + return [ + `${productStr}`, + `Fund: ${fund_id}`, + `Investor: ${floID}`, + `End value: ${BTC_net} USD`, + `Date of withdrawal: ${end_date}`, + `USD INR rate at end: ${USD_net}`, + `Amount withdrawn: Rs ${amount} via ${payment_ref}`, + `Reference: ${ref_sign}` + ].join("|"); + } + + function parse_end(data) { + //Data (end fund) send by market nodes + let details = {}; + data.split("|").forEach(d => { + d = d.split(': '); + switch (d[0].toLowerCase()) { + case "fund": + details["fundID"] = d[1]; break; + case "investor": + details["floID"] = d[1]; break; + case "end value": + details["BTC_net"] = parseNumber(d[1].slice(0, -4)); break; + case "date of withdrawal": + details["endDate"] = new Date(d[1]); break; + case "amount withdrawn": + details["amountFinal"] = parseNumber(d[1].match(/\d.+ via/).toString()); + details["payment_refRef"] = d[1].match(/via .+/).toString().substring(4); break; + case "usd inr rate at end": + details["USD_net"] = parseFloat(d[1]); break; + case "reference": + details["refSign"] = d[1]; break; + } + }) + } + + + return { + dateAdder, + dateFormat, + calcNetValue, + parse: { + main: parse_main, + end: parse_end + }, + stringify: { + main: stringify_main, + continue: stringify_continue, + end: stringify_end + } + } + +})(); + +bobsFund.config = { + adminID: "FFXy5pJnfzu2fCDLhpUremyXQjGtFpgCDN", + application: "BobsFund" +} + +function refreshBlockchainData(nodeList = []) { + return new Promise((resolve, reject) => { + DB.query("SELECT num FROM LastTx WHERE floID=?", [bobsFund.config.adminID]).then(result => { + let lastTx = result.length ? result[0].num : 0; + floBlockchainAPI.readData(bobsFund.config.adminID, { + ignoreOld: lastTx, + senders: [nodeList].concat(bobsFund.config.adminID), //sentOnly: true, + tx: true, + filter: d => d.startsWith(bobsFund.config.productStr) + }).then(result => { + let promises = []; + result.data.forEach(d => { + let fund = d.senders.has(bobsFund.config.adminID) ? bobsFund.parse.main(d.data) : null; + if (fund && fund.amount) { + let fund_id = d.data.match(/continue: [a-z0-9]{64}\|/); + if (!fund_id) { + fund_id = d.txid; + promises.push(DB.query("INSERT INTO BobsFund(fund_id, begin_date, btc_base, usd_base, fee, duration, tapout_window, tapout_interval) VALUES ? ON DUPLICATE KEY UPDATE fund_id=fund_id", + [[[fund_id, fund.start_date, fund.BTC_base, fund.USD_base, fund.fee, fund.duration, fund.topoutWindow, fund.tapoutInterval]]])); + } else + fund_id = fund_id.pop().match(/[a-z0-9]{64}/).pop(); + let investments = fund.amounts.map(i => [fund_id, i[0], i[1]]); + promises.push(DB.query("INSERT INTO BobsFundInvestments(fund_id, floID, amount_in) VALUES ?", [investments])); + } + else { + let details = bobsFund.parse.end(d.data); + if (details.fundID && details.floID && details.amountFinal) + promises.push(DB.query("UPDATE BobsFundInvestments SET close_id=? amount_out=? WHERE fund_id=? AND floID=?", [d.txid, details.amountFinal, details.fundID, details.floID])); + } + }); + promises.push(DB.query("INSERT INTO LastTx (floID, num) VALUE (?, ?) ON DUPLICATE KEY UPDATE num=?", [bobsFund.config.adminID, result.totalTxs, result.totalTxs])); + Promise.allSettled(promises).then(results => { + //console.debug(results.filter(r => r.status === "rejected")); + if (results.reduce((a, r) => r.status === "rejected" ? ++a : a, 0)) + console.warn("Some fund data might not have been saved in database correctly"); + resolve(result.totalTxs); + }) + }).catch(error => reject(error)) + }).catch(error => reject(error)) + }) +} + +function closeFund(fund_id, floID, ref) { + return new Promise((resolve, reject) => { + DB.query("SELECT status FROM CloseFundTransact WHERE fund_id=?", [fund_id]).then(result => { + if (result.length) + return reject(INVALID(eCode.DUPLICATE_ENTRY, `Fund closing already in process`)); + DB.query("SELECT * FROM BobsFund WHERE fund_id=?", [fund_id]).then(result => { + if (!result.length) + return reject(INVALID(eCode.NOT_FOUND, 'Fund not found')); + let fund = result[0]; + DB.query("SELECT * FROM BobsFundInvestments WHERE fund_id=? AND floID=?", [fund_id, floID]).then(result => { + if (!result.length) + return reject(INVALID(eCode.NOT_OWNER, 'User is not an investor of this fund')); + let investment = result[0]; + if (investment.close_id) + return reject(INVALID(eCode.DUPLICATE_ENTRY, `Fund investment already closed (${investment.close_id})`)); + /* TODO: tapout and lockin period check + if (Date.now() < bobsFund.dateAdder(fund.begin_date, fund.duration).getTime()) + return reject(INVALID(eCode.INSUFFICIENT_PERIOD, 'Fund still in lock-in period')); + */ + getRate.BTC_USD().then(btc_rate => { + getRate.USD_INR().then(usd_rate => { + let end_date = new Date(), + net_value = bobsFund.calcNetValue(fund.btc_base, btc_rate, fund.usd_base, usd_rate, investment.amount_in, fund.fee) + DB.query("INSERT INTO CloseFundTransact(fund_id, floID, amount, end_date, btc_net, usd_net, ref_sign, status) VALUE ?", [[fund_id, floID, net_value, end_date, btc_rate, usd_rate, ref, "PENDING"]]) + .then(result => resolve({ "USD_net": usd_rate, "BTC_net": btc_rate, "amount_out": net_value, "end_date": end_date })) + .catch(error => reject(error)) + }).catch(error => reject(error)) + }).catch(error => reject(error)) + }).catch(error => reject(error)) + }).catch(error => reject(error)) + }).catch(error => reject(error)) + }) +} + +module.exports = { + refresh: refreshBlockchainData, + set DB(db) { + DB = db; + }, + util: bobsFund, + closeFund +} \ No newline at end of file