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