FusionClient.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. /* Import core modules. */
  2. const _ = require('lodash')
  3. const axios = require('axios')
  4. const debug = require('debug')('cashshuffle:client')
  5. const EventEmitter = require('events').EventEmitter
  6. const URL = require('url').URL
  7. const ShuffleRound = require('./ShuffleRound.js')
  8. const coinUtils = require('./coinUtils.js')
  9. /**
  10. * Delay (Execution)
  11. */
  12. const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
  13. /* Set delay (in milliseconds). */
  14. const DELAY_IN_MS = 5000
  15. /**
  16. * Shuffle Client (Class)
  17. */
  18. class ShuffleClient extends EventEmitter {
  19. constructor (clientOptions) {
  20. super()
  21. debug('Client options', clientOptions)
  22. /* Add client options to instance. */
  23. for (let oneOption in clientOptions) {
  24. this[oneOption] = clientOptions[oneOption]
  25. }
  26. /* Set maximum shuffle rounds. */
  27. this.maxShuffleRounds = this.maxShuffleRounds || 5
  28. /* Set coins. */
  29. this.coins = this.coins && this.coins.length ? this.coins : []
  30. /* Initialize coins to populate. */
  31. // NOTE: Will add the necessary properties to the coins, so the
  32. // shuffle libraries can use them.
  33. const coinsToPopulate = []
  34. /* Loop through ALL coins. */
  35. while (this.coins.length) {
  36. coinsToPopulate.push(this.coins.pop())
  37. }
  38. debug('Coins to populate', coinsToPopulate)
  39. /* Initialize hooks. */
  40. this.hooks = this.hooks || {}
  41. /* Validate change hooks. */
  42. if (!_.isFunction(this.hooks.change)) {
  43. debug(`A valid change generation hook was not provided!`)
  44. throw new Error('BAD_CHANGE_FN')
  45. }
  46. /* Validate shuffled hooks. */
  47. if (!_.isFunction(this.hooks.shuffled)) {
  48. debug(`A valid shuffle address generation hook was not provided!`)
  49. throw new Error('BAD_SHUFFLE_FN')
  50. }
  51. /* Add unshuffled coins. */
  52. this.addUnshuffledCoins(
  53. _.orderBy(coinsToPopulate, ['satoshis'], ['desc']))
  54. /* Initialize rounds. */
  55. this.rounds = []
  56. /* Initialize shuffled. */
  57. this.shuffled = []
  58. /* Initialize skipped. */
  59. this.skipped = []
  60. /* Initialize shuffling flag. */
  61. this.isShuffling = false
  62. // TODO: Add option to prioritize coin selection to either
  63. // minimize coins vs maximize shuffle speed.
  64. // this.shufflePriority = this.shufflePriority ? this.shufflePriority : 'amount';
  65. // this.statsIntervalId
  66. /* Initialize server statistics. */
  67. // NOTE: Server Stats fetched from the `/stats` endpoint.
  68. this.serverStats = {}
  69. /* Initialize server back-off (milliseconds). */
  70. // NOTE: If we every try and fail to reach the server, this number
  71. // will be populated with the amount of time the client will
  72. // wait in between reconnection attempts.
  73. this.serverBackoffMs = 0
  74. /* Set the shuffle fee (in satoshis). */
  75. this.shuffleFee = 270
  76. /**
  77. * Server Pool Amounts
  78. *
  79. * (estiamting fiat USD value @ $250.00)
  80. *
  81. * Minimum fee amount of 1,000 satoshis (~$0.0025)
  82. *
  83. * NOTE: Dust amount is 546 satoshis (~$0.001365)
  84. */
  85. this.serverPoolAmounts = [
  86. 1000000000, // 10.0 BCH ($2,500.00)
  87. 100000000, // 1.0 BCH ($250.00)
  88. 10000000, // 0.1 BCH ($25.00)
  89. 1000000, // 0.01 BCH ($2.50)
  90. 100000, // 0.001 BCH ($0.25)
  91. 10000 // 0.0001 BCH ($0.025)
  92. ]
  93. /* Initialize lost server connection flag. */
  94. // NOTE: This flag gets set to true if the server becomes unreachable
  95. // after we've started shuffling. We will use it in our
  96. // auto-reconnect logic.
  97. this.lostServerConnection = false
  98. /**
  99. * Check Statistics Interval
  100. *
  101. * This is the actual function that is called by setInterval every
  102. * 5 seconds. It also enforces server back-off for a persistent
  103. * lost connection.
  104. */
  105. this.checkStatsIntervalFn = async () => {
  106. this
  107. .updateServerStats()
  108. .then(async () => {
  109. debug('Updated server statistics.')
  110. /* Validate (auto) shuffle status. */
  111. if (!this.disableAutoShuffle || this.isShuffling) {
  112. /* Set shuffling flag. */
  113. this.isShuffling = true
  114. /* Validate server connection. */
  115. if (!this.lostServerConnection) {
  116. /* Start shuffling. */
  117. this.shuffle()
  118. }
  119. }
  120. /* Set lost server connection flag. */
  121. this.lostServerConnection = false
  122. })
  123. .catch(async (error) => {
  124. if (error) {
  125. return console.error(error) // eslint-disable-line no-console
  126. }
  127. /* Clear (interval) timer. */
  128. clearInterval(this.tingId)
  129. debug(`No server. Waiting ${Math.floor(this.serverBackoffMs / 1000)} seconds before reconnecting`)
  130. /* Delay execution. */
  131. await delay(this.serverBackoffMs)
  132. /* Set server statistics interval. */
  133. this.setServerStatsInterval()
  134. })
  135. }
  136. /**
  137. * Set Server Statistics Interval
  138. *
  139. * Re-fetch the server stats every 5 seconds, so we can make an
  140. * informed decision about which pools to join!
  141. */
  142. this.setServerStatsInterval = async () => {
  143. /* Set (delay) interval. */
  144. this.tingId = setInterval(this.checkStatsIntervalFn, DELAY_IN_MS)
  145. /* Check statistics interval. */
  146. this.checkStatsIntervalFn()
  147. }
  148. /* Set server statistics interval. */
  149. this.setServerStatsInterval()
  150. return this
  151. }
  152. /**
  153. * Skip Coin
  154. *
  155. * Skip a coin that is deemed unshufflable. This normally occurs when
  156. * UTXOs are at or below the dust threshold.
  157. */
  158. skipCoin (someCoin) {
  159. debug('Skipping coin', someCoin)
  160. /* Remove the coin from the pool of available coins. */
  161. const coinToSkip = _.remove(this.coins, someCoin)[0]
  162. /* Validate coin skip. */
  163. if (!coinToSkip) {
  164. throw new Error('coin_not_found')
  165. }
  166. /* Add coin to skipped. */
  167. this.skipped.push(coinToSkip)
  168. }
  169. /**
  170. * Start New Round
  171. *
  172. * Instantiate new round and add it to our round array. Set the event
  173. * listeners so we know when a round has ended and needs cleanup.
  174. */
  175. async startNewRound (someCoin, poolAmount, serverUri) {
  176. debug('Start new round',
  177. someCoin,
  178. poolAmount,
  179. serverUri
  180. )
  181. /* Remove the coin from the pool of available coins. */
  182. const coinToShuffle = _.remove(this.coins, someCoin)[0]
  183. /* Validate coin shuffle. */
  184. if (!coinToShuffle) {
  185. throw new Error('coin_not_found')
  186. }
  187. /* Initialize new shuffle round. */
  188. const newShuffleRound = new ShuffleRound({
  189. hooks: this.hooks,
  190. serverUri,
  191. coin: coinToShuffle,
  192. protocolVersion: this.protocolVersion,
  193. poolAmount,
  194. shuffleFee: this.shuffleFee
  195. })
  196. /* Handle when a shuffle round ends, successfully or not. */
  197. newShuffleRound.on('shuffle', this.cleanupCompletedRound.bind(this))
  198. /* Handle debugging messages. */
  199. // NOTE: Pass any debug messages from our shuffleround instances
  200. // to any listeners on the shuffleClass instance.
  201. newShuffleRound.on('debug', (someShuffleRoundMessage) => {
  202. this.emit('debug', someShuffleRoundMessage)
  203. })
  204. debug(
  205. 'Attempting to mix a',
  206. newShuffleRound.coin.satoshis,
  207. 'satoshi coin on',
  208. newShuffleRound.serverUri
  209. )
  210. /* Add new shuffle round. */
  211. this.rounds.push(newShuffleRound)
  212. }
  213. /**
  214. * Cleanup Completed Round
  215. */
  216. cleanupCompletedRound (shuffleRoundObject) {
  217. /* Validate shuffle object. */
  218. if (!shuffleRoundObject) {
  219. return
  220. }
  221. /* Remove the coin from the pool of available coins. */
  222. // TODO: Make this removal criteria more specific in case of
  223. // the insanely unlikely case where the server gives us the
  224. // same sessionid for two simultaneously open rounds.
  225. _.remove(this.rounds, { session: shuffleRoundObject.session })
  226. // If successful, add the clean coin to our shuffled coin
  227. // array and emit an event on the client so anyone watching
  228. // can take the appropriate action.
  229. if (!_.get(shuffleRoundObject, 'roundError.shortCode')) {
  230. // debug(`Adding ${shuffleRoundObject.shuffled}`);
  231. /* Put the newly shuffled coin in the "shuffled" array. */
  232. this.shuffled.push(shuffleRoundObject.shuffled)
  233. // Try and shuffle any change outputs
  234. //
  235. // ( HELP! Should this be configurable? Idfk )
  236. //
  237. // if (shuffleRoundObject.change && shuffleRoundObject.change.usedInShuffle && this.reshuffleChange) {
  238. // this.coins.push(shuffleRoundObject.change);
  239. // }
  240. /* Emit an event on the `ShuffleClient` class. */
  241. this.emit('shuffle', shuffleRoundObject)
  242. } else {
  243. // Handle cleanup for when our round ends due to a
  244. // protocol violation or an exception is thrown.
  245. //
  246. // This error property takes the form below
  247. //
  248. // {
  249. // shortCode: 'BAD_SIG',
  250. // errorObject: [ Error instance containing a stacktrace ],
  251. // isProtocolError: true,
  252. // isException: false,
  253. // accusedPlayer: [ Object containing player data ]
  254. // }
  255. //
  256. // TODO: Add logic for segregating coins that fail to shuffle
  257. // because they are deemed unshufflable by our peers or by
  258. // this library.
  259. debug(`Round failed with code ${shuffleRoundObject.roundError.shortCode}`)
  260. /* Push this coin back onto our stack of coins to be shuffled. */
  261. this.coins.push(shuffleRoundObject.coin)
  262. }
  263. }
  264. /**
  265. * Shuffle
  266. */
  267. async shuffle () {
  268. /* Validate shuffling status. */
  269. while (this.isShuffling) {
  270. // If we have a connection error, wait a while
  271. // then try again. Don't exit this loop.
  272. if (!this.serverBackoffMs) {
  273. if (this.coins.length && this.rounds.length < this.maxShuffleRounds) {
  274. // Here we can add logic that considers this client's
  275. // `maxShuffleRounds` param when selecting a coin to
  276. // shuffle.
  277. /* Set coin to shuffle. */
  278. const coinToShuffle = _.maxBy(this.coins, 'satoshis')
  279. /* Determine the pools this coin is eligible for. */
  280. const eligiblePools = _.partition(this.serverPoolAmounts, (onePoolAmount) => {
  281. /* Set amount after fee. */
  282. const amountAfterFee = coinToShuffle.satoshis - this.shuffleFee
  283. /* Validate eligibility. */
  284. return amountAfterFee >= onePoolAmount
  285. })[0]
  286. /* Validate eligibility. */
  287. // NOTE: If the value of the coin is less than the lowest
  288. // pool size on this server, deem it unshufflable.
  289. if (!eligiblePools.length) {
  290. this.skipCoin(coinToShuffle)
  291. this.emit('skipped', _.extend(coinToShuffle, {
  292. error: 'dust'
  293. }))
  294. continue
  295. }
  296. /* Get a list of the pools in which we have an active shuffle round. */
  297. const poolsInUse = _.map(_.filter(this.rounds, { done: false }), 'poolAmount')
  298. /* Remove any pool that we have an active round in. */
  299. const poolsWeCanUse = _.difference(eligiblePools, poolsInUse)
  300. /* Set eligible pools with players. */
  301. const eligiblePoolsWithPlayers = _.intersection(poolsWeCanUse, _.map(this.serverStats.pools, 'amount'))
  302. /* Set pool to use. */
  303. const poolToUse = _.max(eligiblePoolsWithPlayers.length ? eligiblePoolsWithPlayers : poolsWeCanUse)
  304. /* Validate pool to use. */
  305. if (!poolToUse) {
  306. continue
  307. }
  308. /* Validate server statistics. */
  309. if (!(this.serverStats && this.serverStats.shuffleWebSocketPort)) {
  310. debug('Cannot find shuffle server information')
  311. continue
  312. }
  313. /* Initialize server URI. */
  314. let serverUri = this.serverUri
  315. /* Validate server URI. */
  316. if (!serverUri) {
  317. /* Set parsed server statistics. */
  318. const serverStatsUriParsed = new URL(this.serverStatsUri)
  319. /* Update server statistics. */
  320. Object.assign(serverStatsUriParsed, {
  321. protocol: serverStatsUriParsed.protocol.replace(/^http(s?):/, 'ws$1:'),
  322. port: this.serverStats.shuffleWebSocketPort,
  323. pathname: ''
  324. })
  325. /* Set server URI. */
  326. serverUri = serverStatsUriParsed.toString()
  327. debug('Parsed Server URI', serverUri)
  328. }
  329. try {
  330. debug('Starting new round in:', serverUri)
  331. await this.startNewRound(coinToShuffle, poolToUse, serverUri)
  332. } catch (nope) {
  333. debug('Cannot shuffle coin:', nope)
  334. continue
  335. }
  336. } else {
  337. // debug('No coins to shuffle',
  338. // this.coins.length,
  339. // this.rounds.length,
  340. // this.maxShuffleRounds
  341. // )
  342. }
  343. } else {
  344. /* Set lost server connection flag. */
  345. this.lostServerConnection = true
  346. }
  347. /* Delay execution. */
  348. await delay(DELAY_IN_MS)
  349. }
  350. }
  351. /**
  352. * Stop
  353. */
  354. stop () {
  355. /* Validate shuffling status. */
  356. if (this.isShuffling) {
  357. /* Set shuffling flag. */
  358. this.isShuffling = false
  359. }
  360. }
  361. /**
  362. * Add Unshuffled Coins
  363. */
  364. addUnshuffledCoins (oneOrMoreCoins) {
  365. // This accepts single coin objects or arrays of them.
  366. // Always make sure we're processing them as arrays.
  367. oneOrMoreCoins = _.isArray(oneOrMoreCoins) ? oneOrMoreCoins : [ oneOrMoreCoins ]
  368. /* Loop through ALL coins. */
  369. for (let oneCoin of oneOrMoreCoins) {
  370. if (!oneCoin.satoshis || oneCoin.satoshis < 10000 + this.shuffleFee) {
  371. debug(`Skipping coin ${oneCoin} because it's just dust`)
  372. this.skipped.push(_.extend(oneCoin, { shuffled: false, error: 'size' }))
  373. }
  374. try {
  375. // Extend the coin object with `PublicKey` and `PrivateKey`
  376. // instances from the `bitcoinjs-fork` library. They will
  377. // be used for transaction signing and verification.
  378. const keypair = coinUtils.getKeypairFromWif(oneCoin.wif)
  379. _.extend(oneCoin, {
  380. publicKey: keypair.publicKey,
  381. privateKey: keypair.privateKey
  382. })
  383. this.coins.push(oneCoin)
  384. } catch (nope) {
  385. debug('Cannot populate coin for shuffling:', nope)
  386. continue
  387. }
  388. }
  389. }
  390. /**
  391. * Change Shuffle Server
  392. *
  393. * Change the Cashshuffle server this client will use, in future shuffle
  394. * rounds. All pending shuffle rounds will use whichever server it
  395. * started with.
  396. */
  397. async changeShuffleServer (someServerUri) {
  398. try {
  399. await this.updateServerStats(someServerUri)
  400. } catch (nope) {
  401. debug('Error changing servers:', nope)
  402. throw nope
  403. }
  404. return true
  405. }
  406. /**
  407. * Update Server Stats
  408. */
  409. async updateServerStats (newServerUri) {
  410. /* Initialize server stats. */
  411. let serverStats
  412. try {
  413. serverStats = await axios
  414. .get(newServerUri || this.serverStatsUri)
  415. } catch (nope) {
  416. // If we fail to reach the server, try again with
  417. // an increasing infrequency with the maximum time
  418. // between tries being 20 seconds and the minimum
  419. // being 5 seconds.
  420. this.serverBackoffMs = this.serverBackoffMs ? Math.floor((this.serverBackoffMs * 3) / 2) : DELAY_IN_MS
  421. this.serverBackoffMs = this.serverBackoffMs <= 20000 ? this.serverBackoffMs : 20000
  422. debug(nope.message)
  423. throw nope
  424. }
  425. /* Validate server statistics. */
  426. if (serverStats) {
  427. /* Update server statistics. */
  428. _.extend(this.serverStats, serverStats.data)
  429. /* Reset server back-off. */
  430. this.serverBackoffMs = 0
  431. }
  432. /* Return server statistics. */
  433. return serverStats
  434. }
  435. }
  436. module.exports = ShuffleClient