initial commit

main
OPV 1 year ago
parent 0fbb38cd0b
commit 8acd694640

20
.gitignore vendored

@ -0,0 +1,20 @@
#Ignore all
*.md
*.log
logs
*.env
*.o
*.DS_Store
node_modules/
package-lock.json
npm-debug.log
*wallet
*-qr.png
*.keys
.cache
#Include
!README.md
!.gitignore

@ -1,6 +1,6 @@
MIT License
Copyright (c) <year> <copyright holders>
Copyright (c) 2023 Rezisto.net
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

@ -1,3 +1,155 @@
# lumo
## Table of contents
A Node JS server that allows you to host a semi transparent and auditable Monero (XMR) wallet for donation and charity purposes.
* [Introduction](#introduction)
* [Limitations](#limitations)
* [Disclaimer](#disclaimer)
* [Prerequisites](#prerequisites)
* [Installation](#installation)
* [Editing Defaults](#editing-defaults)
* [Technologies](#technologies)
* [License](#license)
* [Donations](#donations)
# Introduction
Lumo (loo-mo) , Esperanto for "Light", is a Node JS server that allows you to self host a semi transparent and auditable Monero (XMR) wallet for donation and charity purposes. It was created as a way to bridge transparency with Monero's unique privacy features. The use case is if you would like to accept Monero donations transparently while still providing your donors with privacy. This allows donors to verify their donation(s) and amount(s) easily in an anonymous way online. It also allows donors to monitor funds in and out (there is a caveat to the out transactions though. See the limitations section below).
Think of it as turning a Monero wallet into a Bitcoin-esque wallet that is viewable on a blockchain explorer, albeit yours is self-hosted and done voluntarily. All of this is achieved while donor/receiver information is still kept private. Got to love Monero!
![main screen](docs/images/frontend-homepage.png)
## Limitations
Due to Monero's rock solid security and unique privacy features, the output amounts of the lumo wallet cannot be 100% trustless. Some trust is involved believing that the API is telling the donors or whoever is viewing the truth about the **amounts** being spent. The wallet owning the transaction can be proven but the amounts technically cannot nor can a receiver address be obtained. For more information about this, please read the following below:
### Spend Proof
> If you see a string beginning with "SpendProofV1", this means that the transaction private key (txkey) was not available. This could be because you had made the transaction from a different wallet. If you don't preserve knowledge of a txkey after making a transaction, it is lost forever and cannot be recovered by scanning the blockchain. It also means you will be unable to recover the per-output shared secret for the output sent to the other person in the transaction.
> The SpendProofV1 string contains a second, newly created ring signature that proves exactly the same input ownership again, but using different random initialization data. Only someone that owned the inputs of the transaction would be able to create this second valid ring signature. That is all the SpendProofV1 string contains. There is no key derivation communicated, because it is unknown by the wallet in this scenario.
> Since the ring signature(s) in the SpendProofV1 string will be valid for one of the transactions on the blockchain, it would be possible to identify the transaction from this SpendProofV1 string.
Source & Credit: [https://monero.stackexchange.com/a/8131](https://monero.stackexchange.com/a/8131)
## Disclaimer
This is experimental software and should be considered as a proof-of-concept. It creates a full mirror of your Monero wallet and displays certain aspects to the public. This means that after wallet creation it can technically be used to send, receive, view transaction in and out, etc etc. Lumo itself only retrieves specific wallet information (balances and transactions) and outputs it to the built in API. However, a bad actor with access, could manipulate the code to spend funds. While it does work on mainnet, it is highly advised as of now to use stagenet for testing purposes. To mitigate risk, Lumo requires the password you set at install to unlock the wallet file every time you run it. Therefore, if someone did alter code they would need the password to re-run the app. So make your password a strong one. There will also most likely be bugs so please notify me of them. Once testing period is done we can hopefully transition to mainnet!
With that explained, use it at your own risk.
## Development
For now, I'll only be supporting Linux for the project, therefore the instructions below will be for it.
To get a local copy up and running follow these simple steps.
### Prerequisites
Before proceeding you should install the following:
- [node (v16)](https://nodejs.org/en/download/)
- [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) (probably bundled with your installation)
- An existing Monero wallet (you can use the Official GUI/CLI or Monerujo (stagenet version on android) to create a wallet)
### Installation
Clone the repository, install all dependencies using npm.
```bash
git clone https://git.rezisto.net/Apps/lumo.git
cd lumo
npm install
```
### Configuration
Edit config.json with your information
```bash
nano config.json
{
"server":{
"url":"0.0.0.0|127.0.0.1|localhost",
"port":3000,
"frontend":true
},
"monero": {
"daemon":{
"url":"0.0.0.0|127.0.0.1|localhost|remote node ip",
"port":38081,
"username":"",
"password":""
},
"wallet":{
"primary_address":"",
"network":"mainnet|stagenet|testnet",
"password": "very strong password here",
"restore_height": 0,
"private_spend": ""
},
"currency":"USD"
}
}
```
#### Options
1. Server:
- This is your local Express JS server which will be an API interface for the Monero wallet. Lumo comes with a basic EJS front end baked in that displays the wallet information (as seen in the demo). However, you can turn this off by setting front end to false in the config file. This will give you a wallet information only API for you to send requests to in whatever technology you wish to use for a front end.
2. Monero:
- Daemon:
- This is the Monero node you wish to use. You can use any node (local or remote) you want. Just make sure you trust the node which you choose. It's always best to use your own node! Node and wallet network must coincide (ie. if you create a stagenet wallet then node must also be a stagenet node)
- Wallet:
- This is a mirror of whatever wallet you want to use. It must be an already created wallet and you must provide a new password (does not have to be the same as you currently use and it is highly advised that this password is strong), the primary address of the wallet, private spend key, and restore height. All of which can be found in the wallet app you created the wallet in. This information is needed to create a full mirror of the wallet (incoming and *outgoing transactions). All the information is deleted post install and you will be required to enter the password you gave each time you run the app. This is a restriction in how you can run Lumo but is done for security purposes. None of the wallet's sensitive information is stored or sent anywhere (except the node you choose).
- Currency:
- This can be any currency code (USD, EUR, CAD, etc etc) that is supported by Coingecko API.
Save file and exit
```bash
ctrl s
ctrl x
```
### Run
```bash
npm start
```
### API Endpoints
* type: 'GET',
* wallet: '/api/wallet',
* walletProof: '/api/wallet/proof',
* transaction: '/api/transaction/:hash',
* transactionProof: '/api/transaction/proof/:hash',
* node: '/api/node',
* price: '/api/price'
## Technologies
* "bootstrap": "^5.2.2",
* "bootstrap-icons": "^1.10.3",
* "cors": "^2.8.5",
* "ejs": "^3.1.8",
* "express": "^4.18.2",
* "monero-javascript": "^0.7.4",
* "qrcode": "^1.5.1"
## License
This project is licensed under MIT.
## Donations
If you find this useful and/or educational then please consider donating Monero to the address below. All donations will go to continued development and funding of the Rezisto ecosystem.
![donate qr](docs/images/donate.png)
86DgQQ12SJk26rnK5LPv9cfdmQwxDCkYXTN9ff7refGSSottZnR3tjk2bhVymtzmnq6hFheeWy22pePnxdNfB26nQH6oLbk

@ -0,0 +1,23 @@
{
"server":{
"url":"0.0.0.0|127.0.0.1|localhost",
"port":3000,
"frontend":true
},
"monero": {
"daemon":{
"url":"0.0.0.0|127.0.0.1|localhost|remote node ip",
"port":38081,
"username":"",
"password":""
},
"wallet":{
"primary_address":"",
"network":"mainnet|stagenet|testnet",
"password": "very strong password here",
"restore_height": 0,
"private_spend": ""
},
"currency":"USD"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@ -0,0 +1,813 @@
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();

@ -0,0 +1,20 @@
{
"name": "lumo",
"version": "1.0.0",
"description": "A Node JS server that allows you to host a semi transparent and auditable Monero (XMR) wallet for donation and charity purposes.",
"main": "main.js",
"scripts": {
"start": "node ./main.js"
},
"dependencies": {
"bootstrap": "^5.2.2",
"bootstrap-icons": "^1.10.3",
"cors": "^2.8.5",
"ejs": "^3.1.8",
"express": "^4.18.2",
"monero-javascript": "^0.7.4",
"qrcode": "^1.5.1"
},
"author": "Rezisto.net",
"license": "MIT"
}

@ -0,0 +1,182 @@
body {
font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif !important;
font-weight: 300;
-webkit-font-smoothing: antialiased;
overflow-x: hidden !important;
}
a {
text-decoration: none !important;
}
p {
word-wrap: break-word !important;
}
h6:first-letter {
text-transform: uppercase;
}
h2 {
margin: 5% auto !important;
}
h3 {
margin: 2% auto !important;
}
.nav-item {
padding-top:7px !important;
}
.bi-question-circle {
height:18px !important;
width: 18px !important;
font-size: 18px !important;
}
.orange, .bi-question-circle {
background: #f26922;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.btn-primary {
background: #f26922 !important;
color: #fff !important;
border: none !important;
border-radius: 30px;
}
.btn-primary:hover {
opacity: 0.9 !important;
}
.address {
width: 90%;
text-align: center;
margin: 10px auto;
}
.container-fluid {
width:80% !important;
margin:0px auto;
max-width: 80% !important;
}
.bi-shield-check {
font-weight: bold !important;
}
.loading,
.loading:after {
border-radius: 50%;
width: 2em;
height: 2em;
}
.loading {
margin: 15px auto;
font-size: 10px;
position: relative;
text-indent: -9999em;
border-top: .2em solid rgba(242,104,34, 0.2);
border-right: .2em solid rgba(242,104,34, 0.2);
border-bottom: .2em solid rgba(242,104,34, 0.2);
border-left: .2em solid rgba(242,104,34,1);
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation: loading 1s infinite linear;
animation: loading 1s infinite linear;
float: right;
}
@-webkit-keyframes loading {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes loading {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
#icon {
width:36px;
height:auto;
margin-right: 10px;
margin-top:-2px;
border-radius: 120px !important;
padding: 2px;
background: #FFF;
}
#wallet h1 {
margin-top: 15%;
}
#qr-code {
margin: 10px auto;
width:70%;
height: auto;
}
#progress-bar {
padding: 2px;
width:100%;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.09), 0 -1px 1px #FFF, 0 1px 0 #FFF;
margin:0px auto;
margin-bottom: 20px;
}
#progress-amount {
background: rgb(251,169,68);
background: linear-gradient(351deg, rgba(251,169,68,1) 0%, rgba(242,105,34,1) 100%); padding:4px;
}
#progress-amount.zero {
background: none !important;
}
#page-404 {
margin: 2% auto;
}
#page-404 h1 {
font-size: 15em !important;
}
@media only screen
and (max-width : 500px) {
h6 {
font-size: 16px !important;
}
p, .table, .list-group {
font-size: 14px !important;
}
}
@media only screen
and (max-width : 860px) {
h6 {
font-size: 13px !important;
}
.border-start, .border-end {
border: none !important;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@ -0,0 +1,14 @@
<%- include('partials/header'); %>
<div class="d-flex align-items-center justify-content-center" id="page-404">
<div class="text-center">
<h1 class="display-1 fw-bold">404</h1>
<p class="fs-3">Page not found.</p>
<p class="lead">
The page youre looking for cannot be found.
</p>
<a href="/" class="btn btn-primary">Go Home</a>
</div>
</div>
<%- include('partials/footer'); %>

@ -0,0 +1,89 @@
<%- include('partials/header'); %>
<div class="row py-4">
<div class="col-12 text-center bg-light rounded-3 p-2">
<h3>Help Page</h3>
<p>A list of terms and helpful guides</p>
</div>
<div class="col-12">
<br /><br />
<ul class="list-group">
<li class="list-group-item">
<div class="row" id="view-key">
<div class="col-md-3 text-center p-5">
<h4>View Key</h4>
</div>
<div class="col-md-9 p-2">
<p><b>The Basics:</b><br /> One of two sets of private and public cryptographic keys that each account has, with the private view key required to view all transactions related to the account.</p>
<p>Monero features an opaque blockchain (with an explicit allowance system called the view key), in sharp contrast with transparent blockchains used by any other cryptocurrency not based on CryptoNote. Thus, Monero is said to be "private,
optionally transparent".</p>
<p>Every Monero address has a private viewkey which can be shared. By sharing a viewkey, a person is allowing access to view every incoming transaction for that address. However, outgoing transactions cannot be reliably viewed as of June
2017. Therefore, the balance of a Monero address as shown via a viewkey should not be relied upon.</p>
<p>Source: <a class="orange" href="https://www.getmonero.org/resources/moneropedia/viewkey.html">https://www.getmonero.org/resources/moneropedia/viewkey.html</a></p>
</div>
</li>
<li class="list-group-item">
<div class="row" id="spend-key">
<div class="col-md-3 text-center p-5">
<h4>Spend Key</h4>
</div>
<div class="col-md-9 p-2">
<p><b>The Basics:</b><br />
One of the two pairs of private and public cryptographic keys that each account has, with the private spend key used to spend any funds in the account.</p>
<p><b>In-depth Information:</b><br />
The private spend key is a 256-bit integer that is used to sign Monero transactions. With the current deterministic key derivation method of the official wallet, the private spend key is also an alternate representation of the mnemonic
seed. It can be used to derive all other account keys.</p>
<p>Source: <a class="orange" href="https://www.getmonero.org/resources/moneropedia/spendkey.html">https://www.getmonero.org/resources/moneropedia/spendkey.html</a></p>
</div>
</div>
</li>
<li class="list-group-item">
<div class="row" id="spend-proof">
<div class="col-md-3 text-center p-5">
<h4>Spend Proof</h4>
</div>
<div class="col-md-9 p-2">
<p><b>The Basics:</b><br />
A generated signature file proving that you own this transaction.</p>
<p><b>In-depth Information</b><br />
If you see a string beginning with "SpendProofV1", this means that the transaction private key (txkey) was not available. This could be because you had made the transaction from a different wallet. If you don't preserve knowledge of a
txkey after making a transaction, it is lost forever and cannot be recovered by scanning the blockchain. It also means you will be unable to recover the per-output shared secret for the output sent to the other person in the
transaction.</p>
<p>Since you can't prove knowledge of the txkey or the per-output shared secret, you can instead prove that you owned the outputs that were spent in the transaction. The transaction you originally sent would have already included a ring
signature proving that you owned one output per ring in the transaction.</p>
<p>The SpendProofV1 string contains a second, newly created ring signature that proves exactly the same input ownership again, but using different random initialization data. Only someone that owned the inputs of the transaction would be
able to create this second valid ring signature. That is all the SpendProofV1 string contains. There is no key derivation communicated, because it is unknown by the wallet in this scenario.</p>
<p>Since the ring signature(s) in the SpendProofV1 string will be valid for one of the transactions on the blockchain, it would be possible to identify the transaction from this SpendProofV1 string.</p>
<p>Source: <a class="orange" href="https://monero.stackexchange.com/a/8131">https://monero.stackexchange.com/a/8131</a></p>
<p><b>Verification:</b><br />
1. Download the spend proof file <br />
2. Using the Monero wallet cli run command: <small>check_spend_proof [tx hash] monero_spend_proof</small></p>
</div>
</div>
</li>
<li class="list-group-item">
<div class="row" id="reserve-proof">
<div class="col-md-3 text-center p-5">
<h4>Reserve Proof</h4>
</div>
<div class="col-md-9 p-2">
<p><b>The Basics:</b><br />
A generated signature file proving that you currently have this amount in your wallet.</p>
<p><b>Verification:</b><br />
1. Download the reserve proof file <br />
2. Using the Monero wallet cli run command: <br /> <small>check_reserve_proof <%= wallet.primaryAddress %> monero_reserve_proof</small></p>
</div>
</div>
</li>
</ul>
</div>
</div>
<%- include('partials/footer'); %>

@ -0,0 +1,73 @@
<%- include('partials/header'); %>
<%- include('partials/wallet'); %>
<div class="row rounded-3 border p-2">
<div class="col-md-6">
<div class=" p-2">
<h2>Total Donated</h2>
<p>A total amount of <b><%= wallet.totalFormatted %> XMR</b> has been donated to this wallet as of: <br /> <b><%= wallet.lastCheckedFormated %></b></p>
</div>
</div>
<div class="col-md-6">
<div class=" p-2 text-end border-start">
<h2>Total Spent</h2>
<p>A total amount of <b><%= wallet.totalSpent %> XMR</b> (<%= wallet.totalSpentPercentage %>) has been spent from this wallet as of: <br /> <b><%= wallet.lastCheckedFormated %></b></p>
</div>
</div>
<div class="col-12">
<div id="progress-bar" class="rounded-3">
<div id="progress-amount" class="rounded-3" style="width: <%= wallet.totalSpentPercentage %>;"></div>
</div>
</div>
</div>
<br /><br />
<div class="row text-center p-2 border rounded-3">
<div class="col-12">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 4%;"><i class="bi bi-arrow-left-right"></i></th>
<th style="width: 10%;">Amount</th>
<th style="width: 10%;">Height</th>
<th style="width: 21%;">Hash</th>
<th style="width: 10%;">Fee</th>
<th style="width: 21%;">Timestamp</th>
<th style="width: 16%;">Confirmed</th>
</tr>
</thead>
<tbody>
<%
wallet.transactions.forEach(function(item) { %>
<tr style="transform: rotate(0);">
<th scope="row" class="text-start">
<a href="/transaction/<%= item.hash %>" class="stretched-link orange"><% if(item.isIncoming){ %> <i class="bi bi-arrow-right-short"></i> <% }else{ %> <i class="bi bi-arrow-left-short"></i> <% } %></a>
</th>
<td><%= item.amountFormatted %></td>
<td><%= item.block.height %></td>
<td><%= item.hash %></td>
<td><%= item.feeFormatted %></td>
<td><%= item.timestampFormatted %></td>
<td>
<div class="row">
<div class="col-6 text-center">
<% if(item.isConfirmed && item.numConfirmations > 10) { %> <i class="bi-shield-check orange"></i> <% } %>
<% if(item.isConfirmed && item.numConfirmations < 11) { %> (<%= item.numConfirmations %> / 10) <% } %>
</div>
<div class="col-6">
<% if(item.numConfirmations < 11) { %> <div class="loading" style="margin-top:0px; margin-right: 0px;"></div> <% } %>
</div>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<%- include('partials/footer'); %>

@ -0,0 +1,12 @@
</div>
<br /><br />
<div class="container-fluid border-top py-5">
<div class="row">
<div class="col-6 text-start">
<p>© 2023, <a href="https://rezisto.net" class="orange">Rezisto.net</a></p>
</div>
<div class="col-6 text-end">
<p>Powered by: <a href="https://git.rezisto.net/Apps/lumo" class="orange">lumo</a></p>
</div>
</div>
</div>

@ -0,0 +1,47 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="description" content="A Node JS server that allows you to host a semi transparent and auditable Monero (XMR) wallet for donation and charity purposes.">
<meta name="author" content="Rezisto.net">
<title>lumo</title>
<link rel="icon" type="image/x-icon" href="/images/icon.png">
<link rel="stylesheet" type="text/css" href="/bootstrap/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/bootstrap-icons/bootstrap-icons.css">
<link rel="stylesheet" type="text/css" href="/css/style.css">
</head>
<div class="container-fluid py-3">
<div class="row py-3 border-bottom">
<div class="col-6">
<a href="/" class="d-flex align-items-center text-dark text-decoration-none">
<img src="/images/icon.png" id="icon">
<span class="fs-4">lumo</span>
</a>
</div>
<div class="col-6 text-end nav-item">
<a href="/help" class="me-3 py-2 text-dark text-decoration-none">Help</a>
</div>
</div>
<br /><br />
<div class="row text-center p-2 row-no-gutters">
<div class="col-xs-1 col-sm-3 col-md-3">
<h6>Monero (XMR)</h6>
<p><small>Blockchain</small></p>
</div>
<div class="col-xs-1 col-sm-3 col-md-3">
<h6><%= node.network %></h6>
<p><small>Network</small></p>
</div>
<div class="col-xs-1 col-sm-3 col-md-3">
<h6><%= ((node.connected) ? 'Connected' : 'Disconnected') %></h6>
<p><small>Node</small></p>
</div>
<div class="col-xs-1 col-sm-3 col-md-3">
<h6><%= price.current_price + ' ' +price.currency %> = 1 XMR</h6>
<p><small>Fiat Market</small></p>
</div>
</div>
<br />

@ -0,0 +1,37 @@
<div id="wallet" class="row text-center p-2 bg-light rounded-3">
<div class="col-md-4">
<h1><%= wallet.unlockedBalanceFormatted %></h1>
<p>Unlocked Balance</p>
</div>
<div class="col-md-4">
<img id="qr-code" class="border rounded-3" src="<%= wallet.qrCode %>" />
<p id="address"><small><%= wallet.primaryAddress %></small></p>
<p><small><b>Primary Address</b></small></p>
</div>
<div class="col-md-4">
<h1><%= wallet.balanceFormatted %></h1>
<p>Full Balance</p>
</div>
</div>
<br /><br />
<div class="row p-2 text-center">
<div class="col-md-4">
<p class="address"><%= wallet.privateViewKey %></p>
<p><small><b>Private View</b></small></p>
<p><small><a href="/help#view-key" title="Help - Private View Key"><i class="bi bi-question-circle"></i></a></small></p>
</div>
<div class="col-md-4 p-4">
<div class="d-grid gap-2">
<a href="/api/wallet/proof" class="btn btn-primary grad"><i class="bi bi-cloud-download"></i> Reserve Proof</a>
<br />
<p><small><a href="/help#reserve-proof" title="Help - Reserve Proof"><i class="bi bi-question-circle"></i></a></small></p>
</div>
</div>
<div class="col-md-4">
<p class="address"><%= wallet.publicSpendKey %></p>
<p><small><b>Public Spend</b></small></p>
<p><small><a href="/help#spend-key" title="Help - Public Spend Key"><i class="bi bi-question-circle"></i></a></small></p>
</div>
</div>
<br /><br />

@ -0,0 +1,92 @@
<%- include('partials/header'); %>
<div class="row py-4 text-center">
<div class="col-12 text-center bg-light rounded-3 p-2">
<h3>Transaction</h3>
<p> <%= transaction.hash %></p>
</div>
<div class="col-12">
<br/><br/>
<ul class="list-group border-0">
<li class="list-group-item">
<div class="row">
<div class="col-6 text-start">
<p>Amount</p>
</div>
<div class="col-6 text-end">
<%= transaction.amountFormatted %>
</div>
</div>
</li>
<li class="list-group-item">
<div class="row">
<div class="col-6 text-start">
<p>Fee</p>
</div>
<div class="col-6 text-end">
<%= transaction.feeFormatted %>
</div>
</div>
</li>
<li class="list-group-item">
<div class="row">
<div class="col-6 text-start">
<p>Locked</p>
</div>
<div class="col-6 text-end">
<%= transaction.isLocked %>
</div>
</div>
</li>
<li class="list-group-item">
<div class="row">
<div class="col-6 text-start">
<p>Confirmations</p>
</div>
<div class="col-6 text-end">
<%= transaction.numConfirmations %>
</div>
</div>
</li>
<li class="list-group-item">
<div class="row">
<div class="col-6 text-start">
<p>Block</p>
</div>
<div class="col-6 text-end">
<%= transaction.block.height %>
</div>
</div>
</li>
<li class="list-group-item">
<div class="row">
<div class="col-6 text-start">
<p>Timstamp</p>
</div>
<div class="col-6 text-end">
<%= transaction.timestampFormatted +' / '+transaction.timestamp %>
</div>
</div>
</li>
<li class="list-group-item">
<div class="row">
<div class="col-6 text-start">
<p>Outgoing</p>
</div>
<div class="col-6 text-end">
<%= transaction.isOutgoing %>
</div>
<div class="col-12">
<% if(transaction.isOutgoing) { %>
<a href="/api/transaction/proof/<%= transaction.hash %>" class="btn btn-primary"><i class="bi bi-cloud-download"></i> Spend Proof</a>
<br/><br/>
<p><small><a href="/help#spend-proof" title="Help - Spend Proof"><i class="bi bi-question-circle"></i></a></small></p>
<% } %>
</div>
</div>
</li>
</ul>
</div>
</div>
<%- include('partials/footer'); %>
Loading…
Cancel
Save