diff --git a/coinjoin.js b/coinjoin.js deleted file mode 100644 index 7c2de2d..0000000 --- a/coinjoin.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -let CoinJoin = module.exports; - -CoinJoin.STANDARD_DENOMINATIONS = [ - // 0.00100001 - 100001, - // 0.01000010 - 1000010, - // 0.10000100 - 10000100, - // 1.00001000 - 100001000, - // 10.0000100 - 1000010000, -]; - -// TODO the spec seems to be more of an ID, though -// the implementation makes it look more like a mask... -CoinJoin.STANDARD_DENOMINATION_MASKS = { - // 0.00100001 - 100001: 0b00010000, - // 0.01000010 - 1000010: 0b00001000, - // 0.10000100 - 10000100: 0b00000100, - // 1.00001000 - 100001000: 0b00000010, - // 10.00010000 - 1000010000: 0b00000001, -}; - -CoinJoin.STANDARD_DENOMINATIONS_MAP = { - // 0.00100001 - 0b00010000: 100001, - // 0.01000010 - 0b00001000: 1000010, - // 0.10000100 - 0b00000100: 10000100, - // 1.00001000 - 0b00000010: 100001000, - // 10.00010000 - 0b00000001: 1000010000, -}; - -// (STANDARD_DENOMINATIONS[0] / 10).floor(); -CoinJoin.COLLATERAL = 10000; -// COLLATERAL * 4 -CoinJoin.MAX_COLLATERAL = 40000; - -CoinJoin.isDenominated = function (sats) { - return CoinJoin.STANDARD_DENOMINATIONS.includes(sats); -}; diff --git a/demo.js b/demo.js index f3aebc8..63ae92f 100644 --- a/demo.js +++ b/demo.js @@ -1,1316 +1,1423 @@ -'use strict'; - -let DotEnv = require('dotenv'); -void DotEnv.config({ path: '.env' }); -void DotEnv.config({ path: '.env.secret' }); - -//@ts-ignore - ts can't understand JSON, still... -let pkg = require('./package.json'); - -let Net = require('node:net'); - -let CoinJoin = require('./coinjoin.js'); -let Packer = require('./packer.js'); // TODO rename packer -let Parser = require('./parser.js'); - -let DashPhrase = require('dashphrase'); -let DashHd = require('dashhd'); -let DashKeys = require('dashkeys'); -let DashRpc = require('dashrpc'); -let DashTx = require('dashtx'); -let Secp256k1 = require('@dashincubator/secp256k1'); - -const DENOM_LOWEST = 100001; -const PREDENOM_MIN = DENOM_LOWEST + 193; -// const MIN_UNUSED = 2500; -const MIN_UNUSED = 1000; -const MIN_BALANCE = 100001 * 1000; -const MIN_DENOMINATED = 200; - -// https://github.com/dashpay/dash/blob/v19.x/src/coinjoin/coinjoin.h#L39 -// const COINJOIN_ENTRY_MAX_SIZE = 9; // real -const COINJOIN_ENTRY_MAX_SIZE = 2; // just for testing right now - -let rpcConfig = { - protocol: 'http', // https for remote, http for local / private networking - user: process.env.DASHD_RPC_USER, - pass: process.env.DASHD_RPC_PASS || process.env.DASHD_RPC_PASSWORD, - host: process.env.DASHD_RPC_HOST || '127.0.0.1', - port: process.env.DASHD_RPC_PORT || '19898', // mainnet=9998, testnet=19998, regtest=19898 - timeout: 10 * 1000, // bump default from 5s to 10s for up to 10k addresses -}; -if (process.env.DASHD_RPC_TIMEOUT) { - let rpcTimeoutSec = parseFloat(process.env.DASHD_RPC_TIMEOUT); - rpcConfig.timeout = rpcTimeoutSec * 1000; -} +//@ts-ignore +var CJDemo = ('object' === typeof module && exports) || {}; +(function (window, CJDemo) { + 'use strict'; + + //@ts-ignore + let Packer = window.CJPacker || require('./packer.js'); + //@ts-ignore + let Parser = window.CJParser || require('./parser.js'); + + let DashPhrase = window.DashPhrase || require('dashphrase'); + let DashHd = window.DashHd || require('dashhd'); + let DashKeys = window.DashKeys || require('dashkeys'); + let DashRpc = window.DashRpc || require('dashrpc'); + let DashTx = window.DashTx || require('dashtx'); + let Secp256k1 = window.nobleSecp256k1 || require('@dashincubator/secp256k1'); + + // (STANDARD_DENOMINATIONS[0] / 10).floor(); + const COLLATERAL = 10000; + // COLLATERAL * 4 + // const MAX_COLLATERAL = 40000; + + const DENOM_LOWEST = 100001; + const PREDENOM_MIN = DENOM_LOWEST + 193; + // const MIN_UNUSED = 2500; + const MIN_UNUSED = 1000; + const MIN_BALANCE = 100001 * 1000; + const MIN_DENOMINATED = 200; + + // https://github.com/dashpay/dash/blob/v19.x/src/coinjoin/coinjoin.h#L39 + // const COINJOIN_ENTRY_MAX_SIZE = 9; // real + const COINJOIN_ENTRY_MAX_SIZE = 2; // just for testing right now + + CJDemo.run = async function (ENV, rpcConfig) { + /* jshint maxstatements: 1000 */ + /* jshint maxcomplexity: 100 */ + + //@ts-ignore - ts can't understand JSON, still... + let pkg = ENV.package || require('./package.json'); + + let walletSalt = ENV.DASH_WALLET_SALT || ''; + let isHelp = walletSalt === 'help' || walletSalt === '--help'; + if (isHelp) { + throw new Error( + `USAGE\n ${process.argv[1]} [wallet-salt]\n\nEXAMPLE\n ${process.argv[1]} 'luke|han|chewie'`, + ); + } -async function main() { - /* jshint maxstatements: 1000 */ - /* jshint maxcomplexity: 100 */ + let walletPhrase = ENV.DASH_WALLET_PHRASE || ''; + if (!walletPhrase) { + throw new Error('missing DASH_WALLET_PHRASE'); + } - let walletSalt = process.argv[2] || ''; - let isHelp = walletSalt === 'help' || walletSalt === '--help'; - if (isHelp) { - throw new Error( - `USAGE\n ${process.argv[1]} [wallet-salt]\n\nEXAMPLE\n ${process.argv[1]} 'luke|han|chewie'`, - ); - } + let network = 'regtest'; + // let minimumParticipants = Packer.NETWORKS[network].minimumParticiparts; + + let rpc = DashRpc.create(rpcConfig); + let height = await rpc.init(rpc); + console.info(`[info] rpc server is ready. Height = ${height}`); + + let keyUtils = { + sign: async function (privKeyBytes, hashBytes) { + let sigOpts = { canonical: true, extraEntropy: true }; + let sigBytes = await Secp256k1.sign(hashBytes, privKeyBytes, sigOpts); + return sigBytes; + }, + getPrivateKey: async function (input) { + if (!input.address) { + //throw new Error('should put the address on the input there buddy...'); + console.warn('missing address:', input.txid, input.outputIndex); + return null; + } + let data = keysMap[input.address]; + let isUint = data.index > -1; + if (!isUint) { + throw new Error(`missing 'index'`); + } + // TODO map xkey by walletid + let addressKey = await xreceiveKey.deriveAddress(data.index); - let walletPhrase = process.env.DASH_WALLET_PHRASE || ''; - if (!walletPhrase) { - throw new Error('missing DASH_WALLET_PHRASE'); - } + { + // sanity check + let privKeyHex = DashTx.utils.bytesToHex(addressKey.privateKey); + if (data._privKeyHex !== privKeyHex) { + if (data._privKeyHex) { + console.log(data._privKeyHex); + console.log(privKeyHex); + throw new Error('mismatch key bytes'); + } + data._privKeyHex = privKeyHex; + } + } + return addressKey.privateKey; + }, + toPublicKey: async function (privKeyBytes) { + // TODO use secp256k1 directly + return await DashKeys.utils.toPublicKey(privKeyBytes); + }, + }; + let dashTx = DashTx.create(keyUtils); - let network = 'regtest'; - // let minimumParticipants = Packer.NETWORKS[network].minimumParticiparts; - rpcConfig.onconnected = async function () { - let rpc = this; - console.info(`[info] rpc client connected ${rpc.host}`); - }; + let testCoin = '1'; + let seedBytes = await DashPhrase.toSeed(walletPhrase, walletSalt); + let walletKey = await DashHd.fromSeed(seedBytes, { + coinType: testCoin, + versions: DashHd.TESTNET, + }); + let walletId = await DashHd.toId(walletKey); + + let accountHdpath = `m/44'/1'/0'`; + let accountKey = await walletKey.deriveAccount(0); + let xreceiveKey = await accountKey.deriveXKey(walletKey, 0); //jshint ignore:line + // let xchangeKey = await accountKey.deriveXKey(walletKey, 1); + // let xprvHdpath = `m/44'/5'/0'/0`; + // let xprvKey = await DashHd.derivePath(walletKey, xprvHdpath); + + // generate bunches of keys + // remove the leading `m/` or `m'/` + let partialPath = accountHdpath.replace(/^m'?\//, ''); + let totalBalance = 0; + let keysMap = {}; //jshint ignore:line + let used = []; + let addresses = []; + let unusedMap = {}; + let index = 0; + let numAddresses = 100; + for (;;) { + let uncheckedAddresses = []; + for (let i = 0; i < numAddresses; i += 1) { + let addressKey = await xreceiveKey.deriveAddress(index); + + // Descriptors are in the form of + // - pkh(xpub123...abc/2) - for the 3rd address of a receiving or change xpub + // - pkh(xpub456...def/0/2) - for the 3rd receive address of an account xpub + // - pkh([walletid/44'/0'/0']xpub123...abc/0/2) - same, plus wallet & hd info + // - pkh([walletid/44'/0'/0'/0/2]Xaddr...#checksum) - same, but the address + // See also: https://github.com/dashpay/dash/blob/master/doc/descriptors.md + // TODO sort out sha vs double-sha vs fingerprint + let descriptor = `pkh([${walletId}/${partialPath}/0/${index}])`; + let address = await DashHd.toAddr(addressKey.publicKey, { + version: 'testnet', + }); + // let utxosRpc = await rpc.getAddressUtxos({ addresses: [address] }); + // let utxos = utxosRpc.result; + // console.log('utxosRpc.result.length', utxosRpc.result.length); + + let data = keysMap[address]; + if (!data) { + data = { + walletId: walletId, + prefix: "m/44'/1'", + account: 0, + usage: 0, + index: index, + descriptor: descriptor, + address: address, + // uxtos: utxos, + used: false, + reserved: 0, + satoshis: 0, + }; + // console.log('[debug] addr info', data); + addresses.push(address); + uncheckedAddresses.push(address); + } + keysMap[index] = data; + keysMap[address] = data; + // console.log('[DEBUG] address:', address); + if (!data.used) { + unusedMap[address] = data; + } - let rpc = new DashRpc(rpcConfig); - rpc.onconnected = rpcConfig.onconnected; - let height = await rpc.init(rpc); - console.info(`[info] rpc server is ready. Height = ${height}`); - - let keyUtils = { - sign: async function (privKeyBytes, hashBytes) { - let sigOpts = { canonical: true, extraEntropy: true }; - let sigBytes = await Secp256k1.sign(hashBytes, privKeyBytes, sigOpts); - return sigBytes; - }, - getPrivateKey: async function (input) { - if (!input.address) { - //throw new Error('should put the address on the input there buddy...'); - console.warn('missing address:', input.txid, input.outputIndex); - return null; - } - let data = keysMap[input.address]; - let isUint = data.index > -1; - if (!isUint) { - throw new Error(`missing 'index'`); + index += 1; } - // TODO map xkey by walletid - let addressKey = await xreceiveKey.deriveAddress(data.index); + // console.log('[debug] addresses.length', addresses.length); + // console.log('[debug] uncheckedAddresses.length', uncheckedAddresses.length); - { - // sanity check - let privKeyHex = DashTx.utils.bytesToHex(addressKey.privateKey); - if (data._privKeyHex !== privKeyHex) { - if (data._privKeyHex) { - console.log(data._privKeyHex); - console.log(privKeyHex); - throw new Error('mismatch key bytes'); - } - data._privKeyHex = privKeyHex; + // TODO segment unused addresses + // let unusedAddresses = Object.keys(unusedMap); + // console.log('[debug] unusedAddresses.length', unusedAddresses.length); + + let mempooldeltas = await rpc.getAddressMempool({ + addresses: uncheckedAddresses, + // addresses: unusedAddresses, + }); + // console.log( + // '[debug] mempooldeltas.result.length', + // mempooldeltas.result.length, + // ); + // TODO check that we have a duplicate in both deltas by using txid, vin/vout + for (let delta of mempooldeltas.result) { + totalBalance += delta.satoshis; + + let data = keysMap[delta.address]; + data.satoshis += delta.satoshis; + data.used = true; + if (!used.includes(data)) { + used.push(data); } + delete unusedMap[data.address]; } - return addressKey.privateKey; - }, - toPublicKey: async function (privKeyBytes) { - // TODO use secp256k1 directly - return await DashKeys.utils.toPublicKey(privKeyBytes); - }, - }; - let dashTx = DashTx.create(keyUtils); - - let testCoin = '1'; - let seedBytes = await DashPhrase.toSeed(walletPhrase, walletSalt); - let walletKey = await DashHd.fromSeed(seedBytes, { - coinType: testCoin, - versions: DashHd.TESTNET, - }); - let walletId = await DashHd.toId(walletKey); - - let accountHdpath = `m/44'/1'/0'`; - let accountKey = await walletKey.deriveAccount(0); - let xreceiveKey = await accountKey.deriveXKey(walletKey, 0); //jshint ignore:line - // let xchangeKey = await accountKey.deriveXKey(walletKey, 1); - // let xprvHdpath = `m/44'/5'/0'/0`; - // let xprvKey = await DashHd.derivePath(walletKey, xprvHdpath); - - // generate bunches of keys - // remove the leading `m/` or `m'/` - let partialPath = accountHdpath.replace(/^m'?\//, ''); - let totalBalance = 0; - let keysMap = {}; //jshint ignore:line - let used = []; - let addresses = []; - let unusedMap = {}; - let index = 0; - let numAddresses = 100; - for (;;) { - let uncheckedAddresses = []; - for (let i = 0; i < numAddresses; i += 1) { - let addressKey = await xreceiveKey.deriveAddress(index); - - // Descriptors are in the form of - // - pkh(xpub123...abc/2) - for the 3rd address of a receiving or change xpub - // - pkh(xpub456...def/0/2) - for the 3rd receive address of an account xpub - // - pkh([walletid/44'/0'/0']xpub123...abc/0/2) - same, plus wallet & hd info - // - pkh([walletid/44'/0'/0'/0/2]Xaddr...#checksum) - same, but the address - // See also: https://github.com/dashpay/dash/blob/master/doc/descriptors.md - // TODO sort out sha vs double-sha vs fingerprint - let descriptor = `pkh([${walletId}/${partialPath}/0/${index}])`; - let address = await DashHd.toAddr(addressKey.publicKey, { - version: 'testnet', + + let deltas = await rpc.getAddressDeltas({ + addresses: uncheckedAddresses, }); - // let utxosRpc = await rpc.getAddressUtxos({ addresses: [address] }); - // let utxos = utxosRpc.result; - // console.log('utxosRpc.result.length', utxosRpc.result.length); - - let data = keysMap[address]; - if (!data) { - data = { - walletId: walletId, - prefix: "m/44'/1'", - account: 0, - usage: 0, - index: index, - descriptor: descriptor, - address: address, - // uxtos: utxos, - used: false, - reserved: 0, - satoshis: 0, - }; - // console.log('[debug] addr info', data); - addresses.push(address); - uncheckedAddresses.push(address); - } - keysMap[index] = data; - keysMap[address] = data; - // console.log('[DEBUG] address:', address); - if (!data.used) { - unusedMap[address] = data; + // console.log('[debug] deltas.result.length', deltas.result.length); + for (let delta of deltas.result) { + totalBalance += delta.satoshis; + + let data = keysMap[delta.address]; + data.satoshis += delta.satoshis; + data.used = true; + if (!used.includes(data)) { + used.push(data); + } + delete unusedMap[data.address]; } - index += 1; + let numUnused = addresses.length - used.length; + if (numUnused >= MIN_UNUSED) { + // console.log('[debug] addresses.length', addresses.length); + // console.log('[debug] used.length', used.length); + break; + } } - // console.log('[debug] addresses.length', addresses.length); - // console.log('[debug] uncheckedAddresses.length', uncheckedAddresses.length); + console.log('[debug] wallet balance:', totalBalance); - // TODO segment unused addresses - // let unusedAddresses = Object.keys(unusedMap); - // console.log('[debug] unusedAddresses.length', unusedAddresses.length); + let denomination = 100001 * 1; - let mempooldeltas = await rpc.getAddressMempool({ - addresses: uncheckedAddresses, - // addresses: unusedAddresses, - }); - // console.log( - // '[debug] mempooldeltas.result.length', - // mempooldeltas.result.length, - // ); - // TODO check that we have a duplicate in both deltas by using txid, vin/vout - for (let delta of mempooldeltas.result) { - totalBalance += delta.satoshis; - - let data = keysMap[delta.address]; - data.satoshis += delta.satoshis; - data.used = true; - if (!used.includes(data)) { - used.push(data); - } - delete unusedMap[data.address]; - } + console.log('[debug] generate min balance...'); + void (await generateMinBalance()); + console.log('[debug] generate denoms...'); + void (await generateDenominations()); - let deltas = await rpc.getAddressDeltas({ - addresses: uncheckedAddresses, - }); - // console.log('[debug] deltas.result.length', deltas.result.length); - for (let delta of deltas.result) { - totalBalance += delta.satoshis; - - let data = keysMap[delta.address]; - data.satoshis += delta.satoshis; - data.used = true; - if (!used.includes(data)) { - used.push(data); + // TODO sort denominated + // for (let addr of addresses) { ... } + + async function generateMinBalance() { + for (let addr of addresses) { + // console.log('[debug] totalBalance:', totalBalance); + if (totalBalance >= MIN_BALANCE) { + break; + } + + let data = keysMap[addr]; + let isAvailable = !data.used && !data.reserved; + if (!isAvailable) { + continue; + } + + void (await generateToAddressAndUpdateBalance(data)); } - delete unusedMap[data.address]; } - let numUnused = addresses.length - used.length; - if (numUnused >= MIN_UNUSED) { - // console.log('[debug] addresses.length', addresses.length); - // console.log('[debug] used.length', used.length); - break; - } - } - console.log('[debug] wallet balance:', totalBalance); + async function generateDenominations() { + // jshint maxcomplexity: 25 + let denomCount = 0; + let denominable = []; + let denominated = {}; + for (let addr of addresses) { + let data = keysMap[addr]; + if (data.reserved) { + continue; + } + if (data.satoshis === 0) { + continue; + } - let denomination = 100001 * 1; + // TODO denominations.includes(data.satoshis) + let isUndenominated = data.satoshis % DENOM_LOWEST; + if (isUndenominated) { + if (data.satoshis >= PREDENOM_MIN) { + denominable.push(data); + } + continue; + } - void (await generateMinBalance()); - void (await generateDenominations()); + if (!denominated[data.satoshis]) { + denominated[data.satoshis] = []; + } + denomCount += 1; + denominated[data.satoshis].push(data); + } - // TODO sort denominated - // for (let addr of addresses) { ... } + // CAVEAT: this fee-approximation strategy that guarantees + // to denominate all coins _correctly_, but in some cases will + // create _smaller_ denominations than necessary - specifically + // 10 x 100001 instead of 1 x 1000010 when the lowest order of + // coin is near the single coin value (i.e. 551000010) + // (because 551000010 / 100194 yields 5499 x 100001 coins + full fees, + // but we actually only generate 5 + 4 + 9 + 9 = 27 coins, leaving + // well over 5472 * 193 extra value) + for (let data of denominable) { + // console.log('[debug] denominable', data); + if (denomCount >= MIN_DENOMINATED) { + break; + } - async function generateMinBalance() { - for (let addr of addresses) { - // console.log('[debug] totalBalance:', totalBalance); - if (totalBalance >= MIN_BALANCE) { - break; - } + let fee = data.satoshis; + + // 123 means + // - 3 x 100001 + // - 2 x 1000010 + // - 1 x 10000100 + let order = data.satoshis / PREDENOM_MIN; + order = Math.floor(order); + let orderStr = order.toString(); + // TODO mod and divide to loop and shift positions, rather than stringify + let orders = orderStr.split(''); + orders.reverse(); + + // TODO Math.min(orders.length, STANDARD_DENOMS.length); + // let numOutputs = 0; + let denomOutputs = []; + // let magnitudes = [0]; + for (let i = 0; i < orders.length; i += 1) { + let order = orders[i]; + let count = parseInt(order, 10); + let orderSingle = DENOM_LOWEST * Math.pow(10, i); + // let orderTotal = count * orderSingle; + // numOutputs += count; + for (let i = 0; i < count; i += 1) { + fee -= orderSingle; + denomOutputs.push({ + satoshis: orderSingle, + }); + } + // magnitudes.push(count); + } + // example: + // [ 0, 3, 2, 1 ] + // - 0 x 100001 * 0 + // - 3 x 100001 * 1 + // - 2 x 100001 * 10 + // - 1 x 100001 * 100 + + // console.log('[debug] denom outputs', denomOutputs); + // console.log('[debug] fee', fee); + // Note: this is where we reconcile the difference between + // the number of the smallest denom, and the number of actual denoms + // (and where we may end up with 10 x LOWEST, which we could carry + // over into the next tier, but won't right now for simplicity). + for (;;) { + let numInputs = 1; + let fees = DashTx._appraiseCounts(numInputs, denomOutputs.length + 1); + let nextCoinCost = DENOM_LOWEST + fees.max; + if (fee < nextCoinCost) { + // TODO split out 10200 (or 10193) collaterals as well + break; + } + fee -= DashTx.OUTPUT_SIZE; + fee -= DENOM_LOWEST; + denomOutputs.push({ + satoshis: DENOM_LOWEST, + }); + // numOutputs += 1; + // magnitudes[1] += 1; + } + // console.log('[debug] denom outputs', denomOutputs); - let data = keysMap[addr]; - let isAvailable = !data.used && !data.reserved; - if (!isAvailable) { - continue; - } + let changes = []; + for (let addr of addresses) { + if (denomOutputs.length === 0) { + break; + } - void (await generateToAddressAndUpdateBalance(data)); - } - } + let unused = unusedMap[addr]; + if (!unused) { + continue; + } - async function generateDenominations() { - // jshint maxcomplexity: 25 - let denomCount = 0; - let denominable = []; - let denominated = {}; - for (let addr of addresses) { - let data = keysMap[addr]; - if (data.reserved) { - continue; - } - if (data.satoshis === 0) { - continue; - } + unused.reserved = Date.now(); + delete unusedMap[addr]; + + let denomValue = denomOutputs.pop(); + if (!denomValue) { + break; + } - // TODO denominations.includes(data.satoshis) - let isUndenominated = data.satoshis % DENOM_LOWEST; - if (isUndenominated) { - if (data.satoshis >= PREDENOM_MIN) { - denominable.push(data); + unused.satoshis = denomValue.satoshis; + changes.push(unused); } - continue; - } - if (!denominated[data.satoshis]) { - denominated[data.satoshis] = []; - } - denomCount += 1; - denominated[data.satoshis].push(data); - } + let txInfo; + { + let utxosRpc = await rpc.getAddressUtxos({ + addresses: [data.address], + }); + let utxos = utxosRpc.result; + for (let utxo of utxos) { + console.log('[debug] input utxo', utxo); + // utxo.sigHashType = 0x01; + utxo.address = data.address; + if (utxo.txid) { + // TODO fix in dashtx + utxo.txId = utxo.txid; + } + } + for (let change of changes) { + let pubKeyHashBytes = await DashKeys.addrToPkh(change.address, { + version: 'testnet', + }); + change.pubKeyHash = DashKeys.utils.bytesToHex(pubKeyHashBytes); + } - // CAVEAT: this fee-approximation strategy that guarantees - // to denominate all coins _correctly_, but in some cases will - // create _smaller_ denominations than necessary - specifically - // 10 x 100001 instead of 1 x 1000010 when the lowest order of - // coin is near the single coin value (i.e. 551000010) - // (because 551000010 / 100194 yields 5499 x 100001 coins + full fees, - // but we actually only generate 5 + 4 + 9 + 9 = 27 coins, leaving - // well over 5472 * 193 extra value) - for (let data of denominable) { - // console.log('[debug] denominable', data); - if (denomCount >= MIN_DENOMINATED) { - break; - } + txInfo = { + version: 3, + inputs: utxos, + outputs: changes, + locktime: 0, + }; + txInfo.inputs.sort(DashTx.sortInputs); + txInfo.outputs.sort(DashTx.sortOutputs); + } - let fee = data.satoshis; - - // 123 means - // - 3 x 100001 - // - 2 x 1000010 - // - 1 x 10000100 - let order = data.satoshis / PREDENOM_MIN; - order = Math.floor(order); - let orderStr = order.toString(); - // TODO mod and divide to loop and shift positions, rather than stringify - let orders = orderStr.split(''); - orders.reverse(); - - // TODO Math.min(orders.length, STANDARD_DENOMS.length); - // let numOutputs = 0; - let denomOutputs = []; - // let magnitudes = [0]; - for (let i = 0; i < orders.length; i += 1) { - let order = orders[i]; - let count = parseInt(order, 10); - let orderSingle = DENOM_LOWEST * Math.pow(10, i); - // let orderTotal = count * orderSingle; - // numOutputs += count; - for (let i = 0; i < count; i += 1) { - fee -= orderSingle; - denomOutputs.push({ - satoshis: orderSingle, + let total = 0; + for (let input of txInfo.inputs) { + let data = keysMap[input.address]; + total += input.satoshis; + let addressKey = await xreceiveKey.deriveAddress(data.index); + keys.push(addressKey.privateKey); + // DEBUG check pkh hex + let pubKeyHashBytes = await DashKeys.addrToPkh(data.address, { + version: 'testnet', }); + data.pubKeyHash = DashKeys.utils.bytesToHex(pubKeyHashBytes); + console.log(data); } - // magnitudes.push(count); - } - // example: - // [ 0, 3, 2, 1 ] - // - 0 x 100001 * 0 - // - 3 x 100001 * 1 - // - 2 x 100001 * 10 - // - 1 x 100001 * 100 - - // console.log('[debug] denom outputs', denomOutputs); - // console.log('[debug] fee', fee); - // Note: this is where we reconcile the difference between - // the number of the smallest denom, and the number of actual denoms - // (and where we may end up with 10 x LOWEST, which we could carry - // over into the next tier, but won't right now for simplicity). - for (;;) { - let numInputs = 1; - let fees = DashTx._appraiseCounts(numInputs, denomOutputs.length + 1); - let nextCoinCost = DENOM_LOWEST + fees.max; - if (fee < nextCoinCost) { - // TODO split out 10200 (or 10193) collaterals as well - break; + console.log('[DEBUG] total, txInfo', total, txInfo); + let txInfoSigned = await dashTx.hashAndSignAll(txInfo); + + console.log('[debug], txInfo, txSigned'); + console.log(txInfo); + console.log(txInfoSigned); + await sleep(150); + let txRpc = await rpc.sendRawTransaction(txInfoSigned.transaction); + await sleep(150); + console.log('[debug] txRpc.result', txRpc.result); + + // TODO don't add collateral coins + for (let change of changes) { + denomCount += 1; + if (!denominated[change.satoshis]) { + denominated[change.satoshis] = []; + } + denominated[change.satoshis].push(change); + change.reserved = 0; } - fee -= DashTx.OUTPUT_SIZE; - fee -= DENOM_LOWEST; - denomOutputs.push({ - satoshis: DENOM_LOWEST, - }); - // numOutputs += 1; - // magnitudes[1] += 1; } - // console.log('[debug] denom outputs', denomOutputs); + } - let changes = []; + async function generateToAddressAndUpdateBalance(data) { + let numBlocks = 1; + await sleep(150); + void (await rpc.generateToAddress(numBlocks, data.address)); + await sleep(150); + // let blocksRpc = await rpc.generateToAddress(numBlocks, addr); + // console.log('[debug] blocksRpc', blocksRpc); + + // let deltas = await rpc.getAddressMempool({ addresses: [addr] }); + // console.log('[debug] generatetoaddress mempool', deltas); + // let deltas2 = await rpc.getAddressDeltas({ addresses: [addr] }); + // console.log('[debug] generatetoaddress deltas', deltas); + // let results = deltas.result.concat(deltas2.result); + // for (let delta of results) { + // totalBalance += delta.satoshis; + // keysMap[delta.address].used = true; + // delete unusedMap[delta.address]; + // } + + let utxosRpc = await rpc.getAddressUtxos({ addresses: [data.address] }); + let utxos = utxosRpc.result; + for (let utxo of utxos) { + // console.log(data.index, '[debug] utxo.satoshis', utxo.satoshis); + data.satoshis += utxo.satoshis; + totalBalance += utxo.satoshis; + keysMap[utxo.address].used = true; + delete unusedMap[utxo.address]; + } + } + + // TODO unreserve collateral after positive response + // (and check for use 30 seconds after failure message) + async function getCollateralTx() { + let barelyEnoughest = { satoshis: Infinity, reserved: 0 }; for (let addr of addresses) { - if (denomOutputs.length === 0) { - break; + let data = keysMap[addr]; + if (data.reserved > 0) { + continue; } - let unused = unusedMap[addr]; - if (!unused) { + if (!data.satoshis) { continue; } - unused.reserved = Date.now(); - delete unusedMap[addr]; + if (barelyEnoughest.reserved > 0) { + let isDenom = data.satoshis % DENOM_LOWEST === 0; + if (isDenom) { + continue; + } + } - let denomValue = denomOutputs.pop(); - if (!denomValue) { - break; + if (data.satoshis < COLLATERAL) { + continue; } - unused.satoshis = denomValue.satoshis; - changes.push(unused); + if (data.satoshis < barelyEnoughest.satoshis) { + barelyEnoughest = data; + barelyEnoughest.reserved = Date.now(); + } } + console.log('[debug] barelyEnoughest coin:', barelyEnoughest); - let txInfo; + let collateralTxInfo; { - let utxosRpc = await rpc.getAddressUtxos({ addresses: [data.address] }); + let addr = barelyEnoughest.address; + let utxosRpc = await rpc.getAddressUtxos({ addresses: [addr] }); let utxos = utxosRpc.result; for (let utxo of utxos) { console.log('[debug] input utxo', utxo); // utxo.sigHashType = 0x01; - utxo.address = data.address; + utxo.address = addr; if (utxo.txid) { // TODO fix in dashtx utxo.txId = utxo.txid; } } - for (let change of changes) { - let pubKeyHashBytes = await DashKeys.addrToPkh(change.address, { + + let output; + let leftover = barelyEnoughest.satoshis - COLLATERAL; + if (leftover >= COLLATERAL) { + let change = await reserveChangeAddress(); + output = Object.assign({}, change); + // TODO change.used = true; + // change.reserved = 0; + let pubKeyHashBytes = await DashKeys.addrToPkh(output.address, { version: 'testnet', }); - change.pubKeyHash = DashKeys.utils.bytesToHex(pubKeyHashBytes); + output.pubKeyHash = DashKeys.utils.bytesToHex(pubKeyHashBytes); + output.satoshis = leftover; + } else { + output = DashTx.createDonationOutput(); + // TODO 0-byte memo? no outputs (bypassing the normal restriction)? } - txInfo = { + console.log('[debug] change or memo', output); + let txInfo = { version: 3, inputs: utxos, - outputs: changes, + outputs: [output], locktime: 0, }; txInfo.inputs.sort(DashTx.sortInputs); txInfo.outputs.sort(DashTx.sortOutputs); - } - let keys = []; - for (let input of txInfo.inputs) { - let data = keysMap[input.address]; - let addressKey = await xreceiveKey.deriveAddress(data.index); - keys.push(addressKey.privateKey); - // DEBUG check pkh hex - let pubKeyHashBytes = await DashKeys.addrToPkh(data.address, { - version: 'testnet', - }); - data.pubKeyHash = DashKeys.utils.bytesToHex(pubKeyHashBytes); - console.log(data); + collateralTxInfo = txInfo; } - let txInfoSigned = await dashTx.hashAndSignAll(txInfo); - console.log('[debug], txInfo, keys, txSigned'); - console.log(txInfo); - console.log(keys); - console.log(txInfoSigned); - await sleep(150); - let txRpc = await rpc.sendRawTransaction(txInfoSigned.transaction); - await sleep(150); - console.log('[debug] txRpc.result', txRpc.result); - - // TODO don't add collateral coins - for (let change of changes) { - denomCount += 1; - if (!denominated[change.satoshis]) { - denominated[change.satoshis] = []; - } - denominated[change.satoshis].push(change); - change.reserved = 0; - } + console.log('[debug] ds* collateral tx', collateralTxInfo); + return collateralTxInfo; } - } - - async function generateToAddressAndUpdateBalance(data) { - let numBlocks = 1; - await sleep(150); - void (await rpc.generateToAddress(numBlocks, data.address)); - await sleep(150); - // let blocksRpc = await rpc.generateToAddress(numBlocks, addr); - // console.log('[debug] blocksRpc', blocksRpc); - - // let deltas = await rpc.getAddressMempool({ addresses: [addr] }); - // console.log('[debug] generatetoaddress mempool', deltas); - // let deltas2 = await rpc.getAddressDeltas({ addresses: [addr] }); - // console.log('[debug] generatetoaddress deltas', deltas); - // let results = deltas.result.concat(deltas2.result); - // for (let delta of results) { - // totalBalance += delta.satoshis; - // keysMap[delta.address].used = true; - // delete unusedMap[delta.address]; - // } - - let utxosRpc = await rpc.getAddressUtxos({ addresses: [data.address] }); - let utxos = utxosRpc.result; - for (let utxo of utxos) { - // console.log(data.index, '[debug] utxo.satoshis', utxo.satoshis); - data.satoshis += utxo.satoshis; - totalBalance += utxo.satoshis; - keysMap[utxo.address].used = true; - delete unusedMap[utxo.address]; - } - } - // TODO unreserve collateral after positive response - // (and check for use 30 seconds after failure message) - async function getCollateralTx() { - let barelyEnoughest = { satoshis: Infinity, reserved: 0 }; - for (let addr of addresses) { - let data = keysMap[addr]; - if (data.reserved > 0) { - continue; - } - - if (!data.satoshis) { - continue; - } + async function reserveChangeAddress() { + for (let addr of addresses) { + let data = keysMap[addr]; - if (barelyEnoughest.reserved > 0) { - let isDenom = data.satoshis % DENOM_LOWEST === 0; - if (isDenom) { + let isAvailable = !data.used && !data.reserved; + if (!isAvailable) { continue; } - } - if (data.satoshis < CoinJoin.COLLATERAL) { - continue; + data.reserved = Date.now(); + return data; } - if (data.satoshis < barelyEnoughest.satoshis) { - barelyEnoughest = data; - barelyEnoughest.reserved = Date.now(); - } + let msg = + 'sanity fail: ran out of addresses despite having 500+ unused extra'; + throw new Error(msg); } - console.log('[debug] barelyEnoughest coin:', barelyEnoughest); - let collateralTxInfo; + // async function getPrivateKeys(inputs) { + // let keys = []; + // for (let input of inputs) { + // let privKeyBytes = await keyUtils.getPrivateKey(input); + // keys.push(privKeyBytes); + // } + + // return keys; + // } + + console.log('[debug] get evonode list...'); + let evonodes = []; { - let addr = barelyEnoughest.address; - let utxosRpc = await rpc.getAddressUtxos({ addresses: [addr] }); - let utxos = utxosRpc.result; - for (let utxo of utxos) { - console.log('[debug] input utxo', utxo); - // utxo.sigHashType = 0x01; - utxo.address = addr; - if (utxo.txid) { - // TODO fix in dashtx - utxo.txId = utxo.txid; + //let resp = await rpc.masternodelist(); + let rpcBaseUrl = `${ENV.DASHD_RPC_PROTOCOL}://${ENV.DASHD_RPC_HOST}:${ENV.DASHD_RPC_PORT}`; + let res = await fetch(`${rpcBaseUrl}/rpc/masternodelist`); + let resp = await res.json(); + let evonodesMap = resp.result; + let evonodeProTxIds = Object.keys(evonodesMap); + for (let id of evonodeProTxIds) { + let evonode = evonodesMap[id]; + if (evonode.status === 'ENABLED') { + let hostParts = evonode.address.split(':'); + let evodata = { + id: evonode.id, + hostname: hostParts[0], + port: hostParts[1], + type: evonode.type, + }; + evonodes.push(evodata); } } - - let output; - let leftover = barelyEnoughest.satoshis - CoinJoin.COLLATERAL; - if (leftover >= CoinJoin.COLLATERAL) { - let change = await reserveChangeAddress(); - output = Object.assign({}, change); - // TODO change.used = true; - // change.reserved = 0; - let pubKeyHashBytes = await DashKeys.addrToPkh(output.address, { - version: 'testnet', - }); - output.pubKeyHash = DashKeys.utils.bytesToHex(pubKeyHashBytes); - output.satoshis = leftover; - } else { - output = DashTx.createDonationOutput(); - // TODO 0-byte memo? no outputs (bypassing the normal restriction)? + if (!evonodes.length) { + throw new Error('Sanity Fail: no evonodes online'); } + } - console.log('[debug] change or memo', output); - let txInfo = { - version: 3, - inputs: utxos, - outputs: [output], - locktime: 0, - }; - txInfo.inputs.sort(DashTx.sortInputs); - txInfo.outputs.sort(DashTx.sortOutputs); + // void shuffle(evonodes); + evonodes.sort(byId); + let evonode = evonodes.at(-1); + console.info('[info] chosen evonode:'); + console.log(JSON.stringify(evonode, null, 2)); - collateralTxInfo = txInfo; + let query = { + access_token: 'secret', + hostname: evonode.hostname, + port: evonode.port, + }; + let searchParams = new URLSearchParams(query); + let search = searchParams.toString(); + let wsc = new WebSocket(`${ENV.DASHD_TCP_WS_URL}?${search}`); + //let conn = Net.createConnection({ + // host: evonode.hostname, + // port: evonode.port, + // keepAlive: true, + // keepAliveInitialDelay: 3, + // //localAddress: rpc.host, + //}); + + /** @type {Array} */ + let chunks = []; + let chunksLength = 0; + let errReject; + + function onError(err) { + console.error('Error:'); + console.error(err); + // conn.removeListener('error', onError); + wsc.onerror = null; + errReject(err); + } + function onEnd() { + console.info('[info] disconnected from server'); } + // conn.on('error', onError); + // conn.once('end', onEnd); + // conn.setMaxListeners(2); + wsc.onerror = onError; + wsc.onclose = onEnd; + + let dataCount = 0; + // conn.on('data', function (data) { + // let bytes = new Uint8Array(data); + // console.log('[DEBUG] data'); + // console.log(dataCount, bytes.length, DashTx.utils.bytesToHex(bytes)); + // dataCount += 1; + // }); + console.log('[DEBUG] main add wsc.onmessage'); + wsc.addEventListener('message', async function (wsevent) { + console.log('[DEBUG] main wsc.onmessage'); + let ab = await wsevent.data.arrayBuffer(); + let bytes = new Uint8Array(ab); + console.log('[DEBUG] data (main)'); + console.log(dataCount, bytes.length, DashTx.utils.bytesToHex(bytes)); + dataCount += 1; + }); - console.log('[debug] ds* collateral tx', collateralTxInfo); - return collateralTxInfo; - } + /** @type {Array} */ + let messages = []; + /** @type {Object} */ + let listenerMap = {}; + async function goRead() { + let pongSize = Packer.HEADER_SIZE + Packer.PING_SIZE; + let pongMessageBytes = new Uint8Array(pongSize); + for (;;) { + console.log('[debug] readMessage()'); + let msg = await readMessage(); + + if (msg.command === 'ping') { + void Packer.packPong({ + network: network, + message: pongMessageBytes, + nonce: msg.payload, + }); + // conn.write(pongMessageBytes); + wsc.send(pongMessageBytes); + console.log('[debug] sent pong'); + continue; + } - async function reserveChangeAddress() { - for (let addr of addresses) { - let data = keysMap[addr]; + if (msg.command === 'dssu') { + let dssu = await Parser.parseDssu(msg.payload); + console.log('[debug] dssu', dssu); + continue; + } - let isAvailable = !data.used && !data.reserved; - if (!isAvailable) { - continue; + let i = messages.length; + messages.push(msg); + let listeners = Object.values(listenerMap); + for (let ln of listeners) { + void ln(msg, i, messages); + } } - - data.reserved = Date.now(); - return data; } + void goRead(); + + /** + * Reads a for a full 24 bytes, parses those bytes as a header, + * and then reads the length of the payload. Any excess bytes will + * be saved for the next cycle - meaning it can handle multiple + * messages in a single packet. + */ + async function readMessage() { + const HEADER_SIZE = 24; + const PAYLOAD_SIZE_MAX = 4 * 1024 * 1024; + + // TODO setTimeout + let _resolve; + let _reject; + let p = new Promise(function (__resolve, __reject) { + _resolve = __resolve; + _reject = __reject; + }); - let msg = - 'sanity fail: ran out of addresses despite having 500+ unused extra'; - throw new Error(msg); - } - - // async function getPrivateKeys(inputs) { - // let keys = []; - // for (let input of inputs) { - // let privKeyBytes = await keyUtils.getPrivateKey(input); - // keys.push(privKeyBytes); - // } + let header; - // return keys; - // } + function cleanup() { + console.log('[DEBUG] [readMessage.cleanup] remove data listener'); + wsc.removeEventListener('message', onWsReadableHeader); + wsc.removeEventListener('message', onWsReadablePayload); + // console.log("[debug] readMessage handlers: remove 'onReadableHeader'"); + // conn.removeListener('data', onReadableHeader); + // conn.removeListener('readable', onReadableHeader); + + // console.log("[debug] readMessage handlers: remove 'onReadablePayload'"); + // conn.removeListener('data', onReadablePayload); + // conn.removeListener('readable', onReadablePayload); + } - let evonodes = []; - { - let resp = await rpc.masternodelist(); - let evonodesMap = resp.result; - let evonodeProTxIds = Object.keys(evonodesMap); - for (let id of evonodeProTxIds) { - let evonode = evonodesMap[id]; - if (evonode.status === 'ENABLED') { - let hostParts = evonode.address.split(':'); - let evodata = { - id: evonode.id, - hostname: hostParts[0], - port: hostParts[1], - type: evonode.type, - }; - evonodes.push(evodata); + function resolve(data) { + cleanup(); + _resolve(data); } - } - if (!evonodes.length) { - throw new Error('Sanity Fail: no evonodes online'); - } - } - // void shuffle(evonodes); - evonodes.sort(byId); - let evonode = evonodes.at(-1); - console.info('[info] chosen evonode:'); - console.log(JSON.stringify(evonode, null, 2)); - - let conn = Net.createConnection({ - host: evonode.hostname, - port: evonode.port, - keepAlive: true, - keepAliveInitialDelay: 3, - //localAddress: rpc.host, - }); - - /** @type {Array} */ - let chunks = []; - let chunksLength = 0; - let errReject; - - function onError(err) { - console.error('Error:'); - console.error(err); - conn.removeListener('error', onError); - errReject(err); - } - function onEnd() { - console.info('[info] disconnected from server'); - } - conn.on('error', onError); - conn.once('end', onEnd); - conn.setMaxListeners(2); - let dataCount = 0; - conn.on('data', function (data) { - console.log('[DEBUG] data'); - console.log(dataCount, data.length, data.toString('hex')); - dataCount += 1; - }); - - /** @type {Array} */ - let messages = []; - /** @type {Object} */ - let listenerMap = {}; - async function goRead() { - let pongSize = Packer.HEADER_SIZE + Packer.PING_SIZE; - let pongMessageBytes = new Uint8Array(pongSize); - for (;;) { - console.log('[debug] readMessage()'); - let msg = await readMessage(); - - if (msg.command === 'ping') { - void Packer.packPong({ - network: network, - message: pongMessageBytes, - nonce: msg.payload, - }); - conn.write(pongMessageBytes); - console.log('[debug] sent pong'); - continue; + function reject(err) { + cleanup(); + _reject(err); } - if (msg.command === 'dssu') { - let dssu = await Parser.parseDssu(msg.payload); - console.log('[debug] dssu', dssu); - continue; + function onReadableHeader(bytes) { + let size = bytes?.length || 0; + console.log('State: reading header', size, typeof bytes); + let chunk; + for (;;) { + chunk = bytes; + // chunk = conn.read(); // TODO reenable + if (!chunk) { + break; + } + chunks.push(chunk); + chunksLength += chunk.byteLength; + bytes = null; // TODO nix + } + if (chunksLength < HEADER_SIZE) { + return; + } + if (chunks.length > 1) { + chunk = concatBytes(chunks, chunksLength); + } else { + chunk = chunks[0]; + } + chunks = []; + chunksLength = 0; + if (chunk.byteLength > HEADER_SIZE) { + let extra = chunk.slice(HEADER_SIZE); + chunks.push(extra); + chunksLength += chunk.byteLength; + chunk = chunk.slice(0, HEADER_SIZE); + } + header = Parser.parseHeader(chunk); + if (header.payloadSize > PAYLOAD_SIZE_MAX) { + console.log(`[DEBUG] header`, header); + throw new Error('too big you are, handle you I cannot'); + } + console.log('DEBUG header', header); + console.log('[DEBUG] [onReadableHeader] remove data listener'); + // conn.removeListener('readable', onReadableHeader); + // conn.removeListener('data', onReadableHeader); + //wsc.onmessage = null; + wsc.removeEventListener('message', onWsReadableHeader); + + if (header.payloadSize === 0) { + resolve(header); + return; + } + + // console.log("[debug] readMessage handlers: add 'onReadablePayload'"); + //conn.on('readable', onReadablePayload); + // conn.on('data', onReadablePayload); + console.log('[DEBUG] onReadableHeader add wsc.onmessage'); + wsc.addEventListener('message', onWsReadablePayload); + onReadablePayload(null); + } + async function onNodeReadableHeader(data) { + let bytes = new Uint8Array(data); + onReadableHeader(bytes); + } + async function onWsReadableHeader(wsevent) { + console.log('[DEBUG] onReadableHeader wsc.onmessage'); + let ab = await wsevent.data.arrayBuffer(); + let bytes = new Uint8Array(ab); + console.log('[DEBUG] bytes (readable header)'); + console.log(dataCount, bytes.length, DashTx.utils.bytesToHex(bytes)); + onReadableHeader(bytes); } - let i = messages.length; - messages.push(msg); - let listeners = Object.values(listenerMap); - for (let ln of listeners) { - void ln(msg, i, messages); + /** + * @param {Uint8Array} bytes + */ + function onReadablePayload(bytes) { + let size = bytes?.length || 0; + console.log('State: reading payload', size); + let chunk; + for (;;) { + chunk = bytes; + // chunk = conn.read(); // TODO revert + if (!chunk) { + break; + } + chunks.push(chunk); + chunksLength += chunk.byteLength; + bytes = null; // TODO nix + } + if (chunksLength < header.payloadSize) { + return; + } + if (chunks.length > 1) { + chunk = concatBytes(chunks, chunksLength); + } else if (chunks.length === 1) { + chunk = chunks[0]; + } else { + console.log("[warn] 'chunk' is 'null' (probably the debug null)"); + return; + } + chunks = []; + chunksLength = 0; + if (chunk.byteLength > header.payloadSize) { + let extra = chunk.slice(header.payloadSize); + chunks.push(extra); + chunksLength += chunk.byteLength; + chunk = chunk.slice(0, header.payloadSize); + } + header.payload = chunk; + console.log('[DEBUG] [onReadablePayload] remove data listener'); + // conn.removeListener('readable', onReadablePayload); + // conn.removeListener('data', onReadablePayload); + // wsc.onmessage = null; + wsc.removeEventListener('message', onWsReadablePayload); + resolve(header); + } + async function onNodeReadablePayload(data) { + let bytes = new Uint8Array(data); + onReadablePayload(bytes); + } + async function onWsReadablePayload(wsevent) { + console.log('[DEBUG] onReadablePayload wsc.onmessage'); + let ab = await wsevent.data.arrayBuffer(); + let bytes = new Uint8Array(ab); + console.log('[DEBUG] data (readable payload)'); + console.log(dataCount, bytes.length, DashTx.utils.bytesToHex(bytes)); + onReadablePayload(bytes); } - } - } - void goRead(); - /** - * Reads a for a full 24 bytes, parses those bytes as a header, - * and then reads the length of the payload. Any excess bytes will - * be saved for the next cycle - meaning it can handle multiple - * messages in a single packet. - */ - async function readMessage() { - const HEADER_SIZE = 24; - const PAYLOAD_SIZE_MAX = 4 * 1024 * 1024; - - // TODO setTimeout - let _resolve; - let _reject; - let p = new Promise(function (__resolve, __reject) { - _resolve = __resolve; - _reject = __reject; - }); + errReject = reject; - let header; + // console.log("[debug] readMessage handlers: add 'onReadableHeader'"); + //conn.on('readable', onReadableHeader); + // conn.on('data', onReadableHeader); + console.log('[DEBUG] readMessage add wsc.onmessage'); + wsc.addEventListener('message', onWsReadableHeader); - function cleanup() { - // console.log("[debug] readMessage handlers: remove 'onReadableHeader'"); - conn.removeListener('data', onReadableHeader); - conn.removeListener('readable', onReadableHeader); + if (chunks.length) { + onReadableHeader(null); + } - // console.log("[debug] readMessage handlers: remove 'onReadablePayload'"); - conn.removeListener('data', onReadablePayload); - conn.removeListener('readable', onReadablePayload); + let msg = await p; + return msg; } - function resolve(data) { - cleanup(); - _resolve(data); - } + async function waitForConnect() { + // connect / connected + // TODO setTimeout + await new Promise(function (_resolve, _reject) { + function cleanup() { + console.log('[DEBUG] [waitForConnect.cleanup] remove data listener'); + // conn.removeListener('readable', onReadable); + // conn.removeListener('data', onReadable); + // wsc.onmessage = null; + wsc.removeEventListener('message', onWsReadable); + } - function reject(err) { - cleanup(); - _reject(err); - } + function resolve(data) { + cleanup(); + _resolve(data); + } - function onReadableHeader(data) { - let size = data?.length || 0; - console.log('State: reading header', size); - let chunk; - for (;;) { - chunk = data; - // chunk = conn.read(); // TODO reenable - if (!chunk) { - break; + function reject(err) { + cleanup(); + _reject(err); } - chunks.push(chunk); - chunksLength += chunk.byteLength; - data = null; // TODO nix - } - if (chunksLength < HEADER_SIZE) { - return; - } - if (chunks.length > 1) { - chunk = Buffer.concat(chunks, chunksLength); - } else { - chunk = chunks[0]; - } - chunks = []; - chunksLength = 0; - if (chunk.byteLength > HEADER_SIZE) { - let extra = chunk.slice(HEADER_SIZE); - chunks.push(extra); - chunksLength += chunk.byteLength; - chunk = chunk.slice(0, HEADER_SIZE); - } - header = Parser.parseHeader(chunk); - if (header.payloadSize > PAYLOAD_SIZE_MAX) { - throw new Error('too big you are, handle you I cannot'); - } - // console.log('DEBUG header', header); - conn.removeListener('readable', onReadableHeader); - conn.removeListener('data', onReadableHeader); - if (header.payloadSize === 0) { - resolve(header); - return; - } + function onConnect() { + console.log('[DEBUG] waitForConnect wsc.onopen'); + resolve(); + } - // console.log("[debug] readMessage handlers: add 'onReadablePayload'"); - //conn.on('readable', onReadablePayload); - conn.on('data', onReadablePayload); - onReadablePayload(null); + function onReadable(bytes) { + // checking an impossible condition, just in case + throw new Error('unexpected response before request'); + } + async function onNodeReadable(data) { + let bytes = new Uint8Array(data); + onReadable(bytes); + } + async function onWsReadable(wsevent) { + console.log('[DEBUG] waitForConnect wsc.onmessage'); + let ab = await wsevent.data.arrayBuffer(); + let bytes = new Uint8Array(ab); + console.log('[DEBUG] data (readable)'); + console.log(dataCount, bytes.length, DashTx.utils.bytesToHex(bytes)); + onReadable(bytes); + } + + errReject = reject; + // conn.once('connect', onConnect); + wsc.onopen = null; + wsc.onopen = onConnect; + //conn.on('readable', onReadable); + // conn.on('data', onReadable); + console.log('[DEBUG] waitForConnect add wsc.onmessage'); + wsc.addEventListener('message', onWsReadable); + }); } - function onReadablePayload(data) { - let size = data?.length || 0; - console.log('State: reading payload', size); - let chunk; - for (;;) { - chunk = data; - // chunk = conn.read(); // TODO revert - if (!chunk) { - break; - } - chunks.push(chunk); - chunksLength += chunk.byteLength; - data = null; // TODO nix - } - if (chunksLength < header.payloadSize) { - return; - } - if (chunks.length > 1) { - chunk = Buffer.concat(chunks, chunksLength); - } else if (chunks.length === 1) { - chunk = chunks[0]; - } else { - console.log("[warn] 'chunk' is 'null' (probably the debug null)"); - return; - } - chunks = []; - chunksLength = 0; - if (chunk.byteLength > header.payloadSize) { - let extra = chunk.slice(header.payloadSize); - chunks.push(extra); - chunksLength += chunk.byteLength; - chunk = chunk.slice(0, header.payloadSize); - } - header.payload = chunk; - conn.removeListener('readable', onReadablePayload); - conn.removeListener('data', onReadablePayload); - resolve(header); + await waitForConnect(); + console.log('connected'); + + // + // version / verack + // + let versionMsg = Packer.version({ + network: network, // Packer.NETWORKS.regtest, + //protocol_version: Packer.PROTOCOL_VERSION, + //addr_recv_services: [Packer.IDENTIFIER_SERVICES.NETWORK], + addr_recv_ip: evonode.hostname, + addr_recv_port: evonode.port, + //addr_trans_services: [], + //addr_trans_ip: '127.0.01', + //addr_trans_port: null, + // addr_trans_ip: conn.localAddress, + // addr_trans_port: conn.localPort, + start_height: height, + //nonce: null, + user_agent: `DashJoin.js/${pkg.version}`, + // optional-ish + relay: false, + mnauth_challenge: null, + mn_connection: false, + }); + + // let versionBuffer = Buffer.from(versionMsg); + // console.log('version', versionBuffer.toString('hex')); + // console.log(Parser.parseHeader(versionBuffer.slice(0, 24))); + // console.log(Parser.parseVerack(versionBuffer.slice(24))); + + { + let versionP = new Promise(function (resolve, reject) { + listenerMap['version'] = async function (message) { + let versionResp = await Parser.parseVersion(message.payload); + console.log('DEBUG version', versionResp.version); + resolve(null); + listenerMap['version'] = null; + delete listenerMap['version']; + }; + }); + await sleep(150); + // conn.write(versionMsg); + wsc.send(versionMsg); + + await versionP; } - errReject = reject; + { + let verackP = await new Promise(function (resolve, reject) { + listenerMap['verack'] = async function (message) { + if (message.command !== 'verack') { + return; + } - // console.log("[debug] readMessage handlers: add 'onReadableHeader'"); - //conn.on('readable', onReadableHeader); - conn.on('data', onReadableHeader); + console.log('DEBUG verack', message); + resolve(); + listenerMap['verack'] = null; + delete listenerMap['verack']; + }; + }); + let verackBytes = Packer.packMessage({ + network, + command: 'verack', + payload: null, + }); + await sleep(150); + // conn.write(verackBytes); + wsc.send(verackBytes); - if (chunks.length) { - onReadableHeader(null); + await verackP; } - let msg = await p; - return msg; - } + { + let mnauthP = new Promise(function (resolve, reject) { + listenerMap['mnauth'] = async function (message) { + if (message.command !== 'mnauth') { + return; + } - async function waitForConnect() { - // connect / connected - // TODO setTimeout - await new Promise(function (_resolve, _reject) { - function cleanup() { - conn.removeListener('readable', onReadable); - conn.removeListener('data', onReadable); - } + resolve(); + listenerMap['mnauth'] = null; + delete listenerMap['mnauth']; + }; + }); - function resolve(data) { - cleanup(); - _resolve(data); - } + let senddsqP = new Promise(function (resolve, reject) { + listenerMap['senddsq'] = async function (message) { + if (message.command !== 'senddsq') { + return; + } - function reject(err) { - cleanup(); - _reject(err); - } + let sendDsqMessage = Packer.packSendDsq({ + network: network, + send: true, + }); + await sleep(150); + // conn.write(sendDsqMessage); + wsc.send(sendDsqMessage); + console.log("[debug] sending 'senddsq':", sendDsqMessage); + + resolve(null); + listenerMap['senddsq'] = null; + delete listenerMap['senddsq']; + }; + }); - function onConnect() { - resolve(); - } + await mnauthP; + await senddsqP; + } - function onReadable() { - // checking an impossible condition, just in case - throw new Error('unexpected response before request'); + { + let dsqPromise = new Promise(readDsq); + // + // dsa / dssu + dsq + // + //for (let i = 0; i < minimumParticipants; i += 1) + let collateralTx; + { + void (await generateMinBalance()); + void (await generateDenominations()); + + void (await generateMinBalance()); + let collateralTxInfo = await getCollateralTx(); + // let keys = await getPrivateKeys(collateralTxInfo.inputs); + // let txInfoSigned = await dashTx.hashAndSignAll(collateralTxInfo, keys); + let txInfoSigned = await dashTx.hashAndSignAll(collateralTxInfo); + collateralTx = DashTx.utils.hexToBytes(txInfoSigned.transaction); } + let dsaMsg = await Packer.packAllow({ + network, + denomination, + collateralTx, + }); + await sleep(150); + // conn.write(dsaMsg); + wsc.send(dsaMsg); - errReject = reject; - conn.once('connect', onConnect); - //conn.on('readable', onReadable); - conn.on('data', onReadable); - }); - } + // let dsaBuf = Buffer.from(dsaMsg); + // console.log('[debug] dsa', dsaBuf.toString('hex')); + console.log('[debug] dsa', DashTx.utils.bytesToHex(dsaMsg)); - await waitForConnect(); - console.log('connected'); - - // - // version / verack - // - let versionMsg = Packer.version({ - network: network, // Packer.NETWORKS.regtest, - //protocol_version: Packer.PROTOCOL_VERSION, - //addr_recv_services: [Packer.IDENTIFIER_SERVICES.NETWORK], - addr_recv_ip: evonode.hostname, - addr_recv_port: evonode.port, - //addr_trans_services: [], - //addr_trans_ip: '127.0.01', - //addr_trans_port: null, - // addr_trans_ip: conn.localAddress, - // addr_trans_port: conn.localPort, - start_height: height, - //nonce: null, - user_agent: `DashJoin.js/${pkg.version}`, - // optional-ish - relay: false, - mnauth_challenge: null, - mn_connection: false, - }); - - // let versionBuffer = Buffer.from(versionMsg); - // console.log('version', versionBuffer.toString('hex')); - // console.log(Parser.parseHeader(versionBuffer.slice(0, 24))); - // console.log(Parser.parseVerack(versionBuffer.slice(24))); - - { - let versionP = new Promise(function (resolve, reject) { - listenerMap['version'] = async function (message) { - let versionResp = await Parser.parseVersion(message.payload); - console.log('DEBUG version', versionResp.version); - resolve(null); - listenerMap['version'] = null; - delete listenerMap['version']; - }; - }); - await sleep(150); - conn.write(versionMsg); - - await versionP; - } + let dsq = await dsqPromise; + for (; !dsq.ready; ) { + dsq = await new Promise(readDsq); + if (dsq.ready) { + break; + } + } + } - { - let verackP = await new Promise(function (resolve, reject) { - listenerMap['verack'] = async function (message) { - if (message.command !== 'verack') { + function readDsq(resolve, reject) { + listenerMap['dsq'] = async function (message) { + if (message.command !== 'dsq') { return; } - console.log('DEBUG verack', message); - resolve(); - listenerMap['verack'] = null; - delete listenerMap['verack']; - }; - }); - let verackBytes = Packer.packMessage({ - network, - command: 'verack', - payload: null, - }); - await sleep(150); - conn.write(verackBytes); + let dsq = await Parser.parseDsq(message.payload); + console.log('DEBUG dsq', dsq); - await verackP; - } + resolve(dsq); + listenerMap['dsq'] = null; + delete listenerMap['dsq']; + }; + } - { - let mnauthP = new Promise(function (resolve, reject) { - listenerMap['mnauth'] = async function (message) { - if (message.command !== 'mnauth') { + let dsfP = new Promise(function (resolve, reject) { + listenerMap['dsf'] = async function (message) { + if (message.command !== 'dsf') { return; } - resolve(); - listenerMap['mnauth'] = null; - delete listenerMap['mnauth']; + let dsf = Parser.parseDsf(message.payload); + resolve(dsf); + listenerMap['dsf'] = null; + delete listenerMap['dsf']; }; }); - let senddsqP = new Promise(function (resolve, reject) { - listenerMap['senddsq'] = async function (message) { - if (message.command !== 'senddsq') { + let dscP = new Promise(function (resolve, reject) { + listenerMap['dsc'] = async function (message) { + if (message.command !== 'dsc') { return; } - let sendDsqMessage = Packer.packSendDsq({ - network: network, - send: true, - }); - await sleep(150); - conn.write(sendDsqMessage); - console.log("[debug] sending 'senddsq':", sendDsqMessage); - + console.log('[debug] DSC Status:', message.payload.slice(4)); + // let dsc = Parser.parseDsc(message.payload); + // resolve(dsc); resolve(); - listenerMap['senddsq'] = null; - delete listenerMap['senddsq']; + listenerMap['dsc'] = null; + delete listenerMap['dsc']; }; }); - await mnauthP; - await senddsqP; - } - - { - let dsqPromise = new Promise(readDsq); - // - // dsa / dssu + dsq - // - //for (let i = 0; i < minimumParticipants; i += 1) - let collateralTx; + let inputs = []; + let outputs = []; { - void (await generateMinBalance()); - void (await generateDenominations()); - - void (await generateMinBalance()); - let collateralTxInfo = await getCollateralTx(); - let txInfoSigned = await dashTx.hashAndSignAll(collateralTxInfo); - collateralTx = DashTx.utils.hexToBytes(txInfoSigned.transaction); - } - let dsaMsg = await Packer.packAllow({ - network, - denomination, - collateralTx, - }); - await sleep(150); - conn.write(dsaMsg); - - let dsaBuf = Buffer.from(dsaMsg); - console.log('[debug] dsa', dsaBuf.toString('hex')); - - let dsq = await dsqPromise; - for (; !dsq.ready; ) { - dsq = await new Promise(readDsq); - if (dsq.ready) { - break; - } - } - } + // build utxo inputs from addrs + for (let addr of addresses) { + if (inputs.length >= COINJOIN_ENTRY_MAX_SIZE) { + break; + } - function readDsq(resolve, reject) { - listenerMap['dsq'] = async function (message) { - if (message.command !== 'dsq') { - return; - } + let data = keysMap[addr]; + // Note: we'd need to look at utxos (not total address balance) + // to be wholly accurate, but this is good enough for now + if (data.satoshis !== denomination) { + continue; + } + if (data.reserved) { + continue; + } - let dsq = await Parser.parseDsq(message.payload); - console.log('DEBUG dsq', dsq); + data.reserved = Date.now(); + let utxosRpc = await rpc.getAddressUtxos({ addresses: [data.address] }); + let utxos = utxosRpc.result; + for (let utxo of utxos) { + // utxo.sigHashType = 0x01; + utxo.address = data.address; + utxo.index = data.index; + // TODO fix in dashtx + utxo.txId = utxo.txId || utxo.txid; + utxo.txid = utxo.txId || utxo.txid; - resolve(dsq); - listenerMap['dsq'] = null; - delete listenerMap['dsq']; - }; - } + // must have pubKeyHash for script to sign + let pubKeyHashBytes = await DashKeys.addrToPkh(data.address, { + version: 'testnet', + }); + utxo.pubKeyHash = DashKeys.utils.bytesToHex(pubKeyHashBytes); - let dsfP = new Promise(function (resolve, reject) { - listenerMap['dsf'] = async function (message) { - if (message.command !== 'dsf') { - return; + console.log('[debug] input utxo', utxo); + inputs.push(utxo); + } } - let dsf = Parser.parseDsf(message.payload); - resolve(dsf); - listenerMap['dsf'] = null; - delete listenerMap['dsf']; - }; - }); + // build output addrs + for (let addr of addresses) { + if (outputs.length >= inputs.length) { + break; + } - let dscP = new Promise(function (resolve, reject) { - listenerMap['dsc'] = async function (message) { - if (message.command !== 'dsc') { - return; - } + let data = keysMap[addr]; - console.log('[debug] DSC Status:', message.payload.slice(4)); - // let dsc = Parser.parseDsc(message.payload); - // resolve(dsc); - resolve(); - listenerMap['dsc'] = null; - delete listenerMap['dsc']; - }; - }); - - let inputs = []; - let outputs = []; - { - // build utxo inputs from addrs - for (let addr of addresses) { - if (inputs.length >= COINJOIN_ENTRY_MAX_SIZE) { - break; - } - - let data = keysMap[addr]; - // Note: we'd need to look at utxos (not total address balance) - // to be wholly accurate, but this is good enough for now - if (data.satoshis !== denomination) { - continue; - } - if (data.reserved) { - continue; - } + let isFree = !data.used && !data.reserved; + if (!isFree) { + continue; + } - data.reserved = Date.now(); - let utxosRpc = await rpc.getAddressUtxos({ addresses: [data.address] }); - let utxos = utxosRpc.result; - for (let utxo of utxos) { - // utxo.sigHashType = 0x01; - utxo.address = data.address; - utxo.index = data.index; - // TODO fix in dashtx - utxo.txId = utxo.txId || utxo.txid; - utxo.txid = utxo.txId || utxo.txid; - - // must have pubKeyHash for script to sign + data.reserved = Date.now(); let pubKeyHashBytes = await DashKeys.addrToPkh(data.address, { version: 'testnet', }); - utxo.pubKeyHash = DashKeys.utils.bytesToHex(pubKeyHashBytes); + let pubKeyHash = DashKeys.utils.bytesToHex(pubKeyHashBytes); - console.log('[debug] input utxo', utxo); - inputs.push(utxo); + let output = { + pubKeyHash: pubKeyHash, + satoshis: denomination, + }; + + outputs.push(output); } + // inputs.sort(DashTx.sortInputs); + // outputs.sort(DashTx.sortOutputs); } - // build output addrs - for (let addr of addresses) { - if (outputs.length >= inputs.length) { - break; - } + console.log('sanity check 1: inputs', inputs); + let dsf; + { + void (await generateMinBalance()); + let collateralTxInfo = await getCollateralTx(); + // let keys = await getPrivateKeys(collateralTxInfo.inputs); + // let txInfoSigned = await dashTx.hashAndSignAll(collateralTxInfo, keys); + let txInfoSigned = await dashTx.hashAndSignAll(collateralTxInfo); + let collateralTx = DashTx.utils.hexToBytes(txInfoSigned.transaction); + + let dsiMessageBytes = Packer.packDsi({ + network, + inputs, + collateralTx, + outputs, + }); + await sleep(150); + // conn.write(dsiMessageBytes); + wsc.send(dsiMessageBytes); + dsf = await dsfP; + } + + console.log('sanity check 2: inputs', inputs); + { + let txRequest = dsf.transaction_unsigned; + console.log('[debug] tx request (unsigned)', txRequest); + let sigHashType = DashTx.SIGHASH_ALL | DashTx.SIGHASH_ANYONECANPAY; //jshint ignore:line + // let sigHashType = DashTx.SIGHASH_ALL; + let txInfo = DashTx.parseUnknown(txRequest); + console.log('[debug] DashTx.parseRequest(dsfTxRequest)'); + console.log(txInfo); + for (let input of inputs) { + console.log('sanity check 3: input', input); + let privKeyBytes = await keyUtils.getPrivateKey(input); + let pubKeyBytes = await keyUtils.toPublicKey(privKeyBytes); + let publicKey = DashTx.utils.bytesToHex(pubKeyBytes); + + { + // sanity check + let addr = await DashKeys.pubkeyToAddr(pubKeyBytes, { + version: 'testnet', + }); + if (addr !== input.address) { + console.error(`privKeyBytes => 'addr': ${addr}`); + console.error(`'input.address': ${input.address}`); + throw new Error('sanity fail: address mismatch'); + } + } - let data = keysMap[addr]; + // let sighashInputs = []; + for (let sighashInput of txInfo.inputs) { + if (sighashInput.txid !== input.txid) { + continue; + } + if (sighashInput.outputIndex !== input.outputIndex) { + continue; + } - let isFree = !data.used && !data.reserved; - if (!isFree) { - continue; + sighashInput.index = input.index; + sighashInput.address = input.address; + sighashInput.satoshis = input.satoshis; + sighashInput.pubKeyHash = input.pubKeyHash; + // sighashInput.script = input.script; + sighashInput.publicKey = publicKey; + sighashInput.sigHashType = sigHashType; + console.log('[debug] YES, CAN HAZ INPUTS!!!', sighashInput); + // sighashInputs.push({ + // txId: input.txId || input.txid, + // txid: input.txid || input.txId, + // outputIndex: input.outputIndex, + // pubKeyHash: input.pubKeyHash, + // sigHashType: input.sigHashType, + // }); + break; + } + // if (sighashInputs.length !== 1) { + // let msg = + // 'expected exactly one selected input to match one tx request input'; + // throw new Error(msg); + // } + // let anyonecanpayIndex = 0; + // let txHashable = DashTx.createHashable( + // { + // version: txInfo.version, + // inputs: sighashInputs, // exactly 1 + // outputs: txInfo.outputs, + // locktime: txInfo.locktime, + // }, + // anyonecanpayIndex, + // ); + // console.log('[debug] txHashable (pre-sighashbyte)', txHashable); + + // let signableHashBytes = await DashTx.hashPartial(txHashable, sigHashType); + // let signableHashHex = DashTx.utils.bytesToHex(signableHashBytes); + // console.log('[debug] signableHashHex', signableHashHex); + // let sigBuf = await keyUtils.sign(privKeyBytes, signableHashBytes); + // let signature = DashTx.utils.bytesToHex(sigBuf); + // Object.assign(input, { publicKey, sigHashType, signature }); } - data.reserved = Date.now(); - let pubKeyHashBytes = await DashKeys.addrToPkh(data.address, { - version: 'testnet', + // for (let input of txInfo.inputs) { + // let inputs = Tx.selectSigHashInputs(txInfo, i, _sigHashType); + // let outputs = Tx.selectSigHashOutputs(txInfo, i, _sigHashType); + // let txForSig = Object.assign({}, txInfo, { inputs, outputs }); + // } + // let txSigned = await dashTx.hashAndSignAll(txForSig); + let txSigned = await dashTx.hashAndSignAll(txInfo); + console.log('[debug] txSigned', txSigned); + let signedInputs = []; + for (let input of txSigned.inputs) { + if (!input?.signature) { + continue; + } + signedInputs.push(input); + } + console.log('[debug] signed inputs', signedInputs); + + let dssMessageBytes = Packer.packDss({ + network: network, + inputs: signedInputs, }); - let pubKeyHash = DashKeys.utils.bytesToHex(pubKeyHashBytes); + console.log('[debug] dss =>', dssMessageBytes.length); + console.log(dssMessageBytes); + let dssHex = DashTx.utils.bytesToHex(dssMessageBytes); + console.log(dssHex); + await sleep(150); + // conn.write(dssMessageBytes); + wsc.send(dssMessageBytes); + await dscP; + } - let output = { - pubKeyHash: pubKeyHash, - satoshis: denomination, - }; + console.log('Sweet, sweet victory!'); + }; - outputs.push(output); + /** + * @param {Object} a + * @param {String} a.id + * @param {Object} b + * @param {String} b.id + */ + function byId(a, b) { + if (a.id > b.id) { + return 1; + } + if (a.id < b.id) { + return -1; } - // inputs.sort(DashTx.sortInputs); - // outputs.sort(DashTx.sortOutputs); + return 0; } - console.log('sanity check 1: inputs', inputs); - let dsf; - { - void (await generateMinBalance()); - let collateralTxInfo = await getCollateralTx(); - let txInfoSigned = await dashTx.hashAndSignAll(collateralTxInfo); - let collateralTx = DashTx.utils.hexToBytes(txInfoSigned.transaction); - - let dsiMessageBytes = Packer.packDsi({ - network, - inputs, - collateralTx, - outputs, - }); - await sleep(150); - conn.write(dsiMessageBytes); - dsf = await dsfP; - } + // http://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array + // function shuffle(arr) { + // let currentIndex = arr.length; - console.log('sanity check 2: inputs', inputs); - { - let txRequest = dsf.transaction_unsigned; - console.log('[debug] tx request (unsigned)', txRequest); - let sigHashType = DashTx.SIGHASH_ALL | DashTx.SIGHASH_ANYONECANPAY; //jshint ignore:line - // let sigHashType = DashTx.SIGHASH_ALL; - let txInfo = DashTx.parseUnknown(txRequest); - console.log('[debug] DashTx.parseRequest(dsfTxRequest)'); - console.log(txInfo); - for (let input of inputs) { - console.log('sanity check 3: input', input); - let privKeyBytes = await keyUtils.getPrivateKey(input); - let pubKeyBytes = await keyUtils.toPublicKey(privKeyBytes); - let publicKey = DashTx.utils.bytesToHex(pubKeyBytes); + // // While there remain elements to shuffle... + // for (; currentIndex !== 0; ) { + // // Pick a remaining element... + // let randomIndexFloat = Math.random() * currentIndex; + // let randomIndex = Math.floor(randomIndexFloat); + // currentIndex -= 1; - { - // sanity check - let addr = await DashKeys.pubkeyToAddr(pubKeyBytes, { - version: 'testnet', - }); - if (addr !== input.address) { - console.error(`privKeyBytes => 'addr': ${addr}`); - console.error(`'input.address': ${input.address}`); - throw new Error('sanity fail: address mismatch'); - } - } + // // And swap it with the current element. + // let temporaryValue = arr[currentIndex]; + // arr[currentIndex] = arr[randomIndex]; + // arr[randomIndex] = temporaryValue; + // } - // let sighashInputs = []; - for (let sighashInput of txInfo.inputs) { - if (sighashInput.txid !== input.txid) { - continue; - } - if (sighashInput.outputIndex !== input.outputIndex) { - continue; - } + // return arr; + // } - sighashInput.index = input.index; - sighashInput.address = input.address; - sighashInput.satoshis = input.satoshis; - sighashInput.pubKeyHash = input.pubKeyHash; - // sighashInput.script = input.script; - sighashInput.publicKey = publicKey; - sighashInput.sigHashType = sigHashType; - console.log('[debug] YES, CAN HAZ INPUTS!!!', sighashInput); - // sighashInputs.push({ - // txId: input.txId || input.txid, - // txid: input.txid || input.txId, - // outputIndex: input.outputIndex, - // pubKeyHash: input.pubKeyHash, - // sigHashType: input.sigHashType, - // }); - break; + /** + * @param {Array} byteArrays + * @param {Number?} [len] + * @returns {Uint8Array} + */ + function concatBytes(byteArrays, len) { + if (!len) { + for (let bytes of byteArrays) { + len += bytes.length; } - // if (sighashInputs.length !== 1) { - // let msg = - // 'expected exactly one selected input to match one tx request input'; - // throw new Error(msg); - // } - // let anyonecanpayIndex = 0; - // let txHashable = DashTx.createHashable( - // { - // version: txInfo.version, - // inputs: sighashInputs, // exactly 1 - // outputs: txInfo.outputs, - // locktime: txInfo.locktime, - // }, - // anyonecanpayIndex, - // ); - // console.log('[debug] txHashable (pre-sighashbyte)', txHashable); - - // let signableHashBytes = await DashTx.hashPartial(txHashable, sigHashType); - // let signableHashHex = DashTx.utils.bytesToHex(signableHashBytes); - // console.log('[debug] signableHashHex', signableHashHex); - // let sigBuf = await keyUtils.sign(privKeyBytes, signableHashBytes); - // let signature = DashTx.utils.bytesToHex(sigBuf); - // Object.assign(input, { publicKey, sigHashType, signature }); } - // for (let input of txInfo.inputs) { - // let inputs = Tx.selectSigHashInputs(txInfo, i, _sigHashType); - // let outputs = Tx.selectSigHashOutputs(txInfo, i, _sigHashType); - // let txForSig = Object.assign({}, txInfo, { inputs, outputs }); - // } - // let txSigned = await dashTx.hashAndSignAll(txForSig); - let txSigned = await dashTx.hashAndSignAll(txInfo); - console.log('[debug] txSigned', txSigned); - let signedInputs = []; - for (let input of txSigned.inputs) { - if (!input?.signature) { - continue; - } - signedInputs.push(input); + let allBytes = new Uint8Array(len); + let offset = 0; + for (let bytes of byteArrays) { + allBytes.set(bytes, offset); + offset += bytes.length; } - console.log('[debug] signed inputs', signedInputs); - let dssMessageBytes = Packer.packDss({ - network: network, - inputs: signedInputs, - }); - console.log('[debug] dss =>', dssMessageBytes.length); - console.log(dssMessageBytes); - let dssHex = DashTx.utils.bytesToHex(dssMessageBytes); - console.log(dssHex); - await sleep(150); - conn.write(dssMessageBytes); - await dscP; + return allBytes; } - console.log('Sweet, sweet victory!'); -} - -/** - * @param {Object} a - * @param {String} a.id - * @param {Object} b - * @param {String} b.id - */ -function byId(a, b) { - if (a.id > b.id) { - return 1; - } - if (a.id < b.id) { - return -1; + function sleep(ms) { + return new Promise(function (resolve) { + setTimeout(resolve, ms); + }); } - return 0; -} -// http://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array -// function shuffle(arr) { -// let currentIndex = arr.length; - -// // While there remain elements to shuffle... -// for (; currentIndex !== 0; ) { -// // Pick a remaining element... -// let randomIndexFloat = Math.random() * currentIndex; -// let randomIndex = Math.floor(randomIndexFloat); -// currentIndex -= 1; - -// // And swap it with the current element. -// let temporaryValue = arr[currentIndex]; -// arr[currentIndex] = arr[randomIndex]; -// arr[randomIndex] = temporaryValue; -// } - -// return arr; -// } - -function sleep(ms) { - return new Promise(function (resolve) { - setTimeout(resolve, ms); - }); + // @ts-ignore + window.CJDemo = CJDemo; +})(('object' === typeof window && window) || {}, CJDemo); +if ('object' === typeof module) { + module.exports = CJDemo; } - -main() - .then(function () { - console.info('Done'); - process.exit(0); - }) - .catch(function (err) { - console.error('Fail:'); - console.error(err.stack || err); - process.exit(1); - }); diff --git a/example.env b/example.env new file mode 100644 index 0000000..d14443d --- /dev/null +++ b/example.env @@ -0,0 +1,18 @@ +# Get these values from ~/.dashmate/local_seed/core/dash.conf or ~/.dashmate/config.json +# regtest= +DASHD_RPC_USER='abcd1234' +DASHD_RPC_PASS='123456789012' +DASHD_RPC_PASSWORD='123456789012' +DASHD_RPC_PROTOCOL='http' +DASHD_RPC_HOST='127.0.0.1' +# mainnet=9998, testnet=19998, regtest= +DASHD_RPC_PORT='8080' +DASHD_RPC_TIMEOUT='10.0' +DASHD_TCP_WS_URL='ws://127.0.0.1:8080/tcp' + +# Generate this from +# npx -p dashphrase-cli -- dashphrase gen --bits 128 -o ./words.txt +# npx -p dashphrase-cli -- dashphrase seed ./words.txt "" -o ./seed.hex +DASH_WALLET_PHRASE='zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' +#DASH_WALLET_SALT='TREZOR' +DASH_WALLET_SEED='ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069' diff --git a/example.env.js b/example.env.js new file mode 100644 index 0000000..c8b881a --- /dev/null +++ b/example.env.js @@ -0,0 +1,28 @@ +var ENV; + +(function () { + 'use strict'; + + ENV = { + // regtest= + DASHD_RPC_USER: 'abcd1234', + DASHD_RPC_PASS: '123456789012', + DASHD_RPC_PASSWORD: '123456789012', + DASHD_RPC_PROTOCOL: 'http', + DASHD_RPC_HOST: 'localhost', + // mainnet=9998, testnet=19998, regtest= + DASHD_RPC_PORT: '8080', + DASHD_RPC_TIMEOUT: '10.0', + DASHD_TCP_WS_URL: 'ws://localhost:8080/tcp', + // Generate this from + // npx -p dashphrase-cli -- dashphrase gen --bits 128 -o ./words.txt + // npx -p dashphrase-cli -- dashphrase seed ./words.txt "" -o ./seed.hex + DASH_WALLET_PHRASE: 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong', + //DASH_WALLET_SALT: 'TREZOR', + DASH_WALLET_SEED: + 'ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069', + package: { + version: '1.0.0', + }, + }; +})(); diff --git a/node-env.js b/node-env.js new file mode 100644 index 0000000..62d13a3 --- /dev/null +++ b/node-env.js @@ -0,0 +1,12 @@ +'use strict'; + +let DotEnv = require('dotenv'); +if (DotEnv.config) { + void DotEnv.config({ path: '.env' }); + void DotEnv.config({ path: '.env.secret' }); +} + +Object.assign(module.exports, process.env); +Object.assign(module.exports, { + DASH_WALLET_SALT: process.argv[2] || '', +}); diff --git a/package-lock.json b/package-lock.json index 06cb0cd..07e4cfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,12 @@ "dashhd": "^3.3.3", "dashkeys": "^1.1.4", "dashphrase": "^1.4.0", - "dashrpc": "^19.0.1", + "dashrpc": "^20.0.0", "dashtx": "^0.18.1", "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@dashincubator/secp256k1": { @@ -39,9 +42,10 @@ "integrity": "sha512-o+LdiPkiYmg07kXBE+2bbcJzBmeTQVPn1GS2XlQeo8lene+KknAprSyiYi5XtqV/QVgNjvzOV7qBst2MijSPAA==" }, "node_modules/dashrpc": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/dashrpc/-/dashrpc-19.0.1.tgz", - "integrity": "sha512-1BLXnYZPHHRwvehIF6HqLLSfv2bTZlU97dq/8XJ2F0cBEk3ofi9/fbxYVmDwWtVjqtIJPfdXhSFx7fJu2hJNPA==" + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/dashrpc/-/dashrpc-20.0.0.tgz", + "integrity": "sha512-43IEnwLs6x33OqLQC0qSBoePWcazXWP95maJxQnByGgRklz2kXzZ1v9RH50573YQfbo7iOVW76QBolr8xEwHgw==", + "license": "MIT" }, "node_modules/dashtx": { "version": "0.18.1", @@ -85,9 +89,9 @@ "integrity": "sha512-o+LdiPkiYmg07kXBE+2bbcJzBmeTQVPn1GS2XlQeo8lene+KknAprSyiYi5XtqV/QVgNjvzOV7qBst2MijSPAA==" }, "dashrpc": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/dashrpc/-/dashrpc-19.0.1.tgz", - "integrity": "sha512-1BLXnYZPHHRwvehIF6HqLLSfv2bTZlU97dq/8XJ2F0cBEk3ofi9/fbxYVmDwWtVjqtIJPfdXhSFx7fJu2hJNPA==" + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/dashrpc/-/dashrpc-20.0.0.tgz", + "integrity": "sha512-43IEnwLs6x33OqLQC0qSBoePWcazXWP95maJxQnByGgRklz2kXzZ1v9RH50573YQfbo7iOVW76QBolr8xEwHgw==" }, "dashtx": { "version": "0.18.1", diff --git a/package.json b/package.json index 2ef8115..1d7a843 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "JavaScript reference implementation of CoinJoin", "main": "index.js", "files": [ - "*.js" + "*.js" ], "scripts": { "bump": "npm version -m \"chore(release): bump to v%s\"", @@ -38,8 +38,11 @@ "dashhd": "^3.3.3", "dashkeys": "^1.1.4", "dashphrase": "^1.4.0", - "dashrpc": "^19.0.1", + "dashrpc": "^20.0.0", "dashtx": "^0.18.1", "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=22.0.0" } } diff --git a/packer.js b/packer.js index 7a9693c..6b6b923 100644 --- a/packer.js +++ b/packer.js @@ -1,738 +1,865 @@ -'use strict'; - -let Packer = module.exports; - -let Crypto = require('node:crypto'); - -let CoinJoin = require('./coinjoin.js'); - -Packer.PROTOCOL_VERSION = 70227; - -Packer.FIELD_SIZES = { - VERSION: 4, - SERVICES: 8, - TIMESTAMP: 8, - ADDR_RECV_SERVICES: 8, - ADDR_RECV_IP: 16, - ADDR_RECV_PORT: 2, - ADDR_TRANS_SERVICES: 8, - ADDR_TRANS_IP: 16, - ADDR_TRANS_PORT: 2, - NONCE: 8, - USER_AGENT_BYTES: 1, // can be skipped - USER_AGENT_STRING: 0, - START_HEIGHT: 4, - // The following 2 fields are OPTIONAL - RELAY: 0, - RELAY_NONEMPTY: 1, - MNAUTH_CHALLENGE: 0, - MNAUTH_CHALLENGE_NONEMPTY: 32, - MN_CONNECTION: 0, - MN_CONNECTION_NONEMPTY: 1, -}; - -Packer.RELAY_PROTOCOL_VERSION_INTRODUCTION = 70001; -Packer.MNAUTH_PROTOCOL_VERSION_INTRODUCTION = 70214; - -let textEncoder = new TextEncoder(); - -let SIZES = { - MAGIC_BYTES: 4, - COMMAND_NAME: 12, - PAYLOAD_SIZE: 4, - CHECKSUM: 4, -}; -const TOTAL_HEADER_SIZE = - SIZES.MAGIC_BYTES + SIZES.COMMAND_NAME + SIZES.PAYLOAD_SIZE + SIZES.CHECKSUM; -Packer.HEADER_SIZE = TOTAL_HEADER_SIZE; - -Packer.PING_SIZE = Packer.FIELD_SIZES.NONCE; -Packer.DSQ_SIZE = 1; // bool - -const EMPTY_CHECKSUM = [0x5d, 0xf6, 0xe0, 0xe2]; - -/** - * @typedef {"mainnet"|"testnet"|"regtest"|"devnet"} NetworkName - */ - -Packer.NETWORKS = {}; -Packer.NETWORKS.mainnet = { - port: 9999, - magic: new Uint8Array([ - //0xBD6B0CBF, - 0xbf, 0x0c, 0x6b, 0xbd, - ]), - start: 0xbf0c6bbd, - nBits: 0x1e0ffff0, - minimumParticiparts: 3, -}; -Packer.NETWORKS.testnet = { - port: 19999, - magic: new Uint8Array([ - //0xFFCAE2CE, - 0xce, 0xe2, 0xca, 0xff, - ]), - start: 0xcee2caff, - nBits: 0x1e0ffff0, - minimumParticiparts: 2, -}; -Packer.NETWORKS.regtest = { - port: 19899, - magic: new Uint8Array([ - //0xDCB7C1FC, - 0xfc, 0xc1, 0xb7, 0xdc, - ]), - start: 0xfcc1b7dc, - nBits: 0x207fffff, - minimumParticiparts: 2, -}; -Packer.NETWORKS.devnet = { - port: 19799, - magic: new Uint8Array([ - //0xCEFFCAE2, - 0xe2, 0xca, 0xff, 0xce, - ]), - start: 0xe2caffce, - nBits: 0x207fffff, - minimumParticiparts: 2, -}; +//@ts-ignore +var CJPacker = ('object' === typeof module && exports) || {}; +(function (window, CJPacker) { + 'use strict'; + + let Crypto = window.crypto || require('node:crypto'); + let DashTx = window.DashTx || require('dashtx'); + + // TODO the spec seems to be more of an ID, though + // the implementation makes it look more like a mask... + let STANDARD_DENOMINATION_MASKS = { + // 0.00100001 + 100001: 0b00010000, + // 0.01000010 + 1000010: 0b00001000, + // 0.10000100 + 10000100: 0b00000100, + // 1.00001000 + 100001000: 0b00000010, + // 10.00010000 + 1000010000: 0b00000001, + }; -/** - * @typedef {0x01|0x02|0x04|0x400} ServiceBitmask - * @typedef {"NETWORK"|"GETUTXO "|"BLOOM"|"NETWORK_LIMITED"} ServiceName - */ + CJPacker.PROTOCOL_VERSION = 70227; + + CJPacker.FIELD_SIZES = { + VERSION: 4, + SERVICES: 8, + TIMESTAMP: 8, + ADDR_RECV_SERVICES: 8, + ADDR_RECV_IP: 16, + ADDR_RECV_PORT: 2, + ADDR_TRANS_SERVICES: 8, + ADDR_TRANS_IP: 16, + ADDR_TRANS_PORT: 2, + NONCE: 8, + USER_AGENT_BYTES: 1, // can be skipped + USER_AGENT_STRING: 0, + START_HEIGHT: 4, + // The following 2 fields are OPTIONAL + RELAY: 0, + RELAY_NONEMPTY: 1, + MNAUTH_CHALLENGE: 0, + MNAUTH_CHALLENGE_NONEMPTY: 32, + MN_CONNECTION: 0, + MN_CONNECTION_NONEMPTY: 1, + }; -/** @type {Object.} */ -let SERVICE_IDENTIFIERS = {}; + CJPacker.RELAY_PROTOCOL_VERSION_INTRODUCTION = 70001; + CJPacker.MNAUTH_PROTOCOL_VERSION_INTRODUCTION = 70214; -/** - * 0x00 is the default - not a full node, no guarantees - */ + let textEncoder = new TextEncoder(); -/** - * NODE_NETWORK: - * This is a full node and can be asked for full - * blocks. It should implement all protocol features - * available in its self-reported protocol version. - */ -SERVICE_IDENTIFIERS.NETWORK = 0x01; + let SIZES = { + MAGIC_BYTES: 4, + COMMAND_NAME: 12, + PAYLOAD_SIZE: 4, + CHECKSUM: 4, + }; + const TOTAL_HEADER_SIZE = + SIZES.MAGIC_BYTES + + SIZES.COMMAND_NAME + + SIZES.PAYLOAD_SIZE + + SIZES.CHECKSUM; + CJPacker.HEADER_SIZE = TOTAL_HEADER_SIZE; -/** - * NODE_GETUTXO: - * This node is capable of responding to the getutxo - * protocol request. Dash Core does not support - * this service. - */ -SERVICE_IDENTIFIERS.GETUTXO = 0x02; + CJPacker.PING_SIZE = CJPacker.FIELD_SIZES.NONCE; + CJPacker.DSQ_SIZE = 1; // bool -/** - * NODE_BLOOM: - * This node is capable and willing to handle bloom- - * filtered connections. Dash Core nodes used to support - * this by default, without advertising this bit, but - * no longer do as of protocol version 70201 - * (= NO_BLOOM_VERSION) - */ -SERVICE_IDENTIFIERS.BLOOM = 0x04; + const EMPTY_CHECKSUM = [0x5d, 0xf6, 0xe0, 0xe2]; -/** - * 0x08 is not supported by Dash - */ + /** + * @typedef {"mainnet"|"testnet"|"regtest"|"devnet"} NetworkName + */ -/** - * NODE_NETWORK_LIMITED: - * This is the same as NODE_NETWORK with the - * limitation of only serving the last 288 blocks. - * Not supported prior to Dash Core 0.16.0 - */ -SERVICE_IDENTIFIERS.NETWORK_LIMITED = 0x400; + CJPacker.NETWORKS = {}; + CJPacker.NETWORKS.mainnet = { + port: 9999, + magic: new Uint8Array([ + //0xBD6B0CBF, + 0xbf, 0x0c, 0x6b, 0xbd, + ]), + start: 0xbf0c6bbd, + nBits: 0x1e0ffff0, + minimumParticiparts: 3, + }; + CJPacker.NETWORKS.testnet = { + port: 19999, + magic: new Uint8Array([ + //0xFFCAE2CE, + 0xce, 0xe2, 0xca, 0xff, + ]), + start: 0xcee2caff, + nBits: 0x1e0ffff0, + minimumParticiparts: 2, + }; + CJPacker.NETWORKS.regtest = { + port: 19899, + magic: new Uint8Array([ + //0xDCB7C1FC, + 0xfc, 0xc1, 0xb7, 0xdc, + ]), + start: 0xfcc1b7dc, + nBits: 0x207fffff, + minimumParticiparts: 2, + }; + CJPacker.NETWORKS.devnet = { + port: 19799, + magic: new Uint8Array([ + //0xCEFFCAE2, + 0xe2, 0xca, 0xff, 0xce, + ]), + start: 0xe2caffce, + nBits: 0x207fffff, + minimumParticiparts: 2, + }; -/** - * @typedef VersionOpts - * @prop {NetworkName} network - "mainnet", "testnet", etc - * @prop {Uint32?} [protocol_version] - features (default: Packer.PROTOCOL_VERSION) - * @prop {Array?} [addr_recv_services] - default: NETWORK - * @prop {String} addr_recv_ip - ipv6 address (can be 'ipv4-mapped') of the server - * @prop {Uint16} addr_recv_port - 9999, 19999, etc (can be arbitrary on testnet) - * @prop {Array?} [addr_trans_services] - default: NONE - * @prop {String?} [addr_trans_ip]- null, or the external ipv6 or ipv4-mapped address - * @prop {Uint16} [addr_trans_port] - null, or the external port (ignored for tcp?) - * @prop {Uint32} start_height - start height of your best block - * @prop {Uint8Array?} [nonce] - 8 random bytes to identify this transmission - * @prop {String?} [user_agent] - ex: "DashJoin/1.0 request/1.0 node/20.0.0 macos/14.0" - * @prop {Boolean?} [relay] - request all network tx & inv messages to be relayed to you - * @prop {Uint8Array?} [mnauth_challenge] - 32 bytes for the masternode to sign as proof - */ + /** + * @typedef {0x01|0x02|0x04|0x400} ServiceBitmask + * @typedef {"NETWORK"|"GETUTXO "|"BLOOM"|"NETWORK_LIMITED"} ServiceName + */ -/** - * Constructs a version message, with fields in the correct byte order. - * @param {VersionOpts} opts - * - * See also: - * - https://dashcore.readme.io/docs/core-ref-p2p-network-control-messages#version - */ -/* jshint maxcomplexity: 9001 */ -/* jshint maxstatements:150 */ -/* (it's simply very complex, okay?) */ -Packer.version = function ({ - network, - protocol_version = Packer.PROTOCOL_VERSION, - // alias of addr_trans_services - //services, - addr_recv_services = [SERVICE_IDENTIFIERS.NETWORK], - addr_recv_ip, - addr_recv_port, - addr_trans_services = [], - addr_trans_ip = '127.0.0.1', - addr_trans_port = 65535, - start_height, - nonce = null, - user_agent = null, - relay = null, - mnauth_challenge = null, -}) { - const command = 'version'; - - let args = { - network, - protocol_version, - addr_recv_services, - addr_recv_ip, - addr_recv_port, - addr_trans_services, - addr_trans_ip, - addr_trans_port, - start_height, - nonce, - user_agent, - relay, - mnauth_challenge, - }; - let SIZES = Object.assign({}, Packer.FIELD_SIZES); + /** @type {Object.} */ + let SERVICE_IDENTIFIERS = {}; - if (!Packer.NETWORKS[args.network]) { - throw new Error(`"network" '${args.network}' is invalid.`); - } - if (!Array.isArray(args.addr_recv_services)) { - throw new Error('"addr_recv_services" must be an array'); - } - if ( - //@ts-ignore - protocol_version has a default value - args.protocol_version < Packer.RELAY_PROTOCOL_VERSION_INTRODUCTION && - args.relay !== null - ) { - throw new Error( - `"relay" field is not supported in protocol versions prior to ${Packer.RELAY_PROTOCOL_VERSION_INTRODUCTION}`, - ); - } - if ( - //@ts-ignore - protocol_version has a default value - args.protocol_version < Packer.MNAUTH_PROTOCOL_VERSION_INTRODUCTION && - args.mnauth_challenge !== null - ) { - throw new Error( - '"mnauth_challenge" field is not supported in protocol versions prior to MNAUTH_CHALLENGE_OFFSET', - ); - } - if (args.mnauth_challenge !== null) { - if (!(args.mnauth_challenge instanceof Uint8Array)) { - throw new Error('"mnauth_challenge" field must be a Uint8Array'); - } - if ( - args.mnauth_challenge.length !== Packer.SIZES.MNAUTH_CHALLENGE_NONEMPTY - ) { - throw new Error( - `"mnauth_challenge" field must be ${Packer.SIZES.MNAUTH_CHALLENGE_NONEMPTY} bytes long`, - ); - } - } - SIZES.USER_AGENT_STRING = args.user_agent?.length || 0; - if (args.relay !== null) { - SIZES.RELAY = Packer.FIELD_SIZES.RELAY_NONEMPTY; - } - // if (args.mnauth_challenge !== null) { - SIZES.MNAUTH_CHALLENGE = Packer.FIELD_SIZES.MNAUTH_CHALLENGE_NONEMPTY; - // } - SIZES.MN_CONNECTION = Packer.FIELD_SIZES.MN_CONNECTION_NONEMPTY; - - let TOTAL_SIZE = - SIZES.VERSION + - SIZES.SERVICES + - SIZES.TIMESTAMP + - SIZES.ADDR_RECV_SERVICES + - SIZES.ADDR_RECV_IP + - SIZES.ADDR_RECV_PORT + - SIZES.ADDR_TRANS_SERVICES + - SIZES.ADDR_TRANS_IP + - SIZES.ADDR_TRANS_PORT + - SIZES.NONCE + - SIZES.USER_AGENT_BYTES + - SIZES.USER_AGENT_STRING + - SIZES.START_HEIGHT + - SIZES.RELAY + - SIZES.MNAUTH_CHALLENGE + - SIZES.MN_CONNECTION; - let payload = new Uint8Array(TOTAL_SIZE); - // Protocol version - - //@ts-ignore - protocol_version has a default value - let versionBytes = uint32ToBytesLE(args.protocol_version); - payload.set(versionBytes, 0); + /** + * 0x00 is the default - not a full node, no guarantees + */ /** - * Set services to NODE_NETWORK (1) + NODE_BLOOM (4) + * NODE_NETWORK: + * This is a full node and can be asked for full + * blocks. It should implement all protocol features + * available in its self-reported protocol version. */ - const SERVICES_OFFSET = SIZES.VERSION; - let senderServicesBytes; - { - let senderServicesMask = 0n; - //@ts-ignore - addr_trans_services has a default value of [] - for (const serviceBit of addr_trans_services) { - senderServicesMask += BigInt(serviceBit); - } - let senderServices64 = new BigInt64Array([senderServicesMask]); // jshint ignore:line - senderServicesBytes = new Uint8Array(senderServices64.buffer); - payload.set(senderServicesBytes, SERVICES_OFFSET); - } + SERVICE_IDENTIFIERS.NETWORK = 0x01; - const TIMESTAMP_OFFSET = SERVICES_OFFSET + SIZES.SERVICES; - { - let tsBytes = uint32ToBytesLE(Date.now()); - payload.set(tsBytes, TIMESTAMP_OFFSET); - } + /** + * NODE_GETUTXO: + * This node is capable of responding to the getutxo + * protocol request. Dash Core does not support + * this service. + */ + SERVICE_IDENTIFIERS.GETUTXO = 0x02; - let ADDR_RECV_SERVICES_OFFSET = TIMESTAMP_OFFSET + SIZES.TIMESTAMP; - { - let serverServicesMask = 0n; - //@ts-ignore - addr_recv_services has a default value - for (const serviceBit of addr_recv_services) { - serverServicesMask += BigInt(serviceBit); - } - let serverServices64 = new BigInt64Array([serverServicesMask]); // jshint ignore:line - let serverServicesBytes = new Uint8Array(serverServices64.buffer); - payload.set(serverServicesBytes, ADDR_RECV_SERVICES_OFFSET); - } + /** + * NODE_BLOOM: + * This node is capable and willing to handle bloom- + * filtered connections. Dash Core nodes used to support + * this by default, without advertising this bit, but + * no longer do as of protocol version 70201 + * (= NO_BLOOM_VERSION) + */ + SERVICE_IDENTIFIERS.BLOOM = 0x04; /** - * "ADDR_RECV" means the host that we're sending this traffic to. - * So, in other words, it's the master node + * 0x08 is not supported by Dash */ - let ADDR_RECV_IP_OFFSET = - ADDR_RECV_SERVICES_OFFSET + SIZES.ADDR_RECV_SERVICES; - { - let ipBytesBE = ipv4ToBytesBE(args.addr_recv_ip); - payload.set([0xff, 0xff], ADDR_RECV_IP_OFFSET + 10); - payload.set(ipBytesBE, ADDR_RECV_IP_OFFSET + 12); - } /** - * Copy address recv port + * NODE_NETWORK_LIMITED: + * This is the same as NODE_NETWORK with the + * limitation of only serving the last 288 blocks. + * Not supported prior to Dash Core 0.16.0 */ - let ADDR_RECV_PORT_OFFSET = ADDR_RECV_IP_OFFSET + SIZES.ADDR_RECV_IP; - { - let portBytes16 = Uint16Array.from([args.addr_recv_port]); - let portBytes = new Uint8Array(portBytes16.buffer); - portBytes.reverse(); - payload.set(portBytes, ADDR_RECV_PORT_OFFSET); - } + SERVICE_IDENTIFIERS.NETWORK_LIMITED = 0x400; /** - * Copy address transmitted services + * @typedef VersionOpts + * @prop {NetworkName} network - "mainnet", "testnet", etc + * @prop {Uint32?} [protocol_version] - features (default: CJPacker.PROTOCOL_VERSION) + * @prop {Array?} [addr_recv_services] - default: NETWORK + * @prop {String} addr_recv_ip - ipv6 address (can be 'ipv4-mapped') of the server + * @prop {Uint16} addr_recv_port - 9999, 19999, etc (can be arbitrary on testnet) + * @prop {Array?} [addr_trans_services] - default: NONE + * @prop {String?} [addr_trans_ip]- null, or the external ipv6 or ipv4-mapped address + * @prop {Uint16} [addr_trans_port] - null, or the external port (ignored for tcp?) + * @prop {Uint32} start_height - start height of your best block + * @prop {Uint8Array?} [nonce] - 8 random bytes to identify this transmission + * @prop {String?} [user_agent] - ex: "DashJoin/1.0 request/1.0 node/20.0.0 macos/14.0" + * @prop {Boolean?} [relay] - request all network tx & inv messages to be relayed to you + * @prop {Uint8Array?} [mnauth_challenge] - 32 bytes for the masternode to sign as proof */ - let ADDR_TRANS_SERVICES_OFFSET = ADDR_RECV_PORT_OFFSET + SIZES.ADDR_RECV_PORT; - payload.set(senderServicesBytes, ADDR_TRANS_SERVICES_OFFSET); /** - * We add the extra 10, so that we can encode an ipv4-mapped ipv6 address + * Constructs a version message, with fields in the correct byte order. + * @param {VersionOpts} opts + * + * See also: + * - https://dashcore.readme.io/docs/core-ref-p2p-network-control-messages#version */ - let ADDR_TRANS_IP_OFFSET = - ADDR_TRANS_SERVICES_OFFSET + SIZES.ADDR_TRANS_SERVICES; - { - //@ts-ignore - addr_trans_ip has a default value - if (is_ipv6_mapped_ipv4(args.addr_trans_ip)) { - //@ts-ignore - addr_trans_ip has a default value - let ipv6Parts = args.addr_trans_ip.split(':'); - let ipv4Str = ipv6Parts.at(-1); - //@ts-ignore - guaranteed to be defined, actually - let ipBytesBE = ipv4ToBytesBE(ipv4Str); - payload.set(ipBytesBE, ADDR_TRANS_IP_OFFSET + 12); - payload.set([0xff, 0xff], ADDR_TRANS_IP_OFFSET + 10); // we add the 10 so that we can fill the latter 6 bytes - } else { - /** TODO: ipv4-only & ipv6-only */ - //@ts-ignore - addr_trans_ip has a default value - let ipBytesBE = ipv4ToBytesBE(args.addr_trans_ip); - payload.set(ipBytesBE, ADDR_TRANS_IP_OFFSET + 12); - payload.set([0xff, 0xff], ADDR_TRANS_IP_OFFSET + 10); // we add the 10 so that we can fill the latter 6 bytes + /* jshint maxcomplexity: 9001 */ + /* jshint maxstatements:150 */ + /* (it's simply very complex, okay?) */ + CJPacker.version = function ({ + network, + protocol_version = CJPacker.PROTOCOL_VERSION, + // alias of addr_trans_services + //services, + addr_recv_services = [SERVICE_IDENTIFIERS.NETWORK], + addr_recv_ip, + addr_recv_port, + addr_trans_services = [], + addr_trans_ip = '127.0.0.1', + addr_trans_port = 65535, + start_height, + nonce = null, + user_agent = null, + relay = null, + mnauth_challenge = null, + }) { + const command = 'version'; + + let args = { + network, + protocol_version, + addr_recv_services, + addr_recv_ip, + addr_recv_port, + addr_trans_services, + addr_trans_ip, + addr_trans_port, + start_height, + nonce, + user_agent, + relay, + mnauth_challenge, + }; + let SIZES = Object.assign({}, CJPacker.FIELD_SIZES); + + if (!CJPacker.NETWORKS[args.network]) { + throw new Error(`"network" '${args.network}' is invalid.`); } - } + if (!Array.isArray(args.addr_recv_services)) { + throw new Error('"addr_recv_services" must be an array'); + } + if ( + //@ts-ignore - protocol_version has a default value + args.protocol_version < CJPacker.RELAY_PROTOCOL_VERSION_INTRODUCTION && + args.relay !== null + ) { + throw new Error( + `"relay" field is not supported in protocol versions prior to ${CJPacker.RELAY_PROTOCOL_VERSION_INTRODUCTION}`, + ); + } + if ( + //@ts-ignore - protocol_version has a default value + args.protocol_version < CJPacker.MNAUTH_PROTOCOL_VERSION_INTRODUCTION && + args.mnauth_challenge !== null + ) { + throw new Error( + '"mnauth_challenge" field is not supported in protocol versions prior to MNAUTH_CHALLENGE_OFFSET', + ); + } + if (args.mnauth_challenge !== null) { + if (!(args.mnauth_challenge instanceof Uint8Array)) { + throw new Error('"mnauth_challenge" field must be a Uint8Array'); + } + if ( + args.mnauth_challenge.length !== + CJPacker.SIZES.MNAUTH_CHALLENGE_NONEMPTY + ) { + throw new Error( + `"mnauth_challenge" field must be ${CJPacker.SIZES.MNAUTH_CHALLENGE_NONEMPTY} bytes long`, + ); + } + } + SIZES.USER_AGENT_STRING = args.user_agent?.length || 0; + if (args.relay !== null) { + SIZES.RELAY = CJPacker.FIELD_SIZES.RELAY_NONEMPTY; + } + // if (args.mnauth_challenge !== null) { + SIZES.MNAUTH_CHALLENGE = CJPacker.FIELD_SIZES.MNAUTH_CHALLENGE_NONEMPTY; + // } + SIZES.MN_CONNECTION = CJPacker.FIELD_SIZES.MN_CONNECTION_NONEMPTY; + + let TOTAL_SIZE = + SIZES.VERSION + + SIZES.SERVICES + + SIZES.TIMESTAMP + + SIZES.ADDR_RECV_SERVICES + + SIZES.ADDR_RECV_IP + + SIZES.ADDR_RECV_PORT + + SIZES.ADDR_TRANS_SERVICES + + SIZES.ADDR_TRANS_IP + + SIZES.ADDR_TRANS_PORT + + SIZES.NONCE + + SIZES.USER_AGENT_BYTES + + SIZES.USER_AGENT_STRING + + SIZES.START_HEIGHT + + SIZES.RELAY + + SIZES.MNAUTH_CHALLENGE + + SIZES.MN_CONNECTION; + let payload = new Uint8Array(TOTAL_SIZE); + // Protocol version - let ADDR_TRANS_PORT_OFFSET = ADDR_TRANS_IP_OFFSET + SIZES.ADDR_TRANS_IP; - { - let portBytes16 = Uint16Array.from([args.addr_trans_port]); - let portBytes = new Uint8Array(portBytes16.buffer); - portBytes.reverse(); - payload.set(portBytes, ADDR_TRANS_PORT_OFFSET); - } + //@ts-ignore - protocol_version has a default value + let versionBytes = uint32ToBytesLE(args.protocol_version); + payload.set(versionBytes, 0); + + /** + * Set services to NODE_NETWORK (1) + NODE_BLOOM (4) + */ + const SERVICES_OFFSET = SIZES.VERSION; + let senderServicesBytes; + { + let senderServicesMask = 0n; + //@ts-ignore - addr_trans_services has a default value of [] + for (const serviceBit of addr_trans_services) { + senderServicesMask += BigInt(serviceBit); + } + let senderServices64 = new BigInt64Array([senderServicesMask]); // jshint ignore:line + senderServicesBytes = new Uint8Array(senderServices64.buffer); + payload.set(senderServicesBytes, SERVICES_OFFSET); + } - // TODO we should set this to prevent duplicate broadcast - // this can be left zero - let NONCE_OFFSET = ADDR_TRANS_PORT_OFFSET + SIZES.ADDR_TRANS_PORT; - if (!args.nonce) { - args.nonce = new Uint8Array(SIZES.NONCE); - Crypto.getRandomValues(args.nonce); - } - payload.set(args.nonce, NONCE_OFFSET); - - let USER_AGENT_BYTES_OFFSET = NONCE_OFFSET + SIZES.NONCE; - if (null !== args.user_agent && typeof args.user_agent === 'string') { - let userAgentSize = args.user_agent.length; - payload.set([userAgentSize], USER_AGENT_BYTES_OFFSET); - let uaBytes = textEncoder.encode(args.user_agent); - payload.set(uaBytes, USER_AGENT_BYTES_OFFSET + 1); - } else { - payload.set([0x0], USER_AGENT_BYTES_OFFSET); - } + const TIMESTAMP_OFFSET = SERVICES_OFFSET + SIZES.SERVICES; + { + let tsBytes = uint32ToBytesLE(Date.now()); + payload.set(tsBytes, TIMESTAMP_OFFSET); + } - let START_HEIGHT_OFFSET = - USER_AGENT_BYTES_OFFSET + SIZES.USER_AGENT_BYTES + SIZES.USER_AGENT_STRING; - { - let heightBytes = uint32ToBytesLE(args.start_height); - payload.set(heightBytes, START_HEIGHT_OFFSET); - } + let ADDR_RECV_SERVICES_OFFSET = TIMESTAMP_OFFSET + SIZES.TIMESTAMP; + { + let serverServicesMask = 0n; + //@ts-ignore - addr_recv_services has a default value + for (const serviceBit of addr_recv_services) { + serverServicesMask += BigInt(serviceBit); + } + let serverServices64 = new BigInt64Array([serverServicesMask]); // jshint ignore:line + let serverServicesBytes = new Uint8Array(serverServices64.buffer); + payload.set(serverServicesBytes, ADDR_RECV_SERVICES_OFFSET); + } - let RELAY_OFFSET = START_HEIGHT_OFFSET + SIZES.START_HEIGHT; - if (args.relay !== null) { - let bytes = [0x00]; - if (args.relay) { - bytes[0] = 0x01; + /** + * "ADDR_RECV" means the host that we're sending this traffic to. + * So, in other words, it's the master node + */ + let ADDR_RECV_IP_OFFSET = + ADDR_RECV_SERVICES_OFFSET + SIZES.ADDR_RECV_SERVICES; + { + let ipBytesBE = ipv4ToBytesBE(args.addr_recv_ip); + payload.set([0xff, 0xff], ADDR_RECV_IP_OFFSET + 10); + payload.set(ipBytesBE, ADDR_RECV_IP_OFFSET + 12); } - payload.set(bytes, RELAY_OFFSET); - } - let MNAUTH_CHALLENGE_OFFSET = RELAY_OFFSET + SIZES.RELAY; - if (!args.mnauth_challenge) { - let rnd = new Uint8Array(32); - Crypto.getRandomValues(rnd); - args.mnauth_challenge = rnd; - } - payload.set(args.mnauth_challenge, MNAUTH_CHALLENGE_OFFSET); + /** + * Copy address recv port + */ + let ADDR_RECV_PORT_OFFSET = ADDR_RECV_IP_OFFSET + SIZES.ADDR_RECV_IP; + { + let portBytes16 = Uint16Array.from([args.addr_recv_port]); + let portBytes = new Uint8Array(portBytes16.buffer); + portBytes.reverse(); + payload.set(portBytes, ADDR_RECV_PORT_OFFSET); + } - // let MNAUTH_CONNECTION_OFFSET = MNAUTH_CHALLENGE_OFFSET + SIZES.MN_CONNECTION; - // if (args.mn_connection) { - // payload.set([0x01], MNAUTH_CONNECTION_OFFSET); - // } + /** + * Copy address transmitted services + */ + let ADDR_TRANS_SERVICES_OFFSET = + ADDR_RECV_PORT_OFFSET + SIZES.ADDR_RECV_PORT; + payload.set(senderServicesBytes, ADDR_TRANS_SERVICES_OFFSET); + + /** + * We add the extra 10, so that we can encode an ipv4-mapped ipv6 address + */ + let ADDR_TRANS_IP_OFFSET = + ADDR_TRANS_SERVICES_OFFSET + SIZES.ADDR_TRANS_SERVICES; + { + //@ts-ignore - addr_trans_ip has a default value + if (is_ipv6_mapped_ipv4(args.addr_trans_ip)) { + //@ts-ignore - addr_trans_ip has a default value + let ipv6Parts = args.addr_trans_ip.split(':'); + let ipv4Str = ipv6Parts.at(-1); + //@ts-ignore - guaranteed to be defined, actually + let ipBytesBE = ipv4ToBytesBE(ipv4Str); + payload.set(ipBytesBE, ADDR_TRANS_IP_OFFSET + 12); + payload.set([0xff, 0xff], ADDR_TRANS_IP_OFFSET + 10); // we add the 10 so that we can fill the latter 6 bytes + } else { + /** TODO: ipv4-only & ipv6-only */ + //@ts-ignore - addr_trans_ip has a default value + let ipBytesBE = ipv4ToBytesBE(args.addr_trans_ip); + payload.set(ipBytesBE, ADDR_TRANS_IP_OFFSET + 12); + payload.set([0xff, 0xff], ADDR_TRANS_IP_OFFSET + 10); // we add the 10 so that we can fill the latter 6 bytes + } + } - payload = Packer.packMessage({ network, command, payload }); - return payload; -}; + let ADDR_TRANS_PORT_OFFSET = ADDR_TRANS_IP_OFFSET + SIZES.ADDR_TRANS_IP; + { + let portBytes16 = Uint16Array.from([args.addr_trans_port]); + let portBytes = new Uint8Array(portBytes16.buffer); + portBytes.reverse(); + payload.set(portBytes, ADDR_TRANS_PORT_OFFSET); + } -/** - * In this case the only bytes are the nonce - * Use a .subarray(offset) to define an offset. - * (a manual offset will not work consistently, and .byteOffset is context-sensitive) - * @param {Object} opts - * @param {NetworkName} opts.network - "mainnet", "testnet", etc - * @param {Uint8Array?} [opts.message] - * @param {Uint8Array?} [opts.nonce] - */ -Packer.packPing = function ({ network, message = null, nonce = null }) { - const command = 'ping'; + // TODO we should set this to prevent duplicate broadcast + // this can be left zero + let NONCE_OFFSET = ADDR_TRANS_PORT_OFFSET + SIZES.ADDR_TRANS_PORT; + if (!args.nonce) { + args.nonce = new Uint8Array(SIZES.NONCE); + Crypto.getRandomValues(args.nonce); + } + payload.set(args.nonce, NONCE_OFFSET); + + let USER_AGENT_BYTES_OFFSET = NONCE_OFFSET + SIZES.NONCE; + if (null !== args.user_agent && typeof args.user_agent === 'string') { + let userAgentSize = args.user_agent.length; + payload.set([userAgentSize], USER_AGENT_BYTES_OFFSET); + let uaBytes = textEncoder.encode(args.user_agent); + payload.set(uaBytes, USER_AGENT_BYTES_OFFSET + 1); + } else { + payload.set([0x0], USER_AGENT_BYTES_OFFSET); + } - if (!message) { - let pingSize = Packer.HEADER_SIZE + Packer.PING_SIZE; - message = new Uint8Array(pingSize); - } - let payload = message.subarray(Packer.HEADER_SIZE); + let START_HEIGHT_OFFSET = + USER_AGENT_BYTES_OFFSET + + SIZES.USER_AGENT_BYTES + + SIZES.USER_AGENT_STRING; + { + let heightBytes = uint32ToBytesLE(args.start_height); + payload.set(heightBytes, START_HEIGHT_OFFSET); + } - if (!nonce) { - nonce = payload; - Crypto.getRandomValues(nonce); - } else { - payload.set(nonce, 0); - } + let RELAY_OFFSET = START_HEIGHT_OFFSET + SIZES.START_HEIGHT; + if (args.relay !== null) { + let bytes = [0x00]; + if (args.relay) { + bytes[0] = 0x01; + } + payload.set(bytes, RELAY_OFFSET); + } - void Packer.packMessage({ network, command, bytes: message }); - return message; -}; + let MNAUTH_CHALLENGE_OFFSET = RELAY_OFFSET + SIZES.RELAY; + if (!args.mnauth_challenge) { + let rnd = new Uint8Array(32); + Crypto.getRandomValues(rnd); + args.mnauth_challenge = rnd; + } + payload.set(args.mnauth_challenge, MNAUTH_CHALLENGE_OFFSET); -/** - * In this case the only bytes are the nonce - * Use a .subarray(offset) to define an offset. - * (a manual offset will not work consistently, and .byteOffset is context-sensitive) - * @param {Object} opts - * @param {NetworkName} opts.network - "mainnet", "testnet", etc - * @param {Uint8Array?} [opts.message] - * @param {Uint8Array} opts.nonce - */ -Packer.packPong = function ({ network, message = null, nonce }) { - const command = 'pong'; + // let MNAUTH_CONNECTION_OFFSET = MNAUTH_CHALLENGE_OFFSET + SIZES.MN_CONNECTION; + // if (args.mn_connection) { + // payload.set([0x01], MNAUTH_CONNECTION_OFFSET); + // } - if (!message) { - let pongSize = Packer.HEADER_SIZE + Packer.PING_SIZE; - message = new Uint8Array(pongSize); - } - let payload = message.subarray(Packer.HEADER_SIZE); - payload.set(nonce, 0); + payload = CJPacker.packMessage({ network, command, payload }); + return payload; + }; - void Packer.packMessage({ network, command, bytes: message }); - return message; -}; + /** + * In this case the only bytes are the nonce + * Use a .subarray(offset) to define an offset. + * (a manual offset will not work consistently, and .byteOffset is context-sensitive) + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Uint8Array?} [opts.message] + * @param {Uint8Array?} [opts.nonce] + */ + CJPacker.packPing = function ({ network, message = null, nonce = null }) { + const command = 'ping'; -/** - * Turns on or off DSQ messages (necessary for CoinJoin, but off by default) - * @param {Object} opts - * @param {NetworkName} opts.network - "mainnet", "testnet", etc - * @param {Uint8Array?} [opts.message] - * @param {Boolean?} [opts.send] - */ -Packer.packSendDsq = function ({ network, message = null, send = true }) { - const command = 'senddsq'; + if (!message) { + let pingSize = CJPacker.HEADER_SIZE + CJPacker.PING_SIZE; + message = new Uint8Array(pingSize); + } + let payload = message.subarray(CJPacker.HEADER_SIZE); - if (!message) { - let dsqSize = Packer.HEADER_SIZE + Packer.DSQ_SIZE; - message = new Uint8Array(dsqSize); - } + if (!nonce) { + nonce = payload; + Crypto.getRandomValues(nonce); + } else { + payload.set(nonce, 0); + } - let sendByte = [0x01]; - if (!send) { - sendByte = [0x00]; - } - let payload = message.subarray(Packer.HEADER_SIZE); - payload.set(sendByte, 0); + void CJPacker.packMessage({ network, command, bytes: message }); + return message; + }; - void Packer.packMessage({ network, command, bytes: message }); + /** + * In this case the only bytes are the nonce + * Use a .subarray(offset) to define an offset. + * (a manual offset will not work consistently, and .byteOffset is context-sensitive) + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Uint8Array?} [opts.message] + * @param {Uint8Array} opts.nonce + */ + CJPacker.packPong = function ({ network, message = null, nonce }) { + const command = 'pong'; - return message; -}; + if (!message) { + let pongSize = CJPacker.HEADER_SIZE + CJPacker.PING_SIZE; + message = new Uint8Array(pongSize); + } + let payload = message.subarray(CJPacker.HEADER_SIZE); + payload.set(nonce, 0); -/** - * @param {Object} opts - * @param {NetworkName} opts.network - "mainnet", "testnet", etc - * @param {Uint32} opts.denomination - * @param {Uint8Array} opts.collateralTx - */ -Packer.packAllow = function ({ network, denomination, collateralTx }) { - const command = 'dsa'; - const DENOMINATION_SIZE = 4; - - //@ts-ignore - numbers can be used as map keys - let denomMask = CoinJoin.STANDARD_DENOMINATION_MASKS[denomination]; - if (!denomMask) { - throw new Error( - `contact your local Dash representative to vote for denominations of '${denomination}'`, - ); - } + void CJPacker.packMessage({ network, command, bytes: message }); + return message; + }; - let totalLength = DENOMINATION_SIZE + collateralTx.length; - let payload = new Uint8Array(totalLength); - let dv = new DataView(payload.buffer); - let offset = 0; + /** + * Turns on or off DSQ messages (necessary for CoinJoin, but off by default) + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Uint8Array?} [opts.message] + * @param {Boolean?} [opts.send] + */ + CJPacker.packSendDsq = function ({ network, message = null, send = true }) { + const command = 'senddsq'; - let DV_LITTLE_ENDIAN = true; - dv.setUint32(offset, denomMask, DV_LITTLE_ENDIAN); - offset += DENOMINATION_SIZE; + if (!message) { + let dsqSize = CJPacker.HEADER_SIZE + CJPacker.DSQ_SIZE; + message = new Uint8Array(dsqSize); + } - payload.set(collateralTx, offset); + let sendByte = [0x01]; + if (!send) { + sendByte = [0x00]; + } + let payload = message.subarray(CJPacker.HEADER_SIZE); + payload.set(sendByte, 0); - let message = Packer.packMessage({ network, command, payload }); - return message; -}; + void CJPacker.packMessage({ network, command, bytes: message }); -let DashTx = require('dashtx'); + return message; + }; -/** - * @param {Object} opts - * @param {NetworkName} opts.network - "mainnet", "testnet", etc - * @param {Array} opts.inputs - * @param {Array} opts.outputs - * @param {Uint8Array} opts.collateralTx - */ -Packer.packDsi = function ({ network, inputs, collateralTx, outputs }) { - const command = 'dsi'; - - let neutered = []; - for (let input of inputs) { - let _input = { - txId: input.txId || input.txid, - txid: input.txid || input.txId, - outputIndex: input.outputIndex, - }; - neutered.push(_input); - } + /** + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Uint32} opts.denomination + * @param {Uint8Array} opts.collateralTx + */ + CJPacker.packAllow = function ({ network, denomination, collateralTx }) { + const command = 'dsa'; + const DENOMINATION_SIZE = 4; - let inputsHex = DashTx.serializeInputs(inputs); - let inputHex = inputsHex.join(''); - let outputsHex = DashTx.serializeOutputs(outputs); - let outputHex = outputsHex.join(''); + //@ts-ignore - numbers can be used as map keys + let denomMask = STANDARD_DENOMINATION_MASKS[denomination]; + if (!denomMask) { + throw new Error( + `contact your local Dash representative to vote for denominations of '${denomination}'`, + ); + } - let len = collateralTx.length; - len += inputHex.length / 2; - len += outputHex.length / 2; - let bytes = new Uint8Array(Packer.HEADER_SIZE + len); + let totalLength = DENOMINATION_SIZE + collateralTx.length; + let payload = new Uint8Array(totalLength); + let dv = new DataView(payload.buffer); + let offset = 0; - let offset = Packer.HEADER_SIZE; + let DV_LITTLE_ENDIAN = true; + dv.setUint32(offset, denomMask, DV_LITTLE_ENDIAN); + offset += DENOMINATION_SIZE; - { - let inputsPayload = bytes.subarray(offset); - let j = 0; - for (let i = 0; i < inputHex.length; i += 2) { - let end = i + 2; - let hex = inputHex.slice(i, end); - inputsPayload[j] = parseInt(hex, 16); - j += 1; + payload.set(collateralTx, offset); + + let message = CJPacker.packMessage({ network, command, payload }); + return message; + }; + + /** + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Array} opts.inputs + * @param {Array} opts.outputs + * @param {Uint8Array} opts.collateralTx + */ + CJPacker.packDsi = function ({ network, inputs, collateralTx, outputs }) { + const command = 'dsi'; + + let neutered = []; + for (let input of inputs) { + let _input = { + txId: input.txId || input.txid, + txid: input.txid || input.txId, + outputIndex: input.outputIndex, + }; + neutered.push(_input); } - offset += inputHex.length / 2; - } - bytes.set(collateralTx, offset); - offset += collateralTx.length; + let inputsHex = DashTx.serializeInputs(inputs); + let inputHex = inputsHex.join(''); + let outputsHex = DashTx.serializeOutputs(outputs); + let outputHex = outputsHex.join(''); + + let len = collateralTx.length; + len += inputHex.length / 2; + len += outputHex.length / 2; + let bytes = new Uint8Array(CJPacker.HEADER_SIZE + len); + + let offset = CJPacker.HEADER_SIZE; + + { + let inputsPayload = bytes.subarray(offset); + let j = 0; + for (let i = 0; i < inputHex.length; i += 2) { + let end = i + 2; + let hex = inputHex.slice(i, end); + inputsPayload[j] = parseInt(hex, 16); + j += 1; + } + offset += inputHex.length / 2; + } - { - let outputsPayload = bytes.subarray(offset); - let j = 0; - for (let i = 0; i < outputHex.length; i += 2) { - let end = i + 2; - let hex = outputHex.slice(i, end); - outputsPayload[j] = parseInt(hex, 16); - j += 1; + bytes.set(collateralTx, offset); + offset += collateralTx.length; + + { + let outputsPayload = bytes.subarray(offset); + let j = 0; + for (let i = 0; i < outputHex.length; i += 2) { + let end = i + 2; + let hex = outputHex.slice(i, end); + outputsPayload[j] = parseInt(hex, 16); + j += 1; + } + offset += outputHex.length / 2; } - offset += outputHex.length / 2; - } - void Packer.packMessage({ network, command, bytes }); - return bytes; -}; + void CJPacker.packMessage({ network, command, bytes }); + return bytes; + }; -/** - * @param {Object} opts - * @param {NetworkName} opts.network - "mainnet", "testnet", etc - * @param {Array} [opts.inputs] - */ -Packer.packDss = function ({ network, inputs }) { - const command = 'dss'; + /** + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Array} [opts.inputs] + */ + CJPacker.packDss = function ({ network, inputs }) { + const command = 'dss'; - if (!inputs?.length) { - // TODO make better - throw new Error('you must provide some inputs'); - } + if (!inputs?.length) { + // TODO make better + throw new Error('you must provide some inputs'); + } - let txInputsHex = DashTx.serializeInputs(inputs); - let txInputHex = txInputsHex.join(''); - let payload = DashTx.utils.hexToBytes(txInputHex); + let txInputsHex = DashTx.serializeInputs(inputs); + let txInputHex = txInputsHex.join(''); + let payload = DashTx.utils.hexToBytes(txInputHex); - // TODO prealloc bytes - let bytes = Packer.packMessage({ network, command, payload }); - return bytes; -}; + // TODO prealloc bytes + let bytes = CJPacker.packMessage({ network, command, payload }); + return bytes; + }; -/** - * @param {Object} opts - * @param {NetworkName} opts.network - "mainnet", "testnet", etc - * @param {String} opts.command - * @param {Uint8Array?} [opts.bytes] - * @param {Uint8Array?} [opts.payload] - */ -Packer.packMessage = function ({ - network, - command, - bytes = null, - payload = null, -}) { - let payloadLength = payload?.byteLength || 0; - let messageSize = Packer.HEADER_SIZE + payloadLength; - let offset = 0; - - let embeddedPayload = false; - let message = bytes; - if (message) { + /** + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {String} opts.command + * @param {Uint8Array?} [opts.bytes] + * @param {Uint8Array?} [opts.payload] + */ + CJPacker.packMessage = function ({ + network, + command, + bytes = null, + payload = null, + }) { + let payloadLength = payload?.byteLength || 0; + let messageSize = CJPacker.HEADER_SIZE + payloadLength; + let offset = 0; + + let embeddedPayload = false; + let message = bytes; + if (message) { + if (!payload) { + payload = message.subarray(CJPacker.HEADER_SIZE); + payloadLength = payload.byteLength; + messageSize = CJPacker.HEADER_SIZE + payloadLength; + embeddedPayload = true; + } + } else { + message = new Uint8Array(messageSize); + } + if (message.length !== messageSize) { + throw new Error( + `expected bytes of length ${messageSize}, but got ${message.length}`, + ); + } + message.set(CJPacker.NETWORKS[network].magic, offset); + offset += SIZES.MAGIC_BYTES; + + // Set command_name (char[12]) + let nameBytes = textEncoder.encode(command); + message.set(nameBytes, offset); + offset += SIZES.COMMAND_NAME; + + // Finally, append the payload to the header if (!payload) { - payload = message.subarray(Packer.HEADER_SIZE); - payloadLength = payload.byteLength; - messageSize = Packer.HEADER_SIZE + payloadLength; - embeddedPayload = true; + // skip because it's already initialized to 0 + //message.set(payloadLength, offset); + offset += SIZES.PAYLOAD_SIZE; + + message.set(EMPTY_CHECKSUM, offset); + return message; } - } else { - message = new Uint8Array(messageSize); - } - if (message.length !== messageSize) { - throw new Error( - `expected bytes of length ${messageSize}, but got ${message.length}`, - ); - } - message.set(Packer.NETWORKS[network].magic, offset); - offset += SIZES.MAGIC_BYTES; - - // Set command_name (char[12]) - let nameBytes = textEncoder.encode(command); - message.set(nameBytes, offset); - offset += SIZES.COMMAND_NAME; - - // Finally, append the payload to the header - if (!payload) { - // skip because it's already initialized to 0 - //message.set(payloadLength, offset); + + let payloadSizeBytes = uint32ToBytesLE(payloadLength); + message.set(payloadSizeBytes, offset); offset += SIZES.PAYLOAD_SIZE; - message.set(EMPTY_CHECKSUM, offset); + let checksum = CJPacker.checksum(payload); + message.set(checksum, offset); + offset += SIZES.CHECKSUM; + + if (!embeddedPayload) { + message.set(payload, offset); + } return message; - } + }; - let payloadSizeBytes = uint32ToBytesLE(payloadLength); - message.set(payloadSizeBytes, offset); - offset += SIZES.PAYLOAD_SIZE; + /** + * First 4 bytes of SHA256(SHA256(payload)) in internal byte order. + * @param {Uint8Array} payload + */ + CJPacker.checksum = function (payload) { + // TODO this should be node-specific in node for performance reasons + if (Crypto.createHash) { + let hash = Crypto.createHash('sha256').update(payload).digest(); + let hashOfHash = Crypto.createHash('sha256').update(hash).digest(); + return hashOfHash.slice(0, 4); + } - let checksum = compute_checksum(payload); - message.set(checksum, offset); - offset += SIZES.CHECKSUM; + let hash = sha256(payload); + let hashOfHash = sha256(hash); + return hashOfHash.slice(0, 4); + }; - if (!embeddedPayload) { - message.set(payload, offset); + /** + * @param {Uint8Array} bytes + */ + function sha256(bytes) { + let K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]); + + /** + * @param {Number} value + * @param {Number} amount + */ + function rightRotate(value, amount) { + return (value >>> amount) | (value << (32 - amount)); + } + + let H = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, + ]); + + let padded = new Uint8Array((bytes.length + 9 + 63) & ~63); + padded.set(bytes); + padded[bytes.length] = 0x80; + let dv = new DataView(padded.buffer); + dv.setUint32(padded.length - 4, bytes.length << 3, false); + + let w = new Uint32Array(64); + for (let i = 0; i < padded.length; i += 64) { + for (let j = 0; j < 16; j += 1) { + w[j] = + (padded[i + 4 * j] << 24) | + (padded[i + 4 * j + 1] << 16) | + (padded[i + 4 * j + 2] << 8) | + padded[i + 4 * j + 3]; + } + for (let j = 16; j < 64; j += 1) { + let w1 = w[j - 15]; + let w2 = w[j - 2]; + let s0 = rightRotate(w1, 7) ^ rightRotate(w1, 18) ^ (w1 >>> 3); + let s1 = rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10); + w[j] = w[j - 16] + s0 + w[j - 7] + s1; + } + + let [a, b, c, d, e, f, g, h] = H; + for (let j = 0; j < 64; j += 1) { + let S1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25); + let ch = (e & f) ^ (~e & g); + let temp1 = h + S1 + ch + K[j] + w[j]; + let S0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = S0 + maj; + + h = g; + g = f; + f = e; + e = d + temp1; + d = c; + c = b; + b = a; + a = temp1 + temp2; + } + + H[0] += a; + H[1] += b; + H[2] += c; + H[3] += d; + H[4] += e; + H[5] += f; + H[6] += g; + H[7] += h; + } + + let numBytes = H.length * 4; + let hash = new Uint8Array(numBytes); + for (let i = 0; i < H.length; i += 1) { + hash[i * 4] = (H[i] >>> 24) & 0xff; + hash[i * 4 + 1] = (H[i] >>> 16) & 0xff; + hash[i * 4 + 2] = (H[i] >>> 8) & 0xff; + hash[i * 4 + 3] = H[i] & 0xff; + } + return hash; } - return message; -}; -/** - * First 4 bytes of SHA256(SHA256(payload)) in internal byte order. - * @param {Uint8Array} payload - */ -function compute_checksum(payload) { - // TODO this should be node-specific in node for performance reasons - let hash = Crypto.createHash('sha256').update(payload).digest(); - let hashOfHash = Crypto.createHash('sha256').update(hash).digest(); - return hashOfHash.slice(0, 4); -} + /** + * @param {String} ipv4 + */ + function ipv4ToBytesBE(ipv4) { + let u8s = []; + // let u8s = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff /*,0,0,0,0*/]; + + let octets = ipv4.split('.'); + for (let octet of octets) { + let int8 = parseInt(octet); + u8s.push(int8); + } -/** - * @param {String} ipv4 - */ -function ipv4ToBytesBE(ipv4) { - let u8s = []; - // let u8s = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff /*,0,0,0,0*/]; - - let octets = ipv4.split('.'); - for (let octet of octets) { - let int8 = parseInt(octet); - u8s.push(int8); + let bytes = Uint8Array.from(u8s); + return bytes; } - let bytes = Uint8Array.from(u8s); - return bytes; -} + /** + * @param {Uint32} n + */ + function uint32ToBytesLE(n) { + let u32 = new Uint32Array([n]); + let u8 = new Uint8Array(u32.buffer); + return u8; + } -/** - * @param {Uint32} n - */ -function uint32ToBytesLE(n) { - let u32 = new Uint32Array([n]); - let u8 = new Uint8Array(u32.buffer); - return u8; -} + /** + * @param {String} ip + */ + function is_ipv6_mapped_ipv4(ip) { + return !!ip.match(/^[:]{2}[f]{4}[:]{1}.*$/); + } -/** - * @param {String} ip - */ -function is_ipv6_mapped_ipv4(ip) { - return !!ip.match(/^[:]{2}[f]{4}[:]{1}.*$/); + // @ts-ignore + window.CJPacker = CJPacker; +})(('object' === typeof window && window) || {}, CJPacker); +if ('object' === typeof module) { + module.exports = CJPacker; } /** diff --git a/parser.js b/parser.js index f2d7179..c03fefc 100644 --- a/parser.js +++ b/parser.js @@ -1,397 +1,423 @@ -'use strict'; - -let Parser = module.exports; - -const DV_LITTLE_ENDIAN = true; -// const DV_BIG_ENDIAN = false; -//let EMPTY_HASH = Buffer.from('5df6e0e2', 'hex'); - -Parser.HEADER_SIZE = 24; -Parser.DSSU_SIZE = 16; -Parser.DSQ_SIZE = 142; -Parser.SESSION_ID_SIZE = 4; - -let CoinJoin = require('./coinjoin.js'); -let DashTx = require('dashtx'); - -/** - * Parse the 24-byte P2P Message Header - * - 4 byte magic bytes (delimiter) (possibly intended for non-tcp messages?) - * - 12 byte string (stop at first null) - * - 4 byte payload size - * - 4 byte checksum - * - * See also: - * - https://docs.dash.org/projects/core/en/stable/docs/reference/p2p-network-message-headers.html#message-headers - * @param {Uint8Array} bytes - */ -Parser.parseHeader = function (bytes) { - let buffer = Buffer.from(bytes); - // console.log( - // new Date(), - // '[debug] parseHeader(bytes)', - // buffer.length, - // buffer.toString('hex'), - // ); - // console.log(buffer.toString('utf8')); - - bytes = new Uint8Array(buffer); - if (bytes.length < Parser.HEADER_SIZE) { - let msg = `developer error: header should be ${Parser.HEADER_SIZE}+ bytes (optional payload), not ${bytes.length}`; - throw new Error(msg); - } - let dv = new DataView(bytes.buffer); - - let commandStart = 4; - let payloadSizeStart = 16; - let checksumStart = 20; - - let magicBytes = buffer.slice(0, commandStart); - - let commandEnd = buffer.indexOf(0x00, commandStart); - if (commandEnd >= payloadSizeStart) { - throw new Error('command name longer than 12 bytes'); - } - let commandBuf = buffer.slice(commandStart, commandEnd); - let command = commandBuf.toString('utf8'); - - let payloadSize = dv.getUint32(payloadSizeStart, DV_LITTLE_ENDIAN); - let checksum = buffer.slice(checksumStart, checksumStart + 4); - - let headerMessage = { - magicBytes, - command, - payloadSize, - checksum, +//@ts-ignore +var CJParser = ('object' === typeof module && exports) || {}; +(function (window, CJParser) { + 'use strict'; + + let DashTx = window.DashTx || require('dashtx'); + + let STANDARD_DENOMINATIONS_MAP = { + // 0.00100001 + 0b00010000: 100001, + // 0.01000010 + 0b00001000: 1000010, + // 0.10000100 + 0b00000100: 10000100, + // 1.00001000 + 0b00000010: 100001000, + // 10.00010000 + 0b00000001: 1000010000, }; - // if (command !== 'inv') { - // console.log(new Date(), headerMessage); - // } - // console.log(); - return headerMessage; -}; - -/** - * @param {Uint8Array} bytes - */ -Parser.parseVersion = function (bytes) { - let buffer = Buffer.from(bytes); - // console.log( - // '[debug] parseVersion(bytes)', - // buffer.length, - // buffer.toString('hex'), - // ); - // console.log(buffer.toString('utf8')); - - bytes = new Uint8Array(buffer); - let dv = new DataView(bytes.buffer); - - let versionStart = 0; - let version = dv.getUint32(versionStart, DV_LITTLE_ENDIAN); - - let servicesStart = versionStart + 4; // + SIZES.VERSION (4) - let servicesMask = dv.getBigUint64(servicesStart, DV_LITTLE_ENDIAN); - - let timestampStart = servicesStart + 8; // + SIZES.SERVICES (8) - let timestamp64n = dv.getBigInt64(timestampStart, DV_LITTLE_ENDIAN); - let timestamp64 = Number(timestamp64n); - let timestampMs = timestamp64 * 1000; - let timestamp = new Date(timestampMs); - - let addrRecvServicesStart = timestampStart + 8; // + SIZES.TIMESTAMP (8) - let addrRecvServicesMask = dv.getBigUint64( - addrRecvServicesStart, - DV_LITTLE_ENDIAN, - ); - - let addrRecvAddressStart = addrRecvServicesStart + 8; // + SIZES.SERVICES (8) - let addrRecvAddress = buffer.slice( - addrRecvAddressStart, - addrRecvAddressStart + 16, - ); - - let addrRecvPortStart = addrRecvAddressStart + 16; // + SIZES.IPV6 (16) - let addrRecvPort = dv.getUint16(addrRecvPortStart, DV_LITTLE_ENDIAN); - - let addrTransServicesStart = addrRecvPortStart + 2; // + SIZES.PORT (2) - let addrTransServicesMask = dv.getBigUint64( - addrTransServicesStart, - DV_LITTLE_ENDIAN, - ); - - let addrTransAddressStart = addrTransServicesStart + 8; // + SIZES.SERVICES (8) - let addrTransAddress = buffer.slice( - addrTransAddressStart, - addrTransAddressStart + 16, - ); - - let addrTransPortStart = addrTransAddressStart + 16; // + SIZES.IPV6 (16) - let addrTransPort = dv.getUint16(addrTransPortStart, DV_LITTLE_ENDIAN); - - let nonceStart = addrTransPortStart + 2; // + SIZES.PORT (2) - let nonce = buffer.slice(nonceStart, nonceStart + 8); - - let uaSizeStart = 80; // + SIZES.PORT (2) - let uaSize = buffer[uaSizeStart]; - - let uaStart = uaSizeStart + 1; - let uaBytes = buffer.slice(uaStart, uaStart + uaSize); - let ua = uaBytes.toString('utf8'); - - let startHeightStart = uaStart + uaSize; - let startHeight = dv.getUint32(startHeightStart, DV_LITTLE_ENDIAN); - - let relayStart = startHeightStart + 4; - /** @type {Boolean?} */ - let relay = null; - if (buffer.length > relayStart) { - relay = buffer[relayStart] > 0; - } - - let mnAuthChStart = relayStart + 1; - /** @type {Uint8Array?} */ - let mnAuthChallenge = null; - if (buffer.length > mnAuthChStart) { - mnAuthChallenge = buffer.slice(mnAuthChStart, mnAuthChStart + 32); - } - - let mnConnStart = mnAuthChStart + 32; - /** @type {Boolean?} */ - let mnConn = null; - if (buffer.length > mnConnStart) { - mnConn = buffer[mnConnStart] > 0; - } - - let versionMessage = { - version, - servicesMask, - timestamp, - addrRecvServicesMask, - addrRecvAddress, - addrRecvPort, - addrTransServicesMask, - addrTransAddress, - addrTransPort, - nonce, - ua, - startHeight, - relay, - mnAuthChallenge, - mnConn, - }; + const DV_LITTLE_ENDIAN = true; + // const DV_BIG_ENDIAN = false; + //let EMPTY_HASH = Buffer.from('5df6e0e2', 'hex'); + + CJParser.HEADER_SIZE = 24; + CJParser.DSSU_SIZE = 16; + CJParser.DSQ_SIZE = 142; + CJParser.SESSION_ID_SIZE = 4; - // console.log(versionMessage); - // console.log(); - return versionMessage; -}; - -Parser._DSSU_MESSAGE_IDS = { - 0x00: 'ERR_ALREADY_HAVE', - 0x01: 'ERR_DENOM', - 0x02: 'ERR_ENTRIES_FULL', - 0x03: 'ERR_EXISTING_TX', - 0x04: 'ERR_FEES', - 0x05: 'ERR_INVALID_COLLATERAL', - 0x06: 'ERR_INVALID_INPUT', - 0x07: 'ERR_INVALID_SCRIPT', - 0x08: 'ERR_INVALID_TX', - 0x09: 'ERR_MAXIMUM', - 0x0a: 'ERR_MN_LIST', // <-- - 0x0b: 'ERR_MODE', - 0x0c: 'ERR_NON_STANDARD_PUBKEY', // (Not used) - 0x0d: 'ERR_NOT_A_MN', //(Not used) - 0x0e: 'ERR_QUEUE_FULL', - 0x0f: 'ERR_RECENT', - 0x10: 'ERR_SESSION', - 0x11: 'ERR_MISSING_TX', - 0x12: 'ERR_VERSION', - 0x13: 'MSG_NOERR', - 0x14: 'MSG_SUCCESS', - 0x15: 'MSG_ENTRIES_ADDED', - 0x16: 'ERR_SIZE_MISMATCH', -}; - -Parser._DSSU_STATES = { - 0x00: 'IDLE', - 0x01: 'QUEUE', - 0x02: 'ACCEPTING_ENTRIES', - 0x03: 'SIGNING', - 0x04: 'ERROR', - 0x05: 'SUCCESS', -}; - -Parser._DSSU_STATUSES = { - 0x00: 'REJECTED', - 0x01: 'ACCEPTED', -}; - -/** - * @param {Uint8Array} bytes - */ -Parser.parseDssu = function (bytes) { - let buffer = Buffer.from(bytes); - - bytes = new Uint8Array(buffer); - let dv = new DataView(bytes.buffer); - // console.log('[debug] parseDssu(bytes)', bytes.length, buffer.toString('hex')); - // console.log(buffer.toString('utf8')); - if (bytes.length !== Parser.DSSU_SIZE) { - let msg = `developer error: a 'dssu' message is 16 bytes, but got ${bytes.length}`; - throw new Error(msg); - } + let textDecoder = new TextDecoder(); /** - * 4 nMsgSessionID - Required - Session ID - * 4 nMsgState - Required - Current state of processing - * 4 nMsgEntriesCount - Required - Number of entries in the pool (deprecated) - * 4 nMsgStatusUpdate - Required - Update state and/or signal if entry was accepted or not - * 4 nMsgMessageID - Required - ID of the typical masternode reply message + * Parse the 24-byte P2P Message Header + * - 4 byte magic bytes (delimiter) (possibly intended for non-tcp messages?) + * - 12 byte string (stop at first null) + * - 4 byte payload size + * - 4 byte checksum + * + * See also: + * - https://docs.dash.org/projects/core/en/stable/docs/reference/p2p-network-message-headers.html#message-headers + * @param {Uint8Array} bytes */ - const SIZES = { - SESSION_ID: Parser.SESSION_ID_SIZE, - STATE: 4, - ENTRIES_COUNT: 4, - STATUS_UPDATE: 4, - MESSAGE_ID: 4, + CJParser.parseHeader = function (bytes) { + // let buffer = Buffer.from(bytes); + // console.log( + // new Date(), + // '[debug] parseHeader(bytes)', + // buffer.length, + // buffer.toString('hex'), + // ); + // console.log(buffer.toString('utf8')); + // bytes = new Uint8Array(buffer); + + if (bytes.length < CJParser.HEADER_SIZE) { + // console.log( + // `[DEBUG] malformed header`, + // buffer.toString('utf8'), + // buffer.toString('hex'), + // ); + let msg = `developer error: header should be ${CJParser.HEADER_SIZE}+ bytes (optional payload), not ${bytes.length}`; + throw new Error(msg); + } + let dv = new DataView(bytes.buffer); + + let commandStart = 4; + let payloadSizeStart = 16; + let checksumStart = 20; + + let magicBytes = bytes.slice(0, commandStart); + + let commandEnd = bytes.indexOf(0x00, commandStart); + if (commandEnd >= payloadSizeStart) { + throw new Error('command name longer than 12 bytes'); + } + let commandBuf = bytes.slice(commandStart, commandEnd); + let command = textDecoder.decode(commandBuf); + + let payloadSize = dv.getUint32(payloadSizeStart, DV_LITTLE_ENDIAN); + let checksum = bytes.slice(checksumStart, checksumStart + 4); + + let headerMessage = { + magicBytes, + command, + payloadSize, + checksum, + }; + + // if (command !== 'inv') { + // console.log(new Date(), headerMessage); + // } + // console.log(); + return headerMessage; }; - let offset = 0; - - let session_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); - offset += SIZES.SESSION_ID; - - let state_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); - offset += SIZES.STATE; - - ///** - // * Grab the entries count - // * Not parsed because apparently master nodes no longer send - // * the entries count. - // */ - //parsed.entries_count = dv.getUint32(offset, DV_LITTLE_ENDIAN); - //offset += SIZES.ENTRIES_COUNT; - - let status_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); - offset += SIZES.STATUS_UPDATE; - - let message_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); - - let dssuMessage = { - session_id: session_id, - state_id: state_id, - state: Parser._DSSU_STATES[state_id], - // entries_count: 0, - status_id: status_id, - status: Parser._DSSU_STATUSES[status_id], - message_id: message_id, - message: Parser._DSSU_MESSAGE_IDS[message_id], + /** + * @param {Uint8Array} bytes + */ + CJParser.parseVersion = function (bytes) { + // let buffer = Buffer.from(bytes); + // console.log( + // '[debug] parseVersion(bytes)', + // buffer.length, + // buffer.toString('hex'), + // ); + // console.log(buffer.toString('utf8')); + // bytes = new Uint8Array(buffer); + + let dv = new DataView(bytes.buffer); + let versionStart = 0; + let version = dv.getUint32(versionStart, DV_LITTLE_ENDIAN); + + let servicesStart = versionStart + 4; // + SIZES.VERSION (4) + let servicesMask = dv.getBigUint64(servicesStart, DV_LITTLE_ENDIAN); + + let timestampStart = servicesStart + 8; // + SIZES.SERVICES (8) + let timestamp64n = dv.getBigInt64(timestampStart, DV_LITTLE_ENDIAN); + let timestamp64 = Number(timestamp64n); + let timestampMs = timestamp64 * 1000; + let timestamp = new Date(timestampMs); + + let addrRecvServicesStart = timestampStart + 8; // + SIZES.TIMESTAMP (8) + let addrRecvServicesMask = dv.getBigUint64( + addrRecvServicesStart, + DV_LITTLE_ENDIAN, + ); + + let addrRecvAddressStart = addrRecvServicesStart + 8; // + SIZES.SERVICES (8) + let addrRecvAddress = bytes.slice( + addrRecvAddressStart, + addrRecvAddressStart + 16, + ); + + let addrRecvPortStart = addrRecvAddressStart + 16; // + SIZES.IPV6 (16) + let addrRecvPort = dv.getUint16(addrRecvPortStart, DV_LITTLE_ENDIAN); + + let addrTransServicesStart = addrRecvPortStart + 2; // + SIZES.PORT (2) + let addrTransServicesMask = dv.getBigUint64( + addrTransServicesStart, + DV_LITTLE_ENDIAN, + ); + + let addrTransAddressStart = addrTransServicesStart + 8; // + SIZES.SERVICES (8) + let addrTransAddress = bytes.slice( + addrTransAddressStart, + addrTransAddressStart + 16, + ); + + let addrTransPortStart = addrTransAddressStart + 16; // + SIZES.IPV6 (16) + let addrTransPort = dv.getUint16(addrTransPortStart, DV_LITTLE_ENDIAN); + + let nonceStart = addrTransPortStart + 2; // + SIZES.PORT (2) + let nonce = bytes.slice(nonceStart, nonceStart + 8); + + let uaSizeStart = 80; // + SIZES.PORT (2) + let uaSize = bytes[uaSizeStart]; + + let uaStart = uaSizeStart + 1; + let uaBytes = bytes.slice(uaStart, uaStart + uaSize); + let ua = textDecoder.decode(uaBytes); + + let startHeightStart = uaStart + uaSize; + let startHeight = dv.getUint32(startHeightStart, DV_LITTLE_ENDIAN); + + let relayStart = startHeightStart + 4; + /** @type {Boolean?} */ + let relay = null; + if (bytes.length > relayStart) { + relay = bytes[relayStart] > 0; + } + + let mnAuthChStart = relayStart + 1; + /** @type {Uint8Array?} */ + let mnAuthChallenge = null; + if (bytes.length > mnAuthChStart) { + mnAuthChallenge = bytes.slice(mnAuthChStart, mnAuthChStart + 32); + } + + let mnConnStart = mnAuthChStart + 32; + /** @type {Boolean?} */ + let mnConn = null; + if (bytes.length > mnConnStart) { + mnConn = bytes[mnConnStart] > 0; + } + + let versionMessage = { + version, + servicesMask, + timestamp, + addrRecvServicesMask, + addrRecvAddress, + addrRecvPort, + addrTransServicesMask, + addrTransAddress, + addrTransPort, + nonce, + ua, + startHeight, + relay, + mnAuthChallenge, + mnConn, + }; + + // console.log(versionMessage); + // console.log(); + return versionMessage; }; - // console.log(dssuMessage); - // console.log(); - return dssuMessage; -}; - -/** - * @param {Uint8Array} bytes - */ -Parser.parseDsq = function (bytes) { - let buffer = Buffer.from(bytes); - - bytes = new Uint8Array(buffer); - if (bytes.length !== Parser.DSQ_SIZE) { - let msg = `developer error: 'dsq' messages are ${Parser.DSQ_SIZE} bytes, not ${bytes.length}`; - throw new Error(msg); - } - let dv = new DataView(bytes.buffer); - // console.log('[debug] parseDsq(bytes)', bytes.length, buffer.toString('hex')); - // console.log(buffer.toString('utf8')); - - const SIZES = { - DENOM: 4, - PROTX: 32, - TIME: 8, - READY: 1, - SIG: 97, + CJParser._DSSU_MESSAGE_IDS = { + 0x00: 'ERR_ALREADY_HAVE', + 0x01: 'ERR_DENOM', + 0x02: 'ERR_ENTRIES_FULL', + 0x03: 'ERR_EXISTING_TX', + 0x04: 'ERR_FEES', + 0x05: 'ERR_INVALID_COLLATERAL', + 0x06: 'ERR_INVALID_INPUT', + 0x07: 'ERR_INVALID_SCRIPT', + 0x08: 'ERR_INVALID_TX', + 0x09: 'ERR_MAXIMUM', + 0x0a: 'ERR_MN_LIST', // <-- + 0x0b: 'ERR_MODE', + 0x0c: 'ERR_NON_STANDARD_PUBKEY', // (Not used) + 0x0d: 'ERR_NOT_A_MN', //(Not used) + 0x0e: 'ERR_QUEUE_FULL', + 0x0f: 'ERR_RECENT', + 0x10: 'ERR_SESSION', + 0x11: 'ERR_MISSING_TX', + 0x12: 'ERR_VERSION', + 0x13: 'MSG_NOERR', + 0x14: 'MSG_SUCCESS', + 0x15: 'MSG_ENTRIES_ADDED', + 0x16: 'ERR_SIZE_MISMATCH', }; - let offset = 0; - - let denomination_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); - offset += SIZES.DENOM; + CJParser._DSSU_STATES = { + 0x00: 'IDLE', + 0x01: 'QUEUE', + 0x02: 'ACCEPTING_ENTRIES', + 0x03: 'SIGNING', + 0x04: 'ERROR', + 0x05: 'SUCCESS', + }; - //@ts-ignore - correctness of denomination must be checked higher up - let denomination = CoinJoin.STANDARD_DENOMINATIONS_MAP[denomination_id]; + CJParser._DSSU_STATUSES = { + 0x00: 'REJECTED', + 0x01: 'ACCEPTED', + }; /** - * Grab the protxhash + * @param {Uint8Array} bytes */ - let protxhash_bytes = bytes.slice(offset, offset + SIZES.PROTX); - offset += SIZES.PROTX; + CJParser.parseDssu = function (bytes) { + // let buffer = Buffer.from(bytes); + // bytes = new Uint8Array(buffer); + + let dv = new DataView(bytes.buffer); + // console.log('[debug] parseDssu(bytes)', bytes.length, buffer.toString('hex')); + // console.log(buffer.toString('utf8')); + if (bytes.length !== CJParser.DSSU_SIZE) { + let msg = `developer error: a 'dssu' message is 16 bytes, but got ${bytes.length}`; + throw new Error(msg); + } + + /** + * 4 nMsgSessionID - Required - Session ID + * 4 nMsgState - Required - Current state of processing + * 4 nMsgEntriesCount - Required - Number of entries in the pool (deprecated) + * 4 nMsgStatusUpdate - Required - Update state and/or signal if entry was accepted or not + * 4 nMsgMessageID - Required - ID of the typical masternode reply message + */ + const SIZES = { + SESSION_ID: CJParser.SESSION_ID_SIZE, + STATE: 4, + ENTRIES_COUNT: 4, + STATUS_UPDATE: 4, + MESSAGE_ID: 4, + }; + + let offset = 0; + + let session_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); + offset += SIZES.SESSION_ID; + + let state_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); + offset += SIZES.STATE; + + ///** + // * Grab the entries count + // * Not parsed because apparently master nodes no longer send + // * the entries count. + // */ + //parsed.entries_count = dv.getUint32(offset, DV_LITTLE_ENDIAN); + //offset += SIZES.ENTRIES_COUNT; + + let status_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); + offset += SIZES.STATUS_UPDATE; + + let message_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); + + let dssuMessage = { + session_id: session_id, + state_id: state_id, + state: CJParser._DSSU_STATES[state_id], + // entries_count: 0, + status_id: status_id, + status: CJParser._DSSU_STATUSES[status_id], + message_id: message_id, + message: CJParser._DSSU_MESSAGE_IDS[message_id], + }; + + // console.log(dssuMessage); + // console.log(); + return dssuMessage; + }; /** - * Grab the time + * @param {Uint8Array} bytes */ - let timestamp64n = dv.getBigInt64(offset, DV_LITTLE_ENDIAN); - offset += SIZES.TIME; - let timestamp_unix = Number(timestamp64n); - let timestampMs = timestamp_unix * 1000; - let timestampDate = new Date(timestampMs); - let timestamp = timestampDate.toISOString(); + CJParser.parseDsq = function (bytes) { + // let buffer = Buffer.from(bytes); + // bytes = new Uint8Array(buffer); + + if (bytes.length !== CJParser.DSQ_SIZE) { + let msg = `developer error: 'dsq' messages are ${CJParser.DSQ_SIZE} bytes, not ${bytes.length}`; + throw new Error(msg); + } + let dv = new DataView(bytes.buffer); + // console.log('[debug] parseDsq(bytes)', bytes.length, buffer.toString('hex')); + // console.log(buffer.toString('utf8')); + + const SIZES = { + DENOM: 4, + PROTX: 32, + TIME: 8, + READY: 1, + SIG: 97, + }; + + let offset = 0; + + let denomination_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); + offset += SIZES.DENOM; + + //@ts-ignore - correctness of denomination must be checked higher up + let denomination = STANDARD_DENOMINATIONS_MAP[denomination_id]; + + /** + * Grab the protxhash + */ + let protxhash_bytes = bytes.slice(offset, offset + SIZES.PROTX); + offset += SIZES.PROTX; + + /** + * Grab the time + */ + let timestamp64n = dv.getBigInt64(offset, DV_LITTLE_ENDIAN); + offset += SIZES.TIME; + let timestamp_unix = Number(timestamp64n); + let timestampMs = timestamp_unix * 1000; + let timestampDate = new Date(timestampMs); + let timestamp = timestampDate.toISOString(); + + /** + * Grab the fReady + */ + let ready = bytes[offset] > 0x00; + offset += SIZES.READY; + + let signature_bytes = bytes.slice(offset, offset + SIZES.SIG); + + let dsqMessage = { + denomination_id, + denomination, + protxhash_bytes, + // protxhash: '', + timestamp_unix, + timestamp, + ready, + signature_bytes, + // signature: '', + }; + + // console.log(dsqMessage); + // console.log(); + return dsqMessage; + }; /** - * Grab the fReady + * @param {Uint8Array} bytes */ - let ready = bytes[offset] > 0x00; - offset += SIZES.READY; - - let signature_bytes = bytes.slice(offset, offset + SIZES.SIG); - - let dsqMessage = { - denomination_id, - denomination, - protxhash_bytes, - // protxhash: '', - timestamp_unix, - timestamp, - ready, - signature_bytes, - // signature: '', + CJParser.parseDsf = function (bytes) { + // console.log( + // new Date(), + // '[debug] parseDsf (msg len)', + // bytes.length, + // bytes.toString('hex'), + // ); + + let offset = 0; + let sessionId = bytes.subarray(offset, CJParser.SESSION_ID_SIZE); + let session_id = DashTx.utils.bytesToHex(sessionId); + offset += CJParser.SESSION_ID_SIZE; + + // TODO parse transaction completely with DashTx + let transactionUnsigned = bytes.subarray(offset); + let transaction_unsigned = DashTx.utils.bytesToHex(transactionUnsigned); + + // let txLen = transaction_unsigned.length / 2; + // console.log( + // new Date(), + // '[debug] parseDsf (tx len)', + // txLen, + // transaction_unsigned, + // ); + + return { session_id, transaction_unsigned }; }; - // console.log(dsqMessage); - // console.log(); - return dsqMessage; -}; - -/** - * @param {Uint8Array} bytes - */ -Parser.parseDsf = function (bytes) { - // console.log( - // new Date(), - // '[debug] parseDsf (msg len)', - // bytes.length, - // bytes.toString('hex'), - // ); - - let offset = 0; - let sessionId = bytes.subarray(offset, Parser.SESSION_ID_SIZE); - let session_id = DashTx.utils.bytesToHex(sessionId); - offset += Parser.SESSION_ID_SIZE; - - // TODO parse transaction completely with DashTx - let transactionUnsigned = bytes.subarray(offset); - let transaction_unsigned = DashTx.utils.bytesToHex(transactionUnsigned); - - // let txLen = transaction_unsigned.length / 2; - // console.log( - // new Date(), - // '[debug] parseDsf (tx len)', - // txLen, - // transaction_unsigned, - // ); - - return { session_id, transaction_unsigned }; -}; + // @ts-ignore + window.CJParser = CJParser; +})(('object' === typeof window && window) || {}, CJParser); +if ('object' === typeof module) { + module.exports = CJParser; +} diff --git a/public/dashjoin.js b/public/dashjoin.js new file mode 100644 index 0000000..90d42d5 --- /dev/null +++ b/public/dashjoin.js @@ -0,0 +1,472 @@ +var DashJoin = ('object' === typeof module && exports) || {}; +(function (window, DashJoin) { + 'use strict'; + + let DashP2P = window.DashP2P || require('./dashp2p.js'); + let DashTx = window.DashTx || require('dashtx'); + + const DV_LITTLE_ENDIAN = true; + + const DENOM_LOWEST = 100001; + const PREDENOM_MIN = DENOM_LOWEST + 193; + const MIN_COLLATERAL = 10000; // DENOM_LOWEST / 10 + + let STANDARD_DENOMINATIONS_MAP = { + // 0.00100001 + 0b00010000: 100001, + // 0.01000010 + 0b00001000: 1000010, + // 0.10000100 + 0b00000100: 10000100, + // 1.00001000 + 0b00000010: 100001000, + // 10.00010000 + 0b00000001: 1000010000, + }; + + // Note: "mask" may be a misnomer. The spec seems to be more of an ID, + // but the implementation makes it look more like a mask... + let STANDARD_DENOMINATION_MASKS = { + // 0.00100001 + 100001: 0b00010000, + // 0.01000010 + 1000010: 0b00001000, + // 0.10000100 + 10000100: 0b00000100, + // 1.00001000 + 100001000: 0b00000010, + // 10.00010000 + 1000010000: 0b00000001, + }; + + // https://github.com/dashpay/dash/blob/v19.x/src/coinjoin/coinjoin.h#L39 + // const COINJOIN_ENTRY_MAX_SIZE = 9; // real + // const COINJOIN_ENTRY_MAX_SIZE = 2; // just for testing right now + + DashJoin.DENOM_LOWEST = DENOM_LOWEST; + DashJoin.MIN_COLLATERAL = MIN_COLLATERAL; + DashJoin.PREDENOM_MIN = PREDENOM_MIN; + DashJoin.DENOMS = [ + 100001, // 0.00100001 + 1000010, // 0.01000010 + 10000100, // 0.10000100 + 100001000, // 1.00001000 + 1000010000, // 10.00010000 + ]; + let reverseDenoms = DashJoin.DENOMS.slice(0); + reverseDenoms.reverse(); + + let Packers = {}; + let Parsers = {}; + let Sizes = {}; + let Utils = {}; + + // Ask Niles if there's an layman-ish obvious way to do this + DashJoin.getDenom = function (sats) { + for (let denom of reverseDenoms) { + let isDenom = sats === denom; + if (isDenom) { + return denom; + } + } + + return 0; + }; + + Sizes.DSQ = 142; + Sizes.SENDDSQ = 1; // 1-byte bool + Sizes.DENOM = 4; // 32-bit uint + Sizes.PROTX = 32; + Sizes.TIME = 8; // 64-bit uint + Sizes.READY = 1; // 1-byte bool + Sizes.SIG = 97; + + Sizes.DSSU = 16; + Sizes.SESSION_ID = 4; + Sizes.MESSAGE_ID = 4; + + ///////////////////// + // // + // Packers // + // // + ///////////////////// + + /** + * Turns on or off DSQ messages (necessary for CoinJoin, but off by default) + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Uint8Array?} [opts.message] + * @param {Boolean?} [opts.send] + */ + Packers.senddsq = function ({ network = 'mainnet', message, send = true }) { + const command = 'senddsq'; + let [bytes, payload] = DashP2P.packers._alloc(message, Sizes.SENDDSQ); + + let sendByte = [0x01]; + if (!send) { + sendByte = [0x00]; + } + payload.set(sendByte, 0); + + void DashP2P.packers.message({ network, command, bytes }); + return bytes; + }; + + /** + * Request to be allowed to join a CoinJoin pool. This may join an existing + * session - such as one already broadcast by a dsq - or may create a new one. + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Uint8Array?} [opts.message] + * @param {Uint32} opts.denomination + * @param {Uint8Array} opts.collateralTx + */ + Packers.dsa = function ({ + network = 'mainnet', + message, + denomination, + collateralTx, + }) { + const command = 'dsa'; + let dsaSize = Sizes.DENOM + collateralTx.length; + let [bytes, payload] = DashP2P.packers._alloc(message, dsaSize); + + //@ts-ignore - numbers can be used as map keys + let denomMask = STANDARD_DENOMINATION_MASKS[denomination]; + if (!denomMask) { + throw new Error( + `contact your local Dash representative to vote for denominations of '${denomination}'`, + ); + } + + let dv = new DataView(payload.buffer, payload.byteOffset); + let offset = 0; + + dv.setUint32(offset, denomMask, DV_LITTLE_ENDIAN); + offset += Sizes.DENOM; + + payload.set(collateralTx, offset); + + void DashP2P.packers.message({ network, command, bytes }); + return bytes; + }; + + /** + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Uint8Array?} [opts.message] + * @param {Array} opts.inputs + * @param {Array} opts.outputs + * @param {Uint8Array} opts.collateralTx + */ + Packers.dsi = function ({ + network = 'mainnet', + message, + inputs, + collateralTx, + outputs, + }) { + const command = 'dsi'; + + let neutered = []; + for (let input of inputs) { + let _input = { + txId: input.txId || input.txid, + txid: input.txid || input.txId, + outputIndex: input.outputIndex, + }; + neutered.push(_input); + } + + let inputsHex = DashTx.serializeInputs(inputs); + let inputHex = inputsHex.join(''); + let outputsHex = DashTx.serializeOutputs(outputs); + let outputHex = outputsHex.join(''); + + let dsiSize = collateralTx.length; + dsiSize += inputHex.length / 2; + dsiSize += outputHex.length / 2; + + let [bytes, payload] = DashP2P.packers._alloc(message, dsiSize); + + let offset = 0; + { + let j = 0; + for (let i = 0; i < inputHex.length; i += 2) { + let end = i + 2; + let hex = inputHex.slice(i, end); + payload[j] = parseInt(hex, 16); + j += 1; + } + offset += inputHex.length / 2; + } + + payload.set(collateralTx, offset); + offset += collateralTx.length; + + { + let outputsPayload = payload.subarray(offset); + let j = 0; + for (let i = 0; i < outputHex.length; i += 2) { + let end = i + 2; + let hex = outputHex.slice(i, end); + outputsPayload[j] = parseInt(hex, 16); + j += 1; + } + offset += outputHex.length / 2; + } + + void DashP2P.packers.message({ network, command, bytes }); + return bytes; + }; + + /** + * @param {Object} opts + * @param {Uint8Array?} [opts.message] + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Array} [opts.inputs] + */ + Packers.dss = function ({ network = 'mainnet', message, inputs }) { + const command = 'dss'; + + if (!inputs?.length) { + let msg = `'dss' should receive signed inputs as requested in 'dsi' and accepted in 'dsf', but got 0 inputs`; + throw new Error(msg); + } + + let txInputsHex = DashTx.serializeInputs(inputs); + let txInputHex = txInputsHex.join(''); + + let dssSize = txInputHex.length / 2; + let [bytes, payload] = DashP2P.packers._alloc(message, dssSize); + void DashP2P.utils.hexToPayload(txInputHex, payload); + + void DashP2P.packers.message({ network, command, bytes }); + return bytes; + }; + + ///////////////////// + // // + // Parsers // + // // + ///////////////////// + + /** + * @param {Uint8Array} bytes + */ + Parsers.dsq = function (bytes) { + if (bytes.length !== Sizes.DSQ) { + let msg = `developer error: 'dsq' must be ${Sizes.DSQ} bytes, but received ${bytes.length}`; + throw new Error(msg); + } + let dv = new DataView(bytes.buffer, bytes.byteOffset); + + let offset = 0; + + let denomination_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); + offset += Sizes.DENOM; + + //@ts-ignore - correctness of denomination must be checked higher up + let denomination = STANDARD_DENOMINATIONS_MAP[denomination_id]; + + let protxhash_bytes = bytes.subarray(offset, offset + Sizes.PROTX); + offset += Sizes.PROTX; + + let timestamp64n = dv.getBigInt64(offset, DV_LITTLE_ENDIAN); + offset += Sizes.TIME; + let timestamp_unix = Number(timestamp64n); + let timestampMs = timestamp_unix * 1000; + let timestampDate = new Date(timestampMs); + let timestamp = timestampDate.toISOString(); + + let ready = bytes[offset] > 0x00; + offset += Sizes.READY; + + let signature_bytes = bytes.subarray(offset, offset + Sizes.SIG); + + let dsqMessage = { + denomination_id, + denomination, + protxhash_bytes, + // protxhash: '', + timestamp_unix, + timestamp, + ready, + signature_bytes, + // signature: '', + }; + + return dsqMessage; + }; + + Parsers._DSSU_MESSAGE_IDS = { + 0x00: 'ERR_ALREADY_HAVE', + 0x01: 'ERR_DENOM', + 0x02: 'ERR_ENTRIES_FULL', + 0x03: 'ERR_EXISTING_TX', + 0x04: 'ERR_FEES', + 0x05: 'ERR_INVALID_COLLATERAL', + 0x06: 'ERR_INVALID_INPUT', + 0x07: 'ERR_INVALID_SCRIPT', + 0x08: 'ERR_INVALID_TX', + 0x09: 'ERR_MAXIMUM', + 0x0a: 'ERR_MN_LIST', // <-- + 0x0b: 'ERR_MODE', + 0x0c: 'ERR_NON_STANDARD_PUBKEY', // (Not used) + 0x0d: 'ERR_NOT_A_MN', //(Not used) + 0x0e: 'ERR_QUEUE_FULL', + 0x0f: 'ERR_RECENT', + 0x10: 'ERR_SESSION', + 0x11: 'ERR_MISSING_TX', + 0x12: 'ERR_VERSION', + 0x13: 'MSG_NOERR', + 0x14: 'MSG_SUCCESS', + 0x15: 'MSG_ENTRIES_ADDED', + 0x16: 'ERR_SIZE_MISMATCH', + }; + + Parsers._DSSU_STATES = { + 0x00: 'IDLE', + 0x01: 'QUEUE', + 0x02: 'ACCEPTING_ENTRIES', + 0x03: 'SIGNING', + 0x04: 'ERROR', + 0x05: 'SUCCESS', + }; + + Parsers._DSSU_STATUSES = { + 0x00: 'REJECTED', + 0x01: 'ACCEPTED', + }; + + // TODO DSSU type + /** + * 4 nMsgSessionID - Required - Session ID + * 4 nMsgState - Required - Current state of processing + * 4 nMsgEntriesCount - Required - Number of entries in the pool (deprecated) + * 4 nMsgStatusUpdate - Required - Update state and/or signal if entry was accepted or not + * 4 nMsgMessageID - Required - ID of the typical masternode reply message + */ + + /** + * @param {Uint8Array} bytes + */ + Parsers.dssu = function (bytes) { + const STATE_SIZE = 4; + const STATUS_UPDATE_SIZE = 4; + + if (bytes.length !== Sizes.DSSU) { + let msg = `developer error: a 'dssu' message is 16 bytes, but got ${bytes.length}`; + throw new Error(msg); + } + let dv = new DataView(bytes.buffer, bytes.byteOffset); + let offset = 0; + + let session_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); + offset += Sizes.SESSION_ID; + + let state_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); + offset += STATE_SIZE; + + let status_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); + offset += STATUS_UPDATE_SIZE; + + let message_id = dv.getUint32(offset, DV_LITTLE_ENDIAN); + + let dssuMessage = { + session_id: session_id, + state_id: state_id, + state: Parsers._DSSU_STATES[state_id], + status_id: status_id, + status: Parsers._DSSU_STATUSES[status_id], + message_id: message_id, + message: Parsers._DSSU_MESSAGE_IDS[message_id], + }; + return dssuMessage; + }; + + /** + * @param {Uint8Array} bytes + */ + Parsers.dsf = function (bytes) { + let offset = 0; + let sessionId = bytes.subarray(offset, Sizes.SESSION_ID); + let session_id = DashTx.utils.bytesToHex(sessionId); + offset += Sizes.SESSION_ID; + + console.log('DEBUG [[dsf]] bytes', DashTx.utils.bytesToHex(bytes)); + let transactionUnsigned = bytes.subarray(offset); + let transaction_unsigned = DashTx.utils.bytesToHex(transactionUnsigned); + console.log('DEBUG [[dsf]] tx', transaction_unsigned); + + let txRequest = DashTx.parseUnknown(transaction_unsigned); + let dsfTxRequest = { + session_id: session_id, + version: txRequest.version, + inputs: txRequest.inputs, + outputs: txRequest.outputs, + locktime: txRequest.locktime, + transaction_unsigned: transaction_unsigned, + }; + return dsfTxRequest; + }; + + Utils._evonodeMapToList = function (evonodesMap) { + console.log('[debug] get evonode list...'); + let evonodes = []; + { + //let resp = await rpc.masternodelist(); + let evonodeProTxIds = Object.keys(evonodesMap); + for (let id of evonodeProTxIds) { + let evonode = evonodesMap[id]; + if (evonode.status !== 'ENABLED') { + continue; + } + + let hostParts = evonode.address.split(':'); + let evodata = { + id: evonode.id, + host: evonode.address, + hostname: hostParts[0], + port: hostParts[1], + type: evonode.type, + }; + evonodes.push(evodata); + } + if (!evonodes.length) { + throw new Error('Sanity Fail: no evonodes online'); + } + } + + // void shuffle(evonodes); + evonodes.sort(Utils.sortMnListById); + return evonodes; + }; + + /** + * @param {Object} a + * @param {String} a.id + * @param {Object} b + * @param {String} b.id + */ + Utils.sortMnListById = function (a, b) { + if (a.id > b.id) { + return 1; + } + if (a.id < b.id) { + return -1; + } + return 0; + }; + + DashJoin.packers = Packers; + DashJoin.parsers = Parsers; + DashJoin.sizes = Sizes; + DashJoin.utils = Utils; + + //@ts-ignore + window.DashJoin = DashJoin; +})(globalThis.window || {}, DashJoin); +if ('object' === typeof module) { + module.exports = DashJoin; +} diff --git a/public/dashp2p.js b/public/dashp2p.js new file mode 100644 index 0000000..c68e19c --- /dev/null +++ b/public/dashp2p.js @@ -0,0 +1,1260 @@ +// TODO +// create byte stream socket +// auto handle version / verack +// auto handle ping / pong +// auto pool inv +// emit other messages +// reciprocal parsers and packers +// no backwards-compat with really old legacy clients + +var DashP2P = ('object' === typeof module && exports) || {}; +(function (window, DashP2P) { + 'use strict'; + + const DV_LITTLE_ENDIAN = true; + + let EMPTY_CHECKSUM_BYTES = [0x5d, 0xf6, 0xe0, 0xe2]; + let E_CLOSE = { + code: 'E_CLOSE', + message: 'promise stream closed', + }; + + const PAYLOAD_SIZE_MAX = 4 * 1024 * 1024; + DashP2P.PAYLOAD_SIZE_MAX = PAYLOAD_SIZE_MAX; + + let SIZES = { + // header + MAGIC_BYTES: 4, + COMMAND_NAME: 12, + PAYLOAD_SIZE: 4, + CHECKSUM: 4, + // version + VERSION: 4, + SERVICES: 8, + TIMESTAMP: 8, + ADDR_RECV_SERVICES: 8, + ADDR_RECV_IP: 16, + ADDR_RECV_PORT: 2, + ADDR_TRANS_SERVICES: 8, + ADDR_TRANS_IP: 16, + ADDR_TRANS_PORT: 2, + NONCE: 8, + USER_AGENT_BYTES: 1, // can be skipped + USER_AGENT_STRING: 0, + START_HEIGHT: 4, + // RELAY: 0, + RELAY_NONEMPTY: 1, + // MNAUTH_CHALLENGE: 0, + MNAUTH_CHALLENGE_NONEMPTY: 32, + // MN_CONNECTION: 0, + MN_CONNECTION_NONEMPTY: 1, + }; + + let Crypto = globalThis.crypto; + let textDecoder = new TextDecoder(); + let textEncoder = new TextEncoder(); + + let Packers = {}; + let Parsers = {}; + let Sizes = {}; + let Utils = {}; + + DashP2P.create = function () { + const HEADER_SIZE = Sizes.HEADER; + + let p2p = {}; + p2p.state = 'header'; + /** @type {Array} */ + p2p.chunks = []; + p2p.chunksLength = 0; + /** @type {Error?} */ + p2p.error = null; + /** @type {Parser.Header?} */ + p2p.header = null; + /** @type {Uint8Array?} */ + p2p.payload = null; + let explicitEvents = ['version', 'verack', 'ping', 'pong']; + p2p._eventStream = Utils.EventStream.create(explicitEvents); + + p2p._wsc = null; + p2p.send = function (bytes) { + throw new Error('no socket has been initialized'); + }; + p2p.close = function () { + throw new Error('no socket has been initialized'); + }; + p2p._close = function (bytes) { + try { + p2p._eventStream.close(); + } catch (e) { + console.error('error closing event stream:', e); + } + }; + + p2p.createSubscriber = p2p._eventStream.createSubscriber; + + p2p.initWebSocket = async function ( + wsc, + { network, hostname, port, start_height }, + ) { + p2p._wsc = wsc; + + p2p.send = function (bytes) { + return wsc.send(bytes); + }; + + p2p.close = function () { + try { + wsc.close(); + } catch (e) { + console.error('error closing websocket:', e); + } + p2p._close(true); + }; + + wsc.addEventListener('message', async function (wsevent) { + let ab = await wsevent.data.arrayBuffer(); + let bytes = new Uint8Array(ab); + console.log( + `%c ws.onmessage => p2p.processBytes(bytes) [${bytes.length}]`, + `color: #bada55`, + ); + p2p.processBytes(bytes); + }); + + wsc.addEventListener('open', async function () { + { + let versionBytes = DashP2P.packers.version({ + network: network, + addr_recv_ip: hostname, + addr_recv_port: port, + start_height: start_height, + }); + console.log('DEBUG wsc.send(versionBytes)'); + wsc.send(versionBytes); + } + + { + let verackBytes = DashP2P.packers.verack({ network: network }); + console.log('DEBUG wsc.send(verackBytes)'); + wsc.send(verackBytes); + } + }); + + wsc.addEventListener('close', p2p.close); + + let evstream = p2p.createSubscriber(['version', 'verack', 'ping']); + console.log('%c subscribed', 'color: red'); + + void (await evstream.once('version')); + console.log('%c[[version]] PROCESSED', 'color: red'); + // void (await evstream.once('verack')); + // console.log('%c[[verack]] PROCESSED', 'color: red'); + + (async function () { + for (;;) { + let msg = await evstream.once('ping'); + console.log('%c received ping', 'color: red'); + let pongBytes = DashP2P.packers.pong({ + network: network, + nonce: msg.payload, + }); + console.log('%c[[PING]] wsc.send(pongBytes)', 'color: blue;'); + wsc.send(pongBytes); + } + })().catch(DashP2P.createCatchClose(['ping'])); + + return; + }; + + /** @param {Uint8Array?} */ + p2p.processBytes = function (chunk) { + if (p2p.state === 'error') { + p2p._eventStream.rejectAll(p2p.error); + + // in the case of UDP where we miss a packet, + // we can log the error but still resume on the next one. + p2p.chunks = []; + p2p.chunksLength = 0; + p2p.state = 'header'; + } + + if (p2p.state === 'header') { + p2p.processHeaderBytes(chunk); + return; + } + + if (p2p.state === 'payload') { + p2p.processPayloadBytes(chunk); + return; + } + + if (p2p.state === 'result') { + let cmd = p2p.header.command; + let len = p2p.payload?.length || 0; + console.info(`%c[[RCV: ${cmd}]]`, `color: purple`, len); + let msg = { + command: p2p.header.command, + header: p2p.header, + payload: p2p.payload, + }; + p2p._eventStream.emit(msg.command, msg); + + p2p.state = 'header'; + p2p.processBytes(chunk); + return; + } + + let err = new Error(`developer error: unknown state '${p2p.state}'`); + p2p._eventStream.rejectAll(err); + p2p.state = 'header'; + p2p.processBytes(chunk); + }; + + /** + * @param {Uint8Array?} chunk + */ + p2p.processHeaderBytes = function (chunk) { + if (chunk) { + p2p.chunks.push(chunk); + p2p.chunksLength += chunk.byteLength; + } + if (p2p.chunksLength < HEADER_SIZE) { + if (chunk) { + console.log('... partial header'); + } + return; + } + + chunk = Utils.concatBytes(p2p.chunks, p2p.chunksLength); + + p2p.chunks = []; + p2p.chunksLength = 0; + if (chunk.byteLength > HEADER_SIZE) { + let nextChunk = chunk.slice(HEADER_SIZE); + p2p.chunks.push(nextChunk); + p2p.chunksLength += nextChunk.byteLength; + chunk = chunk.slice(0, HEADER_SIZE); + } + + // 'header' is complete, on to 'payload' + try { + p2p.header = Parsers.header(chunk); + } catch (e) { + p2p.state = 'error'; + p2p.error = new Error(`header parse error: ${e.message}`); + // TODO maybe throw away all chunks? + console.error(e); + console.error(chunk); + return; + } + + p2p.state = 'payload'; + if (p2p.header.payloadSize > DashP2P.PAYLOAD_SIZE_MAX) { + p2p.state = 'error'; + p2p.error = new Error( + `header's payload size ${p2p.header.payloadSize} is larger than the maximum allowed size of ${DashP2P.PAYLOAD_SIZE_MAX}`, + ); + return; + } + + if (p2p.header.payloadSize === 0) { + // 'payload' is complete (skipped), on to the 'result' + p2p.state = 'result'; + p2p.payload = null; + } + + let nextChunk = p2p.chunks.pop(); + p2p.processBytes(nextChunk); + }; + + /** + * @param {Uint8Array?} bytes + */ + p2p.processPayloadBytes = function (chunk) { + if (chunk) { + p2p.chunks.push(chunk); + p2p.chunksLength += chunk.byteLength; + } + if (p2p.chunksLength < p2p.header.payloadSize) { + if (chunk) { + console.log('... partial payload'); + } + return; + } + + chunk = Utils.concatBytes(p2p.chunks, p2p.chunksLength); + p2p.chunks = []; + p2p.chunksLength = 0; + + if (chunk.byteLength > p2p.header.payloadSize) { + let nextChunk = chunk.slice(p2p.header.payloadSize); + p2p.chunks.push(nextChunk); + p2p.chunksLength += chunk.byteLength; + chunk = chunk.slice(0, p2p.header.payloadSize); + } + p2p.state = 'result'; + p2p.payload = chunk; + + let nextChunk = p2p.chunks.pop(); + p2p.processBytes(nextChunk); + }; + + return p2p; + }; + + DashP2P.createCatchClose = function (names) { + function catchClose(err) { + if (err.code !== 'E_CLOSE') { + console.error( + `error caused '${names}' event stream to close unexpectedly:`, + ); + console.error(err); + } + } + return catchClose; + }; + + DashP2P.catchClose = function (err) { + if (err.code !== 'E_CLOSE') { + console.error(`error caused event stream to close unexpectedly:`); + console.error(err); + } + }; + + const TOTAL_HEADER_SIZE = + SIZES.MAGIC_BYTES + // 4 + SIZES.COMMAND_NAME + // 12 + SIZES.PAYLOAD_SIZE + // 4 + SIZES.CHECKSUM; // 4 + Sizes.HEADER = TOTAL_HEADER_SIZE; // 24 + Sizes.PING = SIZES.NONCE; // same as pong + Sizes.VERACK = 0; + + Packers.PROTOCOL_VERSION = 70227; + Packers.NETWORKS = {}; + Packers.NETWORKS.mainnet = { + port: 9999, + magic: new Uint8Array([ + //0xBD6B0CBF, + 0xbf, 0x0c, 0x6b, 0xbd, + ]), + start: 0xbf0c6bbd, + nBits: 0x1e0ffff0, + minimumParticiparts: 3, + }; + Packers.NETWORKS.testnet = { + port: 19999, + magic: new Uint8Array([ + //0xFFCAE2CE, + 0xce, 0xe2, 0xca, 0xff, + ]), + start: 0xcee2caff, + nBits: 0x1e0ffff0, + minimumParticiparts: 2, + }; + Packers.NETWORKS.regtest = { + port: 19899, + magic: new Uint8Array([ + //0xDCB7C1FC, + 0xfc, 0xc1, 0xb7, 0xdc, + ]), + start: 0xfcc1b7dc, + nBits: 0x207fffff, + minimumParticiparts: 2, + }; + Packers.NETWORKS.devnet = { + port: 19799, + magic: new Uint8Array([ + //0xCEFFCAE2, + 0xe2, 0xca, 0xff, 0xce, + ]), + start: 0xe2caffce, + nBits: 0x207fffff, + minimumParticiparts: 2, + }; + + /** + * @typedef {0x01|0x02|0x04|0x400} ServiceBitmask + * @typedef {"NETWORK"|"GETUTXO "|"BLOOM"|"NETWORK_LIMITED"} ServiceName + */ + + /** @type {Object.} */ + let SERVICE_IDENTIFIERS = {}; + Packers.SERVICE_IDENTIFIERS = SERVICE_IDENTIFIERS; + + /** + * 0x00 is the default - not a full node, no guarantees + */ + + /** + * NODE_NETWORK: + * This is a full node and can be asked for full + * blocks. It should implement all protocol features + * available in its self-reported protocol version. + */ + SERVICE_IDENTIFIERS.NETWORK = 0x01; + + /** + * NODE_GETUTXO: + * This node is capable of responding to the getutxo + * protocol request. Dash Core does not support + * this service. + */ + //SERVICE_IDENTIFIERS.GETUTXO = 0x02; + + /** + * NODE_BLOOM: + * This node is capable and willing to handle bloom- + * filtered connections. Dash Core nodes used to support + * this by default, without advertising this bit, but + * no longer do as of protocol version 70201 + * (= NO_BLOOM_VERSION) + */ + // SERVICE_IDENTIFIERS.BLOOM = 0x04; + + /** + * 0x08 is not supported by Dash + */ + + /** + * NODE_NETWORK_LIMITED: + * This is the same as NODE_NETWORK with the + * limitation of only serving the last 288 blocks. + * Not supported prior to Dash Core 0.16.0 + */ + // SERVICE_IDENTIFIERS.NETWORK_LIMITED = 0x400; + + /** + * @param {PackMessage} opts + */ + Packers.message = function ({ + network, + command, + bytes = null, + payload = null, + }) { + if (!Packers.NETWORKS[network]) { + throw new Error(`"network" '${network}' is invalid.`); + } + + let payloadLength = payload?.byteLength || 0; + let messageSize = Sizes.HEADER + payloadLength; + let offset = 0; + + let embeddedPayload = false; + let message = bytes; + if (message) { + if (!payload) { + payload = message.subarray(Sizes.HEADER); + payloadLength = payload.byteLength; + messageSize = Sizes.HEADER + payloadLength; + embeddedPayload = true; + } + } else { + message = new Uint8Array(messageSize); + } + if (message.length !== messageSize) { + throw new Error( + `expected bytes of length ${messageSize}, but got ${message.length}`, + ); + } + message.set(Packers.NETWORKS[network].magic, offset); + offset += SIZES.MAGIC_BYTES; + + // Set command_name (char[12]) + let nameBytes = textEncoder.encode(command); + message.set(nameBytes, offset); + offset += SIZES.COMMAND_NAME; + + // Finally, append the payload to the header + if (!payload) { + // skip because it's already initialized to 0 + //message.set(payloadLength, offset); + offset += SIZES.PAYLOAD_SIZE; + + message.set(EMPTY_CHECKSUM_BYTES, offset); + return message; + } + + let payloadSizeBytes = Utils._uint32ToBytesLE(payloadLength); + message.set(payloadSizeBytes, offset); + offset += SIZES.PAYLOAD_SIZE; + + let checksum = Packers._checksum(payload); + message.set(checksum, offset); + offset += SIZES.CHECKSUM; + + if (!embeddedPayload) { + message.set(payload, offset); + } + return message; + }; + + /** + * Returns a correctly-sized buffer and subarray into the payload + * @param {Uint8Array} bytes + * @param {Uint16} payloadSize + */ + Packers._alloc = function (bytes, payloadSize) { + let messageSize = DashP2P.sizes.HEADER + payloadSize; + if (!bytes) { + bytes = new Uint8Array(messageSize); + } else if (bytes.length !== messageSize) { + if (bytes.length < messageSize) { + let msg = `the provided buffer is only ${bytes.length} bytes, but at least ${messageSize} are needed`; + throw new Error(msg); + } + bytes = bytes.subarray(0, messageSize); + } + + let payload = bytes.subarray(DashP2P.sizes.HEADER); + + return [bytes, payload]; + }; + + /** + * First 4 bytes of SHA256(SHA256(payload)) in internal byte order. + * @param {Uint8Array} payload + */ + Packers._checksum = function (payload) { + // TODO this should be node-specific in node for performance reasons + if (Crypto.createHash) { + let hash = Crypto.createHash('sha256').update(payload).digest(); + let hashOfHash = Crypto.createHash('sha256').update(hash).digest(); + return hashOfHash.slice(0, 4); + } + + let hash = Utils.sha256(payload); + let hashOfHash = Utils.sha256(hash); + return hashOfHash.slice(0, 4); + }; + + /** + * Constructs a version message, with fields in the correct byte order. + * @param {VersionOpts} opts + * + * See also: + * - https://dashcore.readme.io/docs/core-ref-p2p-network-control-messages#version + */ + /* jshint maxcomplexity: 9001 */ + /* jshint maxstatements:150 */ + /* (it's simply very complex, okay?) */ + Packers.version = function ({ + network = 'mainnet', + message, + protocol_version = Packers.PROTOCOL_VERSION, + // alias of addr_trans_services + //services, + addr_recv_services = [SERVICE_IDENTIFIERS.NETWORK], + addr_recv_ip, // required to match + addr_recv_port, // required to match + addr_trans_services = [], + addr_trans_ip = '127.0.0.1', + addr_trans_port = Math.ceil(65535 * Math.random()), + start_height, + nonce = null, + user_agent = null, + relay = null, + mnauth_challenge = null, + }) { + const command = 'version'; + + if (!Array.isArray(addr_recv_services)) { + throw new Error('"addr_recv_services" must be an array'); + } + if (mnauth_challenge !== null) { + if (!(mnauth_challenge instanceof Uint8Array)) { + throw new Error('"mnauth_challenge" field must be a Uint8Array'); + } + if (mnauth_challenge.length !== SIZES.MNAUTH_CHALLENGE_NONEMPTY) { + throw new Error( + `"mnauth_challenge" field must be ${SIZES.MNAUTH_CHALLENGE_NONEMPTY} bytes long, not ${mnauth_challenge.length}`, + ); + } + } + + let sizes = { + userAgentString: user_agent?.length || 0, + relay: 0, + mnauthChallenge: 0, + mnConnection: 0, + }; + if (relay !== null) { + sizes.relay = SIZES.RELAY_NONEMPTY; + } + sizes.mnauthChallenge = SIZES.MNAUTH_CHALLENGE_NONEMPTY; + sizes.mnConnection = SIZES.MN_CONNECTION_NONEMPTY; + + let versionSize = + SIZES.VERSION + + SIZES.SERVICES + + SIZES.TIMESTAMP + + SIZES.ADDR_RECV_SERVICES + + SIZES.ADDR_RECV_IP + + SIZES.ADDR_RECV_PORT + + SIZES.ADDR_TRANS_SERVICES + + SIZES.ADDR_TRANS_IP + + SIZES.ADDR_TRANS_PORT + + SIZES.NONCE + + SIZES.USER_AGENT_BYTES + + sizes.userAgentString + // calc + SIZES.START_HEIGHT + + sizes.relay + // calc + sizes.mnauthChallenge + // calc + sizes.mnConnection; // calc + + let [bytes, payload] = Packers._alloc(message, versionSize); + + // Protocol version + //@ts-ignore - protocol_version has a default value + let versionBytes = Utils._uint32ToBytesLE(protocol_version); + payload.set(versionBytes, 0); + + /** + * Set services to NODE_NETWORK (1) + NODE_BLOOM (4) + */ + const SERVICES_OFFSET = SIZES.VERSION; + let senderServicesBytes; + { + let senderServicesMask = 0n; + //@ts-ignore - addr_trans_services has a default value of [] + for (const serviceBit of addr_trans_services) { + senderServicesMask += BigInt(serviceBit); + } + let senderServices64 = new BigInt64Array([senderServicesMask]); // jshint ignore:line + senderServicesBytes = new Uint8Array(senderServices64.buffer); + payload.set(senderServicesBytes, SERVICES_OFFSET); + } + + const TIMESTAMP_OFFSET = SERVICES_OFFSET + SIZES.SERVICES; + { + let tsBytes = Utils._uint32ToBytesLE(Date.now()); + payload.set(tsBytes, TIMESTAMP_OFFSET); + } + + let ADDR_RECV_SERVICES_OFFSET = TIMESTAMP_OFFSET + SIZES.TIMESTAMP; + { + let serverServicesMask = 0n; + //@ts-ignore - addr_recv_services has a default value + for (const serviceBit of addr_recv_services) { + serverServicesMask += BigInt(serviceBit); + } + let serverServices64 = new BigInt64Array([serverServicesMask]); // jshint ignore:line + let serverServicesBytes = new Uint8Array(serverServices64.buffer); + payload.set(serverServicesBytes, ADDR_RECV_SERVICES_OFFSET); + } + + /** + * "ADDR_RECV" means the host that we're sending this traffic to. + * So, in other words, it's the master node + */ + let ADDR_RECV_IP_OFFSET = + ADDR_RECV_SERVICES_OFFSET + SIZES.ADDR_RECV_SERVICES; + { + let ipBytesBE = Utils._ipv4ToBytesBE(addr_recv_ip); + payload.set([0xff, 0xff], ADDR_RECV_IP_OFFSET + 10); + payload.set(ipBytesBE, ADDR_RECV_IP_OFFSET + 12); + } + + /** + * Copy address recv port + */ + let ADDR_RECV_PORT_OFFSET = ADDR_RECV_IP_OFFSET + SIZES.ADDR_RECV_IP; + { + let portBytes16 = Uint16Array.from([addr_recv_port]); + let portBytes = new Uint8Array(portBytes16.buffer); + portBytes.reverse(); + payload.set(portBytes, ADDR_RECV_PORT_OFFSET); + } + + /** + * Copy address transmitted services + */ + let ADDR_TRANS_SERVICES_OFFSET = + ADDR_RECV_PORT_OFFSET + SIZES.ADDR_RECV_PORT; + payload.set(senderServicesBytes, ADDR_TRANS_SERVICES_OFFSET); + + /** + * We add the extra 10, so that we can encode an ipv4-mapped ipv6 address + */ + let ADDR_TRANS_IP_OFFSET = + ADDR_TRANS_SERVICES_OFFSET + SIZES.ADDR_TRANS_SERVICES; + { + //@ts-ignore - addr_trans_ip has a default value + let isIpv6Mapped = addr_trans_ip.startsWith('::ffff:'); + if (isIpv6Mapped) { + //@ts-ignore - addr_trans_ip has a default value + let ipv6Parts = addr_trans_ip.split(':'); + let ipv4Str = ipv6Parts.at(-1); + //@ts-ignore - guaranteed to be defined, actually + let ipBytesBE = Utils._ipv4ToBytesBE(ipv4Str); + payload.set(ipBytesBE, ADDR_TRANS_IP_OFFSET + 12); + payload.set([0xff, 0xff], ADDR_TRANS_IP_OFFSET + 10); // we add the 10 so that we can fill the latter 6 bytes + } else { + /** TODO: ipv4-only & ipv6-only */ + //@ts-ignore - addr_trans_ip has a default value + let ipBytesBE = Utils._ipv4ToBytesBE(addr_trans_ip); + payload.set(ipBytesBE, ADDR_TRANS_IP_OFFSET + 12); + payload.set([0xff, 0xff], ADDR_TRANS_IP_OFFSET + 10); // we add the 10 so that we can fill the latter 6 bytes + } + } + + let ADDR_TRANS_PORT_OFFSET = ADDR_TRANS_IP_OFFSET + SIZES.ADDR_TRANS_IP; + { + let portBytes16 = Uint16Array.from([addr_trans_port]); + let portBytes = new Uint8Array(portBytes16.buffer); + portBytes.reverse(); + payload.set(portBytes, ADDR_TRANS_PORT_OFFSET); + } + + // TODO we should set this to prevent duplicate broadcast + // this can be left zero + let NONCE_OFFSET = ADDR_TRANS_PORT_OFFSET + SIZES.ADDR_TRANS_PORT; + if (!nonce) { + nonce = new Uint8Array(SIZES.NONCE); + Crypto.getRandomValues(nonce); + } + payload.set(nonce, NONCE_OFFSET); + + let USER_AGENT_BYTES_OFFSET = NONCE_OFFSET + SIZES.NONCE; + if (null !== user_agent && typeof user_agent === 'string') { + let userAgentSize = user_agent.length; + payload.set([userAgentSize], USER_AGENT_BYTES_OFFSET); + let uaBytes = textEncoder.encode(user_agent); + payload.set(uaBytes, USER_AGENT_BYTES_OFFSET + 1); + } else { + payload.set([0x0], USER_AGENT_BYTES_OFFSET); + } + + let START_HEIGHT_OFFSET = + USER_AGENT_BYTES_OFFSET + + SIZES.USER_AGENT_BYTES + + SIZES.USER_AGENT_STRING; + { + let heightBytes = Utils._uint32ToBytesLE(start_height); + payload.set(heightBytes, START_HEIGHT_OFFSET); + } + + let RELAY_OFFSET = START_HEIGHT_OFFSET + SIZES.START_HEIGHT; + if (relay !== null) { + let bytes = [0x00]; + if (relay) { + bytes[0] = 0x01; + } + payload.set(bytes, RELAY_OFFSET); + } + + let MNAUTH_CHALLENGE_OFFSET = RELAY_OFFSET + SIZES.RELAY; + if (!mnauth_challenge) { + let rnd = new Uint8Array(32); + Crypto.getRandomValues(rnd); + mnauth_challenge = rnd; + } + payload.set(mnauth_challenge, MNAUTH_CHALLENGE_OFFSET); + + // let MNAUTH_CONNECTION_OFFSET = MNAUTH_CHALLENGE_OFFSET + SIZES.MN_CONNECTION; + // if (mn_connection) { + // payload.set([0x01], MNAUTH_CONNECTION_OFFSET); + // } + + void Packers.message({ network, command, bytes }); + return bytes; + }; + + /** + * No payload, just an ACK + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Uint8Array?} [opts.message] - preallocated bytes + */ + Packers.verack = function ({ network = 'mainnet', message }) { + const command = 'verack'; + let [bytes] = Packers._alloc(message, Sizes.VERACK); + + void Packers.message({ network, command, bytes }); + return bytes; + }; + + /** + * In this case the only bytes are the nonce + * Use a .subarray(offset) to define an offset. + * (a manual offset will not work consistently, and .byteOffset is context-sensitive) + * @param {Object} opts + * @param {NetworkName} opts.network - "mainnet", "testnet", etc + * @param {Uint8Array?} [opts.message] + * @param {Uint8Array} opts.nonce + */ + Packers.pong = function ({ network = 'mainnet', message = null, nonce }) { + const command = 'pong'; + let [bytes, payload] = Packers._alloc(message, Sizes.PING); + + payload.set(nonce, 0); + + void Packers.message({ network, command, bytes }); + return bytes; + }; + + /** + * Parse the 24-byte P2P Message Header + * - 4 byte magic bytes (delimiter) (possibly intended for non-tcp messages?) + * - 12 byte string (stop at first null) + * - 4 byte payload size + * - 4 byte checksum + * + * See also: + * - https://docs.dash.org/projects/core/en/stable/docs/reference/p2p-network-message-headers.html#message-headers + * @param {Uint8Array} bytes + */ + Parsers.header = function (bytes) { + if (bytes.length < Sizes.HEADER) { + let msg = `developer error: header should be ${Sizes.HEADER}+ bytes (optional payload), not ${bytes.length}`; + throw new Error(msg); + } + let dv = new DataView(bytes.buffer, bytes.byteOffset); + + let index = 0; + + let magicBytes = bytes.subarray(index, index + SIZES.MAGIC_BYTES); + index += SIZES.MAGIC_BYTES; // +4 = 4 + + let commandBuf = bytes.subarray(index, index + SIZES.COMMAND_NAME); + let command = ''; + { + let commandEnd = commandBuf.indexOf(0x00); + if (commandEnd !== -1) { + commandBuf = commandBuf.subarray(0, commandEnd); + } + try { + command = textDecoder.decode(commandBuf); + } catch (e) { + // invalid command name + throw e; + } + } + index += SIZES.COMMAND_NAME; // +12 = 16 + + let payloadSize = dv.getUint32(index, DV_LITTLE_ENDIAN); + index += 1; // +1 = 17 + + let checksum = bytes.subarray(index, index + SIZES.CHUCKSUM); + //index += SIZES.CHECKSUM // +4 = 21 (ends at 20) + + let headerMessage = { + magicBytes, + command, + payloadSize, + checksum, + }; + + return headerMessage; + }; + Parsers.SIZES = SIZES; + + /** + * @param {String} hex + * @param {Uint8Array} payload + */ + Utils.hexToPayload = function (hex, payload) { + let i = 0; + let index = 0; + let lastIndex = hex.length - 2; + for (;;) { + if (i > lastIndex) { + break; + } + + let h = hex.slice(i, i + 2); + let b = parseInt(h, 16); + payload[index] = b; + + i += 2; + index += 1; + } + + return payload; + }; + + Utils.EventStream = {}; + + /** @param {String} events */ + Utils.EventStream.create = function (explicitEvents) { + let stream = {}; + + stream._explicitEvents = explicitEvents; + + /** @type {Array} */ + stream._connections = []; + + /** + * @param {Array} events - ex: ['*', 'error'] for default events, or list by name + * @param {Function} eventLoopFn - called in a loop until evstream.close() + */ + stream.createSubscriber = function (events, eventLoopFn) { + let conn = Utils.EventStream.createSubscriber(stream, events); + if (!eventLoopFn) { + return conn; + } + + let go = async function (eventLoop, conn) { + for (;;) { + await eventLoop(conn); + } + }; + go(eventLoopFn, conn).catch(DashP2P.createCatchClose(events)); + return null; + }; + + stream.emit = function (eventname, msg) { + if (eventname === 'error') { + return stream.rejectAll(msg); + } + for (let p of stream._connections) { + let isSubscribed = p._events.includes(eventname); + if (isSubscribed) { + p._resolve(msg); + continue; + } + + let isExplicit = stream._explicitEvents.includes(eventname); + if (isExplicit) { + continue; + } + + let hasCatchall = p._events.includes('*'); + if (hasCatchall) { + p._resolve(msg); + } + } + }; + + stream.rejectAll = function (err) { + if (!(err instanceof Error)) { + throw new Error(`'error instanceof Error' must be true for errors`); + } + let handled = false; + for (let p of stream._connections) { + let handlesErrors = p._events.includes('error'); + if (!handlesErrors) { + continue; + } + + handled = true; + p._reject(err); + } + if (!handled) { + for (let p of stream._connections) { + p._reject(err); + } + } + }; + + stream.close = function () { + for (let conn of stream._connections) { + conn._close(true); + } + }; + + return stream; + }; + + Utils.EventStream.createSubscriber = function (stream, defaultEvents = null) { + let p = {}; + stream._connections.push(p); + + p._events = defaultEvents; + + p.closed = false; + p._settled = false; + p._resolve = function (msg) {}; + p._reject = function (err) {}; + p._promise = Promise.resolve(null); + p._next = async function () { + p._settled = false; + p._promise = new Promise(function (_resolve, _reject) { + p._resolve = function (msg) { + // p._close(true); + _resolve(msg); + }; + p._reject = function (err) { + // p._close(true); + _reject(err); + }; + }); + + return await p._promise; + }; + + /** + * Waits for and returns the next message of the given event name, + * or of any of the default event names. + * @param {String} eventname - '*' for default events, 'error' for error, or others by name + */ + p.once = async function (eventname) { + if (p.closed) { + let err = new Error('cannot receive new events after close'); + Object.assign(err, { code: 'E_ALREADY_CLOSED' }); + throw err; + } + + if (eventname) { + p._events = [eventname]; + } else if (defaultEvents?.length) { + p._events = defaultEvents; + } else { + let err = new Error( + `call stream.createSubscriber(['*']) or conn.once('*') for default events`, + ); + Object.assign(err, { code: 'E_NO_EVENTS' }); + throw err; + } + console.log('%c[[RESUB]]', 'color: red; font-weight: bold;', p._events); + + return await p._next(); + }; + + p._close = function (_settle) { + if (p.closed) { + return; + } + p.closed = true; + + let index = stream._connections.indexOf(p); + if (index >= 0) { + void stream._connections.splice(index, 1); + } + if (_settle) { + p._settled = true; + } + if (p._settled) { + return; + } + + p._settled = true; + let err = new Error(E_CLOSE.message); + Object.assign(err, E_CLOSE); + p._reject(err); + }; + + /** + * Causes `let msg = conn.once()` to fail with E_CLOSE or E_ALREADY_CLOSED + */ + p.close = function () { + p._close(false); + }; + + return p; + }; + + // /** @param {String} events */ + // Utils.createPromiseGenerator = function (events) { + // let g = {}; + + // g.events = events; + + // // g._settled = true; + // g._promise = Promise.resolve(); // for type hint + // g._results = []; + + // g.resolve = function (result) {}; + // g.reject = function (err) {}; + + // // g.init = async function () { + // // if (!g._settled) { + // // console.warn('g.init() called again before previous call was settled'); + // // return await g._promise; + // // } + // // g._settled = false; + // g._promise = new Promise(function (_resolve, _reject) { + // g.resolve = _resolve; + // g.reject = _reject; + // // g.resolve = function (result) { + // // if (g._settled) { + // // g._results.push(result); + // // return; + // // } + // // g._settled = true; + // // _resolve(result); + // // }; + // // g.reject = function (error) { + // // if (g._settled) { + // // g._results.push(error); + // // return; + // // } + // // g._settled = true; + // // _reject(error); + // // }; + // }); + // // if (g._results.length) { + // // let result = g._results.shift(); + // // if (result instanceof Error) { + // // g.reject(result); + // // } else { + // // g.resolve(result); + // // } + // // } + // // return await g._promise; + // // }; + + // return g; + // }; + + /** + * @param {Array} byteArrays + * @param {Number?} [len] + * @returns {Uint8Array} + */ + Utils.concatBytes = function (byteArrays, len) { + if (byteArrays.length === 1) { + return byteArrays[0]; + } + + if (!len) { + for (let bytes of byteArrays) { + len += bytes.length; + } + } + + let allBytes = new Uint8Array(len); + let offset = 0; + for (let bytes of byteArrays) { + allBytes.set(bytes, offset); + offset += bytes.length; + } + + return allBytes; + }; + + /** + * @param {String} ipv4 + */ + Utils._ipv4ToBytesBE = function (ipv4) { + let u8s = []; + // let u8s = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff /*,0,0,0,0*/]; + + let octets = ipv4.split('.'); + for (let octet of octets) { + let int8 = parseInt(octet); + u8s.push(int8); + } + + let bytes = Uint8Array.from(u8s); + return bytes; + }; + + /** + * @param {Uint32} n + */ + Utils._uint32ToBytesLE = function (n) { + let u32 = new Uint32Array([n]); + let u8 = new Uint8Array(u32.buffer); + return u8; + }; + + /** + * @param {Uint8Array} bytes + */ + Utils.sha256 = function (bytes) { + /* jshint ignore:start */ + let K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]); + + /** + * @param {Number} value + * @param {Number} amount + */ + function rightRotate(value, amount) { + return (value >>> amount) | (value << (32 - amount)); + } + + let H = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, + ]); + + let padded = new Uint8Array((bytes.length + 9 + 63) & ~63); + padded.set(bytes); + padded[bytes.length] = 0x80; + let dv = new DataView(padded.buffer, padded.byteOffset); + dv.setUint32(padded.length - 4, bytes.length << 3, false); + + let w = new Uint32Array(64); + for (let i = 0; i < padded.length; i += 64) { + for (let j = 0; j < 16; j += 1) { + w[j] = + (padded[i + 4 * j] << 24) | + (padded[i + 4 * j + 1] << 16) | + (padded[i + 4 * j + 2] << 8) | + padded[i + 4 * j + 3]; + } + for (let j = 16; j < 64; j += 1) { + let w1 = w[j - 15]; + let w2 = w[j - 2]; + let s0 = rightRotate(w1, 7) ^ rightRotate(w1, 18) ^ (w1 >>> 3); + let s1 = rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10); + w[j] = w[j - 16] + s0 + w[j - 7] + s1; + } + + let [a, b, c, d, e, f, g, h] = H; + for (let j = 0; j < 64; j += 1) { + let S1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25); + let ch = (e & f) ^ (~e & g); + let temp1 = h + S1 + ch + K[j] + w[j]; + let S0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = S0 + maj; + + h = g; + g = f; + f = e; + e = d + temp1; + d = c; + c = b; + b = a; + a = temp1 + temp2; + } + + H[0] += a; + H[1] += b; + H[2] += c; + H[3] += d; + H[4] += e; + H[5] += f; + H[6] += g; + H[7] += h; + } + + let numBytes = H.length * 4; + let hash = new Uint8Array(numBytes); + for (let i = 0; i < H.length; i += 1) { + hash[i * 4] = (H[i] >>> 24) & 0xff; + hash[i * 4 + 1] = (H[i] >>> 16) & 0xff; + hash[i * 4 + 2] = (H[i] >>> 8) & 0xff; + hash[i * 4 + 3] = H[i] & 0xff; + } + return hash; + /* jshint ignore:end */ + }; + + DashP2P.packers = Packers; + DashP2P.parsers = Parsers; + DashP2P.sizes = Sizes; + DashP2P.utils = Utils; + + //@ts-ignore + window.DashP2P = DashP2P; +})(globalThis.window || {}, DashP2P); +if ('object' === typeof module) { + module.exports = DashP2P; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..bfe45a0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,526 @@ + + + + + + Wallet - Digital Cash + + + + + + + + + + + + +
+ +
+
+
+
+ + + + + + +
output: private key as wif
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + AmountAddressTXIDIndex
+ + +
+ + + 200 dust + + + + + + + + + + + +
+ + + +
+ + Share: +
output: txid
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DenomPriorityHaveWantNeed
10.000 + + 0 + + 0
1.000 + + 0 + + 0
0.100 + + 0 + + 0
0.010 + + 0 + + 0
0.001 + + 0 + + 0
+ Collateral + + + 0 + + 0
+
+ + +
+
+
+
+
+
+
+ + Spent Addresses (0) + addresses with spent outputs + +
       
+
+ + +
+
+ +
+ + + + diff --git a/public/mvp.css b/public/mvp.css new file mode 100644 index 0000000..a44c50b --- /dev/null +++ b/public/mvp.css @@ -0,0 +1,538 @@ +/* MVP.css v1.15 - https://github.com/andybrewer/mvp */ + +:root { + --active-brightness: 0.85; + --border-radius: 5px; + --box-shadow: 2px 2px 10px; + --color-accent: #118bee15; + --color-bg: #fff; + --color-bg-secondary: #e9e9e9; + --color-link: #118bee; + --color-secondary: #920de9; + --color-secondary-accent: #920de90b; + --color-shadow: #f4f4f4; + --color-table: #118bee; + --color-text: #000; + --color-text-secondary: #999; + --color-scrollbar: #cacae8; + --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + --hover-brightness: 1.2; + --justify-important: center; + --justify-normal: left; + --line-height: 1.5; + --width-card: 285px; + --width-card-medium: 460px; + --width-card-wide: 800px; + --width-content: 1080px; +} + +@media (prefers-color-scheme: dark) { + :root[color-mode="user"] { + --color-accent: #0097fc4f; + --color-bg: #333; + --color-bg-secondary: #555; + --color-link: #0097fc; + --color-secondary: #e20de9; + --color-secondary-accent: #e20de94f; + --color-shadow: #bbbbbb20; + --color-table: #0097fc; + --color-text: #f7f7f7; + --color-text-secondary: #aaa; + } +} + +html { + scroll-behavior: smooth; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + +/* Layout */ +article aside { + background: var(--color-secondary-accent); + border-left: 4px solid var(--color-secondary); + padding: 0.01rem 0.8rem; +} + +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-family); + line-height: var(--line-height); + margin: 0; + overflow-x: hidden; + padding: 0; +} + +footer, +header, +main { + margin: 0 auto; + max-width: var(--width-content); + padding: 3rem 1rem; +} + +hr { + background-color: var(--color-bg-secondary); + border: none; + height: 1px; + margin: 4rem 0; + width: 100%; +} + +section { + display: flex; + flex-wrap: wrap; + justify-content: var(--justify-important); +} + +section img, +article img { + max-width: 100%; +} + +section pre { + overflow: auto; +} + +section aside { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow) var(--color-shadow); + margin: 1rem; + padding: 1.25rem; + width: var(--width-card); +} + +section aside:hover { + box-shadow: var(--box-shadow) var(--color-bg-secondary); +} + +[hidden] { + display: none; +} + +/* Headers */ +article header, +div header, +main header { + padding-top: 0; +} + +header { + text-align: var(--justify-important); +} + +header a b, +header a em, +header a i, +header a strong { + margin-left: 0.5rem; + margin-right: 0.5rem; +} + +header nav img { + margin: 1rem 0; +} + +section header { + padding-top: 0; + width: 100%; +} + +/* Nav */ +nav { + align-items: center; + display: flex; + font-weight: bold; + justify-content: space-between; + margin-bottom: 7rem; +} + +nav ul { + list-style: none; + padding: 0; +} + +nav ul li { + display: inline-block; + margin: 0 0.5rem; + position: relative; + text-align: left; +} + +/* Nav Dropdown */ +nav ul li:hover ul { + display: block; +} + +nav ul li ul { + background: var(--color-bg); + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow) var(--color-shadow); + display: none; + height: auto; + left: -2px; + padding: .5rem 1rem; + position: absolute; + top: 1.7rem; + white-space: nowrap; + width: auto; + z-index: 1; +} + +nav ul li ul::before { + /* fill gap above to make mousing over them easier */ + content: ""; + position: absolute; + left: 0; + right: 0; + top: -0.5rem; + height: 0.5rem; +} + +nav ul li ul li, +nav ul li ul li a { + display: block; +} + +/* Typography */ +code, +samp { + background-color: var(--color-accent); + border-radius: var(--border-radius); + color: var(--color-text); + display: inline-block; + margin: 0 0.1rem; + padding: 0 0.5rem; +} + +details { + margin: 1.3rem 0; +} + +details summary { + font-weight: bold; + cursor: pointer; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: var(--line-height); + text-wrap: balance; +} + +mark { + padding: 0.1rem; +} + +ol li, +ul li { + padding: 0.2rem 0; +} + +p { + margin: 0.75rem 0; + padding: 0; + width: 100%; +} + +pre { + margin: 1rem 0; + max-width: var(--width-card-wide); + padding: 1rem 0; +} + +pre code, +pre samp { + display: block; + max-width: var(--width-card-wide); + padding: 0.5rem 2rem; + white-space: pre-wrap; +} + +small { + color: var(--color-text-secondary); +} + +sup { + background-color: var(--color-secondary); + border-radius: var(--border-radius); + color: var(--color-bg); + font-size: xx-small; + font-weight: bold; + margin: 0.2rem; + padding: 0.2rem 0.3rem; + position: relative; + top: -2px; +} + +/* Links */ +a { + color: var(--color-link); + display: inline-block; + font-weight: bold; + text-decoration: underline; +} + +a:hover { + filter: brightness(var(--hover-brightness)); +} + +a:active { + filter: brightness(var(--active-brightness)); +} + +a b, +a em, +a i, +a strong, +button, +input[type="submit"] { + border-radius: var(--border-radius); + display: inline-block; + font-size: medium; + font-weight: bold; + line-height: var(--line-height); + margin: 0.5rem 0; + padding: 1rem 2rem; +} + +button, +input[type="submit"] { + font-family: var(--font-family); +} + +button:hover, +input[type="submit"]:hover { + cursor: pointer; + filter: brightness(var(--hover-brightness)); +} + +button:active, +input[type="submit"]:active { + filter: brightness(var(--active-brightness)); +} + +a b, +a strong, +button, +input[type="submit"] { + background-color: var(--color-link); + border: 2px solid var(--color-link); + color: var(--color-bg); +} + +a em, +a i { + border: 2px solid var(--color-link); + border-radius: var(--border-radius); + color: var(--color-link); + display: inline-block; + padding: 1rem 2rem; +} + +article aside a { + color: var(--color-secondary); +} + +/* Images */ +figure { + margin: 0; + padding: 0; +} + +figure img { + max-width: 100%; +} + +figure figcaption { + color: var(--color-text-secondary); +} + +/* Forms */ +button:disabled, +input:disabled { + background: var(--color-bg-secondary); + border-color: var(--color-bg-secondary); + color: var(--color-text-secondary); + cursor: not-allowed; +} + +button[disabled]:hover, +input[type="submit"][disabled]:hover { + filter: none; +} + +form { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow) var(--color-shadow); + display: block; + max-width: var(--width-card-wide); + min-width: var(--width-card); + padding: 1.5rem; + text-align: var(--justify-normal); +} + +form header { + margin: 1.5rem 0; + padding: 1.5rem 0; +} + +input, +label, +select, +textarea { + display: block; + font-size: inherit; + max-width: var(--width-card-wide); +} + +input[type="checkbox"], +input[type="radio"] { + display: inline-block; +} + +input[type="checkbox"]+label, +input[type="radio"]+label { + display: inline-block; + font-weight: normal; + position: relative; + top: 1px; +} + +input[type="range"] { + padding: 0.4rem 0; +} + +input, +select, +textarea { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + margin-bottom: 1rem; + padding: 0.4rem 0.8rem; +} + +input[type="text"], +input[type="password"] +textarea { + width: calc(100% - 1.6rem); +} + +input[readonly], +textarea[readonly] { + background-color: var(--color-bg-secondary); +} + +label { + font-weight: bold; + margin-bottom: 0.2rem; +} + +/* Popups */ +dialog { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow) var(--color-shadow); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 50%; + z-index: 999; +} + +/* Tables */ +table { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + border-spacing: 0; + display: inline-block; + max-width: 100%; + overflow-x: auto; + padding: 0; + white-space: nowrap; +} + +table td, +table th, +table tr { + padding: 0.4rem 0.8rem; + text-align: var(--justify-important); +} + +table thead { + background-color: var(--color-table); + border-collapse: collapse; + border-radius: var(--border-radius); + color: var(--color-bg); + margin: 0; + padding: 0; +} + +table thead tr:first-child th:first-child { + border-top-left-radius: var(--border-radius); +} + +table thead tr:first-child th:last-child { + border-top-right-radius: var(--border-radius); +} + +table thead th:first-child, +table tr td:first-child { + text-align: var(--justify-normal); +} + +table tr:nth-child(even) { + background-color: var(--color-accent); +} + +/* Quotes */ +blockquote { + display: block; + font-size: x-large; + line-height: var(--line-height); + margin: 1rem auto; + max-width: var(--width-card-medium); + padding: 1.5rem 1rem; + text-align: var(--justify-important); +} + +blockquote footer { + color: var(--color-text-secondary); + display: block; + font-size: small; + line-height: var(--line-height); + padding: 1.5rem 0; +} + +/* Scrollbars */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-scrollbar) transparent; +} + +*::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: var(--color-scrollbar); + border-radius: 10px; +} diff --git a/public/package-lock.json b/public/package-lock.json new file mode 100644 index 0000000..b9d3b57 --- /dev/null +++ b/public/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "wallet", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wallet", + "version": "0.1.0", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@dashincubator/secp256k1": "^1.7.1-5", + "dashhd": "^3.3.3", + "dashkeys": "^1.1.5", + "dashphrase": "^1.4.0", + "dashtx": "^0.19.1" + } + }, + "node_modules/@dashincubator/secp256k1": { + "version": "1.7.1-5", + "resolved": "https://registry.npmjs.org/@dashincubator/secp256k1/-/secp256k1-1.7.1-5.tgz", + "integrity": "sha512-3iA+RDZrJsRFPpWhlYkp3EdoFAlKjdqkNFiRwajMrzcpA/G/IBX0AnC1pwRLkTrM+tUowcyGrkJfT03U4ETZeg==", + "license": "MIT" + }, + "node_modules/dashhd": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dashhd/-/dashhd-3.3.3.tgz", + "integrity": "sha512-sbhLV8EtmebnlIdx/d1hcbnxdfka/0rcLx+UO5y44kZdu5tyJ5ftBFbhhIb38vd+T+Xfcwpeo0z+0ZDznRkfaw==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/dashkeys": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/dashkeys/-/dashkeys-1.1.5.tgz", + "integrity": "sha512-ohHoe3bNeWZPsVxmOrWFaqZrJP3GeuSk6AtAawUCx0ZXVkTraeDQyMMp7ewhy3OEHkvs5yy6woMAQnwhmooX8w==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/dashphrase": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/dashphrase/-/dashphrase-1.4.0.tgz", + "integrity": "sha512-o+LdiPkiYmg07kXBE+2bbcJzBmeTQVPn1GS2XlQeo8lene+KknAprSyiYi5XtqV/QVgNjvzOV7qBst2MijSPAA==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/dashtx": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/dashtx/-/dashtx-0.19.1.tgz", + "integrity": "sha512-mPiZQxw05pSrI3zFqLk610FKlcG4PHGdKHp5Eul52M4G/n33+4JgeCJBEupRW8wBaY4GzTdKl7tZVNmmOJoLMA==", + "license": "SEE LICENSE IN LICENSE", + "bin": { + "dashtx-inspect": "bin/inspect.js" + } + } + } +} diff --git a/public/package.json b/public/package.json new file mode 100644 index 0000000..fd25fc8 --- /dev/null +++ b/public/package.json @@ -0,0 +1,33 @@ +{ + "name": "wallet", + "version": "0.1.0", + "description": "Digital Cash Cryptocurrency DASH wallet", + "main": "index.html", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/dashhive/DashJoin.js.git" + }, + "keywords": [ + "Digital", + "Cash", + "Cryptocurrency", + "DASH", + "wallet" + ], + "author": "AJ ONeal (https://therootcompany.com/)", + "license": "SEE LICENSE IN LICENSE", + "bugs": { + "url": "https://github.com/dashhive/DashJoin.js/issues" + }, + "homepage": "https://github.com/dashhive/DashJoin.js#readme", + "dependencies": { + "@dashincubator/secp256k1": "^1.7.1-5", + "dashhd": "^3.3.3", + "dashkeys": "^1.1.5", + "dashphrase": "^1.4.0", + "dashtx": "^0.19.1" + } +} diff --git a/public/wallet-app.js b/public/wallet-app.js new file mode 100644 index 0000000..f66b164 --- /dev/null +++ b/public/wallet-app.js @@ -0,0 +1,1393 @@ +(function () { + 'use strict'; + + function $(sel, el) { + return (el || document).querySelector(sel); + } + + function $$(sel, el) { + return Array.from((el || document).querySelectorAll(sel)); + } + + let DashPhrase = window.DashPhrase; + let DashHd = window.DashHd; + let DashKeys = window.DashKeys; + let DashTx = window.DashTx; + let Secp256k1 = window.nobleSecp256k1; + + let DashJoin = window.DashJoin; + let DashP2P = window.DashP2P; + + let App = {}; + window.App = App; + + const SATS = 100000000; + const MIN_BALANCE = 100001 * 1000; + + let network = 'testnet'; + let rpcBasicAuth = `api:null`; + let rpcBaseUrl = `https://${rpcBasicAuth}@trpc.digitalcash.dev/`; + let rpcExplorer = 'https://trpc.digitalcash.dev/'; + + let addresses = []; + let changeAddrs = []; + let receiveAddrs = []; + let spentAddrs = []; + let spendableAddrs = []; + let deltasMap = {}; + let keysMap = {}; + let denomsMap = {}; + + let keyUtils = { + getPrivateKey: async function (txInput, i) { + // let address; + let address = txInput.address; + if (!address) { + return null; + // let pkhBytes = DashKeys.utils.hexToBytes(txInput.pubKeyHash); + // address = await DashKeys.pkhToAddr(pkhBytes, { version: network }); + } + + let yourKeyData = keysMap[address]; + + let privKeyBytes = await DashKeys.wifToPrivKey(yourKeyData.wif, { + version: network, + }); + return privKeyBytes; + }, + + getPublicKey: async function (txInput, i) { + let privKeyBytes = await keyUtils.getPrivateKey(txInput, i); + let pubKeyBytes = await keyUtils.toPublicKey(privKeyBytes); + + return pubKeyBytes; + }, + // TODO + // toPkh: DashKeys.pubkeyToPkh, + + sign: async function (privKeyBytes, txHashBytes) { + let sigOpts = { canonical: true, extraEntropy: true }; + let sigBytes = await Secp256k1.sign(txHashBytes, privKeyBytes, sigOpts); + + return sigBytes; + }, + + toPublicKey: async function (privKeyBytes) { + let isCompressed = true; + let pubKeyBytes = Secp256k1.getPublicKey(privKeyBytes, isCompressed); + + return pubKeyBytes; + }, + }; + let dashTx = DashTx.create(keyUtils); + console.log('DEBUG dashTx instance', dashTx); + + async function rpc(method, ...params) { + let result = await DashTx.utils.rpc(rpcBaseUrl, method, ...params); + return result; + } + + function dbGet(key, defVal) { + let dataJson = localStorage.getItem(key); + if (!dataJson) { + dataJson = JSON.stringify(defVal); + } + + let data; + try { + data = JSON.parse(dataJson); + } catch (e) { + data = defVal; + } + return data; + } + + function dbSet(key, val) { + if (val === null) { + localStorage.removeItem(key); + return; + } + + let dataJson = JSON.stringify(val); + localStorage.setItem(key, dataJson); + } + + function getAllUtxos(opts) { + let utxos = []; + let spendableAddrs = Object.keys(deltasMap); + for (let address of spendableAddrs) { + let info = deltasMap[address]; + info.balance = DashTx.sum(info.deltas); + + for (let coin of info.deltas) { + let addressInfo = keysMap[coin.address]; + Object.assign(coin, { + outputIndex: coin.index, + denom: DashJoin.getDenom(coin.satoshis), + publicKey: addressInfo.publicKey, + pubKeyHash: addressInfo.pubKeyHash, + }); + + if (coin.reserved > 0) { + continue; + } + + if (opts?.denom === false) { + if (coin.denom) { + continue; + } + } + + if (info.balance === 0) { + break; + } + utxos.push(coin); + } + } + return utxos; + } + + function removeElement(arr, val) { + let index = arr.indexOf(val); + if (index !== -1) { + arr.splice(index, 1); + } + } + + App.toggleAll = function (event) { + let checked = event.target.checked; + + let $table = event.target.closest('table'); + for (let $input of $$('[type=checkbox]', $table)) { + $input.checked = checked; + } + return true; + }; + + App.setMax = function (event) { + let totalSats = 0; + let addrs = Object.keys(deltasMap); + let fee = 100; + for (let addr of addrs) { + let info = deltasMap[addr]; + if (info.balance === 0) { + continue; + } + for (let delta of info.deltas) { + totalSats += delta.satoshis; + fee += 100; + } + } + + totalSats -= fee; + const FOUR_ZEROS = 10000; + let sigDigits = Math.floor(totalSats / FOUR_ZEROS); + let totalSigSats = sigDigits * FOUR_ZEROS; + let totalAmount = totalSigSats / SATS; + let dust = totalSats - totalSigSats; + dust += fee; + + $('[data-id=send-amount]').value = toFixed(totalAmount, 4); + //$('[data-id=send-dust]').value = dust; + $('[data-id=send-dust]').textContent = dust; + }; + + App.sendDash = async function (event) { + event.preventDefault(); + + let amountStr = $('[data-id=send-amount]').value || 0; + let amount = parseFloat(amountStr); + let satoshis = Math.round(amount * SATS); + // if (satoshis === 0) { + // satoshis = null; + // } + + let address = $('[data-id=send-address]').value; + if (!address) { + let err = new Error(`missing payment 'address' to send funds to`); + window.alert(err.message); + throw err; + } + + let balance = 0; + + /** @type {Array?} */ + let inputs = null; + /** @type {Array?} */ + let utxos = null; + + let $coins = $$('[data-name=coin]:checked'); + if ($coins.length) { + inputs = []; + for (let $coin of $coins) { + let [address, txid, indexStr] = $coin.value.split(','); + let index = parseInt(indexStr, 10); + let coin = selectCoin(address, txid, index); + Object.assign(coin, { outputIndex: coin.index }); + inputs.push(coin); + } + balance = DashTx.sum(inputs); + } else { + utxos = getAllUtxos(); + balance = DashTx.sum(utxos); + } + + if (balance < satoshis) { + // there's a helper for this in DashTx, including fee calc, + // but this is quick-n-dirty just to get an alert rather than + // checking error types and translating cthe error message + let available = balance / SATS; + let availableStr = toFixed(available, 4); + let err = new Error( + `requested to send '${amountStr}' when only '${availableStr}' is available`, + ); + window.alert(err.message); + throw err; + } + + console.log('DEBUG Payment Address:', address); + console.log('DEBUG Available coins:', utxos?.length || inputs?.length); + console.log('DEBUG Available balance:', balance); + console.log('DEBUG Amount:', amount); + + let output = { satoshis, address }; + let draft = await draftWalletTx(utxos, inputs, output); + + amount = output.satoshis / SATS; + $('[data-id=send-dust]').textContent = draft.tx.feeTarget; + $('[data-id=send-amount]').textContent = toFixed(amount, 8); + + let signedTx = await dashTx.legacy.finalizePresorted(draft.tx); + console.log('DEBUG signed tx', signedTx); + { + let amountStr = toFixed(amount, 4); + let confirmed = window.confirm(`Really send ${amountStr} to ${address}?`); + if (!confirmed) { + return; + } + } + void (await rpc('sendrawtransaction', signedTx.transaction)); + void (await commitWalletTx(signedTx)); + }; + + App.exportWif = async function (event) { + event.preventDefault(); + + let address = $('[data-id=export-address]').value; + let privKey = await keyUtils.getPrivateKey({ address }); + let wif = await DashKeys.privKeyToWif(privKey, { version: network }); + + $('[data-id=export-wif]').textContent = wif; + }; + + App.sendMemo = async function (event) { + event.preventDefault(); + + let msg; + + /** @type {String?} */ + let memo = $('[name=memo]').value || ''; + /** @type {String?} */ + let message = null; + let memoEncoding = $('[name=memo-encoding]:checked').value || 'hex'; + if (memoEncoding !== 'hex') { + message = memo; + memo = null; + } + let burn = 0; + msg = memo || message; + + let signedTx = await App._signMemo({ burn, memo, message }); + { + let confirmed = window.confirm( + `Really send '${memoEncoding}' memo '${msg}'?`, + ); + if (!confirmed) { + return; + } + } + let txid = await rpc('sendrawtransaction', signedTx.transaction); + $('[data-id=memo-txid]').textContent = txid; + let link = `${rpcExplorer}#?method=getrawtransaction¶ms=["${txid}",1]&submit`; + $('[data-id=memo-link]').textContent = link; + $('[data-id=memo-link]').href = link; + void (await commitWalletTx(signedTx)); + }; + + App._signMemo = async function ({ + burn = 0, + memo = null, + message = null, + collateral = 0, + }) { + let satoshis = burn; + satoshis += collateral; // temporary, for fee calculations only + + let memoOutput = { satoshis, memo, message }; + let outputs = [memoOutput]; + let changeOutput = { + address: '', + pubKeyHash: '', + satoshis: 0, + reserved: 0, + }; + + let utxos = getAllUtxos({ denom: false }); + let txInfo = DashTx.createLegacyTx(utxos, outputs, changeOutput); + if (txInfo.changeIndex >= 0) { + let realChange = txInfo.outputs[txInfo.changeIndex]; + realChange.address = changeAddrs.shift(); + let pkhBytes = await DashKeys.addrToPkh(realChange.address, { + version: network, + }); + realChange.pubKeyHash = DashKeys.utils.bytesToHex(pkhBytes); + } + memoOutput.satoshis -= collateral; // adjusting for fee + + let now = Date.now(); + for (let input of txInfo.inputs) { + input.reserved = now; + } + for (let output of txInfo.outputs) { + output.reserved = now; + } + + txInfo.inputs.sort(DashTx.sortInputs); + txInfo.outputs.sort(DashTx.sortOutputs); + + let signedTx = await dashTx.hashAndSignAll(txInfo); + console.log('memo signed', signedTx); + return signedTx; + }; + + App._signCollateral = async function (collateral = DashJoin.MIN_COLLATERAL) { + let signedTx = await App._signMemo({ + burn: 0, + memo: '', + message: null, + collateral: DashJoin.MIN_COLLATERAL, + }); + console.log('collat signed', signedTx); + let signedTxBytes = DashTx.utils.hexToBytes(signedTx.transaction); + return signedTxBytes; + }; + + async function draftWalletTx(utxos, inputs, output) { + let draftTx = dashTx.legacy.draftSingleOutput({ utxos, inputs, output }); + console.log('DEBUG draftTx', draftTx); + + let changeOutput = draftTx.outputs[1]; + if (changeOutput) { + let address = changeAddrs.shift(); + changeOutput.address = address; + } + + // See https://github.com/dashhive/DashTx.js/pull/77 + for (let input of draftTx.inputs) { + let addressInfo = keysMap[input.address]; + Object.assign(input, { + publicKey: addressInfo.publicKey, + pubKeyHash: addressInfo.pubKeyHash, + }); + } + for (let output of draftTx.outputs) { + if (output.pubKeyHash) { + continue; + } + if (output.memo) { + draftTx.feeTarget += output.satoshis; + output.satoshis = 0; + continue; + } + if (!output.address) { + if (typeof output.memo !== 'string') { + let err = new Error(`output is missing 'address' and 'pubKeyHash'`); + window.alert(err.message); + throw err; + } + } else { + let pkhBytes = await DashKeys.addrToPkh(output.address, { + version: network, + }); + Object.assign(output, { + pubKeyHash: DashKeys.utils.bytesToHex(pkhBytes), + }); + } + } + + draftTx.inputs.sort(DashTx.sortInputs); + draftTx.outputs.sort(DashTx.sortOutputs); + + return { + tx: draftTx, + change: changeOutput, + }; + } + + async function commitWalletTx(signedTx) { + let updatedAddrs = []; + for (let input of signedTx.inputs) { + updatedAddrs.push(input.address); + let knownSpent = spentAddrs.includes(input.address); + if (!knownSpent) { + spentAddrs.push(input.address); + } + removeElement(addresses, input.address); + removeElement(receiveAddrs, input.address); + removeElement(changeAddrs, input.address); + delete deltasMap[input.address]; + dbSet(input.address, null); + } + for (let output of signedTx.outputs) { + let isMemo = !output.address; + if (isMemo) { + continue; + } + updatedAddrs.push(output.address); + removeElement(addresses, output.address); + removeElement(receiveAddrs, output.address); + removeElement(changeAddrs, output.address); + delete deltasMap[output.address]; + dbSet(output.address, null); + } + await updateDeltas(updatedAddrs); + + let txid = await DashTx.getId(signedTx.transaction); + let now = Date.now(); + for (let input of signedTx.inputs) { + let coin = selectCoin(input.address, input.txid, input.outputIndex); + if (!coin) { + continue; + } + coin.reserved = now; // mark as spent-ish + } + for (let i = 0; i < signedTx.outputs.length; i += 1) { + let output = signedTx.outputs[i]; + let info = deltasMap[output.address]; + if (!info) { + info = { balance: 0, deltas: [] }; + deltasMap[output.address] = info; + } + let memCoin = selectCoin(output.address, txid, i); + if (!memCoin) { + memCoin = { + address: output.address, + satoshis: output.satoshis, + txid: txid, + index: i, + }; + info.deltas.push(memCoin); + } + } + + renderAddresses(); + renderCoins(); + } + + function renderAddresses() { + $('[data-id=spent-count]').textContent = spentAddrs.length; + $('[data-id=spent]').textContent = spentAddrs.join('\n'); + $('[data-id=receive-addresses]').textContent = receiveAddrs.join('\n'); + $('[data-id=change-addresses]').textContent = changeAddrs.join('\n'); + } + + function selectCoin(address, txid, index) { + let info = deltasMap[address]; + if (!info) { + let err = new Error(`coins for '${address}' disappeared`); + window.alert(err.message); + throw err; + } + for (let delta of info.deltas) { + if (delta.txid !== txid) { + continue; + } + if (delta.index !== index) { + continue; + } + return delta; + } + } + + async function init() { + let phrases = dbGet('wallet-phrases', []); + let primaryPhrase = phrases[0]; + if (!primaryPhrase) { + primaryPhrase = await DashPhrase.generate(128); + dbSet('wallet-phrases', [primaryPhrase]); + } + + let primarySalt = ''; + let primarySeedBytes = await DashPhrase.toSeed(primaryPhrase, primarySalt); + let primarySeedHex = DashKeys.utils.bytesToHex(primarySeedBytes); + $('[data-id=wallet-phrase]').value = primaryPhrase; + $('[data-id=wallet-seed]').innerText = primarySeedHex; + + let accountIndex = 0; + let coinType = 5; // DASH + let versions = DashHd.MAINNET; + if (network === `testnet`) { + coinType = 1; // testnet (for all coins) + versions = DashHd.TESTNET; + } + $('[data-id=wallet-account]').value = `m/44'/${coinType}'/${accountIndex}'`; + + let walletId; + let xprvReceiveKey; + let xprvChangeKey; + { + let walletKey = await DashHd.fromSeed(primarySeedBytes); + walletId = await DashHd.toId(walletKey); + + let accountKey = await walletKey.deriveAccount(0, { + purpose: 44, // BIP-44 (default) + coinType: coinType, + versions: versions, + }); + xprvReceiveKey = await accountKey.deriveXKey(DashHd.RECEIVE); + xprvChangeKey = await accountKey.deriveXKey(DashHd.CHANGE); + } + + let previousIndex = 0; + let last = previousIndex + 50; + for (let i = previousIndex; i < last; i += 1) { + let failed; + try { + let receiveKey = await xprvReceiveKey.deriveAddress(i); // xprvKey from step 2 + await addKey(receiveKey, DashHd.RECEIVE, i); + } catch (e) { + failed = true; + } + try { + let changeKey = await xprvChangeKey.deriveAddress(i); // xprvKey from step 2 + addKey(changeKey, DashHd.CHANGE, i); + } catch (e) { + failed = true; + } + if (failed) { + // to make up for skipping on error + last += 1; + } + } + + async function addKey(key, usage, i) { + let wif = await DashHd.toWif(key.privateKey, { version: 'testnet' }); + let address = await DashHd.toAddr(key.publicKey, { + version: 'testnet', + }); + let hdpath = `m/44'/${coinType}'/${accountIndex}'/${usage}`; // accountIndex from step 2 + + // TODO put this somewhere safe + // let descriptor = `pkh([${walletId}/${partialPath}/0/${index}])`; + + addresses.push(address); + if (usage === DashHd.RECEIVE) { + receiveAddrs.push(address); + } else if (usage === DashHd.CHANGE) { + changeAddrs.push(address); + } else { + let err = new Error(`unknown usage '${usage}'`); + window.alert(err.message); + throw err; + } + + // note: pkh is necessary here because 'getaddressutxos' is unreliable + // and neither 'getaddressdeltas' nor 'getaddressmempool' have 'script' + let pkhBytes = await DashKeys.pubkeyToPkh(key.publicKey); + keysMap[address] = { + walletId: walletId, + index: i, + hdpath: hdpath, // useful for multi-account indexing + address: address, // XrZJJfEKRNobcuwWKTD3bDu8ou7XSWPbc9 + wif: wif, // XCGKuZcKDjNhx8DaNKK4xwMMNzspaoToT6CafJAbBfQTi57buhLK + key: key, + publicKey: DashKeys.utils.bytesToHex(key.publicKey), + pubKeyHash: DashKeys.utils.bytesToHex(pkhBytes), + }; + } + + await updateDeltas(addresses); + renderAddresses(); + + $('body').removeAttribute('hidden'); + renderCoins(); + } + + let defaultCjSlots = [ + { + denom: 1000010000, + priority: 1, + have: 0, + want: 2, + need: 0, + }, + { + denom: 100001000, + priority: 10, + have: 0, + want: 10, + need: 0, + }, + { + denom: 10000100, + priority: 10, + have: 0, + want: 50, + need: 0, + }, + { + denom: 1000010, + priority: 1, + have: 0, + want: 20, + need: 0, + }, + { + denom: 100001, + priority: 0, + have: 0, + want: 5, + need: 0, + }, + // { + // denom: 10000, + // priority: 0, + // have: 0, + // want: 100, + // need: 0, + // collateral: true, + // }, + ]; + function getCashDrawer() { + let slots = dbGet('cash-drawer-control', []); + if (!slots.length) { + slots = defaultCjSlots.slice(0); + dbSet('cash-drawer-control', slots); + } + return slots; + } + App.syncCashDrawer = function (event) { + let isDirty = false; + + let slots = getCashDrawer(); + for (let slot of slots) { + let $row = $(`[data-denom="${slot.denom}"]`); + + let priorityStr = $('[name=priority]', $row).value; + if (priorityStr) { + let priority = parseFloat(priorityStr); + if (slot.priority !== priority) { + isDirty = true; + slot.priority = priority; + } + } + + let wantStr = $('[name=want]', $row).value; + if (wantStr) { + let want = parseFloat(wantStr); + if (slot.want !== want) { + isDirty = true; + slot.want = want; + } + } + } + + for (let slot of slots) { + let addrs = Object.keys(denomsMap[slot.denom]); + let have = addrs.length; + let need = slot.want - have; + need = Math.max(0, need); + if (need !== slot.need) { + isDirty = true; + slot.need = need; + } + } + + if (isDirty) { + dbSet('cash-drawer-control', slots); + } + + renderCashDrawer(); + return true; + }; + + function renderCashDrawer() { + let cjBalance = 0; + let slots = getCashDrawer(); + for (let slot of slots) { + let $row = $(`[data-denom="${slot.denom}"]`); + let addrs = Object.keys(denomsMap[slot.denom]); + let have = addrs.length; + slot.need = slot.want - have; + slot.need = Math.max(0, slot.need); + + let priority = $('[name=priority]', $row).value; + if (priority) { + if (priority !== slot.priority.toString()) { + $('[name=priority]', $row).value = slot.priority; + } + } + let want = $('[name=want]', $row).value; + if (want) { + if (want !== slot.want.toString()) { + $('[name=want]', $row).value = slot.want; + } + } + + $('[data-name=have]', $row).textContent = have; + $('[data-name=need]', $row).textContent = slot.need; + + for (let addr of addrs) { + cjBalance += denomsMap[slot.denom][addr].satoshis; + } + } + + let cjAmount = cjBalance / SATS; + $('[data-id=cj-balance]').textContent = toFixed(cjAmount, 8); + } + + App.denominateCoins = async function (event) { + event.preventDefault(); + + { + let addrs = Object.keys(deltasMap); + spendableAddrs.length = 0; + + for (let address of addrs) { + let info = deltasMap[address]; + if (info.balance === 0) { + continue; + } + spendableAddrs.push(address); + } + } + + let slots = dbGet('cash-drawer-control'); + + let priorityGroups = groupSlotsByPriorityAndAmount(slots); + + let priorities = Object.keys(priorityGroups); + priorities.sort(sortNumberDesc); + + for (let priority of priorities) { + let slots = priorityGroups[priority].slice(0); + slots.sort(sortSlotsByDenomDesc); + + for (;;) { + let slot = slots.shift(); + if (!slot) { + break; + } + let isNeeded = slot.need >= 1; + if (!isNeeded) { + continue; + } + + let utxos = getAllUtxos(); + let coins = DashTx._legacySelectOptimalUtxos(utxos, slot.denom); + let sats = DashTx.sum(coins); + if (sats < slot.denom) { + console.log(`not enough coins for ${slot.denom}`); + continue; + } + + let now = Date.now(); + for (let coin of coins) { + coin.reserved = now; + } + slot.need -= 1; + + // TODO DashTx. + console.log('Found coins to make denom', slot.denom, coins); + let roundRobiner = createRoundRobin(slots, slot); + // roundRobiner(); + + let address = receiveAddrs.shift(); + let satoshis = slot.denom; + let output = { satoshis, address }; + + void (await confirmAndBroadcastAndCompleteTx(coins, output).then( + roundRobiner, + )); + } + } + }; + + function createRoundRobin(slots, slot) { + return function () { + if (slot.need >= 1) { + // round-robin same priority + slots.push(slot); + } + }; + } + + async function confirmAndBroadcastAndCompleteTx(inputs, output) { + let utxos = null; + let draft = await draftWalletTx(utxos, inputs, output); + + let signedTx = await dashTx.legacy.finalizePresorted(draft.tx); + { + console.log('DEBUG confirming signed tx', signedTx); + let amount = output.satoshis / SATS; + let amountStr = toFixed(amount, 4); + let confirmed = window.confirm( + `Really send ${amountStr} to ${output.address}?`, + ); + if (!confirmed) { + return; + } + } + void (await rpc('sendrawtransaction', signedTx.transaction)); + void (await commitWalletTx(signedTx)); + } + + function groupSlotsByPriorityAndAmount(slots) { + let priorityGroups = {}; + for (let slot of slots) { + if (!priorityGroups[slot.priority]) { + priorityGroups[slot.priority] = []; + } + priorityGroups[slot.priority].push(slot); + } + + return priorityGroups; + } + + function sortNumberDesc(a, b) { + if (Number(a) < Number(b)) { + return 1; + } + if (Number(a) > Number(b)) { + return -1; + } + return 0; + } + + function sortSlotsByDenomDesc(a, b) { + if (a.denom < b.denom) { + return 1; + } + if (a.denom > b.denom) { + return -1; + } + return 0; + } + + function sortCoinsByDenomAndSatsDesc(a, b) { + if (a.denom < b.denom) { + return 1; + } + if (a.denom > b.denom) { + return -1; + } + + if (a.satoshis < b.satoshis) { + return 1; + } + if (a.satoshis > b.satoshis) { + return -1; + } + return 0; + } + + async function updateDeltas(addrs) { + for (let address of addrs) { + let info = dbGet(address); + let isSpent = info && info.deltas?.length && !info.balance; + if (!isSpent) { + continue; // used address (only check on manual sync) + } + + let knownSpent = spentAddrs.includes(address); + if (!knownSpent) { + spentAddrs.push(address); + } + removeElement(addrs, info.address); + removeElement(addresses, info.address); + removeElement(receiveAddrs, info.address); + removeElement(changeAddrs, info.address); + } + + let deltaLists = await Promise.all([ + // See + // - + // - + await rpc('getaddressdeltas', { addresses: addrs }), + // TODO check for proof of instantsend / acceptance + await rpc('getaddressmempool', { addresses: addrs }), + ]); + for (let deltaList of deltaLists) { + for (let delta of deltaList) { + console.log('DEBUG delta', delta); + removeElement(addrs, delta.address); + removeElement(addresses, delta.address); + removeElement(receiveAddrs, delta.address); + removeElement(changeAddrs, delta.address); + if (!deltasMap[delta.address]) { + deltasMap[delta.address] = { balance: 0, deltas: [] }; + } + deltasMap[delta.address].deltas.push(delta); + deltasMap[delta.address].balance += delta.satoshis; + } + } + } + + function renderCoins() { + let addrs = Object.keys(deltasMap); + for (let addr of addrs) { + let info = deltasMap[addr]; + dbSet(addr, info); + } + + let utxos = getAllUtxos(); + utxos.sort(sortCoinsByDenomAndSatsDesc); + + let elementStrs = []; + let template = $('[data-id=coin-row-tmpl]').content; + for (let utxo of utxos) { + let amount = utxo.satoshis / SATS; + Object.assign(utxo, { amount: amount }); + + let clone = document.importNode(template, true); + $('[data-name=coin]', clone).value = [ + utxo.address, + utxo.txid, + utxo.outputIndex, + ].join(','); + $('[data-name=address]', clone).textContent = utxo.address; + $('[data-name=amount]', clone).textContent = toFixed(utxo.amount, 4); + if (utxo.denom) { + $('[data-name=amount]', clone).style.fontStyle = 'italic'; + $('[data-name=amount]', clone).style.fontWeight = 'bold'; + } else { + // + } + $('[data-name=txid]', clone).textContent = utxo.txid; + $('[data-name=output-index]', clone).textContent = utxo.index; + + elementStrs.push(clone.firstElementChild.outerHTML); + //tableBody.appendChild(clone); + } + + let totalBalance = DashTx.sum(utxos); + let totalAmount = totalBalance / SATS; + $('[data-id=total-balance]').innerText = toFixed(totalAmount, 4); + + let tableBody = $('[data-id=coins-table]'); + tableBody.textContent = ''; + tableBody.insertAdjacentHTML('beforeend', elementStrs.join('\n')); + //$('[data-id=balances]').innerText = balances.join('\n'); + + if (totalBalance < MIN_BALANCE) { + setTimeout(function () { + window.alert( + 'Error: Balance too low. Please fill up at CN 💸 and/or DCG 💸.', + ); + }, 300); + } + } + + function siftDenoms() { + if (!denomsMap[DashJoin.MIN_COLLATERAL]) { + denomsMap[DashJoin.MIN_COLLATERAL] = {}; + } + for (let denom of DashJoin.DENOMS) { + if (!denomsMap[denom]) { + denomsMap[denom] = {}; + } + } + + let addrs = Object.keys(deltasMap); + for (let addr of addrs) { + let info = deltasMap[addr]; + if (info.balance === 0) { + continue; + } + + for (let coin of info.deltas) { + let denom = DashJoin.getDenom(coin.satoshis); + if (!denom) { + let halfCollateral = DashJoin.MIN_COLLATERAL / 2; + let fitsCollateral = + coin.satoshis >= halfCollateral && + coin.satoshis < DashJoin.DENOM_LOWEST; + if (fitsCollateral) { + denomsMap[DashJoin.MIN_COLLATERAL][coin.address] = coin; + } + continue; + } + + console.log('DEBUG denom', denom, coin); + denomsMap[denom][coin.address] = coin; + } + } + } + + /** + * @param {Number} f - the number + * @param {Number} d - how many digits to truncate (round down) at + */ + function toFixed(f, d) { + let order = Math.pow(10, d); + let t = f * order; + t = Math.floor(t); + f = t / order; + return f.toFixed(d); + } + + async function connectToPeer(evonode, height) { + if (App.peers[evonode.host]) { + return App.peers[evonode.host]; + } + + let p2p = DashP2P.create(); + + let p2pWebProxyUrl = 'wss://ubuntu-127.scratch-dev.digitalcash.dev/ws'; + let query = { + access_token: 'secret', + hostname: evonode.hostname, + port: evonode.port, + }; + let searchParams = new URLSearchParams(query); + let search = searchParams.toString(); + let wsc = new WebSocket(`${p2pWebProxyUrl}?${search}`); + + await p2p.initWebSocket(wsc, { + network: network, + hostname: evonode.hostname, + port: evonode.port, + start_height: height, + }); + + let senddsqBytes = DashJoin.packers.senddsq({ network: network }); + console.log('[REQ: %csenddsq%c]', 'color: $55daba', 'color: inherit'); + p2p.send(senddsqBytes); + + void p2p.createSubscriber(['dsq'], async function (evstream) { + let msg = await evstream.once('dsq'); + let dsq = DashJoin.parsers.dsq(msg.payload); + let dsqStatus = { + // node info + host: evonode.host, + hostname: evonode.hostname, + port: evonode.port, + // dsq status + denomination: dsq.denomination, + ready: dsq.ready, + timestamp: dsq.timestamp, + timestamp_unix: dsq.timestamp_unix, + }; + + App.coinjoinQueues[dsq.denomination][evonode.host] = dsqStatus; + console.log( + '%c[[DSQ]]', + 'color: #bada55', + dsqStatus.denomination, + dsqStatus.ready, + dsqStatus.host, + ); + }); + + function cleanup(err) { + console.error('WebSocket Error:', err); + delete App.peers[evonode.host]; + for (let denom of DashJoin.DENOMS) { + delete App.coinjoinQueues[denom][evonode.host]; + } + p2p.close(); + } + wsc.addEventListener('error', cleanup); + + App.peers[evonode.host] = p2p; + return App.peers[evonode.host]; + } + + // 0. 'dsq' broadcast puts a node in the local in-memory pool + // 1. 'dsa' requests to be allowed to join a session + // 2. 'dssu' accepts + // + 'dsq' marks ready (in either order) + // 3. 'dsi' signals desired coins in and out + // 4. 'dsf' accepts specific coins in and out + // 5. 'dss' sends signed inputs paired to trusted outputs + // 6. 'dssu' updates status + // + 'dsc' confirms the tx will broadcast soon + async function createCoinJoinSession( + evonode, // { host } + inputs, // [{address, txid, pubKeyHash, ...getPrivateKeyInfo }] + outputs, // [{ pubKeyHash, satoshis }] + collateralTxes, // (for dsa and dsi) any 2 txes having fees >=0.00010000 more than necessary + ) { + let p2p = App.peers[evonode.host]; + if (!p2p) { + throw new Error(`'${evonode.host}' is not connected`); + } + + let denomination = inputs[0].satoshis; + for (let input of inputs) { + if (input.satoshis !== denomination) { + let msg = `utxo.satoshis (${input.satoshis}) must match requested denomination ${denomination}`; + throw new Error(msg); + } + } + for (let output of outputs) { + if (!output.sateshis) { + output.satoshis = denomination; + continue; + } + if (output.satoshis !== denomination) { + let msg = `output.satoshis (${output.satoshis}) must match requested denomination ${denomination}`; + throw new Error(msg); + } + } + + // todo: pick a smaller size that matches the dss + let message = new Uint8Array(DashP2P.PAYLOAD_SIZE_MAX); + let evstream = p2p.createSubscriber(['dssu', 'dsq', 'dsf', 'dsc']); + + { + let collateralTx = collateralTxes.shift(); + let dsa = { + network, + message, + denomination, + collateralTx, + }; + let dsaBytes = DashJoin.packers.dsa(dsa); + console.log('DEBUG dsa, dsaBytes', dsa, dsaBytes); + p2p.send(dsaBytes); + for (;;) { + let msg = await evstream.once(); + + if (msg.command === 'dsq') { + let dsq = DashJoin.parsers.dsq(msg.payload); + if (dsq.denomination !== denomination) { + continue; + } + if (!dsq.ready) { + continue; + } + break; + } + + if (msg.command === 'dssu') { + let dssu = DashJoin.parsers.dssu(msg.payload); + if (dssu.state === 'ERROR') { + evstream.close(); + throw new Error(); + } + } + } + } + + let dsfTxRequest; + { + let collateralTx = collateralTxes.shift(); + let dsiBytes = DashJoin.packers.dsi({ + network, + message, + inputs, + collateralTx, + outputs, + }); + p2p.send(dsiBytes); + let msg = await evstream.once('dsf'); + console.log('DEBUG dsf %c[[MSG]]', 'color: blue', msg); + let dsfTxRequest = DashJoin.parsers.dsf(msg.payload); + console.log('DEBUG dsf', dsfTxRequest, inputs); + + makeSelectedInputsSignable(dsfTxRequest, inputs); + let txSigned = await dashTx.hashAndSignAll(dsfTxRequest); + + let signedInputs = []; + for (let input of txSigned.inputs) { + if (!input?.signature) { + continue; + } + signedInputs.push(input); + } + assertSelectedOutputs(dsfTxRequest, outputs, inputs.length); + + let dssBytes = DashJoin.packers.dss({ + network: network, + message: message, + inputs: signedInputs, + }); + p2p.send(dssBytes); + void (await evstream.once('dsc')); + } + + return dsfTxRequest; + } + + function makeSelectedInputsSignable(txRequest, inputs) { + // let selected = []; + + for (let input of inputs) { + if (!input.publicKey) { + let msg = `coin '${input.address}:${input.txid}:${input.outputIndex}' is missing 'input.publicKey'`; + throw new Error(msg); + } + for (let sighashInput of txRequest.inputs) { + if (sighashInput.txid !== input.txid) { + continue; + } + if (sighashInput.outputIndex !== input.outputIndex) { + continue; + } + + let sigHashType = DashTx.SIGHASH_ALL | DashTx.SIGHASH_ANYONECANPAY; //jshint ignore:line + + console.log(sighashInput); + console.log(input); + sighashInput.index = input.index; + sighashInput.address = input.address; + sighashInput.satoshis = input.satoshis; + sighashInput.pubKeyHash = input.pubKeyHash; + // sighashInput.script = input.script; + sighashInput.publicKey = input.publicKey; + sighashInput.sigHashType = sigHashType; + // sighashInputs.push({ + // txId: input.txId || input.txid, + // txid: input.txid || input.txId, + // outputIndex: input.outputIndex, + // pubKeyHash: input.pubKeyHash, + // sigHashType: input.sigHashType, + // }); + + // selected.push(input); + break; + } + } + + // return selected; + } + + function assertSelectedOutputs(txRequest, outputs, count) { + let _count = 0; + for (let output of outputs) { + for (let sighashOutput of txRequest.outputs) { + if (sighashOutput.pubKeyHash !== output.pubKeyHash) { + continue; + } + if (sighashOutput.satoshis !== output.satoshis) { + continue; + } + + _count += 1; + } + } + + if (count !== _count) { + let msg = `expected ${count} matching outputs but found found ${_count}`; + throw new Error(msg); + } + } + + App.peers = {}; + + async function main() { + if (network === `testnet`) { + let $testnets = $$('[data-network=testnet]'); + for (let $testnet of $testnets) { + $testnet.removeAttribute('hidden'); + } + } + + await init(); + + siftDenoms(); + renderCashDrawer(); + App.syncCashDrawer(); + + App._rawmnlist = await rpc('masternodelist'); + App._chaininfo = await rpc('getblockchaininfo'); + console.log(App._rawmnlist); + App._evonodes = DashJoin.utils._evonodeMapToList(App._rawmnlist); + // 35.166.18.166:19999 + let index = 5; + // let index = Math.floor(Math.random() * App._evonodes.length); + // App._evonode = App._evonodes[index]; + App._evonode = App._evonodes.at(index); + // App._evonode = { + // host: '35.166.18.166:19999', + // hostname: '35.166.18.166', + // port: '19999', + // }; + console.info('[info] chosen evonode:', index); + console.log(JSON.stringify(App._evonode, null, 2)); + + App.coinjoinQueues = { + 100001: {}, // 0.00100001 + 1000010: {}, // 0.01000010 + 10000100: {}, // 0.10000100 + 100001000: {}, // 1.00001000 + 1000010000: {}, // 10.00010000 + }; + + void (await connectToPeer(App._evonode, App._chaininfo.blocks)); + } + + App.createCoinJoinSession = async function () { + let $coins = $$('[data-name=coin]:checked'); + if (!$coins.length) { + let msg = + 'Use the Coins table to select which coins to include in the CoinJoin session.'; + window.alert(msg); + return; + } + + let inputs = []; + let outputs = []; + let denom; + for (let $coin of $coins) { + let [address, txid, indexStr] = $coin.value.split(','); + let index = parseInt(indexStr, 10); + let coin = selectCoin(address, txid, index); + coin.denom = DashJoin.getDenom(coin.satoshis); + if (!coin.denom) { + let msg = 'CoinJoin requires 10s-Denominated coins, shown in BOLD.'; + window.alert(msg); + return; + } + if (!denom) { + denom = coin.denom; + } + if (coin.denom !== denom) { + let msg = + 'CoinJoin requires all coins to be of the same denomination (ex: three 0.01, or two 1.0, but not a mix of the two).'; + window.alert(msg); + return; + } + Object.assign(coin, { outputIndex: coin.index }); + inputs.push(coin); + + let output = { + address: receiveAddrs.shift(), + satoshis: denom, + pubKeyHash: '', + }; + let pkhBytes = await DashKeys.addrToPkh(output.address, { + version: network, + }); + output.pubKeyHash = DashKeys.utils.bytesToHex(pkhBytes); + outputs.push(output); + } + + let collateralTxes = [ + await App._signCollateral(DashJoin.MIN_COLLATERAL), + await App._signCollateral(DashJoin.MIN_COLLATERAL), + ]; + + await createCoinJoinSession( + App._evonode, + inputs, // [{address, txid, pubKeyHash, ...getPrivateKeyInfo }] + outputs, // [{ pubKeyHash, satoshis }] + collateralTxes, // any tx with fee >= 0.00010000 + ); + }; + + main().catch(function (err) { + console.error(`Error in main:`, err); + }); +})(); diff --git a/run-demo.js b/run-demo.js new file mode 100644 index 0000000..710ac62 --- /dev/null +++ b/run-demo.js @@ -0,0 +1,36 @@ +'use strict'; + +let CJDemo = require('./demo.js'); + +// TODO move to +let ENV = require('./node-env.js'); +let rpcConfig = { + protocol: ENV.DASHD_RPC_PROTOCOL || 'http', // https for remote, http for local / private networking + user: ENV.DASHD_RPC_USER, + pass: ENV.DASHD_RPC_PASS || ENV.DASHD_RPC_PASSWORD, + host: ENV.DASHD_RPC_HOST || '127.0.0.1', + port: ENV.DASHD_RPC_PORT || '19898', // mainnet=9998, testnet=19998, regtest=19898 + timeout: 10 * 1000, // bump default from 5s to 10s for up to 10k addresses + onconnected: async function () { + console.info(`[info] rpc client connected ${rpcConfig.host}`); + }, +}; +if (ENV.DASHD_RPC_TIMEOUT) { + let rpcTimeoutSec = parseFloat(ENV.DASHD_RPC_TIMEOUT); + rpcConfig.timeout = rpcTimeoutSec * 1000; +} + +CJDemo.run(ENV, rpcConfig) + .then(function () { + console.info('Done'); + if (typeof process !== 'undefined') { + process.exit(0); + } + }) + .catch(function (err) { + console.error('Fail:'); + console.error(err.stack || err); + if (typeof process !== 'undefined') { + process.exit(1); + } + }); diff --git a/test.html b/test.html new file mode 100644 index 0000000..decac38 --- /dev/null +++ b/test.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + Check the console. + + diff --git a/tests/dsa.js b/tests/dsa.js index 745c412..233fb45 100644 --- a/tests/dsa.js +++ b/tests/dsa.js @@ -1,6 +1,6 @@ 'use strict'; -let Packer = require('../packer.js'); +let DashJoin = require('../public/dashjoin.js'); // TODO copy .utils.bytesToHex rather than depend on it let DashKeys = require('dashkeys'); @@ -30,7 +30,7 @@ let collateralTxHex = function test() { let expectedHex = `${regtest}${command}${payloadSize}${checksum}${denomMask}${collateralTxHex}`; let collateralTx = DashKeys.utils.hexToBytes(collateralTxHex); - let message = Packer.packAllow({ network, denomination, collateralTx }); + let message = DashJoin.packers.dsa({ network, denomination, collateralTx }); let messageHex = DashKeys.utils.bytesToHex(message); if (expectedHex.length !== messageHex.length) { let length = expectedHex.length / 2; @@ -39,13 +39,20 @@ function test() { ); } if (expectedHex !== messageHex) { + console.log(); + console.log(`EXPECTED: (${expectedHex.length})`); + console.log(expectedHex); + console.log(); + console.log(`ACTUAL: (${messageHex.length})`); + console.log(messageHex); + console.log(); throw new Error( 'bytes of dsa (allow / join request) messages do not match', ); } console.info( - `PASS: Packer.packAllow({ network, denomination, collateralTx }) matches`, + `PASS: DashJoin.packers.dsa({ network, denomination, collateralTx }) matches`, ); } diff --git a/tests/dsf.js b/tests/dsf.js index 2779413..4f671bb 100644 --- a/tests/dsf.js +++ b/tests/dsf.js @@ -1,10 +1,11 @@ 'use strict'; -let Assert = require('node:assert/strict'); +// let Assert = require('node:assert/strict'); let Fs = require('node:fs/promises'); let Path = require('node:path'); -let Parser = require('../parser.js'); +let DashP2P = require('../public/dashp2p.js'); +let DashJoin = require('../public/dashjoin.js'); // TODO copy .utils.bytesToHex rather than depend on it let DashKeys = require('dashkeys'); @@ -12,29 +13,29 @@ async function test() { let fixtureDsfBytes = await readFixtureHex('dsf'); // let fixtureDsqJson = require('../fixtures/dsf.json'); - let header = Parser.parseHeader(fixtureDsfBytes); + let header = DashP2P.parsers.header(fixtureDsfBytes); if (header.command !== 'dsf') { throw new Error( `sanity fail: should have loaded 'dsf' fixture, but got '${header.command}'`, ); } - let payload = fixtureDsfBytes.subarray(Parser.HEADER_SIZE); + let payload = fixtureDsfBytes.subarray(DashP2P.sizes.HEADER); // TODO verify // - a chosen subset of our offered inputs (e.g. we offer 9, but 3 are selected) // - that equally many of our offered outputs are selected (e.g. 3 = 3) // - that the satoshi values of our outputs match coin for coin - let dsf = Parser.parseDsf(payload); + let dsf = DashJoin.parsers.dsf(payload); console.log(new Date(), '[debug] dsf obj:', dsf); if ('string' !== typeof dsf.transaction_unsigned) { - throw new Error("'.transactionUnsigned' should exist as a hex string"); + throw new Error("'.transaction_unsigned' should exist as a hex string"); } let txLen = dsf.transaction_unsigned.length / 2; - let expectedLen = header.payloadSize - Parser.SESSION_ID_SIZE; + let expectedLen = header.payloadSize - DashJoin.sizes.SESSION_ID; console.log(new Date(), '[debug] dsf len:', txLen); if (txLen !== expectedLen) { throw new Error( - `expected '.transactionUnsigned' to represent ${header.payloadSize} bytes, but got ${txLen}`, + `expected '.transaction_unsigned' to represent ${header.payloadSize} bytes, but got ${txLen}`, ); } diff --git a/tests/dsq.js b/tests/dsq.js index 9cc9746..6e08730 100644 --- a/tests/dsq.js +++ b/tests/dsq.js @@ -4,12 +4,13 @@ let Assert = require('node:assert/strict'); let Fs = require('node:fs/promises'); let Path = require('node:path'); -let Parser = require('../parser.js'); +let DashP2P = require('../public/dashp2p.js'); +let DashJoin = require('../public/dashjoin.js'); // TODO copy .utils.bytesToHex rather than depend on it let DashKeys = require('dashkeys'); async function test() { - let totalSize = Parser.HEADER_SIZE + Parser.DSQ_SIZE; + let totalSize = DashP2P.sizes.HEADER + DashJoin.sizes.DSQ; let fixtureDsqBytes = await readFixtureHex('dsq'); let fixtureDsqJson = require('../fixtures/dsq.json'); @@ -18,21 +19,21 @@ async function test() { throw new Error(msg); } - let header = Parser.parseHeader(fixtureDsqBytes); + let header = DashP2P.parsers.header(fixtureDsqBytes); if (header.command !== 'dsq') { throw new Error('sanity fail: loaded incorrect fixture'); } - if (header.payloadSize !== Parser.DSQ_SIZE) { + if (header.payloadSize !== DashJoin.sizes.DSQ) { throw new Error('sanity fail: wrong payload size in header'); } - let payload = fixtureDsqBytes.subarray(Parser.HEADER_SIZE); - if (payload.length !== Parser.DSQ_SIZE) { + let payload = fixtureDsqBytes.subarray(DashP2P.sizes.HEADER); + if (payload.length !== DashJoin.sizes.DSQ) { throw new Error('sanity fail: payload has trailing bytes'); } - let dsq = Parser.parseDsq(payload); + let dsq = DashJoin.parsers.dsq(payload); // JSON-ify dsq.protxhash = DashKeys.utils.bytesToHex(dsq.protxhash_bytes); diff --git a/tests/ping-pong.js b/tests/ping-pong.js index 8befc48..67abcdc 100644 --- a/tests/ping-pong.js +++ b/tests/ping-pong.js @@ -116,7 +116,9 @@ function test() { let expectedStr = `${headerStr},${staticNonceStr}`; let messageStr = messageBytes.toString(); if (expectedStr !== messageStr) { - throw new Error('complete messages did not match'); + throw new Error( + `complete messages did not match: ${expectedStr} !== ${messageStr}`, + ); } }