oxen-electron-gui-wallet/src-electron/main-process/modules/wallet-rpc.js

2852 lines
79 KiB
JavaScript

import child_process from "child_process";
const request = require("request-promise");
const queue = require("promise-queue");
const http = require("http");
const os = require("os");
const fs = require("fs-extra");
const path = require("upath");
const crypto = require("crypto");
const portscanner = require("portscanner");
export class WalletRPC {
constructor(backend) {
this.backend = backend;
this.data_dir = null;
this.wallet_dir = null;
this.auth = [];
this.id = 0;
this.net_type = "mainnet";
this.heartbeat = null;
this.lnsHeartbeat = null;
this.wallet_state = {
open: false,
name: "",
password_hash: null,
balance: null,
unlocked_balance: null,
lnsRecords: []
};
this.isRPCSyncing = false;
this.dirs = null;
this.last_height_send_time = Date.now();
// A mapping of name => type
this.purchasedNames = {};
this.height_regexes = [
{
string: /Processed block: <([a-f0-9]+)>, height (\d+)/,
height: match => match[2]
},
{
string: /Skipped block by height: (\d+)/,
height: match => match[1]
},
{
string: /Skipped block by timestamp, height: (\d+)/,
height: match => match[1]
},
{
string: /Blockchain sync progress: <([a-f0-9]+)>, height (\d+)/,
height: match => match[2]
}
];
this.agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
this.queue = new queue(1, Infinity);
}
// this function will take an options object for testnet, data-dir, etc
start(options) {
const { net_type } = options.app;
const daemon = options.daemons[net_type];
return new Promise((resolve, reject) => {
let daemon_address = `${daemon.rpc_bind_ip}:${daemon.rpc_bind_port}`;
if (daemon.type == "remote") {
daemon_address = `${daemon.remote_host}:${daemon.remote_port}`;
}
crypto.randomBytes(64 + 64 + 32, (err, buffer) => {
if (err) throw err;
let auth = buffer.toString("hex");
this.auth = [
auth.substr(0, 64), // rpc username
auth.substr(64, 64), // rpc password
auth.substr(128, 32) // password salt
];
const args = [
"--rpc-login",
this.auth[0] + ":" + this.auth[1],
"--rpc-bind-port",
options.wallet.rpc_bind_port,
"--daemon-address",
daemon_address,
// "--log-level", options.wallet.log_level,
"--log-level",
"*:WARNING,net*:FATAL,net.http:DEBUG,global:INFO,verify:FATAL,stacktrace:INFO"
];
const { net_type, wallet_data_dir, data_dir } = options.app;
this.net_type = net_type;
this.data_dir = data_dir;
this.wallet_data_dir = wallet_data_dir;
this.dirs = {
mainnet: this.wallet_data_dir,
stagenet: path.join(this.wallet_data_dir, "stagenet"),
testnet: path.join(this.wallet_data_dir, "testnet")
};
this.wallet_dir = path.join(this.dirs[net_type], "wallets");
args.push("--wallet-dir", this.wallet_dir);
const log_file = path.join(
this.dirs[net_type],
"logs",
"wallet-rpc.log"
);
args.push("--log-file", log_file);
if (net_type === "testnet") {
args.push("--testnet");
} else if (net_type === "stagenet") {
args.push("--stagenet");
}
if (fs.existsSync(log_file)) {
fs.truncateSync(log_file, 0);
}
if (!fs.existsSync(this.wallet_dir)) {
fs.mkdirpSync(this.wallet_dir);
}
// save this info for later RPC calls
this.protocol = "http://";
this.hostname = "127.0.0.1";
this.port = options.wallet.rpc_bind_port;
const rpcExecutable =
process.platform === "win32"
? "loki-wallet-rpc.exe"
: "loki-wallet-rpc";
// eslint-disable-next-line no-undef
const rpcPath = path.join(__ryo_bin, rpcExecutable);
// Check if the rpc exists
if (!fs.existsSync(rpcPath)) {
reject(
new Error(
"Failed to find Loki Wallet RPC. Please make sure you anti-virus has not removed it."
)
);
return;
}
portscanner
.checkPortStatus(this.port, this.hostname)
.catch(() => "closed")
.then(status => {
if (status === "closed") {
const options =
process.platform === "win32" ? {} : { detached: true };
this.walletRPCProcess = child_process.spawn(
rpcPath,
args,
options
);
this.walletRPCProcess.stdout.on("data", data => {
process.stdout.write(`Wallet: ${data}`);
let lines = data.toString().split("\n");
let match,
height = null;
let isRPCSyncing = false;
for (const line of lines) {
for (const regex of this.height_regexes) {
match = line.match(regex.string);
if (match) {
height = regex.height(match);
isRPCSyncing = true;
break;
}
}
}
// Keep track on wether a wallet is syncing or not
this.sendGateway("set_wallet_data", {
isRPCSyncing
});
this.isRPCSyncing = isRPCSyncing;
if (height && Date.now() - this.last_height_send_time > 1000) {
this.last_height_send_time = Date.now();
this.sendGateway("set_wallet_data", {
info: {
height
}
});
}
});
this.walletRPCProcess.on("error", err =>
process.stderr.write(`Wallet: ${err}`)
);
this.walletRPCProcess.on("close", code => {
process.stderr.write(`Wallet: exited with code ${code} \n`);
this.walletRPCProcess = null;
this.agent.destroy();
if (code === null) {
reject(new Error("Failed to start wallet RPC"));
}
});
// To let caller know when the wallet is ready
let intrvl = setInterval(() => {
this.sendRPC("get_languages").then(data => {
if (!data.hasOwnProperty("error")) {
clearInterval(intrvl);
resolve();
} else {
if (
this.walletRPCProcess &&
data.error.cause &&
data.error.cause.code === "ECONNREFUSED"
) {
// Ignore
} else {
clearInterval(intrvl);
if (this.walletRPCProcess) this.walletRPCProcess.kill();
this.walletRPCProcess = null;
reject(new Error("Could not connect to wallet RPC"));
}
}
});
}, 1000);
} else {
reject(new Error(`Wallet RPC port ${this.port} is in use`));
}
});
});
});
}
async handle(data) {
let params = data.data;
switch (data.method) {
case "has_password":
this.hasPassword();
break;
case "validate_address":
this.validateAddress(params.address);
break;
case "decrypt_record": {
const record = await this.decryptLNSRecord(params.type, params.name);
this.sendGateway("set_decrypt_record_result", {
record,
decrypted: !!record
});
break;
}
case "copy_old_gui_wallets":
this.copyOldGuiWallets(params.wallets || []);
break;
case "list_wallets":
this.listWallets();
break;
case "create_wallet":
this.createWallet(params.name, params.password, params.language);
break;
case "restore_wallet":
this.restoreWallet(
params.name,
params.password,
params.seed,
params.refresh_type,
params.refresh_type == "date"
? params.refresh_start_date
: params.refresh_start_height
);
break;
case "restore_view_wallet":
// TODO: Decide if we want this for loki
this.restoreViewWallet(
params.name,
params.password,
params.address,
params.viewkey,
params.refresh_type,
params.refresh_type == "date"
? params.refresh_start_date
: params.refresh_start_height
);
break;
case "import_wallet":
this.importWallet(params.name, params.password, params.path);
break;
case "open_wallet":
this.openWallet(params.name, params.password);
break;
case "close_wallet":
this.closeWallet();
break;
case "stake":
this.stake(
params.password,
params.amount,
params.key,
params.destination
);
break;
case "register_service_node":
this.registerSnode(params.password, params.string);
break;
case "update_service_node_list":
this.updateServiceNodeList();
break;
case "unlock_stake":
this.unlockStake(
params.password,
params.service_node_key,
params.confirmed || false
);
break;
case "transfer":
this.transfer(
params.password,
params.amount,
params.address,
params.payment_id,
params.priority,
!!params.isSweepAll
);
break;
case "relay_tx":
this.relayTransaction(
params.metadataList,
params.isBlink,
params.addressSave,
params.note,
!!params.isSweepAll
);
break;
case "purchase_lns":
this.purchaseLNS(
params.password,
params.type,
params.name,
params.value,
params.owner || "",
params.backup_owner || ""
);
break;
case "lns_renew_mapping":
this.lnsRenewMapping(params.password, params.type, params.name);
break;
case "lns_known_names":
this.lnsKnownNames();
break;
case "update_lns_mapping":
this.updateLNSMapping(
params.password,
params.type,
params.name,
params.value,
params.owner || "",
params.backup_owner || ""
);
break;
case "prove_transaction":
this.proveTransaction(params.txid, params.address, params.message);
break;
case "check_transaction":
this.checkTransactionProof(
params.signature,
params.txid,
params.address,
params.message
);
break;
case "add_address_book":
this.addAddressBook(
params.address,
params.payment_id,
params.description,
params.name,
params.starred,
params.hasOwnProperty("index") ? params.index : false
);
break;
case "delete_address_book":
this.deleteAddressBook(
params.hasOwnProperty("index") ? params.index : false
);
break;
case "save_tx_notes":
this.saveTxNotes(params.txid, params.note);
break;
case "rescan_blockchain":
this.rescanBlockchain();
break;
case "rescan_spent":
this.rescanSpent();
break;
case "get_private_keys":
this.getPrivateKeys(params.password);
break;
case "export_key_images":
this.exportKeyImages(params.password, params.path);
break;
case "import_key_images":
this.importKeyImages(params.password, params.path);
break;
case "change_wallet_password":
this.changeWalletPassword(params.old_password, params.new_password);
break;
case "delete_wallet":
this.deleteWallet(params.password);
break;
default:
}
}
isValidPasswordHash(password_hash) {
if (this.wallet_state.password_hash === null) return true;
return this.wallet_state.password_hash === password_hash.toString("hex");
}
hasPassword() {
if (this.wallet_state.password_hash === null) {
this.sendGateway("set_has_password", false);
return;
}
// We need to check if the hash generated with an empty string is the same as the password_hash we are storing
crypto.pbkdf2(
"",
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("set_has_password", false);
return;
}
// If the pass hash doesn't match empty string then we don't have a password
this.sendGateway(
"set_has_password",
this.wallet_state.password_hash !== password_hash.toString("hex")
);
}
);
}
validateAddress(address) {
this.sendRPC("validate_address", {
address
}).then(data => {
if (data.hasOwnProperty("error")) {
this.sendGateway("set_valid_address", {
address,
valid: false
});
return;
}
const { valid, nettype } = data.result;
const netMatches = this.net_type === nettype;
const isValid = valid && netMatches;
this.sendGateway("set_valid_address", {
address,
valid: isValid,
nettype
});
});
}
createWallet(filename, password, language) {
// Reset the status error
this.sendGateway("reset_wallet_error");
this.sendRPC("create_wallet", {
filename,
password,
language
}).then(data => {
if (data.hasOwnProperty("error")) {
this.sendGateway("set_wallet_error", { status: data.error });
return;
}
// store hash of the password so we can check against it later when requesting private keys, or for sending txs
this.wallet_state.password_hash = crypto
.pbkdf2Sync(password, this.auth[2], 1000, 64, "sha512")
.toString("hex");
this.wallet_state.name = filename;
this.wallet_state.open = true;
this.finalizeNewWallet(filename);
});
}
// the date should be in ms from epoch (Jan 1 1970)
restoreWallet(
filename,
password,
seed,
refresh_type,
refresh_start_timestamp_or_height
) {
if (refresh_type == "date") {
// Convert timestamp to 00:00 and move back a day
// Core code also moved back some amount of blocks
let timestamp = refresh_start_timestamp_or_height;
timestamp = timestamp - (timestamp % 86400000) - 86400000;
this.sendGateway("reset_wallet_error");
this.backend.daemon.timestampToHeight(timestamp).then(height => {
if (height === false) {
this.sendGateway("set_wallet_error", {
status: {
code: -1,
i18n: "notification.errors.invalidRestoreDate"
}
});
} else {
this.restoreWallet(filename, password, seed, "height", height);
}
});
return;
}
let restore_height = Number.parseInt(refresh_start_timestamp_or_height);
// if the height can't be parsed just start from block 0
if (!restore_height) {
restore_height = 0;
}
seed = seed.trim().replace(/\s{2,}/g, " ");
this.sendGateway("reset_wallet_error");
this.sendRPC("restore_deterministic_wallet", {
filename,
password,
seed,
restore_height
}).then(data => {
if (data.hasOwnProperty("error")) {
this.sendGateway("set_wallet_error", { status: data.error });
return;
}
// store hash of the password so we can check against it later when requesting private keys, or for sending txs
this.wallet_state.password_hash = crypto
.pbkdf2Sync(password, this.auth[2], 1000, 64, "sha512")
.toString("hex");
this.wallet_state.name = filename;
this.wallet_state.open = true;
this.finalizeNewWallet(filename);
});
}
restoreViewWallet(
filename,
password,
address,
viewkey,
refresh_type,
refresh_start_timestamp_or_height
) {
if (refresh_type == "date") {
// Convert timestamp to 00:00 and move back a day
// Core code also moved back some amount of blocks
let timestamp = refresh_start_timestamp_or_height;
timestamp = timestamp - (timestamp % 86400000) - 86400000;
this.backend.daemon.timestampToHeight(timestamp).then(height => {
if (height === false) {
this.sendGateway("set_wallet_error", {
status: {
code: -1,
i18n: "notification.errors.invalidRestoreDate"
}
});
} else {
this.restoreViewWallet(
filename,
password,
address,
viewkey,
"height",
height
);
}
});
return;
}
let refresh_start_height = refresh_start_timestamp_or_height;
if (!Number.isInteger(refresh_start_height)) {
refresh_start_height = 0;
}
this.sendRPC("restore_view_wallet", {
filename,
password,
address,
viewkey,
refresh_start_height
}).then(data => {
if (data.hasOwnProperty("error")) {
this.sendGateway("set_wallet_error", { status: data.error });
return;
}
// store hash of the password so we can check against it later when requesting private keys, or for sending txs
this.wallet_state.password_hash = crypto
.pbkdf2Sync(password, this.auth[2], 1000, 64, "sha512")
.toString("hex");
this.wallet_state.name = filename;
this.wallet_state.open = true;
this.finalizeNewWallet(filename);
});
}
importWallet(wallet_name, password, import_path) {
// Reset the status error
this.sendGateway("reset_wallet_error");
// trim off suffix if exists
if (import_path.endsWith(".keys")) {
import_path = import_path.substring(
0,
import_path.length - ".keys".length
);
} else if (import_path.endsWith(".address.txt")) {
import_path = import_path.substring(
0,
import_path.length - ".address.txt".length
);
}
if (!fs.existsSync(import_path)) {
this.sendGateway("set_wallet_error", {
status: {
code: -1,
i18n: "notification.errors.invalidWalletPath"
}
});
return;
} else {
let destination = path.join(this.wallet_dir, wallet_name);
if (fs.existsSync(destination) || fs.existsSync(destination + ".keys")) {
this.sendGateway("set_wallet_error", {
status: {
code: -1,
i18n: "notification.errors.walletAlreadyExists"
}
});
return;
}
try {
fs.copySync(import_path, destination, { errorOnExist: true });
if (fs.existsSync(import_path + ".keys")) {
fs.copySync(import_path + ".keys", destination + ".keys", {
errorOnExist: true
});
}
} catch (e) {
this.sendGateway("set_wallet_error", {
status: {
code: -1,
i18n: "notification.errors.copyWalletFail"
}
});
return;
}
this.sendRPC("open_wallet", {
filename: wallet_name,
password
})
.then(data => {
if (data.hasOwnProperty("error")) {
if (fs.existsSync(destination)) fs.unlinkSync(destination);
if (fs.existsSync(destination + ".keys"))
fs.unlinkSync(destination + ".keys");
this.sendGateway("set_wallet_error", {
status: data.error
});
return;
}
// store hash of the password so we can check against it later when requesting private keys, or for sending txs
this.wallet_state.password_hash = crypto
.pbkdf2Sync(password, this.auth[2], 1000, 64, "sha512")
.toString("hex");
this.wallet_state.name = wallet_name;
this.wallet_state.open = true;
this.finalizeNewWallet(wallet_name);
})
.catch(() => {
this.sendGateway("set_wallet_error", {
status: {
code: -1,
i18n: "notification.errors.unknownError"
}
});
});
}
}
finalizeNewWallet(filename) {
Promise.all([
this.sendRPC("get_address"),
this.sendRPC("getheight"),
this.sendRPC("getbalance", { account_index: 0 }),
this.sendRPC("query_key", { key_type: "mnemonic" }),
this.sendRPC("query_key", { key_type: "spend_key" }),
this.sendRPC("query_key", { key_type: "view_key" })
]).then(data => {
let wallet = {
info: {
name: filename,
address: "",
balance: 0,
unlocked_balance: 0,
height: 0,
view_only: false
},
secret: {
mnemonic: "",
spend_key: "",
view_key: ""
}
};
for (let n of data) {
if (n.hasOwnProperty("error") || !n.hasOwnProperty("result")) {
continue;
}
if (n.method == "get_address") {
wallet.info.address = n.result.address;
} else if (n.method == "getheight") {
wallet.info.height = n.result.height;
} else if (n.method == "getbalance") {
wallet.info.balance = n.result.balance;
wallet.info.unlocked_balance = n.result.unlocked_balance;
} else if (n.method == "query_key") {
wallet.secret[n.params.key_type] = n.result.key;
if (n.params.key_type == "spend_key") {
if (/^0*$/.test(n.result.key)) {
wallet.info.view_only = true;
}
}
}
}
this.saveWallet().then(() => {
let address_txt_path = path.join(
this.wallet_dir,
filename + ".address.txt"
);
if (!fs.existsSync(address_txt_path)) {
fs.writeFile(address_txt_path, wallet.info.address, "utf8", () => {
this.listWallets();
});
} else {
this.listWallets();
}
});
this.sendGateway("set_wallet_data", wallet);
this.startHeartbeat();
});
}
openWallet(filename, password) {
this.sendGateway("reset_wallet_error");
this.sendRPC("open_wallet", {
filename,
password
}).then(data => {
if (data.hasOwnProperty("error")) {
this.sendGateway("set_wallet_error", { status: data.error });
return;
}
let address_txt_path = path.join(
this.wallet_dir,
filename + ".address.txt"
);
if (!fs.existsSync(address_txt_path)) {
this.sendRPC("get_address", { account_index: 0 }).then(data => {
if (data.hasOwnProperty("error") || !data.hasOwnProperty("result")) {
return;
}
fs.writeFile(address_txt_path, data.result.address, "utf8", () => {
this.listWallets();
});
});
}
// store hash of the password so we can check against it later when requesting private keys, or for sending txs
this.wallet_state.password_hash = crypto
.pbkdf2Sync(password, this.auth[2], 1000, 64, "sha512")
.toString("hex");
this.wallet_state.name = filename;
this.wallet_state.open = true;
this.startHeartbeat();
this.purchasedNames = {};
// Check if we have a view only wallet by querying the spend key
this.sendRPC("query_key", { key_type: "spend_key" }).then(data => {
if (data.hasOwnProperty("error") || !data.hasOwnProperty("result")) {
return;
}
if (/^0*$/.test(data.result.key)) {
this.sendGateway("set_wallet_data", {
info: {
view_only: true
}
});
}
});
});
}
startHeartbeat() {
clearInterval(this.heartbeat);
this.heartbeat = setInterval(() => {
this.heartbeatAction();
}, 5000);
this.heartbeatAction(true);
clearInterval(this.lnsHeartbeat);
this.lnsHeartbeat = setInterval(() => {
this.updateLocalLNSRecords();
}, 30 * 1000); // Every 30 seconds
this.updateLocalLNSRecords();
}
heartbeatAction(extended = false) {
Promise.all([
this.sendRPC("get_address", { account_index: 0 }, 5000),
this.sendRPC("getheight", {}, 5000),
this.sendRPC("getbalance", { account_index: 0 }, 5000)
]).then(data => {
let didError = false;
let wallet = {
status: {
code: 0,
message: "OK"
},
info: {
name: this.wallet_state.name
},
transactions: {
tx_list: []
},
address_list: {
primary: [],
used: [],
unused: [],
address_book: [],
address_book_starred: []
}
};
for (let n of data) {
if (n.hasOwnProperty("error") || !n.hasOwnProperty("result")) {
// Maybe we also need to look into the other error codes it could give us
// Error -13: No wallet file - This occurs when you call open wallet while another wallet is still syncing
if (extended && n.error && n.error.code === -13) {
didError = true;
}
continue;
}
if (n.method == "getheight") {
wallet.info.height = n.result.height;
this.sendGateway("set_wallet_data", {
info: {
height: n.result.height
}
});
} else if (n.method == "get_address") {
wallet.info.address = n.result.address;
this.sendGateway("set_wallet_data", {
info: {
address: n.result.address
}
});
} else if (n.method == "getbalance") {
if (
this.wallet_state.balance == n.result.balance &&
this.wallet_state.unlocked_balance == n.result.unlocked_balance
) {
// continue
}
this.wallet_state.balance = wallet.info.balance = n.result.balance;
this.wallet_state.unlocked_balance = wallet.info.unlocked_balance =
n.result.unlocked_balance;
this.sendGateway("set_wallet_data", {
info: wallet.info
});
// if balance has recently changed, get updated list of transactions and used addresses
let actions = [this.getTransactions(), this.getAddressList()];
actions.push(this.getAddressBook());
Promise.all(actions).then(data => {
for (let n of data) {
Object.keys(n).map(key => {
wallet[key] = Object.assign(wallet[key], n[key]);
});
}
this.sendGateway("set_wallet_data", wallet);
});
}
}
// Set the wallet state on initial heartbeat
if (extended) {
if (!didError) {
this.sendGateway("set_wallet_data", wallet);
} else {
this.closeWallet().then(() => {
this.sendGateway("set_wallet_error", {
status: {
code: -1,
i18n: "notification.errors.failedWalletOpen"
}
});
});
}
}
});
}
async updateLocalLNSRecords() {
console.log("calling updateLocalLNSRecords");
try {
const addressData = await this.sendRPC(
"get_address",
{ account_index: 0 },
5000
);
if (
addressData.hasOwnProperty("error") ||
!addressData.hasOwnProperty("result")
) {
return;
}
// Pull out all our addresses from the data and make sure they're valid
const results = addressData.result.addresses || [];
const addresses = results.map(a => a.address).filter(a => !!a);
if (addresses.length === 0) return;
const records = await this.backend.daemon.getLNSRecordsForOwners(
addresses
);
// We need to ensure that we decrypt any incoming records that we already have
const currentRecords = this.wallet_state.lnsRecords;
const recordsToUpdate = { ...this.purchasedNames };
const newRecords = records.map(record => {
// If we have a new record or we haven't decrypted our current record then we should return the new record
const current = currentRecords.find(
c => c.name_hash === record.name_hash
);
if (!current || !current.name) return record;
// We need to check if we need to re-decrypt the record.
// This is only necessary if the encrypted_value changed.
const needsToUpdate =
current.encrypted_value !== record.encrypted_value;
if (needsToUpdate) {
const { name, type } = current;
recordsToUpdate[name] = type;
return {
name,
...record
};
}
// Otherwise just update our current record with new information (in the case that owner or backup_owner was updated)
return {
...current,
...record
};
});
this.wallet_state.lnsRecords = newRecords;
// console.log("New LNS records found in update:");
// console.log(newRecords);
// ========= FETCH THE CACHED RECORDS HERE AND JOIN WITH THE OTHER RECORDS =====
// ===== UI DISPLAYS UNLOCKED RECORDS IF THE ENTRY HAS A 'name' and 'value' field
// const isSession = record => record.type === "session";
// let nonSessionRecords = newRecords.filter(record => !isSession(record));
// console.log("non session records");
// console.log(nonSessionRecords);
// fetch the known (cached) records from the wallet and add the data
// to the records being set in state
// let known_names = await this.lnsKnownNames();
// let known_name_hashes = known_names.map(k => k.hashed);
// console.log("Known name hashes");
// console.log(known_name_hashes);
// where the known matches the records we need to set values here
// let cached_records = newRecords.find(r => known_name_hashes.includes(r));
// console.log("Cached records:");
// console.log(cached_records);
// for (let r of newRecords) {
// for (let k of known_names) {
// if (k.hashed === r.name_hash) {
// r["name"] = k.name;
// // r["value"] = k.value;
// }
// }
// }
// let final = newRecords.forEach(c => {
// known_name_hashes.for
// })
// console.log("Records being set to lnsRecords");
// console.log(newRecords);
this.sendGateway("set_wallet_data", { lnsRecords: newRecords });
// Decrypt the records serially
let updatePromise = Promise.resolve();
for (const [name, type] of Object.entries(recordsToUpdate)) {
updatePromise = updatePromise.then(() => {
this.decryptLNSRecord(type, name);
});
}
} catch (e) {
console.debug("Something went wrong when updating lns records: ", e);
}
}
/*
Get the LNS records cached in this (local) wallet. Call alongside the other call to update as
we go
*/
async lnsKnownNames() {
try {
let data = await this.sendRPC("lns_known_names");
for (let r of data.result.known_names) {
console.log(r);
}
if (data.result && data.result.known_names) {
return data.result.known_names;
} else {
return [];
}
} catch (e) {
console.log("There was an error getting known records: " + e);
return [];
}
}
/*
Renews an LNS (Lokinet) mapping, since they can expire
type can be:
lokinet_1y, lokinet_2y, lokinet_5y, lokinet_10y
*/
lnsRenewMapping(password, type, name) {
let _name = name.trim().toLowerCase();
// the RPC accepts names with the .loki already appeneded only
// can be lokinet_1y, lokinet_2y, lokinet_5y, lokinet_10y
if (type.startsWith("lokinet")) {
_name = _name + ".loki";
}
crypto.pbkdf2(
password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("set_lns_status", {
code: -1,
i18n: "notification.errors.internalError",
sending: false
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("set_lns_status", {
code: -1,
i18n: "notification.errors.invalidPassword",
sending: false
});
return;
}
const params = {
type,
name: _name
};
this.sendRPC("lns_renew_mapping", params).then(data => {
if (data.hasOwnProperty("error")) {
let error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
this.sendGateway("set_lns_status", {
code: -1,
message: error,
sending: false
});
return;
}
this.purchasedNames[name.trim()] = type;
setTimeout(() => this.updateLocalLNSRecords(), 5000);
this.sendGateway("set_lns_status", {
code: 0,
i18n: "notification.positive.nameRenewed",
sending: false
});
});
}
);
}
/*
Get our LNS record and update our wallet state with decrypted values.
This will return `null` if the record is not in our currently stored records.
*/
async decryptLNSRecord(type, name) {
let _type = type;
// type can initially be "lokinet_1y" etc. on a purchase
if (type.startsWith("lokinet")) {
_type = "lokinet";
}
try {
const record = await this.getLNSRecord(_type, name);
if (!record) return null;
// Update our current records with the new decrypted record
const currentRecords = this.wallet_state.lnsRecords;
const isOurRecord = currentRecords.find(
c => c.name_hash === record.name_hash
);
if (!isOurRecord) return null;
const newRecords = currentRecords.map(current => {
if (current.name_hash === record.name_hash) {
return record;
}
return current;
});
this.wallet_state.lnsRecords = newRecords;
this.sendGateway("set_wallet_data", { lnsRecords: newRecords });
return record;
} catch (e) {
console.debug("Something went wrong when updating lns record: ", e);
return null;
}
}
/*
Get a LNS record associated with the given name
*/
async getLNSRecord(type, name) {
// We currently only support session and lokinet
const types = ["session", "lokinet"];
if (!types.includes(type)) return null;
if (!name || name.trim().length === 0) return null;
const lowerCaseName = name.toLowerCase();
let fullName = lowerCaseName;
if (type === "lokinet" && !name.endsWith(".loki")) {
fullName = fullName + ".loki";
}
const nameHash = await this.hashLNSName(type, lowerCaseName);
if (!nameHash) return null;
const record = await this.backend.daemon.getLNSRecord(nameHash);
if (!record || !record.encrypted_value) return null;
// Decrypt the value if possible
const value = await this.decryptLNSValue(
type,
fullName,
record.encrypted_value
);
return {
name: fullName,
value,
...record
};
}
async hashLNSName(type, name) {
if (!type || !name) return null;
let fullName = name;
if (type === "lokinet" && !name.endsWith(".loki")) {
fullName = fullName + ".loki";
}
try {
const data = await this.sendRPC("lns_hash_name", {
type,
name: fullName
});
if (data.hasOwnProperty("error")) {
let error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
throw new Error(error);
}
return (data.result && data.result.name) || null;
} catch (e) {
console.debug("Failed to hash lns name: ", e);
return null;
}
}
async decryptLNSValue(type, name, encrypted_value) {
if (!type || !name || !encrypted_value) return null;
let fullName = name;
if (type === "lokinet" && !name.endsWith(".loki")) {
fullName = fullName + ".loki";
}
try {
const data = await this.sendRPC("lns_decrypt_value", {
type,
name: fullName,
encrypted_value
});
if (data.hasOwnProperty("error")) {
let error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
throw new Error(error);
}
return (data.result && data.result.value) || null;
} catch (e) {
console.debug("Failed to decrypt lns value: ", e);
return null;
}
}
stake(password, amount, service_node_key, destination) {
crypto.pbkdf2(
password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("set_snode_status", {
stake: {
code: -1,
i18n: "notification.errors.internalError",
sending: false
}
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("set_snode_status", {
stake: {
code: -1,
i18n: "notification.errors.invalidPassword",
sending: false
}
});
return;
}
amount = (parseFloat(amount) * 1e9).toFixed(0);
this.sendRPC("stake", {
amount,
destination,
service_node_key
}).then(data => {
if (data.hasOwnProperty("error")) {
let error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
this.sendGateway("set_snode_status", {
stake: {
code: -1,
message: error,
sending: false
}
});
return;
}
// Update the new snode list
this.backend.daemon.updateServiceNodes();
this.sendGateway("set_snode_status", {
stake: {
code: 0,
i18n: "notification.positive.stakeSuccess",
sending: false
}
});
});
}
);
}
registerSnode(password, register_service_node_str) {
crypto.pbkdf2(
password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("set_snode_status", {
registration: {
code: -1,
i18n: "notification.errors.internalError",
sending: false
}
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("set_snode_status", {
registration: {
code: -1,
i18n: "notification.errors.invalidPassword",
sending: false
}
});
return;
}
this.sendRPC("register_service_node", {
register_service_node_str
}).then(data => {
if (data.hasOwnProperty("error")) {
const error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
this.sendGateway("set_snode_status", {
registration: {
code: -1,
message: error,
sending: false
}
});
return;
}
// Update the new snode list
this.backend.daemon.updateServiceNodes();
this.sendGateway("set_snode_status", {
registration: {
code: 0,
i18n: "notification.positive.registerServiceNodeSuccess",
sending: false
}
});
});
}
);
}
async updateServiceNodeList() {
this.backend.daemon.updateServiceNodes();
}
unlockStake(password, service_node_key, confirmed = false) {
const sendError = (message, i18n = true) => {
const key = i18n ? "i18n" : "message";
this.sendGateway("set_snode_status", {
unlock: {
code: -1,
[key]: message,
sending: false
}
});
};
// Unlock code 0 means success, 1 means can unlock, -1 means error
crypto.pbkdf2(
password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
sendError("notification.errors.internalError");
return;
}
if (!this.isValidPasswordHash(password_hash)) {
sendError("notification.errors.invalidPassword");
return;
}
const sendRPC = path => {
return this.sendRPC(path, {
service_node_key
}).then(data => {
if (data.hasOwnProperty("error")) {
const error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
sendError(error, false);
return null;
}
if (!data.hasOwnProperty("result")) {
sendError("notification.errors.failedServiceNodeUnlock");
return null;
}
return data.result;
});
};
if (confirmed) {
sendRPC("request_stake_unlock").then(data => {
if (!data) return;
const unlock = {
code: data.unlocked ? 0 : -1,
message: data.msg,
sending: false
};
// Update the new snode list
if (data.unlocked) {
this.backend.daemon.updateServiceNodes();
}
this.sendGateway("set_snode_status", { unlock });
});
} else {
sendRPC("can_request_stake_unlock").then(data => {
if (!data) return;
const unlock = {
code: data.can_unlock ? 1 : -1,
message: data.msg,
sending: false
};
this.sendGateway("set_snode_status", { unlock });
});
}
}
);
}
// submits the transaction to the blockchain, irreversible from here
async relayTransaction(metadataList, isBlink, addressSave, note, isSweepAll) {
// for a sweep these don't exist
let address = "";
let payment_id = "";
let address_book = "";
if (addressSave) {
address = addressSave.address;
payment_id = addressSave.payment_id;
address_book = addressSave.address_book;
}
let failed = false;
let errorMessage = "Failed to relay transaction";
// submit each transaction individually
for (const hex of metadataList) {
const params = {
hex,
blink: isBlink
};
// don't try submit more txs if a prev one failed
if (failed) break;
try {
const data = await this.sendRPC("relay_tx", params);
if (data.hasOwnProperty("error")) {
errorMessage = data.error.message || errorMessage;
failed = true;
break;
} else if (data.hasOwnProperty("result")) {
const tx_hash = data.result.tx_hash;
if (note && note !== "") {
this.saveTxNotes(tx_hash, note);
}
} else {
errorMessage = "Invalid format of relay_tx RPC return message";
failed = true;
break;
}
} catch (e) {
failed = true;
errorMessage = e.toString();
}
}
// for updating state on the correct page
const gatewayEndpoint = isSweepAll
? "set_sweep_all_status"
: "set_tx_status";
if (!failed) {
this.sendGateway(gatewayEndpoint, {
code: 0,
i18n: "notification.positive.sendSuccess",
sending: false
});
if (address_book.hasOwnProperty("save") && address_book.save) {
this.addAddressBook(
address,
payment_id,
address_book.description,
address_book.name
);
}
return;
}
this.sendGateway(gatewayEndpoint, {
code: -1,
message: errorMessage,
sending: false
});
}
// prepares params and provides a "confirm" popup to allow the user to check
// send address and tx fees before sending
// isSweepAll refers to if it's the sweep from service nodes page
transfer(password, amount, address, payment_id, priority, isSweepAll) {
const cryptoCallback = (err, password_hash) => {
if (err) {
this.sendGateway("set_tx_status", {
code: -1,
i18n: "notification.errors.internalError",
sending: false
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("set_tx_status", {
code: -1,
i18n: "notification.errors.invalidPassword",
sending: false
});
return;
}
amount = (parseFloat(amount) * 1e9).toFixed(0);
// if sending "All" the funds, then we need to send all - fee (sweep_all)
// To be amended after the hardfork, v8.
// https://github.com/loki-project/loki-electron-gui-wallet/issues/181
const isSweepAllRPC = amount == this.wallet_state.unlocked_balance;
const rpc_endpoint = isSweepAllRPC ? "sweep_all" : "transfer_split";
const rpcSpecificParams = isSweepAllRPC
? {
address,
account_index: 0
}
: {
destinations: [{ amount: amount, address: address }]
};
const params = {
...rpcSpecificParams,
priority,
do_not_relay: true,
get_tx_metadata: true
};
if (payment_id) {
params.payment_id = payment_id;
}
// for updating state on the correct page
const gatewayEndpoint = isSweepAll
? "set_sweep_all_status"
: "set_tx_status";
this.sendRPC(rpc_endpoint, params)
.then(data => {
if (data.hasOwnProperty("error") || !data.hasOwnProperty("result")) {
let error = "";
if (data.error && data.error.message) {
error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
} else {
error = `Incorrect result from ${rpc_endpoint} RPC call`;
}
this.sendGateway(gatewayEndpoint, {
code: -1,
message: error,
sending: false
});
return;
}
// update state to show a confirm popup
this.sendGateway(gatewayEndpoint, {
code: 1,
i18n: "strings.awaitingConfirmation",
sending: false,
txData: {
// target address for a sweep all
address: data.params.address,
isSweepAll: isSweepAllRPC,
amountList: data.result.amount_list,
metadataList: data.result.tx_metadata_list,
feeList: data.result.fee_list,
priority: data.params.priority,
// for a "send" tx
destinations: data.params.destinations
}
});
})
.catch(err => {
this.sendGateway(gatewayEndpoint, {
code: -1,
message: err.message,
sending: false
});
});
};
crypto.pbkdf2(password, this.auth[2], 1000, 64, "sha512", cryptoCallback);
}
purchaseLNS(password, type, name, value, owner, backupOwner) {
let _name = name.trim().toLowerCase();
const _owner = owner.trim() === "" ? null : owner;
const backup_owner = backupOwner.trim() === "" ? null : backupOwner;
// the RPC accepts names with the .loki already appeneded only
// can be lokinet_1y, lokinet_2y, lokinet_5y, lokinet_10y
if (type.startsWith("lokinet")) {
_name = _name + ".loki";
value = value + ".loki";
}
crypto.pbkdf2(
password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("set_lns_status", {
code: -1,
i18n: "notification.errors.internalError",
sending: false
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("set_lns_status", {
code: -1,
i18n: "notification.errors.invalidPassword",
sending: false
});
return;
}
const params = {
type,
owner: _owner,
backup_owner,
name: _name,
value
};
this.sendRPC("lns_buy_mapping", params).then(data => {
if (data.hasOwnProperty("error")) {
let error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
this.sendGateway("set_lns_status", {
code: -1,
message: error,
sending: false
});
return;
}
this.purchasedNames[name.trim()] = type;
// Fetch new records and then get the decrypted record for the one we just inserted
setTimeout(() => this.updateLocalLNSRecords(), 5000);
this.sendGateway("set_lns_status", {
code: 0,
i18n: "notification.positive.namePurchased",
sending: false
});
});
}
);
}
updateLNSMapping(password, type, name, value, owner, backupOwner) {
let _name = name.trim().toLowerCase();
const _owner = owner.trim() === "" ? null : owner;
const backup_owner = backupOwner.trim() === "" ? null : backupOwner;
// updated records have type "lokinet" or "session"
// UI passes the values without the extension
if (type === "lokinet") {
_name = _name + ".loki";
value = value + ".loki";
}
crypto.pbkdf2(
password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("set_lns_status", {
code: -1,
i18n: "notification.errors.internalError",
sending: false
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("set_lns_status", {
code: -1,
i18n: "notification.errors.invalidPassword",
sending: false
});
return;
}
const params = {
type,
owner: _owner,
backup_owner,
name: _name,
value
};
this.sendRPC("lns_update_mapping", params).then(data => {
if (data.hasOwnProperty("error")) {
let error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
this.sendGateway("set_lns_status", {
code: -1,
message: error,
sending: false
});
return;
}
this.purchasedNames[name.trim()] = type;
// Fetch new records and then get the decrypted record for the one we just inserted
setTimeout(() => this.updateLocalLNSRecords(), 5000);
// Optimistically update our record
const { lnsRecords } = this.wallet_state;
const newRecords = lnsRecords.map(record => {
if (
record.type === type &&
record.name &&
record.name.toLowerCase() === _name
) {
return {
...record,
owner: _owner,
backup_owner,
value
};
}
return record;
});
this.wallet_state.lnsRecords = newRecords;
this.sendGateway("set_wallet_data", { lnsRecords: newRecords });
this.sendGateway("set_lns_status", {
code: 0,
i18n: "notification.positive.lnsRecordUpdated",
sending: false
});
});
}
);
}
proveTransaction(txid, address, message) {
const _address = address.trim() === "" ? null : address;
const _message = message.trim() === "" ? null : message;
const rpc_endpoint = _address ? "get_tx_proof" : "get_spend_proof";
const params = {
txid,
address: _address,
message: _message
};
this.sendGateway("set_prove_transaction_status", {
code: 1,
message: "",
state: {}
});
this.sendRPC(rpc_endpoint, params).then(data => {
if (data.hasOwnProperty("error")) {
let error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
this.sendGateway("set_prove_transaction_status", {
code: -1,
message: error,
state: {}
});
return;
}
this.sendGateway("set_prove_transaction_status", {
code: 0,
message: "",
state: {
txid,
...(data.result || {})
}
});
});
}
checkTransactionProof(signature, txid, address, message) {
const _address = address.trim() === "" ? null : address;
const _message = message.trim() === "" ? null : message;
const rpc_endpoint = _address ? "check_tx_proof" : "check_spend_proof";
const params = {
txid,
signature,
address: _address,
message: _message
};
this.sendGateway("set_check_transaction_status", {
code: 1,
message: "",
state: {}
});
this.sendRPC(rpc_endpoint, params).then(data => {
if (data.hasOwnProperty("error")) {
let error =
data.error.message.charAt(0).toUpperCase() +
data.error.message.slice(1);
this.sendGateway("set_check_transaction_status", {
code: -1,
message: error,
state: {}
});
return;
}
this.sendGateway("set_check_transaction_status", {
code: 0,
message: "",
state: {
txid,
...(data.result || {})
}
});
});
}
rescanBlockchain() {
this.sendRPC("rescan_blockchain");
}
rescanSpent() {
this.sendRPC("rescan_spent");
}
getPrivateKeys(password) {
crypto.pbkdf2(
password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("set_wallet_data", {
secret: {
mnemonic: "notification.errors.internalError",
spend_key: -1,
view_key: -1
}
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("set_wallet_data", {
secret: {
mnemonic: "notification.errors.invalidPassword",
spend_key: -1,
view_key: -1
}
});
return;
}
Promise.all([
this.sendRPC("query_key", { key_type: "mnemonic" }),
this.sendRPC("query_key", { key_type: "spend_key" }),
this.sendRPC("query_key", { key_type: "view_key" })
]).then(data => {
let wallet = {
secret: {
mnemonic: "",
spend_key: "",
view_key: ""
}
};
for (let n of data) {
if (n.hasOwnProperty("error") || !n.hasOwnProperty("result")) {
continue;
}
wallet.secret[n.params.key_type] = n.result.key;
}
this.sendGateway("set_wallet_data", wallet);
});
}
);
}
getAddressList() {
return new Promise(resolve => {
Promise.all([
this.sendRPC("get_address", { account_index: 0 }),
this.sendRPC("getbalance", { account_index: 0 })
]).then(data => {
for (let n of data) {
if (n.hasOwnProperty("error") || !n.hasOwnProperty("result")) {
resolve({});
return;
}
}
let num_unused_addresses = 10;
let wallet = {
info: {
address: data[0].result.address,
balance: data[1].result.balance,
unlocked_balance: data[1].result.unlocked_balance
// num_unspent_outputs: data[1].result.num_unspent_outputs
},
address_list: {
primary: [],
used: [],
unused: []
}
};
for (let address of data[0].result.addresses) {
address.balance = null;
address.unlocked_balance = null;
address.num_unspent_outputs = null;
if (data[1].result.hasOwnProperty("per_subaddress")) {
for (let address_balance of data[1].result.per_subaddress) {
if (address_balance.address_index == address.address_index) {
address.balance = address_balance.balance;
address.unlocked_balance = address_balance.unlocked_balance;
address.num_unspent_outputs =
address_balance.num_unspent_outputs;
break;
}
}
}
if (address.address_index == 0) {
wallet.address_list.primary.push(address);
} else if (address.used) {
wallet.address_list.used.push(address);
} else {
wallet.address_list.unused.push(address);
}
}
// limit to 10 unused addresses
wallet.address_list.unused = wallet.address_list.unused.slice(0, 10);
if (wallet.address_list.unused.length < num_unused_addresses) {
for (
let n = wallet.address_list.unused.length;
n < num_unused_addresses;
n++
) {
this.sendRPC("create_address", {
account_index: 0
}).then(data => {
wallet.address_list.unused.push(data.result);
if (wallet.address_list.unused.length == num_unused_addresses) {
// should sort them here
resolve(wallet);
}
});
}
} else {
resolve(wallet);
}
});
});
}
getTransactions() {
return new Promise(resolve => {
this.sendRPC("get_transfers", {
in: true,
out: true,
pending: true,
failed: true,
pool: true
}).then(data => {
if (data.hasOwnProperty("error") || !data.hasOwnProperty("result")) {
resolve({});
return;
}
let wallet = {
transactions: {
tx_list: []
}
};
const types = [
"in",
"out",
"pending",
"failed",
"pool",
"miner",
"snode",
"gov",
"stake"
];
types.forEach(type => {
if (data.result.hasOwnProperty(type)) {
wallet.transactions.tx_list = wallet.transactions.tx_list.concat(
data.result[type]
);
}
});
for (let i = 0; i < wallet.transactions.tx_list.length; i++) {
if (/^0*$/.test(wallet.transactions.tx_list[i].payment_id)) {
wallet.transactions.tx_list[i].payment_id = "";
} else if (
/^0*$/.test(wallet.transactions.tx_list[i].payment_id.substring(16))
) {
wallet.transactions.tx_list[
i
].payment_id = wallet.transactions.tx_list[i].payment_id.substring(
0,
16
);
}
}
wallet.transactions.tx_list.sort(function(a, b) {
if (a.timestamp < b.timestamp) return 1;
if (a.timestamp > b.timestamp) return -1;
return 0;
});
resolve(wallet);
});
});
}
getAddressBook() {
return new Promise(resolve => {
this.sendRPC("get_address_book").then(data => {
if (data.hasOwnProperty("error") || !data.hasOwnProperty("result")) {
resolve({});
return;
}
let wallet = {
address_list: {
address_book: [],
address_book_starred: []
}
};
const entries = data.result.entries || [];
const addresses = entries.map(e => {
const entry = { ...e };
const desc = entry.description.split("::");
if (desc.length == 3) {
entry.starred = desc[0] == "starred";
entry.name = desc[1];
entry.description = desc[2];
} else if (desc.length == 2) {
entry.starred = false;
entry.name = desc[0];
entry.description = desc[1];
} else {
entry.starred = false;
entry.name = entry.description;
entry.description = "";
}
if (/^0*$/.test(entry.payment_id)) {
entry.payment_id = "";
} else if (/^0*$/.test(entry.payment_id.substring(16))) {
entry.payment_id = entry.payment_id.substring(0, 16);
}
return entry;
});
for (const entry of addresses) {
const list = entry.starred
? wallet.address_list.address_book_starred
: wallet.address_list.address_book;
const hasAddress = list.find(a => {
return (
a.address === entry.address &&
a.name === entry.name &&
a.payment_id === entry.payment_id
);
});
if (!hasAddress) {
list.push(entry);
}
}
resolve(wallet);
});
});
}
deleteAddressBook(index = false) {
if (index !== false) {
this.sendRPC("delete_address_book", { index: index }).then(() => {
this.saveWallet().then(() => {
this.getAddressBook().then(data => {
this.sendGateway("set_wallet_data", data);
});
});
});
}
}
addAddressBook(
address,
payment_id = null,
description = "",
name = "",
starred = false,
index = false
) {
if (index !== false) {
this.sendRPC("delete_address_book", { index: index }).then(() => {
this.addAddressBook(address, payment_id, description, name, starred);
});
return;
}
let params = {
address
};
if (payment_id != null) {
params.payment_id = payment_id;
}
let desc = [];
if (starred) {
desc.push("starred");
}
desc.push(name, description);
params.description = desc.join("::");
this.sendRPC("add_address_book", params).then(() => {
this.saveWallet().then(() => {
this.getAddressBook().then(data => {
this.sendGateway("set_wallet_data", data);
});
});
});
}
saveTxNotes(txid, note) {
this.sendRPC("set_tx_notes", { txids: [txid], notes: [note] }).then(() => {
this.getTransactions().then(wallet => {
this.sendGateway("set_wallet_data", wallet);
});
});
}
exportKeyImages(password, filename = null) {
crypto.pbkdf2(
password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.internalError",
timeout: 2000
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.invalidPassword",
timeout: 2000
});
return;
}
if (filename == null) {
filename = path.join(
this.wallet_data_dir,
"images",
this.wallet_state.name,
"key_image_export"
);
} else {
filename = path.join(filename, "key_image_export");
}
const onError = () =>
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.keyImages.exporting",
timeout: 2000
});
this.sendRPC("export_key_images")
.then(data => {
if (
data.hasOwnProperty("error") ||
!data.hasOwnProperty("result")
) {
onError();
return;
}
if (data.result.signed_key_images) {
fs.outputJSONSync(filename, data.result.signed_key_images);
this.sendGateway("show_notification", {
i18n: [
"notification.positive.keyImages.exported",
{ filename }
],
timeout: 2000
});
} else {
this.sendGateway("show_notification", {
type: "warning",
textColor: "black",
i18n: "notification.warnings.noKeyImageExport",
timeout: 2000
});
}
})
.catch(onError);
}
);
}
importKeyImages(password, filename = null) {
crypto.pbkdf2(
password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.internalError",
timeout: 2000
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.invalidPassword",
timeout: 2000
});
return;
}
if (filename == null) {
filename = path.join(
this.wallet_data_dir,
"images",
this.wallet_state.name,
"key_image_export"
);
}
const onError = i18n =>
this.sendGateway("show_notification", {
type: "negative",
i18n,
timeout: 2000
});
fs.readJSON(filename)
.then(signed_key_images => {
this.sendRPC("import_key_images", {
signed_key_images
}).then(data => {
if (
data.hasOwnProperty("error") ||
!data.hasOwnProperty("result")
) {
onError("notification.errors.keyImages.importing");
return;
}
this.sendGateway("show_notification", {
i18n: "notification.positive.keyImages.imported",
timeout: 2000
});
});
})
.catch(() => onError("notification.errors.keyImages.reading"));
}
);
}
copyOldGuiWallets(wallets) {
this.sendGateway("set_old_gui_import_status", {
code: 1,
failed_wallets: []
});
/*
Old wallets were in the following format:
wallets:
<name>:
<name>
<name>.keys
<name>.address.txt
We need to change it so it becomes:
wallets:
<name>
<name>.keys
<name>.address.txt
*/
const failed_wallets = [];
for (const wallet of wallets) {
const { type, directory } = wallet;
const old_gui_path = path.join(this.wallet_dir, "old-gui");
const dir_path = path.join(this.wallet_dir, directory);
const stat = fs.statSync(dir_path);
if (!stat.isDirectory()) continue;
// Make sure the directory has the keys file
const wallet_file = path.join(dir_path, directory);
const key_file = wallet_file + ".keys";
// If we don't have them then don't bother copying
if (!fs.existsSync(key_file)) {
failed_wallets.push(directory);
continue;
}
// Copy out the file into the relevant directory
const destination = path.join(this.dirs[type], "wallets");
if (!fs.existsSync(destination)) fs.mkdirpSync(destination);
try {
// Don't move file if we already have copied the keys file
if (fs.existsSync(path.join(destination, directory) + ".keys")) {
failed_wallets.push(directory);
continue;
}
// Archive the folder
if (!fs.existsSync(old_gui_path)) fs.mkdirpSync(old_gui_path);
const archive_path = path.join(old_gui_path, directory);
fs.moveSync(dir_path, archive_path, { overwrite: true });
// Copy contents of archived folder into the wallet folder
fs.copySync(archive_path, this.wallet_dir, { overwrite: true });
} catch (e) {
failed_wallets.push(directory);
continue;
}
}
this.sendGateway("set_old_gui_import_status", {
code: 0,
failed_wallets
});
this.listWallets();
}
listWallets(legacy = false) {
let wallets = {
list: [],
directories: []
};
let walletFiles = [];
try {
walletFiles = fs.readdirSync(this.wallet_dir);
} catch (e) {
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.failedWalletRead",
timeout: 2000
});
return;
}
walletFiles.forEach(filename => {
try {
switch (filename) {
case ".DS_Store":
case ".DS_Store?":
case "._.DS_Store":
case ".Spotlight-V100":
case ".Trashes":
case "ehthumbs.db":
case "Thumbs.db":
case "old-gui":
return;
}
// If it's a directory then check if it's an old gui wallet
const name = path.join(this.wallet_dir, filename);
const stat = fs.statSync(name);
if (stat.isDirectory()) {
// Make sure the directory has keys file
const wallet_file = path.join(name, filename);
const key_file = wallet_file + ".keys";
// If we have them then it is an old gui wallet
if (fs.existsSync(key_file)) {
wallets.directories.push(filename);
}
return;
}
// Exclude all files without a keys extension
if (path.extname(filename) !== ".keys") return;
const wallet_name = path.parse(filename).name;
if (!wallet_name) return;
let wallet_data = {
name: wallet_name,
address: null,
password_protected: null
};
if (
fs.existsSync(path.join(this.wallet_dir, wallet_name + ".meta.json"))
) {
let meta = fs.readFileSync(
path.join(this.wallet_dir, wallet_name + ".meta.json"),
"utf8"
);
if (meta) {
meta = JSON.parse(meta);
wallet_data.address = meta.address;
wallet_data.password_protected = meta.password_protected;
}
} else if (
fs.existsSync(
path.join(this.wallet_dir, wallet_name + ".address.txt")
)
) {
let address = fs.readFileSync(
path.join(this.wallet_dir, wallet_name + ".address.txt"),
"utf8"
);
if (address) {
wallet_data.address = address;
}
}
wallets.list.push(wallet_data);
} catch (e) {
// Something went wrong
}
});
// Check for legacy wallet files
if (legacy) {
wallets.legacy = [];
let legacy_paths = [];
if (os.platform() == "win32") {
legacy_paths = ["C:\\ProgramData\\Loki"];
} else {
legacy_paths = [path.join(os.homedir(), "Loki")];
}
for (var i = 0; i < legacy_paths.length; i++) {
try {
let legacy_config_path = path.join(
legacy_paths[i],
"config",
"wallet_info.json"
);
if (this.net_type === "test") {
legacy_config_path = path.join(
legacy_paths[i],
"testnet",
"config",
"wallet_info.json"
);
}
if (!fs.existsSync(legacy_config_path)) {
continue;
}
let legacy_config = JSON.parse(
fs.readFileSync(legacy_config_path, "utf8")
);
let legacy_wallet_path = legacy_config.wallet_filepath;
if (!fs.existsSync(legacy_wallet_path)) {
continue;
}
let legacy_address = "";
if (fs.existsSync(legacy_wallet_path + ".address.txt")) {
legacy_address = fs.readFileSync(
legacy_wallet_path + ".address.txt",
"utf8"
);
}
wallets.legacy.push({
path: legacy_wallet_path,
address: legacy_address
});
} catch (e) {
// Something went wrong
}
}
}
this.sendGateway("wallet_list", wallets);
}
changeWalletPassword(old_password, new_password) {
crypto.pbkdf2(
old_password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.internalError",
timeout: 2000
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.invalidOldPassword",
timeout: 2000
});
return;
}
this.sendRPC("change_wallet_password", {
old_password,
new_password
}).then(data => {
if (data.hasOwnProperty("error") || !data.hasOwnProperty("result")) {
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.changingPassword",
timeout: 2000
});
return;
}
// store hash of the password so we can check against it later when requesting private keys, or for sending txs
this.wallet_state.password_hash = crypto
.pbkdf2Sync(new_password, this.auth[2], 1000, 64, "sha512")
.toString("hex");
this.sendGateway("show_notification", {
i18n: "notification.positive.passwordUpdated",
timeout: 2000
});
});
}
);
}
deleteWallet(password) {
crypto.pbkdf2(
password,
this.auth[2],
1000,
64,
"sha512",
(err, password_hash) => {
if (err) {
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.internalError",
timeout: 2000
});
return;
}
if (!this.isValidPasswordHash(password_hash)) {
this.sendGateway("show_notification", {
type: "negative",
i18n: "notification.errors.invalidPassword",
timeout: 2000
});
return;
}
this.sendGateway("show_loading", {
message: "Deleting wallet"
});
let wallet_path = path.join(this.wallet_dir, this.wallet_state.name);
this.closeWallet().then(() => {
try {
if (fs.existsSync(wallet_path + ".keys"))
fs.unlinkSync(wallet_path + ".keys");
if (fs.existsSync(wallet_path + ".address.txt"))
fs.unlinkSync(wallet_path + ".address.txt");
if (fs.existsSync(wallet_path)) fs.unlinkSync(wallet_path);
} catch (e) {
console.warn(`Failed to delete wallet files: ${e}`);
}
this.listWallets();
this.sendGateway("hide_loading");
this.sendGateway("return_to_wallet_select");
});
}
);
}
async saveWallet() {
await this.sendRPC("store");
}
async closeWallet() {
clearInterval(this.heartbeat);
clearInterval(this.lnsHeartbeat);
this.wallet_state = {
open: false,
name: "",
password_hash: null,
balance: null,
unlocked_balance: null,
lnsRecords: []
};
this.purchasedNames = {};
await this.saveWallet();
await this.sendRPC("close_wallet");
}
sendGateway(method, data) {
// if wallet is closed, do not send any wallet data to gateway
// this is for the case that we close the wallet at the same
// after another action has started, but before it has finished
if (!this.wallet_state.open && method == "set_wallet_data") {
return;
}
this.backend.send(method, data);
}
sendRPC(method, params = {}, timeout = 0) {
let id = this.id++;
let options = {
uri: `${this.protocol}${this.hostname}:${this.port}/json_rpc`,
method: "POST",
json: {
jsonrpc: "2.0",
id: id,
method: method
},
auth: {
user: this.auth[0],
pass: this.auth[1],
sendImmediately: false
},
agent: this.agent
};
if (Object.keys(params).length !== 0) {
options.json.params = params;
}
if (timeout > 0) {
options.timeout = timeout;
}
return this.queue.add(() => {
return request(options)
.then(response => {
if (response.hasOwnProperty("error")) {
return {
method: method,
params: params,
error: response.error
};
}
return {
method: method,
params: params,
result: response.result
};
})
.catch(error => {
return {
method: method,
params: params,
error: {
code: -1,
message: "Cannot connect to wallet-rpc",
cause: error.cause
}
};
});
});
}
getRPC(parameter, params = {}) {
return this.sendRPC(`get_${parameter}`, params);
}
async quit() {
return new Promise(resolve => {
if (!this.walletRPCProcess) {
resolve();
return;
}
this.closeWallet().then(() => {
// normally we would exit wallet after this promise
// however if the wallet is not responsive to RPC
// requests then we must forcefully close it below
});
setTimeout(() => {
if (this.walletRPCProcess) {
this.walletRPCProcess.on("close", () => {
this.agent.destroy();
clearTimeout(this.forceKill);
resolve();
});
// Force kill after 20 seconds
this.forceKill = setTimeout(() => {
if (this.walletRPCProcess) {
this.walletRPCProcess.kill("SIGKILL");
}
}, 20000);
// Force kill if the rpc is syncing
const signal = this.isRPCSyncing ? "SIGKILL" : "SIGTERM";
this.walletRPCProcess.kill(signal);
} else {
resolve();
}
}, 2500);
});
}
}