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