├── .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 | ![Image of Lightning Decoder](https://i.imgur.com/mg6opec.png) 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 | Lightning 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 |
407 | 408 | {text} 409 | 410 |
411 |
412 | ) 413 | } 414 | 415 | if (key === CALLBACK_KEY) { 416 | return ( 417 |
418 |
419 | {formatDetailsKey(key)} 420 |
421 |
422 | 423 | {decodedInvoice[key]} 424 | 425 |
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 | Imager 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 | Submit 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 |
535 |
536 | 542 | GitHub 547 | 548 |
549 |
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 | QRCode 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 | 5 | 9 | -------------------------------------------------------------------------------- /src/assets/images/bitcoin.svg: -------------------------------------------------------------------------------- 1 | 5 | 9 | -------------------------------------------------------------------------------- /src/assets/images/bolt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrerfneves/lightning-decoder/77cf4cdea255ceb26d5d4614e981c3feba69935f/src/assets/images/bolt.png -------------------------------------------------------------------------------- /src/assets/images/close.svg: -------------------------------------------------------------------------------- 1 | 5 | 9 | -------------------------------------------------------------------------------- /src/assets/images/github.svg: -------------------------------------------------------------------------------- 1 | 5 | 9 | -------------------------------------------------------------------------------- /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 | ); --------------------------------------------------------------------------------