feat: add CrowdNode.stakeMinimum (Đ0.5), as per terms
[crowdnode.js/.git] / lib / crowdnode.js
1 "use strict";
2
3 let request = require("./request.js");
4
5 let CrowdNode = module.exports;
6
7 const DUFFS = 100000000;
8
9 let Dash = require("./dash.js");
10 let Dashcore = require("@dashevo/dashcore-lib");
11 let Insight = require("./insight.js");
12 let Ws = require("./ws.js");
13
14 CrowdNode._insightBaseUrl = "";
15 // TODO don't require these shims
16 CrowdNode._insightApi = Insight.create({ baseUrl: "" });
17 CrowdNode._dashApi = Dash.create({ insightApi: CrowdNode._insightApi });
18
19 CrowdNode.main = {
20   baseUrl: "https://app.crowdnode.io",
21   hotwallet: "",
22 };
23
24 CrowdNode.test = {
25   baseUrl: "https://test.crowdnode.io",
26   hotwallet: "",
27 };
28
29 CrowdNode._baseUrl = CrowdNode.main.baseUrl;
30
31 CrowdNode.offset = 20000;
32 CrowdNode.duffs = 100000000;
33 CrowdNode.depositMinimum = 10000;
34 CrowdNode.stakeMinimum = toDuff(0.5);
35
36 /**
37  * @type {Record<String, Number>}
38  */
39 CrowdNode.requests = {
40   acceptTerms: 65536,
41   offset: 20000,
42   signupForApi: 131072,
43   toggleInstantPayout: 4096,
44   withdrawMin: 1,
45   withdrawMax: 1000,
46 };
47
48 /**
49  * @type {Record<Number, String>}
50  */
51 CrowdNode._responses = {
52   2: "PleaseAcceptTerms",
53   4: "WelcomeToCrowdNodeBlockChainAPI",
54   8: "DepositReceived",
55   16: "WithdrawalQueued",
56   32: "WithdrawalFailed", // Most likely too small amount requested for withdrawal.
57   64: "AutoWithdrawalEnabled",
58   128: "AutoWithdrawalDisabled",
59 };
60 /**
61  * @type {Record<String, Number>}
62  */
63 CrowdNode.responses = {
64   PleaseAcceptTerms: 2,
65   WelcomeToCrowdNodeBlockChainAPI: 4,
66   DepositReceived: 8,
67   WithdrawalQueued: 16,
68   WithdrawalFailed: 32,
69   AutoWithdrawalEnabled: 64,
70   AutoWithdrawalDisabled: 128,
71 };
72
73 /**
74  * @param {Object} opts
75  * @param {String} opts.baseUrl
76  * @param {String} opts.insightBaseUrl
77  */
78 CrowdNode.init = async function ({ baseUrl, insightBaseUrl }) {
79   // TODO use API
80   // See https://github.com/dashhive/crowdnode.js/issues/3
81
82   CrowdNode._baseUrl = baseUrl;
83
84   //hotwallet in Mainnet is XjbaGWaGnvEtuQAUoBgDxJWe8ZNv45upG2
85   CrowdNode.main.hotwallet = await request({
86     // TODO https://app.crowdnode.io/odata/apifundings/HotWallet
87     url: "https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide",
88   }).then(createAddrParser("hotwallet in Main"));
89
90   //hotwallet in Test is yMY5bqWcknGy5xYBHSsh2xvHZiJsRucjuy
91   CrowdNode.test.hotwallet = await request({
92     // TODO https://test.crowdnode.io/odata/apifundings/HotWallet
93     url: "https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide",
94   }).then(createAddrParser("hotwallet in Test"));
95
96   CrowdNode._insightBaseUrl = insightBaseUrl;
97   CrowdNode._insightApi = Insight.create({
98     baseUrl: insightBaseUrl,
99   });
100   CrowdNode._dashApi = Dash.create({ insightApi: CrowdNode._insightApi });
101 };
102
103 /**
104  * @param {String} signupAddr
105  * @param {String} hotwallet
106  */
107 CrowdNode.status = async function (signupAddr, hotwallet) {
108   let maxPages = 10;
109   let data = await CrowdNode._insightApi.getTxs(signupAddr, maxPages);
110   let status = {
111     signup: 0,
112     accept: 0,
113     deposit: 0,
114   };
115
116   data.txs.forEach(function (tx) {
117     // all inputs (utxos) must come from hotwallet
118     let fromHotwallet = tx.vin.every(function (vin) {
119       return vin.addr === hotwallet;
120     });
121     if (!fromHotwallet) {
122       return;
123     }
124
125     // must have one output matching the "welcome" value to the signupAddr
126     tx.vout.forEach(function (vout) {
127       if (vout.scriptPubKey.addresses[0] !== signupAddr) {
128         return;
129       }
130       let amount = Math.round(parseFloat(vout.value) * DUFFS);
131       let msg = amount - CrowdNode.offset;
132
133       if (CrowdNode.responses.DepositReceived === msg) {
134         status.deposit = tx.time;
135         status.signup = status.signup || 1;
136         status.accept = status.accept || 1;
137         return;
138       }
139
140       if (CrowdNode.responses.WelcomeToCrowdNodeBlockChainAPI === msg) {
141         status.signup = status.signup || 1;
142         status.accept = tx.time || 1;
143         return;
144       }
145
146       if (CrowdNode.responses.PleaseAcceptTerms === msg) {
147         status.signup = tx.time;
148         return;
149       }
150     });
151   });
152
153   if (!status.signup) {
154     return null;
155   }
156   return status;
157 };
158
159 /**
160  * @param {String} wif
161  * @param {String} hotwallet
162  */
163 CrowdNode.signup = async function (wif, hotwallet) {
164   // Send Request Message
165   let pk = new Dashcore.PrivateKey(wif);
166   let msg = CrowdNode.offset + CrowdNode.requests.signupForApi;
167   let changeAddr = pk.toPublicKey().toAddress().toString();
168   let tx = await CrowdNode._dashApi.createPayment(
169     wif,
170     hotwallet,
171     msg,
172     changeAddr,
173   );
174   await CrowdNode._insightApi.instantSend(tx.serialize());
175
176   let reply = CrowdNode.offset + CrowdNode.responses.PleaseAcceptTerms;
177   return await Ws.waitForVout(CrowdNode._insightBaseUrl, changeAddr, reply);
178 };
179
180 /**
181  * @param {String} wif
182  * @param {String} hotwallet
183  */
184 CrowdNode.accept = async function (wif, hotwallet) {
185   // Send Request Message
186   let pk = new Dashcore.PrivateKey(wif);
187   let msg = CrowdNode.offset + CrowdNode.requests.acceptTerms;
188   let changeAddr = pk.toPublicKey().toAddress().toString();
189   let tx = await CrowdNode._dashApi.createPayment(
190     wif,
191     hotwallet,
192     msg,
193     changeAddr,
194   );
195   await CrowdNode._insightApi.instantSend(tx.serialize());
196
197   let reply =
198     CrowdNode.offset + CrowdNode.responses.WelcomeToCrowdNodeBlockChainAPI;
199   return await Ws.waitForVout(CrowdNode._insightBaseUrl, changeAddr, reply);
200 };
201
202 /**
203  * @param {String} wif
204  * @param {String} hotwallet
205  * @param {Number} amount - Duffs (1/100000000 Dash)
206  */
207 CrowdNode.deposit = async function (wif, hotwallet, amount) {
208   // Send Request Message
209   let pk = new Dashcore.PrivateKey(wif);
210   let changeAddr = pk.toPublicKey().toAddress().toString();
211
212   // TODO reserve a balance
213   let tx;
214   if (amount) {
215     tx = await CrowdNode._dashApi.createPayment(
216       wif,
217       hotwallet,
218       amount,
219       changeAddr,
220     );
221   } else {
222     tx = await CrowdNode._dashApi.createBalanceTransfer(wif, hotwallet);
223   }
224   await CrowdNode._insightApi.instantSend(tx.serialize());
225
226   let reply = CrowdNode.offset + CrowdNode.responses.DepositReceived;
227   return await Ws.waitForVout(CrowdNode._insightBaseUrl, changeAddr, reply);
228 };
229
230 /**
231  * @param {String} wif
232  * @param {String} hotwallet
233  * @param {Number} permil - 1/1000 (1/10 of a percent) 500 permille = 50.0 percent
234  */
235 CrowdNode.withdrawal = async function (wif, hotwallet, permil) {
236   let valid = permil > 0 && permil <= 1000;
237   valid = valid && Math.round(permil) === permil;
238   if (!valid) {
239     throw new Error(`'permil' must be between 1 and 1000, not '${permil}'`);
240   }
241
242   // Send Request Message
243   let pk = new Dashcore.PrivateKey(wif);
244   let msg = CrowdNode.offset + permil;
245   let changeAddr = pk.toPublicKey().toAddress().toString();
246   let tx = await CrowdNode._dashApi.createPayment(
247     wif,
248     hotwallet,
249     msg,
250     changeAddr,
251   );
252   await CrowdNode._insightApi.instantSend(tx.serialize());
253
254   // Listen for Response
255   let mempoolTx = {
256     address: "",
257     api: "",
258     at: 0,
259     txid: "",
260     satoshis: 0,
261     txlock: false,
262   };
263   return await Ws.listen(CrowdNode._insightBaseUrl, findResponse);
264
265   /**
266    * @param {String} evname
267    * @param {InsightSocketEventData} data
268    */
269   function findResponse(evname, data) {
270     if (!["tx", "txlock"].includes(evname)) {
271       return;
272     }
273
274     let now = Date.now();
275     if (mempoolTx.at) {
276       // don't wait longer than 3s for a txlock
277       if (now - mempoolTx.at > 3000) {
278         return mempoolTx;
279       }
280     }
281
282     let result;
283     // TODO should fetch tx and match hotwallet as vin
284     data.vout.some(function (vout) {
285       return Object.keys(vout).some(function (addr) {
286         if (addr !== changeAddr) {
287           return false;
288         }
289
290         let duffs = vout[addr];
291         let msg = duffs - CrowdNode.offset;
292         let api = CrowdNode._responses[msg];
293         if (!api) {
294           // the withdrawal often happens before the queued message
295           console.warn(`  => received '${duffs}' (${evname})`);
296           return false;
297         }
298
299         let newTx = {
300           address: addr,
301           api: api.toString(),
302           at: now,
303           txid: data.txid,
304           satoshis: duffs,
305           txlock: data.txlock,
306         };
307
308         if ("txlock" !== evname) {
309           // wait up to 3s for a txlock
310           if (!mempoolTx) {
311             mempoolTx = newTx;
312           }
313           return false;
314         }
315
316         result = newTx;
317         return true;
318       });
319     });
320
321     return result;
322   }
323 };
324
325 // See ./bin/crowdnode-list-apis.sh
326 CrowdNode.http = {};
327
328 /**
329  * @param {String} baseUrl
330  * @param {String} pub
331  */
332 CrowdNode.http.FundsOpen = async function (pub) {
333   return `Open <${CrowdNode._baseUrl}/FundsOpen/${pub}> in your browser.`;
334 };
335
336 /**
337  * @param {String} baseUrl
338  * @param {String} pub
339  */
340 CrowdNode.http.VotingOpen = async function (pub) {
341   return `Open <${CrowdNode._baseUrl}/VotingOpen/${pub}> in your browser.`;
342 };
343
344 /**
345  * @param {String} baseUrl
346  * @param {String} pub
347  */
348 CrowdNode.http.GetFunds = createApi(
349   `/odata/apifundings/GetFunds(address='{1}')`,
350 );
351
352 /**
353  * @param {String} baseUrl
354  * @param {String} pub
355  * @param {String} secondsSinceEpoch
356  */
357 CrowdNode.http.GetFundsFrom = createApi(
358   `/odata/apifundings/GetFundsFrom(address='{1}',fromUnixTime={2})`,
359 );
360
361 /**
362  * @param {String} baseUrl
363  * @param {String} pub
364  * @returns {CrowdNodeBalance}
365  */
366 CrowdNode.http.GetBalance = createApi(
367   `/odata/apifundings/GetBalance(address='{1}')`,
368 );
369
370 /**
371  * @param {String} baseUrl
372  * @param {String} pub
373  */
374 CrowdNode.http.GetMessages = createApi(
375   `/odata/apimessages/GetMessages(address='{1}')`,
376 );
377
378 /**
379  * @param {String} baseUrl
380  * @param {String} pub
381  */
382 CrowdNode.http.IsAddressInUse = createApi(
383   `/odata/apiaddresses/IsAddressInUse(address='{1}')`,
384 );
385
386 /**
387  * Set Email Address: messagetype=1
388  * @param {String} baseUrl
389  * @param {String} pub - pay to pubkey base58check address
390  * @param {String} email
391  * @param {String} signature
392  */
393 CrowdNode.http.SetEmail = createApi(
394   `/odata/apimessages/SendMessage(address='{1}',message='{2}',signature='{3}',messagetype=1)`,
395 );
396
397 /**
398  * Vote on Governance Objects: messagetype=2
399  * @param {String} baseUrl
400  * @param {String} pub - pay to pubkey base58check address
401  * @param {String} gobject-hash
402  * @param {String} choice - Yes|No|Abstain|Delegate|DoNothing
403  * @param {String} signature
404  */
405 CrowdNode.http.Vote = createApi(
406   `/odata/apimessages/SendMessage(address='{1}',message='{2},{3}',signature={4}',messagetype=2)`,
407 );
408
409 /**
410  * Set Referral: messagetype=3
411  * @param {String} baseUrl
412  * @param {String} pub - pay to pubkey base58check address
413  * @param {String} referralId
414  * @param {String} signature
415  */
416 CrowdNode.http.SetReferral = createApi(
417   `/odata/apimessages/SendMessage(address='{1}',message='{2}',signature='{3}',messagetype=3)`,
418 );
419
420 /**
421  * @param {String} tmplUrl
422  */
423 function createApi(tmplUrl) {
424   /**
425    * @param {Array<String>} arguments - typically just 'pub', unless SendMessage
426    */
427   return async function () {
428     /** @type Array<String> */
429     //@ts-ignore - arguments
430     let args = [].slice.call(arguments, 0);
431
432     // ex:
433     let url = `${CrowdNode._baseUrl}${tmplUrl}`;
434     args.forEach(function (arg, i) {
435       let n = i + 1;
436       url = url.replace(new RegExp(`\\{${n}\\}`, "g"), arg);
437     });
438
439     let resp = await request({
440       // TODO https://app.crowdnode.io/odata/apifundings/HotWallet
441       method: "GET",
442       url: url,
443       json: true,
444     });
445     if (!resp.ok) {
446       let err = new Error(
447         `http error: ${resp.statusCode} ${resp.body.message}`,
448       );
449       //@ts-ignore
450       err.response = resp.toJSON();
451       throw err;
452     }
453
454     return resp.body;
455   };
456 }
457
458 /**
459  * @param {String} prefix
460  */
461 function createAddrParser(prefix) {
462   /**
463    * @param {import('http').IncomingMessage} resp
464    */
465   return function (resp) {
466     //@ts-ignore
467     let html = resp.body;
468     return parseAddr(prefix, html);
469   };
470 }
471
472 /**
473  * @param {String} prefix
474  * @param {String} html
475  */
476 function parseAddr(prefix, html) {
477   // TODO escape prefix
478   // TODO restrict to true base58 (not base62)
479   let addrRe = new RegExp(prefix + "[^X]+\\b([Xy][a-z0-9]{33})\\b", "i");
480
481   let m = html.match(addrRe);
482   if (!m) {
483     throw new Error("could not find hotwallet address");
484   }
485
486   let hotwallet = m[1];
487   return hotwallet;
488 }
489
490 if (require.main === module) {
491   (async function main() {
492     //@ts-ignore
493     await CrowdNode.init({
494       //@ts-ignore
495       baseUrl: CrowdNode.main.baseUrl,
496       insightBaseUrl: "https://insight.dash.org",
497     });
498     console.info(CrowdNode);
499   })().catch(function (err) {
500     console.error(err);
501   });
502 }
503
504 function toDuff(dash) {
505   return Math.round(parseFloat(dash) * DUFFS);
506 }