diff --git a/bin/create-tx.js b/bin/create-tx.js new file mode 100755 index 0000000..8fe57a7 --- /dev/null +++ b/bin/create-tx.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +"use strict"; + +let Fs = require("fs").promises; + +let Dash = require("../lib/dash.js"); +let Insight = require("../lib/insight.js"); + +async function main() { + let insightBaseUrl = + process.env.INSIGHT_BASE_URL || "https://insight.dash.org"; + let insightApi = Insight.create({ baseUrl: insightBaseUrl }); + let dashApi = Dash.create({ insightApi: insightApi }); + + let wiffilename = process.argv[2] || ""; + if (!wiffilename) { + console.error(`Usage: pay ./source.wif ./targets.csv ./change.b58c`); + process.exit(1); + return; + } + let wif = await Fs.readFile(wiffilename, "utf8"); + wif = wif.trim(); + + let payfilename = process.argv[3] || ""; + if (!payfilename) { + console.error(`Usage: pay ./source.wif ./targets.csv ./change.b58c`); + process.exit(1); + return; + } + let paymentsCsv = await Fs.readFile(payfilename, "utf8"); + paymentsCsv = paymentsCsv.trim(); + /** @type {Array<{ address: String, satoshis: Number }>} */ + //@ts-ignore + let payments = paymentsCsv + .split(/\n/) + .map(function (line) { + line = line.trim(); + if (!line) { + return null; + } + + if ( + line.startsWith("#") || + line.startsWith("//") || + line.startsWith("-") || + line.startsWith('"') || + line.startsWith("'") + ) { + return null; + } + + let parts = line.split(","); + let addr = parts[0] || ""; + let amount = Dash.toDuff(parts[1] || ""); + + if (!addr.startsWith("X")) { + console.error(`unknown address: ${addr}`); + process.exit(1); + return null; + } + + if (isNaN(amount) || !amount) { + console.error(`unknown amount: ${amount}`); + return null; + } + + return { + address: addr, + satoshis: amount, + }; + }) + .filter(Boolean); + + let changefilename = process.argv[4] || ""; + if (!changefilename) { + console.error(`Usage: pay ./source.wif ./targets.csv ./change.b58c`); + process.exit(1); + return; + } + let changeAddr = await Fs.readFile(changefilename, "utf8"); + changeAddr = changeAddr.trim(); + + let tx = await dashApi.createPayments(wif, payments, changeAddr); + console.info('Transaction:'); + console.info(tx.serialize()); + + if (!process.argv.includes("--send")) { + return; + } + + console.info('Instant Send...'); + await insightApi.instantSend(tx.serialize()); + console.info('Done'); +} + +// Run +main().catch(function (err) { + console.error("Fail:"); + console.error(err.stack || err); + process.exit(1); +}); diff --git a/lib/dash.js b/lib/dash.js index db6231a..f2d9aa4 100644 --- a/lib/dash.js +++ b/lib/dash.js @@ -100,7 +100,9 @@ Dash.create = function ({ // TODO make more accurate? let feePreEstimate = 1000; - let utxos = await getOptimalUtxos(utxoAddr, amount + feePreEstimate); + let body = await insightApi.getUtxos(utxoAddr); + let coreUtxos = await getUtxos(body); + let utxos = await getOptimalUtxos(coreUtxos, amount + feePreEstimate); let balance = getBalance(utxos); if (!utxos.length) { @@ -145,16 +147,89 @@ Dash.create = function ({ return tx; }; + /** + * @typedef {Object} CorePayment + * @property {(String|import('@dashevo/dashcore-lib').Address)} address + * @property {Number} satoshis + */ + + /** + * Send with change back + * @param {String} privKey + * @param {Array} payments + * @param {(String|import('@dashevo/dashcore-lib').Address)} [changeAddr] + */ + dashApi.createPayments = async function (privKey, payments, changeAddr) { + let pk = new Dashcore.PrivateKey(privKey); + let utxoAddr = pk.toPublicKey().toAddress().toString(); + if (!changeAddr) { + changeAddr = utxoAddr; + } + + // TODO make more accurate? + let amount = payments.reduce(function (total, pay) { + return pay.satoshis; + }, 0); + let body = await insightApi.getUtxos(utxoAddr); + let coreUtxos = await getUtxos(body); + let feePreEstimate = 150 * (payments.length + coreUtxos.length); + let utxos = await getOptimalUtxos(coreUtxos, amount + feePreEstimate); + let balance = getBalance(utxos); + + if (!utxos.length) { + throw new Error(`not enough funds available in utxos for ${utxoAddr}`); + } + + // (estimate) don't send dust back as change + if (balance - amount <= DUST + FEE) { + amount = balance; + } + + console.log("DEBUG"); + console.log(payments, changeAddr); + + //@ts-ignore - no input required, actually + let tmpTx = new Transaction() + //@ts-ignore - allows single value or array + .from(utxos); + // TODO update jsdoc for dashcore + tmpTx.to(payments, 0); + //@ts-ignore - the JSDoc is wrong in dashcore-lib/lib/transaction/transaction.js + tmpTx.change(changeAddr); + tmpTx.sign(pk); + + // TODO getsmartfeeestimate?? + // fee = 1duff/byte (2 chars hex is 1 byte) + // +10 to be safe (the tmpTx may be a few bytes off - probably only 4 - + // due to how small numbers are encoded) + let fee = 10 + tmpTx.toString().length / 2; + + // (adjusted) don't send dust back as change + if (balance + -amount + -fee <= DUST) { + amount = balance - fee; + } + + //@ts-ignore - no input required, actually + let tx = new Transaction() + //@ts-ignore - allows single value or array + .from(utxos); + tx.to(payments, 0); + tx.fee(fee); + //@ts-ignore - see above + tx.change(changeAddr); + tx.sign(pk); + + return tx; + }; + // TODO make more optimal /** - * @param {String} utxoAddr + * @param {Array} utxos * @param {Number} fullAmount - including fee estimate */ - async function getOptimalUtxos(utxoAddr, fullAmount) { + async function getOptimalUtxos(utxos, fullAmount) { // get smallest coin larger than transaction // if that would create dust, donate it as tx fee - let body = await insightApi.getUtxos(utxoAddr); - let utxos = await getUtxos(body); let balance = getBalance(utxos); if (balance < fullAmount) { @@ -244,3 +319,10 @@ Dash.create = function ({ return dashApi; }; + +/** + * @param {String} dash - ex: 0.00000000 + */ +Dash.toDuff = function (dash) { + return Math.round(parseFloat(dash) * DUFFS); +};