diff --git a/package.json b/package.json index 6cbb24b9..6530e1ab 100755 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "babel-plugin-import": "^1.8.0", "babel-plugin-module-resolver": "^3.1.1", "bn.js": "4.11.8", + "bolt11": "1.2.5", "classnames": "^2.2.6", "clean-webpack-plugin": "^0.1.19", "copy-webpack-plugin": "^5.1.1", diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 263e9aa3..4814c702 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 AllowancesPage from 'pages/allowances'; import FourOhFourPage from 'pages/fourohfour'; import Template, { Props as TemplateProps } from 'components/Template'; @@ -74,6 +75,17 @@ const routeConfigs: RouteConfig[] = [ showBack: true, }, }, + { + // Allowances + route: { + path: '/allowances', + component: AllowancesPage, + }, + template: { + title: 'Allowances', + showBack: true, + }, + }, { // 404 route: { diff --git a/src/app/components/ActiveAppBanner/index.less b/src/app/components/ActiveAppBanner/index.less new file mode 100644 index 00000000..82df70c9 --- /dev/null +++ b/src/app/components/ActiveAppBanner/index.less @@ -0,0 +1,41 @@ +@import '~style/variables.less'; + +.ActiveAppBanner { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 1rem; + + &.is-enabled { + background: @primary-color; + } + + &.is-rejected { + background: @error-color; + } + + &-message { + color: #fff; + padding: 0.4rem 0; + font-size: 0.8rem; + } + + &-actions { + display: flex; + + &-btn { + color: #fff; + cursor: pointer; + padding: 0 0.4rem; + opacity: 0.7; + font-size: 1rem; + background: none; + border: none; + outline: none; + + &:hover { + opacity: 1; + } + } + } +} diff --git a/src/app/components/ActiveAppBanner/index.tsx b/src/app/components/ActiveAppBanner/index.tsx new file mode 100644 index 00000000..e3c9d383 --- /dev/null +++ b/src/app/components/ActiveAppBanner/index.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { browser } from 'webextension-polyfill-ts'; +import { Icon } from 'antd'; +import { withRouter, RouteComponentProps } from 'react-router'; +import { AppState } from 'store/reducers'; +import { + addEnabledDomain, + removeEnabledDomain, + addRejectedDomain, + removeRejectedDomain, +} from 'modules/settings/actions'; +import { shortDomain } from 'utils/formatters'; +import Tooltip from 'components/Tooltip'; +import AllowanceIcon from 'static/images/piggy-bank.svg'; +import './index.less'; + +interface StateProps { + enabledDomains: AppState['settings']['enabledDomains']; + rejectedDomains: AppState['settings']['rejectedDomains']; +} + +interface DispatchProps { + addEnabledDomain: typeof addEnabledDomain; + removeEnabledDomain: typeof removeEnabledDomain; + addRejectedDomain: typeof addRejectedDomain; + removeRejectedDomain: typeof removeRejectedDomain; +} + +type Props = StateProps & DispatchProps & RouteComponentProps; + +interface State { + currentOrigin: string; +} + +class ActiveAppBanner extends React.Component { + state: State = { + currentOrigin: '', + }; + + async componentDidMount() { + try { + const [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + if (tab.url) { + this.setState({ currentOrigin: new URL(tab.url).origin }); + } + } catch (err) { + // no-op, just don't render the banner + } + } + + render() { + const { enabledDomains, rejectedDomains } = this.props; + const { currentOrigin } = this.state; + const isEnabled = enabledDomains.includes(currentOrigin); + const isRejected = rejectedDomains.includes(currentOrigin); + + // Render nothing if they're not on a tab, or it's not a webln page + if (!currentOrigin || (!isEnabled && !isRejected)) { + return null; + } + + if (isRejected) { + return ( +
+
+ {shortDomain(currentOrigin)} is rejected +
+
+ + + +
+
+ ); + } + + if (isEnabled) { + return ( +
+
+ {shortDomain(currentOrigin)} is enabled +
+
+ + + + + + +
+
+ ); + } + } + + private enable = () => { + this.props.addEnabledDomain(this.state.currentOrigin); + this.props.removeRejectedDomain(this.state.currentOrigin); + }; + + private reject = () => { + this.props.addRejectedDomain(this.state.currentOrigin); + this.props.removeEnabledDomain(this.state.currentOrigin); + }; + + private goToAllowance = () => { + this.props.history.push(`/allowances`, { domain: this.state.currentOrigin }); + }; +} + +const ConnectedActiveAppBanner = connect( + state => ({ + enabledDomains: state.settings.enabledDomains, + rejectedDomains: state.settings.rejectedDomains, + }), + { + addEnabledDomain, + removeEnabledDomain, + addRejectedDomain, + removeRejectedDomain, + }, +)(ActiveAppBanner); + +export default withRouter(ConnectedActiveAppBanner); diff --git a/src/app/components/AllowanceForm/index.less b/src/app/components/AllowanceForm/index.less new file mode 100644 index 00000000..6d403606 --- /dev/null +++ b/src/app/components/AllowanceForm/index.less @@ -0,0 +1,75 @@ +.AllowanceForm { + &-header { + display: flex; + padding: 1rem 0.75rem; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(#000, 0.15); + + &-domain { + font-size: 1rem; + font-weight: bold; + } + + &-toggle { + display: flex; + align-items: center; + + &-label { + font-size: 0.6rem; + text-transform: uppercase; + margin-right: 0.4rem; + font-weight: bold; + opacity: 0.7; + } + } + } + + &-fields { + padding: 1rem; + transition: opacity 200ms ease, filter 200ms ease; + + &.is-inactive { + opacity: 0.4; + filter: grayscale(1); + pointer-events: none; + } + + &-balance { + &.ant-form-item { + padding-bottom: 0; + } + + .ant-form-item-children { + display: flex; + } + + &-total { + margin-bottom: 0.5rem; + } + + &-bar { + margin-top: -1rem; + margin-left: 1.5rem; + + .ant-progress-text small { + font-size: 0.65rem; + opacity: 0.6; + } + } + } + + // Ant overrides + .ant-form-item { + margin-bottom: 0.5rem; + } + + .ant-form-item-label label { + font-size: 0.65rem; + text-transform: uppercase; + font-weight: bold; + letter-spacing: 0.04rem; + opacity: 0.7; + } + } +} diff --git a/src/app/components/AllowanceForm/index.tsx b/src/app/components/AllowanceForm/index.tsx new file mode 100644 index 00000000..c9a67a3f --- /dev/null +++ b/src/app/components/AllowanceForm/index.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import classnames from 'classnames'; +import { browser } from 'webextension-polyfill-ts'; +import { connect } from 'react-redux'; +import { Button, Input, Switch, Form, Progress, Row, Col, Icon, Modal } from 'antd'; +import { Allowance, AppConfig } from 'modules/appconf/types'; +import { DEFAULT_ALLOWANCE, COLORS } from 'utils/constants'; +import { setAppConfig, deleteAppConfig } from 'modules/appconf/actions'; +import { AppState } from 'store/reducers'; +import './index.less'; + +interface OwnProps { + domain: string; + appConfig: AppConfig; +} + +interface DispatchProps { + setAppConfig: typeof setAppConfig; + deleteAppConfig: typeof deleteAppConfig; +} + +type Props = OwnProps & DispatchProps; + +interface State { + checkingNotifPermission: boolean; + hasNotifPermission: boolean; +} + +class AllowancesPage extends React.Component { + state: State = { + checkingNotifPermission: true, + hasNotifPermission: false, + }; + + async componentDidMount() { + const perms = await browser.permissions.getAll(); + this.setState({ + checkingNotifPermission: false, + hasNotifPermission: (perms.permissions || []).includes('notifications'), + }); + } + + render() { + const { domain, appConfig } = this.props; + const { checkingNotifPermission, hasNotifPermission } = this.state; + const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; + + return ( +
+
+
{domain}
+
+ Active + +
+
+
+ +
+ + +
+ ( + <> +
{pct}%
+ {allowance.balance} sats left + + )} + /> +
+ + + + + + + + + + + + + + {!checkingNotifPermission && ( + + )} + + + + +
+
+ ); + } + + private handleChangeAllowanceField = (ev: React.ChangeEvent) => { + const { appConfig, domain } = this.props; + const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; + const { name } = ev.currentTarget; + const value = parseInt(ev.currentTarget.value, 10); + if (!value) { + return; + } + + this.props.setAppConfig(domain, { + ...appConfig, + allowance: { + ...allowance, + [name]: value, + // Set balance to the total if it's changed + balance: name === 'total' ? value : allowance.balance, + } as Allowance, + }); + }; + + private toggleAllowanceActive = (active: boolean) => { + const { appConfig, domain } = this.props; + const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; + this.props.setAppConfig(domain, { + ...appConfig, + allowance: { + ...allowance, + active, + }, + }); + }; + + private toggleAllowanceNotifications = async (notifications: boolean) => { + const { appConfig, domain } = this.props; + const { hasNotifPermission } = this.state; + const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; + + // Request permission first, noop if they deny it + if (!hasNotifPermission) { + const granted = await browser.permissions.request({ + permissions: ['notifications'], + }); + if (!granted) { + return; + } + this.setState({ hasNotifPermission: true }); + } + + this.props.setAppConfig(domain, { + ...appConfig, + allowance: { + ...allowance, + notifications, + }, + }); + }; + + private refillAllowance = () => { + const { appConfig, domain } = this.props; + const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; + this.props.setAppConfig(domain, { + ...appConfig, + allowance: { + ...allowance, + balance: allowance.total, + }, + }); + }; + + private promptDelete = () => { + Modal.confirm({ + title: 'Are you sure?', + content: ` + Your allowance configuration will be lost. You can always reconfigure + it later. + `, + okText: 'Confirm', + cancelText: 'Never mind', + onOk: cb => { + this.props.deleteAppConfig(this.props.domain); + cb(); + }, + }); + }; +} + +export default connect<{}, DispatchProps, OwnProps, AppState>( + undefined, + { + setAppConfig, + deleteAppConfig, + }, +)(AllowancesPage); diff --git a/src/app/components/Balances/index.tsx b/src/app/components/Balances/index.tsx index 5c6e9f17..333dbb65 100644 --- a/src/app/components/Balances/index.tsx +++ b/src/app/components/Balances/index.tsx @@ -8,6 +8,7 @@ import { Denomination, denominationSymbols, blockchainDisplayName, + COLORS, } from 'utils/constants'; import { getNodeChain } from 'modules/node/selectors'; import { getChannels } from 'modules/channels/actions'; @@ -121,13 +122,13 @@ class Balances extends React.Component { className="Balances-chart-progress" percent={stats.channelPercent + stats.pendingPercent} type="circle" - strokeColor="#7642ff" + strokeColor={COLORS.PRIMARY} strokeLinecap="square" successPercent={Math.max( 0.1, 100 - stats.channelPercent - stats.onchainPercent, )} - trailColor="#ff9500" + trailColor={COLORS.BITCOIN} format={() => `${stats.spendablePercent}%`} /> @@ -136,21 +137,21 @@ class Balances extends React.Component { diff --git a/src/app/components/SettingsMenu.tsx b/src/app/components/SettingsMenu.tsx index 0b57f2fd..3dc86f12 100644 --- a/src/app/components/SettingsMenu.tsx +++ b/src/app/components/SettingsMenu.tsx @@ -6,6 +6,7 @@ import PeersModal from 'components/PeersModal'; import OpenChannelModal from 'components/OpenChannelModal'; import { clearPasswordCache } from 'utils/background'; import MenuIcon from 'static/images/menu.svg'; +import AllowanceIcon from 'static/images/piggy-bank.svg'; import './SettingsMenu.less'; interface State { @@ -38,7 +39,14 @@ export default class SettingsMenu extends React.Component<{}, State> { Balances + + + Allowances + + + + Settings diff --git a/src/app/components/Tooltip.tsx b/src/app/components/Tooltip.tsx new file mode 100644 index 00000000..cb15513d --- /dev/null +++ b/src/app/components/Tooltip.tsx @@ -0,0 +1,35 @@ +import { Tooltip as AntdTooltip } from 'antd'; + +// Overrides antd tooltip to fix buggy arrow +export default class Tooltip extends AntdTooltip { + onPopupAlign = (popup: HTMLElement, align: any) => { + const placements = this.getPlacements(); + // Get the current placement + const placement = Object.keys(placements).filter( + key => + placements[key].points[0] === align.points[0] && + placements[key].points[1] === align.points[1], + )[0]; + if (!placement) { + return; + } + + const target = (this as any).tooltip.trigger.getRootDomNode(); + const arrow: HTMLDivElement | null = popup.querySelector('.ant-tooltip-arrow'); + if (!arrow) return; + + // Get the rect of the target element. + const rect = target.getBoundingClientRect(); + + // Only the top/bottom/left/right placements should be handled + if (/^(top|bottom)$/.test(placement)) { + const { left, width } = rect; + const arrowOffset = left + width / 2 - popup.offsetLeft; + arrow.style.left = `${arrowOffset}px`; + } else if (/^(left|right)$/.test(placement)) { + const { top, height } = rect; + const arrowOffset = top + height / 2 - popup.offsetTop; + arrow.style.top = `${arrowOffset}px`; + } + }; +} diff --git a/src/app/modules/appconf/actions.ts b/src/app/modules/appconf/actions.ts new file mode 100644 index 00000000..00e48e1e --- /dev/null +++ b/src/app/modules/appconf/actions.ts @@ -0,0 +1,27 @@ +import types, { AppConfig } from './types'; +import { AppconfState } from './reducers'; +import { normalizeDomain } from 'utils/formatters'; + +export function setAppConfig(domain: string, config: AppConfig) { + return { + type: types.SET_APP_CONFIG, + payload: { + domain: normalizeDomain(domain), + config, + }, + }; +} + +export function deleteAppConfig(domain: string) { + return { + type: types.DELETE_APP_CONFIG, + payload: normalizeDomain(domain), + }; +} + +export function setAppconf(state: Partial) { + return { + type: types.SET_APPCONF, + payload: state, + }; +} diff --git a/src/app/modules/appconf/index.ts b/src/app/modules/appconf/index.ts new file mode 100644 index 00000000..1edb9b3d --- /dev/null +++ b/src/app/modules/appconf/index.ts @@ -0,0 +1,7 @@ +import reducers, { AppconfState, INITIAL_STATE } from './reducers'; +import * as appconfActions from './actions'; +import appconfTypes from './types'; + +export { appconfActions, appconfTypes, AppconfState, INITIAL_STATE }; + +export default reducers; diff --git a/src/app/modules/appconf/reducers.ts b/src/app/modules/appconf/reducers.ts new file mode 100644 index 00000000..5ec3ad1a --- /dev/null +++ b/src/app/modules/appconf/reducers.ts @@ -0,0 +1,49 @@ +import types, { AppConfig } from './types'; + +export interface AppconfState { + configs: { [domain: string]: AppConfig }; +} + +export const INITIAL_STATE: AppconfState = { + configs: {}, +}; + +export default function channelsReducers( + state: AppconfState = INITIAL_STATE, + action: any, +): AppconfState { + switch (action.type) { + case types.SET_APP_CONFIG: + return { + ...state, + configs: { + ...state.configs, + [action.payload.domain]: { + ...action.payload.config, + }, + }, + }; + + case types.DELETE_APP_CONFIG: + return { + ...state, + configs: Object.keys(state.configs).reduce( + (prev, domain) => { + if (domain !== action.payload) { + prev[domain] = state.configs[domain]; + } + return prev; + }, + {} as AppconfState['configs'], + ), + }; + + case types.SET_APPCONF: + return { + ...state, + ...action.payload, + }; + } + + return state; +} diff --git a/src/app/modules/appconf/selectors.ts b/src/app/modules/appconf/selectors.ts new file mode 100644 index 00000000..645fa058 --- /dev/null +++ b/src/app/modules/appconf/selectors.ts @@ -0,0 +1,10 @@ +import { AppState as S } from 'store/reducers'; +import { normalizeDomain } from 'utils/formatters'; +import { AppConfig } from './types'; + +export const selectAppconf = (s: S) => s.appconf; +export const selectAppDomains = (s: S) => Object.keys(s.appconf.configs); + +export const selectConfigByDomain = (s: S, domain: string): AppConfig | undefined => { + return s.appconf.configs[normalizeDomain(domain)]; +}; diff --git a/src/app/modules/appconf/types.ts b/src/app/modules/appconf/types.ts new file mode 100644 index 00000000..de19a0ea --- /dev/null +++ b/src/app/modules/appconf/types.ts @@ -0,0 +1,22 @@ +enum AppconfTypes { + SET_APP_CONFIG = 'SET_APP_CONFIG', + DELETE_APP_CONFIG = 'DELETE_APP_CONFIG', + + SET_APPCONF = 'SET_APPCONF', +} + +export interface Allowance { + active: boolean; + notifications: boolean; + total: number; + balance: number; + maxPerPayment: number; + minIntervalPerPayment: number; + lastPaymentAttempt: number; +} + +export interface AppConfig { + allowance: Allowance | null; +} + +export default AppconfTypes; diff --git a/src/app/modules/settings/actions.ts b/src/app/modules/settings/actions.ts index bbaa7ff0..1b9a494e 100644 --- a/src/app/modules/settings/actions.ts +++ b/src/app/modules/settings/actions.ts @@ -1,5 +1,6 @@ import types from './types'; import { SettingsState } from './reducers'; +import { normalizeDomain } from 'utils/formatters'; export function changeSettings(changes: Partial) { return { @@ -22,27 +23,27 @@ export function clearSettings() { export function addEnabledDomain(domain: string) { return { type: types.ADD_ENABLED_DOMAIN, - payload: domain, + payload: normalizeDomain(domain), }; } export function removeEnabledDomain(domain: string) { return { type: types.REMOVE_ENABLED_DOMAIN, - payload: domain, + payload: normalizeDomain(domain), }; } export function addRejectedDomain(domain: string) { return { type: types.ADD_REJECTED_DOMAIN, - payload: domain, + payload: normalizeDomain(domain), }; } export function removeRejectedDomain(domain: string) { return { type: types.REMOVE_REJECTED_DOMAIN, - payload: domain, + payload: normalizeDomain(domain), }; } diff --git a/src/app/pages/allowances.less b/src/app/pages/allowances.less new file mode 100644 index 00000000..5840a236 --- /dev/null +++ b/src/app/pages/allowances.less @@ -0,0 +1,56 @@ +@import '~style/variables.less'; + +.Allowances { + .full-height-flex-page(); + + &-control { + padding: 1rem 0.75rem; + display: flex; + align-items: center; + + &-select { + flex: 1; + } + + > * { + margin-right: 0.5rem; + + &:last-child { + margin-right: 0; + } + } + } + + &-form { + flex: 1; + background: #fff; + border-top: 1px solid rgba(#000, 0.15); + } + + &-add { + width: 100%; + margin-top: -0.25rem; + + &-input { + .ant-select-auto-complete & .ant-input, + .ant-input-search-button { + height: 3rem; + } + + .ant-select-auto-complete & .ant-input { + font-size: 1.1rem; + } + + .ant-input-search-button { + font-size: 1.2rem; + } + } + } + + &-addHint { + margin-top: 0.3rem; + margin-bottom: -0.5rem; + opacity: 0.7; + font-size: 0.7rem; + } +} diff --git a/src/app/pages/allowances.tsx b/src/app/pages/allowances.tsx new file mode 100644 index 00000000..80eaf63a --- /dev/null +++ b/src/app/pages/allowances.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withRouter, RouteComponentProps } from 'react-router'; +import { Select, Button, Modal, AutoComplete, Input, Icon, message } from 'antd'; +import BigMessage from 'components/BigMessage'; +import AllowanceForm from 'components/AllowanceForm'; +import { shortDomain, removeDomainPrefix } from 'utils/formatters'; +import { isValidDomain } from 'utils/validators'; +import { DEFAULT_ALLOWANCE } from 'utils/constants'; +import { setAppConfig } from 'modules/appconf/actions'; +import { AppState } from 'store/reducers'; +import './allowances.less'; + +interface StateProps { + appConfigs: AppState['appconf']['configs']; + enabledDomains: AppState['settings']['enabledDomains']; +} + +interface DispatchProps { + setAppConfig: typeof setAppConfig; +} + +type Props = StateProps & DispatchProps & RouteComponentProps; + +interface State { + domain: string; + isAdding: boolean; +} + +class AllowancesPage extends React.Component { + state: State = { + domain: '', + isAdding: false, + }; + + componentDidMount() { + // Redirects with domain specified are set by default + const { location, appConfigs } = this.props; + if (location.state && location.state.domain) { + this.setState({ domain: location.state.domain }); + // If they don't already have an allowance, make an inactive one for them + if (!appConfigs[location.state.domain]) { + this.props.setAppConfig(location.state.domain, { + allowance: { + ...DEFAULT_ALLOWANCE, + active: false, + }, + }); + } + } + } + + render() { + const { appConfigs, enabledDomains } = this.props; + const { domain, isAdding } = this.state; + + const config = appConfigs[domain]; + const configDomains = Object.keys(appConfigs); + + return ( +
+
+ Allowance for + + +
+ +
+ {config ? ( + + ) : ( +
+ +
+ )} +
+ + + + } + onSearch={v => { + setTimeout(() => this.submitAddDomain(v), 100); + }} + autoFocus + /> + +
Must specify http:// or https://
+
+
+ ); + } + + private handleChangeDomain = (domain: string) => { + this.setState({ domain }); + }; + + private filterAddDomains = (val: string, option: any) => { + return ( + option.key.indexOf(val.toLowerCase()) === 0 || + removeDomainPrefix(option.key).indexOf(val.toLowerCase()) === 0 + ); + }; + + private submitAddDomain = (domain: string) => { + if (!isValidDomain(domain)) { + message.warn('Invalid domain name'); + return; + } + + if (!this.props.appConfigs[domain]) { + this.props.setAppConfig(domain, { + allowance: { ...DEFAULT_ALLOWANCE }, + }); + } + this.setState({ domain }); + this.closeAddModal(); + }; + + private openAddModal = () => this.setState({ isAdding: true }); + private closeAddModal = () => this.setState({ isAdding: false }); +} + +const ConnectedAllowancesPage = connect( + state => ({ + appConfigs: state.appconf.configs, + enabledDomains: state.settings.enabledDomains, + }), + { setAppConfig }, +)(AllowancesPage); + +export default withRouter(ConnectedAllowancesPage); diff --git a/src/app/pages/home.less b/src/app/pages/home.less index 8d959fd8..7ff2e766 100644 --- a/src/app/pages/home.less +++ b/src/app/pages/home.less @@ -1,18 +1,12 @@ @import '~style/variables.less'; .Home { - height: calc(100vh - @header-height); - display: flex; - flex-direction: column; - - .is-page & { - height: 100%; - } + .full-height-flex-page(); // Ant override .ant-tabs { flex: 1; - background: #FFF; + background: #fff; } .ant-tabs-bar { @@ -40,7 +34,7 @@ font-weight: normal; } } - + .ant-tabs-content { height: 100%; padding-top: 44px; // Height of sticky nav diff --git a/src/app/pages/home.tsx b/src/app/pages/home.tsx index 273e18c6..f4a1c59f 100644 --- a/src/app/pages/home.tsx +++ b/src/app/pages/home.tsx @@ -8,8 +8,9 @@ import SendForm from 'components/SendForm'; import InvoiceForm from 'components/InvoiceForm'; import TransactionInfo from 'components/TransactionInfo'; import ConnectionFailureModal from 'components/ConnectionFailureModal'; -import { AppState } from 'store/reducers'; import ChannelInfo from 'components/ChannelInfo'; +import ActiveAppBanner from 'components/ActiveAppBanner'; +import { AppState } from 'store/reducers'; import { ChannelWithNode } from 'modules/channels/types'; import { AnyTransaction } from 'modules/account/types'; import { getAccountInfo } from 'modules/account/actions'; @@ -48,6 +49,7 @@ class HomePage extends React.Component { return (
+ + + + Artboard + Created with Sketch. + + + + + diff --git a/src/app/store/reducers.ts b/src/app/store/reducers.ts index 3dcdc242..0d146ec6 100755 --- a/src/app/store/reducers.ts +++ b/src/app/store/reducers.ts @@ -26,6 +26,10 @@ import onchain, { OnChainState, INITIAL_STATE as onchainInitialState, } from 'modules/onchain'; +import appconf, { + AppconfState, + INITIAL_STATE as appconfInitialState, +} from 'modules/appconf'; export interface AppState { crypto: CryptoState; @@ -39,6 +43,7 @@ export interface AppState { peers: PeersState; sign: SignState; onchain: OnChainState; + appconf: AppconfState; } export const combineInitialState: Partial = { @@ -53,6 +58,7 @@ export const combineInitialState: Partial = { peers: peersInitialState, sign: signInitialState, onchain: onchainInitialState, + appconf: appconfInitialState, }; export default combineReducers({ @@ -67,4 +73,5 @@ export default combineReducers({ peers, sign, onchain, + appconf, }); diff --git a/src/app/style/variables.less b/src/app/style/variables.less index 588c8e7b..413c47b3 100644 --- a/src/app/style/variables.less +++ b/src/app/style/variables.less @@ -16,3 +16,14 @@ @header-height: 2.6rem; @drawer-header-height: 3rem; + +// Mixins +.full-height-flex-page() { + height: calc(100vh - @header-height); + display: flex; + flex-direction: column; + + .is-page & { + height: 100%; + } +} diff --git a/src/app/utils/constants.ts b/src/app/utils/constants.ts index 224f3ca0..7337edb9 100644 --- a/src/app/utils/constants.ts +++ b/src/app/utils/constants.ts @@ -4,6 +4,7 @@ import DecredLogo from 'static/images/decred.svg'; import GroestlcoinLogo from 'static/images/groestlcoin.svg'; import * as React from 'react'; import { CustomIconComponentProps } from 'antd/lib/icon'; +import { Allowance } from 'modules/appconf/types'; import { CHANNEL_STATUS } from 'lnd/message'; import { AddressType } from 'lnd/types'; @@ -247,3 +248,19 @@ export const CHAIN_PREFIXES = [ 'tltc', // Litecoin Testnet 'rltc', // Litecoin Regtest ]; + +export const DEFAULT_ALLOWANCE: Allowance = { + active: true, + notifications: true, + total: 10000, + balance: 10000, + maxPerPayment: 100, + minIntervalPerPayment: 1, + lastPaymentAttempt: 0, +}; + +export const COLORS = { + PRIMARY: '#7642ff', + BITCOIN: '#ff9500', + NEUTRAL: '#858585', +}; diff --git a/src/app/utils/formatters.ts b/src/app/utils/formatters.ts index 8f9bdbe3..ac75b601 100755 --- a/src/app/utils/formatters.ts +++ b/src/app/utils/formatters.ts @@ -84,6 +84,16 @@ export function removeDomainPrefix(domain: string) { return domain.replace(/^(?:https?:\/\/)?(?:www\.)?/i, ''); } +// Domain without trailing slash and lowercase'd, for use as a key +export function normalizeDomain(domain: string) { + return domain.toLowerCase().replace(/\/$/, ''); +} + +// Domain sans prefix, lowercase'd +export function shortDomain(domain: string) { + return removeDomainPrefix(domain).toLowerCase(); +} + export function enumToClassName(key: string) { return key.toLowerCase().replace('_', '-'); } diff --git a/src/app/utils/prompt.ts b/src/app/utils/prompt.ts index f3cc6cab..88256454 100644 --- a/src/app/utils/prompt.ts +++ b/src/app/utils/prompt.ts @@ -44,7 +44,6 @@ export function getPromptOrigin(): OriginData { throw new Error('Missing prompt arguments'); } const { origin } = qs.parse(window.location.search); - console.log(origin); return JSON.parse(origin as string) as OriginData; } diff --git a/src/app/utils/sync.ts b/src/app/utils/sync.ts index 2ce0ca3c..cf6e5964 100755 --- a/src/app/utils/sync.ts +++ b/src/app/utils/sync.ts @@ -19,6 +19,9 @@ import nodeTypes from 'modules/node/types'; import { selectSettings } from 'modules/settings/selectors'; import { changeSettings } from 'modules/settings/actions'; import settingsTypes from 'modules/settings/types'; +import { selectAppconf } from 'modules/appconf/selectors'; +import { setAppconf } from 'modules/appconf/actions'; +import appconfTypes from 'modules/appconf/types'; import { AppState } from 'store/reducers'; export interface SyncConfig { @@ -90,6 +93,14 @@ export const syncConfigs: Array> = [ settingsTypes.REMOVE_REJECTED_DOMAIN, ], }, + { + key: 'appconf', + version: 1, + encrypted: false, + selector: selectAppconf, + action: setAppconf, + triggerActions: [appconfTypes.SET_APP_CONFIG, appconfTypes.DELETE_APP_CONFIG], + }, ]; function getConfigByKey(key: string) { diff --git a/src/app/utils/validators.ts b/src/app/utils/validators.ts index beb144b1..ee4c6988 100755 --- a/src/app/utils/validators.ts +++ b/src/app/utils/validators.ts @@ -62,3 +62,12 @@ export function isSegwitAddress(address: string): boolean { // check if the address starts with one of the prefixes return CHAIN_PREFIXES.some(p => addrPrefix.substring(0, p.length) === p); } + +export function isValidDomain(domain: string): boolean { + try { + new URL(domain); // tslint:disable-line + return true; + } catch (err) { + return false; + } +} diff --git a/src/background_script/handleLndHttp.ts b/src/background_script/handleLndHttp.ts index 1e7306b3..602080db 100644 --- a/src/background_script/handleLndHttp.ts +++ b/src/background_script/handleLndHttp.ts @@ -16,29 +16,33 @@ function isLndRequestMessage(req: any): req is LndAPIRequestMessage { + browser.runtime.onMessage.addListener((request: unknown) => { if (!isLndRequestMessage(request)) { return; } - const client = new LndHttpClient(request.url, request.macaroon); - const fn = client[request.method] as LndHttpClient[typeof request.method]; - const args = request.args as Parameters; + return new Promise(resolve => { + const client = new LndHttpClient(request.url, request.macaroon); + const fn = client[request.method] as LndHttpClient[typeof request.method]; + const args = request.args as Parameters; - return (fn as any)(...args) - .then((data: ReturnType) => { - return { - type: 'lnd-api-response', - method: request.method, - data, - } as LndAPIResponseMessage; - }) - .catch((err: LndAPIResponseError) => { - return { - type: 'lnd-api-response', - method: request.method, - error: err, - } as LndAPIResponseMessage; - }); + resolve( + (fn as any)(...args) + .then((data: ReturnType) => { + return { + type: 'lnd-api-response', + method: request.method, + data, + } as LndAPIResponseMessage; + }) + .catch((err: LndAPIResponseError) => { + return { + type: 'lnd-api-response', + method: request.method, + error: err, + } as LndAPIResponseMessage; + }), + ); + }); }); } diff --git a/src/background_script/handleNotifications.ts b/src/background_script/handleNotifications.ts new file mode 100644 index 00000000..68271072 --- /dev/null +++ b/src/background_script/handleNotifications.ts @@ -0,0 +1,23 @@ +import { browser } from 'webextension-polyfill-ts'; +import { isNotificationMessage } from '../util/messages'; + +export default function handleNotifications() { + browser.runtime.onMessage.addListener(request => { + if (!isNotificationMessage(request)) { + return; + } + + const { method, id, options } = request.args; + if (method === 'create' && options) { + browser.notifications.create(id, options); + } else if (method === 'update' && id && options) { + browser.notifications.clear(id).then(() => { + browser.notifications.create(id, options); + }); + } else if (method === 'clear' && id) { + browser.notifications.clear(id); + } else { + console.warn('Malformed notification message:', request); + } + }); +} diff --git a/src/background_script/handlePassword.ts b/src/background_script/handlePassword.ts index ed602fb8..f4550c6b 100644 --- a/src/background_script/handlePassword.ts +++ b/src/background_script/handlePassword.ts @@ -3,7 +3,7 @@ import { browser } from 'webextension-polyfill-ts'; let cachedPassword: string | undefined; export default function handlePassword() { - browser.runtime.onMessage.addListener((request: any) => { + browser.runtime.onMessage.addListener((request, sender) => { if (!request || request.application !== 'Joule') { return; } @@ -20,11 +20,16 @@ export default function handlePassword() { // Send the password cache back to the app if (request.getPassword) { - browser.runtime.sendMessage({ + const msg = { application: 'Joule', cachedPassword: true, data: cachedPassword, - }); + }; + if (sender.tab && sender.tab.id) { + browser.tabs.sendMessage(sender.tab.id, msg); + } else { + browser.runtime.sendMessage(msg); + } } }); } diff --git a/src/background_script/index.ts b/src/background_script/index.ts index ffb5f8a5..7d9bcd45 100755 --- a/src/background_script/index.ts +++ b/src/background_script/index.ts @@ -2,12 +2,14 @@ import handleLndHttp from './handleLndHttp'; import handlePrompts from './handlePrompts'; import handlePassword from './handlePassword'; import handleContextMenu from './handleContextMenu'; +import handleNotifications from './handleNotifications'; function initBackground() { handleLndHttp(); handlePrompts(); handlePassword(); handleContextMenu(); + handleNotifications(); } initBackground(); diff --git a/src/content_script/index.ts b/src/content_script/index.ts index 4c67c98b..4b391afa 100755 --- a/src/content_script/index.ts +++ b/src/content_script/index.ts @@ -4,6 +4,7 @@ import injectScript from './injectScript'; import respondWithoutPrompt from './respondWithoutPrompt'; import { PROMPT_TYPE } from '../webln/types'; import { getOriginData } from 'utils/prompt'; +import { isPromptMessage } from '../util/messages'; if (shouldInject()) { injectScript(); @@ -14,9 +15,10 @@ if (shouldInject()) { return; } - if (ev.data && ev.data.application === 'Joule' && !ev.data.response) { + const msg = ev.data; + if (isPromptMessage(msg)) { const messageWithOrigin = { - ...ev.data, + ...msg, origin: getOriginData(), }; @@ -53,6 +55,7 @@ if (document) { const lightningLink = target.closest('[href^="lightning:"]'); if (lightningLink) { + ev.preventDefault(); const href = lightningLink.getAttribute('href') as string; const paymentRequest = href.replace('lightning:', ''); browser.runtime.sendMessage({ @@ -62,7 +65,6 @@ if (document) { origin: getOriginData(), args: { paymentRequest }, }); - ev.preventDefault(); } }); }); @@ -74,7 +76,9 @@ if (document) { event => { // 2 = right mouse button. may be better to store in a constant if (event.button === 2) { - let paymentRequest = (window.getSelection() || '').toString(); + const selection = window.getSelection(); + if (!selection) return; + let paymentRequest = selection.toString(); // if nothing selected, try to get the text of the right-clicked element. if (!paymentRequest && event.target) { // Cast as HTMLInputElement to get the value if a form element is used diff --git a/src/content_script/notifications.ts b/src/content_script/notifications.ts new file mode 100644 index 00000000..1b78e4f6 --- /dev/null +++ b/src/content_script/notifications.ts @@ -0,0 +1,47 @@ +import { browser, Notifications } from 'webextension-polyfill-ts'; +import { NotificationMessage } from '../util/messages'; + +const DEFAULT_SETTINGS = { + iconUrl: 'icon128.png', +}; + +export function createNotification( + options: Notifications.CreateNotificationOptions, + id?: string, +) { + browser.runtime.sendMessage({ + application: 'Joule', + notification: true, + args: { + method: 'create', + options: { ...DEFAULT_SETTINGS, ...options }, + id, + }, + } as NotificationMessage); +} + +export function updateNotification( + options: Notifications.CreateNotificationOptions, + id: string, +) { + browser.runtime.sendMessage({ + application: 'Joule', + notification: true, + args: { + method: 'update', + options: { ...DEFAULT_SETTINGS, ...options }, + id, + }, + } as NotificationMessage); +} + +export async function clearNotification(id: string) { + browser.runtime.sendMessage({ + application: 'Joule', + notification: true, + args: { + method: 'clear', + id, + }, + } as NotificationMessage); +} diff --git a/src/content_script/respondWithoutPrompt.ts b/src/content_script/respondWithoutPrompt.ts index 547a22fa..4377eab2 100644 --- a/src/content_script/respondWithoutPrompt.ts +++ b/src/content_script/respondWithoutPrompt.ts @@ -1,19 +1,32 @@ -import runSelector from './runSelector'; +import bolt11 from 'bolt11'; +import { runSelector, runAction } from './store'; import { PROMPT_TYPE } from '../webln/types'; import { selectSettings } from 'modules/settings/selectors'; +import { sendPayment } from 'modules/payment/actions'; +import { selectConfigByDomain } from 'modules/appconf/selectors'; +import { setAppConfig } from 'modules/appconf/actions'; +import { + AnyPromptMessage, + AuthorizePromptMessage, + PaymentPromptMessage, +} from '../util/messages'; +import { createNotification, updateNotification } from './notifications'; -export default async function respondWithoutPrompt(data: any): Promise { - switch (data.type) { +export default async function respondWithoutPrompt( + msg: AnyPromptMessage, +): Promise { + switch (msg.type) { case PROMPT_TYPE.AUTHORIZE: - return handleAuthorizePrompt(data); + return handleAuthorizePrompt(msg); + case PROMPT_TYPE.PAYMENT: + return handleAutoPayment(msg); } - return false; } -async function handleAuthorizePrompt(data: any) { - const { domain } = data.origin; - const settings = await runSelector(selectSettings, 'settings', 'settings'); +async function handleAuthorizePrompt(msg: AuthorizePromptMessage) { + const { domain } = msg.origin; + const settings = await runSelector(selectSettings, true); if (domain) { if (settings.enabledDomains.includes(domain)) { @@ -28,6 +41,105 @@ async function handleAuthorizePrompt(data: any) { return false; } +async function handleAutoPayment(msg: PaymentPromptMessage) { + // Pop up for non-fixed invoices + const { satoshis } = bolt11.decode(msg.args.paymentRequest); + if (!satoshis) { + return false; + } + + // Grab the available allowance, if possible + const config = await runSelector(selectConfigByDomain, msg.origin.domain); + if (!config || !config.allowance || !config.allowance.active) { + return false; + } + + // Check that the payment is allowed via our allowance constraints + const { allowance } = config; + if (satoshis > allowance.maxPerPayment || satoshis > allowance.balance) { + return false; + } + + // Don't allow payments to happen too fast + const now = Date.now(); + if (allowance.lastPaymentAttempt + allowance.minIntervalPerPayment * 1000 > now) { + console.warn('Site attempted to make payments too fast for allowance payment'); + return false; + } + + // Attempt to send the payment and show a notification + const notifId = Math.random().toString(); + createNotification( + { + type: 'basic', + title: 'Autopaying invoice', + message: `Paying ${satoshis} from your allowance...`, + }, + notifId, + ); + + const state = await runAction( + sendPayment({ + payment_request: msg.args.paymentRequest, + fee_limit: { + fixed: '10', + }, + }), + s => + !!s.payment.sendError || + !!s.payment.sendLightningReceipt || + !!s.crypto.isRequestingPassword, + ); + + // If it failed for any reason or we need their password, we'll just open the prompt + if ( + state.payment.sendError || + state.crypto.isRequestingPassword || + !state.payment.sendLightningReceipt + ) { + let message = 'An unknown error caused the payment to fail'; + if (state.crypto.isRequestingPassword) { + message = 'Joule must be unlocked'; + } else if (state.payment.sendError) { + message = state.payment.sendError.message; + } + updateNotification( + { + type: 'basic', + title: 'Autopayment failed', + message, + }, + notifId, + ); + return false; + } + + // Reduce their allowance balance by cost + fee and show notification + const fee = parseInt(state.payment.sendLightningReceipt.payment_route.total_fees, 10); + const balance = allowance.balance - satoshis - (fee || 0); + await runAction( + setAppConfig(msg.origin.domain, { + ...config, + allowance: { + ...allowance, + balance, + lastPaymentAttempt: now, + }, + }), + ); + + updateNotification( + { + type: 'basic', + title: 'Payment complete!', + message: `${balance} sats of allowance remaining`, + }, + notifId, + ); + + return true; +} + function postDataMessage(data: any) { window.postMessage( { diff --git a/src/content_script/store.ts b/src/content_script/store.ts new file mode 100644 index 00000000..fb8df372 --- /dev/null +++ b/src/content_script/store.ts @@ -0,0 +1,55 @@ +import { Store } from 'redux'; +import { AppState } from 'store/reducers'; +import { configureStore } from 'store/configure'; + +// Get or initialize the store. Pass true to get a fresh store. +let store: Store; +export function getStore(fresh?: boolean) { + if (!store || fresh) { + store = configureStore().store; + } + return store; +} + +// Returns a promise that only resolves once store state has hit a certain condition +type WaitStateCheckFunction = (s: AppState) => boolean; +export async function waitForStoreState( + check: WaitStateCheckFunction, + fresh?: boolean, +): Promise { + return new Promise(resolve => { + const s = getStore(fresh); + const initState = s.getState(); + if (check(initState)) { + resolve(initState); + } else { + const unsub = s.subscribe(() => { + const state = s.getState(); + if (check(state)) { + unsub(); + resolve(state); + } + }); + } + }); +} + +// Run a selector, but ensure the store has fully synced first +type Selector = (s: AppState, ...args: any[]) => T; +export async function runSelector(selector: Selector, ...args: any[]): Promise { + const state = await waitForStoreState(s => s.sync.hasSynced); + return selector(state, ...args); +} + +// Returns a promise that resolves once an action has run and the state has hit +// certain conditions. +export async function runAction( + action: any, + check?: WaitStateCheckFunction, + fresh?: boolean, +): Promise { + const s = getStore(fresh); + await waitForStoreState(state => state.sync.hasSynced); + s.dispatch(action); + return check ? waitForStoreState(check) : Promise.resolve(s.getState()); +} diff --git a/src/lnd/http/index.ts b/src/lnd/http/index.ts index 45ca7521..158b7109 100644 --- a/src/lnd/http/index.ts +++ b/src/lnd/http/index.ts @@ -264,7 +264,17 @@ export class LndHttpClient implements T.LndAPI { } return { ...res, + // Convert base64 preimage to more widely used hex one payment_preimage: new Buffer(res.payment_preimage, 'base64').toString('hex'), + // Provide default values for route fees & timelock + payment_route: { + total_amt: '0', + total_amt_msat: '0', + total_fees: '0', + total_fees_msat: '0', + total_time_lock: '0', + ...res.payment_route, + }, } as T.SendPaymentResponse; }); }; diff --git a/src/util/messages.ts b/src/util/messages.ts new file mode 100644 index 00000000..4ceeb506 --- /dev/null +++ b/src/util/messages.ts @@ -0,0 +1,115 @@ +import { RequestInvoiceArgs } from 'webln'; +import { Notifications } from 'webextension-polyfill-ts'; +import { PROMPT_TYPE } from '../webln/types'; + +export interface BaseMessage { + application: 'Joule'; +} + +// Prompt messages +export interface OriginData { + domain: string; + name: string; + icon: string; +} + +export interface PromptMessage + extends BaseMessage { + prompt: true; + type: T; + args: A; + origin: OriginData; +} + +export type AuthorizePromptMessage = PromptMessage; +export type InfoPromptMessage = PromptMessage; +export type PaymentPromptMessage = PromptMessage< + PROMPT_TYPE.PAYMENT, + { paymentRequest: string } +>; +export type InvoicePromptMessage = PromptMessage; +export type SignPromptMessage = PromptMessage; +export type VerifyPromptMessage = PromptMessage< + PROMPT_TYPE.VERIFY, + { signature: string; msg: string } +>; + +export type AnyPromptMessage = + | AuthorizePromptMessage + | InfoPromptMessage + | PaymentPromptMessage + | InvoicePromptMessage + | SignPromptMessage + | VerifyPromptMessage; + +export function isPromptMessage(msg: any): msg is AnyPromptMessage { + return msg && msg.application === 'Joule' && msg.prompt === true; +} + +// Response messages +export interface ResponseMessage extends BaseMessage { + response: true; +} + +export interface ResponseDataMessage extends ResponseMessage { + data: T; + error?: undefined; +} + +export interface ResponseErrorMessage extends ResponseMessage { + error: string; + data?: undefined; +} + +// Context menu message +export interface ContextMenuMessage extends BaseMessage { + contextMenu: true; + args: { + paymentRequest: string; + }; +} + +// Notification messages +export interface NotificationMessage extends BaseMessage { + notification: true; + args: { + method: 'create' | 'update' | 'clear'; + id?: string; + options?: Notifications.CreateNotificationOptions; + }; +} + +export function isNotificationMessage(msg: any): msg is NotificationMessage { + return msg && msg.application === 'Joule' && msg.notification === true; +} + +// Password messages +export interface SetPasswordMessage extends BaseMessage { + setPassword: true; + data: { + password: string; + }; +} + +export interface GetPasswordMessage extends BaseMessage { + getPassword: true; +} + +export interface ClearPasswordMessage extends BaseMessage { + clearPassword: true; +} + +export interface CachedPasswordMessage extends BaseMessage { + cachedPassword: true; + data: string; +} + +// Any of the above messages +export type AnyMessage = + | ResponseDataMessage + | ResponseErrorMessage + | ContextMenuMessage + | SetPasswordMessage + | GetPasswordMessage + | ClearPasswordMessage + | CachedPasswordMessage; diff --git a/static/manifest.json b/static/manifest.json index f54c8fab..b958430d 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -32,7 +32,7 @@ "persistent": true }, "web_accessible_resources": ["inpage_script.js"], - "permissions": ["storage", "clipboardWrite", "activeTab", "contextMenus"], + "permissions": ["storage", "activeTab", "contextMenus"], "optional_permissions": ["notifications", "http://*/", "https://*/"], "applications": { "gecko": { diff --git a/yarn.lock b/yarn.lock index ab48ee24..cc7f968e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -820,6 +820,13 @@ dependencies: "@types/node" "*" +"@types/bn.js@^4.11.3": + version "4.11.6" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-4.11.6.tgz#c306c70d9358aaea33cd4eda092a742b9505967c" + integrity sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg== + dependencies: + "@types/node" "*" + "@types/chrome@0.0.74": version "0.0.74" resolved "https://registry.yarnpkg.com/@types/chrome/-/chrome-0.0.74.tgz#f69827c48fcf7fecc90c96089807661749a5a5e3" @@ -1595,6 +1602,13 @@ base-64@0.1.0: resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" integrity sha1-eAqZyE59YAJgNhURxId2E78k9rs= +base-x@^3.0.2: + version "3.0.8" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.8.tgz#1e1106c2537f0162e8b52474a557ebb09000018d" + integrity sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA== + dependencies: + safe-buffer "^5.0.1" + base64-js@^1.0.2: version "1.3.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" @@ -1625,6 +1639,11 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +bech32@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.3.tgz#bd47a8986bbb3eec34a56a097a84b8d3e9a2dfcd" + integrity sha512-yuVFUvrNcoJi0sv5phmqc6P+Fl1HjRDRNOOkHY2X/3LBy2bIGNSFx4fZ95HMaXHupuS7cZR15AsvtmCIF4UEyg== + big.js@^3.1.3: version "3.2.0" resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" @@ -1635,11 +1654,56 @@ big.js@^5.2.2: resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== +bigi@^1.1.0, bigi@^1.4.0: + version "1.4.2" + resolved "https://registry.yarnpkg.com/bigi/-/bigi-1.4.2.tgz#9c665a95f88b8b08fc05cfd731f561859d725825" + integrity sha1-nGZalfiLiwj8Bc/XMfVhhZ1yWCU= + binary-extensions@^1.0.0: version "1.13.1" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +bip66@^1.1.0, bip66@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/bip66/-/bip66-1.1.5.tgz#01fa8748785ca70955d5011217d1b3139969ca22" + integrity sha1-AfqHSHhcpwlV1QESF9GzE5lpyiI= + dependencies: + safe-buffer "^5.0.1" + +bitcoin-ops@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/bitcoin-ops/-/bitcoin-ops-1.4.1.tgz#e45de620398e22fd4ca6023de43974ff42240278" + integrity sha512-pef6gxZFztEhaE9RY9HmWVmiIHqCb2OyS4HPKkpc6CIiiOa3Qmuoylxc5P2EkU3w+5eTSifI9SEZC88idAIGow== + +bitcoinjs-lib@^3.3.1: + version "3.3.2" + resolved "https://registry.yarnpkg.com/bitcoinjs-lib/-/bitcoinjs-lib-3.3.2.tgz#780c9c53ecb1222adb463b58bef26386067b609a" + integrity sha512-l5qqvbaK8wwtANPf6oEffykycg4383XgEYdia1rI7/JpGf1jfRWlOUCvx5TiTZS7kyIvY4j/UhIQ2urLsvGkzw== + dependencies: + bech32 "^1.1.2" + bigi "^1.4.0" + bip66 "^1.1.0" + bitcoin-ops "^1.3.0" + bs58check "^2.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.3" + ecurve "^1.0.0" + merkle-lib "^2.0.10" + pushdata-bitcoin "^1.0.1" + randombytes "^2.0.1" + safe-buffer "^5.0.1" + typeforce "^1.11.3" + varuint-bitcoin "^1.0.4" + wif "^2.0.1" + block-stream@*: version "0.0.9" resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" @@ -1652,7 +1716,7 @@ bluebird@^3.5.5: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f" integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w== -bn.js@4.11.8, bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: +bn.js@4.11.8, bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.11.8, bn.js@^4.4.0: version "4.11.8" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== @@ -1673,6 +1737,20 @@ body-parser@1.19.0: raw-body "2.4.0" type-is "~1.6.17" +bolt11@1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/bolt11/-/bolt11-1.2.5.tgz#47de493954c488789abe90eacd694d9dfe770647" + integrity sha512-NbvmUxuMqhnqprIHAdaruC5zQIJQN9gLXUgN+avPn8YLlW3+/Fb3uSyXVImK49+aSrDEIbv3FcixZoTxE5jYnw== + dependencies: + "@types/bn.js" "^4.11.3" + bech32 "^1.1.2" + bitcoinjs-lib "^3.3.1" + bn.js "^4.11.8" + coininfo "git+https://github.com/cryptocoinjs/coininfo.git#c7e003b2fc0db165b89e6f98f6d6360ad22616b2" + lodash "^4.17.4" + safe-buffer "^5.1.1" + secp256k1 "^3.4.0" + bonjour@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" @@ -1731,7 +1809,7 @@ browser-process-hrtime@^0.1.2: resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4" integrity sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw== -browserify-aes@^1.0.0, browserify-aes@^1.0.4: +browserify-aes@^1.0.0, browserify-aes@^1.0.4, browserify-aes@^1.0.6: version "1.2.0" resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== @@ -1799,6 +1877,22 @@ browserslist@^4.6.0, browserslist@^4.6.6: electron-to-chromium "^1.3.247" node-releases "^1.1.29" +bs58@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" + integrity sha1-vhYedsNU9veIrkBx9j806MTwpCo= + dependencies: + base-x "^3.0.2" + +bs58check@<3.0.0, bs58check@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc" + integrity sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA== + dependencies: + bs58 "^4.0.0" + create-hash "^1.1.0" + safe-buffer "^5.1.2" + buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -2109,6 +2203,12 @@ code-point-at@^1.0.0: resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= +"coininfo@git+https://github.com/cryptocoinjs/coininfo.git#c7e003b2fc0db165b89e6f98f6d6360ad22616b2": + version "4.3.0" + resolved "git+https://github.com/cryptocoinjs/coininfo.git#c7e003b2fc0db165b89e6f98f6d6360ad22616b2" + dependencies: + safe-buffer "^5.1.1" + collection-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" @@ -2359,7 +2459,7 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" -create-hash@^1.1.0, create-hash@^1.1.2: +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== @@ -2370,7 +2470,7 @@ create-hash@^1.1.0, create-hash@^1.1.2: ripemd160 "^2.0.1" sha.js "^2.4.0" -create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: +create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.3, create-hmac@^1.1.4: version "1.1.7" resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== @@ -2899,6 +2999,15 @@ draft-js@^0.10.0, draft-js@~0.10.0: immutable "~3.7.4" object-assign "^4.1.0" +drbg.js@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/drbg.js/-/drbg.js-1.0.1.tgz#3e36b6c42b37043823cdbc332d58f31e2445480b" + integrity sha1-Pja2xCs3BDgjzbwzLVjzHiRFSAs= + dependencies: + browserify-aes "^1.0.6" + create-hash "^1.1.2" + create-hmac "^1.1.4" + duplexify@^3.4.2, duplexify@^3.6.0: version "3.7.1" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" @@ -2917,6 +3026,14 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +ecurve@^1.0.0: + version "1.0.6" + resolved "https://registry.yarnpkg.com/ecurve/-/ecurve-1.0.6.tgz#dfdabbb7149f8d8b78816be5a7d5b83fcf6de797" + integrity sha512-/BzEjNfiSuB7jIWKcS/z8FK9jNjmEWvUV2YZ4RLSmcDtP7Lq0m6FvDuSnJpBlDpGRpfRQeTLGLBI8H+kEv0r+w== + dependencies: + bigi "^1.1.0" + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -2940,6 +3057,19 @@ elliptic@^6.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.0" +elliptic@^6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.2.tgz#05c5678d7173c049d8ca433552224a495d0e3762" + integrity sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw== + dependencies: + bn.js "^4.4.0" + brorand "^1.0.1" + hash.js "^1.0.0" + hmac-drbg "^1.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.0" + emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -3315,6 +3445,11 @@ file-loader@^2.0.0: loader-utils "^1.0.2" schema-utils "^1.0.0" +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + filesize@^3.6.1: version "3.6.1" resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" @@ -4997,6 +5132,11 @@ merge-descriptors@1.0.1: resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= +merkle-lib@^2.0.10: + version "2.0.10" + resolved "https://registry.yarnpkg.com/merkle-lib/-/merkle-lib-2.0.10.tgz#82b8dbae75e27a7785388b73f9d7725d0f6f3326" + integrity sha1-grjbrnXieneFOItz+ddyXQ9vMyY= + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -5241,7 +5381,7 @@ mutationobserver-shim@^0.3.2: resolved "https://registry.yarnpkg.com/mutationobserver-shim/-/mutationobserver-shim-0.3.3.tgz#65869630bc89d7bf8c9cd9cb82188cd955aacd2b" integrity sha512-gciOLNN8Vsf7YzcqRjKzlAJ6y7e+B86u7i3KXes0xfxx/nfLmozlW1Vn+Sc9x3tPIePFgc1AeIFhtRgkqTjzDQ== -nan@^2.12.1, nan@^2.13.2: +nan@^2.12.1, nan@^2.13.2, nan@^2.14.0: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== @@ -6172,6 +6312,13 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +pushdata-bitcoin@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pushdata-bitcoin/-/pushdata-bitcoin-1.0.1.tgz#15931d3cd967ade52206f523aa7331aef7d43af7" + integrity sha1-FZMdPNlnreUiBvUjqnMxrvfUOvc= + dependencies: + bitcoin-ops "^1.3.0" + q@^1.1.2: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" @@ -7378,6 +7525,20 @@ scss-tokenizer@^0.2.3: js-base64 "^2.1.8" source-map "^0.4.2" +secp256k1@^3.4.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-3.8.0.tgz#28f59f4b01dbee9575f56a47034b7d2e3b3b352d" + integrity sha512-k5ke5avRZbtl9Tqx/SA7CbY3NF6Ro+Sj9cZxezFzuBlLDmyqPiL8hJJ+EmzD8Ig4LUDByHJ3/iPOVoRixs/hmw== + dependencies: + bindings "^1.5.0" + bip66 "^1.1.5" + bn.js "^4.11.8" + create-hash "^1.2.0" + drbg.js "^1.0.1" + elliptic "^6.5.2" + nan "^2.14.0" + safe-buffer "^5.1.2" + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -8311,6 +8472,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +typeforce@^1.11.3: + version "1.18.0" + resolved "https://registry.yarnpkg.com/typeforce/-/typeforce-1.18.0.tgz#d7416a2c5845e085034d70fcc5b6cc4a90edbfdc" + integrity sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g== + typescript-compare@^0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/typescript-compare/-/typescript-compare-0.0.2.tgz#7ee40a400a406c2ea0a7e551efd3309021d5f425" @@ -8530,6 +8696,13 @@ value-equal@^0.4.0: resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.4.0.tgz#c5bdd2f54ee093c04839d71ce2e4758a6890abc7" integrity sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw== +varuint-bitcoin@^1.0.4: + version "1.1.2" + resolved "https://registry.yarnpkg.com/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz#e76c138249d06138b480d4c5b40ef53693e24e92" + integrity sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw== + dependencies: + safe-buffer "^5.1.1" + vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -8785,6 +8958,13 @@ wide-align@^1.1.0: dependencies: string-width "^1.0.2 || 2" +wif@^2.0.1: + version "2.0.6" + resolved "https://registry.yarnpkg.com/wif/-/wif-2.0.6.tgz#08d3f52056c66679299726fade0d432ae74b4704" + integrity sha1-CNP1IFbGZnkplyb63g1DKudLRwQ= + dependencies: + bs58check "<3.0.0" + wordwrap@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"