├── src ├── react-app-env.d.ts ├── shared │ └── types.ts ├── index.css ├── index.tsx ├── components │ ├── VerifyButton.tsx │ ├── VoteButton.tsx │ ├── PostCard.tsx │ └── PayModal.tsx ├── store │ ├── Provider.tsx │ └── store.ts ├── pages │ ├── PostList.tsx │ ├── CreatePost.tsx │ └── Connect.tsx ├── App.tsx └── lib │ └── api.ts ├── .vscode └── settings.json ├── public ├── robots.txt ├── favicon.png ├── manifest.json └── index.html ├── resources └── builders-guide.polar.zip ├── backend ├── tsconfig.json ├── index.ts ├── posts-db.ts ├── node-manager.ts └── routes.ts ├── .gitignore ├── .prettierrc ├── tsconfig.json ├── package.json └── README.md /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightninglabs/builders-guide-sample-app/HEAD/public/favicon.png -------------------------------------------------------------------------------- /resources/builders-guide.polar.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightninglabs/builders-guide-sample-app/HEAD/resources/builders-guide.polar.zip -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true 6 | }, 7 | "include": [".", "../src/shared"] 8 | } 9 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Builders Guide", 3 | "name": "Builders Guide to the LND Galaxy", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | export interface Post { 2 | id: number; 3 | title: string; 4 | content: string; 5 | username: string; 6 | votes: number; 7 | signature: string; 8 | pubkey: string; 9 | verified: boolean; 10 | } 11 | 12 | export const SocketEvents = { 13 | postUpdated: 'post-updated', 14 | invoicePaid: 'invoice-paid', 15 | }; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # app state 26 | db.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": [".prettierrc", ".babelrc", ".eslintrc", ".stylelintrc"], 5 | "options": { 6 | "parser": "json" 7 | } 8 | } 9 | ], 10 | "printWidth": 90, 11 | "proseWrap": "always", 12 | "singleQuote": true, 13 | "useTabs": false, 14 | "semi": true, 15 | "tabWidth": 2, 16 | "trailingComma": "all", 17 | "bracketSpacing": true, 18 | "jsxBracketSameLine": false, 19 | "arrowParens": "avoid" 20 | } 21 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import '~bootswatch/dist/cosmo/bootstrap.min.css'; 2 | 3 | body { 4 | background-color: #efefef; 5 | min-height: 100vh; 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 16 | monospace; 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "useDefineForClassFields": true, 16 | "noEmit": true, 17 | "jsx": "react" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { configure } from 'mobx'; 4 | import './index.css'; 5 | import App from './App'; 6 | import { StoreProvider } from './store/Provider'; 7 | import { Store } from './store/store'; 8 | 9 | // initialize mobx 10 | configure({ enforceActions: 'observed' }); 11 | 12 | const container = document.getElementById('root'); 13 | const root = createRoot(container!); 14 | root.render( 15 | 16 | 17 | 18 | 19 | , 20 | ); 21 | -------------------------------------------------------------------------------- /src/components/VerifyButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import { Post } from '../shared/types'; 4 | import { useStore } from '../store/Provider'; 5 | 6 | interface Props { 7 | post: Post; 8 | } 9 | 10 | const VerifyButton: React.FC = ({ post }) => { 11 | const store = useStore(); 12 | 13 | const handleVerify = useCallback(() => { 14 | store.verifyPost(post.id); 15 | }, [store, post.id]); 16 | 17 | if (post.verified) { 18 | return null; 19 | } 20 | 21 | return ( 22 | 23 | Verify Signature 24 | 25 | ); 26 | }; 27 | 28 | export default VerifyButton; 29 | -------------------------------------------------------------------------------- /src/components/VoteButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import { Post } from '../shared/types'; 4 | import { useStore } from '../store/Provider'; 5 | 6 | interface Props { 7 | post: Post; 8 | } 9 | 10 | const VoteButton: React.FC = ({ post }) => { 11 | const store = useStore(); 12 | 13 | // create an invoice and show the modal when the button is clicked 14 | const handleUpvoteClick = useCallback(async () => { 15 | await store.showPaymentRequest(post); 16 | }, [store, post]); 17 | 18 | return ( 19 | 20 | Upvote 21 | 22 | ); 23 | }; 24 | 25 | export default VoteButton; 26 | -------------------------------------------------------------------------------- /src/store/Provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Store } from './store'; 3 | 4 | /** 5 | * The react context used to cache the store instance 6 | */ 7 | const StoreContext = React.createContext(undefined); 8 | 9 | /** 10 | * A Context Provider component which should wrap any components that need to 11 | * receive a store via the `useStore` hook 12 | * @param store the store instance to provide to child components via `useStore` 13 | */ 14 | export const StoreProvider: React.FC<{ 15 | children?: React.ReactNode; 16 | store: Store; 17 | }> = ({ children, store }) => { 18 | // const localStore = useLocalObservable(() => store); 19 | return {children}; 20 | }; 21 | 22 | /** 23 | * A React hook used to access the global store from child components that may be 24 | * nested many levels deep in the component tree 25 | */ 26 | export const useStore = (): Store => { 27 | const store = useContext(StoreContext); 28 | if (!store) { 29 | // raise an error if the context data has not been provided in a higher level component 30 | throw new Error('useStore must be used within a StoreProvider.'); 31 | } 32 | return store; 33 | }; 34 | -------------------------------------------------------------------------------- /src/pages/PostList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Jumbotron } from 'react-bootstrap'; 3 | import { observer } from 'mobx-react-lite'; 4 | import PayModal from '../components/PayModal'; 5 | import PostCard from '../components/PostCard'; 6 | import { useStore } from '../store/Provider'; 7 | 8 | const PostList: React.FC = () => { 9 | const store = useStore(); 10 | 11 | if (store.posts.length === 0) { 12 | return ( 13 | 14 | Welcome to r/builders 15 | 16 | It's a ghost town in here. Get the party started by creating the first post. 17 | 18 | 19 | Create a Post 20 | 21 | 22 | ); 23 | } 24 | 25 | return ( 26 | <> 27 | 28 | r/builders 29 | 30 | Create a Post 31 | 32 | 33 | {store.sortedPosts.map(post => ( 34 | 35 | ))} 36 | {store.showPayModal && } 37 | > 38 | ); 39 | }; 40 | 41 | export default observer(PostList); 42 | -------------------------------------------------------------------------------- /src/components/PostCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Badge, Card } from 'react-bootstrap'; 3 | import { Post } from '../shared/types'; 4 | import VerifyButton from './VerifyButton'; 5 | import VoteButton from './VoteButton'; 6 | 7 | interface Props { 8 | post: Post; 9 | } 10 | 11 | const PostCard: React.FC = ({ post }) => { 12 | return ( 13 | 14 | 15 | 16 | {post.title} 17 | 18 | 19 | Posted 20 | {post.signature && ' and signed '} 21 | by {post.username} 22 | {post.verified && ( 23 | 24 | verified 25 | 26 | )} 27 | 28 | {post.content} 29 | 30 | 31 | 32 | {post.votes} votes 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default PostCard; 44 | -------------------------------------------------------------------------------- /src/components/PayModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert, Form, Modal, Spinner } from 'react-bootstrap'; 3 | import { observer } from 'mobx-react-lite'; 4 | import { useStore } from '../store/Provider'; 5 | 6 | const PayModal: React.FC = () => { 7 | const store = useStore(); 8 | 9 | const body = !store.pmtSuccessMsg ? ( 10 | <> 11 | 12 | {store.pmtError && {store.pmtError}} 13 | 14 | Payment Request for {store.pmtAmount} sats to{' '} 15 | {store.pmtForPost?.username} 16 | 17 | 18 | 19 | 20 | 21 | 22 | Waiting for payment to be completed... 23 | 24 | > 25 | ) : ( 26 | {store.pmtSuccessMsg} 27 | ); 28 | 29 | return ( 30 | 31 | 32 | {store.pmtForPost?.title} 33 | 34 | {body} 35 | 36 | ); 37 | }; 38 | 39 | export default observer(PayModal); 40 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 26 | Builders Guide to the LND Galaxy 27 | 28 | 29 | You need to enable JavaScript to run this app. 30 | 31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "builders-guide", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@radar/lnrpc": "^0.11.1-beta.1", 7 | "bootswatch": "^4.5.2", 8 | "cors": "^2.8.5", 9 | "express": "^4.17.1", 10 | "express-ws": "^4.0.0", 11 | "mobx": "^6.0.0", 12 | "mobx-react-lite": "^3.0.0", 13 | "react": "18.2.0", 14 | "react-bootstrap": "^1.3.0", 15 | "react-confetti": "^6.0.0", 16 | "react-dom": "18.2.0", 17 | "react-scripts": "5.0.1", 18 | "typescript": "^3.9.7", 19 | "uuid": "^8.3.0" 20 | }, 21 | "devDependencies": { 22 | "@testing-library/jest-dom": "^4.2.4", 23 | "@testing-library/react": "^9.3.2", 24 | "@testing-library/user-event": "^7.1.2", 25 | "@types/cors": "^2.8.7", 26 | "@types/express": "^4.17.7", 27 | "@types/express-ws": "^3.0.0", 28 | "@types/jest": "^24.0.0", 29 | "@types/node": "^12.0.0", 30 | "@types/react": "18.0.25", 31 | "@types/react-dom": "18.0.9", 32 | "@types/uuid": "8.3.0", 33 | "concurrently": "^5.3.0", 34 | "nodemon": "^2.0.4", 35 | "ts-node": "^8.10.2" 36 | }, 37 | "scripts": { 38 | "dev": "concurrently --kill-others --success first \"yarn:dev:*\"", 39 | "dev:api": "nodemon -I --watch ./backend/ --ext ts --exec ts-node --project ./backend/tsconfig.json ./backend/index.ts", 40 | "dev:web": "yarn start", 41 | "start": "react-scripts start", 42 | "build": "react-scripts build", 43 | "test": "react-scripts test", 44 | "eject": "react-scripts eject" 45 | }, 46 | "eslintConfig": { 47 | "extends": "react-app" 48 | }, 49 | "browserslist": { 50 | "production": [ 51 | ">0.2%", 52 | "not dead", 53 | "not op_mini all" 54 | ], 55 | "development": [ 56 | "last 1 chrome version", 57 | "last 1 firefox version", 58 | "last 1 safari version" 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/pages/CreatePost.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | import { Button, Card, Col, Form, Row } from 'react-bootstrap'; 3 | import { observer } from 'mobx-react-lite'; 4 | import { useStore } from '../store/Provider'; 5 | 6 | const CreatePost: React.FC = () => { 7 | const store = useStore(); 8 | 9 | const [title, setTitle] = useState(''); 10 | const [content, setContent] = useState(''); 11 | 12 | const handleSubmit = useCallback( 13 | async (e: React.FormEvent) => { 14 | e.preventDefault(); 15 | store.createPost(title, content); 16 | }, 17 | [title, content, store], 18 | ); 19 | 20 | return ( 21 | 22 | 23 | Create a new Post 24 | 25 | 26 | Title 27 | setTitle(e.target.value)} 31 | /> 32 | 33 | 34 | Content 35 | setContent(e.target.value)} 41 | /> 42 | 43 | 44 | 45 | 46 | 47 | 48 | Cancel 49 | 50 | 51 | 52 | 53 | Submit 54 | 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default observer(CreatePost); 64 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { Alert, Badge, Container, Dropdown, Nav, Navbar, NavLink } from 'react-bootstrap'; 3 | import Confetti from 'react-confetti'; 4 | import { observer } from 'mobx-react-lite'; 5 | import Connect from './pages/Connect'; 6 | import CreatePost from './pages/CreatePost'; 7 | import PostList from './pages/PostList'; 8 | import { useStore } from './store/Provider'; 9 | 10 | function App() { 11 | const store = useStore(); 12 | 13 | const pages: Record = { 14 | posts: , 15 | create: , 16 | connect: , 17 | }; 18 | 19 | return ( 20 | <> 21 | 22 | 23 | Builder's Guide to the LND Galaxy 24 | 25 | 26 | 27 | 28 | {!store.connected ? ( 29 | 30 | Connect to LND 31 | 32 | ) : ( 33 | <> 34 | 35 | 36 | {store.balance.toLocaleString()} sats 37 | 38 | 39 | 40 | {store.alias} 41 | 42 | Disconnect 43 | 44 | 45 | > 46 | )} 47 | 48 | 49 | 50 | 51 | 52 | 53 | {store.error && ( 54 | 55 | {store.error} 56 | 57 | )} 58 | {pages[store.page]} 59 | 60 | 61 | 62 | > 63 | ); 64 | } 65 | 66 | export default observer(App); 67 | -------------------------------------------------------------------------------- /src/lib/api.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Constants 3 | // 4 | const API_URL = 'http://localhost:4000/api'; 5 | const WS_URL = 'ws://localhost:4000/api/events'; 6 | const TOKEN_KEY = 'token'; 7 | 8 | // 9 | // token persistent storage 10 | // 11 | export const getToken = () => sessionStorage.getItem(TOKEN_KEY) || ''; 12 | export const setToken = (value: string) => sessionStorage.setItem(TOKEN_KEY, value); 13 | export const clearToken = () => sessionStorage.removeItem(TOKEN_KEY); 14 | 15 | // 16 | // Shared fetch wrapper funcs 17 | // 18 | 19 | const httpGet = async (path: string) => { 20 | const url = `${API_URL}/${path}`; 21 | const response = await fetch(url, { 22 | method: 'GET', 23 | headers: { 24 | 'Content-Type': 'application/json', 25 | // add the token from localStorage into every request 26 | 'X-Token': getToken(), 27 | }, 28 | }); 29 | const json = await response.json(); 30 | if (json.error) { 31 | throw new Error(json.error); 32 | } 33 | return json; 34 | }; 35 | 36 | const httpPost = async (path: string, data: Record = {}) => { 37 | const url = `${API_URL}/${path}`; 38 | const response = await fetch(url, { 39 | method: 'POST', 40 | headers: { 41 | 'Content-Type': 'application/json', 42 | // add the token from localStorage into every request 43 | 'X-Token': getToken(), 44 | }, 45 | body: JSON.stringify(data), 46 | }); 47 | const json = await response.json(); 48 | if (json.error) { 49 | throw new Error(json.error); 50 | } 51 | return json; 52 | }; 53 | 54 | // 55 | // Exported API functions 56 | // 57 | 58 | // open a WebSocket connection to the server 59 | export const getEventsSocket = () => { 60 | return new WebSocket(WS_URL); 61 | }; 62 | 63 | export const connect = async (host: string, cert: string, macaroon: string) => { 64 | const request = { host, cert, macaroon }; 65 | const { token } = await httpPost('connect', request); 66 | // save the token into the browser's storage 67 | setToken(token); 68 | }; 69 | 70 | export const getInfo = async () => { 71 | return await httpGet('info'); 72 | }; 73 | 74 | export const fetchPosts = async () => { 75 | return await httpGet('posts'); 76 | }; 77 | 78 | export const createPost = async (title: string, content: string) => { 79 | const request = { title, content }; 80 | return await httpPost('posts', request); 81 | }; 82 | 83 | export const createInvoice = async (postId: number) => { 84 | return await httpPost(`posts/${postId}/invoice`); 85 | }; 86 | 87 | export const upvotePost = async (postId: number, hash: string) => { 88 | const request = { hash }; 89 | return await httpPost(`posts/${postId}/upvote`, request); 90 | }; 91 | 92 | export const verifyPost = async (postId: number) => { 93 | return await httpPost(`posts/${postId}/verify`); 94 | }; 95 | -------------------------------------------------------------------------------- /src/pages/Connect.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | import { Button, Card, Col, Form, Row } from 'react-bootstrap'; 3 | import { observer } from 'mobx-react-lite'; 4 | import { useStore } from '../store/Provider'; 5 | 6 | const Connect: React.FC = () => { 7 | const store = useStore(); 8 | 9 | const [host, setHost] = useState(''); 10 | const [cert, setCert] = useState(''); 11 | const [macaroon, setMacaroon] = useState(''); 12 | 13 | const handleSubmit = useCallback( 14 | async (e: React.FormEvent) => { 15 | e.preventDefault(); 16 | store.connectToLnd(host, cert, macaroon); 17 | }, 18 | [host, cert, macaroon, store], 19 | ); 20 | 21 | return ( 22 | 23 | 24 | Connect Node 25 | 26 | 27 | LND Host 28 | setHost(e.target.value)} 33 | /> 34 | 35 | 36 | TLS Certificate 37 | setCert(e.target.value)} 44 | /> 45 | 46 | 47 | Macaroon 48 | setMacaroon(e.target.value)} 55 | /> 56 | 57 | Open a Terminal and enter{' '} 58 | 59 | lncli bakemacaroon info:read offchain:read invoices:read invoices:write 60 | message:read message:write 61 | {' '} 62 | to bake a macaroon with only limited access to get node info, create 63 | invoices, and sign/verify messages. 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | Cancel 72 | 73 | 74 | 75 | 76 | Submit 77 | 78 | 79 | 80 | 81 | 82 | 83 | ); 84 | }; 85 | 86 | export default observer(Connect); 87 | -------------------------------------------------------------------------------- /backend/index.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import express, { Request, Response } from 'express'; 3 | import expressWs from 'express-ws'; 4 | import { Post, SocketEvents } from '../src/shared/types'; 5 | import nodeManager, { NodeEvents } from './node-manager'; 6 | import db, { PostEvents } from './posts-db'; 7 | import * as routes from './routes'; 8 | 9 | const PORT = 4000; 10 | 11 | // 12 | // Create Express server 13 | // 14 | const { app } = expressWs(express()); 15 | app.use(cors({ origin: 'http://localhost:3000' })); 16 | app.use(express.json()); 17 | 18 | // simple middleware to grab the token from the header and add 19 | // it to the request's body 20 | app.use((req, res, next) => { 21 | req.body.token = req.header('X-Token'); 22 | next(); 23 | }); 24 | 25 | /** 26 | * ExpressJS will hang if an async route handler doesn't catch errors and return a response. 27 | * To avoid wrapping every handler in try/catch, just call this func on the handler. It will 28 | * catch any async errors and return 29 | */ 30 | export const catchAsyncErrors = ( 31 | routeHandler: (req: Request, res: Response) => Promise | void, 32 | ) => { 33 | // return a function that wraps the route handler in a try/catch block and 34 | // sends a response on error 35 | return async (req: Request, res: Response) => { 36 | try { 37 | const promise = routeHandler(req, res); 38 | // only await promises from async handlers. 39 | if (promise) await promise; 40 | } catch (err) { 41 | res.status(400).send({ error: err.message }); 42 | } 43 | }; 44 | }; 45 | 46 | // 47 | // Configure Routes 48 | // 49 | app.post('/api/connect', catchAsyncErrors(routes.connect)); 50 | app.get('/api/info', catchAsyncErrors(routes.getInfo)); 51 | app.get('/api/posts', catchAsyncErrors(routes.getPosts)); 52 | app.post('/api/posts', catchAsyncErrors(routes.createPost)); 53 | app.post('/api/posts/:id/invoice', catchAsyncErrors(routes.postInvoice)); 54 | app.post('/api/posts/:id/upvote', catchAsyncErrors(routes.upvotePost)); 55 | app.post('/api/posts/:id/verify', catchAsyncErrors(routes.verifyPost)); 56 | 57 | // 58 | // Configure Websocket 59 | // 60 | app.ws('/api/events', ws => { 61 | // when a websocket connection is made, add listeners for posts and invoices 62 | const postsListener = (posts: Post[]) => { 63 | const event = { type: SocketEvents.postUpdated, data: posts }; 64 | ws.send(JSON.stringify(event)); 65 | }; 66 | 67 | const paymentsListener = (info: any) => { 68 | const event = { type: SocketEvents.invoicePaid, data: info }; 69 | ws.send(JSON.stringify(event)); 70 | }; 71 | 72 | // add listeners to to send data over the socket 73 | db.on(PostEvents.updated, postsListener); 74 | nodeManager.on(NodeEvents.invoicePaid, paymentsListener); 75 | 76 | // remove listeners when the socket is closed 77 | ws.on('close', () => { 78 | db.off(PostEvents.updated, postsListener); 79 | nodeManager.off(NodeEvents.invoicePaid, paymentsListener); 80 | }); 81 | }); 82 | 83 | // 84 | // Start Server 85 | // 86 | console.log('Starting API server...'); 87 | app.listen(PORT, async () => { 88 | console.log(`API listening at http://localhost:${PORT}`); 89 | 90 | // Rehydrate data from the DB file 91 | await db.restore(); 92 | await nodeManager.reconnectNodes(db.getAllNodes()); 93 | }); 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Beginner's Guide to the LND Galaxy 2 | 3 | This sample application serves as a guide to begin learning how to communicate with an 4 | [LND](https://github.com/lightningnetwork/lnd/) node to send and receive payments over the 5 | [Lightning Network](http://lightning.network/). 6 | 7 | ## Overview 8 | 9 | The goal of this guide is to get you familiar with adding payments functionality to your 10 | app using the `lnd` Lightning Network node software. 11 | 12 | This application will be written in [Typescript](https://www.typescriptlang.org) with a 13 | small amount of HTML+CSS. On the frontend we will use [ReactJS](https://reactjs.org/) to 14 | render the UI and [mobx](https://mobx.js.org/) for managing the application state. On the 15 | backend we will use [expressjs](https://expressjs.com/) to host our API server and serve 16 | the app's data to multiple web clients. 17 | 18 | To easily create a local Lightning Network, which exists solely on your computer, we will 19 | be using the [Polar](https://lightningpolar.com/) development tool. 20 | 21 | We'll be making use of the LND gRPC [API](https://api.lightning.community/) to interface 22 | with the LND node. A few of the API endpoints that we will be using throughout this 23 | application are: 24 | 25 | | Endpoint | Description | 26 | | ------------------------------------------------------------------- | ------------------------------------------------------------------------- | 27 | | [`getinfo`](https://api.lightning.community/#getinfo) | returns general information concerning the lightning node | 28 | | [`channelbalance`](https://api.lightning.community/#channelbalance) | returns the total funds available across all open channels in satoshis | 29 | | [`signmessage`](https://api.lightning.community/#signmessage) | signs a message with this node's private key | 30 | | [`verifymessage`](https://api.lightning.community/#verifymessage) | verifies a signature of a message | 31 | | [`addinvoice`](https://api.lightning.community/#addinvoice) | creates a new invoice which can be used by another node to send a payment | 32 | | [`lookupinvoice`](https://api.lightning.community/#lookupinvoice) | look up an invoice according to its payment hash | 33 | 34 | The sample app we'll be starting with is a basic Reddit clone with this small list of 35 | features: 36 | 37 | - view a list of posts on the home page sorted by votes 38 | - click on the Upvote button for a post should increment its number of votes 39 | - create a post containing a username, title, and description 40 | 41 | We'll add Lightning Network integration in this tutorial by implementing the following 42 | features: 43 | 44 | - connect your node to the app by providing your node's host, certificate and macaroon 45 | - display your node's alias and channel balance 46 | - create posts and sign them using your LND node's pubkey 47 | - verify posts made by other users 48 | - up-vote a post by paying 100 satoshis per vote 49 | 50 | ## Running the App Locally 51 | 52 | Requirements: [NodeJS v12.x](https://nodejs.org/en/download/) & 53 | [Yarn v1.x](https://classic.yarnpkg.com/en/docs/install) 54 | 55 | Clone the repo 56 | 57 | ``` 58 | git clone https://github.com/lightninglabs/builders-guide-sample-app.git 59 | ``` 60 | 61 | Install dependencies 62 | 63 | ``` 64 | cd builders-guide-sample-app 65 | yarn 66 | ``` 67 | 68 | Start the API server and client app development server 69 | 70 | ``` 71 | yarn dev 72 | ``` 73 | 74 | Open your browser and navigate to `http://localhost:3000`. 75 | -------------------------------------------------------------------------------- /backend/posts-db.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { existsSync, promises as fs } from 'fs'; 3 | import { Post } from '../src/shared/types'; 4 | 5 | const DB_FILE = 'db.json'; 6 | 7 | export interface LndNode { 8 | token: string; 9 | host: string; 10 | cert: string; 11 | macaroon: string; 12 | pubkey: string; 13 | } 14 | 15 | export interface DbData { 16 | posts: Post[]; 17 | nodes: LndNode[]; 18 | } 19 | 20 | /** 21 | * The list of events emitted by the PostsDb 22 | */ 23 | export const PostEvents = { 24 | updated: 'post-updated', 25 | }; 26 | 27 | /** 28 | * A very simple file-based DB to store the posts 29 | */ 30 | class PostsDb extends EventEmitter { 31 | // in-memory database 32 | private _data: DbData = { 33 | posts: [], 34 | nodes: [], 35 | }; 36 | 37 | // 38 | // Posts 39 | // 40 | 41 | getAllPosts() { 42 | return this._data.posts.sort((a, b) => b.votes - a.votes); 43 | } 44 | 45 | getPostById(id: number) { 46 | return this.getAllPosts().find(post => post.id === id); 47 | } 48 | 49 | async createPost( 50 | username: string, 51 | title: string, 52 | content: string, 53 | signature: string, 54 | pubkey: string, 55 | ) { 56 | // calculate the highest numeric id 57 | const maxId = Math.max(0, ...this._data.posts.map(p => p.id)); 58 | 59 | const post: Post = { 60 | id: maxId + 1, 61 | title, 62 | content, 63 | username, 64 | votes: 0, 65 | signature, 66 | pubkey, 67 | verified: false, 68 | }; 69 | this._data.posts.push(post); 70 | 71 | await this.persist(); 72 | this.emit(PostEvents.updated, post); 73 | return post; 74 | } 75 | 76 | async upvotePost(postId: number) { 77 | const post = this._data.posts.find(p => p.id === postId); 78 | if (!post) { 79 | throw new Error('Post not found'); 80 | } 81 | post.votes++; 82 | await this.persist(); 83 | this.emit(PostEvents.updated, post); 84 | } 85 | 86 | async verifyPost(postId: number) { 87 | const post = this._data.posts.find(p => p.id === postId); 88 | if (!post) { 89 | throw new Error('Post not found'); 90 | } 91 | post.verified = true; 92 | await this.persist(); 93 | this.emit(PostEvents.updated, post); 94 | } 95 | 96 | // 97 | // Nodes 98 | // 99 | 100 | getAllNodes() { 101 | return this._data.nodes; 102 | } 103 | 104 | getNodeByPubkey(pubkey: string) { 105 | return this.getAllNodes().find(node => node.pubkey === pubkey); 106 | } 107 | 108 | getNodeByToken(token: string) { 109 | return this.getAllNodes().find(node => node.token === token); 110 | } 111 | 112 | async addNode(node: LndNode) { 113 | this._data.nodes = [ 114 | // add new node 115 | node, 116 | // exclude existing nodes with the same server 117 | ...this._data.nodes.filter(n => n.host !== node.host), 118 | ]; 119 | await this.persist(); 120 | } 121 | 122 | // 123 | // HACK! Persist data to a JSON file to keep it when the server restarts. 124 | // Do not do this in a production app. This is just for convenience when 125 | // developing this sample app locally. 126 | // 127 | 128 | async persist() { 129 | await fs.writeFile(DB_FILE, JSON.stringify(this._data, null, 2)); 130 | } 131 | 132 | async restore() { 133 | if (!existsSync(DB_FILE)) return; 134 | 135 | const contents = await fs.readFile(DB_FILE); 136 | if (contents) { 137 | this._data = JSON.parse(contents.toString()); 138 | if (!this._data.nodes) this._data.nodes = []; 139 | console.log(`Loaded ${this._data.posts.length} posts`); 140 | } 141 | } 142 | } 143 | 144 | export default new PostsDb(); 145 | -------------------------------------------------------------------------------- /backend/node-manager.ts: -------------------------------------------------------------------------------- 1 | import createLnRpc, { LnRpc } from '@radar/lnrpc'; 2 | import { EventEmitter } from 'events'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import { LndNode } from './posts-db'; 5 | 6 | export const NodeEvents = { 7 | invoicePaid: 'invoice-paid', 8 | }; 9 | 10 | class NodeManager extends EventEmitter { 11 | /** 12 | * a mapping of token to gRPC connection. This is an optimization to 13 | * avoid calling `createLnRpc` on every request. Instead, the object is kept 14 | * in memory for the lifetime of the server. 15 | */ 16 | private _lndNodes: Record = {}; 17 | 18 | /** 19 | * Retrieves the in-memory connection to an LND node 20 | */ 21 | getRpc(token: string): LnRpc { 22 | if (!this._lndNodes[token]) { 23 | throw new Error('Not Authorized. You must login first!'); 24 | } 25 | 26 | return this._lndNodes[token]; 27 | } 28 | 29 | /** 30 | * Tests the LND node connection by validating that we can get the node's info 31 | */ 32 | async connect(host: string, cert: string, macaroon: string, prevToken?: string) { 33 | // generate a random token, without 34 | const token = prevToken || uuidv4().replace(/-/g, ''); 35 | 36 | try { 37 | // add the connection to the cache 38 | const rpc = await createLnRpc({ 39 | server: host, 40 | cert: Buffer.from(cert, 'hex').toString('utf-8'), // utf8 encoded certificate 41 | macaroon, // hex encoded macaroon 42 | }); 43 | 44 | // verify we have permission get node info 45 | const { identityPubkey: pubkey } = await rpc.getInfo(); 46 | 47 | // verify we have permission to get channel balances 48 | await rpc.channelBalance(); 49 | 50 | // verify we can sign a message 51 | const msg = Buffer.from('authorization test').toString('base64'); 52 | const { signature } = await rpc.signMessage({ msg }); 53 | 54 | // verify we have permission to verify a message 55 | await rpc.verifyMessage({ msg, signature }); 56 | 57 | // verify we have permissions to create a 1sat invoice 58 | const { rHash } = await rpc.addInvoice({ value: '1' }); 59 | 60 | // verify we have permission to lookup invoices 61 | await rpc.lookupInvoice({ rHash }); 62 | 63 | // listen for payments from LND 64 | this.listenForPayments(rpc, pubkey); 65 | 66 | // store this rpc connection in the in-memory list 67 | this._lndNodes[token] = rpc; 68 | 69 | // return this node's token for future requests 70 | return { token, pubkey }; 71 | } catch (err) { 72 | // remove the connection from the cache since it is not valid 73 | if (this._lndNodes[token]) { 74 | delete this._lndNodes[token]; 75 | } 76 | throw err; 77 | } 78 | } 79 | 80 | /** 81 | * Reconnect to all persisted nodes to to cache the `LnRpc` objects 82 | * @param nodes the list of nodes 83 | */ 84 | async reconnectNodes(nodes: LndNode[]) { 85 | for (const node of nodes) { 86 | const { host, cert, macaroon, token } = node; 87 | try { 88 | console.log(`Reconnecting to LND node ${host} for token ${token}`); 89 | await this.connect(host, cert, macaroon, token); 90 | } catch (error) { 91 | // the token will not be cached 92 | console.error(`Failed to reconnect to LND node ${host} with token: ${token}`); 93 | } 94 | } 95 | } 96 | 97 | /** 98 | * listen for payments made to the node. When a payment is settled, emit 99 | * the `invoicePaid` event to notify listeners of the NodeManager 100 | */ 101 | listenForPayments(rpc: LnRpc, pubkey: string) { 102 | const stream = rpc.subscribeInvoices(); 103 | stream.on('data', invoice => { 104 | if (invoice.settled) { 105 | const hash = (invoice.rHash as Buffer).toString('base64'); 106 | const amount = invoice.amtPaidSat; 107 | this.emit(NodeEvents.invoicePaid, { hash, amount, pubkey }); 108 | } 109 | }); 110 | } 111 | } 112 | 113 | export default new NodeManager(); 114 | -------------------------------------------------------------------------------- /backend/routes.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import nodeManager from './node-manager'; 3 | import db from './posts-db'; 4 | 5 | /** 6 | * POST /api/connect 7 | */ 8 | export const connect = async (req: Request, res: Response) => { 9 | const { host, cert, macaroon } = req.body; 10 | const { token, pubkey } = await nodeManager.connect(host, cert, macaroon); 11 | await db.addNode({ host, cert, macaroon, token, pubkey }); 12 | res.send({ token }); 13 | }; 14 | 15 | /** 16 | * GET /api/info 17 | */ 18 | export const getInfo = async (req: Request, res: Response) => { 19 | const { token } = req.body; 20 | if (!token) throw new Error('Your node is not connected!'); 21 | // find the node that's making the request 22 | const node = db.getNodeByToken(token); 23 | if (!node) throw new Error('Node not found with this token'); 24 | 25 | // get the node's pubkey and alias 26 | const rpc = nodeManager.getRpc(node.token); 27 | const { alias, identityPubkey: pubkey } = await rpc.getInfo(); 28 | const { balance } = await rpc.channelBalance(); 29 | res.send({ alias, balance, pubkey }); 30 | }; 31 | 32 | /** 33 | * GET /api/posts 34 | */ 35 | export const getPosts = (req: Request, res: Response) => { 36 | const posts = db.getAllPosts(); 37 | res.send(posts); 38 | }; 39 | 40 | /** 41 | * POST /api/posts 42 | */ 43 | export const createPost = async (req: Request, res: Response) => { 44 | const { token, title, content } = req.body; 45 | const rpc = nodeManager.getRpc(token); 46 | 47 | const { alias, identityPubkey: pubkey } = await rpc.getInfo(); 48 | // lnd requires the message to sign to be base64 encoded 49 | const msg = Buffer.from(content).toString('base64'); 50 | // sign the message to obtain a signature 51 | const { signature } = await rpc.signMessage({ msg }); 52 | 53 | const post = await db.createPost(alias, title, content, signature, pubkey); 54 | res.status(201).send(post); 55 | }; 56 | 57 | /** 58 | * POST /api/posts/:id/upvote 59 | */ 60 | export const upvotePost = async (req: Request, res: Response) => { 61 | const { id } = req.params; 62 | const { hash } = req.body; 63 | 64 | // validate that a invoice hash was provided 65 | if (!hash) throw new Error('hash is required'); 66 | // find the post 67 | const post = db.getPostById(parseInt(id)); 68 | if (!post) throw new Error('Post not found'); 69 | // find the node that made this post 70 | const node = db.getNodeByPubkey(post.pubkey); 71 | if (!node) throw new Error('Node not found for this post'); 72 | 73 | const rpc = nodeManager.getRpc(node.token); 74 | const rHash = Buffer.from(hash, 'base64'); 75 | const { settled } = await rpc.lookupInvoice({ rHash }); 76 | if (!settled) { 77 | throw new Error('The payment has not been paid yet!'); 78 | } 79 | 80 | db.upvotePost(post.id); 81 | res.send(post); 82 | }; 83 | 84 | /** 85 | * POST /api/posts/:id/verify 86 | */ 87 | export const verifyPost = async (req: Request, res: Response) => { 88 | const { id } = req.params; 89 | const { token } = req.body; 90 | // find the post 91 | const post = db.getPostById(parseInt(id)); 92 | if (!post) throw new Error('Post not found'); 93 | // find the node that's verifying this post 94 | const verifyingNode = db.getNodeByToken(token); 95 | if (!verifyingNode) throw new Error('Your node not found. Try reconnecting.'); 96 | 97 | if (post.pubkey === verifyingNode.pubkey) 98 | throw new Error('You cannot verify your own posts!'); 99 | 100 | const rpc = nodeManager.getRpc(verifyingNode.token); 101 | const msg = Buffer.from(post.content).toString('base64'); 102 | const { signature } = post; 103 | const { pubkey, valid } = await rpc.verifyMessage({ msg, signature }); 104 | 105 | if (!valid || pubkey !== post.pubkey) { 106 | throw new Error('Verification failed! The signature is invalid.'); 107 | } 108 | 109 | db.verifyPost(post.id); 110 | res.send(post); 111 | }; 112 | 113 | /** 114 | * POST /api/posts/:id/invoice 115 | */ 116 | export const postInvoice = async (req: Request, res: Response) => { 117 | const { id } = req.params; 118 | // find the post 119 | const post = db.getPostById(parseInt(id)); 120 | if (!post) throw new Error('Post not found'); 121 | // find the node that made this post 122 | const node = db.getNodeByPubkey(post.pubkey); 123 | if (!node) throw new Error('Node not found for this post'); 124 | 125 | // create an invoice on the poster's node 126 | const rpc = nodeManager.getRpc(node.token); 127 | const amount = 100; 128 | const inv = await rpc.addInvoice({ value: amount.toString() }); 129 | res.send({ 130 | payreq: inv.paymentRequest, 131 | hash: (inv.rHash as Buffer).toString('base64'), 132 | amount, 133 | }); 134 | }; 135 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | import * as api from '../lib/api'; 3 | import { Post, SocketEvents } from '../shared/types'; 4 | 5 | export class Store { 6 | constructor() { 7 | makeAutoObservable(this); 8 | 9 | this.init(); 10 | } 11 | 12 | // 13 | // Observable state objects 14 | // 15 | 16 | // App state 17 | page = 'posts'; 18 | error = ''; 19 | connected = false; 20 | alias = ''; 21 | balance = 0; 22 | pubkey = ''; 23 | makeItRain = false; 24 | 25 | // PostList state 26 | posts: Post[] = []; 27 | 28 | // PayModal state 29 | showPayModal = false; 30 | pmtForPost: Post | undefined; 31 | pmtAmount = ''; 32 | pmtRequest = ''; 33 | pmtHash = ''; 34 | pmtSuccessMsg = ''; 35 | pmtError = ''; 36 | 37 | // 38 | // Computed props 39 | // 40 | 41 | get sortedPosts() { 42 | return this.posts.slice().sort((a, b) => { 43 | // sort by votes desc if they are not equal 44 | if (a.votes !== b.votes) return b.votes - a.votes; 45 | // sort by id if they have the same votes 46 | return a.id - b.id; 47 | }); 48 | } 49 | 50 | // 51 | // Actions 52 | // 53 | 54 | gotoPosts = () => (this.page = 'posts'); 55 | gotoCreate = () => (this.page = this.connected ? 'create' : 'connect'); 56 | gotoConnect = () => (this.page = 'connect'); 57 | 58 | clearError = () => (this.error = ''); 59 | 60 | init = async () => { 61 | // try to fetch the node's info on startup 62 | try { 63 | await this.fetchInfo(); 64 | this.connected = true; 65 | } catch (err) { 66 | // don't display an error, just disconnect 67 | this.connected = false; 68 | } 69 | 70 | // fetch the posts from the backend 71 | try { 72 | this.posts = await api.fetchPosts(); 73 | } catch (err) { 74 | this.error = err.message; 75 | } 76 | 77 | // connect to the backend WebSocket and listen for events 78 | const ws = api.getEventsSocket(); 79 | ws.addEventListener('message', this.onSocketMessage); 80 | }; 81 | 82 | connectToLnd = async (host: string, cert: string, macaroon: string) => { 83 | this.clearError(); 84 | try { 85 | await api.connect(host, cert, macaroon); 86 | this.connected = true; 87 | this.fetchInfo(); 88 | this.gotoPosts(); 89 | } catch (err) { 90 | this.error = err.message; 91 | } 92 | }; 93 | 94 | disconnect = () => { 95 | api.clearToken(); 96 | this.connected = false; 97 | }; 98 | 99 | fetchInfo = async () => { 100 | const info = await api.getInfo(); 101 | this.alias = info.alias; 102 | this.balance = parseInt(info.balance); 103 | this.pubkey = info.pubkey; 104 | }; 105 | 106 | fetchPosts = async () => { 107 | this.clearError(); 108 | try { 109 | this.posts = await api.fetchPosts(); 110 | } catch (err) { 111 | this.error = err.message; 112 | } 113 | }; 114 | 115 | createPost = async (title: string, content: string) => { 116 | this.clearError(); 117 | try { 118 | await api.createPost(title, content); 119 | this.gotoPosts(); 120 | } catch (err) { 121 | this.error = err.message; 122 | } 123 | }; 124 | 125 | upvotePost = async () => { 126 | this.pmtError = ''; 127 | try { 128 | if (!this.pmtForPost) throw new Error('No post selected to upvote'); 129 | await api.upvotePost(this.pmtForPost.id, this.pmtHash); 130 | this.pmtSuccessMsg = `Your payment of ${this.pmtAmount} sats to ${this.pmtForPost.username} was successful! The post has been upvoted!`; 131 | } catch (err) { 132 | this.pmtError = err.message; 133 | } 134 | }; 135 | 136 | verifyPost = async (postId: number) => { 137 | this.clearError(); 138 | try { 139 | const post = await api.verifyPost(postId); 140 | this._updatePost(post); 141 | } catch (err) { 142 | this.error = err.message; 143 | } 144 | }; 145 | 146 | showPaymentRequest = async (post: Post) => { 147 | this.clearError(); 148 | try { 149 | const res = await api.createInvoice(post.id); 150 | this.pmtForPost = post; 151 | this.pmtAmount = res.amount; 152 | this.pmtRequest = res.payreq; 153 | this.pmtHash = res.hash; 154 | this.pmtSuccessMsg = ''; 155 | this.pmtError = ''; 156 | this.showPayModal = true; 157 | } catch (err) { 158 | this.error = err.message; 159 | } 160 | }; 161 | 162 | hidePaymentRequest = () => { 163 | this.pmtForPost = undefined; 164 | this.pmtAmount = ''; 165 | this.pmtRequest = ''; 166 | this.pmtHash = ''; 167 | this.pmtSuccessMsg = ''; 168 | this.pmtError = ''; 169 | this.showPayModal = false; 170 | }; 171 | 172 | // 173 | // WebSocket listener 174 | // 175 | 176 | onSocketMessage = (msg: MessageEvent) => { 177 | const event = JSON.parse(msg.data); 178 | // update the posts array when a post is updated on the server 179 | if (event.type === SocketEvents.postUpdated) { 180 | // replacing the existing post with this new one 181 | this._updatePost(event.data); 182 | } 183 | if (event.type === SocketEvents.invoicePaid) { 184 | const { hash, amount, pubkey } = event.data; 185 | // upvote the post when the incoming payment is made for the 186 | // pmtHash the we are waiting for 187 | if (hash === this.pmtHash) { 188 | this.upvotePost(); 189 | } 190 | // update the balance when an invoice is paid to the current user 191 | if (pubkey === this.pubkey) { 192 | this._incrementBalance(parseInt(amount)); 193 | } 194 | } 195 | }; 196 | 197 | // 198 | // Private helper methods 199 | // 200 | private _incrementBalance = (amount: number) => { 201 | this.balance = this.balance + amount; 202 | 203 | // make it rain for 3 seconds 💸 204 | this.makeItRain = true; 205 | setTimeout(() => { 206 | this.makeItRain = false; 207 | }, 3000); 208 | }; 209 | 210 | private _updatePost = (post: Post) => { 211 | this.posts = [ 212 | // the updated post 213 | post, 214 | // the existing posts excluding the one that was updated 215 | ...this.posts.filter(p => p.id !== post.id), 216 | ]; 217 | }; 218 | } 219 | --------------------------------------------------------------------------------
16 | It's a ghost town in here. Get the party started by creating the first post. 17 |
19 | Create a Post 20 |
59 | lncli bakemacaroon info:read offchain:read invoices:read invoices:write 60 | message:read message:write 61 |