diff --git a/README.md b/README.md index fd8289d..b731590 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ bos forwards # Look up the channels and fee rates of a node by its public key bos graph "pubkey" +# Collection of lnurl features +bos lnurl --url "lnurl" + # Pay a payment request (invoice), probing first bos pay "payment_request" @@ -1260,6 +1263,171 @@ try {

+### Lnurl + +```javascript +/** +@GetRequest + +@Url +http://localhost:8055/api/lnurl + +Supported functions: auth, channel, pay, withdraw + +Function auth: Request authorization +@Query + { + message_id: + [node]: + function: + url: + } + +@Response + { + is_authenticated: + } + +==================================================== + +Function channel: Request an incoming payment channel +@Query + { + message_id: + [node]: + function: + url: + } + +@Response + { + is_authenticated: + } + +==================================================== + +Function pay: Pay to an lnurl/lightning address +@Query + { + amount: + [avoid]: [] + function: + [max_fee]: + [max_paths]: + message_id: + [node]: + [out]: [] + url: + } + +@Response + { + [fee]: + [id]: + [latency_ms]: + [paid]: + [preimage]: + [relays]: [] + } + +==================================================== + +Function withdraw: Will create an invoice and send it to a service +@Query + { + amount: + message_id: + [node]: + function: + url: + } + +@Response + { + withdrawal_request_sent: + } +*/ + +import { io } from 'socket.io-client'; + +try { + const url = 'http://localhost:8055/api/lnurl'; + + // Unique connection name for websocket connection. + const dateString = Date.now().toString(); + + // Supported functions: auth, channel, pay, withdraw + + // Query for auth function + const query = { + function: 'auth', + message_id: dateString, + }; + + // Query for channel function + const query = { + function: 'channel', + is_private: true, + message_id: dateString, + }; + + // Query for pay function + const query = { + avoid: ['ban'], + function: 'pay', + max_fee: 20, + max_paths: 2, + message_id: dateString, + out: ['02ce4aea072f54422d35eb8d82aebe966b033d4e98b470907f601a025c5c29a7dc'], + }; + + // Query for withdraw function + const query = { + amount: 100, + function: 'withdraw', + message_id: dateString, + }; + + + // To get live logs while working with lnurl, you can start a websocket connection with the server and add an event listener. + // Websocket url is the same as the server url http://localhost:8055 + // Messages from the server are passed to client using the dateString passed from above. + const socket = io(); + + socket.on('connect', () => { + console.log('connected'); + }); + + socket.on('disconnect', () => { + console.log('disconnected'); + }); + +// Make sure to pass the same dateString from above + socket.on(`${dateString}`, data => { + console.log(data); + }); + + socket.on('error', err => { + throw err; + }); + +// End websocket code. + + const response = await axios.get(url, { + params: query, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }); +} catch (error) { + console.error(error); +} +``` + +

+ ### Pay ```javascript @@ -1312,7 +1480,7 @@ try { request: 'lnbcrt500n1p30ust0pp5n07x3ckwhxunpxy4gp2azckwqk8tlaqgl3hu036u5qa77dfj2f6sdqqcqzpgxqyz5vqsp54eg7zhnnrcnkhr848asghvuu349k00a5cltx56ctnt7a80jcjgxq9qyyssqsms9wv8d9244l03wasz40mfaw3xgfxjth4c02mk958gjj0yzm6cx6mne28auz3vk0kypqwnsp37pde958wxu7vrqmmpxp9f3td64jnqp0q0gas', }; - // To get live logs while pushing a payment, you can start a websocket connection with the server and add an event listener. + // To get live logs while paying an invoice, you can start a websocket connection with the server and add an event listener. // Websocket url is the same as the server url http://localhost:8055 // Messages from the server are passed to client using the dateString passed from above. const socket = io(); diff --git a/src/client/commands.ts b/src/client/commands.ts index 359c798..18239c4 100644 --- a/src/client/commands.ts +++ b/src/client/commands.ts @@ -180,6 +180,27 @@ const commands: Commands = [ sort: 'Sort', }, }, + { + name: 'Lnurl', + value: 'Lnurl', + description: 'Collection of lnurl features', + longDescription: 'Functions: auth, channel, pay, withdraw. lnurl auth will request authorization. lnurl channel will request an incoming payment channel. lnurl pay will request a payment request from a service. lnurl withdraw will create an invoice and send it to a service', + args: { + auth: 'auth', + channel: 'channel', + pay: 'pay', + withdraw: 'withdraw', + }, + flags: { + amount: 'Amount', + avoid: 'Avoid', + is_private: 'Private', + max_fee: 'MaxFee', + max_paths: 'MaxPaths', + out: 'Out', + url: 'Url', + } + }, { name: 'Pay', value: 'Pay', diff --git a/src/client/pages/commands/Lnurl.tsx b/src/client/pages/commands/Lnurl.tsx new file mode 100644 index 0000000..0f274e3 --- /dev/null +++ b/src/client/pages/commands/Lnurl.tsx @@ -0,0 +1,326 @@ +import * as types from '~shared/types'; + +import { + Button, + CssBaseline, + FormControlLabel, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + TextField, +} from '@mui/material'; +import React, { useState } from 'react'; +import { + StandardHomeButtonLink, + StandardSwitch, + StartFlexBox, + SubmitButton, +} from '~client/standard_components/app-components'; +import commands, { globalCommands } from '~client/commands'; + +import DeleteIcon from '@mui/icons-material/Delete'; +import Head from 'next/head'; +import Link from 'next/link'; + +const LnurlCommand = commands.find(n => n.value === 'Lnurl'); + +const styles = { + form: { + marginLeft: '50px', + marginTop: '100px', + width: '700px', + }, + textField: { + width: '500px', + }, + pre: { + fontWeight: 'bold', + }, + button: { + color: 'white', + fontWeight: 'bold', + borderRadius: '10px', + border: '1px solid black', + marginTop: '20px', + width: '50px', + }, + iconButton: { + width: '50px', + marginTop: '0px', + }, + switch: { + width: '100px', + }, + url: { + fontWeight: 'bold', + color: 'blue', + }, + inputLabel: { + fontWeight: 'bold', + color: 'black', + }, + select: { + width: '300px', + }, +}; + +const Lnurl = () => { + const [node, setNode] = useState(''); + const [amount, setAmount] = useState(''); + const [avoid, setAvoid] = useState([{ avoid: '' }]); + const [isPrivate, setIsPrivate] = useState(false); + const [lnurlFunction, setLnurlFunction] = useState(''); + const [maxFee, setMaxFee] = useState(''); + const [maxPaths, setMaxPaths] = useState(''); + const [out, setOut] = useState([{ out: '' }]); + const [url, setUrl] = useState(''); + + const handleLnurlFunctionChange = (event: React.ChangeEvent) => { + setLnurlFunction(event.target.value); + }; + + const handeNodeChange = (event: React.ChangeEvent) => { + setNode(event.target.value); + }; + + const handleUrlChange = (event: React.ChangeEvent) => { + setUrl(event.target.value); + }; + + const handleAmountChange = (event: React.ChangeEvent) => { + setAmount(event.target.value); + }; + + const handleMaxFeeChange = (event: React.ChangeEvent) => { + setMaxFee(event.target.value); + }; + + const handleMaxPathsChange = (event: React.ChangeEvent) => { + setMaxPaths(event.target.value); + }; + + // ==================== Avoid ==================== + const addAvoidFields = () => { + setAvoid([...avoid, { avoid: '' }]); + }; + + const removeAvoidFields = (i: number) => { + const newFormValues = [...avoid]; + newFormValues.splice(i, 1); + setAvoid(newFormValues); + }; + + const handleAvoidChange = (i: number, e: React.ChangeEvent) => { + const newFormValues = [...avoid]; + newFormValues[i].avoid = e.target.value; + setAvoid(newFormValues); + }; + + // ==================== Out ==================== + + const addOutFields = () => { + setOut([...out, { out: '' }]); + }; + + const removeOutFields = (i: number) => { + const newFormValues = [...out]; + newFormValues.splice(i, 1); + setOut(newFormValues); + }; + + const handleOutChange = (i: number, e: React.ChangeEvent) => { + const newFormValues = [...out]; + newFormValues[i].out = e.target.value; + setOut(newFormValues); + }; + + // ======================================== + + const handleIsPrivateChange = () => { + setIsPrivate((previousState: boolean) => !previousState); + }; + + const flags: types.commandLnurl = { + node, + url, + amount: Number(amount), + avoid: avoid.map(n => n.avoid), + function: lnurlFunction, + is_private: isPrivate, + max_fee: Number(maxFee), + max_paths: Number(maxPaths), + out: out.map(n => n.out), + }; + + return ( + + + Lnurl + + + + +

{LnurlCommand.name}

+

{LnurlCommand.longDescription}

