You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

814 lines
27 KiB

const fs = require('fs');
const path = require('path');
const monerojs = require("monero-javascript");
const express = require('express');
const cors = require('cors')
const readline = require('readline');
const https = require('https');
const qrCode = require('qrcode');
const stream = require('stream');
class Lumo {
LumoRootDir = __dirname;
LumoPackage = require(path.join(this.LumoRootDir, '/package.json'));
LumoConfiguration = require(path.join(this.LumoRootDir, '/config.json'));
MoneroRpcConnection = monerojs.MoneroRpcConnection;
MoneroConnectionManager = monerojs.MoneroConnectionManager;
MoneroConnectionManagerListener = monerojs.MoneroConnectionManagerListener;
ConnectionManager = new monerojs.MoneroConnectionManager();
MoneroWallet = null;
MoneroWalletListener = null;
MoneroWalletDir = path.join(this.LumoRootDir, '/wallet');
MoneroWalletName = '/lumo';
MoneroDaemon = null;
MoneroConnection = null;
MoneroNetworks = ["mainnet", "testnet", "stagenet"];
MoneroPriceCacheFile = path.join(this.LumoRootDir, '/.cache/xmr-price.json');
MoneroPriceCache = null;
ReadLine = readline.createInterface({
input: process.stdin,
output: process.stdout
});
ExpressServer = express();
QrCode = qrCode;
constructor() {
process.on('exit', () => {
this.close();
});
process.on('SIGINT', () => {
this.close();
process.exit(2);
});
process.on('uncaughtException', (e) => {
this.close();
console.log('Uncaught Exception...');
console.log(e);
process.exit(99);
});
this.load();
}
async load() {
console.log("\r\n \r\n88 \r\n88 \r\n88 \r\n88 88 88 88,dPYba,,adPYba, ,adPPYba, \r\n88 88 88 88P\' \"88\" \"8a a8\" \"8a \r\n88 88 88 88 88 88 8b d8 \r\n88 \"8a, ,a88 88 88 88 \"8a, ,a8\" \r\n88 `\"YbbdP\'Y8 88 88 88 `\"YbbdP\"\' \r\n \r\n \r\n")
console.log("");
console.log("v.", this.LumoPackage.version);
console.log("");
console.log(this.LumoPackage.description);
console.log("");
console.log("Checking config.json...");
if (this.verify()) {
console.log("Config check successful");
((this.LumoConfiguration.monero.wallet.created) ? this.login() : this.install());
} else {
console.log("Error: Config check failed");
this.exit();
}
}
verify() {
let verified = null;
let config = this.LumoConfiguration;
if (!config.hasOwnProperty('server')) {
console.log('missing server');
verified = false;
} else if (!config.server.hasOwnProperty('url') || !config.server.url) {
console.log('missing server url');
verified = false;
} else if (!config.server.hasOwnProperty('port') || !config.server.port) {
console.log('missing server port');
verified = false;
} else if (!config.server.hasOwnProperty('frontend') || !this.isBool(config.server.frontend)) {
console.log('missing server frontend');
verified = false;
} else if (!config.hasOwnProperty('monero')) {
console.log('missing monero');
verified = false;
} else if (!config.monero.hasOwnProperty('daemon')) {
console.log('missing monero daemon');
verified = false;
} else if (!config.monero.daemon.hasOwnProperty('url') || !config.monero.daemon.url) {
console.log('missing monero daemon url');
verified = false;
} else if (!config.monero.daemon.hasOwnProperty('port') || !config.monero.daemon.port) {
console.log('missing monero daemon port');
verified = false;
} else if (!config.monero.hasOwnProperty('wallet')) {
console.log('missing wallet');
verified = false;
} else if (!config.monero.wallet.hasOwnProperty('primary_address') || !config.monero.wallet.primary_address) {
console.log('missing wallet primary address');
verified = false;
} else if (!config.monero.wallet.created && !config.monero.wallet.hasOwnProperty('password')) {
console.log('missing wallet password');
verified = false;
} else if (!config.monero.wallet.created && !config.monero.wallet.password) {
console.log('missing wallet password');
verified = false;
} else if (!config.monero.wallet.created && !config.monero.wallet.hasOwnProperty('private_spend')) {
console.log('missing wallet spend key');
verified = false;
} else if (!config.monero.wallet.created && !config.monero.wallet.private_spend) {
console.log('missing wallet private spend key');
verified = false;
} else if (!config.monero.hasOwnProperty('currency')) {
console.log('missing currency');
verified = false;
} else if (!config.monero.currency) {
console.log('missing currency code');
verified = false;
} else {
verified = true
}
return verified;
}
async login() {
console.log("\n")
this.ReadLine.stdoutMuted = true;
this.ReadLine.query = "Password : ";
this.ReadLine.question(this.ReadLine.query, (password) => {
this.ReadLine.close();
if (password) {
this.LumoConfiguration.monero.wallet.password = password;
this.init();
} else {
console.log("\nInvalid password");
console.log("Exiting");
this.exit();
}
});
this.ReadLine._writeToOutput = (stringToWrite) => {
if (this.ReadLine.stdoutMuted) {
this.ReadLine.output.write("\x1B[2K\x1B[200D" + this.ReadLine.query + "[" + ((this.ReadLine.line.length % 2 == 1) ? "=-" : "-=") + "]");
} else {
this.ReadLine.output.write(stringToWrite);
}
};
}
async install() {
console.log("\n-------------------------------------------------------------");
console.log("");
console.log('Installation');
console.log("");
console.log("-------------------------------------------------------------");
console.log("");
console.log('Lumo will use the settings in the config.json file for installation. Make sure you have configured the config file with the correct information for your Express JS Server, Monero daemon, and Monero wallet. Be sure to write all the information down if necessary because this file will be edited and certain paramaters will be removed (ie. Wallet password, wallet private spend key, wallet restore height) post installation for security purposes! Be sure to use a strong password in your config for your new instance of the Monero full wallet. This is what protects your wallet file from unauthorized access. You will be required to enter this password each time you run Lumo!')
console.log("");
this.ReadLine.question('Do you agree and wish to proceed (Y/y or N/n): ', async (answer) => {
if (answer === "Y" || answer === "y") {
this.ReadLine.close();
await this.createWallet();
} else {
this.ReadLine.close();
this.exit();
}
});
}
async createWallet() {
console.log("\nCreating wallet...");
fs.mkdir(this.MoneroWalletDir, (err) => {
if (err) {
console.log(err);
this.exit();
}
});
let pwd = this.LumoConfiguration.monero.wallet.password;
this.MoneroWallet = await monerojs.createWalletFull({
path: path.join(this.MoneroWalletDir, this.MoneroWalletName),
password: this.LumoConfiguration.monero.wallet.password,
primaryAddress: this.LumoConfiguration.monero.wallet.primary_address,
privateSpendKey: this.LumoConfiguration.monero.wallet.private_spend,
restoreHeight: this.LumoConfiguration.monero.wallet.restore_height,
networkType: this.LumoConfiguration.monero.wallet.network,
server: new monerojs.MoneroRpcConnection({
uri: this.LumoConfiguration.monero.daemon.url.concat(':' + this.LumoConfiguration.monero.daemon.port),
username: this.LumoConfiguration.monero.daemon.username,
password: this.LumoConfiguration.monero.daemon.password,
rejectUnauthorized: false,
proxyToWorker: true
})
}).catch(async (e) => {
console.log("Wallet creation failed: ", e.toString());
this.exit();
})
await this.MoneroWallet.save();
console.log("\nCreated encrypted password protected wallet file");
let address = await this.MoneroWallet.getPrimaryAddress();
console.log({
primaryAddress: address
});
console.log("\nGenerating wallet qrcode file...");
await this.QrCode.toFile(path.join(this.MoneroWalletDir, '/' + address + '-qr.png'), "monero:" + address).catch((err) => {
console.log('\nError writing wallet qrcode file');
})
console.log("\nWallet qrcode file has been generated: ", address + '-qr.png');
console.log("Creating sym link for qrcode to /public...");
fs.symlink(path.join(this.MoneroWalletDir, '/' + address + '-qr.png'), path.join(this.LumoRootDir, '/public/images/' + address + '-qr.png'), async (err) => {
if (err) {
console.log(err);
this.exit();
} else {
console.log("Sym link created");
}
});
this.LumoConfiguration.monero.wallet.created = true;
delete this.LumoConfiguration.monero.wallet.password;
delete this.LumoConfiguration.monero.wallet.private_spend;
delete this.LumoConfiguration.monero.wallet.restore_height;
fs.writeFile(path.join(this.LumoRootDir, '/config.json'), JSON.stringify(this.LumoConfiguration), async (err) => {
if (err) {
console.log('\nError writing to config file');
this.exit();
} else {
console.log("\nSaved config file");
}
});
fs.mkdir(path.join(this.LumoRootDir, '/.cache'), async (err) => {
if (err) {
return console.error(err);
}
console.log('Cache directory created successfully!');
fs.writeFile(path.join(this.LumoRootDir, '/.cache/xmr-price.json'), "{}", async (err) => {
if (err) {
return console.error(err);
}
console.log('Cache file created successfully!');
});
});
this.LumoConfiguration.monero.wallet.password = pwd;
console.log("\nMaking initial connections...");
this.init();
}
async init() {
this.MoneroConnection = await new monerojs.MoneroRpcConnection({
uri: this.LumoConfiguration.monero.daemon.url.concat(':' + this.LumoConfiguration.monero.daemon.port),
username: this.LumoConfiguration.monero.daemon.username,
password: this.LumoConfiguration.monero.daemon.password,
rejectUnauthorized: false,
proxyToWorker: true
});
console.log("\n\nConnecting to daemon...");
try {
this.MoneroDaemon = await monerojs.connectToDaemonRpc(this.MoneroConnection)
} catch (e) {
console.log("Daemon conntection failed: ", e.toString());
this.exit();
}
let daemonInfo = await this.MoneroDaemon.getInfo();
console.log("Daemon connected: ");
console.log({
url: this.MoneroConnection.getUri(),
network: this.MoneroNetworks[await daemonInfo.getNetworkType()],
height: await daemonInfo.getHeight(),
isSynchronized: await daemonInfo.isSynchronized()
})
console.log("\nConnecting to wallet...")
if (!this.MoneroWallet) {
this.MoneroWallet = await monerojs.openWalletFull({
path: path.join(this.MoneroWalletDir, this.MoneroWalletName),
password: this.LumoConfiguration.monero.wallet.password,
networkType: await daemonInfo.getNetworkType(),
server: this.MoneroConnection,
proxyToWorker: true
}).catch(async (e) => {
console.log("Wallet connection failed: " + e.toString());
this.exit();
})
}
console.log('Wallet Connected: ');
console.log({
height: await this.MoneroWallet.getHeight(),
primaryAddress: await this.MoneroWallet.getPrimaryAddress()
});
console.log("\nWallet synchronization started...");
let amt = -1 + "%";
await this.MoneroWallet.sync(new class extends monerojs.MoneroWalletListener {
async onSyncProgress(height, startHeight, endHeight, percentDone, message) {
let progress = Math.round(parseFloat(percentDone.toString()) * 100) + '%';
if (progress !== amt) {
amt = progress;
console.log("Synchronizing", {
start: startHeight,
height: height,
end: endHeight,
progress: amt
});
}
};
});
await this.MoneroWallet.save();
console.log("\nWallet synchronized height: ", await this.MoneroWallet.getHeight());
await this.MoneroWallet.startSyncing(1000);
console.log("Wallet synchronizing: every second");
await this.MoneroWallet.addListener(new class extends monerojs.MoneroWalletListener {
async onOutputReceived(output) {
console.log('Transaction-in: ', await output.getAmount());
}
async onOutputSpent(output) {
console.log('Transaction-out: ', await output.getAmount());
}
async onBalancesChanged(newBalance, newUnlockedBalance) {
console.log('Balance changed: ', await newBalance.toString(), await newUnlockedBalance.toString());
}
});
this.MoneroConnection = await this.MoneroWallet.getDaemonConnection();
this.ConnectionManager.setConnection(this.MoneroConnection);
await this.ConnectionManager.checkConnection();
await this.ConnectionManager.startCheckingConnection(1000);
await this.ConnectionManager.addListener(new class extends monerojs.MoneroConnectionManagerListener {
onConnectionChanged(data) {
console.log("\nNode status changed: ", {
uri: data.getUri(),
isConnected: data.isConnected(),
isOnline: data.isOnline(),
isAuthenticated: data.isAuthenticated()
});
}
});
console.log("Starting Express JS server...")
await this.startExpressServer();
}
async startExpressServer() {
const frontEnd = {
active: this.LumoConfiguration.server.frontend,
routes: [{
url: '/',
page: 'index'
},
{
url: '/help',
page: 'help',
},
{
url: '/transaction/:hash',
page: 'transaction'
}
]
}
this.ExpressServer.use(express.json({
limit: '10kb'
}));
this.ExpressServer.use(cors({
origin: '*',
methods: ['GET']
}));
if (this.LumoConfiguration.server.frontend) {
this.ExpressServer.use(express.static(path.join(this.LumoRootDir, '/public')));
this.ExpressServer.use('/bootstrap', express.static(path.join(this.LumoRootDir, '/node_modules/bootstrap/dist/css/')));
this.ExpressServer.use('/bootstrap-icons', express.static(path.join(this.LumoRootDir, '/node_modules/bootstrap-icons/font/')));
this.ExpressServer.set('view engine', 'ejs');
} else {
this.ExpressServer.use('/images', express.static(path.join(this.LumoRootDir, '/public/images/')));
}
this.ExpressServer.listen(this.LumoConfiguration.server.port, this.LumoConfiguration.server.url, async () => {
console.log("Server started: ");
console.log("\nExpress end points are visible: ", {
frontend: {
active: this.LumoConfiguration.server.frontend,
url: this.LumoConfiguration.server.url + ":" + this.LumoConfiguration.server.port
},
api: {
type: 'GET',
wallet: this.LumoConfiguration.server.url + ":" + this.LumoConfiguration.server.port + "/api/wallet",
walletProof: this.LumoConfiguration.server.url + ":" + this.LumoConfiguration.server.port + "/api/wallet/proof",
transaction: this.LumoConfiguration.server.url + ":" + this.LumoConfiguration.server.port + "/api/transaction/:hash",
transactionProof: this.LumoConfiguration.server.url + ":" + this.LumoConfiguration.server.port + "/api/transaction/proof/:hash",
node: this.LumoConfiguration.server.url + ":" + this.LumoConfiguration.server.port + "/api/node",
price: this.LumoConfiguration.server.url + ":" + this.LumoConfiguration.server.port + "/api/price"
}
});
console.log("\nAll systems are go: ", String(new Date()));
}).on('error', async (e) => {
console.log("Express JS Server listen failed: ", e.toString());
this.exit();
});
/*
Load front end routes if turned on
*/
if (frontEnd.active) {
frontEnd.routes.forEach((item, i) => {
this.ExpressServer.get(item.url, async (req, res) => {
let data = {
wallet: await this.getWallet(),
node: await this.getNode(),
price: await this.getPrice()
}
if (item.page == 'transaction') {
data.transaction = await this.getTransaction(req.params.hash);
if (data.transaction.error) {
res.render('404', data);
} else {
res.render('transaction', data);
}
} else {
res.render(item.page, data);
}
});
});
}
/*
Load api routes always
*/
this.ExpressServer.get('/api/price', async (req, res) => {
let data = await this.getPrice();
res.json(data);
});
this.ExpressServer.get('/api/wallet', async (req, res) => {
let data = await this.getWallet();
if (!data.error) {
data.qrCode = path.join(req.get('Host'), '/' + data.qrCode);
res.json(data);
} else {
res.sendStatus(404);
}
});
this.ExpressServer.get('/api/wallet/proof', async (req, res) => {
var fileContents = Buffer.from(await this.MoneroWallet.getReserveProofWallet(''));
var readStream = new stream.PassThrough();
readStream.end(fileContents);
res.set('Content-disposition', 'attachment; filename=monero_reserve_proof');
res.set('Content-Type', 'text/plain');
readStream.pipe(res);
});
this.ExpressServer.get('/api/transaction/:hash', async (req, res) => {
let data = await this.getTransaction(req.params.hash);
if (!data.error) {
res.json(data);
} else {
res.sendStatus(404);
}
});
this.ExpressServer.get('/api/transaction/proof/:hash', async (req, res) => {
let hash = req.params.hash;
try {
let transaction = this.getTransaction(hash);
if (!transaction.error) {
var fileContents = Buffer.from(await this.MoneroWallet.getSpendProof(hash));
var readStream = new stream.PassThrough();
readStream.end(fileContents);
res.set('Content-disposition', 'attachment; filename=monero_spend_proof');
res.set('Content-Type', 'text/plain');
readStream.pipe(res);
} else {
res.sendStatus(404);
}
} catch (e) {
console.log(e);
res.sendStatus(404);
}
});
this.ExpressServer.get('/api/node', async (req, res) => {
let data = await this.getNode();
if (!data.error) {
res.json(data);
} else {
res.sendStatus(404);
}
});
/*
re-route any request not specifically set to 404
if front end enabled render 404 page
*/
this.ExpressServer.get('*', async (req, res) => {
if (this.LumoConfiguration.server.frontend && !req.url.includes("api/")) {
let data = {
node: await this.getNode(),
price: await this.getPrice()
}
res.render('404', data);
} else {
res.sendStatus(404);
}
});
}
/*
Main functions
*/
async getNode() {
let data = {};
try {
let network = await this.MoneroDaemon.getInfo();
network = await network.getNetworkType();
data = {
error: false,
height: await this.MoneroDaemon.getHeight(),
network: this.MoneroNetworks[network],
connected: await this.MoneroDaemon.isConnected()
};
} catch (e) {
data = {
error: true,
message: 'Node connection error'
};
console.log(data);
}
return data;
}
async getWallet() {
let data = {};
try {
let primaryAddress = await this.MoneroWallet.getPrimaryAddress();
let balance = await this.MoneroWallet.getBalance(0);
let unlockedBalance = await this.MoneroWallet.getUnlockedBalance(0);
let transactions = [];
let tempTransactions = [];
let totalXmr = 0;
let txs = await this.MoneroWallet.getTxs({
transferQuery: {
accountIndex: 0
}
});
await txs.forEach(async (item, key) => {
let obj = await item.toJson();
if (await item.getBlock()) {
let block = await item.getBlock();
let timestamp = await block.getTimestamp();
obj.feeFormatted = this.formatAmount(obj.fee);
obj.block = block.toJson();
obj.timestamp = timestamp;
obj.timestampFormatted = this.formatDate(timestamp);
} else {
obj.timestamp = 0;
obj.block = {
height: await this.MoneroWallet.getHeight()
}
}
obj.amount = 0;
if (obj.isIncoming) {
obj.incomingTransfers.forEach((item, i) => {
obj.amount += item.amount;
});
obj.amountFormatted = this.formatAmount(obj.amount);
} else if (obj.isOutgoing) {
obj.amount = obj.outgoingTransfer.amount;
}
obj.amountFormatted = this.formatAmount(obj.amount);
totalXmr += parseFloat(obj.amount);
tempTransactions.push(obj);
});
let date = Date.now() * 1000;
data = {
primaryAddress: primaryAddress,
publicSpendKey: await this.MoneroWallet.getPublicSpendKey(),
publicViewKey: await this.MoneroWallet.getPublicViewKey(),
privateViewKey: await this.MoneroWallet.getPrivateViewKey(),
balance: balance.toString(),
unlockedBalance: unlockedBalance.toString(),
balanceFormatted: this.formatAmount(balance.toString()),
unlockedBalanceFormatted: this.formatAmount(unlockedBalance.toString()),
total: totalXmr,
totalFormatted: this.formatAmount(totalXmr),
totalSpent: this.formatAmount(totalXmr - parseFloat(balance.toString())),
totalSpentPercentage: this.formatPercentage((totalXmr - parseFloat(balance.toString())), totalXmr),
transactions: tempTransactions.sort(function(a, b) {
return new Date(b.timestamp) - new Date(a.timestamp);
}),
qrCode: '/images/' + primaryAddress + "-qr.png",
lastChecked: date,
lastCheckedFormated: this.formatDate(date)
};
} catch (e) {
data = {
error: true,
message: 'Wallet connection error'
};
console.log(data);
}
return data;
}
async getTransaction(hash) {
try {
let item = await this.MoneroWallet.getTx(hash)
if (item) {
let tx = item.toJson();
if (await item.getBlock()) {
let block = await item.getBlock();
let timestamp = await block.getTimestamp();
tx.feeFormatted = this.formatAmount(tx.fee);
tx.block = block.toJson();
tx.timestamp = timestamp;
tx.timestampFormatted = this.formatDate(timestamp);
} else {
tx.timestamp = 0;
tx.block = {
height: await this.MoneroWallet.getHeight()
}
}
tx.amount = 0;
if (tx.isIncoming) {
tx.incomingTransfers.forEach((item, i) => {
tx.amount += item.amount;
});
tx.amountFormatted = this.formatAmount(tx.amount);
} else if (tx.isOutgoing) {
tx.amount = tx.outgoingTransfer.amount;
}
tx.amountFormatted = this.formatAmount(tx.amount);
return tx;
} else {
return {
error: true,
message: 'Transaction not found'
}
}
} catch (e) {
console.log(e.toString());
return {
error: true,
message: 'Transaction not found'
}
}
}
async getPrice() {
let currency = this.LumoConfiguration.monero.currency;
this.MoneroPriceCache = await JSON.parse(fs.readFileSync(this.MoneroPriceCacheFile));
if (this.MoneroPriceCache.data && !this.priceApiExpired(this.MoneroPriceCache.data.last_checked)) {
console.log('cache is less than 5 minutes old. Returned cached data');
return this.MoneroPriceCache.data;
} else {
return new Promise((resolve, reject) => {
console.log('go get fresh price data');
https.get("https://api.coingecko.com/api/v3/coins/markets?vs_currency=" + currency + "&ids=monero", (resp) => {
let data = '';
resp.on('data', (chunk) => {
data += chunk;
});
resp.on('end', () => {
let obj = {
data: JSON.parse(data)[0]
}
obj.data.current_price = (obj.data.current_price).toFixed(2);
obj.data.currency = currency;
obj.data.last_checked = new Date();
this.MoneroPriceCache = obj;
fs.writeFile(this.MoneroPriceCacheFile, JSON.stringify(this.MoneroPriceCache), (err) => {
if (err) throw err;
console.log('Price cache file has been saved!');
});
resolve(obj.data);
});
}).on("error", (e) => {
console.log(e);
reject({
error: true,
message: 'Price api failed'
});
});
});
}
}
async close() {
if (this.MoneroWallet) {
this.MoneroWallet.close(true);
console.log('Saving wallet...')
}
}
async exit() {
console.log("Exiting...");
process.exit();
}
/*
Helper functions
*/
priceApiExpired(lastChecked) {
let date = new Date();
let expired = false;
console.log("Last checked: " + new Date(lastChecked));
console.log("Current date: " + date);
const FIVE_MIN = 5 * 60 * 1000;
if ((date.getTime() - new Date(lastChecked).getTime()) > FIVE_MIN) {
console.log('Last price api check is greater than 5 minutes');
expired = true;
}
return expired;
}
formatPercentage(x, y) {
x = parseFloat(x);
y = parseFloat(y);
let percentage = Math.round(parseFloat((x / y) * 100));
percentage = ((isNaN(percentage)) ? 0 : percentage);
return percentage + "%";
}
formatAmount(amount) {
let amt = parseFloat(amount);
return (amt / 1000000000000).toFixed(6)
};
formatDate(UNIX_timestamp) {
var a = new Date(UNIX_timestamp * 1000);
a = (((String(a) === "Invalid Date") ? new Date() : a));
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
var year = a.getFullYear();
var month = months[a.getMonth()];
var date = a.getDate();
var hour = a.getHours();
var min = a.getMinutes();
var sec = a.getSeconds();
var time = month + ' ' + date + ' ' + year + ' ' + hour + ':' + min + ':' + sec;
return time;
}
isBool(val) {
return val === false || val === true;
}
}
/*
Declare and launch app
*/
const lumo = new Lumo();