diff --git a/.gitignore b/.gitignore index f40da82d..acd421be 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,4 @@ main.js.map npm-debug.log.* # lnd binary -resources/bin/* \ No newline at end of file +resources/bin/ \ No newline at end of file diff --git a/app/app.global.scss b/app/app.global.scss index d7d5d94d..85870d37 100644 --- a/app/app.global.scss +++ b/app/app.global.scss @@ -164,3 +164,33 @@ body { padding: 10px 5px; font-size: 15px; } + +// network + +@keyframes dash { + to { + stroke-dashoffset: 1000; + } +} + +// each node in the map +.network-node { + +} + +// each channel in the map +.network-link { + opacity: 0.6; +} + +.active-peer { + fill: #5589F3; +} + +.active-channel { + opacity: 1; + stroke: #88D4A2; + stroke-width: 15; + stroke-dasharray: 100; + animation: dash 2.5s infinite linear; +} diff --git a/app/components/GlobalError/GlobalError.scss b/app/components/GlobalError/GlobalError.scss index 5486c7fc..d9fe4b72 100644 --- a/app/components/GlobalError/GlobalError.scss +++ b/app/components/GlobalError/GlobalError.scss @@ -13,6 +13,10 @@ &.closed { max-height: 0; padding: 0; + + .content .close { + opacity: 0; + } } .content { diff --git a/app/components/Nav/Nav.js b/app/components/Nav/Nav.js index 26f5587f..1f638321 100644 --- a/app/components/Nav/Nav.js +++ b/app/components/Nav/Nav.js @@ -6,6 +6,7 @@ import Isvg from 'react-inlinesvg' import walletIcon from 'icons/wallet.svg' import peersIcon from 'icons/peers.svg' import channelsIcon from 'icons/channels.svg' +import networkIcon from 'icons/globe.svg' import styles from './Nav.scss' @@ -37,6 +38,13 @@ const Nav = ({ openPayForm, openRequestForm }) => ( Channels + + +
  • + + Network +
  • +
    diff --git a/app/components/Network/CanvasNetworkGraph.js b/app/components/Network/CanvasNetworkGraph.js new file mode 100644 index 00000000..936d752e --- /dev/null +++ b/app/components/Network/CanvasNetworkGraph.js @@ -0,0 +1,292 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import * as d3Force from 'd3-force'; +import * as d3Selection from 'd3-selection'; +import * as d3Zoom from 'd3-zoom'; +const d3 = Object.assign({}, d3Force, d3Selection, d3Zoom) +import styles from './CanvasNetworkGraph.scss' + +function generateSimulationData(nodes, edges) { + const resNodes = nodes.map(node => Object.assign(node, { id: node.pub_key })) + const resEdges = edges.map(node => Object.assign(node, { source: node.node1_pub, target: node.node2_pub })) + + return { + nodes: resNodes, + links: resEdges + } +} + +class CanvasNetworkGraph extends Component { + constructor(props) { + super(props) + + this.state = { + width: 800, + height: 800, + + simulation: {}, + simulationData: { + nodes: [], + links: [] + }, + + svgLoaded: false + } + + this.startSimulation = this.startSimulation.bind(this) + this.zoomActions = this.zoomActions.bind(this) + this.ticked = this.ticked.bind(this) + this.restart = this.restart.bind(this) + } + + componentWillReceiveProps(nextProps) { + const { network } = nextProps + const { simulationData: { nodes, links } } = this.state + + const simulationDataEmpty = !nodes.length && !links.length + const networkDataLoaded = network.nodes.length || network.edges.length + + // if the simulationData is empty and we have network data + if (simulationDataEmpty && networkDataLoaded) { + this.setState({ + simulationData: generateSimulationData(network.nodes, network.edges) + }) + } + } + + componentDidMount() { + // wait for the svg to be in the DOM before we start the simulation + const svgInterval = setInterval(() => { + if (document.getElementById('mapContainer')) { + d3.select('#mapContainer') + .append('svg') + .attr('id', 'map') + .attr('width', 800) + .attr('height', 800) + + this.startSimulation() + + clearInterval(svgInterval) + } + }, 1000) + } + + componentDidUpdate(prevProps) { + const { + network: { nodes, edges }, + selectedPeerPubkeys, + selectedChannelIds, + currentRouteChanIds + } = this.props + + const prevNodes = prevProps.network.nodes + const prevEdges = prevProps.network.edges + + // update the simulationData only if the nodes or edges have changed + if (prevNodes.length !== nodes.length || prevEdges.length !== edges.length) { + this.setState({ + simulationData: generateSimulationData(nodes, edges) + }) + } + + if (prevProps.selectedPeerPubkeys.length !== selectedPeerPubkeys.length) { + this.updateSelectedPeers() + } + + if (prevProps.selectedChannelIds.length !== selectedChannelIds.length) { + this.updateSelectedChannels() + } + + if (prevProps.currentRouteChanIds.length !== currentRouteChanIds.length) { + this.renderSelectedRoute() + } + } + + componentWillUnmount() { + d3.select('#map') + .remove() + } + + updateSelectedPeers() { + const { selectedPeerPubkeys } = this.props + + // remove active class + d3.selectAll('.active-peer') + .each(function () { + d3.select(this).classed('active-peer', false) + }) + + // add active class to all selected peers + selectedPeerPubkeys.forEach((pubkey) => { + d3.select(`#node-${pubkey}`).classed('active-peer', true) + }) + } + + updateSelectedChannels() { + const { selectedChannelIds } = this.props + + // remove active class + d3.selectAll('.active-channel') + .each(function () { + d3.select(this).classed('active-channel', false) + }) + + // add active class to all selected peers + selectedChannelIds.forEach((chanid) => { + d3.select(`#link-${chanid}`).classed('active-channel', true) + }) + } + + renderSelectedRoute() { + const { currentRouteChanIds } = this.props + + // remove all route animations before rendering new ones + d3.selectAll('.animated-route-circle') + .each(function () { + d3.select(this).remove() + }) + + currentRouteChanIds.forEach((chanId) => { + const link = document.getElementById(`link-${chanId}`) + + if (!link) { return } + const x1 = link.x1.baseVal.value + const x2 = link.x2.baseVal.value + const y1 = link.y1.baseVal.value + const y2 = link.y2.baseVal.value + + // create the circle that represent btc traveling through a channel + this.g + .append('circle') + .attr('id', `circle-${chanId}`) + .attr('class', 'animated-route-circle') + .attr('r', 50) + .attr('cx', x1) + .attr('cy', y1) + .attr('fill', '#FFDC53') + + // we want the animation to repeat back and forth, this function executes that visually + const repeat = () => { + d3.select(`#circle-${chanId}`) + .transition() + .attr('cx', x2) + .attr('cy', y2) + .duration(1000) + .transition() + .duration(1000) + .attr('cx', x1) + .attr('cy', y1) + .on('end', repeat) + } + + // call repeat to animate the circle + repeat() + }) + } + + startSimulation() { + const { simulationData: { nodes, links } } = this.state + + // grab the svg el along with the attributes + const svg = d3.select('#map') + const width = +svg.attr('width') + const height = +svg.attr('height') + + this.g = svg.append('g').attr('transform', `translate(${width / 2},${height / 2})`) + this.link = this.g.append('g').attr('stroke', 'white').attr('stroke-width', 1.5).selectAll('.link') + this.node = this.g.append('g').attr('stroke', 'silver').attr('stroke-width', 1.5).selectAll('.node') + + this.simulation = d3.forceSimulation(nodes) + .force('charge', d3.forceManyBody().strength(-750)) + .force('link', d3.forceLink(links).id(d => d.pub_key).distance(500)) + .force('collide', d3.forceCollide(300)) + .on('tick', this.ticked) + .on('end', () => { + this.setState({ svgLoaded: true }) + }) + // zoom + const zoom_handler = d3.zoom().on('zoom', this.zoomActions) + zoom_handler(svg) + + this.restart() + } + + zoomActions() { + this.g.attr('transform', d3Selection.event.transform) + } + + ticked() { + this.node.attr('cx', d => d.x) + .attr('cy', d => d.y) + + this.link.attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y) + } + + restart() { + const { identity_pubkey } = this.props + const { simulationData: { nodes, links } } = this.state + + // Apply the general update pattern to the nodes. + this.node = this.node.data(nodes, d => d.pub_key) + this.node.exit().remove() + this.node = this.node.enter() + .append('circle') + .attr('stroke', () => 'silver') + .attr('fill', d => d.pub_key === identity_pubkey ? '#FFF' : '#353535') + .attr('r', () => 100) + .attr('id', d => `node-${d.pub_key}`) + .attr('class', 'network-node') + .merge(this.node) + + // Apply the general update pattern to the links. + this.link = this.link.data(links, d => `${d.source.id}-${d.target.id}`) + this.link.exit().remove() + this.link = + this.link.enter() + .append('line') + .attr('id', d => `link-${d.channel_id}`) + .attr('class', 'network-link') + .merge(this.link) + + // Update and restart the simulation. + this.simulation.nodes(nodes) + this.simulation.force('link').links(links) + this.simulation.restart() + } + + render() { + const { svgLoaded } = this.state + + return ( +
    + { + !svgLoaded && +
    +
    +
    +
    +
    +
    +
    loading
    +
    +
    + } +
    + ) + } +} + +CanvasNetworkGraph.propTypes = { + identity_pubkey: PropTypes.string.isRequired, + + network: PropTypes.object.isRequired, + + selectedPeerPubkeys: PropTypes.array.isRequired, + selectedChannelIds: PropTypes.array.isRequired, + currentRouteChanIds: PropTypes.array.isRequired +} + +export default CanvasNetworkGraph diff --git a/app/components/Network/CanvasNetworkGraph.scss b/app/components/Network/CanvasNetworkGraph.scss new file mode 100644 index 00000000..7a9d1da9 --- /dev/null +++ b/app/components/Network/CanvasNetworkGraph.scss @@ -0,0 +1,156 @@ +@import '../../variables.scss'; + +@keyframes fadein { + 0% { background: $white; } + 50% { background: lighten($secondary, 50%); } + 100% { background: $secondary; animation-fill-mode:forwards; } +} + +.mapContainer { + position: relative; + display: inline-block; + width: 70%; + height: 100%; +} + +.loadingContainer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: $secondary; +} + +.loadingWrap { + position: relative; + top: calc(50% - 150px); + width: 150px; + margin: 0 auto; +} + +.loader { + position: absolute; + top: 0; + z-index: 10; + width: 50px; + height: 50px; + border: 15px solid; + border-radius: 50%; + border-top-color: rgba(44,44,44,0); + border-right-color: rgba(55,55,55,0); + border-bottom-color: rgba(66,66,66,0); + border-left-color: rgba(33,33,33,0); + animation: loadEr 3s infinite; +} + +@keyframes loadEr { + 0% { + border-top-color: rgba(44,44,44,0); + border-right-color: rgba(55,55,55,0); + border-bottom-color: rgba(66,66,66,0); + border-left-color: rgba(33,33,33,0); + + } + 10.4% { + border-top-color: rgba(44,44,44,0.5); + border-right-color: rgba(55,55,55,0); + border-bottom-color: rgba(66,66,66,0); + border-left-color: rgba(33,33,33,0); + } + 20.8% { + border-top-color: rgba(44,44,44,0); + border-right-color: rgba(55,55,55,0); + border-bottom-color: rgba(66,66,66,0); + border-left-color: rgba(33,33,33,0); + } +31.2% { + border-top-color: rgba(44,44,44,0); + border-right-color: rgba(55,55,55,0.5); + border-bottom-color: rgba(66,66,66,0); + border-left-color: rgba(33,33,33,0); +} +41.6% { + border-top-color: rgba(44,44,44,0); + border-right-color: rgba(55,55,55,0); + border-bottom-color: rgba(66,66,66,0); + border-left-color: rgba(33,33,33,0); + transform: rotate(40deg); +} +52% { + border-top-color: rgba(44,44,44,0); + border-right-color: rgba(55,55,55,0); + border-bottom-color: rgba(66,66,66,0.5); + border-left-color: rgba(33,33,33,0); +} +62.4% { + border-top-color: rgba(44,44,44,0); + border-right-color: rgba(55,55,55,0); + border-bottom-color: rgba(66,66,66,0); + border-left-color: rgba(33,33,33,0); +} +72.8% { + border-top-color: rgba(44,44,44,0); + border-right-color: rgba(55,55,55,0); + border-bottom-color: rgba(66,66,66,0); + border-left-color: rgba(33,33,33,0.5); +} +} + +.loaderbefore { + width: 50px; + height:50px; + border: 15px solid #ddd; + border-radius: 50%; + position: absolute; + top: 0; + z-index: 9; +} + +.circular { + position: absolute; + top: -15px; + left: -15px; + width: 70px; + height: 70px; + border: 20px solid; + border-radius: 50%; + border-top-color: #333; + border-left-color: #fff; + border-bottom-color: #333; + border-right-color: #fff; + opacity: 0.2; + animation: poof 5s infinite; +} +@keyframes poof { + 0% {transform: scale(1,1) rotate(0deg); opacity: 0.2;} + 50% {transform: scale(4,4) rotate(360deg); opacity: 0;} +} +.another { + opacity: 0.1; + transform: rotate(90deg); + animation: poofity 5s infinite; + animation-delay: 1s; +} +@keyframes poofity { + 0% {transform: scale(1,1) rotate(90deg); opacity: 0.1;} + 50% {transform: scale(4,4) rotate(-360deg); opacity: 0;} +} + +.text { + position: absolute; + top: 95px; + left: 8px; + font-family: Arial; + text-transform: uppercase; + color: #888; + animation: opaa 10s infinite; +} +@keyframes opaa { + 0% {opacity: 1;} +10% {opacity: 0.5} +15% {opacity: 1;} +30% {opacity: 1;} +65% {opacity: 0.3;} +90% {opacity: 0.8;} +} \ No newline at end of file diff --git a/app/components/Network/ChannelsList.js b/app/components/Network/ChannelsList.js new file mode 100644 index 00000000..15b3fae7 --- /dev/null +++ b/app/components/Network/ChannelsList.js @@ -0,0 +1,46 @@ +import { shell } from 'electron' +import React from 'react' +import PropTypes from 'prop-types' +import { btc } from 'utils' +import styles from './ChannelsList.scss' + +const ChannelsList = ({ channels, updateSelectedChannels, selectedChannelIds }) => ( +
      + { + channels.map(channel => +
    • updateSelectedChannels(channel)}> + + +
      +

      Capacity: {btc.satoshisToBtc(channel.capacity)}

      + shell.openExternal(`https://testnet.smartbit.com.au/tx/${channel.channel_point.split(':')[0]}`)}>Channel Point +
      + +
      +

      Remote Pubkey:

      +

      {channel.remote_pubkey.substring(0, Math.min(30, channel.remote_pubkey.length))}...

      +
      + +
      +
      +

      Sent:

      +

      {btc.satoshisToBtc(channel.total_satoshis_sent)} BTC

      +
      +
      +

      Received:

      +

      {btc.satoshisToBtc(channel.total_satoshis_received)} BTC

      +
      +
      +
    • + ) + } +
    +) + +ChannelsList.propTypes = { + channels: PropTypes.array.isRequired, + updateSelectedChannels: PropTypes.func.isRequired, + selectedChannelIds: PropTypes.array.isRequired +} + +export default ChannelsList diff --git a/app/components/Network/ChannelsList.scss b/app/components/Network/ChannelsList.scss new file mode 100644 index 00000000..fd177e5f --- /dev/null +++ b/app/components/Network/ChannelsList.scss @@ -0,0 +1,78 @@ +@import '../../variables.scss'; + +.channels { + color: $white; + margin-top: 50px; +} + +.channel { + position: relative; + margin: 20px 0; + padding: 10px 40px; + cursor: pointer; + transition: all 0.25s; + + &:hover { + background: darken(#353535, 10%); + + .dot { + background: #88D4A2; + opacity: 0.5; + } + } + + .dot { + position: absolute; + top: calc(15% - 10px); + left: 5%; + width: 10px; + height: 10px; + border: 1px solid #979797; + border-radius: 50%; + + &.active { + background: #88D4A2; + } + } + + header { + margin-bottom: 10px; + display: flex; + flex-direction: row; + justify-content: space-between; + + h1 { + margin-bottom: 10px; + } + + span { + font-size: 10px; + text-decoration: underline; + transition: all 0.25s; + + &:hover { + color: #88D4A2; + } + } + } + + + section { + margin: 10px 0; + + h4 { + font-weight: bold; + text-transform: uppercase; + font-size: 10px; + margin-bottom: 5px; + } + } + + .funds { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-top: 20px; + } +} diff --git a/app/components/Network/PeersList.js b/app/components/Network/PeersList.js new file mode 100644 index 00000000..42d75f58 --- /dev/null +++ b/app/components/Network/PeersList.js @@ -0,0 +1,25 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from './PeersList.scss' + +const PeersList = ({ peers, updateSelectedPeers, selectedPeerPubkeys }) => ( +
      + { + peers.map(peer => +
    • updateSelectedPeers(peer)}> + +

      {peer.address}

      +

      {peer.pub_key}

      +
    • + ) + } +
    +) + +PeersList.propTypes = { + peers: PropTypes.array.isRequired, + updateSelectedPeers: PropTypes.func.isRequired, + selectedPeerPubkeys: PropTypes.array.isRequired +} + +export default PeersList diff --git a/app/components/Network/PeersList.scss b/app/components/Network/PeersList.scss new file mode 100644 index 00000000..e2ee2861 --- /dev/null +++ b/app/components/Network/PeersList.scss @@ -0,0 +1,46 @@ +@import '../../variables.scss'; + +.peers { + color: $white; + margin-top: 50px; +} + +.peer { + position: relative; + margin: 20px 0; + padding: 10px 40px; + cursor: pointer; + transition: all 0.25s; + + &:hover { + background: darken(#353535, 10%); + + .dot { + background: #5589F3; + opacity: 0.5; + } + } + + .dot { + position: absolute; + top: calc(50% - 10px); + left: 5%; + width: 10px; + height: 10px; + border: 1px solid #979797; + border-radius: 50%; + + &.active { + background: #5589F3; + } + } + + h1 { + font-size: 16px; + margin-bottom: 10px; + } + + h4 { + font-size: 8px; + } +} \ No newline at end of file diff --git a/app/components/Network/TransactionForm.js b/app/components/Network/TransactionForm.js new file mode 100644 index 00000000..9c643b5a --- /dev/null +++ b/app/components/Network/TransactionForm.js @@ -0,0 +1,61 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { btc } from 'utils' +import styles from './TransactionForm.scss' + +const TransactionForm = ({ updatePayReq, pay_req, loadingRoutes, payReqRoutes, setCurrentRoute, currentRoute }) => ( +
    +
    + updatePayReq(event.target.value)} + /> +
    + + { + loadingRoutes && +
    +
    +

    calculating all routes...

    +
    + } + +
      + { + payReqRoutes.map((route, index) => +
    • setCurrentRoute(route)}> +
      +

      Route #{index + 1}

      + Hops: {route.hops.length} +
      + +
      +
      +

      Amount

      + {btc.satoshisToBtc(route.total_amt)} BTC +
      + +
      +

      Fees

      + {btc.satoshisToBtc(route.total_fees)} BTC +
      +
      +
    • + ) + } +
    +
    +) + +TransactionForm.propTypes = { + updatePayReq: PropTypes.func.isRequired, + pay_req: PropTypes.string.isRequired, + loadingRoutes: PropTypes.bool.isRequired, + payReqRoutes: PropTypes.array.isRequired, + setCurrentRoute: PropTypes.func.isRequired, + currentRoute: PropTypes.object.isRequired +} + +export default TransactionForm diff --git a/app/components/Network/TransactionForm.scss b/app/components/Network/TransactionForm.scss new file mode 100644 index 00000000..f4c35cf7 --- /dev/null +++ b/app/components/Network/TransactionForm.scss @@ -0,0 +1,125 @@ +@import '../../variables.scss'; + +@-webkit-keyframes animation-rotate { + 100% { + -webkit-transform: rotate(360deg); + } +} + +@-moz-keyframes animation-rotate { + 100% { + -moz-transform: rotate(360deg); + } +} + +@-o-keyframes animation-rotate { + 100% { + -o-transform: rotate(360deg); + } +} + +@keyframes animation-rotate { + 100% { + transform: rotate(360deg); + } +} + +.spinner { + border: 1px solid rgba(255, 220, 83, 0.1); + border-left-color: rgba(255, 220, 83, 0.4); + -webkit-border-radius: 999px; + -moz-border-radius: 999px; + border-radius: 999px; +} + +.spinner { + margin: 0 auto; + height: 100px; + width: 100px; + -webkit-animation: animation-rotate 1000ms linear infinite; + -moz-animation: animation-rotate 1000ms linear infinite; + -o-animation: animation-rotate 1000ms linear infinite; + animation: animation-rotate 1000ms linear infinite; +} + +.loading { + margin-top: 40px; + + h1 { + text-align: center; + margin-top: 25px; + } +} + +.transactionForm { + color: $white; + margin-top: 50px; + + .form { + padding: 0 20px; + } + + .transactionInput { + outline: 0; + border: 0; + border-bottom: 1px solid $secondary; + color: $secondary; + background: transparent; + padding: 5px; + width: 100%; + font-size: 14px; + color: $white; + } +} + +.routes { + margin-top: 40px; +} + +.route { + margin: 20px 0; + padding: 20px; + cursor: pointer; + + &:hover { + background: darken(#353535, 10%); + } + + &.active { + background: darken(#353535, 10%); + } + + header { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 20px; + + h1 { + font-size: 16px; + font-weight: bold; + } + + span { + font-weight: bold; + text-transform: uppercase; + font-size: 12px; + letter-spacing: 1.2px; + } + } + + .data { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + section { + h4 { + font-size: 12px; + font-weight: bold; + margin-bottom: 5px; + } + } + } +} \ No newline at end of file diff --git a/app/components/Wallet/ReceiveModal.js b/app/components/Wallet/ReceiveModal.js index 10612aac..6274e827 100644 --- a/app/components/Wallet/ReceiveModal.js +++ b/app/components/Wallet/ReceiveModal.js @@ -93,7 +93,9 @@ ReceiveModal.propTypes = { hideActivityModal: PropTypes.func.isRequired, pubkey: PropTypes.string.isRequired, address: PropTypes.string.isRequired, - newAddress: PropTypes.func.isRequired + newAddress: PropTypes.func.isRequired, + changeQrCode: PropTypes.func.isRequired, + qrCodeType: PropTypes.number.isRequired } export default ReceiveModal diff --git a/app/components/Wallet/Wallet.js b/app/components/Wallet/Wallet.js index 8c9b0216..62675fa9 100644 --- a/app/components/Wallet/Wallet.js +++ b/app/components/Wallet/Wallet.js @@ -29,9 +29,9 @@ class Wallet extends Component { const { modalOpen, qrCodeType } = this.state const changeQrCode = () => { - const qrCodeType = this.state.qrCodeType === 1 ? 2 : 1 + const qrCodeNum = this.state.qrCodeType === 1 ? 2 : 1 - this.setState({ qrCodeType }) + this.setState({ qrCodeType: qrCodeNum }) } return ( diff --git a/app/lnd/lib/rpc.proto b/app/lnd/lib/rpc.proto index c2fda951..46e4ffac 100644 --- a/app/lnd/lib/rpc.proto +++ b/app/lnd/lib/rpc.proto @@ -25,9 +25,46 @@ package lnrpc; * https://github.com/MaxFangX/lightning-api */ +// The WalletUnlocker service is used to set up a wallet password for +// lnd at first startup, and unlock a previously set up wallet. +service WalletUnlocker { + /** lncli: `create` + CreateWallet is used at lnd startup to set the encryption password for + the wallet database. + */ + rpc CreateWallet(CreateWalletRequest) returns (CreateWalletResponse) { + option (google.api.http) = { + post: "/v1/createwallet" + body: "*" + }; + } + + /** lncli: `unlock` + UnlockWallet is used at startup of lnd to provide a password to unlock + the wallet database. + */ + rpc UnlockWallet(UnlockWalletRequest) returns (UnlockWalletResponse) { + option (google.api.http) = { + post: "/v1/unlockwallet" + body: "*" + }; + } +} + +message CreateWalletRequest { + bytes password = 1; +} +message CreateWalletResponse {} + + +message UnlockWalletRequest { + bytes password = 1; +} +message UnlockWalletResponse {} + service Lightning { /** lncli: `walletbalance` - WalletBalance returns the sum of all confirmed unspent outputs under control + WalletBalance returns total unspent outputs(confirmed and unconfirmed), all confirmed unspent outputs and all unconfirmed unspent outputs under control by the wallet. This method can be modified by having the request specify only witness outputs should be factored into the final output sum. */ @@ -59,7 +96,10 @@ service Lightning { /** lncli: `sendcoins` SendCoins executes a request to send coins to a particular address. Unlike - SendMany, this RPC call only allows creating a single output at a time. + SendMany, this RPC call only allows creating a single output at a time. If + neither target_conf, or sat_per_byte are set, then the internal wallet will + consult its fee model to determine a fee for the default confirmation + target. */ rpc SendCoins (SendCoinsRequest) returns (SendCoinsResponse) { option (google.api.http) = { @@ -77,7 +117,9 @@ service Lightning { /** lncli: `sendmany` SendMany handles a request for a transaction that creates multiple specified - outputs in parallel. + outputs in parallel. If neither target_conf, or sat_per_byte are set, then + the internal wallet will consult its fee model to determine a fee for the + default confirmation target. */ rpc SendMany (SendManyRequest) returns (SendManyResponse); @@ -191,7 +233,10 @@ service Lightning { /** lncli: `openchannel` OpenChannel attempts to open a singly funded channel specified in the - request to a remote peer. + request to a remote peer. Users are able to specify a target number of + blocks that the funding transaction should be confirmed in, or a manual fee + rate to us for the funding transaction. If neither are specified, then a + lax block confirmation target is used. */ rpc OpenChannel (OpenChannelRequest) returns (stream OpenStatusUpdate); @@ -199,7 +244,10 @@ service Lightning { CloseChannel attempts to close an active channel identified by its channel outpoint (ChannelPoint). The actions of this method can additionally be augmented to attempt a force close after a timeout period in the case of an - inactive peer. + inactive peer. If a non-force close (cooperative closure) is requested, + then the user can specify either a target number of blocks until the + closure transaction is confirmed, or a manual fee rate. If neither are + specified, then a default lax, block confirmation target is used. */ rpc CloseChannel (CloseChannelRequest) returns (stream CloseStatusUpdate) { option (google.api.http) = { @@ -431,6 +479,9 @@ message Transaction { /// Fees paid for this transaction int64 total_fees = 7 [ json_name = "total_fees" ]; + + /// Addresses that received funds for this transaction + repeated string dest_addresses = 8 [ json_name = "dest_addresses" ]; } message GetTransactionsRequest { } @@ -492,6 +543,12 @@ message LightningAddress { message SendManyRequest { /// The map from addresses to amounts map AddrToAmount = 1; + + /// The target number of blocks that this transaction should be confirmed by. + int32 target_conf = 3; + + /// A manual fee rate set in sat/byte that should be used when crafting the transaction. + int64 sat_per_byte = 5; } message SendManyResponse { /// The id of the transaction @@ -504,6 +561,12 @@ message SendCoinsRequest { /// The amount in satoshis to send int64 amount = 2; + + /// The target number of blocks that this transaction should be confirmed by. + int32 target_conf = 3; + + /// A manual fee rate set in sat/byte that should be used when crafting the transaction. + int64 sat_per_byte = 5; } message SendCoinsResponse { /// The transaction ID of the transaction @@ -657,6 +720,13 @@ message ActiveChannel { The list of active, uncleared HTLCs currently pending within the channel. */ repeated HTLC pending_htlcs = 15 [json_name = "pending_htlcs"]; + + /** + The CSV delay expressed in relative blocks. If the channel is force + closed, we'll need to wait for this many blocks before we can regain our + funds. + */ + uint32 csv_delay = 16 [ json_name = "csv_delay" ]; } message ListChannelsRequest { @@ -764,6 +834,12 @@ message CloseChannelRequest { /// If true, then the channel will be closed forcibly. This means the current commitment transaction will be signed and broadcast. bool force = 2; + + /// The target number of blocks that the closure transaction should be confirmed by. + int32 target_conf = 3; + + /// A manual fee rate set in sat/byte that should be used when crafting the closure transaction. + int64 sat_per_byte = 5; } message CloseStatusUpdate { oneof update { @@ -794,6 +870,12 @@ message OpenChannelRequest { /// The number of satoshis to push to the remote side as part of the initial commitment state int64 push_sat = 5 [json_name = "push_sat"]; + + /// The target number of blocks that the closure transaction should be confirmed by. + int32 target_conf = 6; + + /// A manual fee rate set in sat/byte that should be used when crafting the closure transaction. + int64 sat_per_byte = 7; } message OpenStatusUpdate { oneof update { @@ -803,6 +885,31 @@ message OpenStatusUpdate { } } +message PendingHTLC { + + /// The direction within the channel that the htlc was sent + bool incoming = 1 [ json_name = "incoming" ]; + + /// The total value of the htlc + int64 amount = 2 [ json_name = "amount" ]; + + /// The final output to be swept back to the user's wallet + string outpoint = 3 [ json_name = "outpoint" ]; + + /// The next block height at which we can spend the current stage + uint32 maturity_height = 4 [ json_name = "maturity_height" ]; + + /** + The number of blocks remaining until the current stage can be swept. + Negative values indicate how many blocks have passed since becoming + mature. + */ + int32 blocks_til_maturity = 5 [ json_name = "blocks_til_maturity" ]; + + /// Indicates whether the htlc is in its first or second stage of recovery + uint32 stage = 6 [ json_name = "stage" ]; +} + message PendingChannelRequest {} message PendingChannelResponse { message PendingChannel { @@ -823,7 +930,7 @@ message PendingChannelResponse { uint32 confirmation_height = 2 [ json_name = "confirmation_height" ]; /// The number of blocks until this channel is open - uint32 blocks_till_open = 3 [ json_name = "blocks_till_open" ]; + int32 blocks_till_open = 3 [ json_name = "blocks_till_open" ]; /** The amount calculated to be paid in fees for the current set of @@ -857,8 +964,6 @@ message PendingChannelResponse { /// The pending channel to be force closed PendingChannel channel = 1 [ json_name = "channel" ]; - // TODO(roasbeef): HTLC's as well? - /// The transaction id of the closing transaction string closing_txid = 2 [ json_name = "closing_txid" ]; @@ -868,8 +973,17 @@ message PendingChannelResponse { /// The height at which funds can be sweeped into the wallet uint32 maturity_height = 4 [ json_name = "maturity_height" ]; - /// Remaining # of blocks until funds can be sweeped into the wallet - uint32 blocks_til_maturity = 5 [ json_name = "blocks_til_maturity" ]; + /* + Remaining # of blocks until the commitment output can be swept. + Negative values indicate how many blocks have passed since becoming + mature. + */ + int32 blocks_til_maturity = 5 [ json_name = "blocks_til_maturity" ]; + + /// The total value of funds successfully recovered from this channel + int64 recovered_balance = 6 [ json_name = "recovered_balance" ]; + + repeated PendingHTLC pending_htlcs = 8 [ json_name = "pending_htlcs" ]; } /// The balance in satoshis encumbered in pending channels @@ -891,7 +1005,13 @@ message WalletBalanceRequest { } message WalletBalanceResponse { /// The balance of the wallet - int64 balance = 1 [json_name = "balance"]; + int64 total_balance = 1 [json_name = "total_balance"]; + + /// The confirmed balance of a wallet(with >= 1 confirmations) + int64 confirmed_balance = 2 [json_name = "confirmed_balance"]; + + /// The unconfirmed balance of a wallet(with 0 confirmations) + int64 unconfirmed_balance = 3 [json_name = "unconfirmed_balance"]; } message ChannelBalanceRequest { @@ -994,6 +1114,7 @@ message LightningNode { string pub_key = 2 [ json_name = "pub_key" ]; string alias = 3 [ json_name = "alias" ]; repeated NodeAddress addresses = 4 [ json_name = "addresses" ]; + string color = 5 [ json_name = "color" ]; } message NodeAddress { @@ -1179,6 +1300,9 @@ message Invoice { /// Fallback on-chain address. string fallback_addr = 12 [json_name = "fallback_addr"]; + + /// Delta to use for the time-lock of the CLTV extended to the final hop. + uint64 cltv_expiry = 13 [json_name = "cltv_expiry"]; } message AddInvoiceResponse { bytes r_hash = 1 [json_name = "r_hash"]; @@ -1263,6 +1387,7 @@ message PayReq { string description = 6 [json_name = "description"]; string description_hash = 7 [json_name = "description_hash"]; string fallback_addr = 8 [json_name = "fallback_addr"]; + int64 cltv_expiry = 9 [json_name = "cltv_expiry"]; } message FeeReportRequest {} diff --git a/app/lnd/methods/index.js b/app/lnd/methods/index.js index 7f4a4132..4495637f 100644 --- a/app/lnd/methods/index.js +++ b/app/lnd/methods/index.js @@ -44,8 +44,20 @@ export default function (lnd, event, msg, data) { .then(routes => event.sender.send('receiveQueryRoutes', routes)) .catch(error => console.log('queryRoutes error: ', error)) break + case 'getInvoiceAndQueryRoutes': + // Data looks like { pubkey: String, amount: Number } + invoicesController.getInvoice(lnd, { pay_req: data.payreq }) + .then((invoiceData) => { + networkController.queryRoutes(lnd, { pubkey: invoiceData.destination, amount: invoiceData.num_satoshis }) + .then((routes) => { + event.sender.send('receiveInvoiceAndQueryRoutes', routes) + }) + .catch(error => console.log('getInvoiceAndQueryRoutes queryRoutes error: ', error)) + }) + .catch(error => console.log('getInvoiceAndQueryRoutes invoice error: ', error)) + break case 'newaddress': - // Data looks like { address: '' } + // Data looks like { address: '' } walletController.newAddress(lnd, data.type) .then(({ address }) => event.sender.send('receiveAddress', address)) .catch(error => console.log('newaddress error: ', error)) @@ -98,7 +110,9 @@ export default function (lnd, event, msg, data) { case 'balance': // Balance looks like [ { balance: '129477456' }, { balance: '243914' } ] Promise.all([walletController.walletBalance, channelController.channelBalance].map(func => func(lnd))) - .then(balance => event.sender.send('receiveBalance', { walletBalance: balance[0].balance, channelBalance: balance[1].balance })) + .then((balance) => { + event.sender.send('receiveBalance', { walletBalance: balance[0].total_balance, channelBalance: balance[1].balance }) + }) .catch(error => console.log('balance error: ', error)) break case 'createInvoice': @@ -129,7 +143,10 @@ export default function (lnd, event, msg, data) { console.log('payinvoice success: ', payment_route) event.sender.send('paymentSuccessful', Object.assign(data, { payment_route })) }) - .catch(({ error }) => event.sender.send('paymentFailed', { error: error.toString() })) + .catch(({ error }) => { + console.log('error: ', error) + event.sender.send('paymentFailed', { error: error.toString() }) + }) break case 'sendCoins': // Transaction looks like { txid: String } diff --git a/app/lnd/methods/paymentsController.js b/app/lnd/methods/paymentsController.js index 29ff826c..6a5d5397 100644 --- a/app/lnd/methods/paymentsController.js +++ b/app/lnd/methods/paymentsController.js @@ -7,7 +7,7 @@ export function sendPaymentSync(lnd, { paymentRequest }) { return new Promise((resolve, reject) => { lnd.sendPaymentSync({ payment_request: paymentRequest }, (error, data) => { - if (error) { + if (error) { reject({ error }) return } diff --git a/app/lnd/utils/index.js b/app/lnd/utils/index.js deleted file mode 100644 index 6fa3f7c6..00000000 --- a/app/lnd/utils/index.js +++ /dev/null @@ -1,42 +0,0 @@ -import zbase32 from 'zbase32' - -function convertBigEndianBufferToLong(longBuffer) { - let longValue = 0 - const byteArray = Buffer.from(longBuffer).swap64() - - for (let i = byteArray.length - 1; i >= 0; i -= 1) { - longValue = (longValue * 256) + byteArray[i] - } - - return longValue -} - -export function decodeInvoice(payreq) { - const payreqBase32 = zbase32.decode(payreq) - - const bufferHexRotated = Buffer.from(payreqBase32).toString('hex') - const bufferHex = bufferHexRotated.substr(bufferHexRotated.length - 1, bufferHexRotated.length) - + bufferHexRotated.substr(0, bufferHexRotated.length - 1) - const buffer = Buffer.from(bufferHex, 'hex') - - const pubkeyBuffer = buffer.slice(0, 33) - const pubkey = pubkeyBuffer.toString('hex') - - const paymentHashBuffer = buffer.slice(33, 65) - const paymentHashHex = paymentHashBuffer.toString('hex') - - const valueBuffer = buffer.slice(65, 73) - - const amount = convertBigEndianBufferToLong(valueBuffer) - - return { - payreq, - pubkey, - amount, - r_hash: paymentHashHex - } -} - -export default { - decodeInvoice -} diff --git a/app/reducers/channels.js b/app/reducers/channels.js index 5f99fb83..e28f60d1 100644 --- a/app/reducers/channels.js +++ b/app/reducers/channels.js @@ -202,7 +202,8 @@ export const channelGraphData = (event, data) => (dispatch, getState) => { // if there are any new channel updates if (channel_updates.length) { // The network has updated, so fetch a new result - dispatch(fetchDescribeNetwork()) + // TODO: can't do this now because of the SVG performance issues, after we fix this we can uncomment the line below + // dispatch(fetchDescribeNetwork()) // loop through the channel updates for (let i = 0; i < channel_updates.length; i += 1) { diff --git a/app/reducers/ipc.js b/app/reducers/ipc.js index 557555e5..8ea03f28 100644 --- a/app/reducers/ipc.js +++ b/app/reducers/ipc.js @@ -33,7 +33,7 @@ import { newTransaction } from './transaction' -import { receiveDescribeNetwork, receiveQueryRoutes } from './network' +import { receiveDescribeNetwork, receiveQueryRoutes, receiveInvoiceAndQueryRoutes } from './network' // Import all receiving IPC event handlers and pass them into createIpc const ipc = createIpc({ @@ -90,7 +90,8 @@ const ipc = createIpc({ newTransaction, receiveDescribeNetwork, - receiveQueryRoutes + receiveQueryRoutes, + receiveInvoiceAndQueryRoutes }) export default ipc diff --git a/app/reducers/network.js b/app/reducers/network.js index 0fc82592..6e482680 100644 --- a/app/reducers/network.js +++ b/app/reducers/network.js @@ -1,5 +1,6 @@ import { createSelector } from 'reselect' import { ipcRenderer } from 'electron' +import { bech32 } from '../utils' // ------------------------------------ // Constants @@ -19,6 +20,17 @@ export const SET_CURRENT_TAB = 'SET_CURRENT_TAB' export const SET_CURRENT_PEER = 'SET_CURRENT_PEER' export const UPDATE_PAY_REQ = 'UPDATE_PAY_REQ' +export const RESET_PAY_REQ = 'RESET_PAY_REQ' + +export const UPDATE_SELECTED_PEERS = 'UPDATE_SELECTED_PEERS' +export const CLEAR_SELECTED_PEERS = 'CLEAR_SELECTED_PEERS' + +export const UPDATE_SELECTED_CHANNELS = 'UPDATE_SELECTED_CHANNELS' +export const CLEAR_SELECTED_CHANNELS = 'CLEAR_SELECTED_CHANNELS' + +export const GET_INFO_AND_QUERY_ROUTES = 'GET_INFO_AND_QUERY_ROUTES' +export const RECEIVE_INFO_AND_QUERY_ROUTES = 'RECEIVE_INFO_AND_QUERY_ROUTES' +export const CLEAR_QUERY_ROUTES = 'CLEAR_QUERY_ROUTES' // ------------------------------------ // Actions @@ -71,6 +83,50 @@ export function updatePayReq(pay_req) { } } +export function resetPayReq() { + return { + type: RESET_PAY_REQ + } +} + +export function updateSelectedPeers(peer) { + return { + type: UPDATE_SELECTED_PEERS, + peer + } +} + +export function updateSelectedChannels(channel) { + return { + type: UPDATE_SELECTED_CHANNELS, + channel + } +} + +export function getInvoiceAndQueryRoutes() { + return { + type: GET_INFO_AND_QUERY_ROUTES + } +} + +export function clearQueryRoutes() { + return { + type: CLEAR_QUERY_ROUTES + } +} + +export function clearSelectedPeers() { + return { + type: CLEAR_SELECTED_PEERS + } +} + +export function clearSelectedChannels() { + return { + type: CLEAR_SELECTED_CHANNELS + } +} + // Send IPC event for describeNetwork export const fetchDescribeNetwork = () => (dispatch) => { dispatch(getDescribeNetwork()) @@ -87,6 +143,15 @@ export const queryRoutes = (pubkey, amount) => (dispatch) => { export const receiveQueryRoutes = (event, { routes }) => dispatch => dispatch({ type: RECEIVE_QUERY_ROUTES, routes }) +// take a payreq and query routes for it +export const fetchInvoiceAndQueryRoutes = payreq => (dispatch) => { + dispatch(getInvoiceAndQueryRoutes()) + ipcRenderer.send('lnd', { msg: 'getInvoiceAndQueryRoutes', data: { payreq } }) +} + +export const receiveInvoiceAndQueryRoutes = (event, { routes }) => dispatch => { + dispatch({ type: RECEIVE_INFO_AND_QUERY_ROUTES, routes }) +} // ------------------------------------ // Action Handlers // ------------------------------------ @@ -103,12 +168,7 @@ const ACTION_HANDLERS = { } ), - [SET_CURRENT_ROUTE]: (state, { route }) => ( - { - ...state, - selectedNode: { pubkey: state.selectedNode.pubkey, routes: state.selectedNode.routes, currentRoute: route } - } - ), + [SET_CURRENT_ROUTE]: (state, { route }) => ({ ...state, currentRoute: route }), [SET_CURRENT_CHANNEL]: (state, { selectedChannel }) => ({ ...state, selectedChannel }), @@ -116,21 +176,96 @@ const ACTION_HANDLERS = { [SET_CURRENT_PEER]: (state, { currentPeer }) => ({ ...state, currentPeer }), - [UPDATE_PAY_REQ]: (state, { pay_req }) => ({ ...state, pay_req }) + [UPDATE_PAY_REQ]: (state, { pay_req }) => ({ ...state, pay_req }), + [RESET_PAY_REQ]: state => ({ ...state, pay_req: '' }), + + [GET_INFO_AND_QUERY_ROUTES]: state => ({ ...state, fetchingInvoiceAndQueryingRoutes: true }), + [RECEIVE_INFO_AND_QUERY_ROUTES]: (state, { routes }) => ({ ...state, fetchingInvoiceAndQueryingRoutes: false, payReqRoutes: routes }), + [CLEAR_QUERY_ROUTES]: state => ({ ...state, payReqRoutes: [], currentRoute: {} }), + + [UPDATE_SELECTED_PEERS]: (state, { peer }) => { + let selectedPeers + + if (state.selectedPeers.includes(peer)) { + selectedPeers = state.selectedPeers.filter(selectedPeer => selectedPeer.pub_key !== peer.pub_key) + } + + if (!state.selectedPeers.includes(peer)) { + selectedPeers = [...state.selectedPeers, peer] + } + + return { + ...state, selectedPeers + } + }, + [CLEAR_SELECTED_PEERS]: state => ({ ...state, selectedPeers: [] }), + + [UPDATE_SELECTED_CHANNELS]: (state, { channel }) => { + let selectedChannels + + if (state.selectedChannels.includes(channel)) { + selectedChannels = state.selectedChannels.filter(selectedChannel => selectedChannel.chan_id !== channel.chan_id) + } + + if (!state.selectedChannels.includes(channel)) { + selectedChannels = [...state.selectedChannels, channel] + } + + return { + ...state, selectedChannels + } + }, + [CLEAR_SELECTED_CHANNELS]: state => ({ ...state, selectedChannels: [] }) } // ------------------------------------ // Selectors // ------------------------------------ const networkSelectors = {} -const currentRouteSelector = state => state.network.selectedNode.currentRoute +const selectedPeersSelector = state => state.network.selectedPeers +const selectedChannelsSelector = state => state.network.selectedChannels +const payReqSelector = state => state.network.pay_req +const currentRouteSelector = state => state.network.currentRoute + +// networkSelectors.currentRouteHopChanIds = createSelector( +// currentRouteSelector, +// (currentRoute) => { +// if (!currentRoute.hops) { return [] } + +// return currentRoute.hops.map(hop => hop.chan_id) +// } +// ) + +networkSelectors.selectedPeerPubkeys = createSelector( + selectedPeersSelector, + peers => peers.map(peer => peer.pub_key) +) -networkSelectors.currentRouteHopChanIds = createSelector( +networkSelectors.selectedChannelIds = createSelector( + selectedChannelsSelector, + channels => channels.map(channel => channel.chan_id) +) + +networkSelectors.payReqIsLn = createSelector( + payReqSelector, + (input) => { + if (!input.startsWith('ln')) { return false } + + try { + bech32.decode(input) + return true + } catch (e) { + return false + } + } +) + +networkSelectors.currentRouteChanIds = createSelector( currentRouteSelector, - (currentRoute) => { - if (!currentRoute.hops) { return [] } + (route) => { + if (!route.hops || !route.hops.length) { return [] } - return currentRoute.hops.map(hop => hop.chan_id) + return route.hops.map(hop => hop.chan_id) } ) @@ -143,18 +278,18 @@ const initialState = { networkLoading: false, nodes: [], edges: [], - selectedNode: { - pubkey: '', - routes: [], - currentRoute: {} - }, selectedChannel: {}, currentTab: 1, currentPeer: {}, + currentRoute: {}, - pay_req: '' + fetchingInvoiceAndQueryingRoutes: false, + pay_req: '', + payReqRoutes: [], + selectedPeers: [], + selectedChannels: [] } diff --git a/app/routes.js b/app/routes.js index b97b8172..c0bfe51b 100644 --- a/app/routes.js +++ b/app/routes.js @@ -5,12 +5,14 @@ import App from './routes/app' import Activity from './routes/activity' import Peers from './routes/peers' import Channels from './routes/channels' +import Network from './routes/network' export default () => ( + diff --git a/app/routes/activity/components/Activity.js b/app/routes/activity/components/Activity.js index 7326f26a..063f20f9 100644 --- a/app/routes/activity/components/Activity.js +++ b/app/routes/activity/components/Activity.js @@ -4,6 +4,7 @@ import { MdSearch } from 'react-icons/lib/md' import { FaAngleDown } from 'react-icons/lib/fa' import Wallet from 'components/Wallet' +import LoadingBolt from 'components/LoadingBolt' import Invoice from './components/Invoice' import Payment from './components/Payment' import Transaction from './components/Transaction' @@ -19,8 +20,9 @@ class Activity extends Component { } componentWillMount() { - const { fetchPayments, fetchInvoices, fetchTransactions } = this.props + const { fetchPayments, fetchInvoices, fetchTransactions, fetchBalance } = this.props + fetchBalance() fetchPayments() fetchInvoices() fetchTransactions() @@ -60,6 +62,8 @@ class Activity extends Component { } = this.props if (invoiceLoading || paymentLoading) { return
    Loading...
    } + if (balance.balanceLoading) { return } + if (!balance.channelBalance || !balance.walletBalance) { return } return (
    @@ -123,6 +127,7 @@ Activity.propTypes = { fetchPayments: PropTypes.func.isRequired, fetchInvoices: PropTypes.func.isRequired, fetchTransactions: PropTypes.func.isRequired, + fetchBalance: PropTypes.func.isRequired, ticker: PropTypes.object.isRequired, searchInvoices: PropTypes.func.isRequired, diff --git a/app/routes/activity/components/components/Modal/Invoice/Invoice.js b/app/routes/activity/components/components/Modal/Invoice/Invoice.js index ddb78493..540f2b99 100644 --- a/app/routes/activity/components/components/Modal/Invoice/Invoice.js +++ b/app/routes/activity/components/components/Modal/Invoice/Invoice.js @@ -8,7 +8,6 @@ import QRCode from 'qrcode.react' import { FaCircle } from 'react-icons/lib/fa' -import CurrencyIcon from 'components/CurrencyIcon' import { btc } from 'utils' import styles from './Invoice.scss' diff --git a/app/routes/activity/components/components/Modal/Modal.js b/app/routes/activity/components/components/Modal/Modal.js index b53a98df..223b8045 100644 --- a/app/routes/activity/components/components/Modal/Modal.js +++ b/app/routes/activity/components/components/Modal/Modal.js @@ -1,13 +1,12 @@ import React from 'react' import PropTypes from 'prop-types' import ReactModal from 'react-modal' +import { MdClose } from 'react-icons/lib/md' import Transaction from './Transaction' import Payment from './Payment' import Invoice from './Invoice' -import { MdClose } from 'react-icons/lib/md' - import styles from './Modal.scss' const Modal = ({ modalType, modalProps, hideActivityModal, ticker, currentTicker }) => { diff --git a/app/routes/activity/components/components/Modal/Payment/Payment.js b/app/routes/activity/components/components/Modal/Payment/Payment.js index 41f55619..1880be4a 100644 --- a/app/routes/activity/components/components/Modal/Payment/Payment.js +++ b/app/routes/activity/components/components/Modal/Payment/Payment.js @@ -4,7 +4,6 @@ import PropTypes from 'prop-types' import Moment from 'react-moment' import 'moment-timezone' -import CurrencyIcon from 'components/CurrencyIcon' import { btc } from 'utils' import styles from './Payment.scss' diff --git a/app/routes/activity/components/components/Modal/Transaction/Transaction.js b/app/routes/activity/components/components/Modal/Transaction/Transaction.js index f1359842..33f2dc0b 100644 --- a/app/routes/activity/components/components/Modal/Transaction/Transaction.js +++ b/app/routes/activity/components/components/Modal/Transaction/Transaction.js @@ -5,7 +5,6 @@ import PropTypes from 'prop-types' import Moment from 'react-moment' import 'moment-timezone' -import CurrencyIcon from 'components/CurrencyIcon' import { btc } from 'utils' import styles from './Transaction.scss' @@ -43,10 +42,10 @@ const Transaction = ({ transaction, ticker, currentTicker }) => (
    Fee
    { - ticker.currency === 'usd' ? - btc.satoshisToUsd(transaction.total_fees) - : - btc.satoshisToBtc(transaction.total_fees) + ticker.currency === 'usd' ? + btc.satoshisToUsd(transaction.total_fees) + : + btc.satoshisToBtc(transaction.total_fees) }
    Date
    diff --git a/app/routes/activity/containers/ActivityContainer.js b/app/routes/activity/containers/ActivityContainer.js index 650faf24..3f0df128 100644 --- a/app/routes/activity/containers/ActivityContainer.js +++ b/app/routes/activity/containers/ActivityContainer.js @@ -1,5 +1,6 @@ import { connect } from 'react-redux' import { tickerSelectors } from 'reducers/ticker' +import { fetchBalance } from 'reducers/balance' import { fetchInvoices, searchInvoices, @@ -34,7 +35,8 @@ const mapDispatchToProps = { hideActivityModal, changeFilter, toggleFilterPulldown, - newAddress + newAddress, + fetchBalance } const mapStateToProps = state => ({ diff --git a/app/routes/app/components/App.js b/app/routes/app/components/App.js index 32d67c20..cdc179bb 100644 --- a/app/routes/app/components/App.js +++ b/app/routes/app/components/App.js @@ -9,10 +9,9 @@ import styles from './App.scss' class App extends Component { componentWillMount() { - const { fetchTicker, fetchBalance, fetchInfo, newAddress } = this.props + const { fetchTicker, fetchInfo, newAddress } = this.props fetchTicker() - fetchBalance() fetchInfo() newAddress('np2wkh') } @@ -23,7 +22,6 @@ class App extends Component { hideModal, ticker, currentTicker, - balance, form, openPayForm, @@ -37,7 +35,7 @@ class App extends Component { children } = this.props - if (!currentTicker || balance.balanceLoading) { return } + if (!currentTicker) { return } return (
    @@ -68,7 +66,6 @@ class App extends Component { App.propTypes = { modal: PropTypes.object.isRequired, ticker: PropTypes.object.isRequired, - balance: PropTypes.object.isRequired, form: PropTypes.object.isRequired, formProps: PropTypes.object.isRequired, closeForm: PropTypes.func.isRequired, @@ -78,7 +75,6 @@ App.propTypes = { fetchInfo: PropTypes.func.isRequired, hideModal: PropTypes.func.isRequired, fetchTicker: PropTypes.func.isRequired, - fetchBalance: PropTypes.func.isRequired, openPayForm: PropTypes.func.isRequired, openRequestForm: PropTypes.func.isRequired, clearError: PropTypes.func.isRequired, diff --git a/app/routes/app/containers/AppContainer.js b/app/routes/app/containers/AppContainer.js index fabd8e99..e670af8b 100644 --- a/app/routes/app/containers/AppContainer.js +++ b/app/routes/app/containers/AppContainer.js @@ -2,7 +2,6 @@ import { withRouter } from 'react-router' import { connect } from 'react-redux' import { fetchTicker, setCurrency, tickerSelectors } from 'reducers/ticker' import { newAddress } from 'reducers/address' -import { fetchBalance } from 'reducers/balance' import { fetchInfo } from 'reducers/info' @@ -27,7 +26,6 @@ const mapDispatchToProps = { fetchTicker, setCurrency, newAddress, - fetchBalance, fetchInfo, @@ -58,7 +56,6 @@ const mapStateToProps = state => ({ ticker: state.ticker, address: state.address, - balance: state.balance, info: state.info, payment: state.payment, transaction: state.transaction, diff --git a/app/routes/channels/components/Channels.js b/app/routes/channels/components/Channels.js index 8a086aac..c1191d28 100644 --- a/app/routes/channels/components/Channels.js +++ b/app/routes/channels/components/Channels.js @@ -58,7 +58,7 @@ class Channels extends Component { this.setState({ refreshing: true }) // store event in icon so we dont get an error when react clears it - const icon = this.refs.repeat.childNodes + const icon = this.repeat.childNodes // fetch peers fetchChannels() @@ -127,7 +127,7 @@ class Channels extends Component {
    - + (this.repeat = ref)}> { this.state.refreshing ? diff --git a/app/routes/network/components/Network.js b/app/routes/network/components/Network.js new file mode 100644 index 00000000..c44aa2c3 --- /dev/null +++ b/app/routes/network/components/Network.js @@ -0,0 +1,155 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +import CanvasNetworkGraph from 'components/Network/CanvasNetworkGraph' +import PeersList from 'components/Network/PeersList' +import ChannelsList from 'components/Network/ChannelsList' +import TransactionForm from 'components/Network/TransactionForm' + +import styles from './Network.scss' + +class Network extends Component { + componentWillMount() { + const { fetchDescribeNetwork, fetchPeers, fetchChannels } = this.props + + fetchPeers() + fetchChannels() + fetchDescribeNetwork() + } + + componentDidUpdate(prevProps) { + const { payReqIsLn, network: { pay_req }, fetchInvoiceAndQueryRoutes, clearQueryRoutes } = this.props + + // If LN go retrieve invoice details + if ((prevProps.network.pay_req !== pay_req) && payReqIsLn) { + fetchInvoiceAndQueryRoutes(pay_req) + } + + if (prevProps.payReqIsLn && !payReqIsLn) { + clearQueryRoutes() + } + } + + componentWillUnmount() { + const { clearQueryRoutes, resetPayReq, clearSelectedChannels, clearSelectedPeers } = this.props + + clearQueryRoutes() + resetPayReq() + clearSelectedChannels() + clearSelectedPeers() + } + + render() { + const { + setCurrentTab, + updateSelectedPeers, + setCurrentRoute, + + network, + selectedPeerPubkeys, + currentRouteChanIds, + + peers: { peers }, + + activeChannels, + selectedChannelIds, + updateSelectedChannels, + + updatePayReq, + + identity_pubkey + } = this.props + + const renderContent = () => { + switch (network.currentTab) { + case 1: + return + case 2: + return + case 3: + return ( + + ) + default: + return + } + } + + return ( +
    + + +
    +
      +
    • setCurrentTab(1)} + > + Peers +
    • +
    • setCurrentTab(2)} + > + Channels +
    • +
    • setCurrentTab(3)} + > + Transactions +
    • +
    + +
    + {renderContent()} +
    +
    +
    + ) + } +} + +Network.propTypes = { + fetchDescribeNetwork: PropTypes.func.isRequired, + fetchPeers: PropTypes.func.isRequired, + setCurrentTab: PropTypes.func.isRequired, + fetchChannels: PropTypes.func.isRequired, + fetchInvoiceAndQueryRoutes: PropTypes.func.isRequired, + clearQueryRoutes: PropTypes.func.isRequired, + resetPayReq: PropTypes.func.isRequired, + clearSelectedChannels: PropTypes.func.isRequired, + clearSelectedPeers: PropTypes.func.isRequired, + updateSelectedPeers: PropTypes.func.isRequired, + setCurrentRoute: PropTypes.func.isRequired, + updateSelectedChannels: PropTypes.func.isRequired, + updatePayReq: PropTypes.func.isRequired, + + network: PropTypes.object.isRequired, + peers: PropTypes.object.isRequired, + + selectedPeerPubkeys: PropTypes.array.isRequired, + currentRouteChanIds: PropTypes.array.isRequired, + activeChannels: PropTypes.array.isRequired, + selectedChannelIds: PropTypes.array.isRequired, + + identity_pubkey: PropTypes.string.isRequired, + + payReqIsLn: PropTypes.bool.isRequired +} + +export default Network diff --git a/app/routes/network/components/Network.scss b/app/routes/network/components/Network.scss new file mode 100644 index 00000000..185adb85 --- /dev/null +++ b/app/routes/network/components/Network.scss @@ -0,0 +1,81 @@ +@import '../../../variables.scss'; + +@keyframes dash { + to { + stroke-dashoffset: 1000; + } +} + +@keyframes fadein { + 0% { background: $white; } + 50% { background: lighten($secondary, 50%); } + 100% { background: $secondary; animation-fill-mode:forwards; } +} + +.container { + width: 100%; + height: 100vh; + animation: fadein 0.5s; + animation-timing-function:linear; + animation-fill-mode:forwards; + animation-iteration-count: 1; + + line.active { + opacity: 1; + stroke: green; + stroke-width: 5; + stroke-dasharray: 100; + animation: dash 2.5s infinite linear; + } + + circle { + cursor: pointer; + } +} + +.network, .toolbox { + display: inline-block; + vertical-align: top; + height: 100vh; +} + +.network { + width: 70%; +} + +.toolbox { + width: 30%; + height: 100%; + background: #353535; + overflow-y: scroll; +} + +.tabs { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding-top: 40px; + + .tab { + color: $white; + text-align: center; + cursor: pointer; + width: 100%; + padding: 10px 0; + border-bottom: 1px solid #464646; + transition: all 0.5s; + + &.peersTab:hover, &.peersTab.active { + border-bottom: 1px solid #588CF0; + } + + &.channelsTab:hover, &.channelsTab.active { + border-bottom: 1px solid #88D4A2; + } + + &.transactionsTab:hover, &.transactionsTab.active { + border-bottom: 1px solid #FFDC53; + } + } +} diff --git a/app/routes/network/containers/NetworkContainer.js b/app/routes/network/containers/NetworkContainer.js new file mode 100644 index 00000000..a429744c --- /dev/null +++ b/app/routes/network/containers/NetworkContainer.js @@ -0,0 +1,62 @@ +import { withRouter } from 'react-router' +import { connect } from 'react-redux' + +import { + fetchDescribeNetwork, + + setCurrentTab, + + updateSelectedPeers, + clearSelectedPeers, + + updateSelectedChannels, + clearSelectedChannels, + + setCurrentRoute, + + updatePayReq, + resetPayReq, + + fetchInvoiceAndQueryRoutes, + clearQueryRoutes, + + networkSelectors +} from '../../../reducers/network' +import { fetchPeers } from '../../../reducers/peers' +import { fetchChannels, channelsSelectors } from '../../../reducers/channels' + +import Network from '../components/Network' + +const mapDispatchToProps = { + fetchDescribeNetwork, + setCurrentTab, + + updateSelectedPeers, + clearSelectedPeers, + + updatePayReq, + fetchInvoiceAndQueryRoutes, + setCurrentRoute, + clearQueryRoutes, + resetPayReq, + + fetchPeers, + + fetchChannels, + updateSelectedChannels, + clearSelectedChannels +} + +const mapStateToProps = state => ({ + network: state.network, + peers: state.peers, + identity_pubkey: state.info.data.identity_pubkey, + + selectedPeerPubkeys: networkSelectors.selectedPeerPubkeys(state), + selectedChannelIds: networkSelectors.selectedChannelIds(state), + payReqIsLn: networkSelectors.payReqIsLn(state), + currentRouteChanIds: networkSelectors.currentRouteChanIds(state), + activeChannels: channelsSelectors.activeChannels(state) +}) + +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Network)) diff --git a/app/routes/network/index.js b/app/routes/network/index.js new file mode 100644 index 00000000..36983a87 --- /dev/null +++ b/app/routes/network/index.js @@ -0,0 +1,3 @@ +import NetworkContainer from './containers/NetworkContainer' + +export default NetworkContainer diff --git a/app/routes/peers/components/Peers.js b/app/routes/peers/components/Peers.js index 2b1b4e9e..86739a4c 100644 --- a/app/routes/peers/components/Peers.js +++ b/app/routes/peers/components/Peers.js @@ -42,7 +42,7 @@ class Peers extends Component { this.setState({ refreshing: true }) // store event in icon so we dont get an error when react clears it - const icon = this.refs.repeat.childNodes + const icon = this.repeat.childNodes // fetch peers fetchPeers() @@ -98,7 +98,7 @@ class Peers extends Component {
    - + (this.repeat = ref)}> { this.state.refreshing ? diff --git a/app/rpc.proto b/app/rpc.proto index c2fda951..46e4ffac 100644 --- a/app/rpc.proto +++ b/app/rpc.proto @@ -25,9 +25,46 @@ package lnrpc; * https://github.com/MaxFangX/lightning-api */ +// The WalletUnlocker service is used to set up a wallet password for +// lnd at first startup, and unlock a previously set up wallet. +service WalletUnlocker { + /** lncli: `create` + CreateWallet is used at lnd startup to set the encryption password for + the wallet database. + */ + rpc CreateWallet(CreateWalletRequest) returns (CreateWalletResponse) { + option (google.api.http) = { + post: "/v1/createwallet" + body: "*" + }; + } + + /** lncli: `unlock` + UnlockWallet is used at startup of lnd to provide a password to unlock + the wallet database. + */ + rpc UnlockWallet(UnlockWalletRequest) returns (UnlockWalletResponse) { + option (google.api.http) = { + post: "/v1/unlockwallet" + body: "*" + }; + } +} + +message CreateWalletRequest { + bytes password = 1; +} +message CreateWalletResponse {} + + +message UnlockWalletRequest { + bytes password = 1; +} +message UnlockWalletResponse {} + service Lightning { /** lncli: `walletbalance` - WalletBalance returns the sum of all confirmed unspent outputs under control + WalletBalance returns total unspent outputs(confirmed and unconfirmed), all confirmed unspent outputs and all unconfirmed unspent outputs under control by the wallet. This method can be modified by having the request specify only witness outputs should be factored into the final output sum. */ @@ -59,7 +96,10 @@ service Lightning { /** lncli: `sendcoins` SendCoins executes a request to send coins to a particular address. Unlike - SendMany, this RPC call only allows creating a single output at a time. + SendMany, this RPC call only allows creating a single output at a time. If + neither target_conf, or sat_per_byte are set, then the internal wallet will + consult its fee model to determine a fee for the default confirmation + target. */ rpc SendCoins (SendCoinsRequest) returns (SendCoinsResponse) { option (google.api.http) = { @@ -77,7 +117,9 @@ service Lightning { /** lncli: `sendmany` SendMany handles a request for a transaction that creates multiple specified - outputs in parallel. + outputs in parallel. If neither target_conf, or sat_per_byte are set, then + the internal wallet will consult its fee model to determine a fee for the + default confirmation target. */ rpc SendMany (SendManyRequest) returns (SendManyResponse); @@ -191,7 +233,10 @@ service Lightning { /** lncli: `openchannel` OpenChannel attempts to open a singly funded channel specified in the - request to a remote peer. + request to a remote peer. Users are able to specify a target number of + blocks that the funding transaction should be confirmed in, or a manual fee + rate to us for the funding transaction. If neither are specified, then a + lax block confirmation target is used. */ rpc OpenChannel (OpenChannelRequest) returns (stream OpenStatusUpdate); @@ -199,7 +244,10 @@ service Lightning { CloseChannel attempts to close an active channel identified by its channel outpoint (ChannelPoint). The actions of this method can additionally be augmented to attempt a force close after a timeout period in the case of an - inactive peer. + inactive peer. If a non-force close (cooperative closure) is requested, + then the user can specify either a target number of blocks until the + closure transaction is confirmed, or a manual fee rate. If neither are + specified, then a default lax, block confirmation target is used. */ rpc CloseChannel (CloseChannelRequest) returns (stream CloseStatusUpdate) { option (google.api.http) = { @@ -431,6 +479,9 @@ message Transaction { /// Fees paid for this transaction int64 total_fees = 7 [ json_name = "total_fees" ]; + + /// Addresses that received funds for this transaction + repeated string dest_addresses = 8 [ json_name = "dest_addresses" ]; } message GetTransactionsRequest { } @@ -492,6 +543,12 @@ message LightningAddress { message SendManyRequest { /// The map from addresses to amounts map AddrToAmount = 1; + + /// The target number of blocks that this transaction should be confirmed by. + int32 target_conf = 3; + + /// A manual fee rate set in sat/byte that should be used when crafting the transaction. + int64 sat_per_byte = 5; } message SendManyResponse { /// The id of the transaction @@ -504,6 +561,12 @@ message SendCoinsRequest { /// The amount in satoshis to send int64 amount = 2; + + /// The target number of blocks that this transaction should be confirmed by. + int32 target_conf = 3; + + /// A manual fee rate set in sat/byte that should be used when crafting the transaction. + int64 sat_per_byte = 5; } message SendCoinsResponse { /// The transaction ID of the transaction @@ -657,6 +720,13 @@ message ActiveChannel { The list of active, uncleared HTLCs currently pending within the channel. */ repeated HTLC pending_htlcs = 15 [json_name = "pending_htlcs"]; + + /** + The CSV delay expressed in relative blocks. If the channel is force + closed, we'll need to wait for this many blocks before we can regain our + funds. + */ + uint32 csv_delay = 16 [ json_name = "csv_delay" ]; } message ListChannelsRequest { @@ -764,6 +834,12 @@ message CloseChannelRequest { /// If true, then the channel will be closed forcibly. This means the current commitment transaction will be signed and broadcast. bool force = 2; + + /// The target number of blocks that the closure transaction should be confirmed by. + int32 target_conf = 3; + + /// A manual fee rate set in sat/byte that should be used when crafting the closure transaction. + int64 sat_per_byte = 5; } message CloseStatusUpdate { oneof update { @@ -794,6 +870,12 @@ message OpenChannelRequest { /// The number of satoshis to push to the remote side as part of the initial commitment state int64 push_sat = 5 [json_name = "push_sat"]; + + /// The target number of blocks that the closure transaction should be confirmed by. + int32 target_conf = 6; + + /// A manual fee rate set in sat/byte that should be used when crafting the closure transaction. + int64 sat_per_byte = 7; } message OpenStatusUpdate { oneof update { @@ -803,6 +885,31 @@ message OpenStatusUpdate { } } +message PendingHTLC { + + /// The direction within the channel that the htlc was sent + bool incoming = 1 [ json_name = "incoming" ]; + + /// The total value of the htlc + int64 amount = 2 [ json_name = "amount" ]; + + /// The final output to be swept back to the user's wallet + string outpoint = 3 [ json_name = "outpoint" ]; + + /// The next block height at which we can spend the current stage + uint32 maturity_height = 4 [ json_name = "maturity_height" ]; + + /** + The number of blocks remaining until the current stage can be swept. + Negative values indicate how many blocks have passed since becoming + mature. + */ + int32 blocks_til_maturity = 5 [ json_name = "blocks_til_maturity" ]; + + /// Indicates whether the htlc is in its first or second stage of recovery + uint32 stage = 6 [ json_name = "stage" ]; +} + message PendingChannelRequest {} message PendingChannelResponse { message PendingChannel { @@ -823,7 +930,7 @@ message PendingChannelResponse { uint32 confirmation_height = 2 [ json_name = "confirmation_height" ]; /// The number of blocks until this channel is open - uint32 blocks_till_open = 3 [ json_name = "blocks_till_open" ]; + int32 blocks_till_open = 3 [ json_name = "blocks_till_open" ]; /** The amount calculated to be paid in fees for the current set of @@ -857,8 +964,6 @@ message PendingChannelResponse { /// The pending channel to be force closed PendingChannel channel = 1 [ json_name = "channel" ]; - // TODO(roasbeef): HTLC's as well? - /// The transaction id of the closing transaction string closing_txid = 2 [ json_name = "closing_txid" ]; @@ -868,8 +973,17 @@ message PendingChannelResponse { /// The height at which funds can be sweeped into the wallet uint32 maturity_height = 4 [ json_name = "maturity_height" ]; - /// Remaining # of blocks until funds can be sweeped into the wallet - uint32 blocks_til_maturity = 5 [ json_name = "blocks_til_maturity" ]; + /* + Remaining # of blocks until the commitment output can be swept. + Negative values indicate how many blocks have passed since becoming + mature. + */ + int32 blocks_til_maturity = 5 [ json_name = "blocks_til_maturity" ]; + + /// The total value of funds successfully recovered from this channel + int64 recovered_balance = 6 [ json_name = "recovered_balance" ]; + + repeated PendingHTLC pending_htlcs = 8 [ json_name = "pending_htlcs" ]; } /// The balance in satoshis encumbered in pending channels @@ -891,7 +1005,13 @@ message WalletBalanceRequest { } message WalletBalanceResponse { /// The balance of the wallet - int64 balance = 1 [json_name = "balance"]; + int64 total_balance = 1 [json_name = "total_balance"]; + + /// The confirmed balance of a wallet(with >= 1 confirmations) + int64 confirmed_balance = 2 [json_name = "confirmed_balance"]; + + /// The unconfirmed balance of a wallet(with 0 confirmations) + int64 unconfirmed_balance = 3 [json_name = "unconfirmed_balance"]; } message ChannelBalanceRequest { @@ -994,6 +1114,7 @@ message LightningNode { string pub_key = 2 [ json_name = "pub_key" ]; string alias = 3 [ json_name = "alias" ]; repeated NodeAddress addresses = 4 [ json_name = "addresses" ]; + string color = 5 [ json_name = "color" ]; } message NodeAddress { @@ -1179,6 +1300,9 @@ message Invoice { /// Fallback on-chain address. string fallback_addr = 12 [json_name = "fallback_addr"]; + + /// Delta to use for the time-lock of the CLTV extended to the final hop. + uint64 cltv_expiry = 13 [json_name = "cltv_expiry"]; } message AddInvoiceResponse { bytes r_hash = 1 [json_name = "r_hash"]; @@ -1263,6 +1387,7 @@ message PayReq { string description = 6 [json_name = "description"]; string description_hash = 7 [json_name = "description_hash"]; string fallback_addr = 8 [json_name = "fallback_addr"]; + int64 cltv_expiry = 9 [json_name = "cltv_expiry"]; } message FeeReportRequest {} diff --git a/package.json b/package.json index 2d99cddc..2fa7554e 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,9 @@ "bitcoinjs-lib": "^3.2.0", "bitcore-lib": "^0.14.0", "copy-to-clipboard": "^3.0.8", + "d3-force": "^1.1.0", + "d3-selection": "^1.2.0", + "d3-zoom": "^1.7.1", "devtron": "^1.4.0", "electron-debug": "^1.2.0", "font-awesome": "^4.7.0", diff --git a/yarn.lock b/yarn.lock index f27e13af..79fee4d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2528,6 +2528,77 @@ currently-unhandled@^0.4.1: dependencies: array-find-index "^1.0.1" +d3-collection@1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.4.tgz#342dfd12837c90974f33f1cc0a785aea570dcdc2" + +d3-color@1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.0.3.tgz#bc7643fca8e53a8347e2fbdaffa236796b58509b" + +d3-dispatch@1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.3.tgz#46e1491eaa9b58c358fce5be4e8bed626e7871f8" + +d3-drag@1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-1.2.1.tgz#df8dd4c502fb490fc7462046a8ad98a5c479282d" + dependencies: + d3-dispatch "1" + d3-selection "1" + +d3-ease@1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.3.tgz#68bfbc349338a380c44d8acc4fbc3304aa2d8c0e" + +d3-force@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-1.1.0.tgz#cebf3c694f1078fcc3d4daf8e567b2fbd70d4ea3" + dependencies: + d3-collection "1" + d3-dispatch "1" + d3-quadtree "1" + d3-timer "1" + +d3-interpolate@1: + version "1.1.6" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.1.6.tgz#2cf395ae2381804df08aa1bf766b7f97b5f68fb6" + dependencies: + d3-color "1" + +d3-quadtree@1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-1.0.3.tgz#ac7987e3e23fe805a990f28e1b50d38fcb822438" + +d3-selection@1, d3-selection@^1.1.0, d3-selection@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.2.0.tgz#1b8ec1c7cedadfb691f2ba20a4a3cfbeb71bbc88" + +d3-timer@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.7.tgz#df9650ca587f6c96607ff4e60cc38229e8dd8531" + +d3-transition@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.1.1.tgz#d8ef89c3b848735b060e54a39b32aaebaa421039" + dependencies: + d3-color "1" + d3-dispatch "1" + d3-ease "1" + d3-interpolate "1" + d3-selection "^1.1.0" + d3-timer "1" + +d3-zoom@^1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-1.7.1.tgz#02f43b3c3e2db54f364582d7e4a236ccc5506b63" + dependencies: + d3-dispatch "1" + d3-drag "1" + d3-interpolate "1" + d3-selection "1" + d3-transition "1" + d@1: version "1.0.0" resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f"