+
+ + Pick a value (Required) + + +
+ + + + {lnurlFunction === 'channel' && ( + + } + style={styles.switch} + label={LnurlCommand.flags.is_private} + /> + )} + + {lnurlFunction === 'pay' && ( + <> + <> + + {avoid.map((element, index) => ( +
+ handleAvoidChange(index, e)} + style={styles.textField} + id={`avoid-${index}`} + /> + {!!index ? ( + removeAvoidFields(index)} + style={styles.iconButton} + > + + + ) : null} +
+ ))} + + + <> + + {out.map((element, index) => ( +
+ handleOutChange(index, e)} + style={styles.textField} + id={`out-${index}`} + /> + {!!index ? ( + removeOutFields(index)} style={styles.iconButton}> + + + ) : null} +
+ ))} + + + + + + )} + + {(lnurlFunction === 'withdraw' || lnurlFunction === 'pay') && ( + + )} + + + + + + +           Run + Command           + + + +
+
+
+ ); +}; + +export default Lnurl; diff --git a/src/client/pages/result/LnurlResult.tsx b/src/client/pages/result/LnurlResult.tsx new file mode 100644 index 0000000..8c4b414 --- /dev/null +++ b/src/client/pages/result/LnurlResult.tsx @@ -0,0 +1,123 @@ +import * as YAML from 'json-to-pretty-yaml'; + +import React, { useEffect, useState } from 'react'; + +import { CssBaseline } from '@mui/material'; +import Head from 'next/head'; +import { StartFlexBoxBlack } from '~client/standard_components/app-components'; +import { axiosGetWebSocket } from '~client/utils/axios'; +import { io } from 'socket.io-client'; +import { useRouter } from 'next/router'; + +const socket = io(); + +/* + Renders the output of the lnurl command + Listens to the websocket events for logging lnurl output to the browser +*/ + +const styles = { + pre: { + fontweight: 'bold', + color: 'white', + }, + div: { + marginLeft: '20px', + }, + h1: { + color: 'white', + }, +}; + +const LnurlResult = () => { + const router = useRouter(); + + const [data, setData] = useState(undefined); + const output = []; + + useEffect(() => { + if (!!data) { + window.scroll({ + top: document.body.offsetHeight, + left: 0, + behavior: 'smooth', + }); + } + }, [data]); + + useEffect(() => { + const dateString = Date.now().toString(); + + const query = { + amount: router.query.amount, + avoid: router.query.avoid, + function: router.query.function, + is_private: router.query.is_private, + max_fee: router.query.max_fee, + max_paths: router.query.max_paths, + message_id: dateString, + node: router.query.node, + out: router.query.out, + url: router.query.url, + }; + + socket.on('connect', () => { + console.log('connected'); + }); + + socket.on('disconnect', () => { + console.log('disconnected'); + }); + + socket.on(`${dateString}`, data => { + const message = data.message; + + output.push(YAML.stringify(message)); + + setData(output.toString()); + }); + + socket.on('error', err => { + throw err; + }); + + const fetchData = async () => { + const result = await axiosGetWebSocket({ path: 'lnurl', query }); + + if (!!result) { + output.push(YAML.stringify(result)); + setData(output.toString()); + } + }; + + fetchData(); + }, []); + + return ( + + + Lnurl Result + + +
+

+ {router.query.function} +

+ {!!data && ( +
+
{data}
+
+ )} +
+
+
+ ); +}; + +export async function getServerSideProps() { + return { + props: {}, + }; +} + +export default LnurlResult; diff --git a/src/server/commands/accounting/accounting_command.ts b/src/server/commands/accounting/accounting_command.ts index 27ef525..a5b4406 100644 --- a/src/server/commands/accounting/accounting_command.ts +++ b/src/server/commands/accounting/accounting_command.ts @@ -2,7 +2,6 @@ import * as request from 'balanceofsatoshis/commands/simple_request'; import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { getAccountingReport } from 'balanceofsatoshis/balances'; import { httpLogger } from '~server/utils/global_functions'; @@ -50,7 +49,6 @@ const accountingCommand = async ({ args, lnd }: Args): Promise<{ result: any }> return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/balance/balance_command.ts b/src/server/commands/balance/balance_command.ts index 1043da3..7b4fae7 100644 --- a/src/server/commands/balance/balance_command.ts +++ b/src/server/commands/balance/balance_command.ts @@ -3,7 +3,6 @@ import * as types from '~shared/types'; import { getBalance, getDetailedBalance } from 'balanceofsatoshis/balances'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { httpLogger } from '~server/utils/global_functions'; const parseAnsi = (n: string) => @@ -68,7 +67,6 @@ const balanceCommand = async ({ args, lnd }: Args): Promise<{ result: any }> => return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/certValidityDays/cert_validity_days_command.ts b/src/server/commands/certValidityDays/cert_validity_days_command.ts index 94a8d20..86232e2 100644 --- a/src/server/commands/certValidityDays/cert_validity_days_command.ts +++ b/src/server/commands/certValidityDays/cert_validity_days_command.ts @@ -1,6 +1,5 @@ import { certExpiration, pemAsDer } from 'balanceofsatoshis/encryption'; -import { Logger } from '@nestjs/common'; import { httpLogger } from '~server/utils/global_functions'; import { lndCredentials } from '~server/lnd'; @@ -44,7 +43,6 @@ const certValidityDaysCommand = async ({ below, node }: Args): Promise<{ result: return { result: round(days) }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/chainfees/chainfees_command.ts b/src/server/commands/chainfees/chainfees_command.ts index ac0b42c..1f9e4b6 100644 --- a/src/server/commands/chainfees/chainfees_command.ts +++ b/src/server/commands/chainfees/chainfees_command.ts @@ -1,7 +1,6 @@ import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { getChainFees } from 'balanceofsatoshis/chain'; import { httpLogger } from '~server/utils/global_functions'; @@ -36,7 +35,6 @@ const chainfeesCommand = async ({ args, lnd }: Args): Promise<{ result: any }> = return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/chartChainFees/chart_chain_fees_command.ts b/src/server/commands/chartChainFees/chart_chain_fees_command.ts index 14b1171..162170e 100644 --- a/src/server/commands/chartChainFees/chart_chain_fees_command.ts +++ b/src/server/commands/chartChainFees/chart_chain_fees_command.ts @@ -2,7 +2,6 @@ import * as request from 'balanceofsatoshis/commands/simple_request'; import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { getChainFeesChart } from 'balanceofsatoshis/routing'; import { httpLogger } from '~server/utils/global_functions'; @@ -37,7 +36,6 @@ const chartChainFeesCommand = async ({ args, lnd }: Args): Promise<{ result: any return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/chartFeesEarned/chart_fees_earned_command.ts b/src/server/commands/chartFeesEarned/chart_fees_earned_command.ts index bab8782..0c40b0b 100644 --- a/src/server/commands/chartFeesEarned/chart_fees_earned_command.ts +++ b/src/server/commands/chartFeesEarned/chart_fees_earned_command.ts @@ -1,7 +1,6 @@ import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { getFeesChart } from 'balanceofsatoshis/routing'; import { httpLogger } from '~server/utils/global_functions'; import { readFile } from 'fs'; @@ -40,7 +39,6 @@ const chartFeesEarnedCommand = async ({ args, lnd }: Args): Promise<{ result: an return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/chartFeesPaid/chart_fees_paid_command.ts b/src/server/commands/chartFeesPaid/chart_fees_paid_command.ts index b2f5f54..bfde002 100644 --- a/src/server/commands/chartFeesPaid/chart_fees_paid_command.ts +++ b/src/server/commands/chartFeesPaid/chart_fees_paid_command.ts @@ -1,7 +1,6 @@ import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { getFeesPaid } from 'balanceofsatoshis/routing'; import { httpLogger } from '~server/utils/global_functions'; import { readFile } from 'fs'; @@ -48,7 +47,6 @@ const chartFeesPaidCommand = async ({ args, lnd }: Args): Promise<{ result: any return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/chartPaymentsReceived/chart_payments_received_command.ts b/src/server/commands/chartPaymentsReceived/chart_payments_received_command.ts index be32e77..fc5887a 100644 --- a/src/server/commands/chartPaymentsReceived/chart_payments_received_command.ts +++ b/src/server/commands/chartPaymentsReceived/chart_payments_received_command.ts @@ -1,7 +1,6 @@ import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { getReceivedChart } from 'balanceofsatoshis/wallets'; import { httpLogger } from '~server/utils/global_functions'; @@ -32,7 +31,6 @@ const chartPaymentsReceivedCommand = async ({ args, lnd }: Args): Promise<{ resu }); return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/closed/closed_command.ts b/src/server/commands/closed/closed_command.ts index 19a05a3..7a9d7e6 100644 --- a/src/server/commands/closed/closed_command.ts +++ b/src/server/commands/closed/closed_command.ts @@ -1,7 +1,6 @@ import * as request from 'balanceofsatoshis/commands/simple_request'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { commandClosed } from '~shared/types'; import { getChannelCloses } from 'balanceofsatoshis/chain'; import { httpLogger } from '~server/utils/global_functions'; @@ -50,7 +49,6 @@ const closedCommand = async ({ args, lnd }: Args): Promise<{ result: any }> => { return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/find/find_command.ts b/src/server/commands/find/find_command.ts index a22ecf6..ae1ef73 100644 --- a/src/server/commands/find/find_command.ts +++ b/src/server/commands/find/find_command.ts @@ -1,7 +1,6 @@ import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { findRecord } from 'balanceofsatoshis/lnd'; import { httpLogger } from '~server/utils/global_functions'; @@ -54,7 +53,6 @@ const findCommand = async ({ args, lnd }: Args): Promise<{ result: any }> => { return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/forwards/forwards_command.ts b/src/server/commands/forwards/forwards_command.ts index c39e1c1..75cd8c8 100644 --- a/src/server/commands/forwards/forwards_command.ts +++ b/src/server/commands/forwards/forwards_command.ts @@ -1,7 +1,6 @@ import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { getForwards } from 'balanceofsatoshis/network'; import { httpLogger } from '~server/utils/global_functions'; import { readFile } from 'fs'; @@ -51,7 +50,6 @@ const forwardsCommand = async ({ args, lnd }: Args): Promise<{ result: any }> => return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/graph/graph_command.ts b/src/server/commands/graph/graph_command.ts index b037827..831737b 100644 --- a/src/server/commands/graph/graph_command.ts +++ b/src/server/commands/graph/graph_command.ts @@ -1,8 +1,7 @@ import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; -import { Logger as LoggerType } from 'winston'; +import { Logger } from 'winston'; import { getGraphEntry } from 'balanceofsatoshis/network'; import graphSummary from './graph_summary'; import { httpLogger } from '~server/utils/global_functions'; @@ -40,7 +39,7 @@ const parseAnsi = (n: string) => type Args = { args: types.commandGraph; lnd: AuthenticatedLnd; - logger: LoggerType; + logger: Logger; }; const graphCommand = async ({ args, lnd, logger }: Args): Promise<{ result: any }> => { try { @@ -78,7 +77,6 @@ const graphCommand = async ({ args, lnd, logger }: Args): Promise<{ result: any return { result: { rows, summary: summary.nodeDetails } }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/grpc_utils/grpc_utils.ts b/src/server/commands/grpc_utils/grpc_utils.ts index adaacde..2d79626 100644 --- a/src/server/commands/grpc_utils/grpc_utils.ts +++ b/src/server/commands/grpc_utils/grpc_utils.ts @@ -8,7 +8,6 @@ import { signMessage, } from 'lightning'; -import { Logger } from '@nestjs/common'; import { httpLogger } from '~server/utils/global_functions'; /** @@ -37,7 +36,6 @@ export const signature = async ({ lnd, message }: SignMessage): Promise<{ result return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; @@ -58,7 +56,6 @@ export const walletInfo = async ({ lnd }: { lnd: AuthenticatedLnd }): Promise<{ return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; @@ -84,7 +81,6 @@ export const channelBalance = async ({ return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/index.ts b/src/server/commands/index.ts index d26d311..182da56 100644 --- a/src/server/commands/index.ts +++ b/src/server/commands/index.ts @@ -11,6 +11,7 @@ import closedCommand from './closed/closed_command'; import findCommand from './find/find_command'; import forwardsCommand from './forwards/forwards_command'; import graphCommand from './graph/graph_command'; +import lnurlCommand from './lnurl/lnurl_command'; import payCommand from './pay/pay_command'; import peersCommand from './peers/peers_command'; import priceCommand from './price/price_command'; @@ -34,6 +35,7 @@ export { findCommand, forwardsCommand, graphCommand, + lnurlCommand, payCommand, peersCommand, priceCommand, diff --git a/src/server/commands/lnurl/auth.ts b/src/server/commands/lnurl/auth.ts new file mode 100644 index 0000000..7988465 --- /dev/null +++ b/src/server/commands/lnurl/auth.ts @@ -0,0 +1,190 @@ +import { AuthenticatedLnd, SignMessageResult, signMessage } from 'lightning'; +import { ECPairFactory, TinySecp256k1Interface } from 'ecpair'; + +import { Logger } from 'winston'; +import { auto } from 'async'; +import { bech32 } from 'bech32'; +import signAuthChallenge from './sign_auth_challenge'; + +const actionKey = 'action'; +const { decode } = bech32; +const defaultAction = 'authenticate'; +const asLnurl = (n: string) => n.substring(n.startsWith('lightning:') ? 10 : 0); +const bech32CharLimit = 2000; +const challengeKey = 'k1'; +const errorStatus = 'ERROR'; +const knownActions = ['auth', 'link', 'login', 'register']; +const okStatus = 'OK'; +const prefix = 'lnurl'; +const tlsProtocol = 'https:'; +const lud13AuthPhrase = 'DO NOT EVER SIGN THIS TEXT WITH YOUR PRIVATE KEYS! IT IS ONLY USED FOR DERIVATION OF LNURL-AUTH HASHING-KEY, DISCLOSING ITS SIGNATURE WILL COMPROMISE YOUR LNURL-AUTH IDENTITY AND MAY LEAD TO LOSS OF FUNDS!'; +const wordsAsUtf8 = n => Buffer.from(bech32.fromWords(n)).toString('utf8'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const tinysecp: TinySecp256k1Interface = require('tiny-secp256k1'); + +/** Authenticate using lnurl + + { + ask: + request: + lnd: + lnurl: + logger: + } + + @returns via Promise + { + is_authenticated: + } +*/ + +type Args = { + lnurl: string; + lnd: AuthenticatedLnd; + request: any; + logger: Logger; +} + +type Tasks = { + ecp: any; + validate: undefined; + parse: { + action: string; + hostname: string; + k1: string; + url: string; + }; + seed: SignMessageResult; + sign: { + public_key: string; + signature: string; + } + send: { + is_authenticated: boolean; + } +}; +const auth = async (args: Args): Promise => { + return auto({ + // Import the ECPair library + ecp: (cbk) => cbk(null, ECPairFactory(tinysecp)), + + // Check arguments + validate: (cbk: any) => { + if (!args.lnurl) { + return cbk([400, 'ExpectedUrlToAuthenticateToLnurl']); + } + + try { + decode(asLnurl(args.lnurl), bech32CharLimit); + } catch (err) { + return cbk([400, 'FailedToDecodeLnurlToAuthenticate', { err }]); + } + + if (decode(asLnurl(args.lnurl), bech32CharLimit).prefix !== prefix) { + return cbk([400, 'ExpectedLnUrlPrefixToAuthenticate']); + } + + if (!args.lnd) { + return cbk([400, 'ExpectedLndToAuthenticateUsingLnurl']); + } + + if (!args.logger) { + return cbk([400, 'ExpectedLoggerToAuthenticateUsingLnurl']); + } + + if (!args.request) { + return cbk([400, 'ExpectedRequestFunctionToGetLnurlAuthentication']); + } + + return cbk(); + }, + + // Parse the encoded Lnurl + parse: ['validate', ({}, cbk: any) => { + const { words } = decode(asLnurl(args.lnurl), bech32CharLimit); + + const url = wordsAsUtf8(words); + + try { + // eslint-disable-next-line no-new + new URL(url); + } catch (err) { + return cbk([400, 'ExpectedValidCallbackUrlInDecodedLnurlForAuth']); + } + + const { hostname, protocol, searchParams } = new URL(url); + + if (protocol !== tlsProtocol) { + return cbk([501, 'UnsupportedUrlProtocolForLnurlAuthentication']); + } + + const action = searchParams.get(actionKey); + + if (!!action && !knownActions.includes(action)) { + return cbk([503, 'UnknownAuthenticationActionForLnurlAuth']); + } + + const k1 = searchParams.get(challengeKey); + + if (!k1) { + return cbk([503, 'ExpectedChallengeK1ValueInDecodedLnurlForAuth']); + } + + return cbk(null, { hostname, k1, url, action: action || defaultAction }); + }], + + // Sign the canonical phrase for LUD-13 signMessage based seed generation + seed: ['parse', async ({}) => await signMessage({ lnd: args.lnd, message: lud13AuthPhrase })], + + // Derive keys and get signatures + sign: ['ecp', 'parse', 'seed', ({ ecp, parse, seed }, cbk) => { + const sign = signAuthChallenge({ + ecp, + hostname: parse.hostname, + k1: parse.k1, + seed: seed.signature, + }); + + return cbk(null, { + public_key: sign.public_key, + signature: sign.signature, + }); + }], + + // Transmit authenticating signature and key to the host + send: ['parse', 'sign', ({ parse, sign }, cbk: any) => { + + args.logger.info({ sending_authentication: sign.public_key }); + + return args.request({ + json: true, + qs: { key: sign.public_key, sig: sign.signature }, + url: parse.url, + }, + (err, r, json) => { + if (!!err) { + return cbk([503, 'FailedToGetLnurlAuthenticationData', { err }]); + } + + if (!json) { + return cbk([503, 'ExpectedJsonReturnedInLnurlResponseForAuth']); + } + + if (json.status === errorStatus) { + return cbk([503, 'LnurlAuthenticationFail', { err: json.reason }]); + } + + if (json.status !== okStatus) { + return cbk([503, 'ExpectedOkStatusInLnurlResponseJsonForAuth']); + } + + args.logger.info({ is_authenticated: true }); + + return cbk(null, { is_authenticated: true }); + }); + }], + }, + ); +}; + +export default auth; diff --git a/src/server/commands/lnurl/channel.ts b/src/server/commands/lnurl/channel.ts new file mode 100644 index 0000000..4952fa4 --- /dev/null +++ b/src/server/commands/lnurl/channel.ts @@ -0,0 +1,236 @@ +import { AuthenticatedLnd, GetIdentityResult, GetPeersResult, addPeer, getIdentity, getPeers } from 'lightning'; + +import { Logger } from 'winston'; +import { auto } from 'async'; +import { bech32 } from 'bech32'; +import { getNodeAlias } from 'ln-sync'; + +const asLnurl = n => n.substring(n.startsWith('lightning:') ? 10 : 0); +const bech32CharLimit = 2000; +const { decode } = bech32; +const errorStatus = 'ERROR'; +const isPublicKey = n => !!n && /^0[2-3][0-9A-F]{64}$/i.test(n); +const okStatus = 'OK'; +const parseUri = n => n.split('@'); +const prefix = 'lnurl'; +const sslProtocol = 'https:'; +const tag = 'channelRequest'; +const wordsAsUtf8 = n => Buffer.from(bech32.fromWords(n)).toString('utf8'); + +/** Request inbound channel from lnurl + + { + ask: + lnd: + lnurl: + logger: + request: + } + + @returns via Promise + { + requested_channel_open: + } +*/ + +type Args = { + lnurl: string; + lnd: AuthenticatedLnd; + request: any; + logger: Logger; + is_private: boolean; +} + +type Tasks = { + validate: undefined; + getIdentity: GetIdentityResult; + getPeers: GetPeersResult; + getTerms: { + id: string; + socket: string; + k1: string; + url: string; + }; + getAlias: { + alias: string; + }; + connect: undefined; + sendConfirmation: { + requested_channel_open: boolean; + }; +}; +const channel = async (args: Args): Promise => { + return auto({ + // Check arguments + validate: (cbk: any) => { + if (!args.lnurl) { + return cbk([400, 'ExpectedUrlToRequestChannelFromLnurl']); + } + + try { + decode(asLnurl(args.lnurl), bech32CharLimit); + } catch (err) { + return cbk([400, 'FailedToDecodeLnurlToRequestChannel', { err }]); + } + + if (decode(asLnurl(args.lnurl), bech32CharLimit).prefix !== prefix) { + return cbk([400, 'ExpectedLnUrlPrefixToRequestChannel']); + } + + if (!args.lnd) { + return cbk([400, 'ExpectedLndToRequestChannelFromLnurl']); + } + + if (!args.logger) { + return cbk([400, 'ExpectedLoggerToRequestChannelFromLnurl']); + } + + if (!args.request) { + return cbk([400, 'ExpectedRequestFunctionToGetLnurlRequestChannel']); + } + + return cbk(); + }, + + // Get node identity public key + getIdentity: ['validate', async ({}) => await getIdentity({ lnd: args.lnd })], + + // Get the list of connected peers to determine if connection is needed + getPeers: ['validate', async ({}) => await getPeers({ lnd: args.lnd })], + + // Get accepted terms from the encoded url + getTerms: ['validate', ({}, cbk: any) => { + const { words } = decode(asLnurl(args.lnurl), bech32CharLimit); + + const url = wordsAsUtf8(words); + + return args.request({ url, json: true }, (err, r, json) => { + if (!!err) { + return cbk([503, 'FailureGettingLnurlDataFromUrl', { err }]); + } + + if (!json) { + return cbk([503, 'ExpectedJsonObjectReturnedInLnurlResponse']); + } + + if (json.status === errorStatus) { + return cbk([503, 'UnexpectedServiceError', { err: json.reason }]); + } + + if (!json.callback) { + return cbk([503, 'ExpectedCallbackInLnurlResponseJson']); + } + + try { + // eslint-disable-next-line no-new + new URL(json.callback); + } catch (err) { + return cbk([503, 'ExpectedValidLnurlResponseCallbackUrl', { err }]); + } + + if ((new URL(json.callback)).protocol !== sslProtocol) { + return cbk([400, 'LnurlsThatSpecifyNonSslUrlsAreUnsupported']); + } + + if (!json.k1) { + return cbk([503, 'ExpectedK1InLnurlChannelResponseJson']); + } + + if (!json.tag) { + return cbk([503, 'ExpectedTagInLnurlChannelResponseJson']); + } + + if (json.tag !== tag) { + return cbk([503, 'ExpectedTagToBeChannelRequestInLnurlResponse']); + } + + if (!json.uri) { + return cbk([503, 'ExpectedUriInLnurlResponseJson']); + } + + // uri: remote node address of form node_key@ip_address:port_number + const [id, socket] = parseUri(json.uri); + + if (!isPublicKey(id)) { + return cbk([503, 'ExpectedValidPublicKeyIdInLnurlResponseJson']); + } + + if (!socket) { + return cbk([503, 'ExpectedNetworkSocketAddressInLnurlResponse']); + } + + return cbk(null, { id, socket, k1: json.k1, url: json.callback }); + }); + }], + + // Get the node alias + getAlias: ['getTerms', async ({ getTerms }) => await getNodeAlias({ id: getTerms.id, lnd: args.lnd })], + + // Connect to the peer returned in the lnurl response + connect: [ + 'getAlias', + 'getPeers', + 'getTerms', + async ({ getAlias, getPeers, getTerms }) => { + // Exit early when the node is already connected + if (getPeers.peers.map(n => n.public_key).includes(getTerms.id)) { + return; + } + + args.logger.info({ + connecting_to: { + alias: getAlias.alias || undefined, + public_key: getTerms.id, + socket: getTerms.socket, + }, + }); + + return await addPeer({ + lnd: args.lnd, + public_key: getTerms.id, + socket: getTerms.socket, + }); + }], + + // Make the request to confirm a request for an inbound channel + sendConfirmation: [ + 'getIdentity', + 'getTerms', + ({ getIdentity, getTerms }, cbk: any) => { + const type = args.is_private === true ? '1' : '0'; + + return args.request({ + json: true, + qs: { + k1: getTerms.k1, + private: type, + remoteid: getIdentity.public_key, + }, + url: getTerms.url, + }, + (err, r, json) => { + if (!!err) { + return cbk([503, 'UnexpectedErrorRequestingLnurlChannel', { err }]); + } + + if (!json) { + return cbk([503, 'ExpectedJsonObjectReturnedInChannelResponse']); + } + + if (json.status === errorStatus) { + return cbk([503, 'ChannelRequestReturnedErr', { err: json.reason }]); + } + + if (json.status !== okStatus) { + return cbk([503, 'ExpectedOkStatusInChannelRequestResponse']); + } + + args.logger.info({ requested_channel_open: true }); + + return cbk(null, { requested_channel_open: true }); + }); + }], + }) +}; + +export default channel; diff --git a/src/server/commands/lnurl/der_encode_signature.ts b/src/server/commands/lnurl/der_encode_signature.ts new file mode 100644 index 0000000..070bf65 --- /dev/null +++ b/src/server/commands/lnurl/der_encode_signature.ts @@ -0,0 +1,39 @@ +const asDer = n => (n[0] & 128) ? Buffer.concat([Buffer.alloc(1), n], 1 + n.length) : n; +const bufferAsHex = buffer => buffer.toString('hex'); +const { concat } = Buffer; +const decomposeSignature = sig => [sig.slice(0, 32), sig.slice(32, 64)]; +const { from } = Buffer; +const header = 0x30; +const hexAsBuffer = hex => Buffer.from(hex, 'hex'); +const int = 0x02; + +/** DER encode a signature given r and s values + + { + signature: + } + + @returns + { + encoded: + } +*/ +const derEncodeSignature = ({ signature }) => { + // Split the signature for DER encoding + const [r, s] = decomposeSignature(hexAsBuffer(signature)).map(asDer); + + const encoded = bufferAsHex(concat([ + from([header]), // Header byte indicating compound structure + from([r.length + s.length + [int, int, r.length, s.length].length]), // Len + from([int]), // Integer indicator + from([r.length]), // Length of data + r, + from([int]), // Integer indicator + from([s.length]), // Length of data + s, + ])); + + return { encoded }; +}; + +export default derEncodeSignature; diff --git a/src/server/commands/lnurl/get_pay_request.ts b/src/server/commands/lnurl/get_pay_request.ts new file mode 100644 index 0000000..1efe925 --- /dev/null +++ b/src/server/commands/lnurl/get_pay_request.ts @@ -0,0 +1,109 @@ +import { auto } from 'async'; +import { parsePaymentRequest } from 'ln-service'; + +const errorStatus = 'ERROR'; + +/** Get a payment request for a LNURL + + { + hash: + mtokens: + request: + url: + } + + @returns via cbk or Promise + { + destination: + request: + } +*/ + +type Args = { + hash: string; + mtokens: string; + request: any; + url: string; +} + +type Tasks = { + validate: undefined; + getRequest: { + destination: string; + request: string; + }; +} +const getPayRequest = async ({ hash, mtokens, request, url }: Args) => { + return auto({ + // Check arguments + validate: (cbk: any) => { + if (!hash) { + return cbk([400, 'ExpectedDescriptionHashToGetLnurlPayRequest']); + } + + if (!mtokens) { + return cbk([400, 'ExpectedMillitokensToGetLnurlPayRequest']); + } + + if (!request) { + return cbk([400, 'ExpectedRequestFunctionToGetLnurlPayRequest']); + } + + if (!url) { + return cbk([400, 'ExpectedUrlToGetLnurlPayRequest']); + } + + return cbk(); + }, + + // Get the payment request + getRequest: ['validate', ({}, cbk: any) => { + const qs = { amount: mtokens }; + + return request({ qs, url, json: true }, (err, r, json) => { + if (!!err) { + return cbk([503, 'FailedToGetPaymentRequestFromService', { err }]); + } + + if (!json) { + return cbk([503, 'ServiceFailedToReturnPayReqJson']); + } + + if (json.status === errorStatus) { + return cbk([503, 'ServiceReturnedError', { err: json.reason }]); + } + + if (!json.pr) { + return cbk([503, 'ExpectedPaymentRequestFromService']); + } + + try { + parsePaymentRequest({ request: json.pr }); + } catch (err) { + return cbk([503, 'FailedToParseReturnedPaymentRequest', { err }]); + } + + const request = parsePaymentRequest({ request: json.pr }); + + if (request.description_hash !== hash) { + return cbk([503, 'ServiceReturnedInvalidPaymentDescriptionHash']); + } + + if (request.is_expired) { + return cbk([503, 'ServiceReturnedExpiredPaymentRequest']); + } + + if (request.mtokens !== mtokens) { + return cbk([503, 'ServiceReturnedIncorrectInvoiceAmount']); + } + + return cbk(null, { + destination: request.destination, + request: json.pr, + }); + }); + }], + }); +}; + +export default getPayRequest; diff --git a/src/server/commands/lnurl/get_pay_terms.ts b/src/server/commands/lnurl/get_pay_terms.ts new file mode 100644 index 0000000..8ec88f8 --- /dev/null +++ b/src/server/commands/lnurl/get_pay_terms.ts @@ -0,0 +1,153 @@ +import { auto } from 'async'; +import { createHash } from 'crypto'; + +const { isArray } = Array; +const isNumber = n => !isNaN(n); +const lowestSendableValue = 1000; +const { max } = Math; +const minMaxSendable = 1000; +const minMinSendable = 1; +const mtokensAsTokens = n => Math.floor(n / 1000); +const { parse } = JSON; +const payRequestTag = 'payRequest'; +const sha256 = n => createHash('sha256').update(n).digest().toString('hex'); +const sslProtocol = 'https:'; +const textPlain = 'text/plain'; +const utf8AsBuffer = utf8 => Buffer.from(utf8, 'utf8'); + +/** Get payment terms + + { + request: + url: + } + + @returns via cbk or Promise + { + description: + hash: + max: + min: + url: + } +*/ + +type Args = { + request: any; + url: string; +} + +type Tasks = { + validate: undefined; + getTerms: { + description: string; + hash: string; + max: number; + min: number; + url: string; + } +} +const getPayTerms = async ({ request, url }: Args): Promise => { + return auto({ + // Check arguments + validate: (cbk: any) => { + if (!request) { + return cbk([400, 'ExpectedRequestFunctionToGetPayTerms']); + } + + if (!url) { + return cbk([400, 'ExpectedUrlToGetPayTerms']); + } + + return cbk(); + }, + + // Get payment terms + getTerms: ['validate', ({}, cbk: any) => { + return request({ url, json: true }, (err, r, json) => { + if (!!err) { + return cbk([503, 'FailureGettingLnUrlDataFromUrl', { err }]); + } + + if (!json) { + return cbk([503, 'ExpectedJsonObjectReturnedInLnurlResponse']); + } + + if (!json.callback) { + return cbk([503, 'ExpectedCallbackInLnurlResponseJson']); + } + + try { + // eslint-disable-next-line no-new + new URL(json.callback); + } catch (err) { + return cbk([503, 'ExpectedValidCallbackUrlInLnurlResponseJson']); + } + + if ((new URL(json.callback)).protocol !== sslProtocol) { + return cbk([400, 'LnurlsThatSpecifyNonSslUrlsAreUnsupported']); + } + + if (!isNumber(json.maxSendable)) { + return cbk([503, 'ExpectedNumericValueForMaxSendable']); + } + + if (!json.maxSendable) { + return cbk([503, 'ExpectedNonZeroMaxSendableInLnurlResponse']); + } + + if (json.maxSendable < minMaxSendable) { + return cbk([400, 'MaxSendableValueIsLowerThanSupportedValue']); + } + + if (!json.metadata) { + return cbk([503, 'ExpectedLnUrlMetadataInLnurlResponse']); + } + + try { + parse(json.metadata); + } catch (err) { + return cbk([503, 'ExpectedValidMetadataInLnurlResponse']); + } + + if (!isArray(parse(json.metadata))) { + return cbk([503, 'ExpectedMetadataArrayInLnurlResponse', json]); + } + + const [, description] = parse(json.metadata) + .filter(isArray) + .find(([entry, text]) => entry === textPlain && !!text); + + if (!description) { + return cbk([503, 'ExpectedTextPlainEntryInLnurlResponse']); + } + + if (!isNumber(json.minSendable)) { + return cbk([503, 'ExpectedNumericValueForMinSendable']); + } + + if (json.minSendable < minMinSendable) { + return cbk([503, 'ExpectedHigherMinSendableValueInLnurlResponse']); + } + + if (json.minSendable > json.maxSendable) { + return cbk([503, 'ExpectedMaxSendableMoreThanMinSendable']); + } + + if (json.tag !== payRequestTag) { + return cbk([503, 'ExpectedPaymentRequestTagInLnurlResponse']); + } + + return cbk(null, { + description, + hash: sha256(utf8AsBuffer(json.metadata)), + max: mtokensAsTokens(json.maxSendable), + min: mtokensAsTokens(max(lowestSendableValue, json.minSendable)), + url: json.callback, + }); + }); + }], + }); +}; + +export default getPayTerms; diff --git a/src/server/commands/lnurl/index.ts b/src/server/commands/lnurl/index.ts new file mode 100644 index 0000000..7b2a4f4 --- /dev/null +++ b/src/server/commands/lnurl/index.ts @@ -0,0 +1,4 @@ +import lnurlCommand from './lnurl_command'; +import parseUrl from './parse_url'; + +export { lnurlCommand, parseUrl }; diff --git a/src/server/commands/lnurl/lnurl_command.ts b/src/server/commands/lnurl/lnurl_command.ts new file mode 100644 index 0000000..eedde2c --- /dev/null +++ b/src/server/commands/lnurl/lnurl_command.ts @@ -0,0 +1,133 @@ +import * as request from 'balanceofsatoshis/commands/simple_request'; +import * as types from '~shared/types'; + +import { AuthenticatedLnd } from 'lightning'; +import { Logger } from 'winston'; +import auth from './auth'; +import { auto } from 'async'; +import channel from './channel'; +import pay from './pay'; +import withdraw from './withdraw'; + +const functionAuth = 'auth'; +const functionChannel = 'channel'; +const functionPay = 'pay'; +const functionWithdraw = 'withdraw'; +const supportedFunctions = ['auth', 'channel', 'pay', 'withdraw']; + +/** Manage Lnurl functions + + { + [amount]: + avoid: [] + function: + request: + lnd: + lnurl: + logger: + [max_fee]: + [max_paths]: + out: [] + } + + @returns via Promise +*/ +type Args = { + args: types.commandLnurl; + lnd: AuthenticatedLnd; + logger: Logger; +} +const lnurlCommand = async ({ args, lnd, logger }: Args) => { + return auto({ + // Check arguments + validate: (cbk: any) => { + if (!supportedFunctions.includes(args.function)) { + return cbk([400, 'ExpectedLnurlFunctionToManageLnurl']); + } + + if (!lnd) { + return cbk([400, 'ExpectedLndToManageLnurl']); + } + + if (!args.url) { + return cbk([400, 'ExpectedUrlStringToManageLnurl']); + } + + if (!logger) { + return cbk([400, 'ExpectedLoggerToManageLnurl']); + } + + return cbk(); + }, + + // Authenticate using lnurl + auth: ['validate', async ({}) => { + // Exit early if not lnurl auth + if (args.function !== functionAuth) { + return; + } + + return (await auth({ + lnd, + logger, + request, + lnurl: args.url, + })).send; + }], + + // Request inbound channel + channel: ['validate', async ({}) => { + // Exit early if not lnurl channel + if (args.function !== functionChannel) { + return; + } + + return (await channel({ + lnd, + logger, + request, + is_private: args.is_private, + lnurl: args.url, + })).sendConfirmation; + }], + + // Pay to lnurl + pay: ['validate', async ({}) => { + // Exit early if not lnurl pay + if (args.function !== functionPay) { + return; + } + + return (await pay({ + lnd, + logger, + request, + amount: args.amount, + avoid: args.avoid, + lnurl: args.url, + max_fee: args.max_fee || 1337, + max_paths: args.max_paths || 1, + out: args.out, + })).pay; + }], + + // Withdraw from lnurl + withdraw: ['validate', async ({}) => { + // Exit early if not lnurl withdraw + if (args.function !== functionWithdraw) { + return; + } + + return (await withdraw({ + lnd, + logger, + request, + amount: args.amount, + lnurl: args.url, + })).withdraw; + }], + }, + ); +}; + +export default lnurlCommand; diff --git a/src/server/commands/lnurl/parse_url.ts b/src/server/commands/lnurl/parse_url.ts new file mode 100644 index 0000000..2e5d44d --- /dev/null +++ b/src/server/commands/lnurl/parse_url.ts @@ -0,0 +1,61 @@ +import { bech32 } from 'bech32'; + +const asLnurl = n => n.substring(n.startsWith('lightning:') ? 10 : 0); +const bech32CharLimit = 2000; +const { decode } = bech32; +const isEmail = n => /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/.test(n); +const isOnion = n => /.onion$/.test(n); +const isUsername = n => /^[a-z0-9_.]*$/.test(n); +const join = arr => arr.join(''); +const parseEmail = email => email.split('@'); +const prefix = 'lnurl'; +const sslProtocol = 'https://'; +const urlString = '/.well-known/lnurlp/'; +const wordsAsUtf8 = n => Buffer.from(bech32.fromWords(n)).toString('utf8'); + +/** Parse lnurl or LUD-16 lightning address + + { + url: + } + + @throws + + + @returns + { + url: + } +*/ +const parseUrl = ({ url }: { url: string }): { url: string } => { + if (!url) { + throw new Error('ExpectedLnurlOrLightningAddressToParse'); + } + + // Exit early when the URL looks like an email, indicating lightning address + if (!!isEmail(url)) { + const [username, domain] = parseEmail(url); + + // Check if the user name is valid + if (!isUsername(username)) { + throw new Error('ExpectedValidUsernameInLightningAddress'); + } + + // Because of restrictions on the HTTP request library, disallow onion URLs + if (!!isOnion(domain)) { + throw new Error('LnurlOnionUrlsCurrentlyUnsupported'); + } + + return { url: join([sslProtocol, domain, urlString, username]) }; + } + + if (decode(asLnurl(url), bech32CharLimit).prefix !== prefix) { + throw new Error('ExpectedLnurlPrefix'); + } + + const { words } = decode(asLnurl(url), bech32CharLimit); + + return { url: wordsAsUtf8(words) }; +}; + +export default parseUrl; diff --git a/src/server/commands/lnurl/pay.ts b/src/server/commands/lnurl/pay.ts new file mode 100644 index 0000000..edd80e7 --- /dev/null +++ b/src/server/commands/lnurl/pay.ts @@ -0,0 +1,154 @@ +import { AuthenticatedLnd } from 'lightning'; +import { Logger } from 'winston'; +import { auto } from 'async'; +import { getNodeAlias } from 'ln-sync'; +import getPayRequest from './get_pay_request'; +import getPayTerms from './get_pay_terms'; +import parseUrl from './parse_url'; +import { pay as payInvoice } from 'balanceofsatoshis/network'; +import { readFile } from 'fs'; + +const tokensAsMtokens = (tokens: number): string => Math.floor(tokens * 1e3).toString(); + +/** Pay to lnurl + + { + ask: + avoid: [] + lnd: + lnurl: + logger: + max_fee: + max_paths: + out: [] + request: + } + + @returns via Promise +*/ + +type Args = { + amount: number; + avoid: string[]; + lnd: AuthenticatedLnd; + lnurl: string; + logger: Logger; + max_fee: number; + max_paths: number; + out: string[]; + request: any; +} + +type Tasks = { + validate: undefined; + getTerms: { + description: string; + hash: string; + max: number; + min: number; + url: string; + }; + validateTerms: undefined; + getRequest: { destination: string; request: string }; + getAlias: { alias: string }; + pay: any; +} +const pay = async (args: Args): Promise => { + return auto({ + // Check arguments + validate: (cbk: any) => { + if (!args.lnurl) { + return cbk([400, 'ExpectedUrlToGetPaymentRequestFromLnurl']); + } + + try { + parseUrl({ url: args.lnurl }); + } catch (err) { + return cbk([400, err.message]); + } + + if (!args.lnd) { + return cbk([400, 'ExpectedLndToGetPaymentRequestFromLnurl']); + } + + if (!args.logger) { + return cbk([400, 'ExpectedLoggerToGetPaymentRequestFromLnurl']); + } + + if (!args.max_fee) { + return cbk([400, 'ExpectedMaxFeeToGetPaymentRequestFromLnurl']); + } + + if (!args.max_paths) { + return cbk([400, 'ExpectedMaxPathsCountToPayViaLnurl']); + } + + if (!args.request) { + return cbk([400, 'ExpectedRequestFunctionToGetLnurlData']); + } + + if (!args.amount) { + return cbk([400, 'ExpectedAmountToPayViaLnurl']); + } + + return cbk(); + }, + + // Get accepted terms from the encoded url + getTerms: ['validate', async ({}) => { + return (await getPayTerms({ + request: args.request, + url: parseUrl({ url: args.lnurl }).url, + })).getTerms; + }], + + // Validate terms + validateTerms: ['getTerms', ({ getTerms }, cbk: any) => { + if (getTerms.min > args.amount) { + return cbk([400, 'AmountBelowMinimumAcceptedAmountToPayViaLnurl', getTerms.min]); + } + + if (getTerms.max < args.amount) { + return cbk([400, 'AmountAboveMaximumAcceptedAmountToPayViaLnurl', getTerms.max]); + } + + return cbk(); + }], + + // Get payment request + getRequest: ['getTerms', 'validateTerms', async ({ getTerms }) => { + return (await getPayRequest({ + hash: getTerms.hash, + mtokens: tokensAsMtokens(args.amount), + request: args.request, + url: getTerms.url, + })).getRequest; + }], + + // Get the destination node alias + getAlias: ['getRequest', async ({ getRequest }) => { + return await getNodeAlias({ id: getRequest.destination, lnd: args.lnd }); + }], + + // Pay the payment request + pay: ['getAlias', 'getRequest', async ({ getAlias, getRequest }) => { + args.logger.info({ paying: getAlias.alias }); + + const avoidArray = !!args.avoid ? args.avoid.filter(n => !!n) : []; + const outArray = !!args.out ? args.out.filter(n => !!n) : []; + + return await payInvoice({ + avoid: avoidArray, + fs: { getFile: readFile }, + lnd: args.lnd, + logger: args.logger, + max_fee: args.max_fee, + max_paths: args.max_paths, + out: outArray, + request: getRequest.request, + }); + }], + }); +}; + +export default pay; diff --git a/src/server/commands/lnurl/sign_auth_challenge.ts b/src/server/commands/lnurl/sign_auth_challenge.ts new file mode 100644 index 0000000..4b30857 --- /dev/null +++ b/src/server/commands/lnurl/sign_auth_challenge.ts @@ -0,0 +1,46 @@ +import { createHash, createHmac } from 'crypto'; + +import derEncodeSignature from './der_encode_signature'; + +const bufferAsHex = buffer => buffer.toString('hex'); +const { from } = Buffer; +const hexAsBuffer = hex => Buffer.from(hex, 'hex'); +const hmacSha256 = (pk, url) => createHmac('sha256', pk).update(url).digest(); +const sha256 = n => createHash('sha256').update(n).digest(); +const utf8AsBuffer = utf8 => Buffer.from(utf8, 'utf8'); + +/** Sign an authentication challenge for LNURL Auth + + { + ecp: + hostname: + k1: + seed: + } + + @returns + { + public_key: + signature: + } +*/ +const signAuthChallenge = ({ ecp, hostname, k1, seed }) => { + // LUD-13: LN wallet defines hashingKey as sha256(signature) + const hashingKey = sha256(utf8AsBuffer(seed)); + + // LUD-13: linkingPrivKey is defined as hmacSha256(hashingKey, domain) + const linkingPrivKey = hmacSha256(hashingKey, utf8AsBuffer(hostname)); + + // Instantiate the key pair from this derived private key + const linkingKey = ecp.fromPrivateKey(linkingPrivKey); + + // Using the host-specific linking key, sign the challenge k1 value + const signature = bufferAsHex(from(linkingKey.sign(hexAsBuffer(k1)))); + + return { + public_key: bufferAsHex(linkingKey.publicKey), + signature: derEncodeSignature({ signature }).encoded, + }; +}; + +export default signAuthChallenge; diff --git a/src/server/commands/lnurl/withdraw.ts b/src/server/commands/lnurl/withdraw.ts new file mode 100644 index 0000000..06e0b75 --- /dev/null +++ b/src/server/commands/lnurl/withdraw.ts @@ -0,0 +1,218 @@ +import { AuthenticatedLnd, CreateInvoiceResult, createInvoice } from 'lightning'; + +import { Logger } from 'winston'; +import { auto } from 'async'; +import { bech32 } from 'bech32'; + +const { decode } = bech32; +const asLnurl = n => n.substring(n.startsWith('lightning:') ? 10 : 0); +const bech32CharLimit = 2000; +const errorStatus = 'ERROR'; +const isNumber = n => !isNaN(n); +const minWithdrawable = 1; +const mtokensAsTokens = n => Math.floor(n / 1000); +const prefix = 'lnurl'; +const sslProtocol = 'https:'; +const tag = 'withdrawRequest'; +const tokensAsMillitokens = n => n * 1000; +const wordsAsUtf8 = n => Buffer.from(bech32.fromWords(n)).toString('utf8'); + +/** Withdraw from lnurl + + { + ask: + request: + lnd: + lnurl: + logger: + } + + @returns via cbk or Promise +*/ + +type Args = { + lnurl: string; + lnd: AuthenticatedLnd; + request: any; + logger: Logger; + amount: number; +} + +type Tasks = { + validate: undefined; + getTerms: { + description: string; + max: number; + min: number; + url: string; + k1: string; + }; + validateTerms: undefined; + createInvoice: CreateInvoiceResult; + withdraw: { withdrawal_request_sent: boolean }; +} +const withdraw = async (args: Args): Promise => { + return auto({ + // Check arguments + validate: (cbk: any) => { + if (!args.lnurl) { + return cbk([400, 'ExpectedUrlToWithdrawFromLnurl']); + } + + try { + decode(asLnurl(args.lnurl), bech32CharLimit); + } catch (err) { + return cbk([400, 'FailedToDecodeLnurlToWithdraw', { err }]); + } + + if (decode(asLnurl(args.lnurl), bech32CharLimit).prefix !== prefix) { + return cbk([400, 'ExpectedLnUrlPrefixToWithdraw']); + } + + if (!args.lnd) { + return cbk([400, 'ExpectedLndToWithdrawFromLnurl']); + } + + if (!args.logger) { + return cbk([400, 'ExpectedLoggerToWithdrawFromLnurl']); + } + + if (!args.request) { + return cbk([400, 'ExpectedRequestFunctionToGetLnurlWithdrawData']); + } + + return cbk(); + }, + + // Get accepted terms from the encoded url + getTerms: ['validate', ({}, cbk: any) => { + const { words } = decode(asLnurl(args.lnurl), bech32CharLimit); + + const url = wordsAsUtf8(words); + + return args.request({ url, json: true }, (err, r, json) => { + if (!!err) { + return cbk([503, 'FailureGettingLnUrlDataFromUrl', { err }]); + } + + if (!json) { + return cbk([503, 'ExpectedJsonObjectReturnedInLnurlResponse']); + } + + if (json.status === errorStatus) { + return cbk([503, 'LnurlWithdrawReturnedErr', { err: json.reason }]); + } + + if (!json.callback) { + return cbk([503, 'ExpectedCallbackInLnurlResponseJson']); + } + + try { + // eslint-disable-next-line no-new + new URL(json.callback); + } catch (err) { + return cbk([503, 'ExpectedValidCallbackUrlInLnurlResponseJson']); + } + + if ((new URL(json.callback)).protocol !== sslProtocol) { + return cbk([400, 'LnurlsThatSpecifyNonSslUrlsAreUnsupported']); + } + + if (!json.k1) { + return cbk([503, 'ExpectedK1InLnurlResponseJson']); + } + + if (!json.tag) { + return cbk([503, 'ExpectedTagInLnurlResponseJson']); + } + + if (json.tag !== tag) { + return cbk([503, 'ExpectedTagToBeWithdrawRequestInLnurlResponse']); + } + + if (!isNumber(json.minWithdrawable)) { + return cbk([503, 'ExpectedNumericValueForMinWithdrawable']); + } + + if (!isNumber(json.maxWithdrawable)) { + return cbk([503, 'ExpectedNumericValueForMaxWithdrawable']); + } + + if (json.minWithdrawable < minWithdrawable) { + return cbk([400, 'MinWithdrawableIsLowerThanSupportedValue']); + } + + if (json.minWithdrawable > json.maxWithdrawable) { + return cbk([400, 'MinWithdrawableIsHigherThanMaxWithdrawable']); + } + + return cbk(null, { + description: json.defaultDescription, + k1: json.k1, + max: mtokensAsTokens(json.maxWithdrawable), + min: mtokensAsTokens(json.minWithdrawable), + url: json.callback, + }); + }); + }], + + // Validate terms + validateTerms: ['getTerms', ({ getTerms }, cbk: any) => { + if (getTerms.min > args.amount) { + return cbk([400, 'AmountBelowMinimumAcceptedAmountToPayViaLnurl', getTerms.min]); + } + + if (getTerms.max < args.amount) { + return cbk([400, 'AmountAboveMaximumAcceptedAmountToPayViaLnurl', getTerms.max]); + } + + return cbk(); + }], + + // Create a new payment request for withdrawl + createInvoice: ['getTerms', 'validateTerms', async ({}) => { + return await createInvoice({ lnd: args.lnd, mtokens: tokensAsMillitokens(args.amount).toString() }); + }], + + // Send the withdraw request + withdraw: [ + 'createInvoice', + 'getTerms', + ({ createInvoice, getTerms }, cbk: any) => { + args.logger.info({ invoice: createInvoice.request }); + + const { url } = getTerms; + const { k1 } = getTerms; + + const qs = { k1, pr: createInvoice.request }; + + return args.request({ url, qs, json: true }, (err, r, json) => { + if (!!err) { + return cbk([503, 'UnexpectedErrorRequestingLnurlWithdraw', { err }]); + } + + if (!json) { + return cbk([503, 'ExpectedJsonObjectReturnedInWithdrawResponse']); + } + + if (!json.status) { + return cbk([503, 'ExpectedStatusInLnurlWithdrawResponseJson']); + } + + if (json.status === errorStatus) { + return cbk([503, 'LnurlWithdrawReqFailed', { err: json.reason }]); + } + + if (json.status !== 'OK') { + return cbk([503, 'ExpectedStatusToBeOkInLnurlResponseJson']); + } + + args.logger.info({ withdrawal_request_sent: true }); + + return cbk(null, { withdrawal_request_sent: true }); + }); + }], + }); +}; + +export default withdraw; diff --git a/src/server/commands/pay/pay_command.ts b/src/server/commands/pay/pay_command.ts index 1677609..011eff6 100644 --- a/src/server/commands/pay/pay_command.ts +++ b/src/server/commands/pay/pay_command.ts @@ -1,7 +1,6 @@ import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { Logger as LoggerType } from 'winston'; import { httpLogger } from '~server/utils/global_functions'; import { pay } from 'balanceofsatoshis/network'; @@ -39,7 +38,6 @@ type Args = { lnd: AuthenticatedLnd; }; const payCommand = async ({ args, lnd, logger }: Args): Promise<{ result: any }> => { - console.log(args); const avoidArray = !!args.avoid ? args.avoid.filter(n => !!n) : []; const outArray = !!args.out ? args.out.filter(n => !!n) : []; @@ -60,7 +58,6 @@ const payCommand = async ({ args, lnd, logger }: Args): Promise<{ result: any }> return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/peers/peers_command.ts b/src/server/commands/peers/peers_command.ts index 9bbf1aa..bffba8c 100644 --- a/src/server/commands/peers/peers_command.ts +++ b/src/server/commands/peers/peers_command.ts @@ -1,7 +1,6 @@ import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { getPeers } from 'balanceofsatoshis/network'; import { httpLogger } from '~server/utils/global_functions'; import { readFile } from 'fs'; @@ -78,7 +77,6 @@ const peersCommand = async ({ args, lnd }: Args): Promise<{ result: Result }> => return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/price/price_command.ts b/src/server/commands/price/price_command.ts index 22e90a0..29775fd 100644 --- a/src/server/commands/price/price_command.ts +++ b/src/server/commands/price/price_command.ts @@ -1,7 +1,6 @@ import * as request from 'balanceofsatoshis/commands/simple_request'; import * as types from '~shared/types'; -import { Logger } from '@nestjs/common'; import { getPrices } from '@alexbosworth/fiat'; import { httpLogger } from '~server/utils/global_functions'; @@ -21,14 +20,17 @@ import { httpLogger } from '~server/utils/global_functions'; } */ -const priceCommand = async (args: types.commandPrice): Promise<{ result: any }> => { +type Args = { + args: types.commandPrice; +} +const priceCommand = async ({ args }: Args): Promise<{ result: any }> => { try { const symbols = !!args.symbols ? args.symbols - .toUpperCase() - .trim() - .split(',') - .map(n => n.trim()) + .toUpperCase() + .trim() + .split(',') + .map(n => n.trim()) : ['USD']; const result = await getPrices({ @@ -39,7 +41,6 @@ const priceCommand = async (args: types.commandPrice): Promise<{ result: any }> return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/probe/probe_command.ts b/src/server/commands/probe/probe_command.ts index f56e1e0..4e77c2f 100644 --- a/src/server/commands/probe/probe_command.ts +++ b/src/server/commands/probe/probe_command.ts @@ -67,7 +67,6 @@ const probeCommand = async ({ args, lnd, logger }: Args): Promise<{ result: Retu return { result }; } catch (error) { - logger.error({ error }); httpLogger({ error }); } }; diff --git a/src/server/commands/rebalance/rebalance_command.ts b/src/server/commands/rebalance/rebalance_command.ts index 3435550..fd7753f 100644 --- a/src/server/commands/rebalance/rebalance_command.ts +++ b/src/server/commands/rebalance/rebalance_command.ts @@ -53,7 +53,6 @@ const rebalanceCommand = async ({ args, lnd, logger }): Promise<{ result: any }> return { result }; } catch (error) { - logger.error({ error }); httpLogger({ error }); } }; diff --git a/src/server/commands/reconnect/reconnect_command.ts b/src/server/commands/reconnect/reconnect_command.ts index 30d72a9..7bc1a86 100644 --- a/src/server/commands/reconnect/reconnect_command.ts +++ b/src/server/commands/reconnect/reconnect_command.ts @@ -1,5 +1,4 @@ import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { httpLogger } from '~server/utils/global_functions'; import { reconnect } from 'balanceofsatoshis/network'; @@ -41,7 +40,6 @@ const reconnectCommand = async ({ lnd }: Args): Promise<{ result: Result }> => { return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/send/send_command.ts b/src/server/commands/send/send_command.ts index 34e6b34..0712b9a 100644 --- a/src/server/commands/send/send_command.ts +++ b/src/server/commands/send/send_command.ts @@ -2,7 +2,6 @@ import * as request from 'balanceofsatoshis/commands/simple_request'; import * as types from '~shared/types'; import { AuthenticatedLnd } from 'lightning'; -import { Logger } from '@nestjs/common'; import { Logger as LoggerType } from 'winston'; import { httpLogger } from '~server/utils/global_functions'; import { pushPayment } from 'balanceofsatoshis/network'; @@ -71,7 +70,6 @@ const sendCommand = async ({ args, lnd, logger }: Args): Promise<{ result: any } return { result }; } catch (error) { - Logger.error(error); httpLogger({ error }); } }; diff --git a/src/server/commands/tags/tags_command.ts b/src/server/commands/tags/tags_command.ts index 144c047..a394655 100644 --- a/src/server/commands/tags/tags_command.ts +++ b/src/server/commands/tags/tags_command.ts @@ -1,3 +1,5 @@ +import * as types from '~shared/types'; + import { mkdir, readFile, writeFile } from 'fs'; import { auto } from 'async'; @@ -45,12 +47,7 @@ const uniq = (arr: Iterable) => Array.from(new Set(arr)); */ type Args = { - add: string[]; - id?: string; - tag?: string; - remove: string[]; - is_avoided?: boolean; - icon?: string; + args: types.commandTags; }; type Tasks = { @@ -63,7 +60,7 @@ type Tasks = { }; }; -const tagsCommand = async (args: Args): Promise<{ result: any }> => { +const tagsCommand = async ({ args }: Args): Promise<{ result: any }> => { try { const result = await auto({ // Validate diff --git a/src/server/modules/commands/commands.controller.ts b/src/server/modules/commands/commands.controller.ts index 557b3b3..81dace1 100644 --- a/src/server/modules/commands/commands.controller.ts +++ b/src/server/modules/commands/commands.controller.ts @@ -1,129 +1,119 @@ import { Controller, Get, Query } from '@nestjs/common'; -import { - accountingDto, - balanceDto, - certValidityDaysDto, - chainDepositDto, - chainfeesDto, - chartChainFeesDto, - chartFeesEarnedDto, - chartFeesPaidDto, - chartPaymentsReceivedDto, - closedDto, - findDto, - forwardsDto, - graphDto, - payDto, - peersDto, - priceDto, - probeDto, - reconnectDto, - sendDto, - tagsDto, -} from '~shared/commands.dto'; +import * as dto from '~shared/commands.dto'; + import { CommandsService } from './commands.service'; +/** + * CommandsController: Controller for handling bos commands + * Takes in a request body, calls the appropriate commands service, and returns the result + */ + @Controller('api') export class CommandsController { constructor(private readonly commandsService: CommandsService) {} @Get('accounting') - async accountingCommand(@Query() args: accountingDto) { + async accountingCommand(@Query() args: dto.accountingDto) { return this.commandsService.accountingCommand(args); } @Get('balance') - async balanceCommand(@Query() args: balanceDto) { + async balanceCommand(@Query() args: dto.balanceDto) { return this.commandsService.balanceCommand(args); } @Get('cert-validity-days') - async certValidityDaysCommand(@Query() args: certValidityDaysDto) { + async certValidityDaysCommand(@Query() args: dto.certValidityDaysDto) { return this.commandsService.certValidityDaysCommand(args); } @Get('chain-deposit') - async chainDepositCommand(@Query() args: chainDepositDto) { + async chainDepositCommand(@Query() args: dto.chainDepositDto) { return this.commandsService.chainDepositCommand(args); } @Get('chainfees') - async chainfeesCommand(@Query() args: chainfeesDto) { + async chainfeesCommand(@Query() args: dto.chainfeesDto) { return this.commandsService.chainfeesCommand(args); } @Get('chart-chain-fees') - async chartChainFeesCommand(@Query() args: chartChainFeesDto) { + async chartChainFeesCommand(@Query() args: dto.chartChainFeesDto) { return this.commandsService.chartChainFeesCommand(args); } @Get('chart-fees-earned') - async chartFeesEarnedCommand(@Query() args: chartFeesEarnedDto) { + async chartFeesEarnedCommand(@Query() args: dto.chartFeesEarnedDto) { return this.commandsService.chartFeesEarnedCommand(args); } @Get('chart-fees-paid') - async chartFeesPaidCommand(@Query() args: chartFeesPaidDto) { + async chartFeesPaidCommand(@Query() args: dto.chartFeesPaidDto) { return this.commandsService.chartFeesPaidCommand(args); } @Get('chart-payments-received') - async chartPaymentsReceivedCommand(@Query() args: chartPaymentsReceivedDto) { + async chartPaymentsReceivedCommand(@Query() args: dto.chartPaymentsReceivedDto) { return this.commandsService.chartPaymentsReceivedCommand(args); } @Get('closed') - async closedCommand(@Query() args: closedDto) { + async closedCommand(@Query() args: dto.closedDto) { return this.commandsService.closedCommand(args); } @Get('find') - async findCommand(@Query() args: findDto) { + async findCommand(@Query() args: dto.findDto) { return this.commandsService.findCommand(args); } @Get('forwards') - async forwardsCommand(@Query() args: forwardsDto) { + async forwardsCommand(@Query() args: dto.forwardsDto) { return this.commandsService.forwardsCommand(args); } @Get('graph') - async graphCommand(@Query() args: graphDto) { + async graphCommand(@Query() args: dto.graphDto) { return this.commandsService.graphCommand(args); } + @Get('lnurl') + async lnurlCommand(@Query() args: dto.lnurlDto) { + return this.commandsService.lnurlCommand(args); + } + @Get('pay') - async payCommand(@Query() args: payDto) { + async payCommand(@Query() args: dto.payDto) { return this.commandsService.payCommand(args); } @Get('peers') - async peersCommand(@Query() args: peersDto) { + async peersCommand(@Query() args: dto.peersDto) { return this.commandsService.peersCommand(args); } @Get('price') - async priceCommand(@Query() args: priceDto) { + async priceCommand(@Query() args: dto.priceDto) { return this.commandsService.priceCommand(args); } @Get('probe') - async probeCommand(@Query() args: probeDto) { + async probeCommand(@Query() args: dto.probeDto) { return this.commandsService.probeCommand(args); } @Get('reconnect') - async reconnectCommand(@Query() args: reconnectDto) { + async reconnectCommand(@Query() args: dto.reconnectDto) { return this.commandsService.reconnectCommand(args); } @Get('send') - async sendCommand(@Query() args: sendDto) { + async sendCommand(@Query() args: dto.sendDto) { return this.commandsService.sendCommand(args); } @Get('tags') - async tagsCommand(@Query() args: tagsDto) { + async tagsCommand(@Query() args: dto.tagsDto) { return this.commandsService.tagsCommand(args); } } diff --git a/src/server/modules/commands/commands.service.ts b/src/server/modules/commands/commands.service.ts index 40c0787..c72f22b 100644 --- a/src/server/modules/commands/commands.service.ts +++ b/src/server/modules/commands/commands.service.ts @@ -1,152 +1,140 @@ +import * as commands from '~server/commands'; +import * as dto from '~shared/commands.dto'; + import { Logger, createLogger, format, transports } from 'winston'; -import { - accountingCommand, - balanceCommand, - certValidityDaysCommand, - chainDepositCommand, - chainfeesCommand, - chartChainFeesCommand, - chartFeesEarnedCommand, - chartFeesPaidCommand, - chartPaymentsReceivedCommand, - closedCommand, - findCommand, - forwardsCommand, - graphCommand, - payCommand, - peersCommand, - priceCommand, - probeCommand, - reconnectCommand, - sendCommand, - tagsCommand, -} from '~server/commands'; -import { - accountingDto, - balanceDto, - certValidityDaysDto, - chainDepositDto, - chainfeesDto, - chartChainFeesDto, - chartFeesEarnedDto, - chartFeesPaidDto, - chartPaymentsReceivedDto, - closedDto, - findDto, - forwardsDto, - graphDto, - payDto, - peersDto, - priceDto, - probeDto, - reconnectDto, - sendDto, - tagsDto, -} from '~shared/commands.dto'; import { Injectable } from '@nestjs/common'; import { LndService } from '../lnd/lnd.service'; import { SocketGateway } from '../socket/socket.gateway'; +import { httpLogger } from '~server/utils/global_functions'; import { removeStyling } from '~server/utils/constants'; +/** + * CommandsService: Service for handling bos commands + * Takes in a request body, calls the appropriate command, and returns the result + */ + @Injectable() export class CommandsService { constructor(private socketService: SocketGateway) {} - async accountingCommand(args: accountingDto): Promise<{ result: any }> { + async logger({ messageId, service }) { + const emit = this.socketService.server.emit.bind(this.socketService.server); + + const myFormat = format.printf(({ message }) => { + return emit(messageId, { + message: format.prettyPrint(removeStyling(message)), + }); + }); + + const logger: Logger = createLogger({ + level: 'info', + format: format.combine(myFormat), + defaultMeta: { service }, + transports: [ + new transports.Console({ + format: format.combine(format.prettyPrint()), + }), + ], + }); + + return logger; + } + + async accountingCommand(args: dto.accountingDto): Promise<{ result: any }> { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await accountingCommand({ args, lnd }); + const { result } = await commands.accountingCommand({ args, lnd }); return { result }; } - async balanceCommand(args: balanceDto): Promise<{ result: any }> { + async balanceCommand(args: dto.balanceDto): Promise<{ result: any }> { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await balanceCommand({ args, lnd }); + const { result } = await commands.balanceCommand({ args, lnd }); return { result }; } - async certValidityDaysCommand(args: certValidityDaysDto): Promise<{ result: any }> { - const { result } = await certValidityDaysCommand({ below: args.below, node: args.node }); + async certValidityDaysCommand(args: dto.certValidityDaysDto): Promise<{ result: any }> { + const { result } = await commands.certValidityDaysCommand({ below: args.below, node: args.node }); return { result: String(result) }; } - async chainDepositCommand(args: chainDepositDto): Promise<{ result: any }> { + async chainDepositCommand(args: dto.chainDepositDto): Promise<{ result: any }> { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await chainDepositCommand({ args, lnd }); + const { result } = await commands.chainDepositCommand({ args, lnd }); return { result }; } - async chainfeesCommand(args: chainfeesDto): Promise<{ result: any }> { + async chainfeesCommand(args: dto.chainfeesDto): Promise<{ result: any }> { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await chainfeesCommand({ args, lnd }); + const { result } = await commands.chainfeesCommand({ args, lnd }); return { result }; } - async chartChainFeesCommand(args: chartChainFeesDto): Promise<{ result: any }> { + async chartChainFeesCommand(args: dto.chartChainFeesDto): Promise<{ result: any }> { const lnds = await LndService.getLnds({ nodes: args.nodes }); - const { result } = await chartChainFeesCommand({ args, lnd: lnds }); + const { result } = await commands.chartChainFeesCommand({ args, lnd: lnds }); return { result }; } - async chartFeesEarnedCommand(args: chartFeesEarnedDto): Promise<{ result: any }> { + async chartFeesEarnedCommand(args: dto.chartFeesEarnedDto): Promise<{ result: any }> { const lnds = await LndService.getLnds({ nodes: args.nodes }); - const { result } = await chartFeesEarnedCommand({ args, lnd: lnds }); + const { result } = await commands.chartFeesEarnedCommand({ args, lnd: lnds }); return { result }; } - async chartFeesPaidCommand(args: chartFeesPaidDto): Promise<{ result: any }> { + async chartFeesPaidCommand(args: dto.chartFeesPaidDto): Promise<{ result: any }> { const lnds = await LndService.getLnds({ nodes: args.nodes }); - const { result } = await chartFeesPaidCommand({ args, lnd: lnds }); + const { result } = await commands.chartFeesPaidCommand({ args, lnd: lnds }); return { result }; } - async chartPaymentsReceivedCommand(args: chartPaymentsReceivedDto): Promise<{ result: any }> { + async chartPaymentsReceivedCommand(args: dto.chartPaymentsReceivedDto): Promise<{ result: any }> { const lnds = await LndService.getLnds({ nodes: args.nodes }); - const { result } = await chartPaymentsReceivedCommand({ args, lnd: lnds }); + const { result } = await commands.chartPaymentsReceivedCommand({ args, lnd: lnds }); return { result }; } - async closedCommand(args: closedDto): Promise<{ result: any }> { + async closedCommand(args: dto.closedDto): Promise<{ result: any }> { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await closedCommand({ args, lnd }); + const { result } = await commands.closedCommand({ args, lnd }); return { result }; } - async findCommand(args: findDto): Promise<{ result: any }> { + async findCommand(args: dto.findDto): Promise<{ result: any }> { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await findCommand({ args, lnd }); + const { result } = await commands.findCommand({ args, lnd }); return { result }; } - async forwardsCommand(args: forwardsDto): Promise<{ result: any }> { + async forwardsCommand(args: dto.forwardsDto): Promise<{ result: any }> { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await forwardsCommand({ args, lnd }); + const { result } = await commands.forwardsCommand({ args, lnd }); return { result }; } - async graphCommand(args: graphDto): Promise<{ result: any }> { + async graphCommand(args: dto.graphDto): Promise<{ result: any }> { const logger: Logger = createLogger({ level: 'info', format: format.json(), @@ -160,34 +148,32 @@ export class CommandsService { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await graphCommand({ args, lnd, logger }); + const { result } = await commands.graphCommand({ args, lnd, logger }); return { result }; } - async payCommand(args: payDto): Promise<{ result: any }> { - const emit = this.socketService.server.emit.bind(this.socketService.server); - const myFormat = format.printf(({ message }) => { - return emit(args.message_id, { - message: format.prettyPrint(removeStyling(message)), - }); - }); + async lnurlCommand(args: dto.lnurlDto): Promise<{ result: any }> { + try { + const logger = await this.logger({ messageId: args.message_id, service: 'lnurl' }); - const logger: Logger = createLogger({ - level: 'info', - format: format.combine(myFormat), - defaultMeta: { service: 'pay' }, - transports: [ - new transports.Console({ - format: format.combine(format.prettyPrint()), - }), - ], - }); + const lnd = await LndService.authenticatedLnd({ node: args.node }); + + const result = await commands.lnurlCommand({ args, lnd, logger }); + + return { result }; + } catch (error) { + httpLogger({ error }); + } + } + + async payCommand(args: dto.payDto): Promise<{ result: any }> { + const logger = await this.logger({ messageId: args.message_id, service: 'pay' }); const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await payCommand({ + const { result } = await commands.payCommand({ args, lnd, logger, @@ -196,78 +182,44 @@ export class CommandsService { return { result }; } - async peersCommand(args: peersDto): Promise<{ result: any }> { + async peersCommand(args: dto.peersDto): Promise<{ result: any }> { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await peersCommand({ args, lnd }); + const { result } = await commands.peersCommand({ args, lnd }); return { result }; } - async priceCommand(args: priceDto): Promise<{ result: any }> { - const { result } = await priceCommand(args); + async priceCommand(args: dto.priceDto): Promise<{ result: any }> { + const { result } = await commands.priceCommand({ args }); return { result }; } - async probeCommand(args: probeDto): Promise<{ result: any }> { - const emit = this.socketService.server.emit.bind(this.socketService.server); - - const myFormat = format.printf(({ message }) => { - return emit(args.message_id, { - message: format.prettyPrint(removeStyling(message)), - }); - }); - - const logger: Logger = createLogger({ - level: 'info', - format: format.combine(myFormat), - defaultMeta: { service: 'probe' }, - transports: [ - new transports.Console({ - format: format.combine(format.prettyPrint()), - }), - ], - }); + async probeCommand(args: dto.probeDto): Promise<{ result: any }> { + const logger = await this.logger({ messageId: args.message_id, service: 'probe' }); const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await probeCommand({ args, lnd, logger }); + const { result } = await commands.probeCommand({ args, lnd, logger }); return { result }; } - async reconnectCommand(args: reconnectDto): Promise<{ result: any }> { + async reconnectCommand(args: dto.reconnectDto): Promise<{ result: any }> { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await reconnectCommand({ lnd }); + const { result } = await commands.reconnectCommand({ lnd }); return { result }; } - async sendCommand(args: sendDto): Promise<{ result: any }> { - const emit = this.socketService.server.emit.bind(this.socketService.server); - - const myFormat = format.printf(({ message }) => { - return emit(args.message_id, { - message: format.prettyPrint(removeStyling(message)), - }); - }); - - const logger: Logger = createLogger({ - level: 'info', - format: format.combine(myFormat), - defaultMeta: { service: 'send' }, - transports: [ - new transports.Console({ - format: format.combine(format.prettyPrint()), - }), - ], - }); + async sendCommand(args: dto.sendDto): Promise<{ result: any }> { + const logger = await this.logger({ messageId: args.message_id, service: 'send' }); const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await sendCommand({ + const { result } = await commands.sendCommand({ args, lnd, logger, @@ -276,8 +228,8 @@ export class CommandsService { return { result }; } - async tagsCommand(args: tagsDto): Promise<{ result: any }> { - const { result } = await tagsCommand(args); + async tagsCommand(args: dto.tagsDto): Promise<{ result: any }> { + const { result } = await commands.tagsCommand({ args }); return { result }; } diff --git a/src/server/utils/global_functions.ts b/src/server/utils/global_functions.ts index aa2b65a..77d0d8d 100644 --- a/src/server/utils/global_functions.ts +++ b/src/server/utils/global_functions.ts @@ -10,12 +10,17 @@ const isNumber = (n: any) => !isNaN(n); // Logger for throwing http errors export const httpLogger = ({ error }: { error: any }) => { - Logger.error(stringify(error)); + Logger.error(error); if (isArray(error) && !!error.length && isString(error[1]) && isNumber(error[0])) { throw new HttpException(String(error[1]), Number(error[0])); } else { - throw new HttpException(stringify(error), 503); + try { + JSON.parse(error); + throw new HttpException(stringify(error), 503); + } catch (e) { + throw new HttpException(String(error), 503); + } } }; diff --git a/src/shared/commands.dto.ts b/src/shared/commands.dto.ts index 58b30bd..d7a9555 100644 --- a/src/shared/commands.dto.ts +++ b/src/shared/commands.dto.ts @@ -299,67 +299,117 @@ export class findDto { query: string; } -export class graphDto { - @Transform(({ value }) => toStringArray(value)) +export class forwardsDto { + @Transform(({ value }) => toNumber(value)) @IsOptional() - @IsArray() - filters: string[]; + @IsNumber() + days: number; @Transform(({ value }) => trim(value)) @IsOptional() @IsString() - node: string; + from: string; @Transform(({ value }) => trim(value)) @IsOptional() @IsString() - query: string; + node: string; @Transform(({ value }) => trim(value)) @IsOptional() @IsString() sort: string; + + @Transform(({ value }) => trim(value)) + @IsOptional() + @IsString() + to: string; } -export class forwardsDto { - @Transform(({ value }) => toNumber(value)) +export class graphDto { + @Transform(({ value }) => toStringArray(value)) @IsOptional() - @IsNumber() - days: number; + @IsArray() + filters: string[]; @Transform(({ value }) => trim(value)) @IsOptional() @IsString() - from: string; + node: string; @Transform(({ value }) => trim(value)) @IsOptional() @IsString() - node: string; + query: string; @Transform(({ value }) => trim(value)) @IsOptional() @IsString() sort: string; +} +export class getRebalancesDto { @Transform(({ value }) => trim(value)) @IsOptional() @IsString() - to: string; + node: string; } -export class getRebalancesDto { +export class grpcDto { @Transform(({ value }) => trim(value)) @IsOptional() @IsString() node: string; } -export class grpcDto { +export class lnurlDto { + @Transform(({ value }) => toNumber(value)) + @IsOptional() + @IsNumber() + amount: number; + + @Transform(({ value }) => toStringArray(value)) + @IsOptional() + @IsArray() + avoid: string[]; + + @Transform(({ value }) => trim(value)) + @IsOptional() + @IsString() + function: string; + + @Transform(({ value }) => toBoolean(value)) + @IsOptional() + @IsBoolean() + is_private: boolean; + + @Transform(({ value }) => toNumber(value)) + @IsOptional() + @IsNumber() + max_fee: number; + + @Transform(({ value }) => toNumber(value)) + @IsOptional() + @IsNumber() + max_paths: number; + + @Transform(({ value }) => trim(value)) + @IsString() + message_id: string; + @Transform(({ value }) => trim(value)) @IsOptional() @IsString() node: string; + + @Transform(({ value }) => toStringArray(value)) + @IsOptional() + @IsArray() + out: string[]; + + @Transform(({ value }) => trim(value)) + @IsString() + url: string; } export class payDto { diff --git a/src/shared/types.ts b/src/shared/types.ts index a346112..f41521d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -115,7 +115,23 @@ export type commandGraph = { sort: string; }; -// ========================Pay command================================ +// ========================Lnurl Command===================================== + +export type commandLnurl = { + amount: number; + avoid: string[]; + function: string; + is_private: boolean; + max_fee: number; + max_paths: number; + message_id?: string; + node: string; + out: string[]; + url: string; +}; + + +// ========================Pay command======================================= export type commandPay = { avoid: string[]; diff --git a/tests/client/lnurl.test.ts b/tests/client/lnurl.test.ts new file mode 100644 index 0000000..44ea60a --- /dev/null +++ b/tests/client/lnurl.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from '@playwright/test'; +import { removeAccessToken, setAccessToken } from '../utils/setAccessToken'; + +import commands from '../../src/client/commands'; +import { testConstants } from '../utils/constants'; + +const LnurlCommand = commands.find(n => n.value === 'Lnurl'); + +test.describe('Test the Lnurl command client page', async () => { + test.beforeEach(async ({ page }) => { + await setAccessToken({ page }); + }); + + test('test the Lnurl command page: auth', async ({ page }) => { + await page.goto(testConstants.commandsPage); + await page.click('#Lnurl'); + await expect(page).toHaveTitle('Lnurl'); + await page.locator('#function').click(); + await page.locator(`#${LnurlCommand?.args?.auth}`).click(); + + await page.type(`#${LnurlCommand?.flags?.url}`, testConstants.lnurlAuth); + await page.type('#node', 'testnode1'); + + await page.click('text=run command'); + const popup = await page.waitForEvent('popup'); + + await expect(popup).toHaveTitle('Lnurl Result'); + await popup.waitForTimeout(1000); + + await expect(popup.locator('#lnurlResultTitle')).toBeVisible(); + + await popup.close(); + + await page.bringToFront(); + await page.click('text=home'); + }); + + test('test the Lnurl command page: channel', async ({ page }) => { + await page.goto(testConstants.commandsPage); + await page.click('#Lnurl'); + await expect(page).toHaveTitle('Lnurl'); + await page.locator('#function').click(); + await page.locator(`#${LnurlCommand?.args?.channel}`).click(); + await page.check(`#${LnurlCommand?.flags?.is_private}`); + await page.type(`#${LnurlCommand?.flags?.url}`, testConstants.lnurlChannel); + await page.type('#node', 'testnode1'); + + await page.click('text=run command'); + const popup = await page.waitForEvent('popup'); + + await expect(popup).toHaveTitle('Lnurl Result'); + await popup.waitForTimeout(1000); + + await expect(popup.locator('#lnurlResultTitle')).toBeVisible(); + + await popup.close(); + + await page.bringToFront(); + await page.click('text=home'); + }); + + test('test the Lnurl command page: pay', async ({ page }) => { + await page.goto(testConstants.commandsPage); + await page.click('#Lnurl'); + await expect(page).toHaveTitle('Lnurl'); + await page.locator('#function').click(); + await page.locator(`#${LnurlCommand?.args?.pay}`).click(); + + await page.type(`#${LnurlCommand?.flags?.url}`, testConstants.lnurlPay); + await page.type(`#${LnurlCommand?.flags?.amount}`, '5'); + await page.type('#node', 'testnode1'); + + await page.click('text=run command'); + const popup = await page.waitForEvent('popup'); + + await expect(popup).toHaveTitle('Lnurl Result'); + await popup.waitForTimeout(1000); + + await expect(popup.locator('#lnurlResultTitle')).toBeVisible(); + + await popup.close(); + + await page.bringToFront(); + await page.click('text=home'); + }); + + + test('test the Lnurl command page: withdraw', async ({ page }) => { + await page.goto(testConstants.commandsPage); + await page.click('#Lnurl'); + await expect(page).toHaveTitle('Lnurl'); + await page.locator('#function').click(); + await page.locator(`#${LnurlCommand?.args?.withdraw}`).click(); + + await page.type(`#${LnurlCommand?.flags?.url}`, testConstants.lnurlWithdraw); + await page.type(`#${LnurlCommand?.flags?.amount}`, '4'); + await page.type('#node', 'testnode1'); + + await page.click('text=run command'); + const popup = await page.waitForEvent('popup'); + + await expect(popup).toHaveTitle('Lnurl Result'); + await popup.waitForTimeout(1000); + + await expect(popup.locator('#lnurlResultTitle')).toBeVisible(); + + await popup.close(); + + await page.bringToFront(); + await page.click('text=home'); + }); + + test.afterEach(async ({ page }) => { + await removeAccessToken({ page }); + }); +}); diff --git a/tests/server/lnurl.test.ts b/tests/server/lnurl.test.ts new file mode 100644 index 0000000..3edf1c8 --- /dev/null +++ b/tests/server/lnurl.test.ts @@ -0,0 +1,57 @@ +import { Logger, createLogger, format, transports } from 'winston'; +import { expect, test } from '@playwright/test'; +import spawnLightningServer, { SpawnLightningServerType } from '../utils/spawn_lightning_server.js'; + +import auth from '../../src/server/commands/lnurl/auth'; +import channel from '../../src/server/commands/lnurl/channel'; +import request from 'balanceofsatoshis/commands/simple_request'; +import { testConstants } from '../utils/constants.js'; + +test.describe('Test Lnurl command on the node.js side', async () => { + let lightning: SpawnLightningServerType; + let logger: Logger; + + test.beforeAll(async () => { + lightning = await spawnLightningServer(); + + logger = createLogger({ + level: 'info', + format: format.combine(format.prettyPrint()), + defaultMeta: { service: 'lnurl' }, + transports: [ + new transports.Console({ + format: format.combine(format.prettyPrint()), + }), + ], + }); + }); + + test('run lnurl command: auth', async () => { + const args = { + lnd: lightning.lnd, + lnurl: testConstants.lnurlAuth, + request, + logger, + }; + const result = (await auth(args)).send; + console.log('lnurl auth----', result); + expect(result).toBeTruthy(); + }); + + test('run lnurl command: channel', async () => { + const args = { + is_private: true, + lnurl: testConstants.lnurlChannel, + lnd: lightning.lnd, + request, + logger, + }; + const result = await channel(args); + console.log('lnurl channel----', result); + expect(result).toBeTruthy(); + }); + + test.afterAll(async () => { + await lightning.kill({}); + }); +}); diff --git a/tests/server/tags.test.ts b/tests/server/tags.test.ts index d77be5e..da4bacd 100644 --- a/tests/server/tags.test.ts +++ b/tests/server/tags.test.ts @@ -27,7 +27,7 @@ test.describe('Test Tags command on the node.js side', async () => { icon: '❤️', is_avoided: true, }; - const { result } = await tagsCommand(args); + const { result } = await tagsCommand({ args }); console.log(result); expect(result).toBeTruthy(); }); @@ -37,7 +37,7 @@ test.describe('Test Tags command on the node.js side', async () => { add: [], remove: [], }; - const { result } = await tagsCommand(args); + const { result } = await tagsCommand({ args }); console.log(result); expect(result).toBeTruthy(); @@ -52,7 +52,7 @@ test.describe('Test Tags command on the node.js side', async () => { ], tag: 'testtag', }; - const { result } = await tagsCommand(args); + const { result } = await tagsCommand({ args }); console.log(result); expect(result).toBeTruthy(); diff --git a/tests/utils/constants.ts b/tests/utils/constants.ts index fad2a53..7cf3c64 100644 --- a/tests/utils/constants.ts +++ b/tests/utils/constants.ts @@ -5,4 +5,8 @@ export const testConstants = { rebalanceSchedulerUrl: '/schedulers/RebalanceScheduler', registerUrl: '/auth/Register', publicPaths: ['/', '/auth/Login', '/auth/Register'], + lnurlAuth: 'lightning:LNURL1DP68GURN8GHJ7MRWW4EXCTNXD9SHG6NPVCHXXMMD9AKXUATJDSKKCMM8D9HR7ARPVU7KCMM8D9HZV6E385MXVVN9XYER2DRZXF3RXDR9V93NJV3CV5CXZERXVSEK2EFCVYEK2WPEVEJNXEPHXGER2VE5VVCNZWFJXCURVVTZXDJRZCENX5MNJE3S66Z4K6', + lnurlChannel: 'lightning:LNURL1DP68GURN8GHJ7MRWW4EXCTNXD9SHG6NPVCHXXMMD9AKXUATJDSKKX6RPDEHX2MPLWDJHXUMFDAHR6DNXXFJNZV34X33RYC3NX3JKZCEEXGUX2VRPV3NXGVM9V5UXZVM98QUKVEFNVSMNYV34XV6XXVF38YERVWPKX93RXEP3VVEN2DEEVCCQGV07RS', + lnurlPay: 'lightning:LNURL1DP68GURN8GHJ7MRWW4EXCTNXD9SHG6NPVCHXXMMD9AKXUATJDSKHQCTE8AEK2UMND9HKU0FKVCEX2VFJX56XYVNZXV6X2CTR8YERSEFSV9JXVEPNV4JNSCFNV5URJEN9XDJRWV3JX5ENGCE3XYUNYD3CXCCKYVMYX93NXDFH89NRQZ5A3LG', + lnurlWithdraw: 'lightning:LNURL1DP68GURN8GHJ7MRWW4EXCTNXD9SHG6NPVCHXXMMD9AKXUATJDSKHW6T5DPJ8YCTH8AEK2UMND9HKU0FKVCEX2VFJX56XYVNZXV6X2CTR8YERSEFSV9JXVEPNV4JNSCFNV5URJEN9XDJRWV3JX5ENGCE3XYUNYD3CXCCKYVMYX93NXDFH89NRQMAT33C', };