├── .env
├── .gitignore
├── .npmignore
├── LICENSE
├── README.md
├── now.json
├── package.json
├── public
├── favicon.png
├── index.html
└── manifest.json
├── src
├── app.js
├── assets
│ ├── images
│ │ ├── arrow.svg
│ │ ├── bitcoin.svg
│ │ ├── bolt.png
│ │ ├── close.svg
│ │ ├── github.svg
│ │ └── qrcode.png
│ └── styles
│ │ ├── base
│ │ ├── colors.scss
│ │ ├── defaults.scss
│ │ ├── media_queries.scss
│ │ ├── normalize.scss
│ │ └── variables.scss
│ │ ├── common
│ │ ├── helpers.scss
│ │ └── resets.scss
│ │ ├── main.scss
│ │ └── modules
│ │ ├── app.scss
│ │ ├── error.scss
│ │ ├── input.scss
│ │ ├── invoice.scss
│ │ ├── logo.scss
│ │ ├── options.scss
│ │ ├── qrcode.scss
│ │ └── submit.scss
├── constants
│ ├── app.js
│ └── keys.js
├── index.js
├── lib
│ └── bolt11.js
└── utils
│ ├── internet-identifier.js
│ ├── invoices.js
│ ├── keys.js
│ └── timestamp.js
└── yarn.lock
/.env:
--------------------------------------------------------------------------------
1 | SKIP_PREFLIGHT_CHECK=true
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /.pnp
4 | .pnp.js
5 |
6 | # keys
7 | ga.js
8 |
9 | # testing
10 | /coverage
11 |
12 | # production
13 | /build
14 |
15 | # misc
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | !ga.js
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 André Neves
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be
8 | included in all copies or substantial portions of the Software.
9 |
10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
11 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE ARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Lightning Decoder
2 |
3 | > [https://lightningdecoder.com](https://lightningdecoder.com)
4 |
5 | 
6 |
7 | Lightning Decoder is a utility app that helps with understanding the individual parts of a Lightning Network Invoice/Payment Request (BOLT11s) and any [LNURL](https://github.com/btcontract/lnurl-rfc) request codes. It aims to be a tool for developers building applications on top of the LN network.
8 |
9 | ## Installing & Developing
10 |
11 | To run this application locally, simply clone the repo and run `yarn` or `npm install` to install all dependencies. You should then be able to run `yarn start` or `npm start` to spin up a local development server.
12 |
13 | ## Building for Production
14 |
15 | To build the assets for production, use script `yarn build` or `npm run build`. A `/build` top-level folder will be created, hosting all of the necessary files and assets bundled for serving in production.
16 |
17 | ## Contributions
18 |
19 | I'm always aiming to introduce new features and improvements to the application. If you see a need and would like to contribute, please send a PR with detailed descriptions and I'll evaluate as early as I can.
20 |
21 | MIT Licensed 2023
22 |
--------------------------------------------------------------------------------
/now.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lightningdecoder",
3 | "alias": "lightningdecoder.com"
4 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lightning-decoder",
3 | "version": "0.7.1",
4 | "author": {
5 | "email": "andrerfneves@protonmail.com",
6 | "name": "André Neves",
7 | "url": "https://zbd.gg"
8 | },
9 | "license": "MIT",
10 | "dependencies": {
11 | "bech32": "^1.1.3",
12 | "bitcoinjs-lib": "^5.1.6",
13 | "bn.js": "^5.0.0",
14 | "buffer": "^5.4.3",
15 | "classnames": "^2.2.6",
16 | "coininfo": "https://github.com/cryptocoinjs/coininfo#dc3e6cc59e593ee7dbeb7c993485706e72d32743",
17 | "date-fns": "^2.1.0",
18 | "eslint": "^5.16.0",
19 | "lodash": "^4.17.15",
20 | "react": "^16.9.0",
21 | "react-dom": "^16.9.0",
22 | "react-ga": "^2.6.0",
23 | "react-qr-reader": "^2.2.1",
24 | "react-scripts": "3.1.1",
25 | "safe-buffer": "^5.2.0",
26 | "sass": "^1.37.5",
27 | "secp256k1": "^3.7.1",
28 | "serve": "^11.1.0"
29 | },
30 | "scripts": {
31 | "start": "export NODE_OPTIONS=--openssl-legacy-provider && react-scripts start",
32 | "build": "export NODE_OPTIONS=--openssl-legacy-provider && react-scripts build",
33 | "test": "react-scripts test",
34 | "eject": "react-scripts eject",
35 | "deploy": "npm run build && cd build && now"
36 | },
37 | "eslintConfig": {
38 | "extends": "react-app"
39 | },
40 | "browserslist": [
41 | ">0.2%",
42 | "not dead",
43 | "not ie <= 11",
44 | "not op_mini all"
45 | ],
46 | "keywords": [
47 | "lightning",
48 | "network",
49 | "bitcoin"
50 | ],
51 | "devDependencies": {
52 | "node": "^18.9.0",
53 | "eslint": "^6.1.0"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrerfneves/lightning-decoder/77cf4cdea255ceb26d5d4614e981c3feba69935f/public/favicon.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | Lightning Decoder - Decode Lightning Network Requests (BOLT11, LNURL, and Lightning Address)
39 |
40 |
41 |
42 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Lightning Decoder",
3 | "name": "Lightning Decoder",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#f3f3f3"
15 | }
16 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | // Core Libs & Utils
2 | import React, { PureComponent } from 'react';
3 | import QrReader from 'react-qr-reader';
4 | import cx from 'classnames';
5 |
6 | // Assets
7 | import boltImage from './assets/images/bolt.png';
8 | import arrowImage from './assets/images/arrow.svg';
9 | import closeImage from './assets/images/close.svg';
10 | import qrcodeImage from './assets/images/qrcode.png';
11 | import githubImage from './assets/images/github.svg';
12 |
13 | // Utils
14 | import { formatDetailsKey } from './utils/keys';
15 | import { parseInvoice } from './utils/invoices';
16 |
17 | // Constants
18 | import {
19 | APP_NAME,
20 | APP_GITHUB,
21 | APP_TAGLINE,
22 | APP_SUBTAGLINE,
23 | APP_INPUT_PLACEHOLDER,
24 | } from './constants/app';
25 | import {
26 | TAGS_KEY,
27 | COMPLETE_KEY,
28 | LNURL_METADATA_KEY,
29 | TIMESTAMP_STRING_KEY,
30 | CALLBACK_KEY,
31 | LNURL_TAG_KEY,
32 | } from './constants/keys';
33 |
34 | // Styles
35 | import './assets/styles/main.scss';
36 |
37 | const INITIAL_STATE = {
38 | text: '',
39 | error: {},
40 | hasError: false,
41 | decodedInvoice: {},
42 | isLNAddress: false,
43 | isQRCodeOpened: false,
44 | isInvoiceLoaded: false,
45 | isBitcoinAddrOpened: false,
46 | };
47 |
48 | export class App extends PureComponent {
49 | state = INITIAL_STATE;
50 |
51 | componentDidMount() {
52 | const invoiceOnURLParam = window.location.pathname;
53 |
54 | // Remove first `/` from pathname
55 | const cleanInvoice = invoiceOnURLParam.split('/')[1];
56 | if (cleanInvoice && cleanInvoice !== '') {
57 | this.setState(() => ({ text: cleanInvoice }));
58 | this.getInvoiceDetails(cleanInvoice);
59 | }
60 | }
61 |
62 | clearInvoiceDetails = () => {
63 | // Reset URL address
64 | const currentOrigin = window.location.origin;
65 | window.history.pushState({}, null, `${currentOrigin}`);
66 |
67 | this.setState(() => ({
68 | ...INITIAL_STATE,
69 | }));
70 | };
71 |
72 | getInvoiceDetails = async (text) => {
73 | // If this returns null is because there is no invoice to parse
74 | if (!text) {
75 | return this.setState(() => ({
76 | hasError: true,
77 | decodedInvoice: {},
78 | isInvoiceLoaded: false,
79 | error: { message: 'Please enter a valid request or address and try again.'},
80 | }));
81 | }
82 |
83 | try {
84 | let response;
85 | const parsedInvoiceResponse = await parseInvoice(text);
86 |
87 | // If this returns null is because there is no invoice to parse
88 | if (!parsedInvoiceResponse) {
89 | return this.setState(() => ({
90 | hasError: true,
91 | decodedInvoice: {},
92 | isInvoiceLoaded: false,
93 | error: { message: 'Please enter a valid request or address and try again.'},
94 | }));
95 | }
96 |
97 | const { isLNURL, data, error, isLNAddress } = parsedInvoiceResponse;
98 |
99 | // If an error comes back from a nested operation in parsing it must
100 | // propagate back to the end user
101 | if (error && error.length > 0) {
102 | return this.setState(() => ({
103 | hasError: true,
104 | decodedInvoice: {},
105 | isInvoiceLoaded: false,
106 | error: { message: error },
107 | }));
108 | }
109 |
110 | // If data is null it means the parser could not understand the invoice
111 | if (!data) {
112 | return this.setState(() => ({
113 | hasError: true,
114 | decodedInvoice: {},
115 | isInvoiceLoaded: false,
116 | error: { message: 'Could not parse/understand this invoice or request. Please try again.'},
117 | }));
118 | }
119 |
120 | // Handle LNURLs differently
121 | if (isLNURL) {
122 | // If this is a Lightning Address, the contents have already been fetched
123 | if (isLNAddress) {
124 | response = data;
125 | } else {
126 | // Otherwise this is an LNURL ready to be fetched
127 | response = await data;
128 | }
129 | } else {
130 | // Handle normal invoices
131 | response = data;
132 | }
133 |
134 | if (response) {
135 | // On successful response, set the request content on the addressbar
136 | // if there isn't one already in there from before (user-entered)
137 | const currentUrl = window.location;
138 | const currentOrigin = window.location.origin;
139 | const currentPathname = window.location.pathname;
140 | const hasPathnameAlready = currentPathname && currentPathname !== '';
141 |
142 | // If there's a pathname already, we can just remove it and let the
143 | // new pathname be entered
144 | if (hasPathnameAlready) {
145 | window.history.pushState({}, null, `${currentOrigin}`);
146 | }
147 |
148 | window.history.pushState({}, null, `${currentUrl}${text}`);
149 |
150 | this.setState(() => ({
151 | isLNURL,
152 | error: {},
153 | isLNAddress,
154 | hasError: false,
155 | isInvoiceLoaded: true,
156 | decodedInvoice: response,
157 | }));
158 | }
159 | } catch(error) {
160 | this.setState(() => ({
161 | error: error,
162 | hasError: true,
163 | decodedInvoice: {},
164 | isInvoiceLoaded: false,
165 | }));
166 | }
167 | }
168 |
169 | handleChange = (event) => {
170 | const { target: { value: text } } = event;
171 |
172 | this.setState(() => ({
173 | text,
174 | error: {},
175 | hasError: false,
176 | }));
177 | }
178 |
179 | handleKeyPress = (event) => {
180 | const { text } = this.state;
181 |
182 | if (event.key === 'Enter') {
183 | this.getInvoiceDetails(text);
184 | }
185 | }
186 |
187 | handleQRCode = () => this.setState(prevState => ({
188 | isQRCodeOpened: !prevState.isQRCodeOpened
189 | }))
190 |
191 | renderErrorDetails = () => {
192 | const { hasError, error } = this.state;
193 |
194 | if (!hasError) return null;
195 |
196 | return (
197 |
198 |
199 |
200 | {error.message}
201 |
202 |
203 |
204 | );
205 | }
206 |
207 | renderInput = () => {
208 | const { text } = this.state;
209 |
210 | return (
211 |
212 |

217 |
225 |
226 | );
227 | }
228 |
229 | renderInvoiceDetails = () => {
230 | const { decodedInvoice, isInvoiceLoaded } = this.state;
231 | const invoiceContainerClassnames = cx(
232 | 'invoice',
233 | { 'invoice--opened': isInvoiceLoaded },
234 | );
235 |
236 | const invoiceDetails = Object.keys(decodedInvoice)
237 | .map((key) => {
238 | switch (key) {
239 | case COMPLETE_KEY:
240 | return null;
241 | case TAGS_KEY:
242 | return this.renderInvoiceInnerItem(key);
243 | case TIMESTAMP_STRING_KEY:
244 | return this.renderInvoiceItem(
245 | key,
246 | TIMESTAMP_STRING_KEY,
247 | );
248 | default:
249 | return this.renderInvoiceItem(key);
250 | }
251 | });
252 |
253 | return !isInvoiceLoaded ? null : (
254 |
255 | {invoiceDetails}
256 |
257 | );
258 | }
259 |
260 | renderInvoiceInnerItem = (key) => {
261 | const { decodedInvoice } = this.state;
262 | const tags = decodedInvoice[key];
263 |
264 | const renderTag = (tag) => (
265 | typeof tag.data !== 'string' &&
266 | typeof tag.data !== 'number'
267 | ) ? renderNestedTag(tag) : renderNormalTag(tag);
268 |
269 | const renderNestedItem = (label, value) => (
270 |
274 |
275 | {formatDetailsKey(label)}
276 |
277 |
278 | {value}
279 |
280 |
281 | );
282 |
283 | const renderNestedTag = (tag) => (
284 |
285 |
286 | {formatDetailsKey(tag.tagName)}
287 |
288 |
289 | {/* Strings */}
290 | {typeof tag.data === 'string' && (
291 |
292 | {tag.data}
293 |
294 | )}
295 | {/* Array of Objects */}
296 | {Array.isArray(tag.data) && tag.data.map((item) => (
297 | <>
298 | {Object.keys(item).map((label) => renderNestedItem(label, item[label]))}
299 | >
300 | ))}
301 | {/* Objects */}
302 | {(
303 | !Array.isArray(tag.data) && (
304 | (typeof tag.data !== 'string') || (typeof tag.data !== 'number'))
305 | ) && (
306 | <>
307 | {Object.keys(tag.data).map((label) => renderNestedItem(label, tag.data[label]))}
308 | >
309 | )}
310 |
311 |
312 | );
313 |
314 | const renderNormalTag = (tag) => (
315 |
316 |
317 | {formatDetailsKey(tag.tagName)}
318 |
319 |
320 | {`${tag.data || '--'}`}
321 |
322 |
323 | )
324 |
325 | return tags.map((tag) => renderTag(tag));
326 | }
327 |
328 | renderInvoiceItem = (key, valuePropFormat) => {
329 | const { decodedInvoice } = this.state;
330 |
331 | let value = `${decodedInvoice[key]}`;
332 | if (
333 | valuePropFormat &&
334 | valuePropFormat === TIMESTAMP_STRING_KEY
335 | ) {
336 | // TODO: this breaks
337 | // value = `${formatTimestamp(decodedInvoice[key])}`;
338 | }
339 |
340 | return (
341 |
345 |
346 | {formatDetailsKey(key)}
347 |
348 |
349 | {value}
350 |
351 |
352 | );
353 | }
354 |
355 | renderLogo = () => (
356 |
357 |
358 | {APP_NAME}
359 |
360 |
361 | {APP_TAGLINE}
362 | {APP_SUBTAGLINE}
363 |
364 |
365 | );
366 |
367 | renderLNURLDetails = () => {
368 | const { decodedInvoice, isInvoiceLoaded } = this.state;
369 | const invoiceContainerClassnames = cx(
370 | 'invoice',
371 | { 'invoice--opened': isInvoiceLoaded },
372 | );
373 |
374 | let requestContents = decodedInvoice;
375 |
376 | return !isInvoiceLoaded ? null : (
377 |
378 | {Object.keys(requestContents).map((key) => {
379 | let text = decodedInvoice[key];
380 |
381 | if (typeof decodedInvoice[key] === 'object') {
382 | return <>>;
383 | }
384 |
385 | if (key === 'status') {
386 | return <>>
387 | }
388 |
389 | if (key === LNURL_TAG_KEY) {
390 | switch (key) {
391 | case 'payRequest':
392 | text = 'LNURL Pay (payRequest)'
393 | break;
394 | case 'withdrawRequest':
395 | text = 'LNURL Withdraw (withdrawRequest)'
396 | break;
397 | default:
398 | break;
399 | }
400 |
401 | return (
402 |
403 |
404 | {formatDetailsKey(key)}
405 |
406 |
411 |
412 | )
413 | }
414 |
415 | if (key === CALLBACK_KEY) {
416 | return (
417 |
418 |
419 | {formatDetailsKey(key)}
420 |
421 |
426 |
427 | )
428 | }
429 |
430 | if (key === LNURL_METADATA_KEY) {
431 | const splitMetadata = JSON.parse(decodedInvoice[key]);
432 |
433 | // eslint-disable-next-line array-callback-return
434 | const toRender = splitMetadata.map((arrOfData) => {
435 | if (arrOfData[0] === 'text/plain') {
436 | return (
437 |
438 |
439 | Description
440 |
441 |
442 | {arrOfData[1]}
443 |
444 |
445 | )
446 | }
447 |
448 | if (arrOfData[0] === 'text/identifier') {
449 | return (
450 |
451 |
452 | Lightning Address
453 |
454 |
455 | {arrOfData[1]}
456 |
457 |
458 | )
459 | }
460 |
461 | if (arrOfData[0] === 'image/png;base64') {
462 | return (
463 |
464 |
465 | Image
466 |
467 |
468 |

473 |
474 |
475 | );
476 | }
477 | });
478 |
479 | return toRender;
480 | }
481 |
482 | return (
483 |
484 |
485 | {formatDetailsKey(key)}
486 |
487 |
488 | {decodedInvoice[key]}
489 |
490 |
491 | );
492 | })}
493 |
494 | );
495 | }
496 |
497 | renderSubmit = () => {
498 | const { isInvoiceLoaded, text } = this.state;
499 | const submitClassnames = cx(
500 | 'submit',
501 | { 'submit__close': isInvoiceLoaded },
502 | );
503 |
504 | const onClick = () => {
505 | if (isInvoiceLoaded) {
506 | this.clearInvoiceDetails();
507 | } else {
508 | this.getInvoiceDetails(text);
509 | }
510 | }
511 |
512 | return (
513 |
517 |

522 |
523 | );
524 | }
525 |
526 | renderOptions = () => {
527 | const { isInvoiceLoaded } = this.state;
528 | const optionsClassnames = cx(
529 | 'options',
530 | { 'options--hide': isInvoiceLoaded },
531 | );
532 |
533 | return (
534 |
550 | );
551 | }
552 |
553 | renderCamera = () => {
554 | const { isQRCodeOpened, isInvoiceLoaded } = this.state;
555 |
556 | const styleQRWrapper = cx({
557 | 'qrcode' : true,
558 | 'qrcode--opened': isQRCodeOpened,
559 | });
560 | const styleQRContainer = cx(
561 | 'qrcode__container',
562 | { 'qrcode__container--opened': isQRCodeOpened },
563 | );
564 | const styleImgQR = cx(
565 | 'qrcode__img',
566 | { 'qrcode__img--opened': isQRCodeOpened },
567 | );
568 |
569 | const qrReaderStyles = {
570 | width: '100%',
571 | border: '2pt solid #000000',
572 | };
573 |
574 | const srcImage = isQRCodeOpened ? closeImage : qrcodeImage;
575 |
576 | const handleScan = (value) => {
577 | if (Object.is(value, null)) return;
578 |
579 | let text = value;
580 | if (value.includes('lightning')) {
581 | text = value.split('lightning:')[1];
582 | }
583 |
584 | this.getInvoiceDetails(text);
585 | this.setState(() => ({
586 | isQRCodeOpened: false,
587 | text,
588 | }));
589 | }
590 |
591 | const handleError = (error) => this.setState(() => ({
592 | isInvoiceLoaded: false,
593 | hasError: true,
594 | error,
595 | isQRCodeOpened: false
596 | }));
597 |
598 | return isInvoiceLoaded ? null : (
599 |
600 | {isQRCodeOpened && (
601 |
602 | )}
603 |
604 |

610 | {!isQRCodeOpened ? null : (
611 |
617 | )}
618 |
619 |
620 | );
621 | }
622 |
623 | render() {
624 | const { isLNURL, isInvoiceLoaded, hasError } = this.state;
625 |
626 | const appClasses = cx(
627 | 'app',
628 | { 'app--opened': isInvoiceLoaded },
629 | );
630 | const appColumnClasses = cx(
631 | 'app__column',
632 | {
633 | 'app__column--invoice-loaded': isInvoiceLoaded,
634 | 'app__column--error': hasError,
635 | },
636 | );
637 | const appSubmitClasses = cx(
638 | 'app__submit',
639 | { 'app__submit--invoice-loaded': isInvoiceLoaded },
640 | );
641 |
642 | return (
643 |
644 | {this.renderOptions()}
645 | {this.renderLogo()}
646 |
647 | {this.renderInput()}
648 |
649 | {this.renderSubmit()}
650 | {this.renderCamera()}
651 |
652 |
653 |
654 | {isLNURL ? this.renderLNURLDetails() : this.renderInvoiceDetails()}
655 | {this.renderErrorDetails()}
656 |
657 |
658 | );
659 | }
660 | }
661 |
--------------------------------------------------------------------------------
/src/assets/images/arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/images/bitcoin.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/images/bolt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrerfneves/lightning-decoder/77cf4cdea255ceb26d5d4614e981c3feba69935f/src/assets/images/bolt.png
--------------------------------------------------------------------------------
/src/assets/images/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/images/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/images/qrcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrerfneves/lightning-decoder/77cf4cdea255ceb26d5d4614e981c3feba69935f/src/assets/images/qrcode.png
--------------------------------------------------------------------------------
/src/assets/styles/base/colors.scss:
--------------------------------------------------------------------------------
1 | // Colors
2 | $app-background: #292929;
3 | $background-primary: #000000;
4 | $text-primary: #f3f3f3;
5 | $text-secondary: #838383;
6 | $text-tertiary: #979797;
7 | $error-color: #3e0203;
8 | $border-color: #212020;
--------------------------------------------------------------------------------
/src/assets/styles/base/defaults.scss:
--------------------------------------------------------------------------------
1 | // Defaults
2 |
3 | * {
4 | box-sizing: border-box;
5 | }
6 |
7 | html,
8 | body,
9 | #root {
10 | height: 100%;
11 | width: 100%;
12 | }
13 |
14 | body {
15 | margin: 0;
16 | padding: 0;
17 | font-family: $font-family;
18 | -webkit-font-smoothing: antialiased;
19 | -moz-osx-font-smoothing: grayscale;
20 | }
21 |
22 | img {
23 | max-width: 100%;
24 | }
25 |
26 | a {
27 | color: inherit;
28 | }
29 |
--------------------------------------------------------------------------------
/src/assets/styles/base/media_queries.scss:
--------------------------------------------------------------------------------
1 | // Media Query Specs
2 |
3 | $mqs: (
4 | allSizes: 16 / 16 * 1rem,
5 | smallMobile: 400 / 16 * 1rem,
6 | mobile: 768 / 16 * 1rem,
7 | smallTablet: 900 / 16 * 1rem,
8 | tablet: 1024 / 16 * 1rem,
9 | smallDesktop: 1180/ 16 * 1rem,
10 | desktop: 1280 / 16 * 1rem,
11 | );
12 |
13 | // Main Utility Mixin
14 | @mixin larger-than($size) {
15 | $width: #{map-get($mqs, $size)};
16 |
17 | @media (min-width: $width) {
18 | @content;
19 | }
20 | }
21 |
22 | // Optional Utility Mixins
23 | @mixin smaller-than($size) {
24 | $width: #{map-get($mqs, $size)};
25 |
26 | @media (max-width: $width) {
27 | @content;
28 | }
29 | }
30 |
31 | @mixin between($min, $max) {
32 | $minwidth: #{map-get($mqs, $min)};
33 | $maxwidth: #{map-get($mqs, $max)};
34 | @media (min-width: $minwidth) and (max-width: $maxwidth) {
35 | @content;
36 | }
37 | }
38 |
39 | // Orientation Specific
40 | @mixin between-queries-landscape($min, $max) {
41 | $minwidth: #{map-get($mqs, $min)};
42 | $maxwidth: #{map-get($mqs, $max)};
43 |
44 | @media (min-width: $minwidth) and (max-width: $maxwidth) and (orientation: landscape) {
45 | @content;
46 | }
47 | }
48 |
49 | @mixin between-queries-portrait($min, $max) {
50 | $minwidth: #{map-get($mqs, $min)};
51 | $maxwidth: #{map-get($mqs, $max)};
52 | @media (min-width: $minwidth) and (max-width: $maxwidth) and (orientation: portrait) {
53 | @content;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/assets/styles/base/normalize.scss:
--------------------------------------------------------------------------------
1 | /*! sanitize.css v5.0.0 | CC0 License | github.com/jonathantneal/sanitize.css */
2 |
3 | /* Document (https://html.spec.whatwg.org/multipage/semantics.html#semantics)
4 | ========================================================================== */
5 |
6 | /**
7 | * 1. Remove repeating backgrounds in all browsers (opinionated).
8 | * 2. Add box sizing inheritence in all browsers (opinionated).
9 | */
10 |
11 | *,
12 | ::before,
13 | ::after {
14 | background-repeat: no-repeat; /* 1 */
15 | box-sizing: inherit; /* 2 */
16 | }
17 |
18 | /**
19 | * 1. Add text decoration inheritance in all browsers (opinionated).
20 | * 2. Add vertical alignment inheritence in all browsers (opinionated).
21 | */
22 |
23 | ::before,
24 | ::after {
25 | text-decoration: inherit; /* 1 */
26 | vertical-align: inherit; /* 2 */
27 | }
28 |
29 | /**
30 | * 1. Add border box sizing in all browsers (opinionated).
31 | * 2. Add the default cursor in all browsers (opinionated).
32 | * 3. Prevent font size adjustments after orientation changes in IE and iOS.
33 | */
34 |
35 | html {
36 | box-sizing: border-box; /* 1 */
37 | cursor: default; /* 2 */
38 | -ms-text-size-adjust: 100%; /* 3 */
39 | -webkit-text-size-adjust: 100%; /* 3 */
40 | }
41 |
42 | /* Sections (https://html.spec.whatwg.org/multipage/semantics.html#sections)
43 | ========================================================================== */
44 |
45 | /**
46 | * Add the correct display in IE 9-.
47 | */
48 |
49 | article,
50 | aside,
51 | footer,
52 | header,
53 | nav,
54 | section {
55 | display: block;
56 | }
57 |
58 | /**
59 | * Remove the margin in all browsers (opinionated).
60 | */
61 |
62 | body {
63 | margin: 0;
64 | }
65 |
66 | /* Grouping content (https://html.spec.whatwg.org/multipage/semantics.html#grouping-content)
67 | ========================================================================== */
68 |
69 | /**
70 | * Add the correct display in IE 9-.
71 | * 1. Add the correct display in IE.
72 | */
73 |
74 | figcaption,
75 | figure,
76 | main { /* 1 */
77 | display: block;
78 | }
79 |
80 | /**
81 | * 1. Add the correct box sizing in Firefox.
82 | * 2. Show the overflow in Edge and IE.
83 | */
84 |
85 | hr {
86 | box-sizing: content-box; /* 1 */
87 | height: 0; /* 1 */
88 | overflow: visible; /* 2 */
89 | }
90 |
91 | /**
92 | * Remove the list style on navigation lists in all browsers (opinionated).
93 | */
94 |
95 | nav ol,
96 | nav ul {
97 | list-style: none;
98 | }
99 |
100 | /**
101 | * 1. Correct the inheritance and scaling of font size in all browsers.
102 | * 2. Correct the odd `em` font sizing in all browsers.
103 | */
104 |
105 | pre {
106 | font-family: monospace, monospace; /* 1 */
107 | font-size: 1em; /* 2 */
108 | }
109 |
110 | /* Text-level semantics (https://html.spec.whatwg.org/multipage/semantics.html#text-level-semantics)
111 | ========================================================================== */
112 |
113 | /**
114 | * 1. Remove the gray background on active links in IE 10.
115 | * 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
116 | */
117 |
118 | a {
119 | background-color: transparent; /* 1 */
120 | -webkit-text-decoration-skip: objects; /* 2 */
121 | }
122 |
123 | /**
124 | * 1. Remove the bottom border in Firefox 39-.
125 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
126 | */
127 |
128 | abbr[title] {
129 | border-bottom: none; /* 1 */
130 | text-decoration: underline; /* 2 */
131 | text-decoration: underline dotted; /* 2 */
132 | }
133 |
134 | /**
135 | * Prevent the duplicate application of `bolder` by the next rule in Safari 6.
136 | */
137 |
138 | b,
139 | strong {
140 | font-weight: inherit;
141 | }
142 |
143 | /**
144 | * Add the correct font weight in Chrome, Edge, and Safari.
145 | */
146 |
147 | b,
148 | strong {
149 | font-weight: bolder;
150 | }
151 |
152 | /**
153 | * 1. Correct the inheritance and scaling of font size in all browsers.
154 | * 2. Correct the odd `em` font sizing in all browsers.
155 | */
156 |
157 | code,
158 | kbd,
159 | samp {
160 | font-family: monospace, monospace; /* 1 */
161 | font-size: 1em; /* 2 */
162 | }
163 |
164 | /**
165 | * Add the correct font style in Android 4.3-.
166 | */
167 |
168 | dfn {
169 | font-style: italic;
170 | }
171 |
172 |
173 | /**
174 | * Add the correct font size in all browsers.
175 | */
176 |
177 | small {
178 | font-size: 80%;
179 | }
180 |
181 | /**
182 | * Prevent `sub` and `sup` elements from affecting the line height in
183 | * all browsers.
184 | */
185 |
186 | sub,
187 | sup {
188 | font-size: 75%;
189 | line-height: 0;
190 | position: relative;
191 | vertical-align: baseline;
192 | }
193 |
194 | sub {
195 | bottom: -.25em;
196 | }
197 |
198 | sup {
199 | top: -.5em;
200 | }
201 |
202 | /*
203 | * Remove the text shadow on text selections (opinionated).
204 | * 1. Restore the coloring undone by defining the text shadow (opinionated).
205 | */
206 |
207 | ::-moz-selection {
208 | background-color: #b3d4fc; /* 1 */
209 | color: #000000; /* 1 */
210 | text-shadow: none;
211 | }
212 |
213 | ::selection {
214 | background-color: #b3d4fc; /* 1 */
215 | color: #000000; /* 1 */
216 | text-shadow: none;
217 | }
218 |
219 | /* Embedded content (https://html.spec.whatwg.org/multipage/embedded-content.html#embedded-content)
220 | ========================================================================== */
221 |
222 | /*
223 | * Change the alignment on media elements in all browers (opinionated).
224 | */
225 |
226 | audio,
227 | canvas,
228 | iframe,
229 | img,
230 | svg,
231 | video {
232 | vertical-align: middle;
233 | }
234 |
235 | /**
236 | * Add the correct display in IE 9-.
237 | */
238 |
239 | audio,
240 | video {
241 | display: inline-block;
242 | }
243 |
244 | /**
245 | * Add the correct display in iOS 4-7.
246 | */
247 |
248 | audio:not([controls]) {
249 | display: none;
250 | height: 0;
251 | }
252 |
253 | /**
254 | * Remove the border on images inside links in IE 10-.
255 | */
256 |
257 | img {
258 | border-style: none;
259 | }
260 |
261 | /**
262 | * Change the fill color to match the text color in all browsers (opinionated).
263 | */
264 |
265 | svg {
266 | fill: currentColor;
267 | }
268 |
269 | /**
270 | * Hide the overflow in IE.
271 | */
272 |
273 | svg:not(:root) {
274 | overflow: hidden;
275 | }
276 |
277 | /* Tabular data (https://html.spec.whatwg.org/multipage/tables.html#tables)
278 | ========================================================================== */
279 |
280 | /**
281 | * Collapse border spacing
282 | */
283 |
284 | table {
285 | border-collapse: collapse;
286 | }
287 |
288 | /* Forms (https://html.spec.whatwg.org/multipage/forms.html#forms)
289 | ========================================================================== */
290 |
291 | /**
292 | * Remove the margin in Firefox and Safari.
293 | */
294 |
295 | button,
296 | input,
297 | optgroup,
298 | select,
299 | textarea {
300 | margin: 0;
301 | }
302 |
303 | /**
304 | * Inherit styling in all browsers (opinionated).
305 | */
306 |
307 | button,
308 | input,
309 | select,
310 | textarea {
311 | background-color: transparent;
312 | color: inherit;
313 | font-size: inherit;
314 | line-height: inherit;
315 | }
316 |
317 | /**
318 | * Show the overflow in IE.
319 | * 1. Show the overflow in Edge.
320 | */
321 |
322 | button,
323 | input { /* 1 */
324 | overflow: visible;
325 | }
326 |
327 | /**
328 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
329 | * 1. Remove the inheritance of text transform in Firefox.
330 | */
331 |
332 | button,
333 | select { /* 1 */
334 | text-transform: none;
335 | }
336 |
337 | /**
338 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
339 | * controls in Android 4.
340 | * 2. Correct the inability to style clickable types in iOS and Safari.
341 | */
342 |
343 | button,
344 | html [type="button"], /* 1 */
345 | [type="reset"],
346 | [type="submit"] {
347 | -webkit-appearance: button; /* 2 */
348 | }
349 |
350 | /**
351 | * Remove the inner border and padding in Firefox.
352 | */
353 |
354 | button::-moz-focus-inner,
355 | [type="button"]::-moz-focus-inner,
356 | [type="reset"]::-moz-focus-inner,
357 | [type="submit"]::-moz-focus-inner {
358 | border-style: none;
359 | padding: 0;
360 | }
361 |
362 | /**
363 | * Restore the focus styles unset by the previous rule.
364 | */
365 |
366 | button:-moz-focusring,
367 | [type="button"]:-moz-focusring,
368 | [type="reset"]:-moz-focusring,
369 | [type="submit"]:-moz-focusring {
370 | outline: 1px dotted ButtonText;
371 | }
372 |
373 | /**
374 | * 1. Correct the text wrapping in Edge and IE.
375 | * 2. Correct the color inheritance from `fieldset` elements in IE.
376 | * 3. Remove the padding so developers are not caught out when they zero out
377 | * `fieldset` elements in all browsers.
378 | */
379 |
380 | legend {
381 | box-sizing: border-box; /* 1 */
382 | color: inherit; /* 2 */
383 | display: table; /* 1 */
384 | max-width: 100%; /* 1 */
385 | padding: 0; /* 3 */
386 | white-space: normal; /* 1 */
387 | }
388 |
389 | /**
390 | * 1. Add the correct display in IE 9-.
391 | * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
392 | */
393 |
394 | progress {
395 | display: inline-block; /* 1 */
396 | vertical-align: baseline; /* 2 */
397 | }
398 |
399 | /**
400 | * 1. Remove the default vertical scrollbar in IE.
401 | * 2. Change the resize direction on textareas in all browsers (opinionated).
402 | */
403 |
404 | textarea {
405 | overflow: auto; /* 1 */
406 | resize: vertical; /* 2 */
407 | }
408 |
409 | /**
410 | * 1. Add the correct box sizing in IE 10-.
411 | * 2. Remove the padding in IE 10-.
412 | */
413 |
414 | [type="checkbox"],
415 | [type="radio"] {
416 | box-sizing: border-box; /* 1 */
417 | padding: 0; /* 2 */
418 | }
419 |
420 | /**
421 | * Correct the cursor style of increment and decrement buttons in Chrome.
422 | */
423 |
424 | [type="number"]::-webkit-inner-spin-button,
425 | [type="number"]::-webkit-outer-spin-button {
426 | height: auto;
427 | }
428 |
429 | /**
430 | * 1. Correct the odd appearance in Chrome and Safari.
431 | * 2. Correct the outline style in Safari.
432 | */
433 |
434 | [type="search"] {
435 | -webkit-appearance: textfield; /* 1 */
436 | outline-offset: -2px; /* 2 */
437 | }
438 |
439 | /**
440 | * Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
441 | */
442 |
443 | [type="search"]::-webkit-search-cancel-button,
444 | [type="search"]::-webkit-search-decoration {
445 | -webkit-appearance: none;
446 | }
447 |
448 | /**
449 | * 1. Correct the inability to style clickable types in iOS and Safari.
450 | * 2. Change font properties to `inherit` in Safari.
451 | */
452 |
453 | ::-webkit-file-upload-button {
454 | -webkit-appearance: button; /* 1 */
455 | font: inherit; /* 2 */
456 | }
457 |
458 | /* Interactive elements (https://html.spec.whatwg.org/multipage/forms.html#interactive-elements)
459 | ========================================================================== */
460 |
461 | /*
462 | * Add the correct display in IE 9-.
463 | * 1. Add the correct display in Edge, IE, and Firefox.
464 | */
465 |
466 | details, /* 1 */
467 | menu {
468 | display: block;
469 | }
470 |
471 | /*
472 | * Add the correct display in all browsers.
473 | */
474 |
475 | summary {
476 | display: list-item;
477 | }
478 |
479 | /* Scripting (https://html.spec.whatwg.org/multipage/scripting.html#scripting-3)
480 | ========================================================================== */
481 |
482 | /**
483 | * Add the correct display in IE 9-.
484 | */
485 |
486 | canvas {
487 | display: inline-block;
488 | }
489 |
490 | /**
491 | * Add the correct display in IE.
492 | */
493 |
494 | template {
495 | display: none;
496 | }
497 |
498 | /* User interaction (https://html.spec.whatwg.org/multipage/interaction.html#editing)
499 | ========================================================================== */
500 |
501 | /*
502 | * Remove the tapping delay on clickable elements (opinionated).
503 | * 1. Remove the tapping delay in IE 10.
504 | */
505 |
506 | a,
507 | area,
508 | button,
509 | input,
510 | label,
511 | select,
512 | summary,
513 | textarea,
514 | [tabindex] {
515 | -ms-touch-action: manipulation; /* 1 */
516 | touch-action: manipulation;
517 | }
518 |
519 | /**
520 | * Add the correct display in IE 10-.
521 | */
522 |
523 | [hidden] {
524 | display: none;
525 | }
526 |
527 | /* ARIA (https://w3c.github.io/html-aria/)
528 | ========================================================================== */
529 |
530 | /**
531 | * Change the cursor on busy elements (opinionated).
532 | */
533 |
534 | [aria-busy="true"] {
535 | cursor: progress;
536 | }
537 |
538 | /*
539 | * Change the cursor on control elements (opinionated).
540 | */
541 |
542 | [aria-controls] {
543 | cursor: pointer;
544 | }
545 |
546 | /*
547 | * Change the display on visually hidden accessible elements (opinionated).
548 | */
549 |
550 | [aria-hidden="false"][hidden]:not(:focus) {
551 | clip: rect(0, 0, 0, 0);
552 | display: inherit;
553 | position: absolute;
554 | }
555 |
556 | /*
557 | * Change the cursor on disabled, not-editable, or otherwise
558 | * inoperable elements (opinionated).
559 | */
560 |
561 | [aria-disabled] {
562 | cursor: default;
563 | }
--------------------------------------------------------------------------------
/src/assets/styles/base/variables.scss:
--------------------------------------------------------------------------------
1 | // Variables
2 |
3 | $input-container-width: 540px;
4 | $border: 1px solid $border-color;
5 | $border-radius: 4px;
6 | $icon-size: 72px;
7 | $base-spacing: 20px;
8 | $transition: all 0.5s cubic-bezier(0.215, 0.610, 0.355, 1);
9 |
10 | $font-family: source-code-pro, Menlo, Monaco, Consolas,"Courier New", monospace;
11 | $font-weight-bold: 900;
12 | $font-weight-regular: 400;
13 | $font-extra-small-size: 14px;
14 | $font-small-size: 18px;
15 | $font-base-size: 24px;
16 | $font-medium-size: 32px;
17 | $font-large-size: 50px;
18 |
--------------------------------------------------------------------------------
/src/assets/styles/common/helpers.scss:
--------------------------------------------------------------------------------
1 | .textreplace {
2 | display: block;
3 | padding: 0;
4 | text-indent: 100%;
5 | white-space: nowrap;
6 | overflow: hidden;
7 | }
8 |
9 | .textreplace-sr {
10 | border: 0;
11 | clip: rect(0 0 0 0);
12 | height: 1px;
13 | margin: -1px;
14 | overflow: hidden;
15 | padding: 0;
16 | position: absolute;
17 | width: 1px;
18 | }
19 |
20 | %fullsize, .fullsize {
21 | position: absolute;
22 | top: 0;
23 | right: 0;
24 | bottom: 0;
25 | left: 0;
26 | }
27 |
28 | %defullsize {
29 | position: static;
30 | top: auto;
31 | right: auto;
32 | bottom: auto;
33 | left: auto;
34 | }
35 |
36 | @mixin ib-whitespace-fix {
37 | font-size: 0;
38 | & > * {
39 | font-size: $base-font-size * 1px;
40 | display: inline-block;
41 | vertical-align: top;
42 | }
43 | }
44 |
45 | @mixin background-overlay($color, $position: before) {
46 | position: relative;
47 | &:#{$position} {
48 | content: '';
49 | position: absolute;
50 | width: 100%;
51 | height: 100%;
52 | left: 0;
53 | top: 0;
54 | background-color: $color;
55 | z-index: 1;
56 | }
57 | }
58 |
59 | @mixin reset-background-overlay {
60 | &:before {
61 | content: none;
62 | }
63 | }
64 |
65 | @mixin full-width-background-color($color) {
66 | position: relative;
67 | &:before {
68 | @include absolute-center;
69 | content: '';
70 | width: 100vw;
71 | height: 100%;
72 | background: $color;
73 | background-size: cover;
74 | z-index: -1;
75 | }
76 | }
77 |
78 | @mixin full-width-background-container($background-position: 50%) {
79 | @include absolute-center;
80 | z-index: 0;
81 | content: '';
82 | width: 100vw;
83 | height: 100%;
84 | background-size: cover;
85 | background-position: $background-position;
86 | }
87 |
88 | @mixin aspect-ratio($width, $height) {
89 | position: relative;
90 | &:before {
91 | display: block;
92 | content: "";
93 | width: 100%;
94 | padding-top: ($height / $width) * 100%;
95 | }
96 | }
97 |
98 | @mixin remove-aspect-ratio() {
99 | position: relative;
100 | &:before {
101 | display: none;
102 | }
103 | }
104 |
105 | /*https://gist.github.com/terkel/4373420#gistcomment-1626534*/
106 | @function decimal-round ($number, $digits: 0, $mode: round) {
107 | $n: 1;
108 | // $number must be a number
109 | @if type-of($number) != number {
110 | @warn '#{ $number } is not a number.';
111 | @return $number;
112 | }
113 | // $digits must be a unitless number
114 | @if type-of($digits) != number {
115 | @warn '#{ $digits } is not a number.';
116 | @return $number;
117 | } @else if not unitless($digits) {
118 | @warn '#{ $digits } has a unit.';
119 | @return $number;
120 | }
121 | @if $digits > 0 {
122 | @for $i from 1 through $digits {
123 | $n: $n * 10;
124 | }
125 | }
126 | @if $mode == round {
127 | @return round($number * $n) / $n;
128 | } @else if $mode == ceil {
129 | @return ceil($number * $n) / $n;
130 | } @else if $mode == floor {
131 | @return floor($number * $n) / $n;
132 | } @else {
133 | @warn '#{ $mode } is undefined keyword.';
134 | @return $number;
135 | }
136 | }
137 |
138 | @mixin columns($numCols, $gutter: 2em) {
139 | /* Stop IE from rounding number up */
140 | $roundPercentage: decimal-round((100% / $numCols), 1);
141 | @include safe-flexbox;
142 | flex-wrap: wrap;
143 | justify-content: flex-start;
144 | > * {
145 | width: 100%;
146 | flex-shrink: 0;
147 | width: calc(#{$roundPercentage} - #{$gutter / $numCols * ($numCols - 1)});
148 | margin-right: $gutter;
149 | margin-bottom: 0;
150 | @for $i from 1 through 16 {
151 | $n: #{$i}#{"n+"}#{$i}; // this is dumb
152 | &:nth-child(#{$n}) {
153 | @if($i == $numCols) {
154 | margin-right: 0;
155 | }
156 | @elseif($i < $numCols) {
157 | margin-right: $gutter;
158 | }
159 | }
160 | }
161 | &:last-child {
162 | margin-right: 0;
163 | }
164 | }
165 | }
166 |
167 | @mixin safe-flexbox {
168 | display: flex;
169 | > * {
170 | -ms-flex: 0 1 auto;
171 | }
172 | }
173 |
174 | @mixin safe-inline-flexbox {
175 | display: inline-flex;
176 | > * {
177 | -ms-flex: 0 1 auto;
178 | }
179 | }
180 |
181 | @mixin absolute-center {
182 | position: absolute;
183 | left: 50%;
184 | top: 50%;
185 | transform: translate(-50%, -50%);
186 | }
187 |
188 | @mixin absolute-center--horizontal {
189 | position: absolute;
190 | left: 50%;
191 | right: auto;
192 | transform: translateX(-50%);
193 | }
194 |
195 | @mixin absolute-center--vertical {
196 | position: absolute;
197 | top: 50%;
198 | bottom: auto;
199 | transform: translateY(-50%);
200 | }
201 |
202 | @mixin reset-absolute-center {
203 | position: static;
204 | left: auto;
205 | top: auto;
206 | transform: none;
207 | }
208 |
209 | @mixin fullscreen {
210 | position: fixed;
211 | width: 100%;
212 | height: 100vh;
213 | left: 0;
214 | top: 0;
215 | }
216 |
217 | @function em($pixels, $dens: 1) {
218 | @return $pixels / $base-font-size / $dens * 1em;
219 | }
220 |
221 | @function rem($pixels, $dens: 1) {
222 | @return $pixels / $base-font-size / $dens * 1rem;
223 | }
224 |
225 | @function em2rem($em) {
226 | @return deunit($em) * 1rem;
227 | }
228 |
229 | @function rem2em($rem) {
230 | @return deunit($rem) * 1em;
231 | }
232 |
233 | @function deunit($value) {
234 | @return $value / ($value * 0 + 1);
235 | }
236 |
237 | @mixin font-legibility {
238 | -webkit-font-smoothing: antialiased;
239 | -moz-osx-font-smoothing: grayscale;
240 | }
241 |
242 | @mixin visibility--hidden {
243 | visibility: hidden;
244 | pointer-events: none;
245 | opacity: 0;
246 | }
247 |
248 | @mixin visibility--visible {
249 | visibility: visible;
250 | pointer-events: visible;
251 | opacity: 1;
252 | }
253 |
254 | @mixin input--hidden {
255 | width: 0.1px;
256 | height: 0.1px;
257 | opacity: 0;
258 | overflow: hidden;
259 | position: absolute;
260 | z-index: -1;
261 | }
262 |
263 | @mixin truncate-text($width) {
264 | width: $width;
265 | white-space: nowrap;
266 | overflow: hidden;
267 | text-overflow: ellipsis;
268 | }
269 |
270 | @mixin buttonStyles($bg, $text, $border) {
271 | background-color: $bg;
272 | color: $text;
273 | border: $border;
274 | }
275 |
--------------------------------------------------------------------------------
/src/assets/styles/common/resets.scss:
--------------------------------------------------------------------------------
1 | @mixin reset {
2 | margin: 0;
3 | padding: 0;
4 | line-height: 1;
5 | }
6 |
7 | @mixin reset-ul {
8 | @include reset;
9 | li {
10 | list-style: none;
11 | }
12 | }
13 |
14 | @mixin reset-a {
15 | text-decoration: none;
16 | color: inherit;
17 | }
18 |
19 | @mixin reset-button {
20 | @include reset;
21 | appearance: none;
22 | border: none;
23 | background: none;
24 | font-family: inherit;
25 | outline: 0;
26 | cursor: pointer;
27 | &[disabled] {
28 | opacity: 0.5;
29 | }
30 | }
31 |
32 | @mixin reset-text-input {
33 | @include reset;
34 | background: transparent;
35 | font-family: inherit;
36 | border: none;
37 | color: #000;
38 | resize: none;
39 | &:focus {
40 | outline: none;
41 | }
42 | &::-webkit-input-placeholder {
43 | color: rgba(0, 0, 0, 0.3);
44 | }
45 | &::-moz-placeholder {
46 | color: rgba(0, 0, 0, 0.3);
47 | }
48 | &:-ms-input-placeholder {
49 | color: rgba(0, 0, 0, 0.3);
50 | }
51 | &:-moz-placeholder {
52 | color: rgba(0, 0, 0, 0.3);
53 | }
54 | }
55 |
56 | @mixin reset-iframe {
57 | @include reset;
58 | border: none;
59 | width: 100%;
60 | }
61 |
62 | @mixin reset-select {
63 | appearance: none;
64 | border: none;
65 | background-color: transparent;
66 | -webkit-tap-highlight-color: rgba(255, 255, 255, 0);
67 | -webkit-focus-ring-color: rgba(255, 255, 255, 0);
68 | outline: none;
69 | }
70 |
--------------------------------------------------------------------------------
/src/assets/styles/main.scss:
--------------------------------------------------------------------------------
1 | // Base
2 | @import 'base/normalize';
3 | @import 'base/colors';
4 | @import 'base/variables';
5 | @import 'base/media_queries';
6 | @import 'base/defaults';
7 |
8 | // Common
9 | @import 'common/resets';
10 | @import 'common/helpers';
11 |
12 | // Modules
13 | @import 'modules/app';
14 | @import 'modules/error';
15 | @import 'modules/input';
16 | @import 'modules/invoice';
17 | @import 'modules/logo';
18 | @import 'modules/submit';
19 | @import 'modules/options';
20 | @import 'modules/qrcode';
21 |
22 |
--------------------------------------------------------------------------------
/src/assets/styles/modules/app.scss:
--------------------------------------------------------------------------------
1 | $container-width: 700px;
2 |
3 | // App Layout
4 | body {
5 | background: $app-background;
6 | }
7 |
8 | .app {
9 | background: $app-background;
10 | display: flex;
11 | flex-direction: column;
12 | align-items: center;
13 | height: 100%;
14 | width: 100%;
15 | padding: $base-spacing;
16 |
17 | @include larger-than(mobile) {
18 | justify-content: center;
19 | height: 100%;
20 | }
21 |
22 | &--opened {
23 | @include larger-than(mobile) {
24 | height: auto;
25 | }
26 | }
27 |
28 | &__row {
29 | display: flex;
30 | flex-direction: column;
31 | align-items: center;
32 | position: relative;
33 | width: 100%;
34 | margin-bottom: $base-spacing;
35 |
36 | @include larger-than(mobile) {
37 | width: 100%;
38 | max-width: $container-width;
39 | flex-direction: row;
40 | }
41 | }
42 |
43 | &__column {
44 | margin-top: 2.5 * $base-spacing;
45 | display: flex;
46 | flex-direction: column;
47 | align-items: center;
48 | position: relative;
49 | width: 100%;
50 |
51 | @include larger-than(mobile) {
52 | width: $container-width;
53 | }
54 |
55 | &--invoice-loaded {
56 | margin-top: 0;
57 | }
58 |
59 | &--error {
60 | margin-top: 0;
61 | }
62 | }
63 |
64 | &__submit {
65 | display: flex;
66 | flex-direction: row;
67 | position: relative;
68 | margin-top: $base-spacing;
69 |
70 | @include larger-than(mobile) {
71 | margin-top: 0;
72 | padding-left: $base-spacing / 2;
73 | }
74 |
75 | &--invoice-loaded {
76 | position: absolute;
77 | margin-top: 0;
78 | right: 0;
79 | top: 7px;
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/assets/styles/modules/error.scss:
--------------------------------------------------------------------------------
1 | // Error
2 | .error {
3 | position: relative;
4 | width: 100%;
5 |
6 | &__container {
7 | align-items: center;
8 | background: $error-color;
9 | border: $border;
10 | border-radius: $border-radius;
11 | padding: ($base-spacing + 5px);
12 | }
13 |
14 | &__message {
15 | color: $text-primary;
16 | font-size: $font-extra-small-size;
17 | font-weight: $font-weight-bold;
18 | line-height: 1.6;
19 |
20 | @include larger-than(mobile) {
21 | font-size: $font-small-size;
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/assets/styles/modules/input.scss:
--------------------------------------------------------------------------------
1 | // Input
2 | .input {
3 | display: flex;
4 | flex-direction: row;
5 | position: relative;
6 | width: 100%;
7 |
8 | &__text {
9 | background: $background-primary;
10 | border-radius: $border-radius;
11 | padding: $base-spacing 15px $base-spacing 55px;
12 | font-size: $font-small-size;
13 | font-weight: $font-weight-bold;
14 | border: $border;
15 | outline: none;
16 | width: 100%;
17 | color: $text-primary;
18 | font-family: $font-family;
19 | position: relative;
20 |
21 | @include larger-than(mobile) {
22 | font-size: $font-base-size;
23 | min-width: $input-container-width;
24 | padding: $base-spacing 25px $base-spacing 75px;
25 | }
26 | }
27 |
28 | &__asset {
29 | position: absolute;
30 | top: 17px;
31 | left: 15px;
32 | width: 28px;
33 | height: 30px;
34 | z-index: 5;
35 | opacity: 0.6;
36 |
37 | @include larger-than(mobile) {
38 | left: $base-spacing;
39 | top: 15px;
40 | width: 35px;
41 | height: 2 * $base-spacing;
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/assets/styles/modules/invoice.scss:
--------------------------------------------------------------------------------
1 | // Invoice
2 | .invoice {
3 | background: $background-primary;
4 | border-radius: $border-radius;
5 | width: 100%;
6 |
7 | margin: 0 auto;
8 | padding: ($base-spacing / 2) 0;
9 | opacity: 0;
10 | visibility: hidden;
11 | border: $border;
12 | margin-bottom: 20px;
13 |
14 | &--opened {
15 | opacity: 1;
16 | visibility: visible;
17 | }
18 |
19 | &__item {
20 | padding: ($base-spacing / 2) 20px;
21 | display: flex;
22 | flex-direction: column;
23 |
24 | @include larger-than(mobile) {
25 | flex-direction: row;
26 | }
27 |
28 | &--nested {
29 | flex-direction: column;
30 | }
31 | }
32 |
33 | &__item-title {
34 | color: $text-secondary;
35 | font-weight: $font-weight-bold;
36 | min-width: 200px;
37 | padding-bottom: 5px;
38 |
39 | @include larger-than(mobile) {
40 | padding-bottom: 0;
41 | }
42 | }
43 |
44 | &__item-value {
45 | font-weight: $font-weight-bold;
46 | color: $text-primary;
47 | width: 100%;
48 | word-break: break-all;
49 |
50 | &--nested {
51 | padding: ($base-spacing / 2) 0;
52 |
53 | &:last-child {
54 | padding-bottom: 0;
55 | }
56 | }
57 | }
58 |
59 | &__nested-item {
60 | padding: ($base-spacing / 4) 0;
61 | display: flex;
62 | flex-direction: row;
63 | padding-left: $base-spacing;
64 | }
65 |
66 | &__nested-title {
67 | font-size: 13px;
68 | color: $text-secondary;
69 | font-weight: $font-weight-bold;
70 | min-width: 180px;
71 | }
72 |
73 | &__nested-value {
74 | font-weight: $font-weight-bold;
75 | color: $text-primary;
76 | width: 100%;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/assets/styles/modules/logo.scss:
--------------------------------------------------------------------------------
1 | // Logo
2 | .logo {
3 | width: 100%;
4 | display: flex;
5 | flex-direction: column;
6 | align-items: flex-start;
7 | justify-content: flex-start;
8 | margin-bottom: $base-spacing + 5;
9 |
10 | @include larger-than(mobile) {
11 | align-items: center;
12 | padding: 2 * $base-spacing 0;
13 | padding-bottom: 2 * $base-spacing;
14 | margin-bottom: 2 * $base-spacing + 5;
15 | }
16 |
17 | &__title {
18 | color: $text-primary;
19 | font-size: 24px;
20 | font-weight: $font-weight-bold;
21 |
22 | @include larger-than(mobile) {
23 | font-size: 60px;
24 | }
25 | }
26 |
27 | &__subtitle {
28 | text-align: left;
29 | color: $text-tertiary;
30 | padding-top: $base-spacing;
31 | font-weight: $font-weight-bold;
32 | font-size: $font-extra-small-size;
33 |
34 | @include larger-than(mobile) {
35 | text-align: center;
36 | font-size: $font-base-size;
37 | }
38 | }
39 |
40 | &__subtitle-small {
41 | display: block;
42 | font-size: 12px;
43 | color: $text-primary;
44 | padding-top: 0.5 * $base-spacing;
45 | font-weight: $font-weight-bold;
46 |
47 | @include larger-than(mobile) {
48 | font-size: 16px;
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/assets/styles/modules/options.scss:
--------------------------------------------------------------------------------
1 | .options {
2 | position: absolute;
3 | bottom: $base-spacing;
4 | right: $base-spacing;
5 | left: $base-spacing;
6 |
7 | @include larger-than(mobile) {
8 | top: $base-spacing;
9 | right: $base-spacing;
10 | bottom: auto;
11 | left: auto;
12 | }
13 |
14 | &--hide {
15 | opacity: 0;
16 | visibility: hidden;
17 |
18 | @include larger-than(mobile) {
19 | opacity: 1;
20 | visibility: visible;
21 | }
22 | }
23 |
24 | &__wrapper {
25 | display: flex;
26 | flex-direction: row;
27 | justify-content: space-between;
28 |
29 | @include larger-than(mobile) {
30 | justify-content: flex-end;
31 | }
32 | }
33 |
34 | &__bitcoin {
35 | display: flex;
36 | flex-direction: row;
37 | align-items: center;
38 | order: 2;
39 |
40 | @include larger-than(mobile) {
41 | order: 1;
42 | }
43 |
44 | &--opened {
45 | .options__bitcoin-address {
46 | opacity: 1;
47 | visibility: visible;
48 | }
49 |
50 | .options__bitcoin-icon {
51 | opacity: 1;
52 | }
53 | }
54 | }
55 |
56 | &__bitcoin-address {
57 | position: absolute;
58 | right: -5px;
59 | top: -1.5 * $base-spacing;
60 | opacity: 0;
61 | visibility: hidden;
62 | margin-right: $base-spacing / 2;
63 | color: #ffffff;
64 | font-weight: 900;
65 | font-size: 12px;
66 |
67 | @include larger-than(mobile) {
68 | position: relative;
69 | top: auto;
70 | right: auto;
71 | padding-right: $base-spacing / 2;
72 | font-size: 14px;
73 | }
74 | }
75 |
76 | &__bitcoin-icon-wrapper {
77 | @include reset-button;
78 | width: 40px;
79 | height: 40px;
80 | background: $background-primary;
81 | border-radius: 50%;
82 | display: flex;
83 | align-items: center;
84 | justify-content: center;
85 |
86 | &:hover {
87 | .options__bitcoin-icon {
88 | opacity: 1;
89 | }
90 | }
91 | }
92 |
93 | &__bitcoin-icon {
94 | width: 25px;
95 | height: 25px;
96 | opacity: 0.6;
97 | }
98 |
99 | &__github {
100 | width: 40px;
101 | height: 40px;
102 | background: $background-primary;
103 | border-radius: 50%;
104 | display: flex;
105 | align-items: center;
106 | justify-content: center;
107 | order: 1;
108 |
109 | @include larger-than(mobile) {
110 | order: 2;
111 | margin-left: $base-spacing / 2;
112 | }
113 |
114 | &:hover {
115 | .options__github-icon {
116 | opacity: 1;
117 | }
118 | }
119 | }
120 |
121 | &__github-icon {
122 | width: 25px;
123 | height: 25px;
124 | opacity: 0.6;
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/assets/styles/modules/qrcode.scss:
--------------------------------------------------------------------------------
1 | // QRCode
2 | .qrcode {
3 | width: $icon-size;
4 | height: $icon-size;
5 | z-index: 10;
6 | align-self: center;
7 | display: flex;
8 | flex-direction: column;
9 | justify-content: center;
10 | align-items: center;
11 | margin-left: 10px;
12 |
13 | &:hover {
14 | .qrcode__img {
15 | filter: brightness(150%);
16 | }
17 | }
18 |
19 | &__container {
20 | position: relative;
21 | height: $icon-size;
22 | width: $icon-size;
23 | border-radius: 50%;
24 | background: $background-primary;
25 | display: flex;
26 | flex-direction: row;
27 | align-items: center;
28 | justify-content: center;
29 | border: $border;
30 | cursor: pointer;
31 |
32 | &--opened {
33 | width: 300px;
34 | height: 300px;
35 | }
36 | }
37 |
38 | &__modal {
39 | position: fixed;
40 | height: 100vh;
41 | width: 100vw;
42 | top: 0;
43 | left: 0;
44 | background-color: rgba($color: #292929, $alpha: 0.9)
45 | }
46 |
47 | &--opened {
48 | position: absolute;
49 | height: 100vh;
50 | width: 100%;
51 | }
52 |
53 | &__img {
54 | width: 30px;
55 | height: 30px;
56 | background-color: $text-tertiary;
57 | border-radius: 2px;
58 | box-shadow: 0px 2px 2px 0px $text-secondary;
59 |
60 | &:hover {
61 | cursor: pointer;
62 | }
63 |
64 | &--opened {
65 | box-shadow: none;
66 | background-color: transparent;
67 | width: 32px;
68 | height: 32px;
69 | position: absolute;
70 | z-index: 10;
71 | right: 0;
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/assets/styles/modules/submit.scss:
--------------------------------------------------------------------------------
1 | $submit-icon-size: 40px;
2 |
3 | // Submit
4 | .submit {
5 | height: $icon-size;
6 | width: $icon-size;
7 | border-radius: 50%;
8 | background: $background-primary;
9 | display: flex;
10 | flex-direction: row;
11 | align-items: center;
12 | justify-content: center;
13 | border: $border;
14 | cursor: pointer;
15 | z-index: 3;
16 |
17 | @include larger-than(tablet) {
18 | border-radius: 50%;
19 | }
20 |
21 | &:hover {
22 | .submit__asset {
23 | opacity: 1;
24 | }
25 | }
26 |
27 | &__asset {
28 | width: 60%;
29 | opacity: 0.6;
30 | }
31 |
32 | &__close {
33 | border-radius: unset;
34 | border: 0;
35 | height: $submit-icon-size;
36 | width: $submit-icon-size;
37 | top: $base-spacing / 2;
38 |
39 | @include larger-than(mobile) {
40 | margin-top: 4px;
41 | border-radius: 50%;
42 | margin-right: 1px;
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/constants/app.js:
--------------------------------------------------------------------------------
1 | // App-Wide Constants
2 | export const APP_NAME = 'Lightning Decoder';
3 | export const APP_TAGLINE = 'Decode Lightning Network Requests';
4 | export const APP_SUBTAGLINE = 'BOLT11, LNURL, and Lightning Address'
5 | export const APP_INPUT_PLACEHOLDER = 'Enter Invoice';
6 | export const APP_GITHUB = 'https://github.com/andrerfneves/lightning-decoder';
--------------------------------------------------------------------------------
/src/constants/keys.js:
--------------------------------------------------------------------------------
1 | // BOLT11 Keys
2 | export const COIN_TYPE_KEY = 'coinType';
3 | export const COMPLETE_KEY = 'complete';
4 | export const PAYEE_NODE_KEY = 'payeeNodeKey';
5 | export const PAYMENT_REQUEST_KEY = 'paymentRequest';
6 | export const PREFIX_KEY = 'prefix';
7 | export const RECOVERY_FLAG_KEY = 'recoveryFlag';
8 | export const SATOSHIS_KEY = 'satoshis';
9 | export const MILLISATOSHIS_KEY = 'millisatoshis';
10 | export const SIGNATURE_KEY = 'signature';
11 | export const TIMESTAMP_KEY = 'timestamp';
12 | export const TIMESTAMP_STRING_KEY = 'timestampString';
13 | export const WORDS_TEMP_KEY = 'wordsTemp';
14 | export const TAGS_KEY = 'tags';
15 | export const COMMIT_HASH_KEY = 'purpose_commit_hash';
16 | export const PAYMENT_HASH_KEY = 'payment_hash';
17 | export const FALLBACK_ADDRESS_KEY = 'fallback_address';
18 | export const CODE_KEY = 'code';
19 | export const ADDRESS_KEY = 'address';
20 | export const ADDRESS_HASH_KEY = 'addressHash';
21 | export const DESCRIPTION_KEY = 'description';
22 | export const EXPIRE_TIME = 'expire_time';
23 | export const EXPIRY_HTLC = 'expiry_htlc';
24 | export const MIN_FINAL_CLTV_EXPIRY = 'min_final_cltv_expiry';
25 | export const TIME_EXPIRE_DATE_STRING = 'timeExpireDateString';
26 | export const TIME_EXPIRE_DATE = 'timeExpireDate';
27 | export const timeExpireDate = 'min_final_cltv_expiry';
28 | export const UNKNOWN_TAG_KEY = 'unknownTag';
29 | export const ROUTING_INFO_KEY = 'routing_info';
30 | export const TAG_CODE_KEY = 'tagCode';
31 | export const TAG_WORDS_KEY = 'words';
32 | export const SHORT_CHANNEL_KEY = 'short_channel_id';
33 | export const PUBKEY_KEY = 'pubkey';
34 | export const FEE_PROPORTIONAL_KEY = 'fee_proportional_millionths';
35 | export const FEE_BASE_MSAT_KEY = 'fee_base_msat';
36 | export const CLTV_EXPIRY_DELTA_KEY = 'cltv_expiry_delta';
37 | export const CALLBACK_KEY = 'callback';
38 | export const COMMENT_ALLOWED_KEY = 'commentAllowed';
39 | export const LNURL_TAG_KEY = 'tag';
40 | export const LNURL_K1_KEY = 'k1';
41 | export const DEFAULT_DESCRIPTION_KEY = 'defaultDescription';
42 | export const LNURL_METADATA_KEY = 'metadata';
43 | export const MIN_SENDABLE_KEY = 'minSendable';
44 | export const MAX_SENDABLE_KEY = 'maxSendable';
45 | export const MAX_WITHDRAWABLE_KEY = 'minWithdrawable';
46 | export const MIN_WITHDRAWABLE_KEY = 'minWithdrawable';
47 | export const LN_ADDRESS_DOMAIN_KEY = 'domain';
48 | export const LN_ADDRESS_USERNAME_KEY = 'username';
49 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | // Core Libs
2 | import React from 'react';
3 | import ReactDOM from 'react-dom';
4 |
5 | // Main Component
6 | import { App } from './app';
7 |
8 | // DOM Render
9 | ReactDOM.render(
10 | ,
11 | document.getElementById('root'),
12 | );
13 |
14 |
--------------------------------------------------------------------------------
/src/lib/bolt11.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | const crypto = require('crypto')
3 | const bech32 = require('bech32')
4 | const secp256k1 = require('secp256k1')
5 | const Buffer = require('safe-buffer').Buffer
6 | const BN = require('bn.js')
7 | const bitcoinjsAddress = require('bitcoinjs-lib/src/address')
8 | const cloneDeep = require('lodash/cloneDeep')
9 | const coininfo = require('coininfo')
10 |
11 | const BITCOINJS_NETWORK_INFO = {
12 | bitcoin: coininfo.bitcoin.main.toBitcoinJS(),
13 | testnet: coininfo.bitcoin.test.toBitcoinJS(),
14 | regtest: coininfo.bitcoin.regtest.toBitcoinJS(),
15 | simnet: coininfo.bitcoin.regtest.toBitcoinJS(),
16 | litecoin: coininfo.litecoin.main.toBitcoinJS(),
17 | litecoin_testnet: coininfo.litecoin.test.toBitcoinJS()
18 | }
19 | BITCOINJS_NETWORK_INFO.bitcoin.bech32 = 'bc'
20 | BITCOINJS_NETWORK_INFO.testnet.bech32 = 'tb'
21 | BITCOINJS_NETWORK_INFO.regtest.bech32 = 'bcrt'
22 | BITCOINJS_NETWORK_INFO.simnet.bech32 = 'sb'
23 | BITCOINJS_NETWORK_INFO.litecoin.bech32 = 'ltc'
24 | BITCOINJS_NETWORK_INFO.litecoin_testnet.bech32 = 'tltc'
25 |
26 | // defaults for encode; default timestamp is current time at call
27 | const DEFAULTNETWORKSTRING = 'main'
28 | const DEFAULTNETWORK = BITCOINJS_NETWORK_INFO[DEFAULTNETWORKSTRING]
29 | const DEFAULTEXPIRETIME = 3600
30 | const DEFAULTCLTVEXPIRY = 9
31 | const DEFAULTDESCRIPTION = ''
32 |
33 | const VALIDWITNESSVERSIONS = [0]
34 |
35 | const BECH32CODES = {
36 | bc: 'bitcoin',
37 | tb: 'testnet',
38 | bcrt: 'regtest',
39 | sb: 'simnet',
40 | ltc: 'litecoin',
41 | tltc: 'litecoin_testnet'
42 | }
43 |
44 | const DIVISORS = {
45 | m: new BN(1e3, 10),
46 | u: new BN(1e6, 10),
47 | n: new BN(1e9, 10),
48 | p: new BN(1e12, 10)
49 | }
50 |
51 | const MAX_MILLISATS = new BN('2100000000000000000', 10)
52 |
53 | const MILLISATS_PER_BTC = new BN(1e11, 10)
54 | const MILLISATS_PER_MILLIBTC = new BN(1e8, 10)
55 | const MILLISATS_PER_MICROBTC = new BN(1e5, 10)
56 | const MILLISATS_PER_NANOBTC = new BN(1e2, 10)
57 | const PICOBTC_PER_MILLISATS = new BN(10, 10)
58 |
59 | const TAGCODES = {
60 | payment_hash: 1,
61 | description: 13,
62 | payee_node_key: 19,
63 | purpose_commit_hash: 23, // commit to longer descriptions (like a website)
64 | expire_time: 6, // default: 3600 (1 hour)
65 | min_final_cltv_expiry: 24, // default: 9
66 | fallback_address: 9,
67 | routing_info: 3 // for extra routing info (private etc.)
68 | }
69 |
70 | // reverse the keys and values of TAGCODES and insert into TAGNAMES
71 | const TAGNAMES = {}
72 | for (let i = 0, keys = Object.keys(TAGCODES); i < keys.length; i++) {
73 | let currentName = keys[i]
74 | let currentCode = TAGCODES[keys[i]].toString()
75 | TAGNAMES[currentCode] = currentName
76 | }
77 |
78 | const TAGENCODERS = {
79 | payment_hash: hexToWord, // 256 bits
80 | description: textToWord, // string variable length
81 | payee_node_key: hexToWord, // 264 bits
82 | purpose_commit_hash: purposeCommitEncoder, // 256 bits
83 | expire_time: intBEToWords, // default: 3600 (1 hour)
84 | min_final_cltv_expiry: intBEToWords, // default: 9
85 | fallback_address: fallbackAddressEncoder,
86 | routing_info: routingInfoEncoder // for extra routing info (private etc.)
87 | }
88 |
89 | const TAGPARSERS = {
90 | '1': (words) => wordsToBuffer(words, true).toString('hex'), // 256 bits
91 | '13': (words) => wordsToBuffer(words, true).toString('utf8'), // string variable length
92 | '19': (words) => wordsToBuffer(words, true).toString('hex'), // 264 bits
93 | '23': (words) => wordsToBuffer(words, true).toString('hex'), // 256 bits
94 | '6': wordsToIntBE, // default: 3600 (1 hour)
95 | '24': wordsToIntBE, // default: 9
96 | '9': fallbackAddressParser,
97 | '3': routingInfoParser // for extra routing info (private etc.)
98 | }
99 |
100 | const unknownTagName = 'unknownTag'
101 |
102 | function unknownEncoder (data) {
103 | data.words = bech32.decode(data.words, Number.MAX_SAFE_INTEGER).words
104 | return data
105 | }
106 |
107 | function getUnknownParser (tagCode) {
108 | return (words) => ({
109 | tagCode: parseInt(tagCode),
110 | words: bech32.encode('unknown', words, Number.MAX_SAFE_INTEGER)
111 | })
112 | }
113 |
114 | function wordsToIntBE (words) {
115 | return words.reverse().reduce((total, item, index) => {
116 | return total + item * Math.pow(32, index)
117 | }, 0)
118 | }
119 |
120 | function intBEToWords (intBE, bits) {
121 | let words = []
122 | if (bits === undefined) bits = 5
123 | intBE = Math.floor(intBE)
124 | if (intBE === 0) return [0]
125 | while (intBE > 0) {
126 | words.push(intBE & (Math.pow(2, bits) - 1))
127 | intBE = Math.floor(intBE / Math.pow(2, bits))
128 | }
129 | return words.reverse()
130 | }
131 |
132 | function sha256 (data) {
133 | return crypto.createHash('sha256').update(data).digest()
134 | }
135 |
136 | function convert (data, inBits, outBits) {
137 | let value = 0
138 | let bits = 0
139 | let maxV = (1 << outBits) - 1
140 |
141 | let result = []
142 | for (let i = 0; i < data.length; ++i) {
143 | value = (value << inBits) | data[i]
144 | bits += inBits
145 |
146 | while (bits >= outBits) {
147 | bits -= outBits
148 | result.push((value >> bits) & maxV)
149 | }
150 | }
151 |
152 | if (bits > 0) {
153 | result.push((value << (outBits - bits)) & maxV)
154 | }
155 |
156 | return result
157 | }
158 |
159 | function wordsToBuffer (words, trim) {
160 | let buffer = Buffer.from(convert(words, 5, 8, true))
161 | if (trim && words.length * 5 % 8 !== 0) {
162 | buffer = buffer.slice(0, -1)
163 | }
164 | return buffer
165 | }
166 |
167 | function hexToBuffer (hex) {
168 | if (hex !== undefined &&
169 | (typeof hex === 'string' || hex instanceof String) &&
170 | hex.match(/^([a-zA-Z0-9]{2})*$/)) {
171 | return Buffer.from(hex, 'hex')
172 | }
173 | return hex
174 | }
175 |
176 | function textToBuffer (text) {
177 | return Buffer.from(text, 'utf8')
178 | }
179 |
180 | function hexToWord (hex) {
181 | let buffer = hexToBuffer(hex)
182 | return bech32.toWords(buffer)
183 | }
184 |
185 | function textToWord (text) {
186 | let buffer = textToBuffer(text)
187 | let words = bech32.toWords(buffer)
188 | return words
189 | }
190 |
191 | // see encoder for details
192 | function fallbackAddressParser (words, network) {
193 | let version = words[0]
194 | words = words.slice(1)
195 |
196 | let addressHash = wordsToBuffer(words, true)
197 |
198 | let address = null
199 |
200 | switch (version) {
201 | case 17:
202 | address = bitcoinjsAddress.toBase58Check(addressHash, network.pubKeyHash)
203 | break
204 | case 18:
205 | address = bitcoinjsAddress.toBase58Check(addressHash, network.scriptHash)
206 | break
207 | case 0:
208 | address = bitcoinjsAddress.toBech32(addressHash, version, network.bech32)
209 | break
210 | }
211 |
212 | return {
213 | code: version,
214 | address,
215 | addressHash: addressHash.toString('hex')
216 | }
217 | }
218 |
219 | // the code is the witness version OR 17 for P2PKH OR 18 for P2SH
220 | // anything besides code 17 or 18 should be bech32 encoded address.
221 | // 1 word for the code, and right pad with 0 if necessary for the addressHash
222 | // (address parsing for encode is done in the encode function)
223 | function fallbackAddressEncoder (data, network) {
224 | return [data.code].concat(hexToWord(data.addressHash))
225 | }
226 |
227 | // first convert from words to buffer, trimming padding where necessary
228 | // parse in 51 byte chunks. See encoder for details.
229 | function routingInfoParser (words) {
230 | let routes = []
231 | let pubkey, shortChannelId, feeBaseMSats, feeProportionalMillionths, cltvExpiryDelta
232 | let routesBuffer = wordsToBuffer(words, true)
233 | while (routesBuffer.length > 0) {
234 | pubkey = routesBuffer.slice(0, 33).toString('hex') // 33 bytes
235 | shortChannelId = routesBuffer.slice(33, 41).toString('hex') // 8 bytes
236 | feeBaseMSats = parseInt(routesBuffer.slice(41, 45).toString('hex'), 16) // 4 bytes
237 | feeProportionalMillionths = parseInt(routesBuffer.slice(45, 49).toString('hex'), 16) // 4 bytes
238 | cltvExpiryDelta = parseInt(routesBuffer.slice(49, 51).toString('hex'), 16) // 2 bytes
239 |
240 | routesBuffer = routesBuffer.slice(51)
241 |
242 | routes.push({
243 | pubkey,
244 | short_channel_id: shortChannelId,
245 | fee_base_msat: feeBaseMSats,
246 | fee_proportional_millionths: feeProportionalMillionths,
247 | cltv_expiry_delta: cltvExpiryDelta
248 | })
249 | }
250 | return routes
251 | }
252 |
253 | // routing info is encoded first as a large buffer
254 | // 51 bytes for each channel
255 | // 33 byte pubkey, 8 byte short_channel_id, 4 byte millisatoshi base fee (left padded)
256 | // 4 byte fee proportional millionths and a 2 byte left padded CLTV expiry delta.
257 | // after encoding these 51 byte chunks and concatenating them
258 | // convert to words right padding 0 bits.
259 | function routingInfoEncoder (datas) {
260 | let buffer = Buffer.from([])
261 | datas.forEach(data => {
262 | buffer = Buffer.concat([buffer, hexToBuffer(data.pubkey)])
263 | buffer = Buffer.concat([buffer, hexToBuffer(data.short_channel_id)])
264 | buffer = Buffer.concat([buffer, Buffer.from([0, 0, 0].concat(intBEToWords(data.fee_base_msat, 8)).slice(-4))])
265 | buffer = Buffer.concat([buffer, Buffer.from([0, 0, 0].concat(intBEToWords(data.fee_proportional_millionths, 8)).slice(-4))])
266 | buffer = Buffer.concat([buffer, Buffer.from([0].concat(intBEToWords(data.cltv_expiry_delta, 8)).slice(-2))])
267 | })
268 | return hexToWord(buffer)
269 | }
270 |
271 | // if text, return the sha256 hash of the text as words.
272 | // if hex, return the words representation of that data.
273 | function purposeCommitEncoder (data) {
274 | let buffer
275 | if (data !== undefined && (typeof data === 'string' || data instanceof String)) {
276 | if (data.match(/^([a-zA-Z0-9]{2})*$/)) {
277 | buffer = Buffer.from(data, 'hex')
278 | } else {
279 | buffer = sha256(Buffer.from(data, 'utf8'))
280 | }
281 | } else {
282 | throw new Error('purpose or purpose commit must be a string or hex string')
283 | }
284 | return bech32.toWords(buffer)
285 | }
286 |
287 | function tagsItems (tags, tagName) {
288 | let tag = tags.filter(item => item.tagName === tagName)
289 | let data = tag.length > 0 ? tag[0].data : null
290 | return data
291 | }
292 |
293 | function tagsContainItem (tags, tagName) {
294 | return tagsItems(tags, tagName) !== null
295 | }
296 |
297 | function orderKeys (unorderedObj) {
298 | let orderedObj = {}
299 | Object.keys(unorderedObj).sort().forEach((key) => {
300 | orderedObj[key] = unorderedObj[key]
301 | })
302 | return orderedObj
303 | }
304 |
305 | function satToHrp (satoshis) {
306 | if (!satoshis.toString().match(/^\d+$/)) {
307 | throw new Error('satoshis must be an integer')
308 | }
309 | let millisatoshisBN = new BN(satoshis, 10)
310 | return millisatToHrp(millisatoshisBN.mul(new BN(1000, 10)))
311 | }
312 |
313 | function millisatToHrp (millisatoshis) {
314 | if (!millisatoshis.toString().match(/^\d+$/)) {
315 | throw new Error('millisatoshis must be an integer')
316 | }
317 | let millisatoshisBN = new BN(millisatoshis, 10)
318 | let millisatoshisString = millisatoshisBN.toString(10)
319 | let millisatoshisLength = millisatoshisString.length
320 | let divisorString, valueString
321 | if (millisatoshisLength > 11 && /0{11}$/.test(millisatoshisString)) {
322 | divisorString = ''
323 | valueString = millisatoshisBN.div(MILLISATS_PER_BTC).toString(10)
324 | } else if (millisatoshisLength > 8 && /0{8}$/.test(millisatoshisString)) {
325 | divisorString = 'm'
326 | valueString = millisatoshisBN.div(MILLISATS_PER_MILLIBTC).toString(10)
327 | } else if (millisatoshisLength > 5 && /0{5}$/.test(millisatoshisString)) {
328 | divisorString = 'u'
329 | valueString = millisatoshisBN.div(MILLISATS_PER_MICROBTC).toString(10)
330 | } else if (millisatoshisLength > 2 && /0{2}$/.test(millisatoshisString)) {
331 | divisorString = 'n'
332 | valueString = millisatoshisBN.div(MILLISATS_PER_NANOBTC).toString(10)
333 | } else {
334 | divisorString = 'p'
335 | valueString = millisatoshisBN.mul(PICOBTC_PER_MILLISATS).toString(10)
336 | }
337 | return valueString + divisorString
338 | }
339 |
340 | function hrpToSat (hrpString, outputString) {
341 | let millisatoshisBN = hrpToMillisat(hrpString, false)
342 | if (!millisatoshisBN.mod(new BN(1000, 10)).eq(new BN(0, 10))) {
343 | throw new Error('Amount is outside of valid range')
344 | }
345 | let result = millisatoshisBN.div(new BN(1000, 10))
346 | return outputString ? result.toString() : result
347 | }
348 |
349 | function hrpToMillisat (hrpString, outputString) {
350 | let divisor, value
351 | if (hrpString.slice(-1).match(/^[munp]$/)) {
352 | divisor = hrpString.slice(-1)
353 | value = hrpString.slice(0, -1)
354 | } else if (hrpString.slice(-1).match(/^[^munp0-9]$/)) {
355 | throw new Error('Not a valid multiplier for the amount')
356 | } else {
357 | value = hrpString
358 | }
359 |
360 | if (!value.match(/^\d+$/)) throw new Error('Not a valid human readable amount')
361 |
362 | let valueBN = new BN(value, 10)
363 |
364 | let millisatoshisBN = divisor
365 | ? valueBN.mul(MILLISATS_PER_BTC).div(DIVISORS[divisor])
366 | : valueBN.mul(MILLISATS_PER_BTC)
367 |
368 | if (((divisor === 'p' && !valueBN.mod(new BN(10, 10)).eq(new BN(0, 10))) ||
369 | millisatoshisBN.gt(MAX_MILLISATS))) {
370 | throw new Error('Amount is outside of valid range')
371 | }
372 |
373 | return outputString ? millisatoshisBN.toString() : millisatoshisBN
374 | }
375 |
376 | function sign (inputPayReqObj, inputPrivateKey) {
377 | let payReqObj = cloneDeep(inputPayReqObj)
378 | let privateKey = hexToBuffer(inputPrivateKey)
379 | if (payReqObj.complete && payReqObj.paymentRequest) return payReqObj
380 |
381 | if (privateKey === undefined || privateKey.length !== 32 ||
382 | !secp256k1.privateKeyVerify(privateKey)) {
383 | throw new Error('privateKey must be a 32 byte Buffer and valid private key')
384 | }
385 |
386 | let nodePublicKey, tagNodePublicKey
387 | // If there is a payee_node_key tag convert to buffer
388 | if (tagsContainItem(payReqObj.tags, TAGNAMES['19'])) {
389 | tagNodePublicKey = hexToBuffer(tagsItems(payReqObj.tags, TAGNAMES['19']))
390 | }
391 | // If there is payeeNodeKey attribute, convert to buffer
392 | if (payReqObj.payeeNodeKey) {
393 | nodePublicKey = hexToBuffer(payReqObj.payeeNodeKey)
394 | }
395 | // If they are not equal throw an error
396 | if (nodePublicKey && tagNodePublicKey && !tagNodePublicKey.equals(nodePublicKey)) {
397 | throw new Error('payee node key tag and payeeNodeKey attribute must match')
398 | }
399 |
400 | // make sure if either exist they are in nodePublicKey
401 | nodePublicKey = tagNodePublicKey || nodePublicKey
402 |
403 | let publicKey = secp256k1.publicKeyCreate(privateKey)
404 |
405 | // Check if pubkey matches for private key
406 | if (nodePublicKey && !publicKey.equals(nodePublicKey)) {
407 | throw new Error('The private key given is not the private key of the node public key given')
408 | }
409 |
410 | let words = bech32.decode(payReqObj.wordsTemp, Number.MAX_SAFE_INTEGER).words
411 |
412 | // the preimage for the signing data is the buffer of the prefix concatenated
413 | // with the buffer conversion of the data words excluding the signature
414 | // (right padded with 0 bits)
415 | let toSign = Buffer.concat([Buffer.from(payReqObj.prefix, 'utf8'), wordsToBuffer(words)])
416 | // single SHA256 hash for the signature
417 | let payReqHash = sha256(toSign)
418 |
419 | // signature is 64 bytes (32 byte r value and 32 byte s value concatenated)
420 | // PLUS one extra byte appended to the right with the recoveryID in [0,1,2,3]
421 | // Then convert to 5 bit words with right padding 0 bits.
422 | let sigObj = secp256k1.sign(payReqHash, privateKey)
423 | let sigWords = hexToWord(sigObj.signature.toString('hex') + '0' + sigObj.recovery)
424 |
425 | // append signature words to the words, mark as complete, and add the payreq
426 | payReqObj.payeeNodeKey = publicKey.toString('hex')
427 | payReqObj.signature = sigObj.signature.toString('hex')
428 | payReqObj.recoveryFlag = sigObj.recovery
429 | payReqObj.wordsTemp = bech32.encode('temp', words.concat(sigWords), Number.MAX_SAFE_INTEGER)
430 | payReqObj.complete = true
431 | payReqObj.paymentRequest = bech32.encode(payReqObj.prefix, words.concat(sigWords), Number.MAX_SAFE_INTEGER)
432 |
433 | return orderKeys(payReqObj)
434 | }
435 |
436 | /* MUST but default OK:
437 | coinType (default: testnet OK)
438 | timestamp (default: current time OK)
439 |
440 | MUST:
441 | signature OR privatekey
442 | tags[TAGNAMES['1']] (payment hash)
443 | tags[TAGNAMES['13']] OR tags[TAGNAMES['23']] (description or description for hashing (or description hash))
444 |
445 | MUST CHECK:
446 | IF tags[TAGNAMES['19']] (payee_node_key) THEN MUST CHECK THAT PUBKEY = PUBKEY OF PRIVATEKEY / SIGNATURE
447 | IF tags[TAGNAMES['9']] (fallback_address) THEN MUST CHECK THAT THE ADDRESS IS A VALID TYPE
448 | IF tags[TAGNAMES['3']] (routing_info) THEN MUST CHECK FOR ALL INFO IN EACH
449 | */
450 | function encode (inputData, addDefaults) {
451 | // we don't want to affect the data being passed in, so we copy the object
452 | let data = cloneDeep(inputData)
453 |
454 | // by default we will add default values to description, expire time, and min cltv
455 | if (addDefaults === undefined) addDefaults = true
456 |
457 | let canReconstruct = !(data.signature === undefined || data.recoveryFlag === undefined)
458 |
459 | // if no cointype is defined, set to testnet
460 | let coinTypeObj
461 | if (data.coinType === undefined && !canReconstruct) {
462 | data.coinType = DEFAULTNETWORKSTRING
463 | coinTypeObj = DEFAULTNETWORK
464 | } else if (data.coinType === undefined && canReconstruct) {
465 | throw new Error('Need coinType for proper payment request reconstruction')
466 | } else {
467 | // if the coinType is not a valid name of a network in bitcoinjs-lib, fail
468 | if (!BITCOINJS_NETWORK_INFO[data.coinType]) throw new Error('Unknown coin type')
469 | coinTypeObj = BITCOINJS_NETWORK_INFO[data.coinType]
470 | }
471 |
472 | // use current time as default timestamp (seconds)
473 | if (data.timestamp === undefined && !canReconstruct) {
474 | data.timestamp = Math.floor(new Date().getTime() / 1000)
475 | } else if (data.timestamp === undefined && canReconstruct) {
476 | throw new Error('Need timestamp for proper payment request reconstruction')
477 | }
478 |
479 | if (data.tags === undefined) throw new Error('Payment Requests need tags array')
480 |
481 | // If no payment hash, fail
482 | if (!tagsContainItem(data.tags, TAGNAMES['1'])) {
483 | throw new Error('Lightning Payment Request needs a payment hash')
484 | }
485 | // If no description or purpose commit hash/message, fail
486 | if (!tagsContainItem(data.tags, TAGNAMES['13']) && !tagsContainItem(data.tags, TAGNAMES['23'])) {
487 | if (addDefaults) {
488 | data.tags.push({
489 | tagName: TAGNAMES['13'],
490 | data: DEFAULTDESCRIPTION
491 | })
492 | } else {
493 | throw new Error('Payment request requires description or purpose commit hash')
494 | }
495 | }
496 |
497 | // If a description exists, check to make sure the buffer isn't greater than
498 | // 639 bytes long, since 639 * 8 / 5 = 1023 words (5 bit) when padded
499 | if (tagsContainItem(data.tags, TAGNAMES['13']) &&
500 | Buffer.from(tagsItems(data.tags, TAGNAMES['13']), 'utf8').length > 639) {
501 | throw new Error('Description is too long: Max length 639 bytes')
502 | }
503 |
504 | // if there's no expire time, and it is not reconstructing (must have private key)
505 | // default to adding a 3600 second expire time (1 hour)
506 | if (!tagsContainItem(data.tags, TAGNAMES['6']) && !canReconstruct && addDefaults) {
507 | data.tags.push({
508 | tagName: TAGNAMES['6'],
509 | data: DEFAULTEXPIRETIME
510 | })
511 | }
512 |
513 | // if there's no minimum cltv time, and it is not reconstructing (must have private key)
514 | // default to adding a 9 block minimum cltv time (90 minutes for bitcoin)
515 | if (!tagsContainItem(data.tags, TAGNAMES['24']) && !canReconstruct && addDefaults) {
516 | data.tags.push({
517 | tagName: TAGNAMES['24'],
518 | data: DEFAULTCLTVEXPIRY
519 | })
520 | }
521 |
522 | let nodePublicKey, tagNodePublicKey
523 | // If there is a payee_node_key tag convert to buffer
524 | if (tagsContainItem(data.tags, TAGNAMES['19'])) tagNodePublicKey = hexToBuffer(tagsItems(data.tags, TAGNAMES['19']))
525 | // If there is payeeNodeKey attribute, convert to buffer
526 | if (data.payeeNodeKey) nodePublicKey = hexToBuffer(data.payeeNodeKey)
527 | if (nodePublicKey && tagNodePublicKey && !tagNodePublicKey.equals(nodePublicKey)) {
528 | throw new Error('payeeNodeKey and tag payee node key do not match')
529 | }
530 | // in case we have one or the other, make sure it's in nodePublicKey
531 | nodePublicKey = nodePublicKey || tagNodePublicKey
532 | if (nodePublicKey) data.payeeNodeKey = nodePublicKey.toString('hex')
533 |
534 | let code, addressHash, address
535 | // If there is a fallback address tag we must check it is valid
536 | if (tagsContainItem(data.tags, TAGNAMES['9'])) {
537 | let addrData = tagsItems(data.tags, TAGNAMES['9'])
538 | // Most people will just provide address so Hash and code will be undefined here
539 | address = addrData.address
540 | addressHash = addrData.addressHash
541 | code = addrData.code
542 |
543 | if (addressHash === undefined || code === undefined) {
544 | let bech32addr, base58addr
545 | try {
546 | bech32addr = bitcoinjsAddress.fromBech32(address)
547 | addressHash = bech32addr.data
548 | code = bech32addr.version
549 | } catch (e) {
550 | try {
551 | base58addr = bitcoinjsAddress.fromBase58Check(address)
552 | if (base58addr.version === coinTypeObj.pubKeyHash) {
553 | code = 17
554 | } else if (base58addr.version === coinTypeObj.scriptHash) {
555 | code = 18
556 | }
557 | addressHash = base58addr.hash
558 | } catch (f) {
559 | throw new Error('Fallback address type is unknown')
560 | }
561 | }
562 | if (bech32addr && !(bech32addr.version in VALIDWITNESSVERSIONS)) {
563 | throw new Error('Fallback address witness version is unknown')
564 | }
565 | if (bech32addr && bech32addr.prefix !== coinTypeObj.bech32) {
566 | throw new Error('Fallback address network type does not match payment request network type')
567 | }
568 | if (base58addr && base58addr.version !== coinTypeObj.pubKeyHash &&
569 | base58addr.version !== coinTypeObj.scriptHash) {
570 | throw new Error('Fallback address version (base58) is unknown or the network type is incorrect')
571 | }
572 |
573 | // FIXME: If addressHash or code is missing, add them to the original Object
574 | // after parsing the address value... this changes the actual attributes of the data object.
575 | // Not very clean.
576 | // Without this, a person can not specify a fallback address tag with only the address key.
577 | addrData.addressHash = addressHash.toString('hex')
578 | addrData.code = code
579 | }
580 | }
581 |
582 | // If there is route info tag, check that each route has all 4 necessary info
583 | if (tagsContainItem(data.tags, TAGNAMES['3'])) {
584 | let routingInfo = tagsItems(data.tags, TAGNAMES['3'])
585 | routingInfo.forEach(route => {
586 | if (route.pubkey === undefined ||
587 | route.short_channel_id === undefined ||
588 | route.fee_base_msat === undefined ||
589 | route.fee_proportional_millionths === undefined ||
590 | route.cltv_expiry_delta === undefined) {
591 | throw new Error('Routing info is incomplete')
592 | }
593 | if (!secp256k1.publicKeyVerify(hexToBuffer(route.pubkey))) {
594 | throw new Error('Routing info pubkey is not a valid pubkey')
595 | }
596 | let shortId = hexToBuffer(route.short_channel_id)
597 | if (!(shortId instanceof Buffer) || shortId.length !== 8) {
598 | throw new Error('Routing info short channel id must be 8 bytes')
599 | }
600 | if (typeof route.fee_base_msat !== 'number' ||
601 | Math.floor(route.fee_base_msat) !== route.fee_base_msat) {
602 | throw new Error('Routing info fee base msat is not an integer')
603 | }
604 | if (typeof route.fee_proportional_millionths !== 'number' ||
605 | Math.floor(route.fee_proportional_millionths) !== route.fee_proportional_millionths) {
606 | throw new Error('Routing info fee proportional millionths is not an integer')
607 | }
608 | if (typeof route.cltv_expiry_delta !== 'number' ||
609 | Math.floor(route.cltv_expiry_delta) !== route.cltv_expiry_delta) {
610 | throw new Error('Routing info cltv expiry delta is not an integer')
611 | }
612 | })
613 | }
614 |
615 | let prefix = 'ln'
616 | prefix += coinTypeObj.bech32
617 |
618 | let hrpString
619 | // calculate the smallest possible integer (removing zeroes) and add the best
620 | // divisor (m = milli, u = micro, n = nano, p = pico)
621 | if (data.millisatoshis && data.satoshis) {
622 | hrpString = millisatToHrp(new BN(data.millisatoshis, 10))
623 | let hrpStringSat = satToHrp(new BN(data.satoshis, 10))
624 | if (hrpStringSat !== hrpString) {
625 | throw new Error('satoshis and millisatoshis do not match')
626 | }
627 | } else if (data.millisatoshis) {
628 | hrpString = millisatToHrp(new BN(data.millisatoshis, 10))
629 | } else if (data.satoshis) {
630 | hrpString = satToHrp(new BN(data.satoshis, 10))
631 | } else {
632 | hrpString = ''
633 | }
634 |
635 | // bech32 human readable part is lnbc2500m (ln + coinbech32 + satoshis (optional))
636 | // lnbc or lntb would be valid as well. (no value specified)
637 | prefix += hrpString
638 |
639 | // timestamp converted to 5 bit number array (left padded with 0 bits, NOT right padded)
640 | let timestampWords = intBEToWords(data.timestamp)
641 |
642 | let tags = data.tags
643 | let tagWords = []
644 | tags.forEach(tag => {
645 | const possibleTagNames = Object.keys(TAGENCODERS)
646 | if (canReconstruct) possibleTagNames.push(unknownTagName)
647 | // check if the tagName exists in the encoders object, if not throw Error.
648 | if (possibleTagNames.indexOf(tag.tagName) === -1) {
649 | throw new Error('Unknown tag key: ' + tag.tagName)
650 | }
651 |
652 | let words
653 | if (tag.tagName !== unknownTagName) {
654 | // each tag starts with 1 word code for the tag
655 | tagWords.push(TAGCODES[tag.tagName])
656 |
657 | const encoder = TAGENCODERS[tag.tagName]
658 | words = encoder(tag.data)
659 | } else {
660 | let result = unknownEncoder(tag.data)
661 | tagWords.push(result.tagCode)
662 | words = result.words
663 | }
664 | // after the tag code, 2 words are used to store the length (in 5 bit words) of the tag data
665 | // (also left padded, most integers are left padded while buffers are right padded)
666 | tagWords = tagWords.concat([0].concat(intBEToWords(words.length)).slice(-2))
667 | // then append the tag data words
668 | tagWords = tagWords.concat(words)
669 | })
670 |
671 | // the data part of the bech32 is TIMESTAMP || TAGS || SIGNATURE
672 | // currently dataWords = TIMESTAMP || TAGS
673 | let dataWords = timestampWords.concat(tagWords)
674 |
675 | // the preimage for the signing data is the buffer of the prefix concatenated
676 | // with the buffer conversion of the data words excluding the signature
677 | // (right padded with 0 bits)
678 | let toSign = Buffer.concat([Buffer.from(prefix, 'utf8'), Buffer.from(convert(dataWords, 5, 8))])
679 | // single SHA256 hash for the signature
680 | let payReqHash = sha256(toSign)
681 |
682 | // signature is 64 bytes (32 byte r value and 32 byte s value concatenated)
683 | // PLUS one extra byte appended to the right with the recoveryID in [0,1,2,3]
684 | // Then convert to 5 bit words with right padding 0 bits.
685 | let sigWords
686 | if (canReconstruct) {
687 | /* Since BOLT11 does not require a payee_node_key tag in the specs,
688 | most parsers will have to recover the pubkey from the signature
689 | To ensure the tag data has been provided in the right order etc.
690 | we should check that the data we got and the node key given match when
691 | reconstructing a payment request from given signature and recoveryID.
692 | However, if a privatekey is given, the caller is the privkey owner.
693 | Earlier we check if the private key matches the payee node key IF they
694 | gave one. */
695 | if (nodePublicKey) {
696 | let recoveredPubkey = secp256k1.recover(payReqHash, Buffer.from(data.signature, 'hex'), data.recoveryFlag, true)
697 | if (nodePublicKey && !nodePublicKey.equals(recoveredPubkey)) {
698 | throw new Error('Signature, message, and recoveryID did not produce the same pubkey as payeeNodeKey')
699 | }
700 | sigWords = hexToWord(data.signature + '0' + data.recoveryFlag)
701 | } else {
702 | throw new Error('Reconstruction with signature and recoveryID requires payeeNodeKey to verify correctness of input data.')
703 | }
704 | }
705 |
706 | if (sigWords) dataWords = dataWords.concat(sigWords)
707 |
708 | if (tagsContainItem(data.tags, TAGNAMES['6'])) {
709 | data.timeExpireDate = data.timestamp + tagsItems(data.tags, TAGNAMES['6'])
710 | data.timeExpireDateString = new Date(data.timeExpireDate * 1000).toISOString()
711 | }
712 | data.timestampString = new Date(data.timestamp * 1000).toISOString()
713 | data.paymentRequest = data.complete ? bech32.encode(prefix, dataWords, Number.MAX_SAFE_INTEGER) : ''
714 | data.prefix = prefix
715 | data.wordsTemp = bech32.encode('temp', dataWords, Number.MAX_SAFE_INTEGER)
716 | data.complete = !!sigWords
717 |
718 | // payment requests get pretty long. Nothing in the spec says anything about length.
719 | // Even though bech32 loses error correction power over 1023 characters.
720 | return orderKeys(data)
721 | }
722 |
723 | // decode will only have extra comments that aren't covered in encode comments.
724 | // also if anything is hard to read I'll comment.
725 | function decode (paymentRequest, network) {
726 | if (typeof paymentRequest !== 'string') throw new Error('Lightning Payment Request must be string')
727 | if (paymentRequest.slice(0, 2).toLowerCase() !== 'ln') throw new Error('Not a proper lightning payment request')
728 | let decoded = bech32.decode(paymentRequest, Number.MAX_SAFE_INTEGER)
729 | paymentRequest = paymentRequest.toLowerCase()
730 | let prefix = decoded.prefix
731 | let words = decoded.words
732 |
733 | // signature is always 104 words on the end
734 | // cutting off at the beginning helps since there's no way to tell
735 | // ahead of time how many tags there are.
736 | let sigWords = words.slice(-104)
737 | // grabbing a copy of the words for later, words will be sliced as we parse.
738 | let wordsNoSig = words.slice(0, -104)
739 | words = words.slice(0, -104)
740 |
741 | let sigBuffer = wordsToBuffer(sigWords, true)
742 | let recoveryFlag = sigBuffer.slice(-1)[0]
743 | sigBuffer = sigBuffer.slice(0, -1)
744 |
745 | if (!(recoveryFlag in [0, 1, 2, 3]) || sigBuffer.length !== 64) {
746 | throw new Error('Signature is missing or incorrect')
747 | }
748 |
749 | // Without reverse lookups, can't say that the multipier at the end must
750 | // have a number before it, so instead we parse, and if the second group
751 | // doesn't have anything, there's a good chance the last letter of the
752 | // coin type got captured by the third group, so just re-regex without
753 | // the number.
754 | let prefixMatches = prefix.match(/^ln(\S+?)(\d*)([a-zA-Z]?)$/)
755 | if (prefixMatches && !prefixMatches[2]) prefixMatches = prefix.match(/^ln(\S+)$/)
756 | if (!prefixMatches) {
757 | throw new Error('Not a proper lightning payment request')
758 | }
759 |
760 | let bech32Prefix = prefixMatches[1]
761 | let coinType = 'unknown'
762 | let coinNetwork
763 | if (BECH32CODES[bech32Prefix]) {
764 | coinType = BECH32CODES[bech32Prefix]
765 | coinNetwork = BITCOINJS_NETWORK_INFO[coinType]
766 | } else if (network && network.bech32) {
767 | coinNetwork = network
768 | }
769 |
770 | let value = prefixMatches[2]
771 | let satoshis, millisatoshis, removeSatoshis
772 | if (value) {
773 | let divisor = prefixMatches[3]
774 | try {
775 | satoshis = parseInt(hrpToSat(value + divisor, true))
776 | } catch (e) {
777 | satoshis = null
778 | removeSatoshis = true
779 | }
780 | millisatoshis = hrpToMillisat(value + divisor, true)
781 | } else {
782 | satoshis = null
783 | millisatoshis = null
784 | }
785 |
786 | // reminder: left padded 0 bits
787 | let timestamp = wordsToIntBE(words.slice(0, 7))
788 | let timestampString = new Date(timestamp * 1000).toISOString()
789 | words = words.slice(7) // trim off the left 7 words
790 |
791 | let tags = []
792 | let tagName, parser, tagLength, tagWords
793 | // we have no tag count to go on, so just keep hacking off words
794 | // until we have none.
795 | while (words.length > 0) {
796 | let tagCode = words[0].toString()
797 | tagName = TAGNAMES[tagCode] || unknownTagName
798 | parser = TAGPARSERS[tagCode] || getUnknownParser(tagCode)
799 | words = words.slice(1)
800 |
801 | tagLength = wordsToIntBE(words.slice(0, 2))
802 | words = words.slice(2)
803 |
804 | tagWords = words.slice(0, tagLength)
805 | words = words.slice(tagLength)
806 |
807 | // See: parsers for more comments
808 | tags.push({
809 | tagName,
810 | data: parser(tagWords, coinNetwork) // only fallback address needs coinNetwork
811 | })
812 | }
813 |
814 | let timeExpireDate, timeExpireDateString
815 | // be kind and provide an absolute expiration date.
816 | // good for logs
817 | if (tagsContainItem(tags, TAGNAMES['6'])) {
818 | timeExpireDate = timestamp + tagsItems(tags, TAGNAMES['6'])
819 | timeExpireDateString = new Date(timeExpireDate * 1000).toISOString()
820 | }
821 |
822 | let toSign = Buffer.concat([Buffer.from(prefix, 'utf8'), Buffer.from(convert(wordsNoSig, 5, 8))])
823 | let payReqHash = sha256(toSign)
824 | let sigPubkey = secp256k1.recover(payReqHash, sigBuffer, recoveryFlag, true)
825 | if (tagsContainItem(tags, TAGNAMES['19']) && tagsItems(tags, TAGNAMES['19']) !== sigPubkey.toString('hex')) {
826 | throw new Error('Lightning Payment Request signature pubkey does not match payee pubkey')
827 | }
828 |
829 | let finalResult = {
830 | paymentRequest,
831 | complete: true,
832 | prefix,
833 | wordsTemp: bech32.encode('temp', wordsNoSig.concat(sigWords), Number.MAX_SAFE_INTEGER),
834 | coinType,
835 | satoshis,
836 | millisatoshis,
837 | timestamp,
838 | timestampString,
839 | payeeNodeKey: sigPubkey.toString('hex'),
840 | signature: sigBuffer.toString('hex'),
841 | recoveryFlag,
842 | tags
843 | }
844 |
845 | if (removeSatoshis) {
846 | delete finalResult['satoshis']
847 | }
848 |
849 | if (timeExpireDate) {
850 | finalResult = Object.assign(finalResult, {timeExpireDate, timeExpireDateString})
851 | }
852 |
853 | return orderKeys(finalResult)
854 | }
855 |
856 | module.exports = {
857 | encode,
858 | decode,
859 | sign,
860 | satToHrp,
861 | millisatToHrp,
862 | hrpToSat,
863 | hrpToMillisat
864 | }
865 |
--------------------------------------------------------------------------------
/src/utils/internet-identifier.js:
--------------------------------------------------------------------------------
1 | export const validateInternetIdentifier = (internetIdentifier) => {
2 | var re = /\S+@\S+\.\S+/;
3 | return re.test(internetIdentifier);
4 | }
--------------------------------------------------------------------------------
/src/utils/invoices.js:
--------------------------------------------------------------------------------
1 | import bech32 from 'bech32';
2 | import { Buffer } from 'buffer';
3 |
4 | import { validateInternetIdentifier } from './internet-identifier';
5 | import LightningPayReq from '../lib/bolt11';
6 |
7 | const LIGHTNING_SCHEME = 'lightning';
8 | const BOLT11_SCHEME_MAINNET = 'lnbc';
9 | const BOLT11_SCHEME_TESTNET = 'lntb';
10 | const LNURL_SCHEME = 'lnurl';
11 |
12 | export const parseInvoice = async (invoice: string) => {
13 | if (!invoice || invoice === '') {
14 | return null;
15 | }
16 |
17 | const lcInvoice = invoice.trim().toLowerCase();
18 | let requestCode = lcInvoice;
19 |
20 | // Check if this is a Lightning Address
21 | if (validateInternetIdentifier(requestCode)) {
22 | const { success, data, message } = await handleLightningAddress(requestCode);
23 |
24 | if (!success) {
25 | return {
26 | data: null,
27 | error: message,
28 | isLNURL: false,
29 | isLNAddress: true,
30 | };
31 | }
32 |
33 | return {
34 | data,
35 | isLNURL: true,
36 | isLNAddress: true,
37 | }
38 | }
39 |
40 | // Check if Invoice has `lightning` or `lnurl` prefixes
41 | // (9 chars + the `:` or `=` chars) --> 10 characters total
42 | const hasLightningPrefix = lcInvoice.indexOf(`${LIGHTNING_SCHEME}:`) !== -1;
43 | if (hasLightningPrefix) {
44 | // Remove the `lightning` prefix
45 | requestCode = lcInvoice.slice(10, lcInvoice.length);
46 | }
47 |
48 | // (5 chars + the `:` or `=` chars) --> 6 characters total
49 | const hasLNURLPrefix = lcInvoice.indexOf(`${LNURL_SCHEME}:`) !== -1;
50 | if (hasLNURLPrefix) {
51 | // Remove the `lightning` prefix
52 | requestCode = lcInvoice.slice(6, lcInvoice.length);
53 | }
54 |
55 | // Parse LNURL or BOLT11
56 | const isLNURL = requestCode.startsWith(LNURL_SCHEME);
57 | if (isLNURL) {
58 | return {
59 | isLNURL: true,
60 | data: handleLNURL(requestCode)
61 | };
62 | } else {
63 | return {
64 | isLNURL: false,
65 | data: handleBOLT11(requestCode)
66 | };
67 | }
68 | };
69 |
70 | const handleLNURL = (invoice: string) => {
71 | // Decoding bech32 LNURL
72 | const decodedLNURL = bech32.decode(invoice, 1500);
73 | const url = Buffer.from(bech32.fromWords(decodedLNURL.words)).toString();
74 |
75 | return fetch(url)
76 | .then(r => r.json())
77 | };
78 |
79 | const handleLightningAddress = (internetIdentifier: string) => {
80 | const addressArr = internetIdentifier.split('@');
81 |
82 | // Must only have 2 fields (username and domain name)
83 | if (addressArr.length !== 2) {
84 | return {
85 | success: false,
86 | message: 'Invalid internet identifier format.',
87 | };
88 | }
89 |
90 | const [username, domain] = addressArr;
91 |
92 | // Must only have 2 fields (username and domain name)
93 | if (addressArr[1].indexOf('.') === -1) {
94 | return {
95 | success: false,
96 | message: 'Invalid internet identifier format.',
97 | };
98 | }
99 |
100 | const url = `https://${domain}/.well-known/lnurlp/${username}`;
101 |
102 | return fetch(url)
103 | .then(r => r.json())
104 | .then(data => {
105 | data.domain = domain;
106 |
107 | return {
108 | success: true,
109 | data: {
110 | ...data,
111 | domain,
112 | username,
113 | },
114 | }
115 | }).catch(_ => {
116 | return {
117 | success: false,
118 | message: 'This identifier does not support Lightning Address yet.',
119 | };
120 | });
121 | };
122 |
123 | const handleBOLT11 = (invoice: string) => {
124 | // Check if Invoice starts with `lnbc` prefix
125 | if (!invoice.includes(BOLT11_SCHEME_MAINNET) && !invoice.includes(BOLT11_SCHEME_TESTNET)) {
126 | return null;
127 | }
128 |
129 | // Decoded BOLT11 Invoice
130 | const result = LightningPayReq.decode(invoice);
131 |
132 | return result;
133 | };
134 |
135 |
--------------------------------------------------------------------------------
/src/utils/keys.js:
--------------------------------------------------------------------------------
1 | // Key Formatting Utilities
2 | import {
3 | CODE_KEY,
4 | PREFIX_KEY,
5 | PUBKEY_KEY,
6 | EXPIRE_TIME,
7 | ADDRESS_KEY,
8 | EXPIRY_HTLC,
9 | SATOSHIS_KEY,
10 | TAG_CODE_KEY,
11 | CALLBACK_KEY,
12 | LNURL_K1_KEY,
13 | LNURL_TAG_KEY,
14 | TAG_WORDS_KEY,
15 | SIGNATURE_KEY,
16 | COIN_TYPE_KEY,
17 | TIMESTAMP_KEY,
18 | WORDS_TEMP_KEY,
19 | PAYEE_NODE_KEY,
20 | DESCRIPTION_KEY,
21 | COMMIT_HASH_KEY,
22 | UNKNOWN_TAG_KEY,
23 | MAX_SENDABLE_KEY,
24 | MIN_SENDABLE_KEY,
25 | ROUTING_INFO_KEY,
26 | PAYMENT_HASH_KEY,
27 | ADDRESS_HASH_KEY,
28 | TIME_EXPIRE_DATE,
29 | RECOVERY_FLAG_KEY,
30 | MILLISATOSHIS_KEY,
31 | FEE_BASE_MSAT_KEY,
32 | SHORT_CHANNEL_KEY,
33 | LNURL_METADATA_KEY,
34 | COMMENT_ALLOWED_KEY,
35 | PAYMENT_REQUEST_KEY,
36 | MAX_WITHDRAWABLE_KEY,
37 | MIN_WITHDRAWABLE_KEY,
38 | FEE_PROPORTIONAL_KEY,
39 | FALLBACK_ADDRESS_KEY,
40 | TIMESTAMP_STRING_KEY,
41 | MIN_FINAL_CLTV_EXPIRY,
42 | CLTV_EXPIRY_DELTA_KEY,
43 | LN_ADDRESS_DOMAIN_KEY,
44 | LN_ADDRESS_USERNAME_KEY,
45 | TIME_EXPIRE_DATE_STRING,
46 | DEFAULT_DESCRIPTION_KEY,
47 | } from '../constants/keys';
48 |
49 | export const formatDetailsKey = (key) => {
50 | switch (key) {
51 | case COIN_TYPE_KEY:
52 | return 'Chain';
53 | case PAYEE_NODE_KEY:
54 | return 'Payee Pub Key';
55 | case EXPIRE_TIME:
56 | return 'Expire Time';
57 | case PAYMENT_REQUEST_KEY:
58 | return 'Invoice';
59 | case PREFIX_KEY:
60 | return 'Prefix';
61 | case RECOVERY_FLAG_KEY:
62 | return 'Recovery Flag';
63 | case SATOSHIS_KEY:
64 | return 'Amount (Satoshis)';
65 | case MILLISATOSHIS_KEY:
66 | return 'Amount (Millisatoshis)';
67 | case SIGNATURE_KEY:
68 | return 'Transaction Signature';
69 | case TIMESTAMP_STRING_KEY:
70 | return 'Timestamp String';
71 | case WORDS_TEMP_KEY:
72 | return 'Words Temp';
73 | case COMMIT_HASH_KEY:
74 | return 'Commit Hash';
75 | case PAYMENT_HASH_KEY:
76 | return 'Payment Hash';
77 | case FALLBACK_ADDRESS_KEY:
78 | return 'Fallback Address';
79 | case ADDRESS_HASH_KEY:
80 | return 'Address Hash';
81 | case ADDRESS_KEY:
82 | return 'Address';
83 | case CODE_KEY:
84 | return 'Code';
85 | case DESCRIPTION_KEY:
86 | return 'Description';
87 | case EXPIRY_HTLC:
88 | return 'Expiry CLTV';
89 | case TIME_EXPIRE_DATE_STRING:
90 | return 'Time Expire Date String';
91 | case TIME_EXPIRE_DATE:
92 | return 'Time Expire Date';
93 | case TIMESTAMP_KEY:
94 | return 'Timestamp';
95 | case MIN_FINAL_CLTV_EXPIRY:
96 | return 'Minimum Final CLTV Expiry';
97 | case UNKNOWN_TAG_KEY:
98 | return 'Unknown Tag';
99 | case ROUTING_INFO_KEY:
100 | return 'Routing Info';
101 | case TAG_CODE_KEY:
102 | return 'Tag Code';
103 | case TAG_WORDS_KEY:
104 | return 'Tag Words';
105 | case CLTV_EXPIRY_DELTA_KEY:
106 | return 'CLTV Expiry Delta';
107 | case FEE_BASE_MSAT_KEY:
108 | return 'Fee Base (MSats)';
109 | case FEE_PROPORTIONAL_KEY:
110 | return 'Tag Words';
111 | case PUBKEY_KEY:
112 | return 'Public Key';
113 | case SHORT_CHANNEL_KEY:
114 | return 'Short Channel ID';
115 | case CALLBACK_KEY:
116 | return 'Callback URL';
117 | case COMMENT_ALLOWED_KEY:
118 | return 'Comment Allowed (Chars)';
119 | case MAX_SENDABLE_KEY:
120 | return 'Max Sendable (MSats)';
121 | case MIN_SENDABLE_KEY:
122 | return 'Min Sendable (MSats)';
123 | case MAX_WITHDRAWABLE_KEY:
124 | return 'Max Withdrawable (MSats)';
125 | case MIN_WITHDRAWABLE_KEY:
126 | return 'Min Withdrawable (MSats)';
127 | case LNURL_TAG_KEY:
128 | return 'LNURL Tag/Type';
129 | case LNURL_METADATA_KEY:
130 | return 'LNURL Metadata';
131 | case LNURL_K1_KEY:
132 | return 'K1';
133 | case DEFAULT_DESCRIPTION_KEY:
134 | return 'Description';
135 | case LN_ADDRESS_DOMAIN_KEY:
136 | return 'Domain';
137 | case LN_ADDRESS_USERNAME_KEY:
138 | return 'Username';
139 | default:
140 | return 'Error';
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/utils/timestamp.js:
--------------------------------------------------------------------------------
1 | // Timestamp / Date / Time Utilities
2 | import { format } from 'date-fns';
3 |
4 | const DATE_FORMAT = 'ddd, DD MMM YYYY HH:mm:ss A';
5 |
6 | export const formatTimestamp = (time) => format(
7 | time,
8 | DATE_FORMAT,
9 | );
--------------------------------------------------------------------------------