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

537 lines
25 KiB

// Copyright (c) 2014-2017, MyMonero.com
//
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without modification, are
// permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this list of
// conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice, this list
// of conditions and the following disclaimer in the documentation and/or other
// materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its contributors may be
// used to endorse or promote products derived from this software without specific
// prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
thinwalletCtrls.controller('SendCoinsCtrl', function($scope, $http, $q, AccountService) {
"use strict";
$scope.status = "";
$scope.error = "";
$scope.submitting = false;
$scope.targets = [{}];
$scope.totalAmount = JSBigInt.ZERO;
$scope.mixins = config.defaultMixin;
$scope.success_page = false;
$scope.sent_tx = {};
$scope.openaliasDialog = undefined;
function confirmOpenAliasAddress(domain, address, name, description, dnssec_used) {
var deferred = $q.defer();
if ($scope.openaliasDialog !== undefined) {
deferred.reject("OpenAlias confirm dialog is already being shown!");
return;
}
$scope.openaliasDialog = {
address: address,
domain: domain,
name: name,
description: description,
dnssec_used: dnssec_used,
confirm: function() {
$scope.openaliasDialog = undefined;
console.log("User confirmed OpenAlias resolution for " + domain + " to " + address);
deferred.resolve();
},
cancel: function() {
$scope.openaliasDialog = undefined;
console.log("User rejected OpenAlias resolution for " + domain + " to " + address);
deferred.reject("OpenAlias resolution rejected by user");
}
};
return deferred.promise;
}
function getTxCharge(amount) {
amount = new JSBigInt(amount);
// amount * txChargeRatio
return amount.divide(1 / config.txChargeRatio);
}
$scope.removeTarget = function(index) {
$scope.targets.splice(index, 1);
};
$scope.$watch('targets', function() {
var totalAmount = JSBigInt.ZERO;
for (var i = 0; i < $scope.targets.length; ++i) {
try {
var amount = cnUtil.parseMoney($scope.targets[i].amount);
totalAmount = totalAmount.add(amount);
} catch (e) {
}
}
$scope.totalAmount = totalAmount;
}, true);
$scope.resetSuccessPage = function() {
$scope.success_page = false;
$scope.sent_tx = {};
};
$scope.sendCoins = function(targets, mixin, payment_id) {
if ($scope.submitting) return;
$scope.status = "";
$scope.error = "";
$scope.submitting = true;
mixin = parseInt(mixin);
var rct = true; //maybe want to set this later based on inputs (?)
var realDsts = [];
var targetPromises = [];
for (var i = 0; i < targets.length; ++i) {
var target = targets[i];
if (!target.address && !target.amount) {
continue;
}
var deferred = $q.defer();
targetPromises.push(deferred.promise);
(function(deferred, target) {
var amount;
try {
amount = cnUtil.parseMoney(target.amount);
} catch (e) {
deferred.reject("Failed to parse amount (#" + i + ")");
return;
}
if (target.address.indexOf('.') === -1) {
try {
// verify that the address is valid
cnUtil.decode_address(target.address);
deferred.resolve({
address: target.address,
amount: amount
});
} catch (e) {
deferred.reject("Failed to decode address (#" + i + "): " + e);
return;
}
} else {
var domain = target.address.replace(/@/g, ".");
$http.post(config.apiUrl + "get_txt_records", {
domain: domain
}).success(function(data) {
var records = data.records;
var oaRecords = [];
console.log(domain + ": ", data.records);
if (data.dnssec_used) {
if (data.secured) {
console.log("DNSSEC validation successful");
} else {
deferred.reject("DNSSEC validation failed for " + domain + ": " + data.dnssec_fail_reason);
return;
}
} else {
console.log("DNSSEC Not used");
}
for (var i = 0; i < records.length; i++) {
var record = records[i];
if (record.slice(0, 4 + config.openAliasPrefix.length + 1) !== "oa1:" + config.openAliasPrefix + " ") {
continue;
}
console.log("Found OpenAlias record: " + record);
oaRecords.push(parseOpenAliasRecord(record));
}
if (oaRecords.length === 0) {
deferred.reject("No OpenAlias records found for: " + domain);
return;
}
if (oaRecords.length !== 1) {
deferred.reject("Multiple addresses found for given domain: " + domain);
return;
}
console.log("OpenAlias record: ", oaRecords[0]);
var oaAddress = oaRecords[0].address;
try {
cnUtil.decode_address(oaAddress);
confirmOpenAliasAddress(domain, oaAddress, oaRecords[0].name, oaRecords[0].description, data.dnssec_used && data.secured).then(function() {
deferred.resolve({
address: oaAddress,
amount: amount,
domain: domain
});
}, function(err) {
deferred.reject(err);
});
} catch (e) {
deferred.reject("Failed to decode OpenAlias address: " + oaRecords[0].address + ": " + e);
return;
}
}).error(function(data) {
deferred.reject("Failed to resolve DNS records for '" + domain + "': " + ((data || {}).Error || data || "Unknown error"));
});
}
})(deferred, target);
}
// Transaction will need at least 1KB fee (13KB for RingCT)
var neededFee = rct ? config.feePerKB.multiply(13) : config.feePerKB;
var totalAmountWithoutFee;
var unspentOuts;
var pid_encrypt = false; //don't encrypt payment ID unless we find an integrated one
$q.all(targetPromises).then(function(destinations) {
totalAmountWithoutFee = new JSBigInt(0);
for (var i = 0; i < destinations.length; i++) {
totalAmountWithoutFee = totalAmountWithoutFee.add(destinations[i].amount);
}
realDsts = destinations;
console.log("Parsed destinations: " + JSON.stringify(realDsts));
console.log("Total before fee: " + cnUtil.formatMoney(totalAmountWithoutFee));
if (realDsts.length === 0) {
$scope.submitting = false;
$scope.error = "You need to enter a valid destination";
return;
}
if (payment_id && (payment_id.length !== 64 || !(/^[0-9a-fA-F]{64}$/.test(payment_id)))) {
$scope.submitting = false;
$scope.error = "The payment ID you've entered is not valid";
return;
}
if (realDsts.length === 1) {//multiple destinations aren't supported by MyMonero, but don't include integrated ID anyway (possibly should error in the future)
var decode_result = cnUtil.decode_address(realDsts[0].address);
if (decode_result.intPaymentId && payment_id) {
$scope.submitting = false;
$scope.error = "Payment ID field must be blank when using an Integrated Address";
return;
} else if (decode_result.intPaymentId) {
payment_id = decode_result.intPaymentId;
pid_encrypt = true; //encrypt if using an integrated address
}
}
if (totalAmountWithoutFee.compare(0) <= 0) {
$scope.submitting = false;
$scope.error = "The amount you've entered is too low";
return;
}
$scope.status = "Generating transaction...";
console.log("Destinations: ");
// Log destinations to console
for (var j = 0; j < realDsts.length; j++) {
console.log(realDsts[j].address + ": " + cnUtil.formatMoneyFull(realDsts[j].amount));
}
var getUnspentOutsRequest = {
address: AccountService.getAddress(),
view_key: AccountService.getViewKey(),
amount: '0',
mixin: mixin,
// Use dust outputs only when we are using no mixins
use_dust: mixin === 0,
dust_threshold: config.dustThreshold.toString()
};
$http.post(config.apiUrl + 'get_unspent_outs', getUnspentOutsRequest)
.success(function(data) {
unspentOuts = checkUnspentOuts(data.outputs || []);
unused_outs = unspentOuts.slice(0);
using_outs = [];
using_outs_amount = new JSBigInt(0);
transfer().then(transferSuccess, transferFailure);
})
.error(function(data) {
$scope.status = "";
$scope.submitting = false;
if (data && data.Error) {
$scope.error = data.Error;
console.warn(data.Error);
} else {
$scope.error = "Something went wrong with getting your available balance for spending";
}
});
}, function(err) {
$scope.submitting = false;
$scope.error = err;
console.log("Error decoding targets: " + err);
});
function checkUnspentOuts(outputs) {
for (var i = 0; i < outputs.length; i++) {
for (var j = 0; outputs[i] && j < outputs[i].spend_key_images.length; j++) {
var key_img = AccountService.cachedKeyImage(outputs[i].tx_pub_key, outputs[i].index);
if (key_img === outputs[i].spend_key_images[j]) {
console.log("Output was spent with key image: " + key_img + " amount: " + cnUtil.formatMoneyFull(outputs[i].amount));
// Remove output from list
outputs.splice(i, 1);
if (outputs[i]) {
j = outputs[i].spend_key_images.length;
}
i--;
} else {
console.log("Output used as mixin (" + key_img + "/" + outputs[i].spend_key_images[j] + ")");
}
}
}
console.log("Unspent outs: " + JSON.stringify(outputs));
return outputs;
}
function transferSuccess(tx_h) {
var prevFee = neededFee;
var raw_tx = tx_h.raw;
var tx_hash = tx_h.hash;
// work out per-kb fee for transaction
var txBlobBytes = raw_tx.length / 2;
var numKB = Math.floor((txBlobBytes) / 1024);
if (txBlobBytes % 1024) {
numKB++;
}
console.log(txBlobBytes + " bytes <= " + numKB + " KB (current fee: " + cnUtil.formatMoneyFull(prevFee) + ")");
neededFee = config.feePerKB.multiply(numKB);
// if we need a higher fee
if (neededFee.compare(prevFee) > 0) {
console.log("Previous fee: " + cnUtil.formatMoneyFull(prevFee) + " New fee: " + cnUtil.formatMoneyFull(neededFee));
transfer().then(transferSuccess, transferFailure);
return;
}
// generated with correct per-kb fee
console.log("Successful tx generation, submitting tx");
console.log("Tx hash: " + tx_hash);
$scope.status = "Submitting...";
var request = {
address: AccountService.getAddress(),
view_key: AccountService.getViewKey(),
tx: raw_tx
};
$http.post(config.apiUrl + 'submit_raw_tx', request)
.success(function() {
console.log("Successfully submitted tx");
$scope.targets = [{}];
$scope.sent_tx = {
address: realDsts[0].address,
domain: realDsts[0].domain,
amount: realDsts[0].amount,
payment_id: payment_id,
tx_id: tx_hash,
tx_fee: neededFee/*.add(getTxCharge(neededFee))*/
};
$scope.success_page = true;
$scope.status = "";
$scope.submitting = false;
})
.error(function(error) {
$scope.status = "";
$scope.submitting = false;
$scope.error = "Something unexpected occurred when submitting your transaction: " + (error.Error || error);
});
}
function transferFailure(reason) {
$scope.status = "";
$scope.submitting = false;
$scope.error = reason;
console.log("Transfer failed: " + reason);
}
var unused_outs;
var using_outs;
var using_outs_amount;
function random_index(list) {
return Math.floor(Math.random() * list.length);
}
function pop_random_value(list) {
var idx = random_index(list);
var val = list[idx];
list.splice(idx, 1);
return val;
}
function select_outputs(target_amount) {
console.log("Selecting outputs to use. Current total: " + cnUtil.formatMoney(using_outs_amount) + " target: " + cnUtil.formatMoney(target_amount));
while (using_outs_amount.compare(target_amount) < 0 && unused_outs.length > 0) {
var out = pop_random_value(unused_outs);
if (!rct && out.rct) {continue;} //skip rct outs if not creating rct tx
using_outs.push(out);
using_outs_amount = using_outs_amount.add(out.amount);
console.log("Using output: " + cnUtil.formatMoney(out.amount) + " - " + JSON.stringify(out));
}
}
function transfer() {
var deferred = $q.defer();
(function() {
var dsts = realDsts.slice(0);
// Calculate service charge and add to tx destinations
//var chargeAmount = getTxCharge(neededFee);
//dsts.push({
// address: config.txChargeAddress,
// amount: chargeAmount
//});
// Add fee to total amount
var totalAmount = totalAmountWithoutFee.add(neededFee)/*.add(chargeAmount)*/;
console.log("Balance required: " + cnUtil.formatMoneySymbol(totalAmount));
select_outputs(totalAmount);
//compute fee as closely as possible before hand
if (using_outs.length > 1 && rct) {
var newNeededFee = JSBigInt(Math.ceil(cnUtil.estimateRctSize(using_outs.length, mixin, 2) / 1024)).multiply(config.feePerKB);
totalAmount = totalAmountWithoutFee.add(newNeededFee);
//add outputs 1 at a time till we either have them all or can meet the fee
while (using_outs_amount.compare(totalAmount) < 0 && unused_outs.length > 0) {
var out = pop_random_value(unused_outs);
using_outs.push(out);
using_outs_amount = using_outs_amount.add(out.amount);
console.log("Using output: " + cnUtil.formatMoney(out.amount) + " - " + JSON.stringify(out));
newNeededFee = JSBigInt(Math.ceil(cnUtil.estimateRctSize(using_outs.length, mixin, 2) / 1024)).multiply(config.feePerKB);
totalAmount = totalAmountWithoutFee.add(newNeededFee);
}
console.log("New fee: " + cnUtil.formatMoneySymbol(newNeededFee) + " for " + using_outs.length + " inputs");
neededFee = newNeededFee;
}
if (using_outs_amount.compare(totalAmount) < 0) {
deferred.reject("Not enough spendable outputs / balance too low (have: " + cnUtil.formatMoneyFull(using_outs_amount) + " need: " + cnUtil.formatMoneyFull(totalAmount) + ")");
return;
} else if (using_outs_amount.compare(totalAmount) > 0) {
var changeAmount = using_outs_amount.subtract(totalAmount);
if (!rct) { //for rct we don't presently care about dustiness
//do not give ourselves change < dust threshold
var changeAmountDivRem = changeAmount.divRem(config.dustThreshold);
if (changeAmountDivRem[1].toString() !== "0") {
// add dusty change to fee
console.log("Adding change of " + cnUtil.formatMoneyFullSymbol(changeAmountDivRem[1]) + " to transaction fee (below dust threshold)");
}
if (changeAmountDivRem[0].toString() !== "0") {
// send non-dusty change to our address
var usableChange = changeAmountDivRem[0].multiply(config.dustThreshold);
console.log("Sending change of " + cnUtil.formatMoneySymbol(usableChange) + " to " + AccountService.getAddress());
dsts.push({
address: AccountService.getAddress(),
amount: usableChange
});
}
} else {
//add entire change for rct
console.log("Sending change of " + cnUtil.formatMoneySymbol(changeAmount) + " to " + AccountService.getAddress());
dsts.push({
address: AccountService.getAddress(),
amount: changeAmount
});
}
} else if (using_outs_amount.compare(totalAmount) === 0 && rct) {
//create random destination to keep 2 outputs always in case of 0 change
var fakeAddress = cnUtil.create_address(cnUtil.random_scalar()).public_addr;
console.log("Sending 0 XMR to a fake address to keep tx uniform (no change exists): " + fakeAddress);
dsts.push({
address: fakeAddress,
amount: 0
});
}
if (mixin > 0) {
var amounts = [];
for (var l = 0; l < using_outs.length; l++) {
amounts.push(using_outs[l].rct ? "0" : using_outs[l].amount.toString());
}
var request = {
amounts: amounts,
count: mixin + 1 // Add one to mixin so we can skip real output key if necessary
};
$http.post(config.apiUrl + 'get_random_outs', request)
.success(function(data) {
createTx(data.amount_outs);
})
.error(function(data) {
if (data && data.Error) {
deferred.reject(data.Error);
} else {
deferred.reject('Failed to get unspent outs');
}
});
} else if (mixin < 0 || isNaN(mixin)) {
deferred.reject("Invalid mixin");
return;
} else { // mixin === 0
createTx();
}
// Create & serialize transaction
function createTx(mix_outs) {
var signed;
try {
console.log('Destinations: ');
cnUtil.printDsts(dsts);
//need to get viewkey for encrypting here, because of splitting and sorting
if (pid_encrypt) {
var realDestViewKey = cnUtil.decode_address(dsts[0].address).view;
}
var splittedDsts = cnUtil.decompose_tx_destinations(dsts, rct);
console.log('Decomposed destinations:');
cnUtil.printDsts(splittedDsts);
signed = cnUtil.create_transaction(
AccountService.getPublicKeys(),
AccountService.getSecretKeys(),
splittedDsts, using_outs,
mix_outs, mixin, neededFee,
payment_id, pid_encrypt,
realDestViewKey, 0, rct);
} catch (e) {
deferred.reject("Failed to create transaction: " + e);
return;
}
console.log("signed tx: ", JSON.stringify(signed));
//move some stuff here to normalize rct vs non
var raw_tx_and_hash = {};
if (signed.version === 1) {
raw_tx_and_hash.raw = cnUtil.serialize_tx(signed);
raw_tx_and_hash.hash = cnUtil.cn_fast_hash(raw_tx);
} else {
raw_tx_and_hash = cnUtil.serialize_rct_tx_with_hash(signed);
}
console.log("raw_tx and hash:");
console.log(raw_tx_and_hash);
deferred.resolve(raw_tx_and_hash);
}
})();
return deferred.promise;
}
};
});
function parseOpenAliasRecord(record) {
var parsed = {};
if (record.slice(0, 4 + config.openAliasPrefix.length + 1) !== "oa1:" + config.openAliasPrefix + " ") {
throw "Invalid OpenAlias prefix";
}
function parse_param(name) {
var pos = record.indexOf(name + "=");
if (pos === -1) {
// Record does not contain param
return undefined;
}
pos += name.length + 1;
var pos2 = record.indexOf(";", pos);
return record.substr(pos, pos2 - pos);
}
parsed.address = parse_param('recipient_address');
parsed.name = parse_param('recipient_name');
parsed.description = parse_param('tx_description');
return parsed;
}