diff --git a/package.json b/package.json index 518958e9..6f27bd6d 100755 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "rimraf": "2.6.2", "string-hash": "1.1.3", "webextension-polyfill-ts": "0.8.9", - "webpack-bundle-analyzer": "3.0.3", + "webpack-bundle-analyzer": "^3.3.2", "webpack-dev-server": "3.2.1", "write-file-webpack-plugin": "^4.5.0" } diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index ecb2b7ba..84efa882 100755 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -15,6 +15,7 @@ import HomePage from 'pages/home'; import OnboardingPage from 'pages/onboarding'; import SettingsPage from 'pages/settings'; import BalancesPage from 'pages/balances'; +import LoopPage from 'pages/loop'; import FourOhFourPage from 'pages/fourohfour'; import Template, { Props as TemplateProps } from 'components/Template'; @@ -74,6 +75,17 @@ const routeConfigs: RouteConfig[] = [ showBack: true, }, }, + { + // Loop + route: { + path: '/loop', + component: LoopPage, + }, + template: { + title: 'Loop', + showBack: true, + }, + }, { // 404 route: { diff --git a/src/app/components/Loop/InputLoopAddress.less b/src/app/components/Loop/InputLoopAddress.less new file mode 100644 index 00000000..ac15a5c5 --- /dev/null +++ b/src/app/components/Loop/InputLoopAddress.less @@ -0,0 +1,14 @@ +.InputLoopAddress { + width: 100%; + max-width: 440px; + margin: 0 auto; + + .ant-form-item, + .ant-alert { + margin-bottom: 1rem; + } + + .ant-alert a { + text-decoration: underline; + } +} diff --git a/src/app/components/Loop/InputLoopAddress.tsx b/src/app/components/Loop/InputLoopAddress.tsx new file mode 100644 index 00000000..9e78c11d --- /dev/null +++ b/src/app/components/Loop/InputLoopAddress.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { browser } from 'webextension-polyfill-ts'; +import { Button, Form, Input, message } from 'antd'; +import { urlWithoutPort } from 'utils/formatters'; +import './InputLoopAddress.less'; +import { setLoop } from 'modules/loop/actions'; + +interface Props { + initialUrl?: string | null; + isCheckingLoop: boolean; + error: Error | null; + setLoop: typeof setLoop; + type: string; +} + +interface State { + url: string; + submittedUrl: string; + validation: string; +} + +export default class InputLoopAddress extends React.Component { + state: State = { + url: this.props.initialUrl || '', + submittedUrl: this.props.initialUrl || '', + validation: '', + }; + + componentDidUpdate(nextProps: Props) { + // Handle errors for incorrect URL + const { error } = this.props; + if (error !== null && nextProps.error === null) { + message.error(`Error setting URL!`, 2); + } + } + + render() { + const { validation, url } = this.state; + const { isCheckingLoop } = this.props; + const validateStatus = url ? (validation ? 'error' : 'success') : undefined; + return ( +
+ + + + + +
+ ); + } + + private handleChange = (ev: React.ChangeEvent) => { + const url = ev.currentTarget.value; + let validation = ''; + try { + // tslint:disable-next-line + new URL(url); + } catch (err) { + validation = 'That doesn’t look like a valid url'; + } + this.setState({ url, validation }); + }; + + private handleSubmit = (ev: React.FormEvent) => { + const url = this.state.url.replace(/\/$/, ''); + const loop = this.props; + ev.preventDefault(); + browser.permissions + .request({ + origins: [urlWithoutPort(url)], + }) + .then(accepted => { + if (!accepted) { + message.warn('Permission denied, connection may fail'); + } + this.setState({ submittedUrl: url }); + loop.setLoop(url); + }); + }; +} diff --git a/src/app/components/Loop/QuoteModal.tsx b/src/app/components/Loop/QuoteModal.tsx new file mode 100644 index 00000000..86a38875 --- /dev/null +++ b/src/app/components/Loop/QuoteModal.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Modal, message } from 'antd'; +import { AppState } from 'store/reducers'; +import { getLoopOutQuote, getLoopOut, getLoopIn } from 'modules/loop/actions'; +import { Button, Icon, Alert } from 'antd'; +import { ButtonProps } from 'antd/lib/button'; +import { GetLoopOutArguments, GetLoopInArguments } from 'lib/loop-http/types'; +import { LOOP_TYPE } from 'utils/constants'; + +interface StateProps { + loopQuote: AppState['loop']['loopQuote']; + loop: AppState['loop']['loop']; + error: AppState['loop']['error']; + hasPassword: boolean; +} + +interface DispatchProps { + getLoopOutQuote: typeof getLoopOutQuote; + getLoopOut: typeof getLoopOut; + getLoopIn: typeof getLoopIn; +} + +interface OwnProps { + amount: string; + sweepConfirmationTarget: string; + isOpen?: boolean; + type: string; + destination: string; + swapFee: string; + minerFee: string; + prepayAmount: string; + channel: string; + advanced: boolean; + htlc: boolean; + onClose(): void; +} + +type Props = StateProps & DispatchProps & OwnProps; + +class QuoteModal extends React.Component { + componentWillUpdate(nextProps: Props) { + if (!this.props.isOpen && nextProps.isOpen) { + // Fire even if amt is in store in case we need to cycle + this.props.getLoopOutQuote(this.props.amount, this.props.sweepConfirmationTarget); + } + } + render() { + const { + loopQuote, + amount, + sweepConfirmationTarget, + isOpen, + onClose, + type, + hasPassword, + error, + } = this.props; + if (!loopQuote) { + return null; + } + const actions: ButtonProps[] = [ + { + children: ( + <> + {`${type}`} + + ), + type: 'primary' as any, + }, + ]; + const isVisible = !!isOpen && !!(hasPassword || error); + + let content; + if (loopQuote.miner_fee !== '') { + content = ( +
+

{`Miner fee: ${loopQuote.miner_fee} sats`}

+

{`Prepay amt: ${ + loopQuote.prepay_amt === undefined ? '1337' : loopQuote.prepay_amt + } sats`}

+

{`Swap fee: ${loopQuote.swap_fee} sats`}

+

{`Swap amt: ${amount} sats`}

+

{`Sweep Conf. Target: ${sweepConfirmationTarget}`}

+ {actions.map((props, idx) => ( +
+ ); + } else if (error) { + content = ( + + ); + } + return ( + +
{content}
+
+ ); + } + + private loopOut = () => { + // get values from loopOutQuote for default loopOut + const loopOutQuote = this.props.loopQuote; + const loopOut = this.props.loop; + const { advanced, minerFee, prepayAmount, swapFee } = this.props; + if (!loopOutQuote) { + return null; + } + if (!loopOut) { + return null; + } + const req: GetLoopOutArguments = { + amt: this.props.amount, + dest: this.props.destination, + loop_out_channel: this.props.channel, + max_miner_fee: advanced ? minerFee : loopOutQuote.miner_fee, + max_prepay_amt: advanced ? prepayAmount : loopOutQuote.prepay_amt, + max_prepay_routing_fee: advanced ? prepayAmount : loopOutQuote.prepay_amt, + max_swap_fee: advanced ? swapFee : loopOutQuote.swap_fee, + max_swap_routing_fee: advanced ? swapFee : loopOutQuote.swap_fee, + sweep_conf_target: this.props.sweepConfirmationTarget, + }; + this.props.getLoopOut(req); + setTimeout(() => { + message.info(`Attempting ${this.props.type}`, 2); + }, 1000); + setTimeout(() => { + this.props.onClose(); + }, 3000); + }; + + private loopIn = () => { + // get values from loopInQuote for default loopIn + const loopInQuote = this.props.loopQuote; + const loopIn = this.props.loop; + const { advanced, minerFee, swapFee } = this.props; + if (!loopInQuote) { + return null; + } + if (!loopIn) { + return null; + } + const req: GetLoopInArguments = { + amt: this.props.amount, + loop_in_channel: this.props.channel, + max_miner_fee: advanced ? minerFee : loopInQuote.miner_fee, + max_swap_fee: advanced ? swapFee : loopInQuote.swap_fee, + external_htlc: this.props.htlc, + }; + + this.props.getLoopIn(req); + setTimeout(() => { + message.info(`Attempting ${this.props.type}`, 2); + }, 1000); + setTimeout(() => { + this.props.onClose(); + }, 3000); + }; +} + +export default connect( + state => ({ + hasPassword: !!state.crypto.password, + loopQuote: state.loop.loopQuote, + loop: state.loop.loop, + error: state.loop.error, + }), + { + getLoopOutQuote, + getLoopIn, + getLoopOut, + }, +)(QuoteModal); diff --git a/src/app/components/Loop/index.less b/src/app/components/Loop/index.less new file mode 100644 index 00000000..ffa57d9d --- /dev/null +++ b/src/app/components/Loop/index.less @@ -0,0 +1,15 @@ +@import '~style/variables.less'; + +.Loop { + background-color: #ffffff; + margin: 1rem; + + &-header { + padding: 1rem; + text-align: right; + } + + &-terms { + max-width: 300px; + } +} diff --git a/src/app/components/Loop/index.tsx b/src/app/components/Loop/index.tsx new file mode 100644 index 00000000..1e56f32f --- /dev/null +++ b/src/app/components/Loop/index.tsx @@ -0,0 +1,394 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { AppState } from 'store/reducers'; +import './index.less'; +import { setLoop } from 'modules/loop/actions'; +import { ButtonProps } from 'antd/lib/button'; +import { + Menu, + Select, + Form, + Button, + Icon, + Radio, + Collapse, + Input, + Switch, + message, +} from 'antd'; +const { Panel } = Collapse; +import AmountField from 'components/AmountField'; +import InputLoopAddress from 'components/Loop/InputLoopAddress'; +import QuoteModal from './QuoteModal'; +import { ChannelWithNode } from 'modules/channels/types'; +import { LOOP_TYPE } from 'utils/constants'; + +interface StateProps { + channels: AppState['channels']['channels']; + isCheckingLoop: AppState['loop']['isCheckingLoop']; + url: AppState['loop']['url']; + lib: AppState['loop']['lib']; + loopOutTerms: AppState['loop']['loopOutTerms']; + loopInTerms: AppState['loop']['loopInTerms']; + loopQuote: AppState['loop']['loopQuote']; + loop: AppState['loop']['loop']; + error: AppState['loop']['error']; +} + +interface DispatchProps { + setLoop: typeof setLoop; +} + +interface State { + amount: string; + advanced: boolean; + isAnyValue: boolean; + destination: string; + swapFee: string; + minerFee: string; + prepayAmt: string; + channel: string; + conf: string; + htlc: boolean; + quoteModalIsOpen: boolean; + loopType: string; +} + +const INITIAL_STATE = { + amount: '0', + advanced: false, + isAnyValue: false, + destination: '', + swapFee: '0', + minerFee: '0', + prepayAmt: '0', + channel: '', + conf: '2', + htlc: false, + quoteModalIsOpen: false, + loopType: LOOP_TYPE.LOOP_OUT, +}; + +type Props = StateProps & DispatchProps; + +class Loop extends React.Component { + state: State = { ...INITIAL_STATE }; + + componentDidMount() { + const loopUrl = this.props.url; + if (loopUrl !== null) { + this.props.setLoop(loopUrl); + } + } + render() { + const { + url, + loopOutTerms, + loopInTerms, + channels, + isCheckingLoop, + error, + } = this.props; + + if (!channels || !loopOutTerms || !loopInTerms) { + return null; + } + + // Only return channels with enough loot for looping out + const openChannelsLoopOut = channels.filter( + o => + o.status === 'OPEN' && + parseInt(o.local_balance, 10) > parseInt(loopOutTerms.min_swap_amount, 10), + ); + // Only return channels with enough loot for looping in + const openChannelsLoopIn = channels.filter( + o => + o.status === 'OPEN' && + parseInt(o.capacity, 10) - parseInt(o.local_balance, 10) > + parseInt(loopInTerms.min_swap_amount, 10), + ); + + // Get channels to choose from + const loopOutItems = openChannelsLoopOut.map(c => ( + this.handleSetChannelId(c.channel_point, openChannelsLoopOut)} + > + {`${c.node.alias} => ${c.local_balance} sats available`} + + )); + const loopInItems = openChannelsLoopIn.map(c => ( + this.handleSetChannelId(c.channel_point, openChannelsLoopIn)} + > + {`${c.node.alias} => ${c.local_balance} sats available`} + + )); + + const { + isAnyValue, + amount, + loopType, + destination, + swapFee, + minerFee, + prepayAmt, + channel, + quoteModalIsOpen, + advanced, + conf, + htlc, + } = this.state; + + const loopItems = loopType === LOOP_TYPE.LOOP_OUT ? loopOutItems : loopInItems; + const loopMenu = ; + + const loopTerms = loopType === LOOP_TYPE.LOOP_OUT ? loopOutTerms : loopInTerms; + const loopTermsText = ( + <> + Base Fee : {loopTerms.swap_fee_base} sats
+ Fee Rate : {loopTerms.swap_fee_rate} sats
+ Prepay Amount :{' '} + {loopTerms.prepay_amt === undefined ? '1337' : loopTerms.prepay_amt} sats
+ Min Swap Amount : {loopTerms.min_swap_amount} sats
+ Max Swap Amount : {loopTerms.max_swap_amount} sats
+ CLTV Delta : {loopTerms.cltv_delta} blocks + + ); + + const actions: ButtonProps[] = [ + { + children: ( + <> + {`${loopType} Quote`} + + ), + type: 'primary' as any, + }, + ]; + + if (loopTerms === null) { + return null; + } + + return ( + <> +
+ + + Loop Out + + + Loop In + + +
+
+ {url === null && ( + + )} +
+ {loopTerms.swap_fee_base !== '' && ( + + +

{loopTermsText}

+
+
+ )} +
+
+ +
+ {url !== null && {loopMenu}} + {advanced && loopType === LOOP_TYPE.LOOP_OUT && ( + + + + )} + {advanced && ( + + + + )} + {advanced && ( + + + + )} + {advanced && loopType === LOOP_TYPE.LOOP_OUT && ( + + + + )} + {advanced && loopType === LOOP_TYPE.LOOP_OUT && ( + + + + )} + {advanced && loopType === LOOP_TYPE.LOOP_IN && ( + +

External HTLC?

+ + + +
+ )} +
+ +
+ {/* Don't allow for quote until amount greater than min swap amount is entered and less than max swap amount*/} + {this.state.amount !== null && + this.state.amount !== undefined && + parseInt(this.state.amount, 10) > parseInt(loopTerms.min_swap_amount, 10) && + parseInt(this.state.amount, 10) < parseInt(loopTerms.max_swap_amount, 10) && + actions.map((props, idx) => ( +
+ +
+ + ); + } + + private handleSetChannelId(point: string, openChannels: ChannelWithNode[]) { + // Work-around to grab channel id and pass to Loop API + const match = openChannels.filter(id => id.channel_point === point); + const keys = Object.keys(match); + const jsonClone = JSON.parse(JSON.stringify(match)); + const key = keys[0]; + const channelId = jsonClone[key].chan_id; + message.success('Channel set successfully!'); + this.setState({ channel: channelId }); + } + + private handleChangeAmount = (amount: string) => { + this.setState({ amount }); + }; + + private handleChangeField = (ev: React.ChangeEvent) => { + this.setState({ name: ev.currentTarget.value }); + }; + + private handleChangeHtlc = (checked: boolean) => { + this.setState({ + htlc: checked, + }); + }; + + private openQuoteModal = () => { + if (this.state.channel === '') { + message.warn('Please set Channel', 2); + } else { + this.setState({ + ...this.state, + quoteModalIsOpen: this.state.quoteModalIsOpen === false ? true : false, + }); + } + }; + + private setLoopOutType = () => { + this.setState({ loopType: LOOP_TYPE.LOOP_OUT }); + }; + + private setLoopInType = () => { + this.setState({ loopType: LOOP_TYPE.LOOP_IN }); + }; + + private toggleAdvanced = () => { + this.setState({ advanced: this.state.advanced === false ? true : false }); + }; +} + +export default connect( + state => ({ + channels: state.channels.channels, + isCheckingLoop: state.loop.isCheckingLoop, + url: state.loop.url, + lib: state.loop.lib, + loopOutTerms: state.loop.loopOutTerms, + loopInTerms: state.loop.loopInTerms, + loopQuote: state.loop.loopQuote, + loop: state.loop.loop, + error: state.loop.error, + }), + { + setLoop, + }, +)(Loop); diff --git a/src/app/components/SettingsMenu.tsx b/src/app/components/SettingsMenu.tsx index 0b57f2fd..f9810b58 100644 --- a/src/app/components/SettingsMenu.tsx +++ b/src/app/components/SettingsMenu.tsx @@ -38,6 +38,11 @@ export default class SettingsMenu extends React.Component<{}, State> { Balances + + + Loop + + diff --git a/src/app/lib/loop-http/errors.ts b/src/app/lib/loop-http/errors.ts new file mode 100644 index 00000000..f1170cfa --- /dev/null +++ b/src/app/lib/loop-http/errors.ts @@ -0,0 +1,62 @@ +/* tslint:disable:max-classes-per-file */ + +interface ErrorConstructor { + new (...args: any[]): Error; +} + +/** + * Workaround for custom errors when compiling typescript targeting 'ES5'. + * see: https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + * @param {CustomError} error + * @param newTarget the value of `new.target` + * @param {Function} errorType + */ +// tslint:enable:max-line-length +function fixError( + error: Error, + newTarget: ErrorConstructor, + errorType: ErrorConstructor, +) { + Object.setPrototypeOf(error, errorType.prototype); + + // when an error constructor is invoked with the `new` operator + if (newTarget === errorType) { + error.name = newTarget.name; + + // exclude the constructor call of the error type from the stack trace. + if (Error.captureStackTrace) { + Error.captureStackTrace(error, errorType); + } else { + const stack = new Error(error.message).stack; + if (stack) { + error.stack = fixStack(stack, `new ${newTarget.name}`); + } + } + } +} +function fixStack(stack: string, functionName: string) { + if (!stack) return stack; + if (!functionName) return stack; + + // exclude lines starts with: " at functionName " + const exclusion: RegExp = new RegExp(`\\s+at\\s${functionName}\\s`); + + const lines = stack.split('\n'); + const resultLines = lines.filter(line => !line.match(exclusion)); + return resultLines.join('\n'); +} + +export class NetworkError extends Error { + statusCode: number; + constructor(message: string, code: number) { + super(message); + this.statusCode = code; + } +} + +export class UnknownServerError extends Error { + constructor(message: string) { + super(message); + fixError(this, new.target, UnknownServerError); + } +} diff --git a/src/app/lib/loop-http/index.ts b/src/app/lib/loop-http/index.ts new file mode 100644 index 00000000..6ffe6dbc --- /dev/null +++ b/src/app/lib/loop-http/index.ts @@ -0,0 +1,127 @@ +import { stringify } from 'query-string'; +import { NetworkError } from './errors'; +import { parseLoopErrorResponse } from './utils'; +import * as T from './types'; + +export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +export class LoopHttpClient { + url: string; + + constructor(url: string) { + // Remove trailing slash for consistency + this.url = url.replace(/\/$/, ''); + } + + // Public API methods + + getLoopOutTerms = () => { + return this.request('GET', `/v1/loop/out/terms`); + }; + + getLoopInTerms = () => { + return this.request('GET', `/v1/loop/in/terms`); + }; + + getLoopOutQuote = (amt: string, conf: string) => { + return this.request( + 'GET', + `/v1/loop/out/quote/${amt}`, + { conf_target: conf }, + { + miner_fee: '', + swap_fee: '', + prepay_amt: '', + }, + ); + }; + + /** + * TODO: Add confirmation target as required + * in future iterations for Loop + */ + getLoopInQuote = (amt: string /*, conf: string*/) => { + return this.request( + 'GET', + // `/v1/loop/out/quote/${amt}?conf_target=${conf}`, + `/v1/loop/out/quote/${amt}`, + // {conf_target: conf} + undefined, + { + miner_fee: '', + swap_fee: '', + prepay_amt: '', + }, + ); + }; + + loopOut = (args: T.GetLoopOutArguments) => { + return this.request( + 'POST', + '/v1/loop/out', + args, + ); + }; + + loopIn = (args: T.GetLoopInArguments) => { + return this.request( + 'POST', + '/v1/loop/in', + args, + ); + }; + + // Internal fetch function + protected request( + method: ApiMethod, + path: string, + args?: A, + defaultValues?: Partial, + ): T.Response { + let body = null; + let query = ''; + const headers = new Headers(); + headers.append('Accept', 'application/json'); + + if (method === 'POST') { + body = JSON.stringify(args); + headers.append('Content-Type', 'application/json'); + } else if (args !== undefined) { + // TS Still thinks it might be undefined(?) + query = `?${stringify(args as any)}`; + } + + return fetch(this.url + path + query, { + method, + headers, + body, + }) + .then(async res => { + if (!res.ok) { + let errBody: any; + try { + errBody = await res.json(); + if (!errBody.error) throw new Error(); + } catch (err) { + throw new NetworkError(res.statusText, res.status); + } + const error = parseLoopErrorResponse(errBody); + throw error; + } + return res.json(); + }) + .then((res: Partial) => { + if (defaultValues) { + // TS can't handle generic spreadables + return { ...(defaultValues as any), ...(res as any) } as R; + } + return res as R; + }) + .catch(err => { + console.error(`API error calling ${method} ${path}`, err); + throw err; + }); + } +} + +export default LoopHttpClient; diff --git a/src/app/lib/loop-http/types.ts b/src/app/lib/loop-http/types.ts new file mode 100644 index 00000000..55c2f9b3 --- /dev/null +++ b/src/app/lib/loop-http/types.ts @@ -0,0 +1,48 @@ +// Shared Types +export type Response = Promise; + +export interface ErrorResponse { + error: string; + code: number; +} + +export interface GetLoopTermsResponse { + swap_payment_dest: string; + swap_fee_base: string; + swap_fee_rate: string; + prepay_amt: string; + min_swap_amount: string; + max_swap_amount: string; + cltv_delta: number; +} + +export interface GetLoopQuoteResponse { + swap_fee: string; + prepay_amt: string; + miner_fee: string; +} + +export interface GetLoopOutArguments { + amt: string; + dest: string; + max_swap_routing_fee: string | null; + max_prepay_routing_fee: string | null; + max_swap_fee: string | null; + max_prepay_amt: string | null; + max_miner_fee: string | null; + loop_out_channel: string; + sweep_conf_target: string; +} + +export interface GetLoopInArguments { + amt: string; + max_swap_fee: string | null; + max_miner_fee: string | null; + loop_in_channel: string; + external_htlc: boolean; +} + +export interface GetLoopResponse { + id: string; + htlc_address: string; +} diff --git a/src/app/lib/loop-http/utils.ts b/src/app/lib/loop-http/utils.ts new file mode 100644 index 00000000..9b7ff764 --- /dev/null +++ b/src/app/lib/loop-http/utils.ts @@ -0,0 +1,19 @@ +import * as Errors from './errors'; +import { ErrorResponse } from './types'; + +/*** + * Future error handling for LoopLib + * **/ +export function parseLoopErrorResponse(res: ErrorResponse): Error { + return new Errors.UnknownServerError(res.error); +} + +export function txIdBytesToHex(txbytes: string) { + const txbinary = Buffer.from(txbytes, 'base64').toString('binary'); + const txarray = new Uint8Array(txbinary.length); + for (let i = 0; i < txbinary.length; i++) { + txarray[i] = txbinary.charCodeAt(i); + } + txarray.reverse(); + return new Buffer(txarray).toString('hex'); +} diff --git a/src/app/modules/loop/actions.ts b/src/app/modules/loop/actions.ts new file mode 100644 index 00000000..a3355856 --- /dev/null +++ b/src/app/modules/loop/actions.ts @@ -0,0 +1,35 @@ +import types from './types'; +import { GetLoopOutArguments, GetLoopInArguments } from 'lib/loop-http/types'; +import { selectSyncedLoopState } from './selectors'; +import LoopHttpClient from 'lib/loop-http'; + +export function setLoop(url: string) { + return { type: types.SET_LOOP, payload: url }; +} + +export function getLoopOutQuote(amt: string, conf: string) { + return { type: types.GET_LOOP_OUT_QUOTE, payload: amt, conf }; +} + +export function getLoopInQuote(amt: string /*, conf: string*/) { + return { type: types.GET_LOOP_IN_QUOTE, payload: amt /*, conf*/ }; +} + +export function getLoopOut(payload: GetLoopOutArguments) { + return { type: types.LOOP_OUT, payload }; +} + +export function getLoopIn(payload: GetLoopInArguments) { + return { type: types.LOOP_IN, payload }; +} + +export function setSyncedLoopState(payload: ReturnType) { + const { url } = payload; + return { + type: types.SYNC_LOOP_STATE, + payload: { + url, + loop: url ? new LoopHttpClient(url as string) : null, + }, + }; +} diff --git a/src/app/modules/loop/index.ts b/src/app/modules/loop/index.ts new file mode 100644 index 00000000..3ce89db1 --- /dev/null +++ b/src/app/modules/loop/index.ts @@ -0,0 +1,8 @@ +import reducers, { LoopState, INITIAL_STATE } from './reducers'; +import * as loopActions from './actions'; +import loopTypes from './types'; +import loopSagas from './sagas'; + +export { loopActions, loopTypes, LoopState, loopSagas, INITIAL_STATE }; + +export default reducers; diff --git a/src/app/modules/loop/reducers.ts b/src/app/modules/loop/reducers.ts new file mode 100644 index 00000000..fbb64f1c --- /dev/null +++ b/src/app/modules/loop/reducers.ts @@ -0,0 +1,153 @@ +import types, { LoopTermsPayload, LoopQuotePayload, LoopPayload } from './types'; +import LoopHttpClient from 'lib/loop-http'; + +export interface LoopState { + isCheckingLoop: boolean; + loopOutTerms: LoopTermsPayload; + loopInTerms: LoopTermsPayload; + loopQuote: null | LoopQuotePayload; + loop: null | LoopPayload; + lib: null | LoopHttpClient; + url: null | string; + error: null | Error; +} + +export const INITIAL_STATE: LoopState = { + lib: null, + url: null, + error: null, + isCheckingLoop: false, + loopOutTerms: { + swap_payment_dest: '', + swap_fee_base: '', + swap_fee_rate: '', + prepay_amt: '', + min_swap_amount: '', + max_swap_amount: '', + cltv_delta: 0, + }, + loopInTerms: { + swap_payment_dest: '', + swap_fee_base: '', + swap_fee_rate: '', + prepay_amt: '', + min_swap_amount: '', + max_swap_amount: '', + cltv_delta: 0, + }, + loopQuote: { + swap_fee: '', + prepay_amt: '', + miner_fee: '', + }, + loop: { + id: '', + htlc_address: '', + }, +}; + +export default function loopReducers( + state: LoopState = INITIAL_STATE, + action: any, +): LoopState { + switch (action.type) { + case types.SET_LOOP: + return { + ...state, + isCheckingLoop: true, + error: null, + }; + case types.SET_LOOP_SUCCESS: + return { + ...state, + isCheckingLoop: false, + url: action.payload, + lib: new LoopHttpClient(action.payload), + error: null, + }; + case types.SET_LOOP_FAILURE: + return { + ...state, + isCheckingLoop: false, + error: action.payload, + lib: null, + }; + case types.GET_LOOP_OUT_TERMS_SUCCESS: + return { + ...state, + loopOutTerms: action.payload, + }; + case types.GET_LOOP_OUT_TERMS_FAILURE: + return { + ...state, + error: action.payload, + }; + case types.GET_LOOP_OUT_QUOTE: + return { + ...state, + error: null, + }; + case types.GET_LOOP_OUT_QUOTE_SUCCESS: + return { + ...state, + loopQuote: action.payload, + }; + case types.GET_LOOP_OUT_QUOTE_FAILURE: + return { + ...state, + error: action.payload, + }; + case types.LOOP_OUT_SUCCESS: + return { + ...state, + loop: action.payload, + }; + case types.LOOP_OUT_FAILURE: + return { + ...state, + error: action.payload, + }; + // handle loop in actions + case types.GET_LOOP_IN_TERMS_SUCCESS: + return { + ...state, + loopInTerms: action.payload, + }; + case types.GET_LOOP_IN_TERMS_FAILURE: + return { + ...state, + error: action.payload, + }; + case types.GET_LOOP_IN_QUOTE: + return { + ...state, + error: null, + }; + case types.GET_LOOP_IN_QUOTE_SUCCESS: + return { + ...state, + loopQuote: action.payload, + }; + case types.GET_LOOP_IN_QUOTE_FAILURE: + return { + ...state, + error: action.payload, + }; + case types.LOOP_IN_SUCCESS: + return { + ...state, + loop: action.payload, + }; + case types.LOOP_IN_FAILURE: + return { + ...state, + error: action.payload, + }; + case types.SYNC_LOOP_STATE: + return { + ...state, + ...action.payload, + }; + } + return state; +} diff --git a/src/app/modules/loop/sagas.ts b/src/app/modules/loop/sagas.ts new file mode 100644 index 00000000..d07e2861 --- /dev/null +++ b/src/app/modules/loop/sagas.ts @@ -0,0 +1,130 @@ +import { SagaIterator } from 'redux-saga'; +import { takeLatest, select, call, all, put } from 'redux-saga/effects'; +import { selectLoopLibOrThrow } from 'modules/loop/selectors'; +import types from './types'; +import * as actions from './actions'; +import LoopHttpClient from '../../lib/loop-http'; +import { requirePassword } from 'modules/crypto/sagas'; + +// Setup Loop URL and Loop Terms +export function* handleSetLoopOut( + action: ReturnType, +): SagaIterator { + const url = action.payload; + let loopOutTermsPayload; + let loopInTermsPayload; + try { + const client = new LoopHttpClient(url); + [loopOutTermsPayload, loopInTermsPayload] = yield all([ + yield call(client.getLoopOutTerms), + yield call(client.getLoopInTerms), + ]); + } catch (err) { + yield put({ type: types.SET_LOOP_FAILURE, payload: err }); + return; + } + yield put({ + type: types.SET_LOOP_SUCCESS, + payload: url, + }); + yield put({ + type: types.GET_LOOP_OUT_TERMS_SUCCESS, + payload: loopOutTermsPayload, + }); + yield put({ + type: types.GET_LOOP_IN_TERMS_SUCCESS, + payload: loopInTermsPayload, + }); +} + +export function* handleGetLoopOutQuote( + action: ReturnType, +): SagaIterator { + const amt = action.payload; + const conf = action.payload; + let loopLib: Yielded; + let loopQuote: Yielded | undefined; + try { + yield call(requirePassword); + loopLib = yield select(selectLoopLibOrThrow); + loopQuote = (yield call(loopLib.getLoopOutQuote, amt, conf)) as Yielded< + typeof loopLib.getLoopOutQuote + >; + } catch (err) { + yield put({ type: types.GET_LOOP_OUT_QUOTE_FAILURE, payload: err }); + return; + } + yield put({ + type: types.GET_LOOP_OUT_QUOTE_SUCCESS, + payload: loopQuote, + }); +} + +export function* handleGetLoopInQuote( + action: ReturnType, +): SagaIterator { + const amt = action.payload; + /*const conf = action.payload;*/ + let loopLib: Yielded; + let loopQuote: Yielded | undefined; + try { + yield call(requirePassword); + loopLib = yield select(selectLoopLibOrThrow); + loopQuote = (yield call(loopLib.getLoopInQuote, amt /*, conf*/)) as Yielded< + typeof loopLib.getLoopInQuote + >; + } catch (err) { + yield put({ type: types.GET_LOOP_IN_QUOTE_FAILURE, payload: err }); + return; + } + yield put({ + type: types.GET_LOOP_IN_QUOTE_SUCCESS, + payload: loopQuote, + }); +} + +export function* handleGetLoopOut( + action: ReturnType, +): SagaIterator { + const payload = action.payload; + let loopLib: Yielded; + let loopOut: Yielded | undefined; + try { + loopLib = yield select(selectLoopLibOrThrow); + loopOut = (yield call(loopLib.loopOut, payload)) as Yielded; + } catch (err) { + yield put({ type: types.LOOP_OUT_FAILURE, payload: err }); + return; + } + yield put({ + type: types.LOOP_OUT_SUCCESS, + payload: loopOut, + }); +} + +export function* handleGetLoopIn( + action: ReturnType, +): SagaIterator { + const payload = action.payload; + let loopLib: Yielded; + let loopIn: Yielded | undefined; + try { + loopLib = yield select(selectLoopLibOrThrow); + loopIn = (yield call(loopLib.loopIn, payload)) as Yielded; + } catch (err) { + yield put({ type: types.LOOP_IN_FAILURE, payload: err }); + return; + } + yield put({ + type: types.LOOP_IN_SUCCESS, + payload: loopIn, + }); +} + +export default function* loopSagas(): SagaIterator { + yield takeLatest(types.SET_LOOP, handleSetLoopOut); + yield takeLatest(types.GET_LOOP_OUT_QUOTE, handleGetLoopOutQuote); + yield takeLatest(types.GET_LOOP_IN_QUOTE, handleGetLoopInQuote); + yield takeLatest(types.LOOP_OUT, handleGetLoopOut); + yield takeLatest(types.LOOP_IN, handleGetLoopIn); +} diff --git a/src/app/modules/loop/selectors.ts b/src/app/modules/loop/selectors.ts new file mode 100644 index 00000000..198a150d --- /dev/null +++ b/src/app/modules/loop/selectors.ts @@ -0,0 +1,14 @@ +import { AppState } from 'store/reducers'; + +export const selectSyncedLoopState = (s: AppState) => ({ + url: s.loop.url, +}); + +export const selectLoopLib = (s: AppState) => s.loop.lib; +export const selectLoopLibOrThrow = (s: AppState) => { + const loopLib = selectLoopLib(s); + if (!loopLib) { + throw new Error('Loop must be configured first'); + } + return loopLib; +}; diff --git a/src/app/modules/loop/types.ts b/src/app/modules/loop/types.ts new file mode 100644 index 00000000..2e7c8352 --- /dev/null +++ b/src/app/modules/loop/types.ts @@ -0,0 +1,52 @@ +enum LoopTypes { + SET_LOOP = 'SET_LOOP', + SET_LOOP_SUCCESS = 'SET_LOOP_SUCCESS', + SET_LOOP_FAILURE = 'SET_LOOP_FAILURE', + + GET_LOOP_OUT_TERMS_SUCCESS = 'GET_LOOP_OUT_TERMS_SUCCESS', + GET_LOOP_OUT_TERMS_FAILURE = 'GET_LOOP_OUT_TERMS_FAILURE', + + GET_LOOP_IN_TERMS_SUCCESS = 'GET_LOOP_IN_TERMS_SUCCESS', + GET_LOOP_IN_TERMS_FAILURE = 'GET_LOOP_IN_TERMS_FAILURE', + + GET_LOOP_OUT_QUOTE = 'GET_LOOP_OUT_QUOTE', + GET_LOOP_OUT_QUOTE_SUCCESS = 'GET_LOOP_OUT_QUOTE_SUCCESS', + GET_LOOP_OUT_QUOTE_FAILURE = 'GET_LOOP_OUT_QUOTE_FAILURE', + + GET_LOOP_IN_QUOTE = 'GET_LOOP_IN_QUOTE', + GET_LOOP_IN_QUOTE_SUCCESS = 'GET_LOOP_IN_QUOTE_SUCCESS', + GET_LOOP_IN_QUOTE_FAILURE = 'GET_LOOP_IN_QUOTE_FAILURE', + + LOOP_OUT = 'GET_LOOP_OUT', + LOOP_OUT_SUCCESS = 'LOOP_OUT_SUCCESS', + LOOP_OUT_FAILURE = 'LOOP_OUT_FAILURE', + + LOOP_IN = 'LOOP_IN', + LOOP_IN_SUCCESS = 'LOOP_IN_SUCCESS', + LOOP_IN_FAILURE = 'LOOP_IN_FAILURE', + + SYNC_LOOP_STATE = 'SYNC_LOOP_STATE', +} + +export interface LoopTermsPayload { + swap_payment_dest: string; + swap_fee_base: string; + swap_fee_rate: string; + prepay_amt: string; + min_swap_amount: string; + max_swap_amount: string; + cltv_delta: 0; +} + +export interface LoopQuotePayload { + swap_fee: string; + prepay_amt: string; + miner_fee: string; +} + +export interface LoopPayload { + id: string; + htlc_address: string; +} + +export default LoopTypes; diff --git a/src/app/pages/loop.tsx b/src/app/pages/loop.tsx new file mode 100644 index 00000000..3bc9b861 --- /dev/null +++ b/src/app/pages/loop.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import Loop from 'components/Loop'; + +export default class LoopPage extends React.Component { + render() { + return ; + } +} diff --git a/src/app/store/reducers.ts b/src/app/store/reducers.ts index 3dcdc242..05092196 100755 --- a/src/app/store/reducers.ts +++ b/src/app/store/reducers.ts @@ -26,11 +26,13 @@ import onchain, { OnChainState, INITIAL_STATE as onchainInitialState, } from 'modules/onchain'; +import loop, { LoopState, INITIAL_STATE as loopInitialState } from 'modules/loop'; export interface AppState { crypto: CryptoState; sync: SyncState; node: NodeState; + loop: LoopState; channels: ChannelsState; account: AccountState; payment: PaymentState; @@ -45,6 +47,7 @@ export const combineInitialState: Partial = { crypto: cryptoInitialState, sync: syncInitialState, node: nodeInitialState, + loop: loopInitialState, channels: channelsInitialState, account: accountInitialState, payment: paymentInitialState, @@ -59,6 +62,7 @@ export default combineReducers({ crypto, sync, node, + loop, channels, account, payment, diff --git a/src/app/store/sagas.ts b/src/app/store/sagas.ts index a14bc1f8..2aebba12 100755 --- a/src/app/store/sagas.ts +++ b/src/app/store/sagas.ts @@ -2,6 +2,7 @@ import { fork } from 'redux-saga/effects'; import { syncSagas } from 'modules/sync'; import { cryptoSagas } from 'modules/crypto'; import { nodeSagas } from 'modules/node'; +import { loopSagas } from 'modules/loop'; import { channelsSagas } from 'modules/channels'; import { accountSagas } from 'modules/account'; import { paymentSagas } from 'modules/payment'; @@ -14,6 +15,7 @@ export default function* rootSaga() { yield fork(cryptoSagas); yield fork(syncSagas); yield fork(nodeSagas); + yield fork(loopSagas); yield fork(channelsSagas); yield fork(accountSagas); yield fork(paymentSagas); diff --git a/src/app/utils/constants.ts b/src/app/utils/constants.ts index fb75d3fd..3e64273c 100644 --- a/src/app/utils/constants.ts +++ b/src/app/utils/constants.ts @@ -10,12 +10,19 @@ export enum NODE_TYPE { LIGHTNING_APP = 'LIGHTNING_APP', ZAP_DESKTOP = 'ZAP_DESKTOP', BTCPAY_SERVER = 'BTCPAY_SERVER', + LOOP = 'LIGHTNING LOOP', +} + +export enum LOOP_TYPE { + LOOP_OUT = 'Loop Out', + LOOP_IN = 'Loop In', } export const DEFAULT_NODE_URLS = { [NODE_TYPE.LOCAL]: 'https://localhost:8080', [NODE_TYPE.LIGHTNING_APP]: 'https://localhost:8086', [NODE_TYPE.ZAP_DESKTOP]: 'https://localhost:8180', + [NODE_TYPE.LOOP]: 'http://localhost:8081', } as { [key in NODE_TYPE]: string | undefined }; interface LndDirectories { diff --git a/src/app/utils/sync.ts b/src/app/utils/sync.ts index 2ce0ca3c..2a1ce85a 100755 --- a/src/app/utils/sync.ts +++ b/src/app/utils/sync.ts @@ -20,6 +20,9 @@ import { selectSettings } from 'modules/settings/selectors'; import { changeSettings } from 'modules/settings/actions'; import settingsTypes from 'modules/settings/types'; import { AppState } from 'store/reducers'; +import { selectSyncedLoopState } from 'modules/loop/selectors'; +import { setSyncedLoopState } from 'modules/loop/actions'; +import { loopTypes } from 'modules/loop'; export interface SyncConfig { key: string; @@ -50,6 +53,18 @@ export const syncConfigs: Array> = [ action: setSyncedCryptoState, triggerActions: [cryptoTypes.SET_PASSWORD, settingsTypes.CLEAR_SETTINGS], }, + { + key: 'loop', + version: 1, + encrypted: false, + selector: selectSyncedLoopState, + action: setSyncedLoopState, + triggerActions: [ + loopTypes.SET_LOOP, + loopTypes.GET_LOOP_IN_TERMS_SUCCESS, + loopTypes.GET_LOOP_OUT_TERMS_SUCCESS, + ], + }, { key: 'node-unencrypted', version: 1,