123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523 |
- /* Import core modules. */
- const _ = require('lodash')
- const axios = require('axios')
- const debug = require('debug')('cashshuffle:client')
- const EventEmitter = require('events').EventEmitter
- const URL = require('url').URL
- const ShuffleRound = require('./ShuffleRound.js')
- const coinUtils = require('./coinUtils.js')
- /**
- * Delay (Execution)
- */
- const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
- /* Set delay (in milliseconds). */
- const DELAY_IN_MS = 5000
- /**
- * Shuffle Client (Class)
- */
- class ShuffleClient extends EventEmitter {
- constructor (clientOptions) {
- super()
- debug('Client options', clientOptions)
- /* Add client options to instance. */
- for (let oneOption in clientOptions) {
- this[oneOption] = clientOptions[oneOption]
- }
- /* Set maximum shuffle rounds. */
- this.maxShuffleRounds = this.maxShuffleRounds || 5
- /* Set coins. */
- this.coins = this.coins && this.coins.length ? this.coins : []
- /* Initialize coins to populate. */
- // NOTE: Will add the necessary properties to the coins, so the
- // shuffle libraries can use them.
- const coinsToPopulate = []
- /* Loop through ALL coins. */
- while (this.coins.length) {
- coinsToPopulate.push(this.coins.pop())
- }
- debug('Coins to populate', coinsToPopulate)
- /* Initialize hooks. */
- this.hooks = this.hooks || {}
- /* Validate change hooks. */
- if (!_.isFunction(this.hooks.change)) {
- debug(`A valid change generation hook was not provided!`)
- throw new Error('BAD_CHANGE_FN')
- }
- /* Validate shuffled hooks. */
- if (!_.isFunction(this.hooks.shuffled)) {
- debug(`A valid shuffle address generation hook was not provided!`)
- throw new Error('BAD_SHUFFLE_FN')
- }
- /* Add unshuffled coins. */
- this.addUnshuffledCoins(
- _.orderBy(coinsToPopulate, ['satoshis'], ['desc']))
- /* Initialize rounds. */
- this.rounds = []
- /* Initialize shuffled. */
- this.shuffled = []
- /* Initialize skipped. */
- this.skipped = []
- /* Initialize shuffling flag. */
- this.isShuffling = false
- // TODO: Add option to prioritize coin selection to either
- // minimize coins vs maximize shuffle speed.
- // this.shufflePriority = this.shufflePriority ? this.shufflePriority : 'amount';
- // this.statsIntervalId
- /* Initialize server statistics. */
- // NOTE: Server Stats fetched from the `/stats` endpoint.
- this.serverStats = {}
- /* Initialize server back-off (milliseconds). */
- // NOTE: If we every try and fail to reach the server, this number
- // will be populated with the amount of time the client will
- // wait in between reconnection attempts.
- this.serverBackoffMs = 0
- /* Set the shuffle fee (in satoshis). */
- this.shuffleFee = 270
- /**
- * Server Pool Amounts
- *
- * (estiamting fiat USD value @ $250.00)
- *
- * Minimum fee amount of 1,000 satoshis (~$0.0025)
- *
- * NOTE: Dust amount is 546 satoshis (~$0.001365)
- */
- this.serverPoolAmounts = [
- 1000000000, // 10.0 BCH ($2,500.00)
- 100000000, // 1.0 BCH ($250.00)
- 10000000, // 0.1 BCH ($25.00)
- 1000000, // 0.01 BCH ($2.50)
- 100000, // 0.001 BCH ($0.25)
- 10000 // 0.0001 BCH ($0.025)
- ]
- /* Initialize lost server connection flag. */
- // NOTE: This flag gets set to true if the server becomes unreachable
- // after we've started shuffling. We will use it in our
- // auto-reconnect logic.
- this.lostServerConnection = false
- /**
- * Check Statistics Interval
- *
- * This is the actual function that is called by setInterval every
- * 5 seconds. It also enforces server back-off for a persistent
- * lost connection.
- */
- this.checkStatsIntervalFn = async () => {
- this
- .updateServerStats()
- .then(async () => {
- debug('Updated server statistics.')
- /* Validate (auto) shuffle status. */
- if (!this.disableAutoShuffle || this.isShuffling) {
- /* Set shuffling flag. */
- this.isShuffling = true
- /* Validate server connection. */
- if (!this.lostServerConnection) {
- /* Start shuffling. */
- this.shuffle()
- }
- }
- /* Set lost server connection flag. */
- this.lostServerConnection = false
- })
- .catch(async (error) => {
- if (error) {
- return console.error(error) // eslint-disable-line no-console
- }
- /* Clear (interval) timer. */
- clearInterval(this.tingId)
- debug(`No server. Waiting ${Math.floor(this.serverBackoffMs / 1000)} seconds before reconnecting`)
- /* Delay execution. */
- await delay(this.serverBackoffMs)
- /* Set server statistics interval. */
- this.setServerStatsInterval()
- })
- }
- /**
- * Set Server Statistics Interval
- *
- * Re-fetch the server stats every 5 seconds, so we can make an
- * informed decision about which pools to join!
- */
- this.setServerStatsInterval = async () => {
- /* Set (delay) interval. */
- this.tingId = setInterval(this.checkStatsIntervalFn, DELAY_IN_MS)
- /* Check statistics interval. */
- this.checkStatsIntervalFn()
- }
- /* Set server statistics interval. */
- this.setServerStatsInterval()
- return this
- }
- /**
- * Skip Coin
- *
- * Skip a coin that is deemed unshufflable. This normally occurs when
- * UTXOs are at or below the dust threshold.
- */
- skipCoin (someCoin) {
- debug('Skipping coin', someCoin)
- /* Remove the coin from the pool of available coins. */
- const coinToSkip = _.remove(this.coins, someCoin)[0]
- /* Validate coin skip. */
- if (!coinToSkip) {
- throw new Error('coin_not_found')
- }
- /* Add coin to skipped. */
- this.skipped.push(coinToSkip)
- }
- /**
- * Start New Round
- *
- * Instantiate new round and add it to our round array. Set the event
- * listeners so we know when a round has ended and needs cleanup.
- */
- async startNewRound (someCoin, poolAmount, serverUri) {
- debug('Start new round',
- someCoin,
- poolAmount,
- serverUri
- )
- /* Remove the coin from the pool of available coins. */
- const coinToShuffle = _.remove(this.coins, someCoin)[0]
- /* Validate coin shuffle. */
- if (!coinToShuffle) {
- throw new Error('coin_not_found')
- }
- /* Initialize new shuffle round. */
- const newShuffleRound = new ShuffleRound({
- hooks: this.hooks,
- serverUri,
- coin: coinToShuffle,
- protocolVersion: this.protocolVersion,
- poolAmount,
- shuffleFee: this.shuffleFee
- })
- /* Handle when a shuffle round ends, successfully or not. */
- newShuffleRound.on('shuffle', this.cleanupCompletedRound.bind(this))
- /* Handle debugging messages. */
- // NOTE: Pass any debug messages from our shuffleround instances
- // to any listeners on the shuffleClass instance.
- newShuffleRound.on('debug', (someShuffleRoundMessage) => {
- this.emit('debug', someShuffleRoundMessage)
- })
- debug(
- 'Attempting to mix a',
- newShuffleRound.coin.satoshis,
- 'satoshi coin on',
- newShuffleRound.serverUri
- )
- /* Add new shuffle round. */
- this.rounds.push(newShuffleRound)
- }
- /**
- * Cleanup Completed Round
- */
- cleanupCompletedRound (shuffleRoundObject) {
- /* Validate shuffle object. */
- if (!shuffleRoundObject) {
- return
- }
- /* Remove the coin from the pool of available coins. */
- // TODO: Make this removal criteria more specific in case of
- // the insanely unlikely case where the server gives us the
- // same sessionid for two simultaneously open rounds.
- _.remove(this.rounds, { session: shuffleRoundObject.session })
- // If successful, add the clean coin to our shuffled coin
- // array and emit an event on the client so anyone watching
- // can take the appropriate action.
- if (!_.get(shuffleRoundObject, 'roundError.shortCode')) {
- // debug(`Adding ${shuffleRoundObject.shuffled}`);
- /* Put the newly shuffled coin in the "shuffled" array. */
- this.shuffled.push(shuffleRoundObject.shuffled)
- // Try and shuffle any change outputs
- //
- // ( HELP! Should this be configurable? Idfk )
- //
- // if (shuffleRoundObject.change && shuffleRoundObject.change.usedInShuffle && this.reshuffleChange) {
- // this.coins.push(shuffleRoundObject.change);
- // }
- /* Emit an event on the `ShuffleClient` class. */
- this.emit('shuffle', shuffleRoundObject)
- } else {
- // Handle cleanup for when our round ends due to a
- // protocol violation or an exception is thrown.
- //
- // This error property takes the form below
- //
- // {
- // shortCode: 'BAD_SIG',
- // errorObject: [ Error instance containing a stacktrace ],
- // isProtocolError: true,
- // isException: false,
- // accusedPlayer: [ Object containing player data ]
- // }
- //
- // TODO: Add logic for segregating coins that fail to shuffle
- // because they are deemed unshufflable by our peers or by
- // this library.
- debug(`Round failed with code ${shuffleRoundObject.roundError.shortCode}`)
- /* Push this coin back onto our stack of coins to be shuffled. */
- this.coins.push(shuffleRoundObject.coin)
- }
- }
- /**
- * Shuffle
- */
- async shuffle () {
- /* Validate shuffling status. */
- while (this.isShuffling) {
- // If we have a connection error, wait a while
- // then try again. Don't exit this loop.
- if (!this.serverBackoffMs) {
- if (this.coins.length && this.rounds.length < this.maxShuffleRounds) {
- // Here we can add logic that considers this client's
- // `maxShuffleRounds` param when selecting a coin to
- // shuffle.
- /* Set coin to shuffle. */
- const coinToShuffle = _.maxBy(this.coins, 'satoshis')
- /* Determine the pools this coin is eligible for. */
- const eligiblePools = _.partition(this.serverPoolAmounts, (onePoolAmount) => {
- /* Set amount after fee. */
- const amountAfterFee = coinToShuffle.satoshis - this.shuffleFee
- /* Validate eligibility. */
- return amountAfterFee >= onePoolAmount
- })[0]
- /* Validate eligibility. */
- // NOTE: If the value of the coin is less than the lowest
- // pool size on this server, deem it unshufflable.
- if (!eligiblePools.length) {
- this.skipCoin(coinToShuffle)
- this.emit('skipped', _.extend(coinToShuffle, {
- error: 'dust'
- }))
- continue
- }
- /* Get a list of the pools in which we have an active shuffle round. */
- const poolsInUse = _.map(_.filter(this.rounds, { done: false }), 'poolAmount')
- /* Remove any pool that we have an active round in. */
- const poolsWeCanUse = _.difference(eligiblePools, poolsInUse)
- /* Set eligible pools with players. */
- const eligiblePoolsWithPlayers = _.intersection(poolsWeCanUse, _.map(this.serverStats.pools, 'amount'))
- /* Set pool to use. */
- const poolToUse = _.max(eligiblePoolsWithPlayers.length ? eligiblePoolsWithPlayers : poolsWeCanUse)
- /* Validate pool to use. */
- if (!poolToUse) {
- continue
- }
- /* Validate server statistics. */
- if (!(this.serverStats && this.serverStats.shuffleWebSocketPort)) {
- debug('Cannot find shuffle server information')
- continue
- }
- /* Initialize server URI. */
- let serverUri = this.serverUri
- /* Validate server URI. */
- if (!serverUri) {
- /* Set parsed server statistics. */
- const serverStatsUriParsed = new URL(this.serverStatsUri)
- /* Update server statistics. */
- Object.assign(serverStatsUriParsed, {
- protocol: serverStatsUriParsed.protocol.replace(/^http(s?):/, 'ws$1:'),
- port: this.serverStats.shuffleWebSocketPort,
- pathname: ''
- })
- /* Set server URI. */
- serverUri = serverStatsUriParsed.toString()
- debug('Parsed Server URI', serverUri)
- }
- try {
- debug('Starting new round in:', serverUri)
- await this.startNewRound(coinToShuffle, poolToUse, serverUri)
- } catch (nope) {
- debug('Cannot shuffle coin:', nope)
- continue
- }
- } else {
- // debug('No coins to shuffle',
- // this.coins.length,
- // this.rounds.length,
- // this.maxShuffleRounds
- // )
- }
- } else {
- /* Set lost server connection flag. */
- this.lostServerConnection = true
- }
- /* Delay execution. */
- await delay(DELAY_IN_MS)
- }
- }
- /**
- * Stop
- */
- stop () {
- /* Validate shuffling status. */
- if (this.isShuffling) {
- /* Set shuffling flag. */
- this.isShuffling = false
- }
- }
- /**
- * Add Unshuffled Coins
- */
- addUnshuffledCoins (oneOrMoreCoins) {
- // This accepts single coin objects or arrays of them.
- // Always make sure we're processing them as arrays.
- oneOrMoreCoins = _.isArray(oneOrMoreCoins) ? oneOrMoreCoins : [ oneOrMoreCoins ]
- /* Loop through ALL coins. */
- for (let oneCoin of oneOrMoreCoins) {
- if (!oneCoin.satoshis || oneCoin.satoshis < 10000 + this.shuffleFee) {
- debug(`Skipping coin ${oneCoin} because it's just dust`)
- this.skipped.push(_.extend(oneCoin, { shuffled: false, error: 'size' }))
- }
- try {
- // Extend the coin object with `PublicKey` and `PrivateKey`
- // instances from the `bitcoinjs-fork` library. They will
- // be used for transaction signing and verification.
- const keypair = coinUtils.getKeypairFromWif(oneCoin.wif)
- _.extend(oneCoin, {
- publicKey: keypair.publicKey,
- privateKey: keypair.privateKey
- })
- this.coins.push(oneCoin)
- } catch (nope) {
- debug('Cannot populate coin for shuffling:', nope)
- continue
- }
- }
- }
- /**
- * Change Shuffle Server
- *
- * Change the Cashshuffle server this client will use, in future shuffle
- * rounds. All pending shuffle rounds will use whichever server it
- * started with.
- */
- async changeShuffleServer (someServerUri) {
- try {
- await this.updateServerStats(someServerUri)
- } catch (nope) {
- debug('Error changing servers:', nope)
- throw nope
- }
- return true
- }
- /**
- * Update Server Stats
- */
- async updateServerStats (newServerUri) {
- /* Initialize server stats. */
- let serverStats
- try {
- serverStats = await axios
- .get(newServerUri || this.serverStatsUri)
- } catch (nope) {
- // If we fail to reach the server, try again with
- // an increasing infrequency with the maximum time
- // between tries being 20 seconds and the minimum
- // being 5 seconds.
- this.serverBackoffMs = this.serverBackoffMs ? Math.floor((this.serverBackoffMs * 3) / 2) : DELAY_IN_MS
- this.serverBackoffMs = this.serverBackoffMs <= 20000 ? this.serverBackoffMs : 20000
- debug(nope.message)
- throw nope
- }
- /* Validate server statistics. */
- if (serverStats) {
- /* Update server statistics. */
- _.extend(this.serverStats, serverStats.data)
- /* Reset server back-off. */
- this.serverBackoffMs = 0
- }
- /* Return server statistics. */
- return serverStats
- }
- }
- module.exports = ShuffleClient
|